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

642 lines
20 KiB
Svelte

<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
} from 'lucide-svelte';
import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import { getIconComponent } from '$lib/utils/icons';
import { getLabelColors } from '$lib/utils/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;
timezone?: string;
hawserVersion?: string;
}
interface TestResult {
success: boolean;
error?: string;
info?: {
serverVersion: string;
containers: number;
images: number;
name: string;
};
}
interface NotificationSetting {
id: number;
type: 'smtp' | 'apprise';
name: string;
enabled: boolean;
config: any;
event_types: string[];
created_at: string;
updated_at: 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);
let confirmDeleteEnvId = $state<number | null>(null);
// 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()
);
// === 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');
}
}
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;
// Process environments sequentially to avoid overwhelming the system
// This is especially important for Edge environments that have longer timeouts
for (const env of environments) {
// Mark this environment as testing
testingEnvs.add(env.id);
testingEnvs = new Set(testingEnvs);
try {
const response = await fetch(`/api/environments/${env.id}/test`, {
method: 'POST'
});
const result = await response.json();
testResults[env.id] = result;
} catch (error) {
testResults[env.id] = { success: false, error: 'Connection failed' };
}
testResults = { ...testResults };
// Mark this environment as done
testingEnvs.delete(env.id);
testingEnvs = new Set(testingEnvs);
}
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]}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Table.Row>
<!-- Name Column -->
<Table.Cell>
<div class="flex items-center gap-2">
<EnvIcon 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)}
<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.updateCheckEnabled && !hasScannerEnabled && !env.collectActivity && !env.collectMetrics}
<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}
<span class="text-sm text-muted-foreground">{testResult.info.serverVersion}</span>
{: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')}
<ConfirmPopover
open={confirmDeleteEnvId === env.id}
action="Delete"
itemType="environment"
itemName={env.name}
title="Remove"
position="left"
onConfirm={() => deleteEnvironment(env.id)}
onOpenChange={(open) => confirmDeleteEnvId = open ? env.id : null}
>
{#snippet children({ open })}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}"
title="Delete environment"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
{/snippet}
</ConfirmPopover>
{/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}
/>