Files
dockhand/routes/environments/+page.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

593 lines
18 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import {
Globe,
Plus,
Trash2,
Pencil,
Check,
RefreshCw,
Wifi,
WifiOff,
Lock,
Unlock
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
interface Environment {
id: number;
name: string;
host?: string;
port?: number;
protocol?: string;
tlsCa?: string;
tlsCert?: string;
tlsKey?: string;
socketPath?: string;
connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
createdAt: string;
updatedAt: string;
}
interface TestResult {
success: boolean;
error?: string;
info?: {
serverVersion: string;
containers: number;
images: number;
name: string;
};
}
let environments = $state<Environment[]>([]);
let loading = $state(true);
let showAddModal = $state(false);
let showEditModal = $state(false);
let editingEnv = $state<Environment | null>(null);
let testResults = $state<{ [id: number]: TestResult | 'testing' }>({});
// Form state
let formName = $state('');
let formHost = $state('');
let formPort = $state(2375);
let formProtocol = $state('http');
let formTlsCa = $state('');
let formTlsCert = $state('');
let formTlsKey = $state('');
let formError = $state('');
let formSaving = $state(false);
let quickUrl = $state('');
async function fetchEnvironments() {
loading = true;
try {
const response = await fetch('/api/environments');
environments = await response.json();
} catch (error) {
console.error('Failed to fetch environments:', error);
} finally {
loading = false;
}
}
function resetForm() {
formName = '';
formHost = '';
formPort = 2375;
formProtocol = 'http';
formTlsCa = '';
formTlsCert = '';
formTlsKey = '';
formError = '';
formSaving = false;
quickUrl = '';
}
function parseDockerUrl(url: string) {
try {
const urlObj = new URL(url);
formProtocol = urlObj.protocol.replace(':', '');
formHost = urlObj.hostname;
formPort = urlObj.port ? parseInt(urlObj.port) : (formProtocol === 'https' ? 2376 : 2375);
} catch (e) {
// If not a valid URL, try to extract host:port
const match = url.match(/^(?:(\w+):\/\/)?([^:]+)(?::(\d+))?$/);
if (match) {
formProtocol = match[1] || 'http';
formHost = match[2];
formPort = match[3] ? parseInt(match[3]) : 2375;
}
}
}
function openAddModal() {
resetForm();
showAddModal = true;
}
function openEditModal(env: Environment) {
editingEnv = env;
formName = env.name;
formHost = env.host;
formPort = env.port;
formProtocol = env.protocol;
formTlsCa = env.tlsCa || '';
formTlsCert = env.tlsCert || '';
formTlsKey = env.tlsKey || '';
formError = '';
formSaving = false;
showEditModal = true;
}
async function createEnvironment() {
if (!formName.trim() || !formHost.trim()) {
formError = 'Name and host are required';
return;
}
formSaving = true;
formError = '';
try {
const response = await fetch('/api/environments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim(),
host: formHost.trim(),
port: formPort,
protocol: formProtocol,
tlsCa: formTlsCa || undefined,
tlsCert: formTlsCert || undefined,
tlsKey: formTlsKey || undefined
})
});
if (response.ok) {
showAddModal = false;
await fetchEnvironments();
} else {
const data = await response.json();
formError = data.error || 'Failed to create environment';
}
} catch (error) {
formError = 'Failed to create environment';
} finally {
formSaving = false;
}
}
async function updateEnvironment() {
if (!editingEnv || !formName.trim() || !formHost.trim()) {
formError = 'Name and host are required';
return;
}
formSaving = true;
formError = '';
try {
const response = await fetch(`/api/environments/${editingEnv.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim(),
host: formHost.trim(),
port: formPort,
protocol: formProtocol,
tlsCa: formTlsCa || undefined,
tlsCert: formTlsCert || undefined,
tlsKey: formTlsKey || undefined
})
});
if (response.ok) {
showEditModal = false;
editingEnv = null;
await fetchEnvironments();
} else {
const data = await response.json();
formError = data.error || 'Failed to update environment';
}
} catch (error) {
formError = 'Failed to update environment';
} finally {
formSaving = false;
}
}
async function deleteEnvironment(id: number) {
if (!confirm('Are you sure you want to delete this environment?')) return;
try {
const response = await fetch(`/api/environments/${id}`, {
method: 'DELETE'
});
if (response.ok) {
await fetchEnvironments();
} else {
const data = await response.json();
alert(data.error || 'Failed to delete environment');
}
} catch (error) {
alert('Failed to delete environment');
}
}
async function testConnection(id: number) {
testResults[id] = 'testing';
testResults = { ...testResults };
try {
const response = await fetch(`/api/environments/${id}/test`, {
method: 'POST'
});
const result = await response.json();
testResults[id] = result;
} catch (error) {
testResults[id] = { success: false, error: 'Connection failed' };
}
testResults = { ...testResults };
}
onMount(() => {
fetchEnvironments();
});
</script>
<div class="space-y-4">
<div class="flex flex-wrap justify-between items-center gap-3">
<PageHeader icon={Globe} title="Environments">
<Badge variant="secondary" class="text-xs">{environments.length} total</Badge>
</PageHeader>
<div class="flex gap-2">
<Button size="sm" onclick={openAddModal}>
<Plus class="w-4 h-4 mr-1" />
Add environment
</Button>
<Button size="sm" variant="outline" onclick={fetchEnvironments}>Refresh</Button>
</div>
</div>
{#if loading && environments.length === 0}
<p class="text-muted-foreground text-sm">Loading environments...</p>
{:else if environments.length === 0}
<p class="text-muted-foreground text-sm">No environments found</p>
{:else}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each environments as env (env.id)}
{@const testResult = testResults[env.id]}
<Card.Root>
<Card.Header class="pb-2">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<Globe class="w-5 h-5 text-muted-foreground" />
<Card.Title class="text-base">{env.name}</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-3">
<div class="text-sm text-muted-foreground">
{#if env.connectionType === 'socket' || !env.connectionType}
<span>{env.socketPath || '/var/run/docker.sock'}</span>
{:else if env.connectionType === 'hawser-edge'}
<span>Edge connection (outbound)</span>
{:else}
<span>{env.protocol || 'http'}://{env.host}:{env.port || 2375}</span>
{/if}
</div>
{#if testResult}
<div class="text-sm">
{#if testResult === 'testing'}
<div class="flex items-center gap-2 text-muted-foreground">
<RefreshCw class="w-4 h-4 animate-spin" />
<span>Testing connection...</span>
</div>
{:else if testResult.success}
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<Wifi class="w-4 h-4" />
<span>Connected</span>
</div>
{#if testResult.info}
<div class="mt-2 text-xs text-muted-foreground space-y-0.5">
<div>Host: {testResult.info.name}</div>
<div>Docker: {testResult.info.serverVersion}</div>
<div>Containers: {testResult.info.containers} | Images: {testResult.info.images}</div>
</div>
{/if}
{:else}
<div class="flex items-center gap-2 text-red-600 dark:text-red-400">
<WifiOff class="w-4 h-4" />
<span>Failed</span>
</div>
{#if testResult.error}
<div class="mt-1 text-xs text-muted-foreground">{testResult.error}</div>
{/if}
{/if}
</div>
{/if}
<div class="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onclick={() => testConnection(env.id)}
disabled={testResult === 'testing'}
>
{#if testResult === 'testing'}
<RefreshCw class="w-3 h-3 mr-1 animate-spin" />
{:else}
<Wifi class="w-3 h-3 mr-1" />
{/if}
Test
</Button>
<Button
variant="outline"
size="sm"
onclick={() => openEditModal(env)}
>
<Pencil class="w-3 h-3" />
</Button>
<Button
variant="outline"
size="sm"
onclick={() => deleteEnvironment(env.id)}
>
<Trash2 class="w-3 h-3 text-destructive" />
</Button>
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
</div>
<!-- Add Environment Modal -->
<Dialog.Root bind:open={showAddModal}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Add environment</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
{#if formError}
<div class="text-sm text-red-600 dark:text-red-400">{formError}</div>
{/if}
<!-- Quick URL parser -->
<div class="space-y-2 pb-3 border-b">
<Label for="quick-url">Quick URL (optional)</Label>
<div class="flex gap-2">
<Input
id="quick-url"
bind:value={quickUrl}
placeholder="http://192.168.1.4:2375 or https://docker.example.com:2376"
class="flex-1"
/>
<Button
variant="secondary"
size="sm"
onclick={() => parseDockerUrl(quickUrl)}
disabled={!quickUrl.trim()}
>
Parse
</Button>
</div>
<p class="text-xs text-muted-foreground">
Paste a full Docker URL to auto-fill the fields below
</p>
</div>
<div class="space-y-2">
<Label for="name">Name</Label>
<Input id="name" bind:value={formName} placeholder="Production server" />
</div>
<div class="space-y-2">
<Label for="host">Host</Label>
<Input id="host" bind:value={formHost} placeholder="192.168.1.100 or docker.example.com" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="port">Port</Label>
<Input id="port" type="number" bind:value={formPort} />
</div>
<div class="space-y-2">
<Label for="protocol">Protocol</Label>
<Select.Root type="single" bind:value={formProtocol}>
<Select.Trigger class="w-full h-9">
{#if formProtocol === 'https'}
<Lock class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Unlock class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
<span>{formProtocol === 'https' ? 'HTTPS (TLS)' : 'HTTP'}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="http" label="HTTP">
<Unlock class="w-4 h-4 mr-2 text-muted-foreground" />
HTTP
</Select.Item>
<Select.Item value="https" label="HTTPS (TLS)">
<Lock class="w-4 h-4 mr-2 text-muted-foreground" />
HTTPS (TLS)
</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
{#if formProtocol === 'https'}
<div class="space-y-4 pt-2 border-t">
<p class="text-xs text-muted-foreground">TLS certificates (optional)</p>
<div class="space-y-2">
<Label for="tls_ca">CA certificate</Label>
<textarea
id="tls_ca"
bind:value={formTlsCa}
placeholder="-----BEGIN CERTIFICATE-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
<div class="space-y-2">
<Label for="tls_cert">Client certificate</Label>
<textarea
id="tls_cert"
bind:value={formTlsCert}
placeholder="-----BEGIN CERTIFICATE-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
<div class="space-y-2">
<Label for="tls_key">Client key</Label>
<textarea
id="tls_key"
bind:value={formTlsKey}
placeholder="-----BEGIN RSA PRIVATE KEY-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showAddModal = false)}>Cancel</Button>
<Button onclick={createEnvironment} disabled={formSaving}>
{#if formSaving}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<Plus class="w-4 h-4 mr-1" />
{/if}
Add
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Environment Modal -->
<Dialog.Root bind:open={showEditModal}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Edit environment</Dialog.Title>
</Dialog.Header>
<div class="space-y-4">
{#if formError}
<div class="text-sm text-red-600 dark:text-red-400">{formError}</div>
{/if}
<!-- Quick URL parser -->
<div class="space-y-2 pb-3 border-b">
<Label for="edit-quick-url">Quick URL (optional)</Label>
<div class="flex gap-2">
<Input
id="edit-quick-url"
bind:value={quickUrl}
placeholder="http://192.168.1.4:2375 or https://docker.example.com:2376"
class="flex-1"
/>
<Button
variant="secondary"
size="sm"
onclick={() => parseDockerUrl(quickUrl)}
disabled={!quickUrl.trim()}
>
Parse
</Button>
</div>
<p class="text-xs text-muted-foreground">
Paste a full Docker URL to auto-fill the fields below
</p>
</div>
<div class="space-y-2">
<Label for="edit-name">Name</Label>
<Input id="edit-name" bind:value={formName} placeholder="Production server" />
</div>
<div class="space-y-2">
<Label for="edit-host">Host</Label>
<Input id="edit-host" bind:value={formHost} placeholder="192.168.1.100" />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-port">Port</Label>
<Input id="edit-port" type="number" bind:value={formPort} />
</div>
<div class="space-y-2">
<Label for="edit-protocol">Protocol</Label>
<Select.Root type="single" bind:value={formProtocol}>
<Select.Trigger class="w-full h-9">
{#if formProtocol === 'https'}
<Lock class="w-4 h-4 mr-2 text-muted-foreground" />
{:else}
<Unlock class="w-4 h-4 mr-2 text-muted-foreground" />
{/if}
<span>{formProtocol === 'https' ? 'HTTPS (TLS)' : 'HTTP'}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="http" label="HTTP">
<Unlock class="w-4 h-4 mr-2 text-muted-foreground" />
HTTP
</Select.Item>
<Select.Item value="https" label="HTTPS (TLS)">
<Lock class="w-4 h-4 mr-2 text-muted-foreground" />
HTTPS (TLS)
</Select.Item>
</Select.Content>
</Select.Root>
</div>
</div>
{#if formProtocol === 'https'}
<div class="space-y-4 pt-2 border-t">
<p class="text-xs text-muted-foreground">TLS certificates (optional)</p>
<div class="space-y-2">
<Label for="edit-tls_ca">CA certificate</Label>
<textarea
id="edit-tls_ca"
bind:value={formTlsCa}
placeholder="-----BEGIN CERTIFICATE-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
<div class="space-y-2">
<Label for="edit-tls_cert">Client certificate</Label>
<textarea
id="edit-tls_cert"
bind:value={formTlsCert}
placeholder="-----BEGIN CERTIFICATE-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
<div class="space-y-2">
<Label for="edit-tls_key">Client key</Label>
<textarea
id="edit-tls_key"
bind:value={formTlsKey}
placeholder="-----BEGIN RSA PRIVATE KEY-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => { showEditModal = false; editingEnv = null; }}>Cancel</Button>
<Button onclick={updateEnvironment} disabled={formSaving}>
{#if formSaving}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<Check class="w-4 h-4 mr-1" />
{/if}
Save
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>