Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

55
routes/+layout.server.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { hasAdminUser } from '$lib/server/db';
// Routes that don't require authentication
const PUBLIC_PATHS = ['/login'];
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const authEnabled = await isAuthEnabled();
// If auth is disabled, allow everything
if (!authEnabled) {
return {
authEnabled: false,
user: null
};
}
// Auth is enabled - validate session
const user = await validateSession(cookies);
// Check if this is a public path
const isPublicPath = PUBLIC_PATHS.some(path => url.pathname === path || url.pathname.startsWith(path + '/'));
// If not authenticated and not on a public path
if (!user && !isPublicPath) {
// Special case: allow access when no admin exists yet (initial setup)
const noAdminSetupMode = !(await hasAdminUser());
if (noAdminSetupMode) {
return {
authEnabled: true,
user: null,
setupMode: true
};
}
// Redirect to login
const redirectUrl = encodeURIComponent(url.pathname + url.search);
redirect(307, `/login?redirect=${redirectUrl}`);
}
return {
authEnabled: true,
user: user ? {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
avatar: user.avatar,
isAdmin: user.isAdmin,
provider: user.provider
} : null
};
};

175
routes/+layout.svelte Normal file
View File

@@ -0,0 +1,175 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { Toaster } from '$lib/components/ui/sonner';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import ThemeToggle from '$lib/components/theme-toggle.svelte';
import HostInfo from '$lib/components/host-info.svelte';
import MainContent from '$lib/components/main-content.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import WhatsNewModal from '$lib/components/WhatsNewModal.svelte';
import { SidebarProvider, SidebarTrigger } from '$lib/components/ui/sidebar';
import { startStatsCollection, stopStatsCollection } from '$lib/stores/stats';
import { connectSSE, disconnectSSE } from '$lib/stores/events';
import { currentEnvironment } from '$lib/stores/environment';
import { licenseStore, daysUntilExpiry } from '$lib/stores/license';
import { authStore } from '$lib/stores/auth';
import { themeStore, applyTheme } from '$lib/stores/theme';
import { gridPreferencesStore } from '$lib/stores/grid-preferences';
import { shouldShowWhatsNew } from '$lib/utils/version';
import { AlertTriangle, Search } from 'lucide-svelte';
let { children } = $props();
let envId = $state<number | null>(null);
let commandPaletteOpen = $state(false);
// What's New modal state
let showWhatsNewModal = $state(false);
let changelog = $state<Array<{ version: string; date: string; changes: Array<{ type: string; text: string }> }>>([]);
let lastSeenVersion = $state<string | null>(null);
// App version from git tag (injected at build time)
declare const __APP_VERSION__: string | null;
const currentVersion = __APP_VERSION__;
// Detect if Mac for keyboard hint
const isMac = typeof navigator !== 'undefined' && navigator.platform?.toUpperCase().indexOf('MAC') >= 0;
// Subscribe to environment changes using $effect
$effect(() => {
const env = $currentEnvironment;
envId = env?.id ?? null;
});
// Initialize theme after auth state is known
$effect(() => {
if (!$authStore.loading) {
// Use user-specific preferences if authenticated, otherwise global settings
const userId = $authStore.authEnabled && $authStore.user ? $authStore.user.id : undefined;
themeStore.init(userId);
}
});
onMount(() => {
// Apply theme from localStorage immediately (for flash-free loading)
applyTheme(themeStore.get());
// Initialize grid preferences
gridPreferencesStore.init();
// Start global stats collection for CPU/Memory graphs
startStatsCollection();
// Connect to SSE for real-time Docker events (global)
connectSSE(envId);
// Check enterprise license status
licenseStore.check();
// Check auth status
authStore.check();
// Check What's New popup
checkWhatsNew();
return () => {
stopStatsCollection();
disconnectSSE();
};
});
async function checkWhatsNew() {
if (browser && currentVersion && currentVersion !== 'unknown') {
lastSeenVersion = localStorage.getItem('dockhand-whats-new-version');
if (shouldShowWhatsNew(currentVersion, lastSeenVersion)) {
try {
const res = await fetch('/api/changelog');
if (res.ok) {
changelog = await res.json();
showWhatsNewModal = true;
}
} catch {
// Silently fail - don't show popup if changelog fetch fails
}
}
}
}
function dismissWhatsNew() {
showWhatsNewModal = false;
if (browser && currentVersion) {
localStorage.setItem('dockhand-whats-new-version', currentVersion);
}
}
</script>
<svelte:head>
<link rel="icon" href="/logo_light.webp" />
<title>Dockhand - Docker Management</title>
</svelte:head>
<SidebarProvider>
<AppSidebar />
<MainContent>
<header class="h-14 shrink-0 flex items-center justify-between gap-4 border-b bg-background px-4">
<div class="flex items-center gap-2 min-w-0">
<SidebarTrigger class="md:hidden shrink-0" />
<HostInfo />
</div>
<div class="flex items-center gap-3 shrink-0">
<button
type="button"
onclick={() => commandPaletteOpen = true}
class="flex items-center gap-2 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border rounded-md hover:bg-muted/50 transition-colors"
>
<Search class="w-3.5 h-3.5" />
<span class="hidden sm:inline">Search...</span>
<kbd class="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-2xs font-medium text-muted-foreground">
{#if isMac}
<span class="text-xs"></span>
{:else}
<span class="text-xs">Ctrl</span>
{/if}
K
</kbd>
</button>
{#if $licenseStore.isEnterprise && $daysUntilExpiry !== null && $daysUntilExpiry <= 30}
<a
href="/settings?tab=license"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors
{$daysUntilExpiry <= 7
? 'bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'}"
>
<AlertTriangle class="w-3.5 h-3.5" />
{#if $daysUntilExpiry <= 0}
License expired
{:else if $daysUntilExpiry === 1}
License expires tomorrow
{:else}
License expires in {$daysUntilExpiry} days
{/if}
</a>
{/if}
<ThemeToggle />
</div>
</header>
<div class="flex-1 min-h-0 h-[calc(100%-3.5rem)] overflow-auto py-2 px-3 flex flex-col">
{@render children?.()}
</div>
</MainContent>
</SidebarProvider>
<Toaster richColors position="bottom-right" />
<CommandPalette bind:open={commandPaletteOpen} />
{#if showWhatsNewModal && currentVersion}
<WhatsNewModal
bind:open={showWhatsNewModal}
version={currentVersion}
{lastSeenVersion}
{changelog}
onDismiss={dismissWhatsNew}
/>
{/if}

3
routes/+layout.ts Normal file
View File

@@ -0,0 +1,3 @@
// Disable SSR for the entire app - it's a Docker management dashboard
// that relies entirely on client-side data fetching from the Docker API
export const ssr = false;

1000
routes/+page.svelte Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,931 @@
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Button } from '$lib/components/ui/button';
import { DatePicker } from '$lib/components/ui/date-picker';
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import * as Dialog from '$lib/components/ui/dialog';
import {
RefreshCw,
Calendar,
Box,
X,
Eye,
Server,
Play,
Square,
RotateCcw,
Pause,
CirclePlay,
Trash2,
Plus,
Skull,
Zap,
AlertTriangle,
Pencil,
Activity,
Loader2,
FileX,
Heart,
Search
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
import { canAccess } from '$lib/stores/auth';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
import { formatDateTime } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { DataGrid } from '$lib/components/data-grid';
interface ContainerEvent {
id: number;
environmentId: number | null;
environmentName: string | null;
environmentIcon: string | null;
containerId: string | null;
containerName: string | null;
image: string | null;
action: string;
actorAttributes: Record<string, string> | null;
timestamp: string;
createdAt: string;
}
interface Environment {
id: number;
name: string;
icon: string;
labels?: string[];
}
// Constants
const FETCH_BATCH_SIZE = 100;
// State
let events = $state<ContainerEvent[]>([]);
let total = $state(0);
let loading = $state(false);
let loadingMore = $state(false);
let containers = $state<string[]>([]);
let environments = $state<Environment[]>([]);
let envId = $state<number | null>(null);
let hasMore = $state(true);
let initialized = $state(false);
let dataFetched = $state(false);
let showClearConfirm = $state(false);
let clearingActivity = $state(false);
// Visible range for virtual scroll
let visibleStart = $state(1);
let visibleEnd = $state(0);
// localStorage key for filters
const STORAGE_KEY = 'dockhand_activity_filters';
// Filters
let filterContainerName = $state('');
let filterActions = $state<string[]>([]);
let filterEnvironmentId = $state<number | null>(null);
let filterLabels = $state<string[]>([]);
let filterFromDate = $state('');
let filterToDate = $state('');
// Load filters from localStorage
function loadFiltersFromStorage() {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
filterContainerName = parsed.containerName || '';
filterActions = parsed.actions || [];
filterEnvironmentId = parsed.environmentId || null;
filterLabels = parsed.labels || [];
filterFromDate = parsed.fromDate || '';
filterToDate = parsed.toDate || '';
}
} catch (e) {
console.error('Failed to load activity filters from localStorage:', e);
}
}
// Save filters to localStorage
function saveFiltersToStorage() {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
containerName: filterContainerName,
actions: filterActions,
environmentId: filterEnvironmentId,
labels: filterLabels,
fromDate: filterFromDate,
toDate: filterToDate
}));
} catch (e) {
console.error('Failed to save activity filters to localStorage:', e);
}
}
// Detail dialog
let showDetailDialog = $state(false);
let selectedEvent = $state<ContainerEvent | null>(null);
// SSE connection
let sseConnected = $state(false);
let eventSource: EventSource | null = null;
const actionOptions = [
{ value: 'create', label: 'Create', icon: Plus, color: 'text-emerald-500' },
{ value: 'start', label: 'Start', icon: Play, color: 'text-emerald-500' },
{ value: 'stop', label: 'Stop', icon: Square, color: 'text-amber-500' },
{ value: 'die', label: 'Die', icon: Skull, color: 'text-red-500' },
{ value: 'kill', label: 'Kill', icon: Zap, color: 'text-red-500' },
{ value: 'restart', label: 'Restart', icon: RotateCcw, color: 'text-sky-500' },
{ value: 'pause', label: 'Pause', icon: Pause, color: 'text-amber-500' },
{ value: 'unpause', label: 'Unpause', icon: CirclePlay, color: 'text-emerald-500' },
{ value: 'destroy', label: 'Destroy', icon: Trash2, color: 'text-red-500' },
{ value: 'rename', label: 'Rename', icon: Pencil, color: 'text-muted-foreground' },
{ value: 'update', label: 'Update', icon: Pencil, color: 'text-sky-500' },
{ value: 'oom', label: 'Out of memory', icon: AlertTriangle, color: 'text-red-500' },
{ value: 'health_status', label: 'Health status', icon: Heart, color: 'text-amber-500' }
];
// Date filter preset
let selectedDatePreset = $state<string>('');
// Check if any filters are active
const hasActiveFilters = $derived(
filterContainerName || filterActions.length > 0 || filterEnvironmentId !== null || selectedDatePreset
);
const datePresets = [
{ value: 'today', label: 'Today' },
{ value: 'yesterday', label: 'Yesterday' },
{ value: 'last7days', label: 'Last 7 days' },
{ value: 'last30days', label: 'Last 30 days' },
{ value: 'thisMonth', label: 'This month' },
{ value: 'lastMonth', label: 'Last month' }
];
function formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function applyDatePreset(preset: string): { from: string; to: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
let from = '';
let to = '';
switch (preset) {
case 'today':
from = formatDateForInput(today);
to = formatDateForInput(today);
break;
case 'yesterday': {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
from = formatDateForInput(yesterday);
to = formatDateForInput(yesterday);
break;
}
case 'last7days': {
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 6);
from = formatDateForInput(weekAgo);
to = formatDateForInput(today);
break;
}
case 'last30days': {
const monthAgo = new Date(today);
monthAgo.setDate(monthAgo.getDate() - 29);
from = formatDateForInput(monthAgo);
to = formatDateForInput(today);
break;
}
case 'thisMonth': {
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
from = formatDateForInput(firstOfMonth);
to = formatDateForInput(today);
break;
}
case 'lastMonth': {
const firstOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const lastOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
from = formatDateForInput(firstOfLastMonth);
to = formatDateForInput(lastOfLastMonth);
break;
}
}
filterFromDate = from;
filterToDate = to;
return { from, to };
}
async function clearActivity() {
clearingActivity = true;
try {
const res = await fetch('/api/activity', {
method: 'DELETE'
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to clear activity');
}
toast.success('Activity log cleared');
// Reset and reload
events = [];
total = 0;
hasMore = true;
fetchEvents(false);
} catch (error: any) {
toast.error(error.message);
} finally {
clearingActivity = false;
showClearConfirm = false;
}
}
// Subscribe to environment
$effect(() => {
const env = $currentEnvironment;
envId = env?.id ?? null;
});
let fetchController: AbortController | null = null;
async function fetchEvents(append = false, silent = false) {
if (append && loadingMore) return;
if (!append && fetchController) {
fetchController.abort();
}
if (append) {
loadingMore = true;
} else if (!silent) {
loading = true;
hasMore = true;
}
fetchController = new AbortController();
try {
const params = new URLSearchParams();
if (filterContainerName) params.set('containerName', filterContainerName);
if (filterActions.length > 0) params.set('actions', filterActions.join(','));
if (filterEnvironmentId !== null) params.set('environmentId', String(filterEnvironmentId));
if (filterLabels.length > 0) params.set('labels', filterLabels.join(','));
if (filterFromDate) params.set('fromDate', filterFromDate);
if (filterToDate) params.set('toDate', filterToDate + 'T23:59:59');
params.set('limit', String(FETCH_BATCH_SIZE));
params.set('offset', String(append ? events.length : 0));
const response = await fetch(`/api/activity?${params.toString()}`, {
signal: fetchController.signal
});
if (!response.ok) {
throw new Error('Failed to fetch events');
}
const data = await response.json();
if (append) {
events = [...events, ...data.events];
} else {
events = data.events;
}
total = data.total;
hasMore = events.length < total;
dataFetched = true;
loading = false;
loadingMore = false;
fetchController = null;
} catch (error: any) {
if (error?.name === 'AbortError') {
return;
}
console.error('Failed to fetch events:', error);
if (!append && !silent) {
events = [];
total = 0;
}
loading = false;
loadingMore = false;
fetchController = null;
hasMore = false;
}
}
async function fetchContainers() {
try {
const params = new URLSearchParams();
if (filterEnvironmentId !== null) {
params.set('environmentId', String(filterEnvironmentId));
}
const response = await fetch(`/api/activity/containers?${params.toString()}`);
if (response.ok) {
containers = await response.json();
}
} catch (error) {
console.error('Failed to fetch containers:', error);
}
}
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (response.ok) {
environments = await response.json();
}
} catch (error) {
console.error('Failed to fetch environments:', error);
}
}
function clearFilters() {
filterContainerName = '';
filterActions = [];
filterEnvironmentId = null;
filterLabels = [];
filterFromDate = '';
filterToDate = '';
selectedDatePreset = '';
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
// Track if initial load is done
let initialLoadDone = $state(false);
// Auto-fetch when filters change
$effect(() => {
const _cn = filterContainerName;
const _a = filterActions;
const _fd = filterFromDate;
const _td = filterToDate;
const _ei = filterEnvironmentId;
const _l = filterLabels;
const isReady = untrack(() => initialLoadDone);
if (isReady) {
saveFiltersToStorage();
fetchEvents(false);
}
});
// Called by DataGrid when user scrolls near the bottom
function loadMoreEvents() {
if (hasMore && !loadingMore && !loading) {
fetchEvents(true);
}
}
// Called by DataGrid when visible range changes
function handleVisibleRangeChange(start: number, end: number, _total: number) {
visibleStart = start;
visibleEnd = end;
}
function showDetails(event: ContainerEvent) {
selectedEvent = event;
showDetailDialog = true;
}
function formatTimestamp(ts: string): string {
return formatDateTime(ts, true);
}
function getActionIcon(action: string) {
switch (action) {
case 'create': return Plus;
case 'start': return Play;
case 'stop': return Square;
case 'die': return Skull;
case 'kill': return Zap;
case 'restart': return RotateCcw;
case 'pause': return Pause;
case 'unpause': return CirclePlay;
case 'destroy': return Trash2;
case 'rename': return Pencil;
case 'update': return Pencil;
case 'oom': return AlertTriangle;
case 'health_status': return Heart;
default: return Activity;
}
}
function getActionColor(action: string): string {
switch (action) {
case 'create':
case 'start':
case 'unpause':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400';
case 'stop':
case 'die':
case 'kill':
case 'destroy':
case 'oom':
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400';
case 'restart':
case 'pause':
case 'update':
case 'rename':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'health_status':
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400';
default:
return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400';
}
}
// SSE connection
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let wasConnected = false;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 10;
const BASE_RECONNECT_DELAY = 3000;
function connectSSE() {
if (eventSource) {
eventSource.close();
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
eventSource = new EventSource('/api/activity/events');
eventSource.addEventListener('connected', () => {
const wasDisconnected = wasConnected && !sseConnected;
sseConnected = true;
wasConnected = true;
reconnectAttempts = 0; // Reset backoff on successful connection
// If we were previously connected and reconnected, fetch any missed events
if (wasDisconnected) {
fetchEvents(false, true); // silent fetch to catch up on missed events
}
});
eventSource.addEventListener('activity', (e) => {
try {
const newEvent = JSON.parse(e.data);
// Check if event matches current filters
if (filterContainerName && !newEvent.containerName?.includes(filterContainerName)) return;
if (filterActions.length > 0 && !filterActions.includes(newEvent.action)) return;
if (filterEnvironmentId !== null && newEvent.environmentId !== filterEnvironmentId) return;
// Check date filters
if (filterFromDate) {
const eventDate = new Date(newEvent.timestamp).toISOString().split('T')[0];
if (eventDate < filterFromDate) return;
}
if (filterToDate) {
const eventDate = new Date(newEvent.timestamp).toISOString().split('T')[0];
if (eventDate > filterToDate) return;
}
// Add to beginning of events (prepend new events)
if (!events.some(event => event.id === newEvent.id)) {
events = [newEvent, ...events];
total = total + 1;
// Add container to list if not already there
if (newEvent.containerName && !containers.includes(newEvent.containerName)) {
containers = [...containers, newEvent.containerName].sort();
}
}
} catch {
// Ignore parse errors
}
});
eventSource.addEventListener('heartbeat', () => {
sseConnected = true;
});
eventSource.onerror = () => {
sseConnected = false;
// Exponential backoff reconnection
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts), 60000);
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
if (eventSource?.readyState === EventSource.CLOSED) {
connectSSE();
}
}, delay);
}
};
}
function disconnectSSE() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
sseConnected = false;
}
}
// Handle tab visibility changes (e.g., user switches back from another tab)
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// Tab became visible - check and restore connection
// Clear any pending reconnection timer
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Reset reconnection counter for fresh attempts
reconnectAttempts = 0;
// Reconnect SSE if it's closed or in error state
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
connectSSE();
}
}
}
onMount(() => {
// Listen for tab visibility changes to reconnect when user returns
document.addEventListener('visibilitychange', handleVisibilityChange);
// Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed
document.addEventListener('resume', handleVisibilityChange);
loadFiltersFromStorage();
// Check for URL query param (env) - takes priority over localStorage
const urlEnvParam = $page.url.searchParams.get('env');
if (urlEnvParam) {
const envIdFromUrl = parseInt(urlEnvParam);
if (!isNaN(envIdFromUrl)) {
filterEnvironmentId = envIdFromUrl;
}
}
// Initialize in order - set initialized=true AFTER data is fetched
fetchEnvironments().then(() => {
// Validate filterEnvironmentId - reset if it doesn't exist
if (filterEnvironmentId !== null) {
const envExists = environments.some(e => e.id === filterEnvironmentId);
if (!envExists) {
filterEnvironmentId = null;
saveFiltersToStorage();
}
}
return fetchEvents();
}).then(() => {
initialized = true;
return fetchContainers();
}).then(() => {
connectSSE();
initialLoadDone = true;
});
return () => {
disconnectSSE();
};
});
onDestroy(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
disconnectSSE();
});
</script>
<svelte:head>
<title>Activity - Dockhand</title>
</svelte:head>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header with inline filters -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<PageHeader icon={Activity} title="Activity" count={visibleEnd > 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
<div class="flex flex-wrap items-center gap-2">
<!-- Container name search -->
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Container..."
bind:value={filterContainerName}
onkeydown={(e) => e.key === 'Escape' && (filterContainerName = '')}
class="pl-8 h-8 w-36 text-sm"
/>
</div>
<!-- Action filter -->
<MultiSelectFilter
bind:value={filterActions}
options={actionOptions}
placeholder="Action"
pluralLabel="actions"
width="w-36"
defaultIcon={Activity}
/>
<!-- Environment filter -->
{#if environments.length > 0}
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
<Select.Root
type="single"
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
>
<Select.Trigger size="sm" class="w-44 text-sm">
<SelectedEnvIcon class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
<span class="truncate">
{#if filterEnvironmentId === null}
Environment
{:else}
{selectedEnv?.name || 'Environment'}
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="">
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
All environments
</Select.Item>
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Select.Item value={String(env.id)}>
<EnvIcon class="w-4 h-4 mr-2 text-muted-foreground" />
{env.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Date range filter -->
<Select.Root
type="single"
value={selectedDatePreset}
onValueChange={(v) => {
selectedDatePreset = v || '';
if (v !== 'custom') {
applyDatePreset(v || '');
}
}}
>
<Select.Trigger size="sm" class="w-32 text-sm">
<Calendar class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
<span class="truncate">
{#if selectedDatePreset === 'custom'}
Custom
{:else if selectedDatePreset}
{datePresets.find(d => d.value === selectedDatePreset)?.label || 'All time'}
{:else}
All time
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="">All time</Select.Item>
{#each datePresets as preset}
<Select.Item value={preset.value}>{preset.label}</Select.Item>
{/each}
<Select.Item value="custom">Custom range...</Select.Item>
</Select.Content>
</Select.Root>
<!-- Custom date inputs -->
{#if selectedDatePreset === 'custom'}
<DatePicker bind:value={filterFromDate} placeholder="From" class="h-8 w-28" />
<DatePicker bind:value={filterToDate} placeholder="To" class="h-8 w-28" />
{/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" onclick={() => { hasMore = true; fetchEvents(false); }} disabled={loading}>
<RefreshCw class="w-3.5 h-3.5 {loading ? 'animate-spin' : ''}" />
</Button>
{#if $canAccess('activity', 'delete')}
<ConfirmPopover
bind:open={showClearConfirm}
action="Clear"
itemType="activity log"
title="Clear all"
onConfirm={clearActivity}
confirmText="Clear"
variant="destructive"
disabled={clearingActivity}
onOpenChange={(open) => showClearConfirm = open}
>
{#snippet children({ open })}
<Button variant="outline" size="sm" disabled={clearingActivity || total === 0}>
<Trash2 class="w-3.5 h-3.5" />
</Button>
{/snippet}
</ConfirmPopover>
{/if}
</div>
</div>
<!-- DataGrid with Virtual Scrolling -->
{#if $environmentsStore.length === 0}
<NoEnvironment />
{:else}
<DataGrid
data={events}
keyField="id"
gridId="activity"
virtualScroll
hasMore={hasMore}
onLoadMore={loadMoreEvents}
onVisibleRangeChange={handleVisibleRangeChange}
loading={loading || !initialized}
onRowClick={(event) => showDetails(event)}
class="border-none"
wrapperClass="border rounded-lg"
>
{#snippet cell(column, event, rowState)}
{#if column.id === 'timestamp'}
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(event.timestamp)}</span>
{:else if column.id === 'environment'}
{#if event.environmentName}
{@const EventEnvIcon = getIconComponent(event.environmentIcon || 'globe')}
<div class="flex items-center gap-1 text-xs">
<EventEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{event.environmentName}</span>
</div>
{:else}
<span class="text-muted-foreground text-xs">-</span>
{/if}
{:else if column.id === 'action'}
<div class="flex justify-center">
<Badge class="{getActionColor(event.action)} py-0.5 px-1" title={event.action.charAt(0).toUpperCase() + event.action.slice(1)}>
<svelte:component this={getActionIcon(event.action)} class="w-3 h-3" />
</Badge>
</div>
{:else if column.id === 'container'}
<div class="flex items-center gap-1 truncate text-xs">
<Box class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate" title={event.containerName || event.containerId || 'Unknown'}>
{event.containerName || (event.containerId ? event.containerId.slice(0, 12) : 'Unknown')}
</span>
</div>
{:else if column.id === 'image'}
<span class="text-xs text-muted-foreground truncate" title={event.image || '-'}>
{event.image || '-'}
</span>
{:else if column.id === 'exitCode'}
{#if event.actorAttributes?.exitCode !== undefined}
{@const exitCode = parseInt(event.actorAttributes.exitCode)}
<span class="font-mono text-xs text-center block {exitCode === 0 ? 'text-green-600' : 'text-red-500'}">
{exitCode}
</span>
{:else}
<span class="text-muted-foreground text-xs text-center block">-</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex items-center justify-end">
<Button variant="ghost" size="icon" class="h-6 w-6" onclick={(e) => { e.stopPropagation(); showDetails(event); }}>
<Eye class="w-3.5 h-3.5" />
</Button>
</div>
{/if}
{/snippet}
{#snippet emptyState()}
<div class="flex flex-col items-center justify-center py-16 text-muted-foreground">
<FileX class="w-10 h-10 mb-3 opacity-40" />
<p>No container events found</p>
<p class="text-xs mt-1">Events will appear here as containers start, stop, etc.</p>
</div>
{/snippet}
{#snippet loadingState()}
<div class="flex items-center justify-center py-16 text-muted-foreground">
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
Loading...
</div>
{/snippet}
</DataGrid>
<!-- Loading more indicator -->
{#if loadingMore}
<div class="flex items-center justify-center py-2 text-muted-foreground border-t">
<Loader2 class="w-4 h-4 animate-spin mr-2" />
Loading more...
</div>
{/if}
<!-- End of results -->
{#if !hasMore && events.length > 0}
<div class="text-center py-2 text-sm text-muted-foreground border-t">
End of results ({total.toLocaleString()} events)
</div>
{/if}
{/if}
</div>
<!-- Detail Dialog -->
<Dialog.Root bind:open={showDetailDialog}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Event details</Dialog.Title>
</Dialog.Header>
{#if selectedEvent}
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-muted-foreground">Timestamp</label>
<p class="font-mono text-sm">{formatTimestamp(selectedEvent.timestamp)}</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Action</label>
<p>
<Badge class="{getActionColor(selectedEvent.action)} gap-1">
<svelte:component this={getActionIcon(selectedEvent.action)} class="w-3 h-3" />
{selectedEvent.action}
</Badge>
</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Container name</label>
<p class="flex items-center gap-1">
<Box class="w-4 h-4 text-muted-foreground" />
{selectedEvent.containerName || '-'}
</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Container ID</label>
<p class="font-mono text-sm break-all">{selectedEvent.containerId}</p>
</div>
{#if selectedEvent.image}
<div class="col-span-2">
<label class="text-sm font-medium text-muted-foreground">Image</label>
<p class="font-mono text-sm break-all">{selectedEvent.image}</p>
</div>
{/if}
{#if selectedEvent.environmentName}
<div>
<label class="text-sm font-medium text-muted-foreground">Environment</label>
<p>{selectedEvent.environmentName}</p>
</div>
{/if}
</div>
{#if selectedEvent.actorAttributes && Object.keys(selectedEvent.actorAttributes).length > 0}
<div>
<label class="text-sm font-medium text-muted-foreground">Attributes</label>
<div class="mt-1 border rounded-md overflow-hidden max-h-[200px] overflow-y-auto">
<table class="w-full text-xs">
<tbody>
{#each Object.entries(selectedEvent.actorAttributes) as [key, value], i}
<tr class="{i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}">
<td class="px-3 py-1.5 font-mono font-medium text-muted-foreground whitespace-nowrap align-top w-1/3">{key}</td>
<td class="px-3 py-1.5 font-mono break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => showDetailDialog = false}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { Bell } from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
</script>
<div class="flex flex-col gap-6 p-6">
<PageHeader icon={Bell} title="Alerts" />
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Bell class="h-12 w-12 mb-4 opacity-50" />
<p class="text-lg">No alerts configured</p>
<p class="text-sm">Alert configuration coming soon</p>
</div>
</div>

View File

@@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import { getContainerEvents, getContainerEventContainers, getContainerEventActions, getContainerEventStats, clearContainerEvents, type ContainerEventFilters, type ContainerEventAction } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Parse query parameters
const filters: ContainerEventFilters = {};
const envId = url.searchParams.get('environmentId');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (envIdNum) {
// Specific environment requested - use it
filters.environmentId = envIdNum;
} else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
// Enterprise with auth enabled and non-admin: filter by accessible environments
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
if (accessibleEnvIds !== null) {
// User has limited access - filter by their accessible environments
if (accessibleEnvIds.length === 0) {
// No access to any environment - return empty
return json({ events: [], total: 0, limit: 100, offset: 0 });
}
filters.environmentIds = accessibleEnvIds;
}
// If accessibleEnvIds is null, user has access to all environments
}
const containerId = url.searchParams.get('containerId');
if (containerId) filters.containerId = containerId;
const containerName = url.searchParams.get('containerName');
if (containerName) filters.containerName = containerName;
// Support multi-select actions filter (comma-separated)
const actions = url.searchParams.get('actions');
if (actions) filters.actions = actions.split(',').filter(Boolean) as ContainerEventAction[];
// Labels filter (comma-separated)
const labels = url.searchParams.get('labels');
if (labels) filters.labels = labels.split(',').filter(Boolean);
const fromDate = url.searchParams.get('fromDate');
if (fromDate) filters.fromDate = fromDate;
const toDate = url.searchParams.get('toDate');
if (toDate) filters.toDate = toDate;
const limit = url.searchParams.get('limit');
if (limit) filters.limit = parseInt(limit);
const offset = url.searchParams.get('offset');
if (offset) filters.offset = parseInt(offset);
const result = await getContainerEvents(filters);
return json(result);
} catch (error) {
console.error('Error fetching container events:', error);
return json({ error: 'Failed to fetch container events' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Check permission - admins or users with activity delete permission
// In free edition, all authenticated users can delete
if (auth.authEnabled && !await auth.can('activity', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
await clearContainerEvents();
return json({ success: true });
} catch (error) {
console.error('Error clearing container events:', error);
return json({ error: 'Failed to clear container events' }, { status: 500 });
}
};

View File

@@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import { getContainerEventContainers } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('environment_id');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check for activity viewing
if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
let environmentIds: number[] | undefined;
if (envIdNum) {
// Specific environment requested
const containers = await getContainerEventContainers(envIdNum);
return json(containers);
} else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
// Enterprise with auth enabled and non-admin: filter by accessible environments
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
if (accessibleEnvIds !== null) {
if (accessibleEnvIds.length === 0) {
// No access to any environment - return empty list
return json([]);
}
environmentIds = accessibleEnvIds;
}
}
const containers = await getContainerEventContainers(undefined, environmentIds);
return json(containers);
} catch (error) {
console.error('Error fetching container names:', error);
return json({ error: 'Failed to fetch container names' }, { status: 500 });
}
};

View File

@@ -0,0 +1,107 @@
import type { RequestHandler } from './$types';
import { containerEventEmitter } from '$lib/server/event-collector';
import { authorize } from '$lib/server/authorize';
import { json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Permission check for activity viewing
if (auth.authEnabled && !await auth.can('activity', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Get accessible environment IDs for filtering (enterprise only)
let accessibleEnvIds: number[] | null = null;
if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
// If user has no access to any environment, return empty stream
if (accessibleEnvIds !== null && accessibleEnvIds.length === 0) {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(`event: connected\ndata: ${JSON.stringify({ timestamp: new Date().toISOString() })}\n\n`));
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}
}
let heartbeatInterval: ReturnType<typeof setInterval>;
let handleEvent: ((event: any) => void) | null = null;
let handleEnvStatus: ((status: any) => void) | null = null;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const sendEvent = (type: string, data: any) => {
try {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(event));
} catch {
// Ignore errors when client disconnects
}
};
// Send initial connection event
sendEvent('connected', { timestamp: new Date().toISOString() });
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
clearInterval(heartbeatInterval);
}
}, 5000);
// Listen for new container events (filter by accessible environments)
handleEvent = (event: any) => {
// If accessibleEnvIds is null, user has access to all environments
// Otherwise, filter by accessible environment IDs
if (accessibleEnvIds === null || (event.environmentId && accessibleEnvIds.includes(event.environmentId))) {
sendEvent('activity', event);
}
};
// Listen for environment status changes (online/offline)
handleEnvStatus = (status: any) => {
if (accessibleEnvIds === null || (status.envId && accessibleEnvIds.includes(status.envId))) {
sendEvent('env_status', status);
}
};
containerEventEmitter.on('event', handleEvent);
containerEventEmitter.on('env_status', handleEnvStatus);
},
cancel() {
// Cleanup when client disconnects
clearInterval(heartbeatInterval);
if (handleEvent) {
containerEventEmitter.off('event', handleEvent);
handleEvent = null;
}
if (handleEnvStatus) {
containerEventEmitter.off('env_status', handleEnvStatus);
handleEnvStatus = null;
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -0,0 +1,42 @@
import { json } from '@sveltejs/kit';
import { getContainerEventStats } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('environment_id');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check for activity viewing
if (auth.authEnabled && !await auth.can('activity', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
let environmentIds: number[] | undefined;
if (envIdNum) {
// Specific environment requested
const stats = await getContainerEventStats(envIdNum);
return json(stats);
} else if (auth.isEnterprise && auth.authEnabled && !auth.isAdmin) {
// Enterprise with auth enabled and non-admin: filter by accessible environments
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
if (accessibleEnvIds !== null) {
if (accessibleEnvIds.length === 0) {
// No access to any environment - return empty stats
return json({ total: 0, today: 0, byAction: {} });
}
environmentIds = accessibleEnvIds;
}
}
const stats = await getContainerEventStats(undefined, environmentIds);
return json(stats);
} catch (error) {
console.error('Error fetching container event stats:', error);
return json({ error: 'Failed to fetch stats' }, { status: 500 });
}
};

View File

@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { getAuditLogs, getAuditLogUsers, type AuditLogFilters, type AuditEntityType, type AuditAction } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return json(enterpriseRequired(), { status: 403 });
}
// Check permission
if (!await auth.canViewAuditLog()) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Parse query parameters
const filters: AuditLogFilters = {};
// Support multi-select filters (comma-separated)
const usernames = url.searchParams.get('usernames');
if (usernames) filters.usernames = usernames.split(',').filter(Boolean);
const entityTypes = url.searchParams.get('entityTypes');
if (entityTypes) filters.entityTypes = entityTypes.split(',').filter(Boolean) as AuditEntityType[];
const actions = url.searchParams.get('actions');
if (actions) filters.actions = actions.split(',').filter(Boolean) as AuditAction[];
// Legacy single-value support
const username = url.searchParams.get('username');
if (username) filters.usernames = [username];
const entityType = url.searchParams.get('entityType');
if (entityType) filters.entityTypes = [entityType as AuditEntityType];
const action = url.searchParams.get('action');
if (action) filters.actions = [action as AuditAction];
const envId = url.searchParams.get('environmentId');
if (envId) filters.environmentId = parseInt(envId);
// Labels filter (comma-separated)
const labels = url.searchParams.get('labels');
if (labels) filters.labels = labels.split(',').filter(Boolean);
const fromDate = url.searchParams.get('fromDate');
if (fromDate) filters.fromDate = fromDate;
const toDate = url.searchParams.get('toDate');
if (toDate) filters.toDate = toDate;
const limit = url.searchParams.get('limit');
if (limit) filters.limit = parseInt(limit);
const offset = url.searchParams.get('offset');
if (offset) filters.offset = parseInt(offset);
const result = await getAuditLogs(filters);
return json(result);
} catch (error) {
console.error('Error fetching audit logs:', error);
return json({ error: 'Failed to fetch audit logs' }, { status: 500 });
}
};

View File

@@ -0,0 +1,79 @@
import type { RequestHandler } from './$types';
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { auditEvents, type AuditEventData } from '$lib/server/audit-events';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return new Response(JSON.stringify(enterpriseRequired()), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Check permission
if (!await auth.canViewAuditLog()) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// Send SSE event
const sendEvent = (type: string, data: any) => {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
try {
controller.enqueue(encoder.encode(event));
} catch (e) {
// Client disconnected
}
};
// Send initial connection event
sendEvent('connected', { timestamp: new Date().toISOString() });
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
const heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
clearInterval(heartbeatInterval);
}
}, 5000);
// Listen for audit events
const onAuditEvent = (data: AuditEventData) => {
sendEvent('audit', data);
};
auditEvents.on('audit', onAuditEvent);
// Cleanup when client disconnects
const cleanup = () => {
clearInterval(heartbeatInterval);
auditEvents.off('audit', onAuditEvent);
};
// Note: SvelteKit doesn't provide a direct way to detect client disconnect
// The cleanup will happen when the stream errors or the server shuts down
// For production, consider using a WebSocket instead for better connection management
return cleanup;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable nginx buffering
}
});
};

View File

@@ -0,0 +1,182 @@
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { getAuditLogs, type AuditLogFilters, type AuditEntityType, type AuditAction, type AuditLog } from '$lib/server/db';
import type { RequestHandler } from './$types';
function escapeCSV(value: string | null | undefined): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function formatToJSON(logs: AuditLog[]): string {
return JSON.stringify(logs, null, 2);
}
function formatToCSV(logs: AuditLog[]): string {
const headers = [
'ID',
'Timestamp',
'Username',
'Action',
'Entity Type',
'Entity ID',
'Entity Name',
'Environment ID',
'Description',
'IP Address',
'User Agent',
'Details'
];
const rows = logs.map((log) => [
log.id,
log.createdAt,
escapeCSV(log.username),
escapeCSV(log.action),
escapeCSV(log.entityType),
escapeCSV(log.entityId),
escapeCSV(log.entityName),
log.environmentId ?? '',
escapeCSV(log.description),
escapeCSV(log.ipAddress),
escapeCSV(log.userAgent),
escapeCSV(log.details ? JSON.stringify(log.details) : '')
]);
return [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
}
function formatToMarkdown(logs: AuditLog[]): string {
const lines: string[] = [];
lines.push('# Audit Log Export');
lines.push('');
lines.push(`Generated: ${new Date().toISOString()}`);
lines.push('');
lines.push(`Total entries: ${logs.length}`);
lines.push('');
lines.push('---');
lines.push('');
for (const log of logs) {
lines.push(`## ${log.action.toUpperCase()} - ${log.entityType}`);
lines.push('');
lines.push(`| Field | Value |`);
lines.push(`|-------|-------|`);
lines.push(`| Timestamp | ${log.createdAt} |`);
lines.push(`| User | ${log.username} |`);
lines.push(`| Action | ${log.action} |`);
lines.push(`| Entity Type | ${log.entityType} |`);
if (log.entityName) lines.push(`| Entity Name | ${log.entityName} |`);
if (log.entityId) lines.push(`| Entity ID | \`${log.entityId}\` |`);
if (log.environmentId) lines.push(`| Environment ID | ${log.environmentId} |`);
if (log.description) lines.push(`| Description | ${log.description} |`);
if (log.ipAddress) lines.push(`| IP Address | ${log.ipAddress} |`);
if (log.details) {
lines.push('');
lines.push('**Details:**');
lines.push('```json');
lines.push(JSON.stringify(log.details, null, 2));
lines.push('```');
}
lines.push('');
lines.push('---');
lines.push('');
}
return lines.join('\n');
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return new Response(JSON.stringify(enterpriseRequired()), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Check permission
if (!await auth.canViewAuditLog()) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
try {
// Parse query parameters
const filters: AuditLogFilters = {};
const username = url.searchParams.get('username');
if (username) filters.username = username;
const entityType = url.searchParams.get('entityType');
if (entityType) filters.entityType = entityType as AuditEntityType;
const action = url.searchParams.get('action');
if (action) filters.action = action as AuditAction;
const envId = url.searchParams.get('environmentId');
if (envId) filters.environmentId = parseInt(envId);
const fromDate = url.searchParams.get('fromDate');
if (fromDate) filters.fromDate = fromDate;
const toDate = url.searchParams.get('toDate');
if (toDate) filters.toDate = toDate;
// For export, get all matching records (no pagination)
filters.limit = 10000; // Reasonable max limit
const result = await getAuditLogs(filters);
const logs = result.logs;
const format = url.searchParams.get('format') || 'json';
const timestamp = new Date().toISOString().split('T')[0];
let content: string;
let contentType: string;
let filename: string;
switch (format) {
case 'csv':
content = formatToCSV(logs);
contentType = 'text/csv';
filename = `audit-log-${timestamp}.csv`;
break;
case 'md':
content = formatToMarkdown(logs);
contentType = 'text/markdown';
filename = `audit-log-${timestamp}.md`;
break;
case 'json':
default:
content = formatToJSON(logs);
contentType = 'application/json';
filename = `audit-log-${timestamp}.json`;
break;
}
return new Response(content, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`
}
});
} catch (error) {
console.error('Error exporting audit logs:', error);
return new Response(JSON.stringify({ error: 'Failed to export audit logs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import { authorize, enterpriseRequired } from '$lib/server/authorize';
import { getAuditLogUsers } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Audit log is Enterprise-only
if (!auth.isEnterprise) {
return json(enterpriseRequired(), { status: 403 });
}
// Check permission
if (!await auth.canViewAuditLog()) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const users = await getAuditLogUsers();
return json(users);
} catch (error) {
console.error('Error fetching audit log users:', error);
return json({ error: 'Failed to fetch audit log users' }, { status: 500 });
}
};

View File

@@ -0,0 +1,81 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfigs, createLdapConfig } from '$lib/server/db';
// GET /api/auth/ldap - List all LDAP configurations
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
try {
const configs = await getLdapConfigs();
// Don't return passwords
const sanitized = configs.map(config => ({
...config,
bindPassword: config.bindPassword ? '********' : undefined
}));
return json(sanitized);
} catch (error) {
console.error('Failed to get LDAP configs:', error);
return json({ error: 'Failed to get LDAP configurations' }, { status: 500 });
}
};
// POST /api/auth/ldap - Create a new LDAP configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
try {
const data = await request.json();
// Validate required fields
if (!data.name || !data.serverUrl || !data.baseDn) {
return json({ error: 'Name, server URL, and base DN are required' }, { status: 400 });
}
const config = await createLdapConfig({
name: data.name,
enabled: data.enabled ?? false,
serverUrl: data.serverUrl,
bindDn: data.bindDn || undefined,
bindPassword: data.bindPassword || undefined,
baseDn: data.baseDn,
userFilter: data.userFilter || '(uid={{username}})',
usernameAttribute: data.usernameAttribute || 'uid',
emailAttribute: data.emailAttribute || 'mail',
displayNameAttribute: data.displayNameAttribute || 'cn',
groupBaseDn: data.groupBaseDn || undefined,
groupFilter: data.groupFilter || undefined,
adminGroup: data.adminGroup || undefined,
roleMappings: data.roleMappings || undefined,
tlsEnabled: data.tlsEnabled ?? false,
tlsCa: data.tlsCa || undefined
});
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
}, { status: 201 });
} catch (error) {
console.error('Failed to create LDAP config:', error);
return json({ error: 'Failed to create LDAP configuration' }, { status: 500 });
}
};

View File

@@ -0,0 +1,131 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getLdapConfig, updateLdapConfig, deleteLdapConfig } from '$lib/server/db';
// GET /api/auth/ldap/[id] - Get a specific LDAP configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const config = await getLdapConfig(id);
if (!config) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
});
} catch (error) {
console.error('Failed to get LDAP config:', error);
return json({ error: 'Failed to get LDAP configuration' }, { status: 500 });
}
};
// PUT /api/auth/ldap/[id] - Update a LDAP configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const existing = await getLdapConfig(id);
if (!existing) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
const data = await request.json();
// Don't update password if it's the masked value
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.serverUrl !== undefined) updateData.serverUrl = data.serverUrl;
if (data.bindDn !== undefined) updateData.bindDn = data.bindDn;
if (data.bindPassword !== undefined && data.bindPassword !== '********') {
updateData.bindPassword = data.bindPassword;
}
if (data.baseDn !== undefined) updateData.baseDn = data.baseDn;
if (data.userFilter !== undefined) updateData.userFilter = data.userFilter;
if (data.usernameAttribute !== undefined) updateData.usernameAttribute = data.usernameAttribute;
if (data.emailAttribute !== undefined) updateData.emailAttribute = data.emailAttribute;
if (data.displayNameAttribute !== undefined) updateData.displayNameAttribute = data.displayNameAttribute;
if (data.groupBaseDn !== undefined) updateData.groupBaseDn = data.groupBaseDn;
if (data.groupFilter !== undefined) updateData.groupFilter = data.groupFilter;
if (data.adminGroup !== undefined) updateData.adminGroup = data.adminGroup;
if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings;
if (data.tlsEnabled !== undefined) updateData.tlsEnabled = data.tlsEnabled;
if (data.tlsCa !== undefined) updateData.tlsCa = data.tlsCa;
const config = await updateLdapConfig(id, updateData);
if (!config) {
return json({ error: 'Failed to update configuration' }, { status: 500 });
}
return json({
...config,
bindPassword: config.bindPassword ? '********' : undefined
});
} catch (error) {
console.error('Failed to update LDAP config:', error);
return json({ error: 'Failed to update LDAP configuration' }, { status: 500 });
}
};
// DELETE /api/auth/ldap/[id] - Delete a LDAP configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const deleted = await deleteLdapConfig(id);
if (!deleted) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete LDAP config:', error);
return json({ error: 'Failed to delete LDAP configuration' }, { status: 500 });
}
};

View File

@@ -0,0 +1,37 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { testLdapConnection } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { getLdapConfig } from '$lib/server/db';
// POST /api/auth/ldap/[id]/test - Test LDAP connection
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// Allow access when auth is disabled (setup mode) or when user is admin
if (auth.authEnabled && (!auth.isAuthenticated || !auth.isAdmin)) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!auth.isEnterprise) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const id = parseInt(params.id!, 10);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const config = await getLdapConfig(id);
if (!config) {
return json({ error: 'LDAP configuration not found' }, { status: 404 });
}
const result = await testLdapConnection(id);
return json(result);
} catch (error) {
console.error('Failed to test LDAP connection:', error);
return json({ error: 'Failed to test LDAP connection' }, { status: 500 });
}
};

View File

@@ -0,0 +1,117 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import {
authenticateLocal,
authenticateLdap,
getEnabledLdapConfigs,
createUserSession,
isRateLimited,
recordFailedAttempt,
clearRateLimit,
verifyMfaToken,
isAuthEnabled
} from '$lib/server/auth';
import { getUser, getUserByUsername } from '$lib/server/db';
// POST /api/auth/login - Authenticate user
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
// Check if auth is enabled
if (!(await isAuthEnabled())) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
try {
const { username, password, mfaToken, provider = 'local' } = await request.json();
if (!username || !password) {
return json({ error: 'Username and password are required' }, { status: 400 });
}
// Rate limiting by IP and username
const clientIp = getClientAddress();
const rateLimitKey = `${clientIp}:${username}`;
const { limited, retryAfter } = isRateLimited(rateLimitKey);
if (limited) {
return json(
{ error: `Too many login attempts. Please try again in ${retryAfter} seconds.` },
{ status: 429 }
);
}
// Attempt authentication based on provider
let result: any;
let authProviderType: 'local' | 'ldap' | 'oidc' = 'local';
if (provider.startsWith('ldap:')) {
// LDAP provider with specific config ID (e.g., "ldap:1")
const configId = parseInt(provider.split(':')[1], 10);
result = await authenticateLdap(username, password, configId);
authProviderType = 'ldap';
} else if (provider === 'ldap') {
// Generic LDAP (will try all enabled configs)
result = await authenticateLdap(username, password);
authProviderType = 'ldap';
} else {
result = await authenticateLocal(username, password);
authProviderType = 'local';
}
if (!result.success) {
recordFailedAttempt(rateLimitKey);
return json({ error: result.error || 'Authentication failed' }, { status: 401 });
}
// Handle MFA if required
if (result.requiresMfa) {
if (!mfaToken) {
// Return that MFA is required
return json({ requiresMfa: true }, { status: 200 });
}
// Verify MFA token
const user = await getUserByUsername(username);
if (!user || !(await verifyMfaToken(user.id, mfaToken))) {
recordFailedAttempt(rateLimitKey);
return json({ error: 'Invalid MFA code' }, { status: 401 });
}
// MFA verified, create session
const session = await createUserSession(user.id, authProviderType, cookies);
clearRateLimit(rateLimitKey);
return json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
isAdmin: user.isAdmin
}
});
}
// No MFA, create session directly
if (result.user) {
const session = await createUserSession(result.user.id, authProviderType, cookies);
clearRateLimit(rateLimitKey);
return json({
success: true,
user: {
id: result.user.id,
username: result.user.username,
email: result.user.email,
displayName: result.user.displayName,
isAdmin: result.user.isAdmin
}
});
}
return json({ error: 'Authentication failed' }, { status: 401 });
} catch (error) {
console.error('Login error:', error);
return json({ error: 'Login failed' }, { status: 500 });
}
};

View File

@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { destroySession } from '$lib/server/auth';
// POST /api/auth/logout - End session
export const POST: RequestHandler = async ({ cookies }) => {
try {
await destroySession(cookies);
return json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return json({ error: 'Logout failed' }, { status: 500 });
}
};

View File

@@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import {
getOidcConfigs,
createOidcConfig,
type OidcConfig
} from '$lib/server/db';
// GET /api/auth/oidc - List all OIDC configurations
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:view permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const configs = await getOidcConfigs();
// Sanitize sensitive data
const sanitized = configs.map(config => ({
...config,
clientSecret: config.clientSecret ? '********' : ''
}));
return json(sanitized);
} catch (error) {
console.error('Failed to get OIDC configs:', error);
return json({ error: 'Failed to get OIDC configurations' }, { status: 500 });
}
};
// POST /api/auth/oidc - Create new OIDC configuration
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const data = await request.json();
// Validate required fields
const required = ['name', 'issuerUrl', 'clientId', 'clientSecret', 'redirectUri'];
for (const field of required) {
if (!data[field]) {
return json({ error: `Missing required field: ${field}` }, { status: 400 });
}
}
const config = await createOidcConfig({
name: data.name,
enabled: data.enabled ?? false,
issuerUrl: data.issuerUrl,
clientId: data.clientId,
clientSecret: data.clientSecret,
redirectUri: data.redirectUri,
scopes: data.scopes || 'openid profile email',
usernameClaim: data.usernameClaim || 'preferred_username',
emailClaim: data.emailClaim || 'email',
displayNameClaim: data.displayNameClaim || 'name',
adminClaim: data.adminClaim || undefined,
adminValue: data.adminValue || undefined,
roleMappingsClaim: data.roleMappingsClaim || 'groups',
roleMappings: data.roleMappings || undefined
});
return json({
...config,
clientSecret: '********'
}, { status: 201 });
} catch (error: any) {
console.error('Failed to create OIDC config:', error);
return json({ error: error.message || 'Failed to create OIDC configuration' }, { status: 500 });
}
};

View File

@@ -0,0 +1,136 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import {
getOidcConfig,
updateOidcConfig,
deleteOidcConfig
} from '$lib/server/db';
// GET /api/auth/oidc/[id] - Get specific OIDC configuration
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:view permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const config = await getOidcConfig(id);
if (!config) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
return json({
...config,
clientSecret: config.clientSecret ? '********' : ''
});
} catch (error) {
console.error('Failed to get OIDC config:', error);
return json({ error: 'Failed to get OIDC configuration' }, { status: 500 });
}
};
// PUT /api/auth/oidc/[id] - Update OIDC configuration
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const existing = await getOidcConfig(id);
if (!existing) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
const data = await request.json();
// Don't update clientSecret if it's the masked value
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.issuerUrl !== undefined) updateData.issuerUrl = data.issuerUrl;
if (data.clientId !== undefined) updateData.clientId = data.clientId;
if (data.clientSecret !== undefined && data.clientSecret !== '********') {
updateData.clientSecret = data.clientSecret;
}
if (data.redirectUri !== undefined) updateData.redirectUri = data.redirectUri;
if (data.scopes !== undefined) updateData.scopes = data.scopes;
if (data.usernameClaim !== undefined) updateData.usernameClaim = data.usernameClaim;
if (data.emailClaim !== undefined) updateData.emailClaim = data.emailClaim;
if (data.displayNameClaim !== undefined) updateData.displayNameClaim = data.displayNameClaim;
if (data.adminClaim !== undefined) updateData.adminClaim = data.adminClaim;
if (data.adminValue !== undefined) updateData.adminValue = data.adminValue;
if (data.roleMappingsClaim !== undefined) updateData.roleMappingsClaim = data.roleMappingsClaim;
if (data.roleMappings !== undefined) updateData.roleMappings = data.roleMappings;
const config = await updateOidcConfig(id, updateData);
if (!config) {
return json({ error: 'Failed to update OIDC configuration' }, { status: 500 });
}
return json({
...config,
clientSecret: config.clientSecret ? '********' : ''
});
} catch (error: any) {
console.error('Failed to update OIDC config:', error);
return json({ error: error.message || 'Failed to update OIDC configuration' }, { status: 500 });
}
};
// DELETE /api/auth/oidc/[id] - Delete OIDC configuration
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication and settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const deleted = await deleteOidcConfig(id);
if (!deleted) {
return json({ error: 'OIDC configuration not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete OIDC config:', error);
return json({ error: 'Failed to delete OIDC configuration' }, { status: 500 });
}
};

View File

@@ -0,0 +1,77 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth';
import { getOidcConfig } from '$lib/server/db';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
// Get redirect URL from query params
const redirectUrl = url.searchParams.get('redirect') || '/';
try {
const config = await getOidcConfig(id);
if (!config || !config.enabled) {
return json({ error: 'OIDC provider not found or disabled' }, { status: 404 });
}
const result = await buildOidcAuthorizationUrl(id, redirectUrl);
if ('error' in result) {
return json({ error: result.error }, { status: 500 });
}
// Redirect to the IdP
throw redirect(302, result.url);
} catch (error: any) {
// Re-throw redirect
if (error.status === 302) {
throw error;
}
console.error('Failed to initiate OIDC:', error);
return json({ error: error.message || 'Failed to initiate SSO' }, { status: 500 });
}
};
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
export const POST: RequestHandler = async ({ params, request }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const body = await request.json().catch(() => ({}));
const redirectUrl = body.redirect || '/';
const config = await getOidcConfig(id);
if (!config || !config.enabled) {
return json({ error: 'OIDC provider not found or disabled' }, { status: 404 });
}
const result = await buildOidcAuthorizationUrl(id, redirectUrl);
if ('error' in result) {
return json({ error: result.error }, { status: 500 });
}
return json({ url: result.url });
} catch (error: any) {
console.error('Failed to get OIDC authorization URL:', error);
return json({ error: error.message || 'Failed to initiate SSO' }, { status: 500 });
}
};

View File

@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/auth';
// POST /api/auth/oidc/[id]/test - Test OIDC connection
export const POST: RequestHandler = async ({ params, cookies }) => {
// When auth is disabled, allow access (for initial setup)
// When auth is enabled, require admin
if (isAuthEnabled()) {
const user = await validateSession(cookies);
if (!user || !user.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
}
const id = parseInt(params.id || '');
if (isNaN(id)) {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
try {
const result = await testOidcConnection(id);
return json(result);
} catch (error: any) {
console.error('Failed to test OIDC connection:', error);
return json({ success: false, error: error.message || 'Test failed' }, { status: 500 });
}
};

View File

@@ -0,0 +1,53 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async ({ url, cookies }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
throw redirect(302, '/login?error=auth_disabled');
}
// Get parameters from URL
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Handle error from IdP
if (error) {
console.error('OIDC error from IdP:', error, errorDescription);
const errorMsg = encodeURIComponent(errorDescription || error);
throw redirect(302, `/login?error=${errorMsg}`);
}
// Validate required parameters
if (!code || !state) {
throw redirect(302, '/login?error=invalid_callback');
}
try {
const result = await handleOidcCallback(code, state);
if (!result.success || !result.user) {
const errorMsg = encodeURIComponent(result.error || 'Authentication failed');
throw redirect(302, `/login?error=${errorMsg}`);
}
// Create session
await createUserSession(result.user.id, 'oidc', cookies);
// Redirect to the original destination or home
const redirectUrl = result.redirectUrl || '/';
throw redirect(302, redirectUrl);
} catch (error: any) {
// Re-throw redirect
if (error.status === 302) {
throw error;
}
console.error('OIDC callback error:', error);
const errorMsg = encodeURIComponent(error.message || 'Authentication failed');
throw redirect(302, `/login?error=${errorMsg}`);
}
};

View File

@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { isAuthEnabled, getEnabledLdapConfigs, getEnabledOidcConfigs } from '$lib/server/auth';
import { getAuthSettings } from '$lib/server/db';
import { isEnterprise } from '$lib/server/license';
// GET /api/auth/providers - Get available authentication providers
export const GET: RequestHandler = async () => {
if (!(await isAuthEnabled())) {
return json({ providers: [] });
}
try {
// Fetch all provider configs in parallel
const [settings, enterpriseEnabled, oidcConfigs] = await Promise.all([
getAuthSettings(),
isEnterprise(),
getEnabledOidcConfigs()
]);
const ldapConfigs = enterpriseEnabled ? await getEnabledLdapConfigs() : [];
const providers: { id: string; name: string; type: 'local' | 'ldap' | 'oidc'; initiateUrl?: string }[] = [];
// Local auth is always available when auth is enabled
providers.push({ id: 'local', name: 'Local', type: 'local' });
// Add enabled LDAP providers (enterprise only)
for (const config of ldapConfigs) {
providers.push({
id: `ldap:${config.id}`,
name: config.name,
type: 'ldap'
});
}
// Add enabled OIDC providers (free for all)
for (const config of oidcConfigs) {
providers.push({
id: `oidc:${config.id}`,
name: config.name,
type: 'oidc',
initiateUrl: `/api/auth/oidc/${config.id}/initiate`
});
}
return json({
providers,
defaultProvider: settings.defaultProvider || 'local'
});
} catch (error) {
console.error('Failed to get auth providers:', error);
return json({ providers: [{ id: 'local', name: 'Local', type: 'local' }] });
}
};

View File

@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { validateSession, isAuthEnabled } from '$lib/server/auth';
import { getAuthSettings } from '$lib/server/db';
// GET /api/auth/session - Get current session/user
export const GET: RequestHandler = async ({ cookies }) => {
try {
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
// Auth is disabled, return anonymous session
return json({
authenticated: false,
authEnabled: false
});
}
const user = await validateSession(cookies);
if (!user) {
return json({
authenticated: false,
authEnabled: true
});
}
return json({
authenticated: true,
authEnabled: true,
user: {
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
avatar: user.avatar,
isAdmin: user.isAdmin,
provider: user.provider,
permissions: user.permissions
}
});
} catch (error) {
console.error('Session check error:', error);
return json({ error: 'Failed to check session' }, { status: 500 });
}
};

View File

@@ -0,0 +1,71 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { getAuthSettings, updateAuthSettings, countAdminUsers } from '$lib/server/db';
import { isEnterprise } from '$lib/server/license';
import { authorize } from '$lib/server/authorize';
// GET /api/auth/settings - Get auth settings
// Public when auth is disabled, requires authentication when enabled
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication first, then settings:view permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const settings = await getAuthSettings();
return json(settings);
} catch (error) {
console.error('Failed to get auth settings:', error);
return json({ error: 'Failed to get auth settings' }, { status: 500 });
}
};
// PUT /api/auth/settings - Update auth settings
// Requires authentication and settings:edit permission
export const PUT: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication first, then settings:edit permission
if (auth.authEnabled) {
if (!auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
}
try {
const data = await request.json();
// Check if trying to enable auth without required users
if (data.authEnabled === true) {
const userCount = await countAdminUsers();
// PostgreSQL returns bigint for count(*), convert to number for comparison
if (Number(userCount) === 0) {
const enterprise = await isEnterprise();
const errorMessage = enterprise
? 'Cannot enable authentication without an admin user. Create a user and assign them the Admin role first.'
: 'Cannot enable authentication without any users. Create a user first.';
return json({
error: errorMessage,
requiresUser: true
}, { status: 400 });
}
}
const settings = await updateAuthSettings(data);
return json(settings);
} catch (error) {
console.error('Failed to update auth settings:', error);
return json({ error: 'Failed to update auth settings' }, { status: 500 });
}
};

View File

@@ -0,0 +1,40 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettings } from '$lib/server/db';
/**
* Batch endpoint to get all auto-update settings for an environment.
* Returns a map of containerName -> settings for efficient lookup.
*/
export const GET: RequestHandler = async ({ url }) => {
try {
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const settings = await getAutoUpdateSettings(envId);
// Convert to a map keyed by container name for efficient frontend lookup
const settingsMap: Record<string, {
enabled: boolean;
scheduleType: string;
cronExpression: string | null;
vulnerabilityCriteria: string;
}> = {};
for (const setting of settings) {
if (setting.enabled) {
settingsMap[setting.containerName] = {
enabled: setting.enabled,
scheduleType: setting.scheduleType,
cronExpression: setting.cronExpression,
vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never'
};
}
}
return json(settingsMap);
} catch (error) {
console.error('Failed to get auto-update settings:', error);
return json({ error: 'Failed to get auto-update settings' }, { status: 500 });
}
};

View File

@@ -0,0 +1,126 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getAutoUpdateSetting,
upsertAutoUpdateSetting,
deleteAutoUpdateSetting,
deleteAutoUpdateSchedule
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
export const GET: RequestHandler = async ({ params, url }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const setting = await getAutoUpdateSetting(containerName, envId);
if (!setting) {
return json({
enabled: false,
scheduleType: 'daily',
cronExpression: '0 3 * * *',
vulnerabilityCriteria: 'never'
});
}
// Return with camelCase keys
return json({
...setting,
scheduleType: setting.scheduleType,
cronExpression: setting.cronExpression,
vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never'
});
} catch (error) {
console.error('Failed to get auto-update setting:', error);
return json({ error: 'Failed to get auto-update setting' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ params, url, request }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const body = await request.json();
// Accept both camelCase and snake_case for backward compatibility
const enabled = body.enabled;
const cronExpression = body.cronExpression ?? body.cron_expression;
const vulnerabilityCriteria = body.vulnerabilityCriteria ?? body.vulnerability_criteria;
// Hard delete when disabled
if (enabled === false) {
await deleteAutoUpdateSchedule(containerName, envId);
return json({ success: true, deleted: true });
}
// Auto-detect schedule type from cron expression for backward compatibility
let scheduleType: 'daily' | 'weekly' | 'custom' = 'custom';
if (cronExpression) {
const parts = cronExpression.split(' ');
if (parts.length >= 5) {
const [, , day, month, dow] = parts;
if (dow !== '*' && day === '*' && month === '*') {
scheduleType = 'weekly';
} else if (day === '*' && month === '*' && dow === '*') {
scheduleType = 'daily';
}
}
}
const setting = await upsertAutoUpdateSetting(
containerName,
{
enabled: Boolean(enabled),
scheduleType: scheduleType,
cronExpression: cronExpression || null,
vulnerabilityCriteria: vulnerabilityCriteria || 'never'
},
envId
);
// Register or unregister schedule with croner
if (setting.enabled && setting.cronExpression) {
await registerSchedule(setting.id, 'container_update', setting.environmentId);
} else {
unregisterSchedule(setting.id, 'container_update');
}
// Return with camelCase keys
return json({
...setting,
scheduleType: setting.scheduleType,
cronExpression: setting.cronExpression,
vulnerabilityCriteria: setting.vulnerabilityCriteria || 'never'
});
} catch (error) {
console.error('Failed to save auto-update setting:', error);
return json({ error: 'Failed to save auto-update setting' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, url }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
// Get the setting ID before deleting
const setting = await getAutoUpdateSetting(containerName, envId);
const settingId = setting?.id;
const deleted = await deleteAutoUpdateSetting(containerName, envId);
// Unregister schedule from croner
if (deleted && settingId) {
unregisterSchedule(settingId, 'container_update');
}
return json({ success: deleted });
} catch (error) {
console.error('Failed to delete auto-update setting:', error);
return json({ error: 'Failed to delete auto-update setting' }, { status: 500 });
}
};

463
routes/api/batch/+server.ts Normal file
View File

@@ -0,0 +1,463 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
startContainer,
stopContainer,
restartContainer,
pauseContainer,
unpauseContainer,
removeContainer,
inspectContainer,
listContainers,
removeImage,
removeVolume,
removeNetwork
} from '$lib/server/docker';
import {
startStack,
stopStack,
restartStack,
downStack,
removeStack
} from '$lib/server/stacks';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
// SSE Event types
export type BatchEventType = 'start' | 'progress' | 'complete' | 'error';
export type ItemStatus = 'pending' | 'processing' | 'success' | 'error';
export interface BatchStartEvent {
type: 'start';
total: number;
}
export interface BatchProgressEvent {
type: 'progress';
id: string;
name: string;
status: ItemStatus;
message?: string;
error?: string;
current: number;
total: number;
}
export interface BatchCompleteEvent {
type: 'complete';
summary: {
total: number;
success: number;
failed: number;
};
}
export interface BatchErrorEvent {
type: 'error';
error: string;
}
export type BatchEvent = BatchStartEvent | BatchProgressEvent | BatchCompleteEvent | BatchErrorEvent;
// Supported operations per entity type
const ENTITY_OPERATIONS: Record<string, string[]> = {
containers: ['start', 'stop', 'restart', 'pause', 'unpause', 'remove'],
images: ['remove'],
volumes: ['remove'],
networks: ['remove'],
stacks: ['start', 'stop', 'restart', 'down', 'remove']
};
// Permission mapping for entity operations
const PERMISSION_MAP: Record<string, Record<string, string>> = {
containers: {
start: 'start',
stop: 'stop',
restart: 'restart',
pause: 'stop',
unpause: 'start',
remove: 'remove'
},
images: { remove: 'remove' },
volumes: { remove: 'remove' },
networks: { remove: 'remove' },
stacks: {
start: 'start',
stop: 'stop',
restart: 'restart',
down: 'stop',
remove: 'remove'
}
};
interface BatchRequest {
operation: string;
entityType: string;
items: Array<{ id: string; name: string }>;
options?: {
force?: boolean;
removeVolumes?: boolean;
};
}
// Concurrent execution helper with controlled parallelism
async function processWithConcurrency<T>(
items: T[],
concurrency: number,
processor: (item: T, index: number) => Promise<void>,
signal: AbortSignal
): Promise<void> {
let currentIndex = 0;
const total = items.length;
async function processNext(): Promise<void> {
while (currentIndex < total) {
if (signal.aborted) return;
const index = currentIndex++;
await processor(items[index], index);
}
}
// Start 'concurrency' number of workers
const workers = Array(Math.min(concurrency, total))
.fill(null)
.map(() => processNext());
await Promise.all(workers);
}
/**
* Unified batch operations endpoint with SSE streaming.
* Handles bulk operations for containers, images, volumes, networks, and stacks.
*/
export const POST: RequestHandler = async ({ url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Parse request body
let body: BatchRequest;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { operation, entityType, items, options = {} } = body;
// Validate entity type
if (!ENTITY_OPERATIONS[entityType]) {
return json(
{ error: `Invalid entity type: ${entityType}. Supported: ${Object.keys(ENTITY_OPERATIONS).join(', ')}` },
{ status: 400 }
);
}
// Validate operation for entity type
if (!ENTITY_OPERATIONS[entityType].includes(operation)) {
return json(
{ error: `Invalid operation '${operation}' for ${entityType}. Supported: ${ENTITY_OPERATIONS[entityType].join(', ')}` },
{ status: 400 }
);
}
// Validate items
if (!items || !Array.isArray(items) || items.length === 0) {
return json({ error: 'items array is required and must not be empty' }, { status: 400 });
}
// Permission check
const permissionAction = PERMISSION_MAP[entityType][operation];
if (auth.authEnabled && !(await auth.can(entityType as any, permissionAction, envIdNum))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Check if audit is needed (enterprise only)
const needsAudit = auth.isEnterprise;
// Create abort controller for cancellation
const abortController = new AbortController();
const encoder = new TextEncoder();
let controllerClosed = false;
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (data: BatchEvent) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}
};
// Send SSE keepalive comments every 5s
keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}, 5000);
let successCount = 0;
let failCount = 0;
// Send start event
safeEnqueue({
type: 'start',
total: items.length
});
// Process items with concurrency of 3
await processWithConcurrency(
items,
3,
async (item, index) => {
if (abortController.signal.aborted) return;
const { id, name } = item;
// Send processing status
safeEnqueue({
type: 'progress',
id,
name,
status: 'processing',
current: index + 1,
total: items.length
});
try {
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
safeEnqueue({
type: 'progress',
id,
name,
status: 'success',
current: index + 1,
total: items.length
});
successCount++;
} catch (error: any) {
safeEnqueue({
type: 'progress',
id,
name,
status: 'error',
error: error.message || 'Unknown error',
current: index + 1,
total: items.length
});
failCount++;
}
},
abortController.signal
);
// Send complete event
safeEnqueue({
type: 'complete',
summary: {
total: items.length,
success: successCount,
failed: failCount
}
});
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
controller.close();
},
cancel() {
controllerClosed = true;
abortController.abort();
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};
/**
* Execute a single operation on an entity.
* Centralized operation execution to keep code DRY.
*/
async function executeOperation(
entityType: string,
operation: string,
id: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean; removeVolumes?: boolean },
needsAudit: boolean
): Promise<void> {
switch (entityType) {
case 'containers':
await executeContainerOperation(operation, id, name, envIdNum, options, needsAudit);
break;
case 'images':
await executeImageOperation(operation, id, envIdNum, options);
break;
case 'volumes':
await executeVolumeOperation(operation, id, envIdNum, options);
break;
case 'networks':
await executeNetworkOperation(operation, id, envIdNum);
break;
case 'stacks':
await executeStackOperation(operation, id, envIdNum, options);
break;
default:
throw new Error(`Unsupported entity type: ${entityType}`);
}
}
async function executeContainerOperation(
operation: string,
id: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean },
needsAudit: boolean
): Promise<void> {
switch (operation) {
case 'start':
await startContainer(id, envIdNum);
break;
case 'stop':
await stopContainer(id, envIdNum);
break;
case 'restart':
await restartContainer(id, envIdNum);
break;
case 'pause':
await pauseContainer(id, envIdNum);
break;
case 'unpause':
await unpauseContainer(id, envIdNum);
break;
case 'remove':
// In free edition, skip inspect (no audit needed)
// In enterprise, we might want to audit but for batch ops we skip for performance
await removeContainer(id, options.force ?? true, envIdNum);
// Clean up auto-update schedule if exists
try {
const setting = await getAutoUpdateSetting(name, envIdNum);
if (setting) {
unregisterSchedule(setting.id, 'container_update');
await deleteAutoUpdateSchedule(name, envIdNum);
}
} catch {
// Ignore cleanup errors
}
break;
default:
throw new Error(`Unsupported container operation: ${operation}`);
}
}
async function executeImageOperation(
operation: string,
id: string,
envIdNum: number | undefined,
options: { force?: boolean }
): Promise<void> {
switch (operation) {
case 'remove':
await removeImage(id, options.force ?? false, envIdNum);
break;
default:
throw new Error(`Unsupported image operation: ${operation}`);
}
}
async function executeVolumeOperation(
operation: string,
name: string,
envIdNum: number | undefined,
options: { force?: boolean }
): Promise<void> {
switch (operation) {
case 'remove':
await removeVolume(name, options.force ?? false, envIdNum);
break;
default:
throw new Error(`Unsupported volume operation: ${operation}`);
}
}
async function executeNetworkOperation(
operation: string,
id: string,
envIdNum: number | undefined
): Promise<void> {
switch (operation) {
case 'remove':
await removeNetwork(id, envIdNum);
break;
default:
throw new Error(`Unsupported network operation: ${operation}`);
}
}
async function executeStackOperation(
operation: string,
name: string,
envIdNum: number | undefined,
options: { removeVolumes?: boolean; force?: boolean }
): Promise<void> {
switch (operation) {
case 'start': {
const result = await startStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to start stack');
break;
}
case 'stop': {
const result = await stopStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to stop stack');
break;
}
case 'restart': {
const result = await restartStack(name, envIdNum);
if (!result.success) throw new Error(result.error || 'Failed to restart stack');
break;
}
case 'down': {
const result = await downStack(name, envIdNum, options.removeVolumes ?? false);
if (!result.success) throw new Error(result.error || 'Failed to down stack');
break;
}
case 'remove': {
const result = await removeStack(name, envIdNum, options.force ?? false);
if (!result.success) throw new Error(result.error || 'Failed to remove stack');
break;
}
default:
throw new Error(`Unsupported stack operation: ${operation}`);
}
}

View File

@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import changelog from '$lib/data/changelog.json';
export const GET: RequestHandler = async () => {
return json(changelog);
};

View File

@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSets, createConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const configSets = await getConfigSets();
return json(configSets);
} catch (error) {
console.error('Failed to fetch config sets:', error);
return json({ error: 'Failed to fetch config sets' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
if (!body.name?.trim()) {
return json({ error: 'Name is required' }, { status: 400 });
}
const configSet = await createConfigSet({
name: body.name.trim(),
description: body.description?.trim() || undefined,
envVars: body.envVars || [],
labels: body.labels || [],
ports: body.ports || [],
volumes: body.volumes || [],
networkMode: body.networkMode || 'bridge',
restartPolicy: body.restartPolicy || 'no'
});
return json(configSet, { status: 201 });
} catch (error: any) {
console.error('Failed to create config set:', error);
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A config set with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to create config set' }, { status: 500 });
}
};

View File

@@ -0,0 +1,91 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getConfigSet, updateConfigSet, deleteConfigSet } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const configSet = await getConfigSet(id);
if (!configSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
return json(configSet);
} catch (error) {
console.error('Failed to fetch config set:', error);
return json({ error: 'Failed to fetch config set' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const body = await request.json();
const configSet = await updateConfigSet(id, {
name: body.name?.trim(),
description: body.description?.trim(),
envVars: body.envVars,
labels: body.labels,
ports: body.ports,
volumes: body.volumes,
networkMode: body.networkMode,
restartPolicy: body.restartPolicy
});
if (!configSet) {
return json({ error: 'Config set not found' }, { status: 404 });
}
return json(configSet);
} catch (error: any) {
console.error('Failed to update config set:', error);
if (error.message?.includes('UNIQUE constraint')) {
return json({ error: 'A config set with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to update config set' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('configsets', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const deleted = await deleteConfigSet(id);
if (!deleted) {
return json({ error: 'Config set not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete config set:', error);
return json({ error: 'Failed to delete config set' }, { status: 500 });
}
};

View File

@@ -0,0 +1,123 @@
import { json } from '@sveltejs/kit';
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const all = url.searchParams.get('all') !== 'false';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json([]);
}
// Early return if no environment specified
if (!envIdNum) {
return json([]);
}
try {
const containers = await listContainers(all, envIdNum);
return json(containers);
} catch (error: any) {
// Return 404 for missing environment so frontend can clear stale localStorage
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Error listing containers:', error);
// Return empty array instead of error to allow UI to load
return json([]);
}
};
export const POST: RequestHandler = async (event) => {
const { request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const body = await request.json();
const { startAfterCreate, ...options } = body;
// Check if image needs to be pulled
try {
console.log(`Attempting to create container with image: ${options.image}`);
const container = await createContainer(options, envIdNum);
// Start the container if requested
if (startAfterCreate) {
await container.start();
}
// Audit log
await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image });
return json({ success: true, id: container.id });
} catch (createError: any) {
// If error is due to missing image, try to pull it first
if (createError.statusCode === 404 && createError.json?.message?.includes('No such image')) {
console.log(`Image ${options.image} not found locally. Pulling...`);
try {
// Pull the image
await pullImage(options.image, undefined, envIdNum);
console.log(`Successfully pulled image: ${options.image}`);
// Retry creating the container
const container = await createContainer(options, envIdNum);
// Start the container if requested
if (startAfterCreate) {
await container.start();
}
// Audit log
await auditContainer(event, 'create', container.id, options.name, envIdNum, { image: options.image, imagePulled: true });
return json({ success: true, id: container.id, imagePulled: true });
} catch (pullError) {
console.error('Error pulling image:', pullError);
return json({
error: 'Failed to pull image',
details: `Could not pull image ${options.image}: ${String(pullError)}`
}, { status: 500 });
}
}
// If it's a different error, rethrow it
throw createError;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Container] Create failed: ${message}`);
return json({ error: 'Failed to create container', details: message }, { status: 500 });
}
};

View File

@@ -0,0 +1,93 @@
import { json } from '@sveltejs/kit';
import {
inspectContainer,
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
return json(details);
} catch (error) {
console.error('Error inspecting container:', error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'remove', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
// Get container name before deletion for audit
let containerName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
containerName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Container might not exist or other error, use ID
}
await removeContainer(params.id, force, envIdNum);
// Audit log
await auditContainer(event, 'delete', params.id, containerName, envIdNum, { force });
// Clean up auto-update schedule if exists
try {
// Get the schedule ID before deleting
const setting = await getAutoUpdateSetting(containerName, envIdNum);
if (setting) {
// Unregister from croner
unregisterSchedule(setting.id, 'container_update');
// Delete from database
await deleteAutoUpdateSchedule(containerName, envIdNum);
}
} catch (error) {
console.error('Failed to cleanup auto-update schedule:', error);
// Don't fail the deletion if schedule cleanup fails
}
return json({ success: true });
} catch (error) {
console.error('Error removing container:', error);
return json({ error: 'Failed to remove container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,59 @@
/**
* Container Exec API
*
* POST: Creates an exec instance for terminal attachment
* Returns exec ID that can be used for WebSocket connection
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const containerId = params.id;
const envIdParam = url.searchParams.get('envId');
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
// Permission check with environment context
if (!await auth.can('containers', 'exec', envId)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json().catch(() => ({}));
const shell = body.shell || '/bin/sh';
const user = body.user || 'root';
// Create exec instance
const exec = await createExec({
containerId,
cmd: [shell],
user,
envId
});
// Get connection info for the frontend
const connectionInfo = await getDockerConnectionInfo(envId);
return json({
execId: exec.Id,
connectionInfo: {
type: connectionInfo.type,
host: connectionInfo.host,
port: connectionInfo.port
}
});
} catch (error: any) {
console.error('Failed to create exec:', error);
return json(
{ error: error.message || 'Failed to create exec instance' },
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { listContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path') || '/';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const simpleLs = url.searchParams.get('simpleLs') === 'true';
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const result = await listContainerDirectory(
params.id,
path,
envIdNum,
simpleLs
);
return json(result);
} catch (error: any) {
console.error('Error listing container directory:', error);
return json({ error: error.message || 'Failed to list directory' }, { status: 500 });
}
};

View File

@@ -0,0 +1,57 @@
import { json } from '@sveltejs/kit';
import { chmodContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { path, mode, recursive } = body;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (!mode || typeof mode !== 'string') {
return json({ error: 'Mode is required (e.g., "755" or "u+x")' }, { status: 400 });
}
await chmodContainerPath(params.id, path, mode, recursive === true, envIdNum);
return json({ success: true, path, mode, recursive: recursive === true });
} catch (error: any) {
console.error('Error changing permissions:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}
if (msg.includes('Invalid chmod mode')) {
return json({ error: msg }, { status: 400 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('Operation not permitted')) {
return json({ error: 'Operation not permitted' }, { status: 403 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to change permissions: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,116 @@
import { json } from '@sveltejs/kit';
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
const content = await readContainerFile(
params.id,
path,
envIdNum
);
// Check if content is too large
if (content.length > MAX_FILE_SIZE) {
return json({ error: 'File is too large to edit (max 1MB)' }, { status: 413 });
}
return json({ content, path });
} catch (error: any) {
console.error('Error reading container file:', error);
const msg = error.message || String(error);
if (msg.includes('No such file or directory')) {
return json({ error: 'File not found' }, { status: 404 });
}
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied to read this file' }, { status: 403 });
}
if (msg.includes('Is a directory')) {
return json({ error: 'Cannot read a directory' }, { status: 400 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to read file: ${msg}` }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
const body = await request.json();
if (typeof body.content !== 'string') {
return json({ error: 'Content is required' }, { status: 400 });
}
// Check content size
if (body.content.length > MAX_FILE_SIZE) {
return json({ error: 'Content is too large (max 1MB)' }, { status: 413 });
}
await writeContainerFile(
params.id,
path,
body.content,
envIdNum
);
return json({ success: true, path });
} catch (error: any) {
console.error('Error writing container file:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied to write this file' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Directory not found' }, { status: 404 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('No space left on device')) {
return json({ error: 'No space left on device' }, { status: 507 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to write file: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { path, type } = body;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (type !== 'file' && type !== 'directory') {
return json({ error: 'Type must be "file" or "directory"' }, { status: 400 });
}
if (type === 'file') {
await createContainerFile(params.id, path, envIdNum);
} else {
await createContainerDirectory(params.id, path, envIdNum);
}
return json({ success: true, path, type });
} catch (error: any) {
console.error('Error creating path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('File exists')) {
return json({ error: 'Path already exists' }, { status: 409 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Parent directory not found' }, { status: 404 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to create: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import { deleteContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
if (!path) {
return json({ error: 'Path is required' }, { status: 400 });
}
await deleteContainerPath(params.id, path, envIdNum);
return json({ success: true, path });
} catch (error: any) {
console.error('Error deleting path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}
if (msg.includes('Cannot delete critical')) {
return json({ error: msg }, { status: 400 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('Directory not empty')) {
return json({ error: 'Directory is not empty' }, { status: 400 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to delete: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,98 @@
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
if (!path) {
return new Response(JSON.stringify({ error: 'Path is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
try {
// Get format from query parameter (defaults to tar)
const format = url.searchParams.get('format') || 'tar';
// Get stat info to determine filename
let filename: string;
try {
const stat = await statContainerPath(params.id, path, envIdNum);
filename = stat.name || path.split('/').pop() || 'download';
} catch {
filename = path.split('/').pop() || 'download';
}
// Get the archive from Docker
const response = await getContainerArchive(
params.id,
path,
envIdNum
);
// Prepare response based on format
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
let contentType = 'application/x-tar';
let extension = '.tar';
if (format === 'tar.gz') {
// Compress with gzip using Bun's native implementation
const tarData = new Uint8Array(await response.arrayBuffer());
body = Bun.gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
}
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}${extension}"`
};
// Set content length for compressed data
if (body instanceof Uint8Array) {
headers['Content-Length'] = body.length.toString();
} else {
// Pass through content length for streaming tar
const contentLength = response.headers.get('Content-Length');
if (contentLength) {
headers['Content-Length'] = contentLength;
}
}
return new Response(body, { headers });
} catch (error: any) {
console.error('Error downloading container file:', error);
if (error.message?.includes('No such file or directory')) {
return new Response(JSON.stringify({ error: 'File not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
if (error.message?.includes('Permission denied')) {
return new Response(JSON.stringify({ error: 'Permission denied to access this path' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ error: 'Failed to download file' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import { renameContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { oldPath, newPath } = body;
if (!oldPath || typeof oldPath !== 'string') {
return json({ error: 'Old path is required' }, { status: 400 });
}
if (!newPath || typeof newPath !== 'string') {
return json({ error: 'New path is required' }, { status: 400 });
}
await renameContainerPath(params.id, oldPath, newPath, envIdNum);
return json({ success: true, oldPath, newPath });
} catch (error: any) {
console.error('Error renaming path:', error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (msg.includes('No such file or directory')) {
return json({ error: 'Source path not found' }, { status: 404 });
}
if (msg.includes('File exists') || msg.includes('Directory not empty')) {
return json({ error: 'Destination already exists' }, { status: 409 });
}
if (msg.includes('Read-only file system')) {
return json({ error: 'File system is read-only' }, { status: 403 });
}
if (msg.includes('container is not running')) {
return json({ error: 'Container is not running' }, { status: 400 });
}
return json({ error: `Failed to rename: ${msg}` }, { status: 500 });
}
};

View File

@@ -0,0 +1,154 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
/**
* Create a simple tar archive from a single file
* TAR format: 512-byte header followed by file content padded to 512 bytes
*/
function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
// TAR header is 512 bytes
const header = new Uint8Array(512);
const encoder = new TextEncoder();
// File name (100 bytes)
const nameBytes = encoder.encode(filename.slice(0, 99));
header.set(nameBytes, 0);
// File mode (8 bytes) - 0644
header.set(encoder.encode('0000644\0'), 100);
// Owner UID (8 bytes)
header.set(encoder.encode('0000000\0'), 108);
// Owner GID (8 bytes)
header.set(encoder.encode('0000000\0'), 116);
// File size in octal (12 bytes)
const sizeOctal = content.length.toString(8).padStart(11, '0');
header.set(encoder.encode(sizeOctal + '\0'), 124);
// Modification time (12 bytes) - current time in octal
const mtime = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0');
header.set(encoder.encode(mtime + '\0'), 136);
// Checksum placeholder (8 spaces initially)
header.set(encoder.encode(' '), 148);
// Type flag - '0' for regular file
header[156] = 48; // '0'
// Link name (100 bytes) - empty
// Magic (6 bytes) - 'ustar\0'
header.set(encoder.encode('ustar\0'), 257);
// Version (2 bytes) - '00'
header.set(encoder.encode('00'), 263);
// Owner name (32 bytes)
header.set(encoder.encode('root'), 265);
// Group name (32 bytes)
header.set(encoder.encode('root'), 297);
// Calculate checksum
let checksum = 0;
for (let i = 0; i < 512; i++) {
checksum += header[i];
}
const checksumOctal = checksum.toString(8).padStart(6, '0') + '\0 ';
header.set(encoder.encode(checksumOctal), 148);
// Calculate padding to 512-byte boundary
const paddingSize = (512 - (content.length % 512)) % 512;
const padding = new Uint8Array(paddingSize);
// End of archive marker (two 512-byte zero blocks)
const endMarker = new Uint8Array(1024);
// Combine all parts
const totalSize = header.length + content.length + paddingSize + endMarker.length;
const tar = new Uint8Array(totalSize);
let offset = 0;
tar.set(header, offset);
offset += header.length;
tar.set(content, offset);
offset += content.length;
tar.set(padding, offset);
offset += paddingSize;
tar.set(endMarker, offset);
return tar;
}
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (!path) {
return json({ error: 'Target path is required' }, { status: 400 });
}
try {
const formData = await request.formData();
const files = formData.getAll('files') as File[];
if (files.length === 0) {
return json({ error: 'No files provided' }, { status: 400 });
}
// For simplicity, we'll upload files one at a time
// A more sophisticated implementation could pack multiple files into one tar
const uploaded: string[] = [];
const errors: string[] = [];
for (const file of files) {
try {
const content = new Uint8Array(await file.arrayBuffer());
const tar = createTarArchive(file.name, content);
await putContainerArchive(
params.id,
path,
tar,
envId ? parseInt(envId) : undefined
);
uploaded.push(file.name);
} catch (err: any) {
errors.push(`${file.name}: ${err.message}`);
}
}
if (errors.length > 0 && uploaded.length === 0) {
return json({ error: 'Failed to upload files', details: errors }, { status: 500 });
}
return json({
success: true,
uploaded,
errors: errors.length > 0 ? errors : undefined
});
} catch (error: any) {
console.error('Error uploading to container:', error);
if (error.message?.includes('Permission denied')) {
return json({ error: 'Permission denied to write to this path' }, { status: 403 });
}
if (error.message?.includes('No such file or directory')) {
return json({ error: 'Target directory not found' }, { status: 404 });
}
return json({ error: 'Failed to upload files' }, { status: 500 });
}
};

View File

@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const containerData = await inspectContainer(params.id, envIdNum);
return json(containerData);
} catch (error) {
console.error('Failed to inspect container:', error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import { getContainerLogs } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const tail = parseInt(url.searchParams.get('tail') || '100');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const logs = await getContainerLogs(params.id, tail, envIdNum);
return json({ logs });
} catch (error: any) {
console.error('Error getting container logs:', error?.message || error, error?.stack);
return json({ error: 'Failed to get container logs', details: error?.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,452 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
// Detect Docker socket path
function detectDockerSocket(): string {
if (process.env.DOCKER_SOCKET && existsSync(process.env.DOCKER_SOCKET)) {
return process.env.DOCKER_SOCKET;
}
if (process.env.DOCKER_HOST?.startsWith('unix://')) {
const socketPath = process.env.DOCKER_HOST.replace('unix://', '');
if (existsSync(socketPath)) return socketPath;
}
const possibleSockets = [
'/var/run/docker.sock',
`${homedir()}/.docker/run/docker.sock`,
`${homedir()}/.orbstack/run/docker.sock`,
'/run/docker.sock'
];
for (const socket of possibleSockets) {
if (existsSync(socket)) return socket;
}
return '/var/run/docker.sock';
}
const socketPath = detectDockerSocket();
interface DockerClientConfig {
type: 'socket' | 'http' | 'https' | 'hawser-edge';
socketPath?: string;
host?: string;
port?: number;
ca?: string;
cert?: string;
key?: string;
hawserToken?: string;
environmentId?: number;
}
async function getDockerConfig(envId?: number | null): Promise<DockerClientConfig | null> {
if (!envId) {
return null;
}
const env = await getEnvironment(envId);
if (!env) {
return null;
}
if (env.connectionType === 'socket' || !env.connectionType) {
return { type: 'socket', socketPath: env.socketPath || socketPath };
}
if (env.connectionType === 'hawser-edge') {
return { type: 'hawser-edge', environmentId: envId };
}
const protocol = (env.protocol as 'http' | 'https') || 'http';
return {
type: protocol,
host: env.host || 'localhost',
port: env.port || 2375,
ca: env.tlsCa || undefined,
cert: env.tlsCert || undefined,
key: env.tlsKey || undefined,
hawserToken: env.connectionType === 'hawser-standard' ? env.hawserToken || undefined : undefined
};
}
/**
* Demultiplex Docker stream frame - returns payload and stream type
*/
function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size: number; payload: string } | null {
if (buffer.length < offset + 8) return null;
const streamType = buffer.readUInt8(offset);
const frameSize = buffer.readUInt32BE(offset + 4);
if (buffer.length < offset + 8 + frameSize) return null;
const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8');
return { type: streamType, size: 8 + frameSize, payload };
}
/**
* Handle logs streaming for Hawser Edge connections
*/
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise<Response> {
// Check if edge agent is connected
if (!isEdgeConnected(environmentId)) {
return new Response(JSON.stringify({ error: 'Edge agent not connected' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
// First, check if container has TTY enabled and get container name
let hasTty = false;
let containerName = containerId.substring(0, 12); // Default to short ID
try {
const inspectPath = `/containers/${containerId}/json`;
const inspectResponse = await sendEdgeRequest(environmentId, 'GET', inspectPath);
if (inspectResponse.statusCode === 200) {
const info = JSON.parse(inspectResponse.body as string);
hasTty = info.Config?.Tty ?? false;
// Get container name (strip leading /)
containerName = info.Name?.replace(/^\//, '') || containerName;
}
} catch {
// Ignore - default to demux mode
}
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
let controllerClosed = false;
let cancelStream: (() => void) | null = null;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// Send heartbeat to keep connection alive (every 5s for Traefik)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
// Buffer for non-TTY stream demuxing
let buffer = Buffer.alloc(0);
// Send connected event
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
// Start streaming logs via Edge
const { cancel } = sendEdgeStreamRequest(
environmentId,
'GET',
logsPath,
{
onData: (data: string, streamType?: 'stdout' | 'stderr') => {
if (controllerClosed) return;
if (hasTty) {
// TTY mode: data is raw text, may be base64 encoded
let text = data;
try {
// Try to decode as base64
text = Buffer.from(data, 'base64').toString('utf-8');
} catch {
// Not base64, use as-is
}
if (text) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
} else {
// Non-TTY mode: data might be base64 encoded Docker multiplexed stream
let rawData: Buffer;
try {
rawData = Buffer.from(data, 'base64');
} catch {
rawData = Buffer.from(data, 'utf-8');
}
buffer = Buffer.concat([buffer, rawData]);
// Process complete frames
let offset = 0;
while (true) {
const frame = parseDockerFrame(buffer, offset);
if (!frame) break;
if (frame.payload) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({
text: frame.payload,
containerName,
stream: frame.type === 2 ? 'stderr' : 'stdout'
})}\n\n`);
}
offset += frame.size;
}
// Keep remaining incomplete frame data
buffer = buffer.slice(offset);
}
},
onEnd: (reason?: string) => {
if (buffer.length > 0) {
const text = buffer.toString('utf-8');
if (text.trim()) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
}
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: reason || 'stream ended' })}\n\n`);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
},
onError: (error: string) => {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error })}\n\n`);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
}
}
);
cancelStream = cancel;
},
cancel() {
controllerClosed = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (cancelStream) {
cancelStream();
cancelStream = null;
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const containerId = params.id;
const tail = url.searchParams.get('tail') || '100';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'logs', envIdNum)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
const config = await getDockerConfig(envIdNum);
// Handle Hawser Edge mode separately
if (config.type === 'hawser-edge') {
return handleEdgeLogsStream(containerId, tail, config.environmentId!);
}
// First, check if container has TTY enabled and get container name
let hasTty = false;
let containerName = containerId.substring(0, 12); // Default to short ID
try {
const inspectPath = `/containers/${containerId}/json`;
let inspectResponse: Response;
if (config.type === 'socket') {
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath
});
} else {
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
const inspectHeaders: Record<string, string> = {};
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
inspectResponse = await fetch(inspectUrl, {
headers: inspectHeaders,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
}
if (inspectResponse.ok) {
const info = await inspectResponse.json();
hasTty = info.Config?.Tty ?? false;
// Get container name (strip leading /)
containerName = info.Name?.replace(/^\//, '') || containerName;
}
} catch {
// Ignore - default to demux mode
}
// Build the logs URL with follow=true for streaming
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}&timestamps=true`;
let controllerClosed = false;
let abortController: AbortController | null = new AbortController();
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// Send heartbeat to keep connection alive (every 5s for Traefik)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
try {
let response: Response;
if (config.type === 'socket') {
response = await fetch(`http://localhost${logsPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath,
signal: abortController?.signal
});
} else {
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
const logsHeaders: Record<string, string> = {};
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
response = await fetch(logsUrl, {
headers: logsHeaders,
signal: abortController?.signal,
// @ts-ignore
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
});
}
if (!response.ok) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: `Docker API error: ${response.status}` })}\n\n`);
if (!controllerClosed) controller.close();
return;
}
// Send connected event
safeEnqueue(`event: connected\ndata: ${JSON.stringify({ containerId, containerName, hasTty })}\n\n`);
const reader = response.body?.getReader();
if (!reader) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'No response body' })}\n\n`);
if (!controllerClosed) controller.close();
return;
}
let buffer = Buffer.alloc(0);
while (!controllerClosed) {
const { done, value } = await reader.read();
if (done) {
// Send any remaining buffer content
if (buffer.length > 0) {
const text = buffer.toString('utf-8');
if (text.trim()) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
}
safeEnqueue(`event: end\ndata: ${JSON.stringify({ reason: 'stream ended' })}\n\n`);
break;
}
if (value) {
if (hasTty) {
// TTY mode: raw text, no demux needed
const text = new TextDecoder().decode(value);
if (text) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text, containerName })}\n\n`);
}
} else {
// Non-TTY mode: demux Docker stream frames
buffer = Buffer.concat([buffer, Buffer.from(value)]);
// Process complete frames
let offset = 0;
while (true) {
const frame = parseDockerFrame(buffer, offset);
if (!frame) break;
// Stream type 1 = stdout, 2 = stderr
if (frame.payload) {
safeEnqueue(`event: log\ndata: ${JSON.stringify({ text: frame.payload, containerName, stream: frame.type === 2 ? 'stderr' : 'stdout' })}\n\n`);
}
offset += frame.size;
}
// Keep remaining incomplete frame data
buffer = buffer.slice(offset);
}
}
}
reader.releaseLock();
} catch (error) {
if (!controllerClosed) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (!errorMsg.includes('abort')) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: errorMsg })}\n\n`);
}
}
}
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
},
cancel() {
controllerClosed = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
abortController?.abort();
abortController = null;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { pauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (pause/unpause uses 'stop' permission)
if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await pauseContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'pause', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Failed to pause container:', error);
return json({ error: 'Failed to pause container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,53 @@
import { json } from '@sveltejs/kit';
import { renameContainer, inspectContainer } from '$lib/server/docker';
import { renameAutoUpdateSchedule } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (renaming requires create permission)
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { name } = await request.json();
if (!name || typeof name !== 'string') {
return json({ error: 'New name is required' }, { status: 400 });
}
// Get old container name before renaming
let oldName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
oldName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Container might not exist or other error, use ID
}
await renameContainer(params.id, name, envIdNum);
// Audit log
await auditContainer(event, 'rename', params.id, name, envIdNum, { previousId: params.id, newName: name });
// Update schedule if exists
try {
await renameAutoUpdateSchedule(oldName, name, envIdNum);
} catch (error) {
console.error('Failed to update schedule name:', error);
// Don't fail the rename if schedule update fails
}
return json({ success: true });
} catch (error) {
console.error('Error renaming container:', error);
return json({ error: 'Failed to rename container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import { restartContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'restart', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
// Get container name for audit
let containerName = params.id;
try {
const details = await inspectContainer(params.id, envIdNum);
containerName = details.Name?.replace(/^\//, '') || params.id;
} catch {
// Use ID if can't get name
}
await restartContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'restart', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error restarting container:', error);
return json({ error: 'Failed to restart container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { startContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
await startContainer(params.id, envIdNum);
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
// Audit log
await auditContainer(event, 'start', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error starting container:', error);
return json({ error: 'Failed to start container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,91 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getContainerStats } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
let rx = 0;
let tx = 0;
if (stats.networks) {
for (const iface of Object.values(stats.networks) as any[]) {
rx += iface.rx_bytes || 0;
tx += iface.tx_bytes || 0;
}
}
return { rx, tx };
}
function calculateBlockIO(stats: any): { read: number; write: number } {
let read = 0;
let write = 0;
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
if (Array.isArray(ioStats)) {
for (const entry of ioStats) {
if (entry.op === 'read' || entry.op === 'Read') {
read += entry.value || 0;
} else if (entry.op === 'write' || entry.op === 'Write') {
write += entry.value || 0;
}
}
}
return { read, write };
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (stats uses view permission)
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json({ error: 'No environment configured' }, { status: 404 });
}
try {
const stats = await getContainerStats(params.id, envIdNum) as any;
const cpuPercent = calculateCpuPercent(stats);
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
return json({
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
networkTx: networkIO.tx,
blockRead: blockIO.read,
blockWrite: blockIO.write,
timestamp: Date.now()
});
} catch (error: any) {
console.error('Failed to get container stats:', error);
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
}
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { stopContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'stop', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await stopContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'stop', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error stopping container:', error);
return json({ error: 'Failed to stop container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,73 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { execInContainer, getContainerTop } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
const lines = output.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) return null;
const headerLine = lines[0];
const headers = headerLine.trim().split(/\s+/);
// Find the index of COMMAND (last column, can have spaces)
const commandIndex = headers.findIndex(h => h === 'COMMAND' || h === 'CMD');
const processes = lines.slice(1).map(line => {
const parts = line.trim().split(/\s+/);
// COMMAND can have spaces, so join everything from commandIndex onwards
if (commandIndex !== -1 && parts.length > commandIndex) {
const beforeCommand = parts.slice(0, commandIndex);
const command = parts.slice(commandIndex).join(' ');
return [...beforeCommand, command];
}
return parts;
});
return { Titles: headers, Processes: processes };
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (process list uses inspect permission)
if (auth.authEnabled && !await auth.can('containers', 'inspect', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Try different ps commands in order of preference
const psCommands = [
['ps', 'aux', '--sort=-pcpu'], // GNU ps with CPU sort
['ps', 'aux'], // GNU ps without sort
['ps', '-ef'], // POSIX ps
];
for (const cmd of psCommands) {
try {
const output = await execInContainer(params.id, cmd, envIdNum);
// Check if output looks like an error message (BusyBox error, etc.)
if (output.includes('unrecognized option') || output.includes('Usage:') || output.includes('BusyBox')) {
continue;
}
const result = parsePsOutput(output);
if (result && result.Processes.length > 0) {
return json({ ...result, source: 'ps' });
}
} catch {
// Try next command
}
}
// Fallback to docker top API
const top = await getContainerTop(params.id, envIdNum);
return json({ ...top, source: 'top' });
} catch (error: any) {
console.error('Failed to get container processes:', error);
return json({ error: error.message || 'Failed to get processes' }, { status: 500 });
}
};

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (unpause uses 'start' permission)
if (auth.authEnabled && !await auth.can('containers', 'start', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const details = await inspectContainer(params.id, envIdNum);
const containerName = details.Name.replace(/^\//, '');
await unpauseContainer(params.id, envIdNum);
// Audit log
await auditContainer(event, 'unpause', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Failed to unpause container:', error);
return json({ error: 'Failed to unpause container' }, { status: 500 });
}
};

View File

@@ -0,0 +1,43 @@
import { json } from '@sveltejs/kit';
import { updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context (update requires create permission)
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { startAfterUpdate, ...options } = body;
console.log(`Updating container ${params.id} with name: ${options.name}`);
const container = await updateContainer(params.id, options, startAfterUpdate, envIdNum);
// Clear pending update indicator (if any) since container was just updated
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, params.id).catch(() => {
// Ignore errors - record may not exist
});
}
// Audit log - include full options to see what was modified
await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate });
return json({ success: true, id: container.id });
} catch (error) {
console.error('Error updating container:', error);
return json({ error: 'Failed to update container', details: String(error) }, { status: 500 });
}
};

View File

@@ -0,0 +1,548 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
listContainers,
inspectContainer,
stopContainer,
removeContainer,
createContainer,
pullImage,
getTempImageTag,
isDigestBasedImage,
getImageIdByTag,
removeTempImage,
tagImage
} from '$lib/server/docker';
import { auditContainer } from '$lib/server/audit';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
export interface ScanResult {
critical: number;
high: number;
medium: number;
low: number;
negligible?: number;
unknown?: number;
}
export interface ScannerResult extends ScanResult {
scanner: 'grype' | 'trivy';
}
export interface UpdateProgress {
type: 'start' | 'progress' | 'pull_log' | 'scan_start' | 'scan_log' | 'scan_complete' | 'blocked' | 'complete' | 'error';
containerId?: string;
containerName?: string;
step?: 'pulling' | 'scanning' | 'stopping' | 'removing' | 'creating' | 'starting' | 'done' | 'failed' | 'blocked' | 'skipped';
message?: string;
current?: number;
total?: number;
success?: boolean;
error?: string;
summary?: {
total: number;
success: number;
failed: number;
blocked: number;
skipped: number;
};
// Pull log specific fields
pullStatus?: string;
pullId?: string;
pullProgress?: string;
// Scan specific fields
scanResult?: ScanResult;
scannerResults?: ScannerResult[];
blockReason?: string;
scanner?: string;
}
/**
* Batch update containers with streaming progress.
* Expects JSON body: { containerIds: string[], vulnerabilityCriteria?: VulnerabilityCriteria }
*/
export const POST: RequestHandler = async (event) => {
const { url, cookies, request } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Need create permission to recreate containers
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
let body: { containerIds: string[]; vulnerabilityCriteria?: VulnerabilityCriteria };
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { containerIds, vulnerabilityCriteria = 'never' } = body;
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
return json({ error: 'containerIds array is required' }, { status: 400 });
}
const encoder = new TextEncoder();
let controllerClosed = false;
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (data: UpdateProgress) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
controllerClosed = true;
}
}
};
// Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection
keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
}
}, 5000);
let successCount = 0;
let failCount = 0;
let blockedCount = 0;
let skippedCount = 0;
// Get scanner settings for this environment
const scannerSettings = await getScannerSettings(envIdNum);
// Scan if scanning is enabled (scanner !== 'none')
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
const shouldScan = scannerSettings.scanner !== 'none';
// Send start event
safeEnqueue({
type: 'start',
total: containerIds.length,
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
});
// Process containers sequentially
for (let i = 0; i < containerIds.length; i++) {
const containerId = containerIds[i];
let containerName = 'unknown';
try {
// Find container
const containers = await listContainers(true, envIdNum);
const container = containers.find(c => c.id === containerId);
if (!container) {
safeEnqueue({
type: 'progress',
containerId,
containerName: 'unknown',
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Container not found'
});
failCount++;
continue;
}
containerName = container.name;
// Get full container config
const inspectData = await inspectContainer(containerId, envIdNum) as any;
const wasRunning = inspectData.State.Running;
const config = inspectData.Config;
const hostConfig = inspectData.HostConfig;
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(imageName)) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - cannot update Dockhand itself`
});
skippedCount++;
continue;
}
// Step 1: Pull latest image
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'pulling',
current: i + 1,
total: containerIds.length,
message: `Pulling ${imageName}...`
});
try {
await pullImage(imageName, (data: any) => {
// Send pull progress as log entries
if (data.status) {
safeEnqueue({
type: 'pull_log',
containerId,
containerName,
pullStatus: data.status,
pullId: data.id,
pullProgress: data.progress
});
}
}, envIdNum);
} catch (pullError: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Pull failed: ${pullError.message}`
});
failCount++;
continue;
}
// SAFE-PULL FLOW with vulnerability scanning
if (shouldScan && !isDigestBasedImage(imageName)) {
const tempTag = getTempImageTag(imageName);
// Get new image ID
const newImageId = await getImageIdByTag(imageName, envIdNum);
if (!newImageId) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Failed to get new image ID after pull'
});
failCount++;
continue;
}
// Restore original tag to old image (safety)
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
try {
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
} catch {
// Ignore - old image might have been removed
}
// Tag new image with temp suffix
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
// Step 2: Scan temp image
safeEnqueue({
type: 'scan_start',
containerId,
containerName,
step: 'scanning',
current: i + 1,
total: containerIds.length,
message: `Scanning ${imageName} for vulnerabilities...`
});
let scanBlocked = false;
let blockReason = '';
let finalScanResult: ScanResult | undefined;
let individualScannerResults: ScannerResult[] = [];
try {
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
if (progress.message) {
safeEnqueue({
type: 'scan_log',
containerId,
containerName,
scanner: progress.scanner,
message: progress.message
});
}
});
if (scanResults.length > 0) {
const scanSummary = combineScanSummaries(scanResults);
finalScanResult = {
critical: scanSummary.critical,
high: scanSummary.high,
medium: scanSummary.medium,
low: scanSummary.low,
negligible: scanSummary.negligible,
unknown: scanSummary.unknown
};
// Build individual scanner results
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
high: result.summary.high,
medium: result.summary.medium,
low: result.summary.low,
negligible: result.summary.negligible,
unknown: result.summary.unknown
}));
// Save scan results
for (const result of scanResults) {
try {
await saveVulnerabilityScan({
environmentId: envIdNum,
imageId: newImageId,
imageName: result.imageName,
scanner: result.scanner,
scannedAt: result.scannedAt,
scanDuration: result.scanDuration,
criticalCount: result.summary.critical,
highCount: result.summary.high,
mediumCount: result.summary.medium,
lowCount: result.summary.low,
negligibleCount: result.summary.negligible,
unknownCount: result.summary.unknown,
vulnerabilities: result.vulnerabilities,
error: result.error ?? null
});
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
}
}
safeEnqueue({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
: 'Scan complete: no vulnerabilities found'
});
} catch (scanErr: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Scan failed: ${scanErr.message}`
});
// Clean up temp image on scan failure
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
failCount++;
continue;
}
if (scanBlocked) {
// BLOCKED - Remove temp image and skip this container
safeEnqueue({
type: 'blocked',
containerId,
containerName,
step: 'blocked',
current: i + 1,
total: containerIds.length,
success: false,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
});
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
blockedCount++;
continue;
}
// APPROVED - Re-tag to original
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
try {
await removeTempImage(tempTag, envIdNum);
} catch { /* ignore cleanup errors */ }
}
// Step 3: Stop container if running
if (wasRunning) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'stopping',
current: i + 1,
total: containerIds.length,
message: `Stopping ${containerName}...`
});
await stopContainer(containerId, envIdNum);
}
// Step 4: Remove old container
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'removing',
current: i + 1,
total: containerIds.length,
message: `Removing old container ${containerName}...`
});
await removeContainer(containerId, true, envIdNum);
// Prepare port bindings
const ports: { [key: string]: { HostPort: string } } = {};
if (hostConfig.PortBindings) {
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
if (bindings && (bindings as any[]).length > 0) {
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
}
}
}
// Step 5: Create new container
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
message: `Creating new container ${containerName}...`
});
const newContainer = await createContainer({
name: containerName,
image: imageName,
ports,
volumeBinds: hostConfig.Binds || [],
env: config.Env || [],
labels: config.Labels || {},
cmd: config.Cmd || undefined,
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
networkMode: hostConfig.NetworkMode || undefined
}, envIdNum);
// Step 6: Start if was running
if (wasRunning) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'starting',
current: i + 1,
total: containerIds.length,
message: `Starting ${containerName}...`
});
await newContainer.start();
}
// Audit log
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
// Done with this container - use original containerId for UI consistency
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'done',
current: i + 1,
total: containerIds.length,
success: true,
message: `${containerName} updated successfully`
});
successCount++;
// Clear pending update indicator from database
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
// Ignore errors - record may not exist
});
}
} catch (error: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: error.message
});
failCount++;
}
}
// Send complete event
safeEnqueue({
type: 'complete',
summary: {
total: containerIds.length,
success: successCount,
failed: failCount,
blocked: blockedCount,
skipped: skippedCount
},
message: skippedCount > 0 || blockedCount > 0
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
: `Updated ${successCount} of ${containerIds.length} containers`
});
clearInterval(keepaliveInterval);
controller.close();
},
cancel() {
controllerClosed = true;
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};

View File

@@ -0,0 +1,154 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
listContainers,
inspectContainer,
stopContainer,
removeContainer,
createContainer,
pullImage
} from '$lib/server/docker';
import { auditContainer } from '$lib/server/audit';
export interface BatchUpdateResult {
containerId: string;
containerName: string;
success: boolean;
error?: string;
}
/**
* Batch update containers by recreating them with latest images.
* Expects JSON body: { containerIds: string[] }
*/
export const POST: RequestHandler = async (event) => {
const { url, cookies, request } = event;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Need create permission to recreate containers
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { containerIds } = body as { containerIds: string[] };
if (!containerIds || !Array.isArray(containerIds) || containerIds.length === 0) {
return json({ error: 'containerIds array is required' }, { status: 400 });
}
const results: BatchUpdateResult[] = [];
// Process containers sequentially to avoid resource conflicts
for (const containerId of containerIds) {
try {
const containers = await listContainers(true, envIdNum);
const container = containers.find(c => c.id === containerId);
if (!container) {
results.push({
containerId,
containerName: 'unknown',
success: false,
error: 'Container not found'
});
continue;
}
// Get full container config
const inspectData = await inspectContainer(containerId, envIdNum) as any;
const wasRunning = inspectData.State.Running;
const config = inspectData.Config;
const hostConfig = inspectData.HostConfig;
const imageName = config.Image;
const containerName = container.name;
// Pull latest image first
try {
await pullImage(imageName, undefined, envIdNum);
} catch (pullError: any) {
results.push({
containerId,
containerName,
success: false,
error: `Pull failed: ${pullError.message}`
});
continue;
}
// Stop container if running
if (wasRunning) {
await stopContainer(containerId, envIdNum);
}
// Remove old container
await removeContainer(containerId, true, envIdNum);
// Prepare port bindings
const ports: { [key: string]: { HostPort: string } } = {};
if (hostConfig.PortBindings) {
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
if (bindings && (bindings as any[]).length > 0) {
ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
}
}
}
// Create new container
const newContainer = await createContainer({
name: containerName,
image: imageName,
ports,
volumeBinds: hostConfig.Binds || [],
env: config.Env || [],
labels: config.Labels || {},
cmd: config.Cmd || undefined,
restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
networkMode: hostConfig.NetworkMode || undefined
}, envIdNum);
// Start if was running
if (wasRunning) {
await newContainer.start();
}
// Audit log
await auditContainer(event, 'update', newContainer.id, containerName, envIdNum, { batchUpdate: true });
results.push({
containerId: newContainer.id,
containerName,
success: true
});
} catch (error: any) {
results.push({
containerId,
containerName: 'unknown',
success: false,
error: error.message
});
}
}
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
return json({
success: failCount === 0,
results,
summary: {
total: results.length,
success: successCount,
failed: failCount
}
});
} catch (error: any) {
console.error('Error in batch update:', error);
return json({ error: 'Failed to batch update containers', details: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,111 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
export interface UpdateCheckResult {
containerId: string;
containerName: string;
imageName: string;
hasUpdate: boolean;
currentDigest?: string;
newDigest?: string;
error?: string;
isLocalImage?: boolean;
}
/**
* Check all containers for available image updates.
* Returns all results at once after checking in parallel.
*/
export const POST: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Need at least view permission
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Clear existing pending updates for this environment before checking
if (envIdNum) {
await clearPendingContainerUpdates(envIdNum);
}
const containers = await listContainers(true, envIdNum);
// Check container for updates
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
try {
// Get container's image name from config
const inspectData = await inspectContainer(container.id, envIdNum) as any;
const imageName = inspectData.Config?.Image;
const currentImageId = inspectData.Image;
if (!imageName) {
return {
containerId: container.id,
containerName: container.name,
imageName: container.image,
hasUpdate: false,
error: 'Could not determine image name'
};
}
// Use shared update detection function
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
return {
containerId: container.id,
containerName: container.name,
imageName,
hasUpdate: result.hasUpdate,
currentDigest: result.currentDigest,
newDigest: result.registryDigest,
error: result.error,
isLocalImage: result.isLocalImage
};
} catch (error: any) {
return {
containerId: container.id,
containerName: container.name,
imageName: container.image,
hasUpdate: false,
error: error.message
};
}
};
// Check all containers in parallel
const results = await Promise.all(containers.map(checkContainer));
const updatesFound = results.filter(r => r.hasUpdate).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
for (const result of results) {
if (result.hasUpdate) {
await addPendingContainerUpdate(
envIdNum,
result.containerId,
result.containerName,
result.imageName
);
}
}
}
return json({
total: containers.length,
updatesFound,
results
});
} catch (error: any) {
console.error('Error checking for updates:', error);
return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,67 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db';
/**
* Get pending container updates for an environment.
*/
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum) {
return json({ error: 'Environment ID required' }, { status: 400 });
}
// Need at least view permission
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const pendingUpdates = await getPendingContainerUpdates(envIdNum);
return json({
environmentId: envIdNum,
pendingUpdates: pendingUpdates.map(u => ({
containerId: u.containerId,
containerName: u.containerName,
currentImage: u.currentImage,
checkedAt: u.checkedAt
}))
});
} catch (error: any) {
console.error('Error getting pending updates:', error);
return json({ error: 'Failed to get pending updates', details: error.message }, { status: 500 });
}
};
/**
* Remove a pending container update (e.g., after manual update).
*/
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const containerId = url.searchParams.get('containerId');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum || !containerId) {
return json({ error: 'Environment ID and container ID required' }, { status: 400 });
}
// Need manage permission to delete
if (auth.authEnabled && !await auth.can('containers', 'manage', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
await removePendingContainerUpdate(envIdNum, containerId);
return json({ success: true });
} catch (error: any) {
console.error('Error removing pending update:', error);
return json({ error: 'Failed to remove pending update', details: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listContainersWithSize } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const sizes = await listContainersWithSize(true, envIdNum);
return json(sizes);
} catch (error) {
console.error('Failed to get container sizes:', error);
return json({}, { status: 500 });
}
};

View File

@@ -0,0 +1,148 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listContainers, getContainerStats } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { ContainerStats } from '$lib/types';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
let rx = 0;
let tx = 0;
if (stats.networks) {
for (const iface of Object.values(stats.networks) as any[]) {
rx += iface.rx_bytes || 0;
tx += iface.tx_bytes || 0;
}
}
return { rx, tx };
}
function calculateBlockIO(stats: any): { read: number; write: number } {
let read = 0;
let write = 0;
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
if (Array.isArray(ioStats)) {
for (const entry of ioStats) {
if (entry.op === 'read' || entry.op === 'Read') {
read += entry.value || 0;
} else if (entry.op === 'write' || entry.op === 'Write') {
write += entry.value || 0;
}
}
}
return { read, write };
}
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
]);
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const debugContainer = url.searchParams.get('debug'); // Get raw stats for specific container
// Permission check with environment context
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json([]);
}
// Early return if no environment specified
if (!envIdNum) {
return json([]);
}
try {
// Get all running containers with timeout
const containers = await withTimeout(
listContainers(true, envIdNum),
5000, // 5 second timeout
[]
);
const runningContainers = containers.filter(c => c.state === 'running');
// Debug mode: return raw stats for specific container
if (debugContainer) {
const container = runningContainers.find(c => c.name === debugContainer);
if (container) {
const rawStats = await getContainerStats(container.id, envIdNum);
return json({
name: container.name,
memory_stats: (rawStats as any).memory_stats
});
}
return json({ error: 'Container not found' }, { status: 404 });
}
// Get stats for each running container (in parallel with timeout)
const statsPromises = runningContainers.map(async (container) => {
try {
const stats = await withTimeout(
getContainerStats(container.id, envIdNum) as Promise<any>,
3000, // 3 second timeout per container
null
);
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
// Use raw memory usage (total memory attributed to container)
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
return {
id: container.id,
name: container.name,
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
networkTx: networkIO.tx,
blockRead: blockIO.read,
blockWrite: blockIO.write
};
} catch (err) {
// Silently skip failed containers
return null;
}
});
const allStats = await Promise.all(statsPromises);
const validStats = allStats.filter((s): s is ContainerStats => s !== null);
return json(validStats);
} catch (error: any) {
console.error('Failed to get container stats:', error);
return json([], { status: 200 }); // Return empty array instead of error
}
};

View File

@@ -0,0 +1,53 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getDashboardPreferences, saveDashboardPreferences } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
try {
// Get user-specific preferences, or fall back to global preferences
const userId = auth.user?.id ?? null;
const prefs = await getDashboardPreferences(userId);
// If no preferences exist, return empty gridLayout
if (!prefs) {
return json({
id: 0,
userId: null,
gridLayout: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
return json(prefs);
} catch (error) {
console.error('Failed to get dashboard preferences:', error);
return json({ error: 'Failed to get dashboard preferences' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
try {
const body = await request.json();
const { gridLayout } = body;
if (!gridLayout || !Array.isArray(gridLayout)) {
return json({ error: 'gridLayout is required and must be an array' }, { status: 400 });
}
const userId = auth.user?.id ?? null;
const prefs = await saveDashboardPreferences({
userId,
gridLayout
});
return json(prefs);
} catch (error) {
console.error('Failed to save dashboard preferences:', error);
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
}
};

View File

@@ -0,0 +1,307 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import {
getEnvironments,
getLatestHostMetrics,
getContainerEventStats,
getEnvSetting,
hasEnvironments,
getEnvUpdateCheckSettings
} from '$lib/server/db';
import {
listContainers,
listImages,
listVolumes,
listNetworks,
getDockerInfo,
getDiskUsage
} from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { parseLabels } from '$lib/utils/label-colors';
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
]);
}
// Loading states for progressive tile updates
export interface LoadingStates {
containers?: boolean;
images?: boolean;
volumes?: boolean;
networks?: boolean;
stacks?: boolean;
diskUsage?: boolean;
topContainers?: boolean;
}
export interface EnvironmentStats {
id: number;
name: string;
host?: string;
port?: number | null;
icon: string;
socketPath?: string;
collectActivity: boolean;
collectMetrics: boolean;
scannerEnabled: boolean;
updateCheckEnabled: boolean;
updateCheckAutoUpdate: boolean;
labels?: string[];
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
online: boolean;
error?: string;
containers: {
total: number;
running: number;
stopped: number;
paused: number;
restarting: number;
unhealthy: number;
};
images: {
total: number;
totalSize: number;
};
volumes: {
total: number;
totalSize: number;
};
containersSize: number;
buildCacheSize: number;
networks: {
total: number;
};
stacks: {
total: number;
running: number;
partial: number;
stopped: number;
};
metrics: {
cpuPercent: number;
memoryPercent: number;
memoryUsed: number;
memoryTotal: number;
} | null;
events: {
total: number;
today: number;
};
topContainers: Array<{
id: string;
name: string;
cpuPercent: number;
memoryPercent: number;
}>;
metricsHistory?: Array<{
cpu_percent: number;
memory_percent: number;
timestamp: string;
}>;
recentEvents?: Array<{
container_name: string;
action: string;
timestamp: string;
}>;
// Progressive loading states
loading?: LoadingStates;
}
export const GET: RequestHandler = async ({ cookies, url }) => {
const auth = await authorize(cookies);
// Support single environment query for real-time updates
const envIdParam = url.searchParams.get('env');
const envIdNum = envIdParam ? parseInt(envIdParam) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('environments', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json([]);
}
try {
let environments = await getEnvironments();
// Filter to single environment if specified
if (envIdNum) {
environments = environments.filter(env => env.id === envIdNum);
if (environments.length === 0) {
return json({ error: 'Environment not found' }, { status: 404 });
}
}
// In enterprise mode, filter environments by user's accessible environments
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
const accessibleIds = await auth.getAccessibleEnvironmentIds();
// accessibleIds is null if user has access to all environments
if (accessibleIds !== null) {
environments = environments.filter(env => accessibleIds.includes(env.id));
}
}
// Fetch stats for all environments in parallel
const promises = environments.map(async (env): Promise<EnvironmentStats> => {
const envStats: EnvironmentStats = {
id: env.id,
name: env.name,
host: env.host ?? undefined,
port: env.port ?? undefined,
icon: env.icon || 'globe',
socketPath: env.socketPath ?? undefined,
collectActivity: env.collectActivity,
collectMetrics: env.collectMetrics ?? true,
scannerEnabled: false,
updateCheckEnabled: false,
updateCheckAutoUpdate: false,
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
buildCacheSize: 0,
networks: { total: 0 },
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
metrics: null,
events: { total: 0, today: 0 },
topContainers: []
};
try {
// Check scanner settings - scanner type is stored in 'vulnerability_scanner'
const scannerType = await getEnvSetting('vulnerability_scanner', env.id);
envStats.scannerEnabled = scannerType && scannerType !== 'none';
// Check update check settings
const updateCheckSettings = await getEnvUpdateCheckSettings(env.id);
if (updateCheckSettings && updateCheckSettings.enabled) {
envStats.updateCheckEnabled = true;
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
}
// Check if Docker is accessible (with 5 second timeout)
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
if (!dockerInfo) {
envStats.error = 'Connection timeout or Docker not accessible';
return envStats;
}
envStats.online = true;
// Fetch all data in parallel (with 10 second timeout per operation)
const [containers, images, volumes, networks, stacks, diskUsage] = await Promise.all([
withTimeout(listContainers(true, env.id).catch(() => []), 10000, []),
withTimeout(listImages(env.id).catch(() => []), 10000, []),
withTimeout(listVolumes(env.id).catch(() => []), 10000, []),
withTimeout(listNetworks(env.id).catch(() => []), 10000, []),
withTimeout(listComposeStacks(env.id).catch(() => []), 10000, []),
withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null)
]);
// Process containers
envStats.containers.total = containers.length;
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
// Helper to get valid size (Docker API returns -1 for uncalculated sizes)
const getValidSize = (size: number | undefined | null): number => {
return size && size > 0 ? size : 0;
};
// Process disk usage from /system/df for accurate size data
if (diskUsage) {
// Images: use Size from /system/df
envStats.images.total = diskUsage.Images?.length || images.length;
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || 0;
// Volumes: use UsageData.Size from /system/df
envStats.volumes.total = diskUsage.Volumes?.length || volumes.length;
envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0;
// Containers: use SizeRw (writable layer size)
envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0;
// Build cache: total size
envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0;
} else {
// Fallback to original method if /system/df failed
envStats.images.total = images.length;
envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0);
envStats.volumes.total = volumes.length;
envStats.volumes.totalSize = 0;
}
// Process networks
envStats.networks.total = networks.length;
// Process stacks
envStats.stacks.total = stacks.length;
envStats.stacks.running = stacks.filter((s: any) => s.status === 'running').length;
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
// Get latest metrics and event stats in parallel
const [latestMetrics, eventStats] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id)
]);
if (latestMetrics) {
envStats.metrics = {
cpuPercent: latestMetrics.cpuPercent,
memoryPercent: latestMetrics.memoryPercent,
memoryUsed: latestMetrics.memoryUsed,
memoryTotal: latestMetrics.memoryTotal
};
}
envStats.events = {
total: eventStats.total,
today: eventStats.today
};
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
envStats.error = 'Docker socket not accessible';
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
envStats.error = 'Connection lost';
} else if (errorStr.includes('verbose: true') || errorStr.includes('verbose')) {
envStats.error = 'Connection failed';
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
envStats.error = 'Connection timeout';
} else {
const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/);
envStats.error = match ? match[1].trim() : 'Connection error';
}
}
return envStats;
});
const results = await Promise.all(promises);
// Return single object if single env was requested
if (envIdParam && results.length === 1) {
return json(results[0]);
}
return json(results);
} catch (error: any) {
console.error('Failed to get dashboard stats:', error);
return json({ error: 'Failed to get dashboard stats' }, { status: 500 });
}
};

View File

@@ -0,0 +1,531 @@
import type { RequestHandler } from '@sveltejs/kit';
import {
getEnvironments,
getLatestHostMetrics,
getHostMetrics,
getContainerEventStats,
getContainerEvents,
getEnvSetting,
getEnvUpdateCheckSettings
} from '$lib/server/db';
import {
listContainers,
listImages,
listNetworks,
getDockerInfo,
getContainerStats,
getDiskUsage
} from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import type { EnvironmentStats } from '../+server';
import { parseLabels } from '$lib/utils/label-colors';
// Helper to add timeout to promises
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
]);
}
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
// Cache per environment with 5-minute TTL
interface DiskUsageCache {
data: any;
timestamp: number;
}
const diskUsageCache: Map<number, DiskUsageCache> = new Map();
const DISK_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_CACHE_SIZE = 100; // Maximum environments to cache
// Cleanup expired cache entries periodically to prevent unbounded growth
// Also limits cache size for environments that were deleted
setInterval(() => {
const now = Date.now();
// Remove expired entries
for (const [envId, cached] of diskUsageCache.entries()) {
if (now - cached.timestamp > DISK_CACHE_TTL_MS * 2) {
diskUsageCache.delete(envId);
}
}
// Enforce max size by removing oldest entries
if (diskUsageCache.size > MAX_CACHE_SIZE) {
const entries = Array.from(diskUsageCache.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = entries.slice(0, entries.length - MAX_CACHE_SIZE);
for (const [envId] of toRemove) {
diskUsageCache.delete(envId);
}
}
}, 10 * 60 * 1000); // Every 10 minutes
async function getCachedDiskUsage(envId: number): Promise<any> {
const cached = diskUsageCache.get(envId);
const now = Date.now();
// Return cached data if still valid
if (cached && (now - cached.timestamp) < DISK_CACHE_TTL_MS) {
return cached.data;
}
// Fetch fresh data with timeout
const data = await withTimeout(getDiskUsage(envId).catch(() => null), 30000, null);
// Only cache successful results - if fetch failed, retry on next request
if (data !== null) {
diskUsageCache.set(envId, { data, timestamp: now });
}
return data;
}
// Limit for per-container stats (reduced from 15 to improve performance)
const TOP_CONTAINERS_LIMIT = 8;
// Calculate CPU percentage from Docker stats (same logic as container stats endpoint)
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
// Progressive stats loading - returns stats object and emits partial updates via callback
async function getEnvironmentStatsProgressive(
env: any,
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void
): Promise<EnvironmentStats> {
const envStats: EnvironmentStats = {
id: env.id,
name: env.name,
host: env.host ?? undefined,
port: env.port ?? undefined,
icon: env.icon || 'globe',
socketPath: env.socketPath ?? undefined,
collectActivity: env.collectActivity,
collectMetrics: env.collectMetrics ?? true,
scannerEnabled: false,
updateCheckEnabled: false,
updateCheckAutoUpdate: false,
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
buildCacheSize: 0,
networks: { total: 0 },
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
metrics: null,
events: { total: 0, today: 0 },
topContainers: [],
recentEvents: [],
// Loading states for progressive display
loading: {
containers: true,
images: true,
volumes: true,
networks: true,
stacks: true,
diskUsage: true,
topContainers: true
}
};
try {
// Check scanner settings - scanner type is stored in 'vulnerability_scanner'
const scannerType = await getEnvSetting('vulnerability_scanner', env.id);
envStats.scannerEnabled = scannerType && scannerType !== 'none';
// Check update check settings
const updateCheckSettings = await getEnvUpdateCheckSettings(env.id);
if (updateCheckSettings && updateCheckSettings.enabled) {
envStats.updateCheckEnabled = true;
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
}
// Check if Docker is accessible (with 5 second timeout)
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
if (!dockerInfo) {
envStats.error = 'Connection timeout or Docker not accessible';
envStats.loading = undefined; // Clear loading states on error
// Send offline status to client
onPartialUpdate({
id: env.id,
online: false,
error: envStats.error,
loading: undefined
});
return envStats;
}
envStats.online = true;
// Get all database stats in parallel for better performance
const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(30, env.id)
]);
if (latestMetrics) {
envStats.metrics = {
cpuPercent: latestMetrics.cpuPercent,
memoryPercent: latestMetrics.memoryPercent,
memoryUsed: latestMetrics.memoryUsed,
memoryTotal: latestMetrics.memoryTotal
};
}
envStats.events = {
total: eventStats.total,
today: eventStats.today
};
if (recentEventsResult.events.length > 0) {
envStats.recentEvents = recentEventsResult.events.map(e => ({
container_name: e.containerName || 'unknown',
action: e.action,
timestamp: e.timestamp
}));
}
if (metricsHistory.length > 0) {
envStats.metricsHistory = metricsHistory.reverse().map(m => ({
cpu_percent: m.cpuPercent,
memory_percent: m.memoryPercent,
timestamp: m.timestamp
}));
}
// Send initial update with DB data and online status
onPartialUpdate({
id: env.id,
online: true,
metrics: envStats.metrics,
events: envStats.events,
recentEvents: envStats.recentEvents,
metricsHistory: envStats.metricsHistory,
scannerEnabled: envStats.scannerEnabled,
updateCheckEnabled: envStats.updateCheckEnabled,
updateCheckAutoUpdate: envStats.updateCheckAutoUpdate,
loading: { ...envStats.loading }
});
// Helper to get valid size
const getValidSize = (size: number | undefined | null): number => {
return size && size > 0 ? size : 0;
};
// PHASE 1: Containers (usually fast)
const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, [])
.then(async (containers) => {
envStats.containers.total = containers.length;
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
envStats.loading!.containers = false;
onPartialUpdate({
id: env.id,
containers: { ...envStats.containers },
loading: { ...envStats.loading! }
});
return containers;
});
// PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel
const imagesPromise = withTimeout(listImages(env.id).catch(() => []), 10000, [])
.then((images) => {
envStats.images.total = images.length;
envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0);
envStats.loading!.images = false;
onPartialUpdate({
id: env.id,
images: { ...envStats.images },
loading: { ...envStats.loading! }
});
return images;
});
const networksPromise = withTimeout(listNetworks(env.id).catch(() => []), 10000, [])
.then((networks) => {
envStats.networks.total = networks.length;
envStats.loading!.networks = false;
onPartialUpdate({
id: env.id,
networks: { ...envStats.networks },
loading: { ...envStats.loading! }
});
return networks;
});
const stacksPromise = withTimeout(listComposeStacks(env.id).catch(() => []), 10000, [])
.then((stacks) => {
envStats.stacks.total = stacks.length;
envStats.stacks.running = stacks.filter((s: any) => s.status === 'running').length;
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
envStats.loading!.stacks = false;
onPartialUpdate({
id: env.id,
stacks: { ...envStats.stacks },
loading: { ...envStats.loading! }
});
return stacks;
});
// PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance
const diskUsagePromise = getCachedDiskUsage(env.id)
.then((diskUsage) => {
if (diskUsage) {
// Update images with disk usage data (more accurate)
envStats.images.total = diskUsage.Images?.length || envStats.images.total;
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize;
// Volumes from disk usage
envStats.volumes.total = diskUsage.Volumes?.length || 0;
envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0;
// Containers disk size
envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0;
// Build cache
envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0;
}
envStats.loading!.volumes = false;
envStats.loading!.diskUsage = false;
onPartialUpdate({
id: env.id,
images: { ...envStats.images },
volumes: { ...envStats.volumes },
containersSize: envStats.containersSize,
buildCacheSize: envStats.buildCacheSize,
loading: { ...envStats.loading! }
});
return diskUsage;
});
// PHASE 4: Top containers (slow - requires per-container stats)
// Limited to TOP_CONTAINERS_LIMIT containers to reduce API calls
const topContainersPromise = containersPromise.then(async (containers) => {
const runningContainersList = containers.filter((c: any) => c.state === 'running');
const topContainersPromises = runningContainersList.slice(0, TOP_CONTAINERS_LIMIT).map(async (container: any) => {
try {
// 5 second timeout per container (increased from 2s for Hawser environments)
const stats = await withTimeout(
getContainerStats(container.id, env.id) as Promise<any>,
5000,
null
);
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
const memoryUsage = stats.memory_stats?.usage || 0;
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
return {
name: container.name,
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryPercent: Math.round(memoryPercent * 100) / 100
};
} catch {
return null;
}
});
const topContainersResults = await Promise.all(topContainersPromises);
envStats.topContainers = topContainersResults
.filter((c): c is { name: string; cpuPercent: number; memoryPercent: number } => c !== null)
.sort((a, b) => b.cpuPercent - a.cpuPercent)
.slice(0, 10);
envStats.loading!.topContainers = false;
onPartialUpdate({
id: env.id,
topContainers: [...envStats.topContainers],
loading: { ...envStats.loading! }
});
return envStats.topContainers;
});
// Wait for all to complete
await Promise.all([
containersPromise,
imagesPromise,
networksPromise,
stacksPromise,
diskUsagePromise,
topContainersPromise
]);
// Clear loading states when complete
envStats.loading = undefined;
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) {
envStats.error = 'Agent not connected';
} else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
envStats.error = 'Docker socket not accessible';
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
envStats.error = 'Connection lost';
} else if (errorStr.includes('verbose: true') || errorStr.includes('verbose')) {
envStats.error = 'Connection failed';
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
envStats.error = 'Connection timeout';
} else {
// Extract just the error message, not the full stack/details
const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/);
envStats.error = match ? match[1].trim() : 'Connection error';
}
envStats.loading = undefined;
// Send offline status to client
onPartialUpdate({
id: env.id,
online: false,
error: envStats.error,
loading: undefined
});
}
return envStats;
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
let environments = await getEnvironments();
// In enterprise mode, filter environments by user's accessible environments
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
const accessibleIds = await auth.getAccessibleEnvironmentIds();
// accessibleIds is null if user has access to all environments
if (accessibleIds !== null) {
environments = environments.filter(env => accessibleIds.includes(env.id));
}
}
// Create a readable stream that sends environment stats progressively
let controllerClosed = false;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Safe enqueue that checks if controller is still open
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
// First, send the list of environments so the UI can show skeletons with loading states
const envList = environments.map(env => ({
id: env.id,
name: env.name,
host: env.host ?? undefined,
port: env.port ?? undefined,
icon: env.icon || 'globe',
socketPath: env.socketPath ?? undefined,
collectActivity: env.collectActivity,
collectMetrics: env.collectMetrics ?? true,
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
// Initial loading state for all sections
loading: {
containers: true,
images: true,
volumes: true,
networks: true,
stacks: true,
diskUsage: true,
topContainers: true
}
}));
safeEnqueue(`event: environments\ndata: ${JSON.stringify(envList)}\n\n`);
// Fetch stats for each environment with progressive updates
const promises = environments.map(async (env) => {
try {
await getEnvironmentStatsProgressive(env, (partialStats) => {
// Send partial update as it arrives
safeEnqueue(`event: partial\ndata: ${JSON.stringify(partialStats)}\n\n`);
});
// Send final complete stats event for this environment
safeEnqueue(`event: complete\ndata: ${JSON.stringify({ id: env.id })}\n\n`);
} catch (error) {
console.error(`Failed to get stats for ${env.name}:`, error);
// Convert technical error to user-friendly message
const errorStr = String(error);
let friendlyError = 'Connection error';
if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
friendlyError = 'Docker socket not accessible';
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
friendlyError = 'Connection lost';
} else if (errorStr.includes('verbose') || errorStr.includes('typo')) {
friendlyError = 'Connection failed';
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
friendlyError = 'Connection timeout';
}
safeEnqueue(`event: error\ndata: ${JSON.stringify({ id: env.id, error: friendlyError })}\n\n`);
}
});
// Wait for all to complete
await Promise.all(promises);
// Send done event and close
if (!controllerClosed) {
safeEnqueue(`event: done\ndata: {}\n\n`);
try {
controller.close();
} catch {
// Already closed
}
}
},
cancel() {
// Called when the client disconnects
controllerClosed = true;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};

View File

@@ -0,0 +1,26 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import dependencies from '$lib/data/dependencies.json';
// External tools used by Dockhand (Docker images)
const externalTools = [
{
name: 'anchore/grype',
version: 'latest',
license: 'Apache-2.0',
repository: 'https://github.com/anchore/grype'
},
{
name: 'aquasec/trivy',
version: 'latest',
license: 'Apache-2.0',
repository: 'https://github.com/aquasecurity/trivy'
}
];
export const GET: RequestHandler = async () => {
// Combine npm dependencies with external tools, exclude dockhand itself
const allDependencies = [...dependencies, ...externalTools]
.filter((dep) => dep.name !== 'dockhand')
.sort((a, b) => a.name.localeCompare(b.name));
return json(allDependencies);
};

View File

@@ -0,0 +1,129 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
let environments = await getEnvironments();
// In enterprise mode, filter environments by user's accessible environments
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
const accessibleIds = await auth.getAccessibleEnvironmentIds();
// accessibleIds is null if user has access to all environments
if (accessibleIds !== null) {
environments = environments.filter(env => accessibleIds.includes(env.id));
}
}
// Get public IPs for all environments
const publicIps = await getEnvironmentPublicIps();
// Get update check settings for all environments
const updateCheckSettingsMap = new Map<number, { enabled: boolean; autoUpdate: boolean }>();
for (const env of environments) {
const settings = await getEnvUpdateCheckSettings(env.id);
if (settings && settings.enabled) {
updateCheckSettingsMap.set(env.id, { enabled: true, autoUpdate: settings.autoUpdate });
}
}
// Parse labels from JSON string to array, add public IPs, update check settings, and timezone
const envWithParsedLabels = await Promise.all(environments.map(async env => {
const updateSettings = updateCheckSettingsMap.get(env.id);
const timezone = await getEnvironmentTimezone(env.id);
return {
...env,
labels: parseLabels(env.labels as string | null),
publicIp: publicIps[env.id.toString()] || null,
updateCheckEnabled: updateSettings?.enabled || false,
updateCheckAutoUpdate: updateSettings?.autoUpdate || false,
timezone
};
}));
return json(envWithParsedLabels);
} catch (error) {
console.error('Failed to get environments:', error);
return json({ error: 'Failed to get environments' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
if (!data.name) {
return json({ error: 'Name is required' }, { status: 400 });
}
// Host is required for direct and hawser-standard connections
const connectionType = data.connectionType || 'socket';
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
return json({ error: 'Host is required for this connection type' }, { status: 400 });
}
// Validate labels
const labels = Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [];
const env = await createEnvironment({
name: data.name,
host: data.host,
port: data.port || 2375,
protocol: data.protocol || 'http',
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
icon: data.icon || 'globe',
socketPath: data.socketPath || '/var/run/docker.sock',
collectActivity: data.collectActivity !== false,
collectMetrics: data.collectMetrics !== false,
highlightChanges: data.highlightChanges !== false,
labels: serializeLabels(labels),
connectionType: connectionType,
hawserToken: data.hawserToken
});
// Save public IP if provided
if (data.publicIp) {
await setEnvironmentPublicIp(env.id, data.publicIp);
}
// Notify subprocesses to pick up the new environment
refreshSubprocessEnvironments();
// Auto-assign Admin role to creator (Enterprise only)
if (auth.isEnterprise && auth.authEnabled && auth.isAuthenticated && !auth.isAdmin) {
const user = auth.user;
if (user) {
try {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, env.id);
}
} catch (roleError) {
// Log but don't fail - environment was created successfully
console.error(`Failed to auto-assign Admin role to user ${user.id} for environment ${env.id}:`, roleError);
}
}
}
return json(env);
} catch (error) {
console.error('Failed to create environment:', error);
const message = error instanceof Error ? error.message : 'Failed to create environment';
return json({ error: message }, { status: 500 });
}
};

View File

@@ -0,0 +1,158 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
import { deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Get public IP for this environment
const publicIps = await getEnvironmentPublicIps();
const publicIp = publicIps[id.toString()] || null;
// Parse labels from JSON string to array
return json({
...env,
labels: parseLabels(env.labels as string | null),
publicIp
});
} catch (error) {
console.error('Failed to get environment:', error);
return json({ error: 'Failed to get environment' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
const data = await request.json();
// Clear cached Docker client before updating
clearDockerClientCache(id);
// Handle labels - only update if provided in the request
const labels = data.labels !== undefined
? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : [])
: undefined;
const env = await updateEnvironment(id, {
name: data.name,
host: data.host,
port: data.port,
protocol: data.protocol,
tlsCa: data.tlsCa,
tlsCert: data.tlsCert,
tlsKey: data.tlsKey,
icon: data.icon,
socketPath: data.socketPath,
collectActivity: data.collectActivity,
collectMetrics: data.collectMetrics,
highlightChanges: data.highlightChanges,
labels: labels,
connectionType: data.connectionType,
hawserToken: data.hawserToken
});
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Notify subprocesses if collectActivity or collectMetrics setting changed
if (data.collectActivity !== undefined || data.collectMetrics !== undefined) {
refreshSubprocessEnvironments();
}
// Handle public IP - update if provided in request
if (data.publicIp !== undefined) {
await setEnvironmentPublicIp(id, data.publicIp || null);
}
// Get current public IP for response
const publicIps = await getEnvironmentPublicIps();
const publicIp = publicIps[id.toString()] || null;
// Parse labels from JSON string to array
return json({
...env,
labels: parseLabels(env.labels as string | null),
publicIp
});
} catch (error) {
console.error('Failed to update environment:', error);
return json({ error: 'Failed to update environment' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Close Edge connection if this is a Hawser Edge environment
// This rejects any pending requests and closes the WebSocket
closeEdgeConnection(id);
// Clear cached Docker client before deleting
clearDockerClientCache(id);
// Clean up git stacks for this environment
const gitStacks = await getGitStacksForEnvironmentOnly(id);
for (const stack of gitStacks) {
// Unregister schedule if auto-update was enabled
if (stack.autoUpdate) {
unregisterSchedule(stack.id, 'git_stack_sync');
}
// Delete git stack files from filesystem
deleteGitStackFiles(stack.id);
// Delete git stack from database
await deleteGitStack(stack.id);
}
const success = await deleteEnvironment(id);
if (!success) {
return json({ error: 'Cannot delete this environment' }, { status: 400 });
}
// Clean up public IP entry for this environment
await deleteEnvironmentPublicIp(id);
// Clean up update check settings and unregister schedule
await deleteEnvUpdateCheckSettings(id);
unregisterSchedule(id, 'env_update_check');
// Notify subprocesses to stop collecting from deleted environment
refreshSubprocessEnvironments();
return json({ success: true });
} catch (error) {
console.error('Failed to delete environment:', error);
return json({ error: 'Failed to delete environment' }, { status: 500 });
}
};

View File

@@ -0,0 +1,78 @@
import { json } from '@sveltejs/kit';
import {
getEnvironmentNotifications,
createEnvironmentNotification,
getEnvironment,
getNotificationSettings,
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// GET /api/environments/[id]/notifications - List all notification configurations for an environment
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
if (isNaN(envId)) {
return json({ error: 'Invalid environment ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const notifications = await getEnvironmentNotifications(envId);
return json(notifications);
} catch (error) {
console.error('Error fetching environment notifications:', error);
return json({ error: 'Failed to fetch environment notifications' }, { status: 500 });
}
};
// POST /api/environments/[id]/notifications - Add a notification channel to an environment
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
if (isNaN(envId)) {
return json({ error: 'Invalid environment ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const body = await request.json();
const { notificationId, enabled, eventTypes } = body;
if (!notificationId) {
return json({ error: 'notificationId is required' }, { status: 400 });
}
const notification = await createEnvironmentNotification({
environmentId: envId,
notificationId,
enabled: enabled !== false,
eventTypes: eventTypes as NotificationEventType[]
});
return json(notification);
} catch (error: any) {
console.error('Error creating environment notification:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'This notification channel is already configured for this environment' }, { status: 409 });
}
return json({ error: error.message || 'Failed to create environment notification' }, { status: 500 });
}
};

View File

@@ -0,0 +1,103 @@
import { json } from '@sveltejs/kit';
import {
getEnvironmentNotification,
updateEnvironmentNotification,
deleteEnvironmentNotification,
getEnvironment,
type NotificationEventType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// GET /api/environments/[id]/notifications/[notificationId] - Get a specific environment notification
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
const notifId = parseInt(params.notificationId);
if (isNaN(envId) || isNaN(notifId)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const notification = await getEnvironmentNotification(envId, notifId);
if (!notification) {
return json({ error: 'Environment notification not found' }, { status: 404 });
}
return json(notification);
} catch (error) {
console.error('Error fetching environment notification:', error);
return json({ error: 'Failed to fetch environment notification' }, { status: 500 });
}
};
// PUT /api/environments/[id]/notifications/[notificationId] - Update an environment notification
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
const notifId = parseInt(params.notificationId);
if (isNaN(envId) || isNaN(notifId)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
const env = await getEnvironment(envId);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
try {
const body = await request.json();
const { enabled, eventTypes } = body;
const notification = await updateEnvironmentNotification(envId, notifId, {
enabled,
eventTypes: eventTypes as NotificationEventType[]
});
if (!notification) {
return json({ error: 'Environment notification not found' }, { status: 404 });
}
return json(notification);
} catch (error: any) {
console.error('Error updating environment notification:', error);
return json({ error: error.message || 'Failed to update environment notification' }, { status: 500 });
}
};
// DELETE /api/environments/[id]/notifications/[notificationId] - Remove a notification from an environment
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('notifications', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const envId = parseInt(params.id);
const notifId = parseInt(params.notificationId);
if (isNaN(envId) || isNaN(notifId)) {
return json({ error: 'Invalid ID' }, { status: 400 });
}
try {
const deleted = await deleteEnvironmentNotification(envId, notifId);
if (!deleted) {
return json({ error: 'Environment notification not found' }, { status: 404 });
}
return json({ success: true });
} catch (error: any) {
console.error('Error deleting environment notification:', error);
return json({ error: error.message || 'Failed to delete environment notification' }, { status: 500 });
}
};

View File

@@ -0,0 +1,138 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { getDockerInfo } from '$lib/server/docker';
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Edge mode - check connection status immediately without blocking
if (env.connectionType === 'hawser-edge') {
const edgeConn = edgeConnections.get(id);
const connected = isEdgeConnected(id);
if (!connected) {
console.log(`[Test] Edge environment ${id} (${env.name}) - agent not connected`);
return json({
success: false,
error: 'Edge agent is not connected',
isEdgeMode: true,
hawser: env.hawserVersion ? {
hawserVersion: env.hawserVersion,
agentId: env.hawserAgentId,
agentName: env.hawserAgentName
} : null
}, { status: 200 });
}
// Agent is connected - try to get Docker info with shorter timeout
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
try {
const info = await getDockerInfo(env.id) as any;
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
},
isEdgeMode: true,
hawser: edgeConn ? {
hawserVersion: edgeConn.agentVersion,
agentId: edgeConn.agentId,
agentName: edgeConn.agentName,
hostname: edgeConn.hostname,
dockerVersion: edgeConn.dockerVersion,
capabilities: edgeConn.capabilities
} : null
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Docker API call failed';
console.error(`[Test] Edge environment ${id} Docker test failed:`, message);
return json({
success: false,
error: message,
isEdgeMode: true,
hawser: edgeConn ? {
hawserVersion: edgeConn.agentVersion,
agentId: edgeConn.agentId,
agentName: edgeConn.agentName
} : null
}, { status: 200 });
}
}
const info = await getDockerInfo(env.id) as any;
// For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return)
let hawserInfo = null;
if (env.connectionType === 'hawser-standard') {
// Standard mode: fetch via HTTP
try {
const protocol = env.useTls ? 'https' : 'http';
const headers: Record<string, string> = {};
if (env.hawserToken) {
headers['X-Hawser-Token'] = env.hawserToken;
}
const hawserResp = await fetch(`${protocol}://${env.host}:${env.port || 2376}/_hawser/info`, {
headers,
signal: AbortSignal.timeout(5000)
});
if (hawserResp.ok) {
hawserInfo = await hawserResp.json();
// Save hawser info to database
if (hawserInfo?.hawserVersion) {
await updateEnvironment(id, {
hawserVersion: hawserInfo.hawserVersion,
hawserAgentId: hawserInfo.agentId,
hawserAgentName: hawserInfo.agentName,
hawserLastSeen: new Date().toISOString()
});
}
}
} catch {
// Hawser info fetch failed, continue without it
}
}
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
},
hawser: hawserInfo
});
} catch (error) {
const rawMessage = error instanceof Error ? error.message : 'Connection failed';
console.error('Failed to test connection:', rawMessage);
// Provide more helpful error messages for Hawser connections
let message = rawMessage;
if (rawMessage.includes('401') || rawMessage.toLowerCase().includes('unauthorized')) {
message = 'Invalid token - check that the Hawser token matches';
} else if (rawMessage.includes('403') || rawMessage.toLowerCase().includes('forbidden')) {
message = 'Access forbidden - check token permissions';
} else if (rawMessage.includes('ECONNREFUSED') || rawMessage.includes('Connection refused')) {
message = 'Connection refused - is Hawser running?';
} else if (rawMessage.includes('ETIMEDOUT') || rawMessage.includes('timeout') || rawMessage.includes('Timeout')) {
message = 'Connection timed out - check host and port';
} else if (rawMessage.includes('ENOTFOUND') || rawMessage.includes('getaddrinfo')) {
message = 'Host not found - check the hostname';
} else if (rawMessage.includes('EHOSTUNREACH')) {
message = 'Host unreachable - check network connectivity';
}
return json({ success: false, error: message }, { status: 200 });
}
};

View File

@@ -0,0 +1,75 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getEnvironmentTimezone,
setEnvironmentTimezone,
getEnvironment
} from '$lib/server/db';
import { refreshSchedulesForEnvironment } from '$lib/server/scheduler';
/**
* Get timezone for an environment.
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const timezone = await getEnvironmentTimezone(id);
return json({ timezone });
} catch (error) {
console.error('Failed to get environment timezone:', error);
return json({ error: 'Failed to get environment timezone' }, { status: 500 });
}
};
/**
* Set timezone for an environment.
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
const timezone = data.timezone || 'UTC';
// Validate timezone
const validTimezones = Intl.supportedValuesOf('timeZone');
if (!validTimezones.includes(timezone) && timezone !== 'UTC') {
return json({ error: 'Invalid timezone' }, { status: 400 });
}
await setEnvironmentTimezone(id, timezone);
// Refresh all schedules for this environment to use the new timezone
await refreshSchedulesForEnvironment(id);
return json({ success: true, timezone });
} catch (error) {
console.error('Failed to set environment timezone:', error);
return json({ error: 'Failed to set environment timezone' }, { status: 500 });
}
};

View File

@@ -0,0 +1,87 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import {
getEnvUpdateCheckSettings,
setEnvUpdateCheckSettings,
getEnvironment
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
/**
* Get update check settings for an environment.
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const settings = await getEnvUpdateCheckSettings(id);
return json({
settings: settings || {
enabled: false,
cron: '0 4 * * *',
autoUpdate: false,
vulnerabilityCriteria: 'never'
}
});
} catch (error) {
console.error('Failed to get update check settings:', error);
return json({ error: 'Failed to get update check settings' }, { status: 500 });
}
};
/**
* Save update check settings for an environment.
*/
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
// Verify environment exists
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
const settings = {
enabled: data.enabled ?? false,
cron: data.cron || '0 4 * * *',
autoUpdate: data.autoUpdate ?? false,
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never'
};
// Save settings to database
await setEnvUpdateCheckSettings(id, settings);
// Register or unregister schedule based on enabled state
if (settings.enabled) {
await registerSchedule(id, 'env_update_check', id);
} else {
unregisterSchedule(id, 'env_update_check');
}
return json({ success: true, settings });
} catch (error) {
console.error('Failed to save update check settings:', error);
return json({ error: 'Failed to save update check settings' }, { status: 500 });
}
};

View File

@@ -0,0 +1,46 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
interface DetectedSocket {
path: string;
name: string;
exists: boolean;
}
/**
* Detect available Docker sockets on the system
*/
export const GET: RequestHandler = async () => {
const home = homedir();
// Common socket paths to check
const socketPaths: { path: string; name: string }[] = [
{ path: '/var/run/docker.sock', name: 'Docker (default)' },
{ path: `${home}/.docker/run/docker.sock`, name: 'Docker Desktop' },
{ path: `${home}/.orbstack/run/docker.sock`, name: 'OrbStack' },
{ path: '/run/docker.sock', name: 'Docker (alternate)' },
{ path: `${home}/.colima/default/docker.sock`, name: 'Colima' },
{ path: `${home}/.rd/docker.sock`, name: 'Rancher Desktop' },
{ path: '/run/user/1000/podman/podman.sock', name: 'Podman (user 1000)' },
{ path: `${home}/.local/share/containers/podman/machine/podman.sock`, name: 'Podman Machine' },
];
const detected: DetectedSocket[] = [];
for (const socket of socketPaths) {
if (existsSync(socket.path)) {
detected.push({
path: socket.path,
name: socket.name,
exists: true
});
}
}
return json({
sockets: detected,
homedir: home
});
};

View File

@@ -0,0 +1,201 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
interface TestConnectionRequest {
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
socketPath?: string;
host?: string;
port?: number;
protocol?: string;
tlsCa?: string;
tlsCert?: string;
tlsKey?: string;
tlsSkipVerify?: boolean;
hawserToken?: string;
}
/**
* Test Docker connection with provided configuration (without saving to database)
*/
export const POST: RequestHandler = async ({ request }) => {
try {
const config: TestConnectionRequest = await request.json();
// Build fetch options based on connection type
let response: Response;
if (config.connectionType === 'socket') {
const socketPath = config.socketPath || '/var/run/docker.sock';
response = await fetch('http://localhost/info', {
// @ts-ignore - Bun supports unix socket
unix: socketPath,
signal: AbortSignal.timeout(10000)
});
} else if (config.connectionType === 'hawser-edge') {
// Edge mode - cannot test directly, agent connects to us
return json({
success: true,
info: {
message: 'Edge mode environments are tested when the agent connects'
},
isEdgeMode: true
});
} else {
// Direct or Hawser Standard - HTTP/HTTPS connection
const protocol = config.protocol || 'http';
const host = config.host;
const port = config.port || 2375;
if (!host) {
return json({ success: false, error: 'Host is required' }, { status: 400 });
}
const url = `${protocol}://${host}:${port}/info`;
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add Hawser token if present
if (config.connectionType === 'hawser-standard' && config.hawserToken) {
headers['X-Hawser-Token'] = config.hawserToken;
}
// For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues
if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) {
const fs = await import('node:fs');
let tempCaPath = '';
// Clean the certificate - remove leading/trailing whitespace from each line
let cleanedCa = '';
if (config.tlsCa && !config.tlsSkipVerify) {
cleanedCa = config.tlsCa
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');
tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`;
fs.writeFileSync(tempCaPath, cleanedCa);
}
// Build Bun script that runs outside Vite's process (Vite interferes with TLS)
const tlsConfig = config.tlsSkipVerify
? `tls: { rejectUnauthorized: false }`
: `tls: { ca: await Bun.file('${tempCaPath}').text() }`;
const scriptContent = `
const response = await fetch('https://${host}:${port}/info', {
headers: ${JSON.stringify(headers)},
${tlsConfig}
});
const body = await response.text();
console.log(JSON.stringify({ status: response.status, body }));
`;
const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`;
fs.writeFileSync(scriptPath, scriptContent);
const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
// Cleanup temp files
if (tempCaPath) {
try { fs.unlinkSync(tempCaPath); } catch {}
}
try { fs.unlinkSync(scriptPath); } catch {}
if (!output.trim()) {
throw new Error(stderr || 'Empty response from TLS test subprocess');
}
const result = JSON.parse(output.trim());
if (result.error) {
throw new Error(result.error);
}
response = new Response(result.body, {
status: result.status,
headers: { 'Content-Type': 'application/json' }
});
} else {
response = await fetch(url, {
headers,
signal: AbortSignal.timeout(10000)
});
}
}
if (!response.ok) {
const error = await response.text();
throw new Error(`Docker API error: ${response.status} - ${error}`);
}
const info = await response.json();
// For Hawser Standard, also try to fetch Hawser info
let hawserInfo = null;
if (config.connectionType === 'hawser-standard' && config.host) {
try {
const protocol = config.protocol || 'http';
const headers: Record<string, string> = {};
if (config.hawserToken) {
headers['X-Hawser-Token'] = config.hawserToken;
}
const hawserResp = await fetch(
`${protocol}://${config.host}:${config.port || 2375}/_hawser/info`,
{
headers,
signal: AbortSignal.timeout(5000)
}
);
if (hawserResp.ok) {
hawserInfo = await hawserResp.json();
}
} catch {
// Hawser info fetch failed, continue without it
}
}
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
},
hawser: hawserInfo
});
} catch (error) {
const rawMessage = error instanceof Error ? error.message : 'Connection failed';
console.error('Failed to test connection:', rawMessage);
// Provide more helpful error messages
let message = rawMessage;
if (rawMessage.includes('401') || rawMessage.toLowerCase().includes('unauthorized')) {
message = 'Invalid token - check that the Hawser token matches';
} else if (rawMessage.includes('403') || rawMessage.toLowerCase().includes('forbidden')) {
message = 'Access forbidden - check token permissions';
} else if (rawMessage.includes('ECONNREFUSED') || rawMessage.includes('Connection refused')) {
message = 'Connection refused - is Docker/Hawser running?';
} else if (rawMessage.includes('ETIMEDOUT') || rawMessage.includes('timeout') || rawMessage.includes('Timeout')) {
message = 'Connection timed out - check host and port';
} else if (rawMessage.includes('ENOTFOUND') || rawMessage.includes('getaddrinfo')) {
message = 'Host not found - check the hostname';
} else if (rawMessage.includes('EHOSTUNREACH')) {
message = 'Host unreachable - check network connectivity';
} else if (rawMessage.includes('ENOENT') || rawMessage.includes('no such file')) {
message = 'Socket not found - check the socket path';
} else if (rawMessage.includes('EACCES') || rawMessage.includes('permission denied')) {
message = 'Permission denied - check socket permissions';
} else if (rawMessage.includes('typo in the url') || rawMessage.includes('Was there a typo')) {
message = 'Connection failed - check host and port';
} else if (rawMessage.includes('self signed certificate') || rawMessage.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) {
message = 'TLS certificate error - provide CA certificate for self-signed certs';
} else if (rawMessage.includes('certificate') || rawMessage.includes('SSL') || rawMessage.includes('TLS')) {
message = 'TLS/SSL error - check certificate configuration';
}
return json({ success: false, error: message }, { status: 200 });
}
};

View File

@@ -0,0 +1,137 @@
import type { RequestHandler } from './$types';
import { getDockerEvents } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Early return if no environment specified
if (!envIdNum) {
return new Response(
`event: info\ndata: ${JSON.stringify({ message: 'No environment selected' })}\n\n`,
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
}
);
}
// Check if this is an edge mode environment - events are pushed by the agent, not pulled
const env = await getEnvironment(envIdNum);
if (env?.connectionType === 'hawser-edge') {
return new Response(
`event: error\ndata: ${JSON.stringify({ message: 'Edge environments receive events via agent push, not this endpoint' })}\n\n`,
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
}
);
}
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Send initial connection event
const sendEvent = (type: string, data: any) => {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(event));
};
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
const heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
clearInterval(heartbeatInterval);
}
}, 5000);
sendEvent('connected', { timestamp: new Date().toISOString(), envId: envIdNum });
try {
// Get Docker events stream
const eventStream = await getDockerEvents(
{ type: ['container', 'image', 'volume', 'network'] },
envIdNum
);
if (!eventStream) {
sendEvent('error', { message: 'Failed to connect to Docker events' });
clearInterval(heartbeatInterval);
controller.close();
return;
}
const reader = eventStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processEvents = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
try {
const event = JSON.parse(line);
// Map Docker event to our format
const mappedEvent = {
type: event.Type,
action: event.Action,
actor: {
id: event.Actor?.ID,
name: event.Actor?.Attributes?.name || event.Actor?.Attributes?.image,
attributes: event.Actor?.Attributes
},
time: event.time,
timeNano: event.timeNano
};
sendEvent('docker', mappedEvent);
} catch {
// Ignore parse errors for partial chunks
}
}
}
}
} catch (error: any) {
console.error('Docker event stream error:', error);
sendEvent('error', { message: error.message });
} finally {
clearInterval(heartbeatInterval);
controller.close();
}
};
processEvents();
} catch (error: any) {
console.error('Failed to connect to Docker events:', error);
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
clearInterval(heartbeatInterval);
controller.close();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable nginx buffering
}
});
};

View File

@@ -0,0 +1,88 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitCredentials,
createGitCredential,
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const credentials = await getGitCredentials();
// Don't expose sensitive data in list view
const sanitized = credentials.map(cred => ({
id: cred.id,
name: cred.name,
authType: cred.authType,
username: cred.username,
hasPassword: !!cred.password,
hasSshKey: !!cred.sshPrivateKey,
createdAt: cred.createdAt,
updatedAt: cred.updatedAt
}));
return json(sanitized);
} catch (error) {
console.error('Failed to get git credentials:', error);
return json({ error: 'Failed to get git credentials' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
if (!data.name || typeof data.name !== 'string') {
return json({ error: 'Name is required' }, { status: 400 });
}
const authType = (data.authType || 'none') as GitAuthType;
if (!['none', 'password', 'ssh'].includes(authType)) {
return json({ error: 'Invalid auth type' }, { status: 400 });
}
if (authType === 'password' && !data.password) {
return json({ error: 'Password is required for password authentication' }, { status: 400 });
}
if (authType === 'ssh' && !data.sshPrivateKey) {
return json({ error: 'SSH private key is required for SSH authentication' }, { status: 400 });
}
const credential = await createGitCredential({
name: data.name,
authType,
username: data.username,
password: data.password,
sshPrivateKey: data.sshPrivateKey,
sshPassphrase: data.sshPassphrase
});
return json({
id: credential.id,
name: credential.name,
authType: credential.authType,
username: credential.username,
hasPassword: !!credential.password,
hasSshKey: !!credential.sshPrivateKey,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt
});
} catch (error: any) {
console.error('Failed to create git credential:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A credential with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to create git credential' }, { status: 500 });
}
};

View File

@@ -0,0 +1,122 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitCredential,
updateGitCredential,
deleteGitCredential,
type GitAuthType
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const credential = await getGitCredential(id);
if (!credential) {
return json({ error: 'Credential not found' }, { status: 404 });
}
// Don't expose sensitive data
return json({
id: credential.id,
name: credential.name,
authType: credential.authType,
username: credential.username,
hasPassword: !!credential.password,
hasSshKey: !!credential.sshPrivateKey,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt
});
} catch (error) {
console.error('Failed to get git credential:', error);
return json({ error: 'Failed to get git credential' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const existing = await getGitCredential(id);
if (!existing) {
return json({ error: 'Credential not found' }, { status: 404 });
}
const data = await request.json();
if (data.authType && !['none', 'password', 'ssh'].includes(data.authType)) {
return json({ error: 'Invalid auth type' }, { status: 400 });
}
const credential = await updateGitCredential(id, {
name: data.name,
authType: data.authType as GitAuthType,
username: data.username,
password: data.password,
sshPrivateKey: data.sshPrivateKey,
sshPassphrase: data.sshPassphrase
});
if (!credential) {
return json({ error: 'Failed to update credential' }, { status: 500 });
}
return json({
id: credential.id,
name: credential.name,
authType: credential.authType,
username: credential.username,
hasPassword: !!credential.password,
hasSshKey: !!credential.sshPrivateKey,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt
});
} catch (error: any) {
console.error('Failed to update git credential:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A credential with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to update git credential' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
const deleted = await deleteGitCredential(id);
if (!deleted) {
return json({ error: 'Credential not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete git credential:', error);
return json({ error: 'Failed to delete git credential' }, { status: 500 });
}
};

View File

@@ -0,0 +1,70 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitRepositories,
createGitRepository,
getGitCredentials
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Note: envId parameter is kept for backwards compatibility but repositories
// are now global (not tied to environments). Use git stacks for env-specific deployments.
const repositories = await getGitRepositories();
return json(repositories);
} catch (error) {
console.error('Failed to get git repositories:', error);
return json({ error: 'Failed to get git repositories' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'create')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
if (!data.name || typeof data.name !== 'string') {
return json({ error: 'Name is required' }, { status: 400 });
}
if (!data.url || typeof data.url !== 'string') {
return json({ error: 'Repository URL is required' }, { status: 400 });
}
// Validate credential if provided
if (data.credentialId) {
const credentials = await getGitCredentials();
const credential = credentials.find(c => c.id === data.credentialId);
if (!credential) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
}
// Create repository with just the basic fields
// Deployment-specific config (composePath, autoUpdate, webhook) now belongs to git_stacks
const repository = await createGitRepository({
name: data.name,
url: data.url,
branch: data.branch || 'main',
credentialId: data.credentialId || null
});
return json(repository);
} catch (error: any) {
console.error('Failed to create git repository:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A repository with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to create git repository' }, { status: 500 });
}
};

View File

@@ -0,0 +1,112 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitRepository,
updateGitRepository,
deleteGitRepository,
getGitCredentials
} from '$lib/server/db';
import { deleteRepositoryFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
return json(repository);
} catch (error) {
console.error('Failed to get git repository:', error);
return json({ error: 'Failed to get git repository' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const existing = await getGitRepository(id);
if (!existing) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const data = await request.json();
// Validate credential if provided
if (data.credentialId) {
const credentials = await getGitCredentials();
const credential = credentials.find(c => c.id === data.credentialId);
if (!credential) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
}
// Update only the basic repository fields
// Deployment-specific config (composePath, autoUpdate, webhook) now belongs to git_stacks
const repository = await updateGitRepository(id, {
name: data.name,
url: data.url,
branch: data.branch,
credentialId: data.credentialId
});
if (!repository) {
return json({ error: 'Failed to update repository' }, { status: 500 });
}
return json(repository);
} catch (error: any) {
console.error('Failed to update git repository:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A repository with this name already exists' }, { status: 400 });
}
return json({ error: 'Failed to update git repository' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('git', 'delete')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
// Delete repository files first
deleteRepositoryFiles(id);
const deleted = await deleteGitRepository(id);
if (!deleted) {
return json({ error: 'Repository not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete git repository:', error);
return json({ error: 'Failed to delete git repository' }, { status: 500 });
}
};

View File

@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { deployFromRepository } from '$lib/server/git';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await deployFromRepository(id);
return json(result);
} catch (error: any) {
console.error('Failed to deploy from git repository:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,45 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { syncRepository, checkForUpdates } from '$lib/server/git';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await syncRepository(id);
return json(result);
} catch (error: any) {
console.error('Failed to sync git repository:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
export const GET: RequestHandler = async ({ params }) => {
// Check for updates without syncing
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await checkForUpdates(id);
return json(result);
} catch (error: any) {
console.error('Failed to check for updates:', error);
return json({ hasUpdates: false, error: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { testRepository } from '$lib/server/git';
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
const result = await testRepository(id);
return json(result);
} catch (error: any) {
console.error('Failed to test git repository:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,41 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { testRepositoryConfig } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
/**
* POST /api/git/repositories/test
* Test a git repository configuration before saving.
* Uses stored credentials via credentialId.
*
* Body: {
* url: string; // Repository URL to test
* branch: string; // Branch name to verify
* credentialId?: number; // Optional credential ID from database
* }
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
if (!body.url || typeof body.url !== 'string') {
return json({ error: 'Repository URL is required' }, { status: 400 });
}
const result = await testRepositoryConfig({
url: body.url,
branch: body.branch || 'main',
credentialId: body.credentialId ?? null
});
return json(result);
} catch (error) {
console.error('Failed to test repository:', error);
return json({ success: false, error: 'Failed to test repository' }, { status: 500 });
}
};

View File

@@ -0,0 +1,144 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getGitStacks,
createGitStack,
getGitCredentials,
getGitRepository,
createGitRepository,
upsertStackSource
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
import crypto from 'node:crypto';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const stacks = await getGitStacks(envIdNum);
return json(stacks);
} catch (error) {
console.error('Failed to get git stacks:', error);
return json({ error: 'Failed to get git stacks' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
try {
const data = await request.json();
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'create', data.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (!data.stackName || typeof data.stackName !== 'string') {
return json({ error: 'Stack name is required' }, { status: 400 });
}
// Either repositoryId or new repo details (url, branch) must be provided
let repositoryId = data.repositoryId;
if (!repositoryId) {
// Create a new repository if URL is provided
if (!data.url || typeof data.url !== 'string') {
return json({ error: 'Repository URL or existing repository ID is required' }, { status: 400 });
}
// Validate credential if provided
if (data.credentialId) {
const credentials = await getGitCredentials();
const credential = credentials.find(c => c.id === data.credentialId);
if (!credential) {
return json({ error: 'Invalid credential ID' }, { status: 400 });
}
}
// Create the repository first
const repoName = data.repoName || data.stackName;
try {
const repo = await createGitRepository({
name: repoName,
url: data.url,
branch: data.branch || 'main',
credentialId: data.credentialId || null
});
repositoryId = repo.id;
} catch (error: any) {
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A repository with this name already exists' }, { status: 400 });
}
throw error;
}
} else {
// Verify repository exists
const repo = await getGitRepository(repositoryId);
if (!repo) {
return json({ error: 'Repository not found' }, { status: 400 });
}
}
// Generate webhook secret if webhook is enabled
let webhookSecret = data.webhookSecret;
if (data.webhookEnabled && !webhookSecret) {
webhookSecret = crypto.randomBytes(32).toString('hex');
}
const gitStack = await createGitStack({
stackName: data.stackName,
environmentId: data.environmentId || null,
repositoryId: repositoryId,
composePath: data.composePath || 'docker-compose.yml',
envFilePath: data.envFilePath || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret
});
// Create stack_sources entry so the stack appears in the list immediately
await upsertStackSource({
stackName: data.stackName,
environmentId: data.environmentId || null,
sourceType: 'git',
gitRepositoryId: repositoryId,
gitStackId: gitStack.id
});
// Register schedule with croner if auto-update is enabled
if (gitStack.autoUpdate && gitStack.autoUpdateCron) {
await registerSchedule(gitStack.id, 'git_stack_sync', gitStack.environmentId);
}
// If deployNow is set, deploy immediately
if (data.deployNow) {
const deployResult = await deployGitStack(gitStack.id);
return json({
...gitStack,
deployResult: deployResult
});
}
return json(gitStack);
} catch (error: any) {
console.error('Failed to create git stack:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
}
return json({ error: 'Failed to create git stack' }, { status: 500 });
}
};

View File

@@ -0,0 +1,112 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack, updateGitStack, deleteGitStack } from '$lib/server/db';
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
return json(gitStack);
} catch (error) {
console.error('Failed to get git stack:', error);
return json({ error: 'Failed to get git stack' }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const existing = await getGitStack(id);
if (!existing) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'edit', existing.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const data = await request.json();
const updated = await updateGitStack(id, {
stackName: data.stackName,
composePath: data.composePath,
envFilePath: data.envFilePath,
autoUpdate: data.autoUpdate,
autoUpdateSchedule: data.autoUpdateSchedule,
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret
});
// Register or unregister schedule with croner
if (updated.autoUpdate && updated.autoUpdateCron) {
await registerSchedule(id, 'git_stack_sync', updated.environmentId);
} else {
unregisterSchedule(id, 'git_stack_sync');
}
// If deployNow is set, deploy after saving
if (data.deployNow) {
const deployResult = await deployGitStack(id);
return json({
...updated,
deployResult
});
}
return json(updated);
} catch (error: any) {
console.error('Failed to update git stack:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
}
return json({ error: 'Failed to update git stack' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const existing = await getGitStack(id);
if (!existing) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'remove', existing.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Unregister schedule from croner
unregisterSchedule(id, 'git_stack_sync');
// Delete git files first
deleteGitStackFiles(id);
// Delete from database
await deleteGitStack(id);
return json({ success: true });
} catch (error) {
console.error('Failed to delete git stack:', error);
return json({ error: 'Failed to delete git stack' }, { status: 500 });
}
};

View File

@@ -0,0 +1,54 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStackWithProgress } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return new Response(JSON.stringify({ error: 'Git stack not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'start', gitStack.environmentId || undefined)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
// Create a readable stream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendEvent = (data: any) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
try {
await deployGitStackWithProgress(id, sendEvent);
} catch (error: any) {
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
};

View File

@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'start', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await deployGitStack(id);
return json(result);
} catch (error) {
console.error('Failed to deploy git stack:', error);
return json({ error: 'Failed to deploy git stack' }, { status: 500 });
}
};

View File

@@ -0,0 +1,75 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { listGitStackEnvFiles, readGitStackEnvFile } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
/**
* GET /api/git/stacks/[id]/env-files
* List all .env files in the git stack's repository.
* Returns: { files: string[] }
*/
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await listGitStackEnvFiles(id);
if (result.error) {
return json({ files: [], error: result.error }, { status: 400 });
}
return json({ files: result.files });
} catch (error) {
console.error('Failed to list env files:', error);
return json({ error: 'Failed to list env files' }, { status: 500 });
}
};
/**
* POST /api/git/stacks/[id]/env-files
* Read and parse a specific .env file from the git stack's repository.
* Body: { path: string }
* Returns: { vars: Record<string, string> }
*/
export const POST: RequestHandler = async ({ params, cookies, request }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const body = await request.json();
if (!body.path || typeof body.path !== 'string') {
return json({ error: 'File path is required' }, { status: 400 });
}
const result = await readGitStackEnvFile(id, body.path);
if (result.error) {
return json({ vars: {}, error: result.error }, { status: 400 });
}
return json({ vars: result.vars });
} catch (error) {
console.error('Failed to read env file:', error);
return json({ error: 'Failed to read env file' }, { status: 500 });
}
};

View File

@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { syncGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'edit', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await syncGitStack(id);
return json(result);
} catch (error) {
console.error('Failed to sync git stack:', error);
return json({ error: 'Failed to sync git stack' }, { status: 500 });
}
};

View File

@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { testGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
try {
const id = parseInt(params.id);
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
// Permission check with environment context
if (auth.authEnabled && !await auth.can('stacks', 'view', gitStack.environmentId || undefined)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await testGitStack(id);
return json(result);
} catch (error) {
console.error('Failed to test git stack:', error);
return json({ error: 'Failed to test git stack' }, { status: 500 });
}
};

View File

@@ -0,0 +1,97 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import crypto from 'node:crypto';
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
// Support both GitHub and GitLab webhook signatures
// GitHub: sha256=<hash>
// GitLab: just the token value in X-Gitlab-Token header
if (signature.startsWith('sha256=')) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// GitLab uses X-Gitlab-Token which should match exactly
return signature === secret;
}
export const POST: RequestHandler = async ({ params, request }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid stack ID' }, { status: 400 });
}
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
if (!gitStack.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
}
// Verify webhook secret if set
if (gitStack.webhookSecret) {
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
const signature = githubSignature || gitlabToken;
if (!verifySignature(payload, signature, gitStack.webhookSecret)) {
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
}
// Deploy the git stack (syncs and deploys only if there are changes)
const result = await deployGitStack(id, { force: false });
return json(result);
} catch (error: any) {
console.error('Webhook error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
// Also support GET for simple polling/manual triggers
export const GET: RequestHandler = async ({ params, url }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid stack ID' }, { status: 400 });
}
const gitStack = await getGitStack(id);
if (!gitStack) {
return json({ error: 'Git stack not found' }, { status: 404 });
}
if (!gitStack.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
}
// Verify secret via query parameter for GET requests
const secret = url.searchParams.get('secret');
if (gitStack.webhookSecret && secret !== gitStack.webhookSecret) {
return json({ error: 'Invalid webhook secret' }, { status: 401 });
}
// Deploy the git stack (syncs and deploys only if there are changes)
const result = await deployGitStack(id, { force: false });
return json(result);
} catch (error: any) {
console.error('Webhook GET error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,103 @@
import { json, text } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitRepository } from '$lib/server/db';
import { deployFromRepository } from '$lib/server/git';
import crypto from 'node:crypto';
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
// Support both GitHub and GitLab webhook signatures
// GitHub: sha256=<hash>
// GitLab: just the token value in X-Gitlab-Token header
if (signature.startsWith('sha256=')) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// GitLab uses X-Gitlab-Token which should match exactly
return signature === secret;
}
export const POST: RequestHandler = async ({ params, request }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
if (!repository.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 });
}
// Verify webhook secret if set
if (repository.webhookSecret) {
const payload = await request.text();
const githubSignature = request.headers.get('x-hub-signature-256');
const gitlabToken = request.headers.get('x-gitlab-token');
const signature = githubSignature || gitlabToken;
if (!verifySignature(payload, signature, repository.webhookSecret)) {
return json({ error: 'Invalid webhook signature' }, { status: 401 });
}
}
// Optionally check which branch was pushed (for GitHub)
// const body = await request.json();
// if (body.ref && body.ref !== `refs/heads/${repository.branch}`) {
// return json({ message: 'Push was not to tracked branch, skipping' });
// }
// Deploy from repository
const result = await deployFromRepository(id);
return json(result);
} catch (error: any) {
console.error('Webhook error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};
// Also support GET for simple polling/manual triggers
export const GET: RequestHandler = async ({ params, url }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
return json({ error: 'Invalid repository ID' }, { status: 400 });
}
const repository = await getGitRepository(id);
if (!repository) {
return json({ error: 'Repository not found' }, { status: 404 });
}
if (!repository.webhookEnabled) {
return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 });
}
// Verify secret via query parameter for GET requests
const secret = url.searchParams.get('secret');
if (repository.webhookSecret && secret !== repository.webhookSecret) {
return json({ error: 'Invalid webhook secret' }, { status: 401 });
}
// Deploy from repository
const result = await deployFromRepository(id);
return json(result);
} catch (error: any) {
console.error('Webhook GET error:', error);
return json({ success: false, error: error.message }, { status: 500 });
}
};

View File

@@ -0,0 +1,61 @@
/**
* Hawser Edge WebSocket Connect Endpoint
*
* This endpoint handles WebSocket connections from Hawser agents running in Edge mode.
* In development: WebSocket is handled by Bun.serve in vite.config.ts on port 5174
* In production: WebSocket is handled by the server wrapper in server.ts
*
* The HTTP GET endpoint returns connection info for clients.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { isEdgeConnected, getAllEdgeConnections } from '$lib/server/hawser';
/**
* GET /api/hawser/connect
* Returns status of the Hawser Edge connection endpoint
* This is used for health checks and debugging
*/
export const GET: RequestHandler = async () => {
const connections = getAllEdgeConnections();
const connectionList = Array.from(connections.entries()).map(([envId, conn]) => ({
environmentId: envId,
agentId: conn.agentId,
agentName: conn.agentName,
agentVersion: conn.agentVersion,
dockerVersion: conn.dockerVersion,
hostname: conn.hostname,
capabilities: conn.capabilities,
connectedAt: conn.connectedAt.toISOString(),
lastHeartbeat: conn.lastHeartbeat.toISOString()
}));
return json({
status: 'ready',
message: 'Hawser Edge WebSocket endpoint. Connect via WebSocket.',
protocol: 'wss://<host>/api/hawser/connect',
activeConnections: connectionList.length,
connections: connectionList
});
};
/**
* POST /api/hawser/connect
* This is a fallback for non-WebSocket clients.
* Returns instructions for connecting via WebSocket.
*/
export const POST: RequestHandler = async () => {
return json(
{
error: 'WebSocket required',
message: 'This endpoint requires a WebSocket connection. Use the ws:// or wss:// protocol.',
instructions: [
'1. Generate a token in Settings > Environments > [Environment] > Hawser',
'2. Configure your Hawser agent with DOCKHAND_SERVER_URL and TOKEN',
'3. The agent will connect automatically'
]
},
{ status: 426 }
); // 426 Upgrade Required
};

View File

@@ -0,0 +1,122 @@
/**
* Hawser Token Management API
*
* Handles CRUD operations for Hawser agent tokens.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { db, hawserTokens, eq, desc } from '$lib/server/db/drizzle';
import { generateHawserToken, revokeHawserToken } from '$lib/server/hawser';
/**
* GET /api/hawser/tokens
* List all Hawser tokens (without revealing full token values)
*/
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (auth.authEnabled && !auth.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
try {
const tokens = await db
.select({
id: hawserTokens.id,
tokenPrefix: hawserTokens.tokenPrefix,
name: hawserTokens.name,
environmentId: hawserTokens.environmentId,
isActive: hawserTokens.isActive,
lastUsed: hawserTokens.lastUsed,
createdAt: hawserTokens.createdAt,
expiresAt: hawserTokens.expiresAt
})
.from(hawserTokens)
.orderBy(desc(hawserTokens.createdAt));
return json(tokens);
} catch (error) {
console.error('Error fetching Hawser tokens:', error);
return json({ error: 'Failed to fetch tokens' }, { status: 500 });
}
};
/**
* POST /api/hawser/tokens
* Generate a new Hawser token
*
* Body: { name: string, environmentId: number, expiresAt?: string }
* Returns: { token: string, tokenId: number } - token is only shown ONCE
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (auth.authEnabled && !auth.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
try {
const body = await request.json();
const { name, environmentId, expiresAt, rawToken } = body;
if (!name || typeof name !== 'string') {
return json({ error: 'Token name is required' }, { status: 400 });
}
if (!environmentId || typeof environmentId !== 'number') {
return json({ error: 'Environment ID is required' }, { status: 400 });
}
const result = await generateHawserToken(name, environmentId, expiresAt, rawToken);
return json({
token: result.token,
tokenId: result.tokenId,
message: 'Token generated successfully. Save this token - it will not be shown again.'
});
} catch (error) {
console.error('Error generating Hawser token:', error);
return json({ error: 'Failed to generate token' }, { status: 500 });
}
};
/**
* DELETE /api/hawser/tokens
* Delete (revoke) a token by ID
*
* Query: ?id=<token_id>
*/
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (auth.authEnabled && !auth.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
const tokenId = url.searchParams.get('id');
if (!tokenId) {
return json({ error: 'Token ID is required' }, { status: 400 });
}
try {
await revokeHawserToken(parseInt(tokenId, 10));
return json({ success: true, message: 'Token revoked' });
} catch (error) {
console.error('Error revoking Hawser token:', error);
return json({ error: 'Failed to revoke token' }, { status: 500 });
}
};

View File

@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json({ status: 'ok', timestamp: new Date().toISOString() });
};

View File

@@ -0,0 +1,58 @@
/**
* Database Health Check Endpoint
*
* Returns detailed information about the database schema state,
* including migration status, table existence, and connection info.
*
* GET /api/health/database
*
* Response:
* {
* healthy: boolean,
* database: 'sqlite' | 'postgresql',
* connection: string,
* migrationsTable: boolean,
* appliedMigrations: number,
* pendingMigrations: number,
* schemaVersion: string | null,
* tables: {
* expected: number,
* found: number,
* missing: string[]
* },
* timestamp: string
* }
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { checkSchemaHealth } from '$lib/server/db/drizzle';
export const GET: RequestHandler = async () => {
try {
const health = await checkSchemaHealth();
return json(health, {
status: health.healthy ? 200 : 503,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return json(
{
healthy: false,
error: message,
timestamp: new Date().toISOString()
},
{
status: 500,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
}
);
}
};

158
routes/api/host/+server.ts Normal file
View File

@@ -0,0 +1,158 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { getEdgeConnectionInfo } from '$lib/server/hawser';
import os from 'node:os';
export interface HostInfo {
hostname: string;
ipAddress: string;
platform: string;
arch: string;
cpus: number;
totalMemory: number;
freeMemory: number;
uptime: number;
dockerVersion: string;
dockerContainers: number;
dockerContainersRunning: number;
dockerImages: number;
environment: {
id: number;
name: string;
icon?: string;
socketPath?: string;
connectionType?: string;
hawserVersion?: string;
highlightChanges?: boolean;
};
}
function getLocalIpAddress(): string {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
const netInterface = interfaces[name];
if (!netInterface) continue;
for (const net of netInterface) {
// Skip internal and non-IPv4 addresses
if (!net.internal && net.family === 'IPv4') {
return net.address;
}
}
}
return '127.0.0.1';
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// Check basic environment view permission
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
// Get environment ID from query param, or use default
const envIdParam = url.searchParams.get('env');
let env;
if (envIdParam) {
const envId = parseInt(envIdParam);
// Check if user can access this specific environment
if (auth.authEnabled && auth.isEnterprise && !await auth.canAccessEnvironment(envId)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
env = await getEnvironment(envId);
}
if (!env) {
// No environment specified - return basic local info
return json({
hostname: os.hostname(),
ipAddress: getLocalIpAddress(),
platform: os.platform(),
arch: os.arch(),
cpus: os.cpus().length,
totalMemory: os.totalmem(),
freeMemory: os.freemem(),
uptime: os.uptime(),
dockerVersion: null,
dockerContainers: 0,
dockerContainersRunning: 0,
dockerImages: 0,
environment: null
});
}
// Determine if this is a truly local connection (socket without remote host)
const isSocketType = env.connectionType === 'socket' || !env.connectionType;
const isLocalConnection = isSocketType && (!env.host || env.host === 'localhost' || env.host === '127.0.0.1');
// Fetch Docker info and Hawser info in parallel for hawser-standard mode
let dockerInfo: any;
let uptime = 0;
let hawserVersion: string | undefined;
if (env.connectionType === 'hawser-standard') {
// Parallel fetch for hawser-standard
const [dockerResult, hawserInfo] = await Promise.all([
getDockerInfo(env.id),
getHawserInfo(env.id)
]);
dockerInfo = dockerResult;
if (hawserInfo?.uptime) {
uptime = hawserInfo.uptime;
}
if (hawserInfo?.hawserVersion) {
hawserVersion = hawserInfo.hawserVersion;
}
} else {
// Sequential for other connection types
dockerInfo = await getDockerInfo(env.id);
if (isLocalConnection) {
uptime = os.uptime();
} else if (env.connectionType === 'hawser-edge') {
// For Hawser edge mode, get from edge connection metrics (sync lookup)
const edgeConn = getEdgeConnectionInfo(env.id);
if (edgeConn?.lastMetrics?.uptime) {
uptime = edgeConn.lastMetrics.uptime;
}
}
// For 'direct' connections without Hawser, uptime remains 0 (not available)
}
const hostInfo: HostInfo = {
// For local connections, show local system info; for remote, show Docker host info
hostname: isLocalConnection ? os.hostname() : (dockerInfo.Name || env.host || 'unknown'),
ipAddress: isLocalConnection ? getLocalIpAddress() : (env.host || 'unknown'),
platform: isLocalConnection ? os.platform() : (dockerInfo.OperatingSystem || 'unknown'),
arch: isLocalConnection ? os.arch() : (dockerInfo.Architecture || 'unknown'),
cpus: isLocalConnection ? os.cpus().length : (dockerInfo.NCPU || 0),
totalMemory: isLocalConnection ? os.totalmem() : (dockerInfo.MemTotal || 0),
freeMemory: isLocalConnection ? os.freemem() : 0, // Not available from Docker API
uptime,
dockerVersion: dockerInfo.ServerVersion || 'unknown',
dockerContainers: dockerInfo.Containers || 0,
dockerContainersRunning: dockerInfo.ContainersRunning || 0,
dockerImages: dockerInfo.Images || 0,
environment: {
id: env.id,
name: env.name,
icon: env.icon,
socketPath: env.socketPath,
connectionType: env.connectionType || 'socket',
// For standard mode, use live-fetched version; for edge mode, use stored version
hawserVersion: hawserVersion || env.hawserVersion,
highlightChanges: env.highlightChanges
}
};
return json(hostInfo);
} catch (error) {
console.error('Failed to get host info:', error);
return json({ error: 'Failed to get host info' }, { status: 500 });
}
};

View File

@@ -0,0 +1,39 @@
import { json } from '@sveltejs/kit';
import { listImages, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Early return if no environment specified
if (!envIdNum) {
return json([]);
}
try {
const images = await listImages(envIdNum);
return json(images);
} catch (error) {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Error listing images:', error);
// Return empty array instead of error to allow UI to load
return json([]);
}
};

View File

@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import { removeImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('images', 'remove', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
console.log('Delete image request - params.id:', params.id, 'force:', force, 'envId:', envIdNum);
// Get image name for audit before deleting
let imageName = params.id;
try {
const imageInfo = await inspectImage(params.id, envIdNum);
imageName = imageInfo.RepoTags?.[0] || params.id;
} catch (e) {
console.log('Could not inspect image:', e);
// Use ID if can't get name
}
await removeImage(params.id, force, envIdNum);
// Audit log
await auditImage(event, 'delete', params.id, imageName, envIdNum, { force });
return json({ success: true });
} catch (error: any) {
console.error('Error removing image:', error.message, 'statusCode:', error.statusCode, 'json:', error.json);
// Handle specific Docker errors
if (error.statusCode === 409) {
const message = error.json?.message || error.message || '';
if (message.includes('being used by running container')) {
return json({ error: 'Cannot delete image: it is being used by a running container. Stop the container first.' }, { status: 409 });
}
if (message.includes('has dependent child images')) {
return json({ error: 'Cannot delete image: it has dependent child images. Delete those first or use force delete.' }, { status: 409 });
}
return json({ error: message || 'Image is in use and cannot be deleted' }, { status: 409 });
}
return json({ error: 'Failed to remove image' }, { status: 500 });
}
};

Some files were not shown because too many files have changed in this diff Show More