mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-08 05:39:06 +00:00
Initial commit
This commit is contained in:
2530
routes/settings/environments/EnvironmentModal.svelte
Normal file
2530
routes/settings/environments/EnvironmentModal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
641
routes/settings/environments/EnvironmentsTab.svelte
Normal file
641
routes/settings/environments/EnvironmentsTab.svelte
Normal file
@@ -0,0 +1,641 @@
|
||||
<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}
|
||||
/>
|
||||
210
routes/settings/environments/EventTypesEditor.svelte
Normal file
210
routes/settings/environments/EventTypesEditor.svelte
Normal file
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import {
|
||||
Box,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Layers,
|
||||
Shield,
|
||||
HardDrive,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface EventType {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface EventGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: typeof Box;
|
||||
events: EventType[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selectedEventTypes: string[];
|
||||
onchange: (eventTypes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { selectedEventTypes, onchange, disabled = false }: Props = $props();
|
||||
|
||||
// Track collapsed state for groups
|
||||
let collapsedGroups = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleGroup(groupId: string) {
|
||||
if (collapsedGroups.has(groupId)) {
|
||||
collapsedGroups = new Set([...collapsedGroups].filter(id => id !== groupId));
|
||||
} else {
|
||||
collapsedGroups = new Set([...collapsedGroups, groupId]);
|
||||
}
|
||||
}
|
||||
|
||||
// Notification event types - grouped by category with icons
|
||||
const NOTIFICATION_EVENT_GROUPS: EventGroup[] = [
|
||||
{
|
||||
id: 'container',
|
||||
label: 'Container events',
|
||||
icon: Box,
|
||||
events: [
|
||||
{ id: 'container_started', label: 'Container started', description: 'When a container starts running' },
|
||||
{ id: 'container_stopped', label: 'Container stopped', description: 'When a container is stopped' },
|
||||
{ id: 'container_restarted', label: 'Container restarted', description: 'When a container restarts' },
|
||||
{ id: 'container_exited', label: 'Container exited', description: 'When a container exits unexpectedly' },
|
||||
{ id: 'container_unhealthy', label: 'Container unhealthy', description: 'When a container health check fails' },
|
||||
{ id: 'container_oom', label: 'Container OOM killed', description: 'When a container is killed due to out of memory' },
|
||||
{ id: 'container_updated', label: 'Container updated', description: 'When a container image is updated' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'auto_update',
|
||||
label: 'Auto-update events',
|
||||
icon: RefreshCw,
|
||||
events: [
|
||||
{ id: 'auto_update_success', label: 'Update succeeded', description: 'Container successfully updated to new image' },
|
||||
{ id: 'auto_update_failed', label: 'Update failed', description: 'Container auto-update failed' },
|
||||
{ id: 'auto_update_blocked', label: 'Update blocked', description: 'Update blocked due to vulnerability criteria' },
|
||||
{ id: 'updates_detected', label: 'Updates detected', description: 'Container image updates are available' },
|
||||
{ id: 'batch_update_success', label: 'Batch update completed', description: 'Scheduled container updates completed' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'git_stack',
|
||||
label: 'Git stack events',
|
||||
icon: GitBranch,
|
||||
events: [
|
||||
{ id: 'git_sync_success', label: 'Git sync succeeded', description: 'Git stack synced and deployed successfully' },
|
||||
{ id: 'git_sync_failed', label: 'Git sync failed', description: 'Git stack sync or deploy failed' },
|
||||
{ id: 'git_sync_skipped', label: 'Git sync skipped', description: 'Git stack sync skipped (no changes)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'stack',
|
||||
label: 'Stack events',
|
||||
icon: Layers,
|
||||
events: [
|
||||
{ id: 'stack_started', label: 'Stack started', description: 'When a compose stack starts' },
|
||||
{ id: 'stack_stopped', label: 'Stack stopped', description: 'When a compose stack stops' },
|
||||
{ id: 'stack_deployed', label: 'Stack deployed', description: 'Stack deployed (new or update)' },
|
||||
{ id: 'stack_deploy_failed', label: 'Stack deploy failed', description: 'Stack deployment failed' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'security',
|
||||
label: 'Security events',
|
||||
icon: Shield,
|
||||
events: [
|
||||
{ id: 'vulnerability_critical', label: 'Critical vulns found', description: 'Critical vulnerabilities found in image scan' },
|
||||
{ id: 'vulnerability_high', label: 'High vulns found', description: 'High severity vulnerabilities found' },
|
||||
{ id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'System events',
|
||||
icon: HardDrive,
|
||||
events: [
|
||||
{ id: 'image_pulled', label: 'Image pulled', description: 'When a new image is pulled' },
|
||||
{ id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable' },
|
||||
{ id: 'environment_online', label: 'Environment online', description: 'Environment came back online' },
|
||||
{ id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function toggleEvent(eventId: string) {
|
||||
if (disabled) return;
|
||||
|
||||
const newTypes = selectedEventTypes.includes(eventId)
|
||||
? selectedEventTypes.filter(t => t !== eventId)
|
||||
: [...selectedEventTypes, eventId];
|
||||
onchange(newTypes);
|
||||
}
|
||||
|
||||
function toggleGroupAll(group: EventGroup) {
|
||||
if (disabled) return;
|
||||
|
||||
const groupEventIds = group.events.map(e => e.id);
|
||||
const allSelected = groupEventIds.every(id => selectedEventTypes.includes(id));
|
||||
|
||||
let newTypes: string[];
|
||||
if (allSelected) {
|
||||
// Deselect all from this group
|
||||
newTypes = selectedEventTypes.filter(id => !groupEventIds.includes(id));
|
||||
} else {
|
||||
// Select all from this group
|
||||
const toAdd = groupEventIds.filter(id => !selectedEventTypes.includes(id));
|
||||
newTypes = [...selectedEventTypes, ...toAdd];
|
||||
}
|
||||
onchange(newTypes);
|
||||
}
|
||||
|
||||
function getGroupSelectedCount(group: EventGroup): number {
|
||||
return group.events.filter(e => selectedEventTypes.includes(e.id)).length;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2 max-h-[300px] overflow-y-auto pr-1">
|
||||
{#each NOTIFICATION_EVENT_GROUPS as group (group.id)}
|
||||
{@const isCollapsed = collapsedGroups.has(group.id)}
|
||||
{@const selectedCount = getGroupSelectedCount(group)}
|
||||
{@const allSelected = selectedCount === group.events.length}
|
||||
{@const someSelected = selectedCount > 0 && selectedCount < group.events.length}
|
||||
{@const GroupIcon = group.icon}
|
||||
|
||||
<div class="rounded-lg border bg-card">
|
||||
<!-- Group Header -->
|
||||
<div
|
||||
class="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors rounded-t-lg cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => toggleGroup(group.id)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleGroup(group.id); } }}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isCollapsed}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
<GroupIcon class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{group.label}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({selectedCount}/{group.events.length})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-0.5 rounded border transition-colors {allSelected ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/50 text-muted-foreground border-border hover:bg-muted'}"
|
||||
onclick={(e) => { e.stopPropagation(); toggleGroupAll(group); }}
|
||||
{disabled}
|
||||
>
|
||||
{allSelected ? 'All' : someSelected ? 'Some' : 'None'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Group Events -->
|
||||
{#if !isCollapsed}
|
||||
<div class="ml-4 mb-2 border-l-2 border-muted bg-muted/20 rounded-bl">
|
||||
{#each group.events as event (event.id)}
|
||||
{@const isSelected = selectedEventTypes.includes(event.id)}
|
||||
<div class="flex items-center justify-between pl-3 pr-1 py-1.5 hover:bg-muted/40 transition-colors border-b border-border/30 last:border-b-0">
|
||||
<div class="flex-1 min-w-0 pr-2">
|
||||
<div class="text-xs font-medium">{event.label}</div>
|
||||
<div class="text-2xs text-muted-foreground truncate">{event.description}</div>
|
||||
</div>
|
||||
<TogglePill
|
||||
checked={isSelected}
|
||||
onchange={() => toggleEvent(event.id)}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
Reference in New Issue
Block a user