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

1633 lines
54 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
Calendar,
RefreshCw,
CircleArrowUp,
CircleFadingArrowUp,
Play,
ChevronDown,
ChevronRight,
GitBranch,
Clock,
Check,
CheckCheck,
X,
AlertCircle,
Loader2,
Search,
Server,
Wrench,
Eye,
EyeOff,
Timer,
Webhook,
Hand,
Minus,
FileText,
Pause,
PlayCircle,
Trash2,
Bug,
ShieldX
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
import type { DataGridRowState } from '$lib/components/data-grid';
import { toast } from 'svelte-sonner';
import { formatDateTime, appSettings } from '$lib/stores/settings';
import { getIconComponent } from '$lib/utils/icons';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
import ExecutionLogViewer from '$lib/components/ExecutionLogViewer.svelte';
import { vulnerabilityCriteriaIcons, vulnerabilityCriteriaLabels } from '$lib/utils/update-steps';
import type { VulnerabilityCriteria } from '$lib/server/db';
import cronstrue from 'cronstrue';
// Scanner result per scanner
interface ScannerResult {
scanner: string;
critical: number;
high: number;
medium: number;
low: number;
}
// Blocked container info
interface BlockedContainer {
name: string;
reason: string;
scannerResults?: ScannerResult[];
}
// Container status in execution details
interface ContainerStatus {
name: string;
status: 'updated' | 'blocked' | 'failed' | 'checked';
blockReason?: string;
scannerResults?: ScannerResult[];
imageName?: string;
currentDigest?: string;
newDigest?: string;
}
// Scan result summary
interface ScanResultSummary {
critical: number;
high: number;
medium: number;
low: number;
}
// Full scan result structure
interface ScanResult {
summary: ScanResultSummary;
scannerResults?: ScannerResult[];
scanners?: string[];
scannedAt?: string;
}
// Details for env_update_check schedule type
interface EnvUpdateCheckDetails {
mode?: 'auto_update' | 'notify_only';
updatesFound?: number;
containersChecked?: number;
errors?: number;
autoUpdate?: boolean;
vulnerabilityCriteria?: VulnerabilityCriteria;
summary?: { checked: number; updated: number; blocked: number; failed: number };
containers?: ContainerStatus[];
updated?: number;
blocked?: number;
failed?: number;
blockedContainers?: BlockedContainer[];
}
// Details for container_update schedule type
interface ContainerUpdateDetails {
reason?: string;
newImage?: string;
oldImage?: string;
vulnerabilityCriteria?: VulnerabilityCriteria;
blockReason?: string;
scanResult?: ScanResult;
}
// Details for git_stack_sync schedule type
interface GitStackSyncDetails {
output?: string;
}
// Details for system_cleanup schedule type
interface SystemCleanupDetails {
retentionDays?: number;
deletedCount?: number;
}
// Union type for all possible details
type ScheduleExecutionDetails =
| EnvUpdateCheckDetails
| ContainerUpdateDetails
| GitStackSyncDetails
| SystemCleanupDetails
| null;
interface ScheduleExecution {
id: number;
scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check';
scheduleId: number;
environmentId: number | null;
entityName: string;
triggeredBy: 'cron' | 'webhook' | 'manual';
triggeredAt: string;
startedAt: string | null;
completedAt: string | null;
duration: number | null;
status: 'queued' | 'running' | 'success' | 'failed' | 'skipped';
errorMessage: string | null;
details: ScheduleExecutionDetails;
logs: string | null;
createdAt: string | null;
}
interface Schedule {
key: string; // Unique key: type-id
id: number;
type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check';
name: string;
entityName: string;
description?: string;
environmentId: number | null;
environmentName: string | null;
enabled: boolean;
scheduleType: string;
cronExpression: string | null;
nextRun: string | null;
lastExecution: ScheduleExecution | null;
recentExecutions: ScheduleExecution[];
isSystem: boolean;
// Container update specific fields
envHasScanning?: boolean;
vulnerabilityCriteria?: string | null;
// Env update check specific fields
autoUpdate?: boolean;
}
// State
let schedules = $state<Schedule[]>([]);
let environments = $state<{ id: number; name: string; icon: string }[]>([]);
let loading = $state(true);
let refreshing = $state(false);
let searchQuery = $state('');
let filterTypes = $state<string[]>([]);
let filterEnvironments = $state<string[]>([]);
let filterStatuses = $state<string[]>([]);
let expandedSchedules = $state<Set<string>>(new Set());
let hideSystemJobs = $state(false); // Show by default
// Infinite scroll state for expanded executions
let expandedExecutions = $state<Map<string, ScheduleExecution[]>>(new Map());
let loadingMoreExecutions = $state<Set<string>>(new Set());
let hasMoreExecutions = $state<Map<string, boolean>>(new Map());
const EXECUTIONS_BATCH_SIZE = 50;
// Execution detail dialog
let showExecutionDialog = $state(false);
let selectedExecution = $state<ScheduleExecution | null>(null);
let loadingExecutionDetail = $state(false);
let logDarkMode = $state(true);
function toggleLogTheme() {
logDarkMode = !logDarkMode;
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
}
// Delete confirmation - track which schedule is being deleted
let confirmDeleteId = $state<string | null>(null);
// SSE event source for real-time updates
let eventSource: EventSource | null = null;
// Track timeout IDs for cleanup
let pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
// Connection timeout for initial load
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Track if we've received the 'connected' event from the server
// This is sent immediately, so if we don't receive it, the connection truly failed
let receivedConnectedEvent = false;
// Filter schedules
const filteredSchedules = $derived.by(() => {
let filtered = schedules;
// Hide system jobs if toggle is on
if (hideSystemJobs) {
filtered = filtered.filter(s => !s.isSystem);
}
// Filter by types
if (filterTypes.length > 0) {
filtered = filtered.filter(s => filterTypes.includes(s.type));
}
// Filter by environments
if (filterEnvironments.length > 0) {
filtered = filtered.filter(s => s.environmentId !== null && filterEnvironments.includes(String(s.environmentId)));
}
// Filter by last execution status
if (filterStatuses.length > 0) {
filtered = filtered.filter(s => {
if (!s.lastExecution) return false;
return filterStatuses.includes(s.lastExecution.status);
});
}
// Filter by search
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(s =>
s.name.toLowerCase().includes(query) ||
s.entityName.toLowerCase().includes(query) ||
(s.environmentName?.toLowerCase().includes(query) ?? false)
);
}
return filtered;
});
// Count system jobs for badge
const systemJobCount = $derived.by(() => schedules.filter(s => s.isSystem).length);
// Check if any filters are active
const hasActiveFilters = $derived.by(() =>
searchQuery.length > 0 ||
filterTypes.length > 0 ||
filterEnvironments.length > 0 ||
filterStatuses.length > 0
);
// Clear all filters
function clearFilters() {
searchQuery = '';
filterTypes = [];
filterEnvironments = [];
filterStatuses = [];
}
// Get unique key for a schedule
function getScheduleKey(schedule: Schedule): string {
return schedule.type + '-' + schedule.id;
}
function connectToStream() {
if (eventSource) {
eventSource.close();
}
// Clear any existing connection timeout
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
// Reset connection state for new connection attempt
receivedConnectedEvent = false;
eventSource = new EventSource('/api/schedules/stream');
// Set a connection timeout - only show "no schedules" if we never received
// the 'connected' event (meaning the connection truly failed).
// If we received 'connected' but are waiting for data, keep showing the loader.
connectionTimeoutId = setTimeout(() => {
if (loading && !receivedConnectedEvent) {
// Connection truly failed - no 'connected' event received
console.warn('Schedule stream timeout - connection failed');
loading = false;
refreshing = false;
}
// If receivedConnectedEvent is true, keep loading - data is on the way
}, 5000);
// Handle connection confirmation (sent immediately by server)
eventSource.addEventListener('connected', () => {
receivedConnectedEvent = true;
// Clear connection timeout - we're connected, just waiting for data
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
});
eventSource.addEventListener('schedules', (event) => {
// Also clear connection timeout on first data event (fallback)
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
try {
const data = JSON.parse(event.data);
// Add unique key to each schedule
schedules = data.schedules.map((s: Omit<Schedule, 'key'>) => ({
...s,
key: `${s.type}-${s.id}`
}));
// Update expanded executions if any schedules are expanded
// This ensures the execution history table stays in sync
for (const schedule of data.schedules) {
const scheduleKey = schedule.type + '-' + schedule.id;
if (expandedSchedules.has(scheduleKey) && schedule.recentExecutions) {
// Check if we have new executions that aren't in the current list
const currentExecutions = expandedExecutions.get(scheduleKey) || [];
const newExecutions = schedule.recentExecutions;
// Check if the latest execution is different (new execution added)
if (newExecutions.length > 0) {
const latestNew = newExecutions[0];
const latestCurrent = currentExecutions[0];
if (!latestCurrent || latestNew.id !== latestCurrent.id ||
latestNew.status !== latestCurrent.status) {
// Merge new executions with existing ones
const existingIds = new Set(currentExecutions.map(e => e.id));
const toAdd = newExecutions.filter(e => !existingIds.has(e.id));
// Update existing executions and prepend new ones
const updated = currentExecutions.map(e => {
const newer = newExecutions.find(n => n.id === e.id);
return newer || e;
});
const merged = [...toAdd, ...updated];
const newExecutionsMap = new Map(expandedExecutions);
newExecutionsMap.set(scheduleKey, merged);
expandedExecutions = newExecutionsMap;
}
}
}
}
loading = false;
refreshing = false;
} catch (error) {
console.error('Failed to parse schedules data:', error);
}
});
eventSource.addEventListener('error', (event: Event) => {
// This handles two types of errors:
// 1. SSE connection errors (event.type === 'error' with no data)
// 2. Server-sent error events (event is MessageEvent with data)
// Clear connection timeout
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
// Check if this is a server-sent error event with data
const messageEvent = event as MessageEvent;
if (messageEvent.data) {
try {
const errorData = JSON.parse(messageEvent.data);
console.error('[Schedules] Server error:', errorData.error);
if (errorData.fatal) {
// Fatal error - server couldn't get initial data after retries
toast.error('Failed to load schedules: ' + errorData.error);
}
} catch {
// Not a JSON error event, treat as connection error
}
}
// Stop loading on error (shows empty state instead of spinner)
loading = false;
refreshing = false;
// Try to reconnect after a delay
// Reconnect even if schedules is empty - the server might recover
const timeoutId = setTimeout(() => {
if (eventSource?.readyState === EventSource.CLOSED) {
console.log('[Schedules] Attempting to reconnect SSE...');
connectToStream();
}
}, 5000);
pendingTimeouts.push(timeoutId);
});
}
// Fetch schedules from REST endpoint (for immediate updates without disrupting SSE)
async function refreshSchedulesFromRest() {
try {
const res = await fetch('/api/schedules');
if (res.ok) {
const data = await res.json();
// Add unique key to each schedule
schedules = data.schedules.map((s: Omit<Schedule, 'key'>) => ({
...s,
key: `${s.type}-${s.id}`
}));
loading = false;
refreshing = false;
}
} catch (error) {
console.error('Failed to refresh schedules from REST:', error);
}
}
async function loadSchedules() {
// Force a reconnect to get fresh data immediately
refreshing = true;
connectToStream();
}
async function loadEnvironments() {
try {
const res = await fetch('/api/environments');
if (res.ok) {
environments = await res.json();
}
} catch (error) {
console.error('Failed to load environments:', error);
}
}
async function loadSettings() {
try {
const res = await fetch('/api/schedules/settings');
if (res.ok) {
const data = await res.json();
hideSystemJobs = data.hideSystemJobs ?? false;
}
} catch (error) {
console.error('Failed to load settings:', error);
}
}
async function toggleHideSystemJobs() {
hideSystemJobs = !hideSystemJobs;
// Remove system_cleanup from filter if hiding system jobs
if (hideSystemJobs && filterTypes.includes('system_cleanup')) {
filterTypes = filterTypes.filter(t => t !== 'system_cleanup');
}
// Save preference in background
try {
await fetch('/api/schedules/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hideSystemJobs })
});
} catch (error) {
console.error('Failed to save hide system jobs preference:', error);
}
}
async function loadScheduleExecutions(schedule: Schedule, offset = 0) {
const scheduleKey = schedule.type + '-' + schedule.id;
// Mark as loading - create new Set to trigger reactivity
const loadingSet = new Set(loadingMoreExecutions);
loadingSet.add(scheduleKey);
loadingMoreExecutions = loadingSet;
try {
const res = await fetch(
`/api/schedules/executions?scheduleType=${schedule.type}&scheduleId=${schedule.id}&limit=${EXECUTIONS_BATCH_SIZE}&offset=${offset}`
);
if (!res.ok) throw new Error('Failed to load executions');
const data = await res.json();
const executions = data.executions || [];
const currentExecutions = expandedExecutions.get(scheduleKey) || [];
// Append new executions - create new Map to trigger reactivity
const newExecutionsMap = new Map(expandedExecutions);
newExecutionsMap.set(scheduleKey, [...currentExecutions, ...executions]);
expandedExecutions = newExecutionsMap;
// Check if there are more executions - create new Map to trigger reactivity
const newHasMoreMap = new Map(hasMoreExecutions);
newHasMoreMap.set(scheduleKey, executions.length === EXECUTIONS_BATCH_SIZE);
hasMoreExecutions = newHasMoreMap;
} catch (error: any) {
toast.error('Failed to load executions: ' + error.message);
} finally {
// Remove loading state - create new Set to trigger reactivity
const loadingSet = new Set(loadingMoreExecutions);
loadingSet.delete(scheduleKey);
loadingMoreExecutions = loadingSet;
}
}
function toggleScheduleExpansion(schedule: Schedule) {
const scheduleKey = schedule.type + '-' + schedule.id;
if (expandedSchedules.has(scheduleKey)) {
// Collapse - create new Set to trigger reactivity
const newSet = new Set(expandedSchedules);
newSet.delete(scheduleKey);
expandedSchedules = newSet;
} else {
// Expand - create new Set to trigger reactivity
const newSet = new Set(expandedSchedules);
newSet.add(scheduleKey);
expandedSchedules = newSet;
// Load first batch if not already loaded
if (!expandedExecutions.has(scheduleKey)) {
loadScheduleExecutions(schedule, 0);
}
}
}
// Handle DataGrid expand change
function handleExpandChange(key: unknown, expanded: boolean) {
const scheduleKey = key as string;
const schedule = filteredSchedules.find(s => getScheduleKey(s) === scheduleKey);
if (schedule) {
if (expanded && !expandedExecutions.has(scheduleKey)) {
loadScheduleExecutions(schedule, 0);
}
}
}
async function triggerSchedule(schedule: Schedule) {
try {
const res = await fetch(`/api/schedules/${schedule.type}/${schedule.id}/run`, {
method: 'POST'
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to trigger schedule');
}
toast.success(`Triggered: ${schedule.name}`);
// Refresh schedules from REST after a short delay to show running status
// This doesn't disrupt the SSE stream but ensures spinner appears quickly
const scheduleKey = schedule.type + '-' + schedule.id;
const timeoutId = setTimeout(async () => {
await refreshSchedulesFromRest();
if (expandedSchedules.has(scheduleKey)) {
// Fetch just the latest execution without clearing the list
try {
const res = await fetch(
`/api/schedules/executions?scheduleType=${schedule.type}&scheduleId=${schedule.id}&limit=1&offset=0`
);
if (res.ok) {
const data = await res.json();
const newExecution = data.executions?.[0];
if (newExecution) {
const currentExecutions = expandedExecutions.get(scheduleKey) || [];
// Check if this execution already exists (by ID)
const existsIndex = currentExecutions.findIndex(e => e.id === newExecution.id);
if (existsIndex >= 0) {
// Update existing execution in place
currentExecutions[existsIndex] = newExecution;
} else {
// Prepend new execution to the list
currentExecutions.unshift(newExecution);
}
// Update map with new array reference
const newExecutionsMap = new Map(expandedExecutions);
newExecutionsMap.set(scheduleKey, [...currentExecutions]);
expandedExecutions = newExecutionsMap;
}
}
} catch (error) {
console.error('Failed to refresh execution:', error);
}
}
}, 1000);
pendingTimeouts.push(timeoutId);
} catch (error: any) {
toast.error(error.message);
}
}
async function toggleScheduleEnabled(schedule: Schedule) {
try {
// Use different endpoint for system schedules
const endpoint = schedule.isSystem
? `/api/schedules/system/${schedule.id}/toggle`
: `/api/schedules/${schedule.type}/${schedule.id}/toggle`;
const res = await fetch(endpoint, {
method: 'POST'
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to toggle schedule');
}
toast.success(`Schedule ${schedule.enabled ? 'paused' : 'resumed'}`);
loadSchedules();
} catch (error: any) {
toast.error(error.message);
}
}
async function deleteSchedule(scheduleType: string, scheduleId: number, entityName: string) {
try {
const res = await fetch(`/api/schedules/${scheduleType}/${scheduleId}`, {
method: 'DELETE'
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to delete schedule');
}
toast.success(`Schedule removed: ${entityName}`);
confirmDeleteId = null;
loadSchedules();
} catch (error: any) {
toast.error(error.message);
}
}
async function loadExecutionDetail(executionId: number) {
loadingExecutionDetail = true;
try {
const res = await fetch(`/api/schedules/executions/${executionId}`);
if (!res.ok) throw new Error('Failed to load execution');
selectedExecution = await res.json();
showExecutionDialog = true;
} catch (error: any) {
toast.error('Failed to load execution: ' + error.message);
} finally {
loadingExecutionDetail = false;
}
}
async function deleteExecution(schedule: Schedule, executionId: number) {
try {
const res = await fetch(`/api/schedules/executions/${executionId}`, {
method: 'DELETE'
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to delete execution');
}
toast.success('Execution deleted');
// Remove from the expanded executions list
const scheduleKey = schedule.type + '-' + schedule.id;
const currentExecutions = expandedExecutions.get(scheduleKey) || [];
const filtered = currentExecutions.filter(e => e.id !== executionId);
const newExecutionsMap = new Map(expandedExecutions);
newExecutionsMap.set(scheduleKey, filtered);
expandedExecutions = newExecutionsMap;
// Refresh schedules to update the last execution badge
loadSchedules();
} catch (error: any) {
toast.error(error.message);
}
}
async function deleteAllExecutions(schedule: Schedule) {
try {
const scheduleKey = schedule.type + '-' + schedule.id;
const executions = expandedExecutions.get(scheduleKey) || [];
if (executions.length === 0) {
toast.error('No executions to delete');
return;
}
// Delete all executions
const deletePromises = executions.map(exec =>
fetch(`/api/schedules/executions/${exec.id}`, { method: 'DELETE' })
);
await Promise.all(deletePromises);
toast.success(`Deleted ${executions.length} execution(s)`);
// Clear from the expanded executions list
const newExecutionsMap = new Map(expandedExecutions);
newExecutionsMap.delete(scheduleKey);
expandedExecutions = newExecutionsMap;
// Collapse the row since there are no more executions
const newExpandedSet = new Set(expandedSchedules);
newExpandedSet.delete(scheduleKey);
expandedSchedules = newExpandedSet;
// Refresh schedules to update the last execution badge
loadSchedules();
} catch (error: any) {
toast.error('Failed to delete executions: ' + error.message);
}
}
function formatTimestamp(iso: string | null): string {
if (!iso) return '-';
return formatDateTime(iso, true);
}
function formatDuration(ms: number | null): string {
if (ms === null) return '-';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
function formatNextRun(iso: string | null): string {
if (!iso) return '-';
const date = new Date(iso);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Overdue';
if (diff < 60000) return 'Less than 1 min';
if (diff < 3600000) return `In ${Math.floor(diff / 60000)} min`;
if (diff < 86400000) return `In ${Math.floor(diff / 3600000)} hours`;
return formatTimestamp(iso);
}
function getStatusBadge(status: string) {
switch (status) {
case 'success':
return { variant: 'default' as const, class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', icon: Check };
case 'failed':
return { variant: 'default' as const, class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', icon: X };
case 'running':
return { variant: 'default' as const, class: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400', icon: Loader2 };
case 'skipped':
return { variant: 'default' as const, class: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', icon: CheckCheck };
case 'queued':
return { variant: 'default' as const, class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', icon: Clock };
default:
return { variant: 'default' as const, class: 'bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400', icon: AlertCircle };
}
}
// Get effective status for env_update_check with blocked containers
function getEnvUpdateStatus(exec: ScheduleExecution): { status: string; label: string; icon: any; class: string } | null {
if (exec.scheduleType !== 'env_update_check' || !exec.details?.autoUpdate) return null;
const blocked = exec.details.blocked ?? 0;
const updated = exec.details.updated ?? 0;
const failed = exec.details.failed ?? 0;
if (blocked > 0 && updated > 0) {
// Some updated, some blocked
return {
status: 'partial',
label: 'Partially blocked',
icon: Bug,
class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
};
} else if (blocked > 0 && updated === 0 && failed === 0) {
// All blocked, none updated
return {
status: 'blocked',
label: 'Blocked',
icon: Bug,
class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
};
}
return null;
}
function getTriggerBadge(trigger: string) {
switch (trigger) {
case 'cron':
return {
icon: Timer,
label: 'Scheduled',
class: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
};
case 'webhook':
return {
icon: Webhook,
label: 'Webhook',
class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
};
case 'manual':
return {
icon: Hand,
label: 'Manual',
class: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400'
};
default:
return {
icon: AlertCircle,
label: trigger,
class: 'bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400'
};
}
}
// Handle tab visibility changes (e.g., user switches back from another tab)
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// Tab became visible - reconnect SSE if it's closed
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
connectToStream();
}
// Also refresh data immediately
refreshSchedulesFromRest();
}
}
onMount(async () => {
// Load settings and environments in parallel
loadSettings();
loadEnvironments();
// Listen for tab visibility changes to reconnect when user returns
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('resume', handleVisibilityChange);
// Load initial data from REST immediately for fast display
// This ensures we have data even if SSE connection is slow/fails
await refreshSchedulesFromRest();
// Then connect to SSE for live updates
connectToStream();
// Load log theme preference
const savedLogTheme = localStorage.getItem('logTheme');
if (savedLogTheme !== null) {
logDarkMode = savedLogTheme === 'dark';
}
});
onDestroy(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Clear connection timeout
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
// Clear any pending timeouts
pendingTimeouts.forEach(id => clearTimeout(id));
pendingTimeouts = [];
});
</script>
<svelte:head>
<title>Schedules - Dockhand</title>
</svelte:head>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header with filters -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<PageHeader icon={Timer} title="Schedules" count={filteredSchedules.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Search schedules..."
class="pl-9 w-48 h-8 text-sm"
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
/>
</div>
<!-- Type filter (multiselect) -->
<Select.Root type="multiple" bind:value={filterTypes}>
<Select.Trigger size="sm" class="w-40 text-sm">
<span class="truncate">
{#if filterTypes.length === 0}
All types
{:else if filterTypes.length === 1}
{#if filterTypes[0] === 'container_update'}
Container updates
{:else if filterTypes[0] === 'git_stack_sync'}
Git stack syncs
{:else if filterTypes[0] === 'env_update_check'}
Env update checks
{:else}
System jobs
{/if}
{:else}
{filterTypes.length} types
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#if filterTypes.length > 0}
<button
type="button"
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
onclick={() => filterTypes = []}
>
Clear
</button>
{/if}
<Select.Item value="container_update">
<CircleArrowUp class="w-4 h-4 mr-2 inline text-green-500 drop-shadow-[0_0_3px_rgba(34,197,94,0.4)]" />
Container updates
</Select.Item>
<Select.Item value="git_stack_sync">
<GitBranch class="w-4 h-4 mr-2 inline text-purple-500 drop-shadow-[0_0_3px_rgba(168,85,247,0.4)]" />
Git stack syncs
</Select.Item>
<Select.Item value="env_update_check">
<CircleFadingArrowUp class="w-4 h-4 mr-2 inline text-green-500/50 drop-shadow-[0_0_3px_rgba(34,197,94,0.3)]" />
Env update checks
</Select.Item>
{#if !hideSystemJobs}
<Select.Item value="system_cleanup">
<Wrench class="w-4 h-4 mr-2 inline text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
System jobs
</Select.Item>
{/if}
</Select.Content>
</Select.Root>
<!-- Environment filter (multiselect) -->
<Select.Root type="multiple" bind:value={filterEnvironments}>
<Select.Trigger size="sm" class="w-40 text-sm">
<Server class="w-3.5 h-3.5 mr-2 shrink-0" />
<span class="truncate">
{#if filterEnvironments.length === 0}
All envs
{:else if filterEnvironments.length === 1}
{environments.find(e => String(e.id) === filterEnvironments[0])?.name || 'Environment'}
{:else}
{filterEnvironments.length} envs
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#if filterEnvironments.length > 0}
<button
type="button"
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
onclick={() => filterEnvironments = []}
>
Clear
</button>
{/if}
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon)}
<Select.Item value={String(env.id)}>
<EnvIcon class="w-4 h-4 mr-2 inline" />
{env.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Status filter (multiselect) -->
<Select.Root type="multiple" bind:value={filterStatuses}>
<Select.Trigger size="sm" class="w-36 text-sm">
<span class="truncate">
{#if filterStatuses.length === 0}
All statuses
{:else if filterStatuses.length === 1}
{#if filterStatuses[0] === 'success'}
Success
{:else if filterStatuses[0] === 'failed'}
Failed
{:else if filterStatuses[0] === 'skipped'}
Up-to-date
{:else if filterStatuses[0] === 'running'}
Running
{:else}
{filterStatuses[0]}
{/if}
{:else}
{filterStatuses.length} statuses
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#if filterStatuses.length > 0}
<button
type="button"
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
onclick={() => filterStatuses = []}
>
Clear
</button>
{/if}
<Select.Item value="success">
<Check class="w-4 h-4 mr-2 inline text-green-500" />
Success
</Select.Item>
<Select.Item value="failed">
<X class="w-4 h-4 mr-2 inline text-red-500" />
Failed
</Select.Item>
<Select.Item value="skipped">
<CheckCheck class="w-4 h-4 mr-2 inline text-green-500" />
Up-to-date
</Select.Item>
<Select.Item value="running">
<Loader2 class="w-4 h-4 mr-2 inline text-sky-500 animate-spin" />
Running
</Select.Item>
</Select.Content>
</Select.Root>
<!-- Toggle system jobs visibility -->
{#if systemJobCount > 0}
<Button
variant={hideSystemJobs ? 'outline' : 'secondary'}
size="sm"
class="h-8"
onclick={toggleHideSystemJobs}
>
{#if hideSystemJobs}
<Eye class="w-3.5 h-3.5 mr-1" />
Show system ({systemJobCount})
{:else}
<EyeOff class="w-3.5 h-3.5 mr-1" />
Hide system
{/if}
</Button>
{/if}
<!-- Clear filters -->
<Button
variant="outline"
size="sm"
class="h-8 px-2"
onclick={clearFilters}
disabled={!hasActiveFilters}
title="Clear all filters"
>
<X class="w-3.5 h-3.5" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
onclick={() => { refreshing = true; loadSchedules(); }}
disabled={refreshing}
>
<RefreshCw class="w-3.5 h-3.5 {refreshing ? 'animate-spin' : ''}" />
</Button>
</div>
</div>
<!-- DataGrid -->
<DataGrid
data={filteredSchedules}
keyField="key"
gridId="schedules"
loading={loading}
bind:expandedKeys={expandedSchedules}
onExpandChange={handleExpandChange}
onRowClick={(schedule, e) => {
if (schedule.lastExecution !== null) {
toggleScheduleExpansion(schedule);
}
}}
class="border-none"
wrapperClass="border rounded-lg"
>
{#snippet cell(column, schedule, rowState)}
{#if column.id === 'expand'}
{#if schedule.lastExecution !== null}
<button
type="button"
class="p-0.5 hover:bg-muted rounded transition-colors"
onclick={(e) => { e.stopPropagation(); toggleScheduleExpansion(schedule); }}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4" />
{:else}
<ChevronRight class="w-4 h-4" />
{/if}
</button>
{/if}
{:else if column.id === 'schedule'}
<div class="flex flex-wrap items-center gap-2">
{#if schedule.type === 'container_update'}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green shrink-0" />
{:else if schedule.type === 'git_stack_sync'}
<GitBranch class="w-4 h-4 text-emerald-500 shrink-0" />
{:else if schedule.type === 'env_update_check'}
{#if schedule.autoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green shrink-0" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green shrink-0" />
{/if}
{:else}
<Wrench class="w-4 h-4 text-amber-500 shrink-0" />
{/if}
<div class="min-w-0">
<div class="font-medium flex items-center gap-2 truncate">
<span class="truncate">{schedule.name}</span>
{#if schedule.isSystem}
<Badge variant="outline" class="text-xs shrink-0">System</Badge>
{/if}
</div>
<div class="text-xs text-muted-foreground flex items-center gap-1 truncate">
{#if schedule.type === 'container_update'}
{#if schedule.envHasScanning}
{@const criteria = (schedule.vulnerabilityCriteria || 'never') as VulnerabilityCriteria}
{@const icon = vulnerabilityCriteriaIcons[criteria]}
{@const IconComponent = icon.component}
<span class="cursor-default shrink-0" title={icon.title}>
<IconComponent class={icon.class} />
</span>
Check, scan & auto-update
{:else}
Check & auto-update
{/if}
{:else if schedule.type === 'git_stack_sync'}
Git sync
{:else if schedule.type === 'env_update_check'}
{#if schedule.autoUpdate && schedule.envHasScanning && schedule.vulnerabilityCriteria}
{@const criteria = schedule.vulnerabilityCriteria as VulnerabilityCriteria}
{@const icon = vulnerabilityCriteriaIcons[criteria]}
{@const IconComponent = icon.component}
<span class="cursor-default shrink-0" title={icon.title}>
<IconComponent class={icon.class} />
</span>
{/if}
<span class="truncate">{schedule.description || 'Env update check'}</span>
{:else}
<span class="truncate">{schedule.description || 'System job'}</span>
{/if}
</div>
</div>
</div>
{:else if column.id === 'environment'}
{#if schedule.environmentName}
<div class="flex items-center gap-1 text-xs">
<Server class="w-3 h-3 shrink-0" />
<span class="truncate">{schedule.environmentName}</span>
</div>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'cron'}
<div class="flex items-center gap-1">
<Clock class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="text-xs truncate">
{#if schedule.cronExpression}
{(() => {
try {
const is12Hour = $appSettings.timeFormat === '12h';
return cronstrue.toString(schedule.cronExpression, {
use24HourTimeFormat: !is12Hour,
throwExceptionOnParseError: true,
locale: 'en'
});
} catch {
return schedule.cronExpression;
}
})()}
{:else}
{schedule.scheduleType}
{/if}
</span>
</div>
{:else if column.id === 'lastRun'}
{#if schedule.lastExecution}
<div class="text-xs">{formatTimestamp(schedule.lastExecution.triggeredAt)}</div>
{#if schedule.lastExecution.duration}
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<Timer class="w-3 h-3" />
{formatDuration(schedule.lastExecution.duration)}
</div>
{/if}
{:else}
<span class="text-muted-foreground text-xs">Never</span>
{/if}
{:else if column.id === 'nextRun'}
<span class="text-xs">{formatNextRun(schedule.nextRun)}</span>
{:else if column.id === 'status'}
{#if schedule.lastExecution}
{@const badge = getStatusBadge(schedule.lastExecution.status)}
{@const envUpdateStatus = getEnvUpdateStatus(schedule.lastExecution)}
{@const isBlockedByVuln = schedule.lastExecution.details?.reason === 'vulnerabilities_found'}
<Tooltip.Root>
<Tooltip.Trigger>
{#if envUpdateStatus}
{@const EnvUpdateIcon = envUpdateStatus.icon}
<Badge variant="default" class={envUpdateStatus.class}>
<EnvUpdateIcon class="w-3.5 h-3.5" />
</Badge>
{:else if isBlockedByVuln}
<Badge variant="default" class="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<Bug class="w-3.5 h-3.5" />
</Badge>
{:else}
{@const BadgeIcon = badge.icon}
<Badge variant={badge.variant} class={badge.class}>
<BadgeIcon class="w-3.5 h-3.5 {schedule.lastExecution.status === 'running' ? 'animate-spin' : ''}" />
</Badge>
{/if}
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p class="whitespace-nowrap">
{#if envUpdateStatus}
{envUpdateStatus.label}
{:else if isBlockedByVuln}
Update blocked due to vulnerabilities
{:else if schedule.lastExecution.status === 'skipped'}
Up-to-date
{:else}
<span class="capitalize">{schedule.lastExecution.status}</span>
{/if}
</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="default" class="bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400">
<Minus class="w-3.5 h-3.5" />
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="whitespace-nowrap">No runs</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{:else if column.id === 'actions'}
<div class="flex items-center gap-1">
{#if schedule.lastExecution}
<button
type="button"
onclick={(e) => { e.stopPropagation(); loadExecutionDetail(schedule.lastExecution!.id); }}
title="View last execution logs"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
{/if}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleScheduleEnabled(schedule); }}
title={schedule.enabled ? 'Pause schedule' : 'Resume schedule'}
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
{#if schedule.enabled}
<Pause class="w-3 h-3 text-muted-foreground hover:text-amber-500" />
{:else}
<PlayCircle class="w-3 h-3 text-muted-foreground hover:text-green-500" />
{/if}
</button>
<button
type="button"
onclick={(e) => { e.stopPropagation(); triggerSchedule(schedule); }}
title="Run now"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
</button>
{#if !schedule.isSystem}
{@const scheduleKey = getScheduleKey(schedule)}
<ConfirmPopover
open={confirmDeleteId === scheduleKey}
action="Remove"
itemType="schedule"
itemName={schedule.entityName}
title="Remove schedule"
onConfirm={() => deleteSchedule(schedule.type, schedule.id, schedule.entityName)}
onOpenChange={(open) => confirmDeleteId = open ? scheduleKey : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-red-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
{/snippet}
{#snippet expandedRow(schedule, rowState)}
{@const scheduleKey = getScheduleKey(schedule)}
{@const executions = expandedExecutions.get(scheduleKey) || []}
{@const isLoading = loadingMoreExecutions.has(scheduleKey)}
{@const canLoadMore = hasMoreExecutions.get(scheduleKey) ?? false}
<div class="p-4 pl-12 shadow-inner bg-muted isolate sticky left-0 max-w-[calc(100vw-18rem)]">
<div class="flex items-center justify-between mb-2">
<h4 class="text-xs font-medium">Execution history</h4>
{#if executions.length > 0}
<button
type="button"
onclick={() => deleteAllExecutions(schedule)}
title="Remove all executions"
class="text-xs text-muted-foreground hover:text-red-500 transition-colors flex items-center gap-1"
>
<Trash2 class="w-3 h-3" />
Remove all
</button>
{/if}
</div>
{#if executions.length > 0}
<div class="max-h-96 overflow-auto">
<table class="w-full table-fixed">
<thead class="sticky top-0 bg-muted z-20">
<tr class="text-xs text-muted-foreground">
<th class="text-left px-2 py-1 w-36">Triggered</th>
<th class="text-center px-2 py-1 w-20">Trigger</th>
<th class="text-left px-2 py-1 w-20">Duration</th>
<th class="text-center px-2 py-1 w-14">Status</th>
<th class="text-left px-2 py-1">Error</th>
<th class="text-left px-2 py-1 w-14"></th>
</tr>
</thead>
<tbody>
{#each executions as exec}
{@const badge = getStatusBadge(exec.status)}
{@const trigger = getTriggerBadge(exec.triggeredBy)}
<tr class="border-t border-muted hover:bg-muted/50">
<td class="px-2 py-1 text-xs">{formatTimestamp(exec.triggeredAt)}</td>
<td class="px-2 py-1 text-center">
<Tooltip.Root>
<Tooltip.Trigger>
{@const TriggerIcon = trigger.icon}
<Badge variant="default" class={trigger.class}>
<TriggerIcon class="w-3.5 h-3.5" />
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{trigger.label}</p>
</Tooltip.Content>
</Tooltip.Root>
</td>
<td class="px-2 py-1 text-xs"><div class="flex items-center gap-1"><Timer class="w-3 h-3 text-muted-foreground" />{formatDuration(exec.duration)}</div></td>
<td class="px-2 py-1 text-center">
<Tooltip.Root>
<Tooltip.Trigger>
{#if exec.details?.reason === 'vulnerabilities_found'}
<Badge variant="default" class="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<Bug class="w-3.5 h-3.5" />
</Badge>
{:else}
{@const ExecBadgeIcon = badge.icon}
<Badge variant={badge.variant} class={badge.class}>
<ExecBadgeIcon class="w-3.5 h-3.5 {exec.status === 'running' ? 'animate-spin' : ''}" />
</Badge>
{/if}
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p class="whitespace-nowrap">{exec.details?.reason === 'vulnerabilities_found' ? 'Update blocked due to vulnerabilities' : (exec.status === 'skipped' ? 'Up-to-date' : exec.status)}</p>
</Tooltip.Content>
</Tooltip.Root>
</td>
<td class="px-2 py-1 text-xs text-destructive">
{exec.errorMessage || ''}
</td>
<td class="px-2 py-1">
<div class="flex items-center gap-1">
<button
type="button"
onclick={() => loadExecutionDetail(exec.id)}
title="View logs"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
</button>
<button
type="button"
onclick={() => deleteExecution(schedule, exec.id)}
title="Delete execution"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Trash2 class="w-3 h-3 text-muted-foreground hover:text-red-500" />
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{#if canLoadMore}
<div class="flex justify-center py-4">
<Button
variant="outline"
size="sm"
disabled={isLoading}
onclick={() => loadScheduleExecutions(schedule, executions.length)}
>
{#if isLoading}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Loading...
{:else}
Load more
{/if}
</Button>
</div>
{/if}
</div>
{:else if isLoading}
<div class="flex justify-center py-8">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
{:else}
<p class="text-xs text-muted-foreground py-4">No executions found</p>
{/if}
</div>
{/snippet}
{#snippet emptyState()}
<div class="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<Calendar class="w-12 h-12" />
<p>No schedules found</p>
<p class="text-xs">Enable auto-update on containers or auto-sync on git stacks to see them here</p>
</div>
{/snippet}
</DataGrid>
</div>
<!-- Execution Detail Dialog -->
<Dialog.Root bind:open={showExecutionDialog}>
<Dialog.Content class="max-w-5xl h-[85vh] overflow-hidden flex flex-col">
<Dialog.Header class="flex flex-row items-center justify-between gap-4">
<Dialog.Title class="flex items-center gap-2">
{#if selectedExecution?.scheduleType === 'container_update'}
<CircleArrowUp class="w-5 h-5 text-green-500 glow-green" />
{:else if selectedExecution?.scheduleType === 'git_stack_sync'}
<GitBranch class="w-5 h-5 text-emerald-500" />
{:else if selectedExecution?.scheduleType === 'env_update_check'}
{#if selectedExecution?.details?.autoUpdate}
<CircleArrowUp class="w-5 h-5 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-5 h-5 text-green-500 glow-green" />
{/if}
{:else}
<Wrench class="w-5 h-5 text-amber-500 drop-shadow-[0_0_3px_rgba(245,158,11,0.4)]" />
{/if}
Execution details
{#if selectedExecution}
<span class="text-muted-foreground font-normal">
({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else}System job{/if})
</span>
{/if}
</Dialog.Title>
{#if selectedExecution}
<span class="text-xs text-muted-foreground shrink-0 pr-6 whitespace-nowrap inline-flex items-center gap-1">
{formatTimestamp(selectedExecution.triggeredAt)} · <Timer class="w-3 h-3 -mt-px" /> {formatDuration(selectedExecution.duration)}
</span>
{/if}
</Dialog.Header>
{#if loadingExecutionDetail}
<div class="flex items-center justify-center py-8">
<Loader2 class="w-8 h-8 animate-spin" />
</div>
{:else if selectedExecution}
<div class="flex-1 flex flex-col min-h-0 space-y-4 overflow-hidden">
<!-- Compact summary panel for env_update_check with autoUpdate -->
{#if selectedExecution.scheduleType === 'env_update_check' && selectedExecution.details?.autoUpdate}
<div class="shrink-0"><UpdateSummaryStats
checked={selectedExecution.details.containersChecked ?? 0}
updated={selectedExecution.details.updated ?? 0}
blocked={selectedExecution.details.blocked ?? 0}
failed={selectedExecution.details.failed ?? 0}
/></div>
{/if}
<!-- Blocked containers list (scrollable) -->
{#if selectedExecution.details?.blockedContainers?.length > 0}
<div class="shrink-0">
<div class="text-xs text-muted-foreground mb-1.5">Blocked containers</div>
<div class="bg-amber-500/5 border border-amber-500/20 rounded-lg max-h-48 overflow-auto">
<div class="divide-y divide-amber-500/10">
{#each selectedExecution.details.blockedContainers as bc}
<div class="flex items-center justify-between gap-3 p-2.5 text-xs">
<div class="flex items-center gap-2 min-w-0">
<ShieldX class="w-3.5 h-3.5 text-amber-500 shrink-0" />
<span class="font-medium truncate">{bc.name}</span>
<span class="text-muted-foreground shrink-0">- {bc.reason}</span>
</div>
{#if bc.scannerResults}
<ScannerSeverityPills results={bc.scannerResults} />
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Execution info -->
<div class="flex flex-wrap items-center gap-4 text-xs shrink-0">
<div class="flex flex-wrap items-center gap-2">
<span class="text-muted-foreground">Status</span>
{#if selectedExecution.status}
{@const badge = getStatusBadge(selectedExecution.status)}
{@const envUpdateStatus = getEnvUpdateStatus(selectedExecution)}
{@const isBlockedByVuln = selectedExecution.details?.reason === 'vulnerabilities_found'}
{#if envUpdateStatus}
{@const StatusIcon = envUpdateStatus.icon}
<Badge variant="default" class={envUpdateStatus.class}>
<StatusIcon class="w-3 h-3 mr-1" />
<span>{envUpdateStatus.label}</span>
</Badge>
{:else if isBlockedByVuln}
<Badge variant="default" class="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<Bug class="w-3 h-3 mr-1" />
<span>Blocked</span>
</Badge>
{:else}
{@const SelBadgeIcon = badge.icon}
<Badge variant={badge.variant} class={badge.class}>
<SelBadgeIcon class="w-3 h-3 mr-1" />
<span class="capitalize">{selectedExecution.status === 'skipped' ? 'Up-to-date' : selectedExecution.status}</span>
</Badge>
{/if}
{/if}
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-muted-foreground">Trigger</span>
{#if selectedExecution.triggeredBy}
{@const trigger = getTriggerBadge(selectedExecution.triggeredBy)}
{@const SelTriggerIcon = trigger.icon}
<Badge variant="default" class={trigger.class}>
<SelTriggerIcon class="w-3.5 h-3.5 mr-1" />
{trigger.label}
</Badge>
{/if}
</div>
{#if selectedExecution.details?.vulnerabilityCriteria}
<div class="flex flex-wrap items-center gap-2">
<span class="text-muted-foreground">Update block criteria</span>
<VulnerabilityCriteriaBadge criteria={selectedExecution.details.vulnerabilityCriteria} showLabel />
</div>
{/if}
</div>
<!-- Block reason if update was blocked due to vulnerabilities -->
{#if selectedExecution.details?.reason === 'vulnerabilities_found'}
<div class="shrink-0">
<div class="text-xs text-muted-foreground mb-1">Block reason</div>
<div class="bg-amber-500/10 border border-amber-500/30 rounded p-3 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-2">
<Bug class="w-4 h-4 shrink-0" />
<span>{selectedExecution.details.blockReason || 'Update blocked due to vulnerabilities'}</span>
</div>
</div>
{/if}
<!-- Scan results if available -->
{#if selectedExecution.details?.scanResult?.summary}
{@const summary = selectedExecution.details.scanResult.summary}
{@const scannerResults = selectedExecution.details.scanResult.scannerResults}
<div class="shrink-0">
<div class="text-xs text-muted-foreground mb-1">Vulnerability scan results</div>
<div class="border border-muted-foreground/20 rounded p-3">
<div class="mb-2">
<ScannerSeverityPills results={scannerResults ?? []} />
</div>
<div class="text-xs text-muted-foreground">
Scanned with {selectedExecution.details.scanResult.scanners?.join(', ') || 'scanner'}
{#if selectedExecution.details.scanResult.scannedAt}
at {formatDateTime(selectedExecution.details.scanResult.scannedAt)}
{/if}
</div>
</div>
</div>
{/if}
<!-- Error message -->
{#if selectedExecution.errorMessage}
<div class="shrink-0">
<div class="text-xs text-muted-foreground mb-1">Error</div>
<div class="bg-destructive/10 border border-destructive/20 rounded p-3 text-xs text-destructive">
{selectedExecution.errorMessage}
</div>
</div>
{/if}
<!-- Logs - fills remaining space -->
<div class="flex-1 flex flex-col min-h-0">
<ExecutionLogViewer
logs={selectedExecution.logs}
darkMode={logDarkMode}
onToggleTheme={toggleLogTheme}
/>
</div>
</div>
{/if}
<Dialog.Footer class="flex justify-end border-t pt-4">
<Button onclick={() => showExecutionDialog = false}>OK</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>