mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-17 16:54:29 +00:00
Initial commit
This commit is contained in:
55
routes/+layout.server.ts
Normal file
55
routes/+layout.server.ts
Normal 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
175
routes/+layout.svelte
Normal 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
3
routes/+layout.ts
Normal 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
1000
routes/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
931
routes/activity/+page.svelte
Normal file
931
routes/activity/+page.svelte
Normal 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>
|
||||
14
routes/alerts/+page.svelte
Normal file
14
routes/alerts/+page.svelte
Normal 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>
|
||||
88
routes/api/activity/+server.ts
Normal file
88
routes/api/activity/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
42
routes/api/activity/containers/+server.ts
Normal file
42
routes/api/activity/containers/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
107
routes/api/activity/events/+server.ts
Normal file
107
routes/api/activity/events/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
42
routes/api/activity/stats/+server.ts
Normal file
42
routes/api/activity/stats/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
68
routes/api/audit/+server.ts
Normal file
68
routes/api/audit/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
79
routes/api/audit/events/+server.ts
Normal file
79
routes/api/audit/events/+server.ts
Normal 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
|
||||
}
|
||||
});
|
||||
};
|
||||
182
routes/api/audit/export/+server.ts
Normal file
182
routes/api/audit/export/+server.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
26
routes/api/audit/users/+server.ts
Normal file
26
routes/api/audit/users/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
81
routes/api/auth/ldap/+server.ts
Normal file
81
routes/api/auth/ldap/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
131
routes/api/auth/ldap/[id]/+server.ts
Normal file
131
routes/api/auth/ldap/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
37
routes/api/auth/ldap/[id]/test/+server.ts
Normal file
37
routes/api/auth/ldap/[id]/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
117
routes/api/auth/login/+server.ts
Normal file
117
routes/api/auth/login/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
14
routes/api/auth/logout/+server.ts
Normal file
14
routes/api/auth/logout/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
88
routes/api/auth/oidc/+server.ts
Normal file
88
routes/api/auth/oidc/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
136
routes/api/auth/oidc/[id]/+server.ts
Normal file
136
routes/api/auth/oidc/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
77
routes/api/auth/oidc/[id]/initiate/+server.ts
Normal file
77
routes/api/auth/oidc/[id]/initiate/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
28
routes/api/auth/oidc/[id]/test/+server.ts
Normal file
28
routes/api/auth/oidc/[id]/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
53
routes/api/auth/oidc/callback/+server.ts
Normal file
53
routes/api/auth/oidc/callback/+server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
54
routes/api/auth/providers/+server.ts
Normal file
54
routes/api/auth/providers/+server.ts
Normal 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' }] });
|
||||
}
|
||||
};
|
||||
46
routes/api/auth/session/+server.ts
Normal file
46
routes/api/auth/session/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
71
routes/api/auth/settings/+server.ts
Normal file
71
routes/api/auth/settings/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
40
routes/api/auto-update/+server.ts
Normal file
40
routes/api/auto-update/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
126
routes/api/auto-update/[containerName]/+server.ts
Normal file
126
routes/api/auto-update/[containerName]/+server.ts
Normal 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
463
routes/api/batch/+server.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
7
routes/api/changelog/+server.ts
Normal file
7
routes/api/changelog/+server.ts
Normal 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);
|
||||
};
|
||||
53
routes/api/config-sets/+server.ts
Normal file
53
routes/api/config-sets/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
91
routes/api/config-sets/[id]/+server.ts
Normal file
91
routes/api/config-sets/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
123
routes/api/containers/+server.ts
Normal file
123
routes/api/containers/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
93
routes/api/containers/[id]/+server.ts
Normal file
93
routes/api/containers/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
59
routes/api/containers/[id]/exec/+server.ts
Normal file
59
routes/api/containers/[id]/exec/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
32
routes/api/containers/[id]/files/+server.ts
Normal file
32
routes/api/containers/[id]/files/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
57
routes/api/containers/[id]/files/chmod/+server.ts
Normal file
57
routes/api/containers/[id]/files/chmod/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
116
routes/api/containers/[id]/files/content/+server.ts
Normal file
116
routes/api/containers/[id]/files/content/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
55
routes/api/containers/[id]/files/create/+server.ts
Normal file
55
routes/api/containers/[id]/files/create/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
51
routes/api/containers/[id]/files/delete/+server.ts
Normal file
51
routes/api/containers/[id]/files/delete/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
98
routes/api/containers/[id]/files/download/+server.ts
Normal file
98
routes/api/containers/[id]/files/download/+server.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
};
|
||||
54
routes/api/containers/[id]/files/rename/+server.ts
Normal file
54
routes/api/containers/[id]/files/rename/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
154
routes/api/containers/[id]/files/upload/+server.ts
Normal file
154
routes/api/containers/[id]/files/upload/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
24
routes/api/containers/[id]/inspect/+server.ts
Normal file
24
routes/api/containers/[id]/inspect/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
25
routes/api/containers/[id]/logs/+server.ts
Normal file
25
routes/api/containers/[id]/logs/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
452
routes/api/containers/[id]/logs/stream/+server.ts
Normal file
452
routes/api/containers/[id]/logs/stream/+server.ts
Normal 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}×tamps=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}×tamps=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'
|
||||
}
|
||||
});
|
||||
};
|
||||
32
routes/api/containers/[id]/pause/+server.ts
Normal file
32
routes/api/containers/[id]/pause/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
53
routes/api/containers/[id]/rename/+server.ts
Normal file
53
routes/api/containers/[id]/rename/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
45
routes/api/containers/[id]/restart/+server.ts
Normal file
45
routes/api/containers/[id]/restart/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
38
routes/api/containers/[id]/start/+server.ts
Normal file
38
routes/api/containers/[id]/start/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
91
routes/api/containers/[id]/stats/+server.ts
Normal file
91
routes/api/containers/[id]/stats/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
38
routes/api/containers/[id]/stop/+server.ts
Normal file
38
routes/api/containers/[id]/stop/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
73
routes/api/containers/[id]/top/+server.ts
Normal file
73
routes/api/containers/[id]/top/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
32
routes/api/containers/[id]/unpause/+server.ts
Normal file
32
routes/api/containers/[id]/unpause/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
43
routes/api/containers/[id]/update/+server.ts
Normal file
43
routes/api/containers/[id]/update/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
548
routes/api/containers/batch-update-stream/+server.ts
Normal file
548
routes/api/containers/batch-update-stream/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
154
routes/api/containers/batch-update/+server.ts
Normal file
154
routes/api/containers/batch-update/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
111
routes/api/containers/check-updates/+server.ts
Normal file
111
routes/api/containers/check-updates/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
67
routes/api/containers/pending-updates/+server.ts
Normal file
67
routes/api/containers/pending-updates/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
24
routes/api/containers/sizes/+server.ts
Normal file
24
routes/api/containers/sizes/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
148
routes/api/containers/stats/+server.ts
Normal file
148
routes/api/containers/stats/+server.ts
Normal 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
|
||||
}
|
||||
};
|
||||
53
routes/api/dashboard/preferences/+server.ts
Normal file
53
routes/api/dashboard/preferences/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
307
routes/api/dashboard/stats/+server.ts
Normal file
307
routes/api/dashboard/stats/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
531
routes/api/dashboard/stats/stream/+server.ts
Normal file
531
routes/api/dashboard/stats/stream/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
26
routes/api/dependencies/+server.ts
Normal file
26
routes/api/dependencies/+server.ts
Normal 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);
|
||||
};
|
||||
129
routes/api/environments/+server.ts
Normal file
129
routes/api/environments/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
158
routes/api/environments/[id]/+server.ts
Normal file
158
routes/api/environments/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
78
routes/api/environments/[id]/notifications/+server.ts
Normal file
78
routes/api/environments/[id]/notifications/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
138
routes/api/environments/[id]/test/+server.ts
Normal file
138
routes/api/environments/[id]/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
75
routes/api/environments/[id]/timezone/+server.ts
Normal file
75
routes/api/environments/[id]/timezone/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
87
routes/api/environments/[id]/update-check/+server.ts
Normal file
87
routes/api/environments/[id]/update-check/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
46
routes/api/environments/detect-socket/+server.ts
Normal file
46
routes/api/environments/detect-socket/+server.ts
Normal 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
|
||||
});
|
||||
};
|
||||
201
routes/api/environments/test/+server.ts
Normal file
201
routes/api/environments/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
137
routes/api/events/+server.ts
Normal file
137
routes/api/events/+server.ts
Normal 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
|
||||
}
|
||||
});
|
||||
};
|
||||
88
routes/api/git/credentials/+server.ts
Normal file
88
routes/api/git/credentials/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
122
routes/api/git/credentials/[id]/+server.ts
Normal file
122
routes/api/git/credentials/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
70
routes/api/git/repositories/+server.ts
Normal file
70
routes/api/git/repositories/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
112
routes/api/git/repositories/[id]/+server.ts
Normal file
112
routes/api/git/repositories/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
24
routes/api/git/repositories/[id]/deploy/+server.ts
Normal file
24
routes/api/git/repositories/[id]/deploy/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
45
routes/api/git/repositories/[id]/sync/+server.ts
Normal file
45
routes/api/git/repositories/[id]/sync/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
24
routes/api/git/repositories/[id]/test/+server.ts
Normal file
24
routes/api/git/repositories/[id]/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
41
routes/api/git/repositories/test/+server.ts
Normal file
41
routes/api/git/repositories/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
144
routes/api/git/stacks/+server.ts
Normal file
144
routes/api/git/stacks/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
112
routes/api/git/stacks/[id]/+server.ts
Normal file
112
routes/api/git/stacks/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
54
routes/api/git/stacks/[id]/deploy-stream/+server.ts
Normal file
54
routes/api/git/stacks/[id]/deploy-stream/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
28
routes/api/git/stacks/[id]/deploy/+server.ts
Normal file
28
routes/api/git/stacks/[id]/deploy/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
75
routes/api/git/stacks/[id]/env-files/+server.ts
Normal file
75
routes/api/git/stacks/[id]/env-files/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
28
routes/api/git/stacks/[id]/sync/+server.ts
Normal file
28
routes/api/git/stacks/[id]/sync/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
28
routes/api/git/stacks/[id]/test/+server.ts
Normal file
28
routes/api/git/stacks/[id]/test/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
97
routes/api/git/stacks/[id]/webhook/+server.ts
Normal file
97
routes/api/git/stacks/[id]/webhook/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
103
routes/api/git/webhook/[id]/+server.ts
Normal file
103
routes/api/git/webhook/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
61
routes/api/hawser/connect/+server.ts
Normal file
61
routes/api/hawser/connect/+server.ts
Normal 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
|
||||
};
|
||||
122
routes/api/hawser/tokens/+server.ts
Normal file
122
routes/api/hawser/tokens/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
6
routes/api/health/+server.ts
Normal file
6
routes/api/health/+server.ts
Normal 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() });
|
||||
};
|
||||
58
routes/api/health/database/+server.ts
Normal file
58
routes/api/health/database/+server.ts
Normal 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
158
routes/api/host/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
39
routes/api/images/+server.ts
Normal file
39
routes/api/images/+server.ts
Normal 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([]);
|
||||
}
|
||||
};
|
||||
61
routes/api/images/[id]/+server.ts
Normal file
61
routes/api/images/[id]/+server.ts
Normal 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
Reference in New Issue
Block a user