Files
dockhand-upstream/src/routes/settings/environments/EnvironmentsTab.svelte
T
2026-06-17 08:21:38 +02:00

761 lines
25 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import {
Plus,
Trash2,
Pencil,
RefreshCw,
Wifi,
WifiOff,
Check,
XCircle,
ShieldCheck,
Activity,
Cpu,
Icon,
Route,
UndoDot,
Unplug,
CircleArrowUp,
CircleFadingArrowUp,
Clock,
AlertTriangle
} from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { getLabelColors } from '$lib/utils/label-colors';
import { labelColorOverrides } from '$lib/stores/label-colors';
import EnvironmentModal from './EnvironmentModal.svelte';
import { environments as environmentsStore } from '$lib/stores/environment';
import { dashboardData } from '$lib/stores/dashboard';
interface Props {
editEnvId?: string | null;
newEnv?: boolean;
}
let { editEnvId = null, newEnv = false }: Props = $props();
// Environment types
interface Environment {
id: number;
name: string;
host?: string;
port?: number;
protocol?: string;
tlsCa?: string;
tlsCert?: string;
tlsKey?: string;
icon?: string;
socketPath?: string;
collectActivity: boolean;
collectMetrics: boolean;
highlightChanges: boolean;
connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
labels?: string[];
createdAt: string;
updatedAt: string;
updateCheckEnabled?: boolean;
updateCheckAutoUpdate?: boolean;
imagePruneEnabled?: boolean;
timezone?: string;
hawserVersion?: string;
}
interface TestResult {
success: boolean;
error?: string;
info?: {
serverVersion: string;
containers: number;
images: number;
name: string;
engine?: 'docker' | 'podman';
};
}
interface NotificationSetting {
id: number;
type: 'smtp' | 'apprise';
name: string;
enabled: boolean;
config: any;
eventTypes: string[];
createdAt: string;
updatedAt: string;
}
// Environment state
let environments = $state<Environment[]>([]);
let envLoading = $state(true);
let showEnvModal = $state(false);
let editingEnv = $state<Environment | null>(null);
let testResults = $state<{ [id: number]: TestResult }>({});
let testingEnvs = $state<Set<number>>(new Set());
let pruneStatus = $state<{ [id: number]: 'pruning' | 'success' | 'error' | null }>({});
let confirmPruneEnvId = $state<number | null>(null);
// Delete confirmation dialog state — shows the on-disk paths that will be
// wiped + the stacks-tracked counts so the user knows what they're losing.
let showDeleteConfirm = $state(false);
let deleteEnvTarget = $state<Environment | null>(null);
let deleteStackCount = $state(0);
let deleteGitStackCount = $state(0);
let deleteCountsUnknown = $state(false);
// Track which environments have scanner enabled (for shield indicator)
let envScannerStatus = $state<{ [id: number]: boolean }>({});
// Notification channels for modal
let notifications = $state<NotificationSetting[]>([]);
// Extract all unique labels from all environments for suggestions
const allLabels = $derived(
[...new Set(environments.flatMap(env => env.labels || []))].sort()
);
// Load custom label colors
$effect(() => { labelColorOverrides.load(); });
// === Environment Functions ===
async function fetchEnvironments() {
envLoading = true;
try {
const response = await fetch('/api/environments');
environments = await response.json();
// Fetch scanner status for all environments in background
fetchAllEnvScannerStatus();
} catch (error) {
console.error('Failed to fetch environments:', error);
} finally {
envLoading = false;
}
}
async function fetchNotifications() {
try {
const response = await fetch('/api/notifications');
notifications = await response.json();
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
}
async function openAddEnvModal() {
editingEnv = null;
await fetchNotifications(); // Refresh notifications when opening modal
showEnvModal = true;
}
async function openEditEnvModal(env: Environment) {
editingEnv = env;
await fetchNotifications(); // Refresh notifications when opening modal
showEnvModal = true;
}
function closeModal() {
showEnvModal = false;
editingEnv = null;
}
async function handleSaved() {
await fetchEnvironments();
// Refresh the global environments store so dropdown updates
environmentsStore.refresh();
// Invalidate dashboard cache so it refreshes on next visit
dashboardData.invalidate();
}
function handleScannerStatusChange(envId: number, enabled: boolean) {
envScannerStatus[envId] = enabled;
envScannerStatus = { ...envScannerStatus };
}
async function deleteEnvironment(id: number) {
const env = environments.find(e => e.id === id);
const name = env?.name || 'environment';
try {
const response = await fetch(`/api/environments/${id}`, {
method: 'DELETE'
});
if (response.ok) {
toast.success(`Deleted ${name}`);
await fetchEnvironments();
// Refresh the global environments store so dropdown updates
environmentsStore.refresh();
} else {
const data = await response.json();
toast.error(data.error || 'Failed to delete environment');
}
} catch (error) {
toast.error('Failed to delete environment');
}
}
// Two-step delete: fetch the stack counts first (fail-closed) and pop the
// dialog that lists the on-disk paths being wiped. Only on explicit
// confirmation do we actually call deleteEnvironment().
async function requestDeleteEnvironment(id: number) {
const env = environments.find(e => e.id === id);
if (!env) return;
const [stacksRes, gitRes] = await Promise.all([
fetch(`/api/stacks?env=${id}`).catch(() => null),
fetch(`/api/git/stacks?env=${id}`).catch(() => null)
]);
let unknown = false;
let stacks: unknown[] = [];
let gitStacks: unknown[] = [];
if (stacksRes?.ok) {
try { stacks = await stacksRes.json(); } catch { unknown = true; }
} else {
unknown = true;
}
if (gitRes?.ok) {
try { gitStacks = await gitRes.json(); } catch { unknown = true; }
} else {
unknown = true;
}
deleteStackCount = Array.isArray(stacks) ? stacks.length : 0;
deleteGitStackCount = Array.isArray(gitStacks) ? gitStacks.length : 0;
deleteCountsUnknown = unknown;
deleteEnvTarget = env;
showDeleteConfirm = true;
}
async function confirmAndDelete() {
const target = deleteEnvTarget;
showDeleteConfirm = false;
deleteEnvTarget = null;
if (target) {
await deleteEnvironment(target.id);
}
}
async function testConnection(id: number) {
testingEnvs.add(id);
testingEnvs = new Set(testingEnvs);
try {
const response = await fetch(`/api/environments/${id}/test`, {
method: 'POST'
});
const result = await response.json();
testResults[id] = result;
testResults = { ...testResults };
} catch (error) {
testResults[id] = { success: false, error: 'Connection failed' };
testResults = { ...testResults };
}
testingEnvs.delete(id);
testingEnvs = new Set(testingEnvs);
}
let testingAll = $state(false);
async function testAllConnections() {
if (testingAll || environments.length === 0) return;
testingAll = true;
// Show all spinners immediately, then test all envs in parallel.
// Sequential testing was wrong for edge envs: 30s timeout × N envs = N×30s total wait.
// Parallel: all timeouts run concurrently, total wait is max(individual timeouts) = 30s.
environments.forEach(env => testingEnvs.add(env.id));
testingEnvs = new Set(testingEnvs);
await Promise.all(
environments.map(async (env) => {
try {
const response = await fetch(`/api/environments/${env.id}/test`, { method: 'POST' });
testResults[env.id] = await response.json();
} catch {
testResults[env.id] = { success: false, error: 'Connection failed' };
} finally {
testingEnvs.delete(env.id);
testingEnvs = new Set(testingEnvs);
}
testResults = { ...testResults };
})
);
testingAll = false;
}
async function pruneSystem(id: number) {
pruneStatus[id] = 'pruning';
pruneStatus = { ...pruneStatus };
try {
const response = await fetch(`/api/prune/all?env=${id}`, {
method: 'POST'
});
if (response.ok) {
pruneStatus[id] = 'success';
// Re-test connection to update container/image counts
testConnection(id);
} else {
const errorData = await response.json().catch(() => ({}));
if (errorData.details?.includes('already running')) {
console.warn('Prune already in progress, please wait');
} else {
console.error('Prune failed:', response.status, errorData, errorData.details);
}
pruneStatus[id] = 'error';
}
} catch (error) {
console.error('Prune error:', error);
pruneStatus[id] = 'error';
}
pruneStatus = { ...pruneStatus };
confirmPruneEnvId = null;
// Clear status after 3 seconds
setTimeout(() => {
pruneStatus[id] = null;
pruneStatus = { ...pruneStatus };
}, 3000);
}
// Fetch scanner status for all environments (for shield indicators)
async function fetchAllEnvScannerStatus() {
for (const env of environments) {
try {
const response = await fetch(`/api/settings/scanner?settingsOnly=true&env=${env.id}`);
const data = await response.json();
envScannerStatus[env.id] = data.settings?.scanner !== 'none';
} catch (error) {
envScannerStatus[env.id] = false;
}
}
envScannerStatus = { ...envScannerStatus }; // trigger reactivity
}
// Track if we've already handled the editEnvId to avoid re-opening
let handledEditId = $state<string | null>(null);
let handledNewEnv = $state(false);
// Auto-open modal when editEnvId is provided and environments are loaded
$effect(() => {
if (editEnvId && environments.length > 0 && handledEditId !== editEnvId) {
const envId = parseInt(editEnvId, 10);
const env = environments.find(e => e.id === envId);
if (env) {
handledEditId = editEnvId;
openEditEnvModal(env);
// Clear the edit param from URL to avoid re-opening on navigation
goto('/settings?tab=environments', { replaceState: true, noScroll: true });
}
}
});
// Auto-open modal for new environment when newEnv is true
$effect(() => {
if (newEnv && !handledNewEnv) {
handledNewEnv = true;
openAddEnvModal();
// Clear the new param from URL to avoid re-opening on navigation
goto('/settings?tab=environments', { replaceState: true, noScroll: true });
}
});
// Initialize on mount
onMount(async () => {
await fetchEnvironments();
fetchNotifications();
// Auto-test all environments after loading
testAllConnections();
});
</script>
<!-- Environments Tab Content -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<Badge variant="secondary" class="text-xs">{environments.length} total</Badge>
</div>
<div class="flex gap-2">
{#if $canAccess('environments', 'create')}
<Button size="sm" onclick={openAddEnvModal}>
<Plus class="w-4 h-4 mr-1" />
Add environment
</Button>
{/if}
<Button
size="sm"
variant="outline"
class="min-w-[100px]"
onclick={testAllConnections}
disabled={testingAll || environments.length === 0}
>
{#if testingAll}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<Wifi class="w-4 h-4 mr-1" />
{/if}
<span class="w-14">Test all</span>
</Button>
<Button size="sm" variant="outline" onclick={fetchEnvironments}>Refresh</Button>
</div>
</div>
{#if envLoading && 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="border rounded-lg overflow-hidden">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[200px]">Name</Table.Head>
<Table.Head>Connection</Table.Head>
<Table.Head class="w-[120px]">Labels</Table.Head>
<Table.Head class="w-[140px]">Timezone</Table.Head>
<Table.Head class="w-[100px]">Features</Table.Head>
<Table.Head class="w-[120px]">Status</Table.Head>
<Table.Head class="w-[100px]">Docker</Table.Head>
<Table.Head class="w-[100px]">Hawser</Table.Head>
<Table.Head class="w-[180px] text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each environments as env (env.id)}
{@const testResult = testResults[env.id]}
{@const isTesting = testingEnvs.has(env.id)}
{@const hasScannerEnabled = envScannerStatus[env.id]}
<Table.Row>
<!-- Name Column -->
<Table.Cell>
<div class="flex items-center gap-2">
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-4 h-4 text-muted-foreground shrink-0" />
{#if env.connectionType === 'socket' || !env.connectionType}
<span title="Unix socket connection" class="shrink-0">
<Unplug class="w-3.5 h-3.5 text-cyan-500 glow-cyan" />
</span>
{:else if env.connectionType === 'direct'}
<span title="Direct Docker connection" class="shrink-0">
<Icon iconNode={whale} class="w-3.5 h-3.5 text-blue-500 glow-blue" />
</span>
{:else if env.connectionType === 'hawser-standard'}
<span title="Hawser agent (standard mode)" class="shrink-0">
<Route class="w-3.5 h-3.5 text-purple-500 glow-purple" />
</span>
{:else if env.connectionType === 'hawser-edge'}
<span title="Hawser agent (edge mode)" class="shrink-0">
<UndoDot class="w-3.5 h-3.5 text-green-500 glow-green" />
</span>
{/if}
<span class="font-medium truncate">{env.name}</span>
</div>
</Table.Cell>
<!-- Connection Column -->
<Table.Cell>
<span class="text-sm text-muted-foreground">
{#if env.connectionType === 'socket' || !env.connectionType}
{env.socketPath || '/var/run/docker.sock'}
{:else if env.connectionType === 'hawser-edge'}
Edge connection (outbound)
{:else}
{env.protocol || 'http'}://{env.host}:{env.port || 2375}
{/if}
</span>
</Table.Cell>
<!-- Labels Column -->
<Table.Cell>
{#if env.labels && env.labels.length > 0}
<div class="flex flex-wrap gap-1">
{#each env.labels as label}
{@const colors = getLabelColors(label, $labelColorOverrides)}
<span
class="px-1.5 py-0.5 text-2xs rounded font-medium"
style="background-color: {colors.bgColor}; color: {colors.color}"
>
{label}
</span>
{/each}
</div>
{:else}
<span class="text-muted-foreground text-xs">—</span>
{/if}
</Table.Cell>
<!-- Timezone Column -->
<Table.Cell>
{#if env.timezone}
<div class="flex items-center gap-1.5">
<Clock class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-sm text-muted-foreground">{env.timezone}</span>
</div>
{:else}
<span class="text-muted-foreground text-xs">—</span>
{/if}
</Table.Cell>
<!-- Features Column -->
<Table.Cell>
<div class="flex items-center gap-1.5">
{#if env.updateCheckEnabled}
<span title={env.updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if env.updateCheckAutoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green" />
{/if}
</span>
{/if}
{#if hasScannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
{#if env.collectActivity}
<span title="Activity collection enabled">
<Activity class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if env.collectMetrics}
<span title="Metrics collection enabled">
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if env.imagePruneEnabled}
<span title="Automatic image pruning enabled">
<Trash2 class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if !env.updateCheckEnabled && !hasScannerEnabled && !env.collectActivity && !env.collectMetrics && !env.imagePruneEnabled}
<span class="text-muted-foreground text-xs">—</span>
{/if}
</div>
</Table.Cell>
<!-- Status Column -->
<Table.Cell>
{#if testResult}
{#if testResult.success}
<div class="flex items-center gap-1.5 text-green-600 dark:text-green-400 text-sm">
{#if isTesting}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
{:else}
<Wifi class="w-3.5 h-3.5" />
{/if}
<span>Connected</span>
</div>
{:else}
<div class="flex items-center gap-1.5 text-red-600 dark:text-red-400 text-sm" title={testResult.error}>
{#if isTesting}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
{:else}
<WifiOff class="w-3.5 h-3.5" />
{/if}
<span>Failed</span>
</div>
{/if}
{:else if isTesting}
<div class="flex items-center gap-1.5 text-muted-foreground text-sm">
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
<span>Testing...</span>
</div>
{:else}
<span class="text-muted-foreground text-xs">Not tested</span>
{/if}
</Table.Cell>
<!-- Docker Version Column -->
<Table.Cell>
{#if testResult?.info?.serverVersion}
<div class="flex items-center gap-1.5">
<span class="text-sm text-muted-foreground">{testResult.info.serverVersion}</span>
{#if testResult.info.engine === 'podman'}
<Badge variant="secondary" class="text-[10px] px-1.5 py-0 leading-tight">Podman</Badge>
{/if}
</div>
{:else}
<span class="text-muted-foreground text-sm">—</span>
{/if}
</Table.Cell>
<!-- Hawser Version Column -->
<Table.Cell>
{#if testResult?.hawser?.hawserVersion}
<span class="text-sm text-muted-foreground">{testResult.hawser.hawserVersion}</span>
{:else if env.hawserVersion}
<span class="text-sm text-muted-foreground">{env.hawserVersion}</span>
{:else}
<span class="text-muted-foreground text-sm">—</span>
{/if}
</Table.Cell>
<!-- Actions Column -->
<Table.Cell class="text-right">
<div class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => testConnection(env.id)}
disabled={isTesting}
title="Test connection"
>
{#if isTesting}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
{:else}
<Wifi class="w-3.5 h-3.5" />
{/if}
</Button>
{#if $canAccess('environments', 'edit')}
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => openEditEnvModal(env)}
title="Edit environment"
>
<Pencil class="w-3.5 h-3.5" />
</Button>
{/if}
{#if $canAccess('containers', 'remove') && $canAccess('images', 'remove') && $canAccess('volumes', 'remove') && $canAccess('networks', 'remove')}
<ConfirmPopover
open={confirmPruneEnvId === env.id}
action="Prune"
itemType="system on "
itemName={env.name}
title="System prune"
position="left"
onConfirm={() => pruneSystem(env.id)}
onOpenChange={(open) => confirmPruneEnvId = open ? env.id : null}
>
{#snippet children({ open })}
<Button
variant="ghost"
size="sm"
class="h-7 px-2"
disabled={pruneStatus[env.id] === 'pruning'}
title="Prune system"
>
{#if pruneStatus[env.id] === 'pruning'}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
{:else if pruneStatus[env.id] === 'success'}
<Check class="w-3.5 h-3.5 text-green-600" />
{:else if pruneStatus[env.id] === 'error'}
<XCircle class="w-3.5 h-3.5 text-destructive" />
{:else}
<Icon iconNode={broom} class="w-3.5 h-3.5" />
{/if}
</Button>
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('environments', 'delete')}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-muted-foreground hover:text-destructive"
title="Delete environment"
onclick={() => requestDeleteEnvironment(env.id)}
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
{/if}
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</div>
<EnvironmentModal
bind:open={showEnvModal}
environment={editingEnv}
{notifications}
existingLabels={allLabels}
onClose={closeModal}
onSaved={handleSaved}
onScannerStatusChange={handleScannerStatusChange}
/>
<!-- Delete environment confirmation. Lists the on-disk paths that will be
wiped + the count of stacks tracked under this env. Fail-closed:
if the stack-count fetch fails, the dialog still shows but the body
warns that the count couldn't be enumerated. -->
<Dialog.Root bind:open={showDeleteConfirm}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<AlertTriangle class="w-5 h-5 text-destructive" />
Delete environment?
</Dialog.Title>
<Dialog.Description class="pt-2 space-y-3 text-sm">
{#if deleteEnvTarget}
<p>
The environment
<code class="text-xs bg-muted px-1 py-0.5 rounded">{deleteEnvTarget.name}</code>
and the following directories will be permanently removed from the Dockhand host:
</p>
<div class="space-y-1 text-xs font-mono bg-muted/40 rounded-md p-3 border overflow-x-auto">
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/stacks/{deleteEnvTarget.name}/</code>
</div>
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{deleteEnvTarget.name}/</code>
</div>
</div>
{#if deleteCountsUnknown}
<p>
Couldn't list the stacks on this environment — proceed only
if you're sure what's deployed here.
</p>
{:else if deleteStackCount === 0 && deleteGitStackCount === 0}
<p>No stacks are currently tracked on this environment.</p>
{:else}
<p>
{#if deleteStackCount > 0 && deleteGitStackCount > 0}
<strong>{deleteStackCount} stack{deleteStackCount === 1 ? '' : 's'}</strong>
and <strong>{deleteGitStackCount} git stack{deleteGitStackCount === 1 ? '' : 's'}</strong>
tracked here will be removed from Dockhand's database.
{:else if deleteStackCount > 0}
<strong>{deleteStackCount} stack{deleteStackCount === 1 ? '' : 's'}</strong>
tracked here will be removed from Dockhand's database.
{:else}
<strong>{deleteGitStackCount} git stack{deleteGitStackCount === 1 ? '' : 's'}</strong>
tracked here will be removed from Dockhand's database.
{/if}
</p>
{/if}
<p class="text-muted-foreground">
Running containers on the Docker/Hawser host are <strong>not</strong> stopped.
You can stop or remove them separately.
</p>
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end gap-2 mt-4">
<Button variant="outline" onclick={() => (showDeleteConfirm = false)}>
Cancel
</Button>
<Button variant="destructive" onclick={confirmAndDelete}>
<Trash2 class="w-4 h-4 mr-2" />
Delete environment
</Button>
</div>
</Dialog.Content>
</Dialog.Root>