diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2cdf9c0..9206d17 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -13,6 +13,7 @@ import type { Registry, RegistryImage, Settings, + StaleContainer, Stage, StageEnv, StandaloneProxy, @@ -405,4 +406,18 @@ export function listAllProxies(): Promise { return get('/api/proxies/all'); } +// ── Stale Containers ──────────────────────────────────────────────── + +export function fetchStaleContainers(): Promise { + return get('/api/containers/stale'); +} + +export function cleanupStaleContainer(id: string): Promise<{ deleted: string }> { + return post<{ deleted: string }>(`/api/containers/stale/${id}/cleanup`); +} + +export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> { + return post<{ deleted: number }>('/api/containers/stale/cleanup'); +} + export { ApiError }; diff --git a/web/src/lib/components/EventLogEntry.svelte b/web/src/lib/components/EventLogEntry.svelte new file mode 100644 index 0000000..b3fb727 --- /dev/null +++ b/web/src/lib/components/EventLogEntry.svelte @@ -0,0 +1,161 @@ + + + +
+
+ +
+ {#if entry.source === 'deploy'} + + + + + + + {:else if entry.source === 'container'} + + + + + + {:else if entry.source === 'proxy'} + + + + + {:else} + + + + + + {/if} +
+ + +
+
+ + + {$t(severityLabelKeys[entry.severity] ?? 'events.severity.info')} + + + + + {$t(`events.source.${entry.source}`)} + + + + + {timeAgo(entry.created_at)} + +
+ + +

+ {entry.message} +

+ + + {#if hasMetadata} + + + {#if expanded} +
+ + + {#each Object.entries(parsedMetadata ?? {}) as [key, value]} + + + + + {/each} + +
{key}{typeof value === 'object' ? JSON.stringify(value) : String(value)}
+
+ {/if} + {/if} +
+
+
diff --git a/web/src/lib/components/EventLogFilter.svelte b/web/src/lib/components/EventLogFilter.svelte new file mode 100644 index 0000000..46652a6 --- /dev/null +++ b/web/src/lib/components/EventLogFilter.svelte @@ -0,0 +1,167 @@ + + + +
+
+ +
+ +
+ {#each allSeverities as sev} + + {/each} +
+
+ + +
+ +
+ {#each allSources as src} + + {/each} +
+
+ + +
+ +
+ {#each dateRangeOptions as opt} + + {/each} +
+
+ + +
+ +
+ + + + onsearchchange((e.target as HTMLInputElement).value)} + class="w-full rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] py-1.5 pl-8 pr-3 text-xs text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]" + /> +
+
+ + +
+ +
+
+
diff --git a/web/src/lib/components/ProxyCard.svelte b/web/src/lib/components/ProxyCard.svelte new file mode 100644 index 0000000..2b2c9b6 --- /dev/null +++ b/web/src/lib/components/ProxyCard.svelte @@ -0,0 +1,129 @@ + + + +
+ +
+
+
+ + + {#if isHealthy} + + {/if} + + + + + + {proxy.domain} + + +
+ + +

+ {proxy.destination} +

+
+ + + + {proxy.type} + +
+ + +
+ + {#if proxy.ssl_enabled} + + + SSL + + {/if} + + + + {healthLabel} + + + + {#if proxy.type === 'managed' && proxy.project_name} + + {proxy.project_name} + + {#if proxy.stage_name} + + {proxy.stage_name} + + {/if} + {/if} +
+ + +
+ {#if proxy.type === 'standalone'} + + + {$t('common.edit')} + + {:else} + + {/if} + + {#if proxy.created_at} +

+ {$t('proxies.lastChecked')}: {formatTimestamp(proxy.created_at)} +

+ {/if} +
+
diff --git a/web/src/lib/components/ProxyFilter.svelte b/web/src/lib/components/ProxyFilter.svelte new file mode 100644 index 0000000..80be70e --- /dev/null +++ b/web/src/lib/components/ProxyFilter.svelte @@ -0,0 +1,85 @@ + + + +
+ +
+ + onsearchchange(e.currentTarget.value)} + placeholder={$t('proxies.filter.search')} + class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2 pl-9 pr-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]" + /> +
+ + + + + + + + + {#if hasFilters} + + {/if} +
diff --git a/web/src/lib/components/ProxyForm.svelte b/web/src/lib/components/ProxyForm.svelte new file mode 100644 index 0000000..d5a10fa --- /dev/null +++ b/web/src/lib/components/ProxyForm.svelte @@ -0,0 +1,292 @@ + + + +
+ +

{title}

+ + +
{ e.preventDefault(); handleSubmit(); }} class="space-y-4"> + + + + + + + +
+ + + +
+ + + {#if validationResult && !validationResult.valid} +

+ Validation reported issues but you can still create the proxy. +

+ {/if} + + + {#if submitError} +

{submitError}

+ {/if} + + +
+
+ {#if mode === 'edit'} + + {/if} +
+ +
+ + + +
+
+ +
+ + +{#if mode === 'edit'} + { deleteConfirmOpen = false; }} + /> +{/if} diff --git a/web/src/lib/components/ProxyGroup.svelte b/web/src/lib/components/ProxyGroup.svelte new file mode 100644 index 0000000..17cd76d --- /dev/null +++ b/web/src/lib/components/ProxyGroup.svelte @@ -0,0 +1,46 @@ + + + +
+ + + + + {#if expanded} +
+
+ {@render children()} +
+
+ {/if} +
diff --git a/web/src/lib/components/StaleContainerCard.svelte b/web/src/lib/components/StaleContainerCard.svelte new file mode 100644 index 0000000..63dd09b --- /dev/null +++ b/web/src/lib/components/StaleContainerCard.svelte @@ -0,0 +1,85 @@ + + + +
+ +
+
+

+ {displayName} +

+
+ + {container.project_name} + + + {container.stage_name} + +
+
+ + + + + {container.days_stale} {$t('stale.daysStale')} + +
+ + +
+ + + {container.image_tag} + + + + {$t('stale.lastAlive')}: {formatDate(container.last_alive_at)} + + + {container.status} + +
+ + +
+ +
+
diff --git a/web/src/lib/components/ValidationChecklist.svelte b/web/src/lib/components/ValidationChecklist.svelte new file mode 100644 index 0000000..54f9a4d --- /dev/null +++ b/web/src/lib/components/ValidationChecklist.svelte @@ -0,0 +1,73 @@ + + + +{#if loading || result} +
+

+ {$t('proxies.validation.title')} +

+ + {#if loading && !result} +
+ + {$t('proxies.validation.checking')} +
+ {:else if result} +
    + {#each result.steps as step} +
  • +
    + {#if step.passed} + + + + {getStepLabel(step.name)} + {#if step.message} + — {step.message} + {/if} + {:else} + + + + {getStepLabel(step.name)} + {#if step.message} + — {step.message} + {/if} + {/if} +
    + {#if !step.passed && step.hint} +

    {step.hint}

    + {/if} +
  • + {/each} +
+ {/if} +
+{/if} diff --git a/web/src/lib/components/icons/IconEvents.svelte b/web/src/lib/components/icons/IconEvents.svelte new file mode 100644 index 0000000..207772a --- /dev/null +++ b/web/src/lib/components/icons/IconEvents.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconProxies.svelte b/web/src/lib/components/icons/IconProxies.svelte new file mode 100644 index 0000000..b6fdede --- /dev/null +++ b/web/src/lib/components/icons/IconProxies.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/index.ts b/web/src/lib/components/icons/index.ts index a38f759..049c76b 100644 --- a/web/src/lib/components/icons/index.ts +++ b/web/src/lib/components/icons/index.ts @@ -45,3 +45,5 @@ export { default as IconContainer } from './IconContainer.svelte'; export { default as IconHardDrive } from './IconHardDrive.svelte'; export { default as IconWifi } from './IconWifi.svelte'; export { default as IconRefresh } from './IconRefresh.svelte'; +export { default as IconProxies } from './IconProxies.svelte'; +export { default as IconEvents } from './IconEvents.svelte'; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 424d07d..2560eda 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -7,6 +7,8 @@ "dashboard": "Dashboard", "projects": "Projects", "deploy": "Deploy", + "proxies": "Proxies", + "events": "Events", "settings": "Settings" }, "dashboard": { @@ -19,7 +21,8 @@ "retry": "Retry", "noProjects": "No projects yet.", "addFirst": "Add your first project", - "loadFailed": "Failed to load dashboard" + "loadFailed": "Failed to load dashboard", + "staleContainers": "Stale Containers" }, "projects": { "title": "Projects", @@ -176,7 +179,9 @@ "registries": "Registries", "credentials": "Credentials", "authentication": "Authentication", - "appearance": "Appearance" + "appearance": "Appearance", + "staleThreshold": "Stale threshold (days)", + "staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale." }, "settingsGeneral": { "title": "General Settings", @@ -320,6 +325,27 @@ "loginFailed": "Login failed", "networkError": "Network error" }, + "proxies": { + "title": "Proxy Manager", + "create": "Create Proxy", + "standalone": "Standalone Proxies", + "managed": "Managed Proxies", + "noProxies": "No proxies found", + "noProxiesDesc": "Create a standalone proxy or deploy a project with proxy enabled.", + "filter": { + "search": "Search by domain or destination...", + "health": "Health", + "type": "Type", + "all": "All", + "clear": "Clear filters" + }, + "health": { + "healthy": "Healthy", + "unhealthy": "Unhealthy", + "unknown": "Unknown" + }, + "lastChecked": "Last checked" + }, "common": { "cancel": "Cancel", "confirm": "Confirm", @@ -387,6 +413,97 @@ "search": "Search...", "noResults": "No results found" }, + "stale": { + "title": "Stale Containers", + "noStale": "No stale containers", + "noStaleDesc": "All containers are healthy and running.", + "cleanup": "Clean up", + "cleanupAll": "Clean up all", + "confirmCleanup": "This will stop and remove the container. Continue?", + "confirmBulkCleanup": "This will stop and remove all stale containers. Continue?", + "daysStale": "days stale", + "lastAlive": "Last alive", + "count": "Stale", + "cleanedUp": "Container cleaned up", + "bulkCleanedUp": "{count} containers cleaned up", + "cleanupFailed": "Cleanup failed", + "loadFailed": "Failed to load stale containers" + }, + "proxies": { + "title": "Proxies", + "create": "Create Proxy", + "noProxies": "No proxies configured yet.", + "noProxiesDesc": "Create a standalone proxy or deploy a project to see proxies here.", + "standalone": "Standalone Proxies", + "managed": "Managed", + "lastChecked": "Last checked", + "health": { + "healthy": "Healthy", + "unhealthy": "Unhealthy", + "unknown": "Unknown" + }, + "filter": { + "search": "Search proxies...", + "health": "Health", + "type": "Type", + "all": "All", + "clear": "Clear filters" + }, + "form": { + "title": "Create Proxy", + "editTitle": "Edit Proxy", + "destination": "Destination URL / IP", + "port": "Port", + "domain": "Domain", + "domainHelp": "The public domain for this proxy.", + "validate": "Validate", + "validating": "Validating...", + "create": "Create Proxy", + "save": "Save Changes", + "cancel": "Cancel", + "delete": "Delete", + "deleteConfirm": "Delete this proxy? This cannot be undone." + }, + "validation": { + "title": "Destination Validation", + "syntax": "URL syntax", + "dns": "DNS resolution", + "tcp": "TCP connection", + "http": "HTTP response", + "checking": "Checking...", + "skipped": "Skipped" + } + }, + "events": { + "title": "Event Log", + "noEvents": "No events found", + "noEventsDesc": "Events will appear here as they occur.", + "loadMore": "Load more", + "newEvents": "new events", + "filter": { + "severity": "Severity", + "source": "Source", + "dateRange": "Date range", + "search": "Search events...", + "lastHour": "Last hour", + "last24h": "Last 24 hours", + "last7d": "Last 7 days", + "allTime": "All time", + "clear": "Clear filters" + }, + "severity": { + "info": "Info", + "warn": "Warning", + "error": "Error" + }, + "source": { + "deploy": "Deploy", + "container": "Container", + "proxy": "Proxy", + "system": "System" + }, + "metadata": "Details" + }, "language": { "en": "English", "ru": "Russian" diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 082dbf7..a1054a4 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -7,6 +7,8 @@ "dashboard": "Панель", "projects": "Проекты", "deploy": "Деплой", + "proxies": "Прокси", + "events": "События", "settings": "Настройки" }, "dashboard": { @@ -19,7 +21,8 @@ "retry": "Повторить", "noProjects": "Проектов пока нет.", "addFirst": "Добавьте первый проект", - "loadFailed": "Не удалось загрузить панель" + "loadFailed": "Не удалось загрузить панель", + "staleContainers": "Устаревшие контейнеры" }, "projects": { "title": "Проекты", @@ -176,7 +179,9 @@ "registries": "Реестры", "credentials": "Учётные данные", "authentication": "Аутентификация", - "appearance": "Внешний вид" + "appearance": "Внешний вид", + "staleThreshold": "Порог устаревания (дни)", + "staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие." }, "settingsGeneral": { "title": "Общие настройки", @@ -320,6 +325,27 @@ "loginFailed": "Ошибка входа", "networkError": "Ошибка сети" }, + "proxies": { + "title": "Менеджер прокси", + "create": "Создать прокси", + "standalone": "Автономные прокси", + "managed": "Управляемые прокси", + "noProxies": "Прокси не найдены", + "noProxiesDesc": "Создайте автономный прокси или разверните проект с включённым прокси.", + "filter": { + "search": "Поиск по домену или назначению...", + "health": "Здоровье", + "type": "Тип", + "all": "Все", + "clear": "Сбросить фильтры" + }, + "health": { + "healthy": "Здоров", + "unhealthy": "Нездоров", + "unknown": "Неизвестно" + }, + "lastChecked": "Последняя проверка" + }, "common": { "cancel": "Отмена", "confirm": "Подтвердить", @@ -387,6 +413,97 @@ "search": "Поиск...", "noResults": "Ничего не найдено" }, + "stale": { + "title": "Устаревшие контейнеры", + "noStale": "Нет устаревших контейнеров", + "noStaleDesc": "Все контейнеры исправны и работают.", + "cleanup": "Очистить", + "cleanupAll": "Очистить все", + "confirmCleanup": "Это остановит и удалит контейнер. Продолжить?", + "confirmBulkCleanup": "Это остановит и удалит все устаревшие контейнеры. Продолжить?", + "daysStale": "дней устарел", + "lastAlive": "Последний раз жив", + "count": "Устаревшие", + "cleanedUp": "Контейнер очищен", + "bulkCleanedUp": "{count} контейнеров очищено", + "cleanupFailed": "Не удалось очистить", + "loadFailed": "Не удалось загрузить устаревшие контейнеры" + }, + "proxies": { + "title": "Прокси", + "create": "Создать прокси", + "noProxies": "Прокси ещё не настроены.", + "noProxiesDesc": "Создайте автономный прокси или разверните проект, чтобы увидеть прокси здесь.", + "standalone": "Автономные прокси", + "managed": "Управляемые", + "lastChecked": "Последняя проверка", + "health": { + "healthy": "Работает", + "unhealthy": "Недоступен", + "unknown": "Неизвестно" + }, + "filter": { + "search": "Поиск прокси...", + "health": "Здоровье", + "type": "Тип", + "all": "Все", + "clear": "Сбросить фильтры" + }, + "form": { + "title": "Создать прокси", + "editTitle": "Редактировать прокси", + "destination": "URL / IP назначения", + "port": "Порт", + "domain": "Домен", + "domainHelp": "Публичный домен для этого прокси.", + "validate": "Проверить", + "validating": "Проверка...", + "create": "Создать прокси", + "save": "Сохранить изменения", + "cancel": "Отмена", + "delete": "Удалить", + "deleteConfirm": "Удалить этот прокси? Это действие необратимо." + }, + "validation": { + "title": "Проверка назначения", + "syntax": "Синтаксис URL", + "dns": "DNS разрешение", + "tcp": "TCP подключение", + "http": "HTTP ответ", + "checking": "Проверка...", + "skipped": "Пропущено" + } + }, + "events": { + "title": "Журнал событий", + "noEvents": "Событий не найдено", + "noEventsDesc": "События будут отображаться здесь по мере их возникновения.", + "loadMore": "Загрузить ещё", + "newEvents": "новых событий", + "filter": { + "severity": "Уровень", + "source": "Источник", + "dateRange": "Период", + "search": "Поиск событий...", + "lastHour": "Последний час", + "last24h": "Последние 24 часа", + "last7d": "Последние 7 дней", + "allTime": "За всё время", + "clear": "Сбросить фильтры" + }, + "severity": { + "info": "Инфо", + "warn": "Предупреждение", + "error": "Ошибка" + }, + "source": { + "deploy": "Развёртывание", + "container": "Контейнер", + "proxy": "Прокси", + "system": "Система" + }, + "metadata": "Подробности" + }, "language": { "en": "Английский", "ru": "Русский" diff --git a/web/src/lib/sse.ts b/web/src/lib/sse.ts index 43926a1..7f04651 100644 --- a/web/src/lib/sse.ts +++ b/web/src/lib/sse.ts @@ -7,7 +7,7 @@ // ── Types ────────────────────────────────────────────────────────── -export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status'; +export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log'; export interface SSEEvent { type: SSEEventType; @@ -36,7 +36,16 @@ export interface DeployStatusPayload { error?: string; } -type SSEPayload = DeployLogPayload | InstanceStatusPayload | DeployStatusPayload; +export interface EventLogSSEPayload { + id: number; + source: string; + severity: string; + message: string; + metadata: string; + created_at: string; +} + +type SSEPayload = DeployLogPayload | InstanceStatusPayload | DeployStatusPayload | EventLogSSEPayload; export interface SSEOptions { /** Called for each SSE event received. */ @@ -179,6 +188,7 @@ export function connectDeployLogs( export function connectGlobalEvents(callbacks: { onInstanceStatus?: (payload: InstanceStatusPayload) => void; onDeployStatus?: (payload: DeployStatusPayload) => void; + onEventLog?: (payload: EventLogSSEPayload) => void; onOpen?: () => void; onError?: (attempt: number) => void; }): SSEConnection { @@ -188,6 +198,8 @@ export function connectGlobalEvents(callbacks: { callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload); } else if (event.type === 'deploy_status') { callbacks.onDeployStatus?.(event.payload as DeployStatusPayload); + } else if (event.type === 'event_log') { + callbacks.onEventLog?.(event.payload as EventLogSSEPayload); } }, onOpen: callbacks.onOpen, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 18db92b..8e7f5ca 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -207,6 +207,19 @@ export interface StandaloneProxy { /** Health status for a proxy. */ export type ProxyHealthStatus = 'unknown' | 'healthy' | 'unhealthy'; +/** A container detected as stale by the backend poller. */ +export interface StaleContainer { + id: string; + project_name: string; + stage_name: string; + image_tag: string; + container_id: string; + status: string; + last_alive_at: string; + days_stale: number; + created_at: string; +} + /** A single step in the validation pipeline. */ export interface ValidationStep { name: string; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index f03609a..ad1f695 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,7 +6,7 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; - import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX } from '$lib/components/icons'; + import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX } from '$lib/components/icons'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { instanceStatusStore } from '$lib/stores/instance-status'; import { resolvedTheme, applyTheme } from '$lib/stores/theme'; @@ -22,6 +22,8 @@ { href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' }, { href: '/projects', labelKey: 'nav.projects', icon: 'projects' }, { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, + { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' }, + { href: '/events', labelKey: 'nav.events', icon: 'events' }, { href: '/settings', labelKey: 'nav.settings', icon: 'settings' } ] as const; @@ -128,6 +130,10 @@ {:else if item.icon === 'deploy'} + {:else if item.icon === 'proxies'} + + {:else if item.icon === 'events'} + {:else if item.icon === 'settings'} {/if} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 68fa09b..f7d89ec 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,14 +1,15 @@ @@ -79,7 +85,7 @@ -
+ diff --git a/web/src/routes/containers/stale/+page.svelte b/web/src/routes/containers/stale/+page.svelte new file mode 100644 index 0000000..9d54c2f --- /dev/null +++ b/web/src/routes/containers/stale/+page.svelte @@ -0,0 +1,152 @@ + + + + {$t('stale.title')} - {$t('app.name')} + + +
+ +
+

{$t('stale.title')}

+ {#if containers.length > 0} + + {/if} +
+ + + {#if loading} +
+ {#each Array(3) as _} + + {/each} +
+ {:else if error} +
+

{error}

+ +
+ {:else if containers.length === 0} + + {:else} +
+ {#each containers as container (container.id)} + + {/each} +
+ {/if} +
+ + + { confirmSingleId = ''; }} +/> + + + { confirmBulk = false; }} +/> diff --git a/web/src/routes/containers/stale/+page.ts b/web/src/routes/containers/stale/+page.ts new file mode 100644 index 0000000..161a35d --- /dev/null +++ b/web/src/routes/containers/stale/+page.ts @@ -0,0 +1,2 @@ +// Client-side only — data is fetched in the component. +export const ssr = false; diff --git a/web/src/routes/events/+page.svelte b/web/src/routes/events/+page.svelte new file mode 100644 index 0000000..ba1db48 --- /dev/null +++ b/web/src/routes/events/+page.svelte @@ -0,0 +1,314 @@ + + + +
+ +
+

{$t('events.title')}

+
+ + +
+
+
+ {$t('events.severity.info')} + {stats.info} +
+
+
+ {$t('events.severity.warn')} + {stats.warn} +
+
+
+ {$t('events.severity.error')} + {stats.error} +
+
+ Total + {stats.total} +
+
+ + + + + + {#if pendingNewEvents.length > 0} + + {/if} + + + {#if loading} +
+ + + + +
+ {:else if filteredEvents.length === 0} + + {:else} +
+ {#each filteredEvents as entry (entry.id)} + + {/each} + + + {#if hasMore && searchText.trim() === ''} +
+ +
+ {/if} +
+ {/if} +
diff --git a/web/src/routes/events/+page.ts b/web/src/routes/events/+page.ts new file mode 100644 index 0000000..7d860cf --- /dev/null +++ b/web/src/routes/events/+page.ts @@ -0,0 +1,2 @@ +// Event log page — all data loaded client-side. +export const ssr = false; diff --git a/web/src/routes/proxies/+page.svelte b/web/src/routes/proxies/+page.svelte new file mode 100644 index 0000000..0319b34 --- /dev/null +++ b/web/src/routes/proxies/+page.svelte @@ -0,0 +1,239 @@ + + + + + {$t('proxies.title')} - {$t('app.name')} + + + +
+
+
+ +
+
+

{$t('proxies.title')}

+ {#if !loading && proxies.length > 0} +

+ {proxies.length} {proxies.length === 1 ? 'proxy' : 'proxies'} +

+ {/if} +
+
+ + + + + + {$t('proxies.create')} + +
+ + +{#if loading} +
+ + {$t('common.loading')} +
+{:else if error} + +
+

{error}

+ +
+{:else if proxies.length === 0} + + +{:else} + +
+ { search = v; }} + onhealthchange={(v) => { healthFilter = v; }} + ontypechange={(v) => { typeFilter = v; }} + onclear={clearFilters} + /> +
+ + + {#if filtered().length === 0} +
+

{$t('proxies.noProxies')}

+ +
+ {:else} +
+ + {#if standaloneProxies.length > 0} + + {#each standaloneProxies as proxy (proxy.id)} + + {/each} + + {/if} + + + {#if managedGroups().length > 0} + {#each managedGroups() as group (group.projectName)} + + {#each group.stages as stage (stage.stageName)} + {#if group.stages.length > 1} +
+

+ {stage.stageName} +

+
+ {/if} + {#each stage.proxies as proxy (proxy.id)} + + {/each} + {/each} +
+ {/each} + {/if} +
+ {/if} +{/if} diff --git a/web/src/routes/proxies/+page.ts b/web/src/routes/proxies/+page.ts new file mode 100644 index 0000000..0aef742 --- /dev/null +++ b/web/src/routes/proxies/+page.ts @@ -0,0 +1 @@ +// Client-side loading — data is fetched in the component via $effect. diff --git a/web/src/routes/proxies/[id]/edit/+page.svelte b/web/src/routes/proxies/[id]/edit/+page.svelte new file mode 100644 index 0000000..ede08ee --- /dev/null +++ b/web/src/routes/proxies/[id]/edit/+page.svelte @@ -0,0 +1,94 @@ + + + + + {$t('proxies.form.editTitle')} - {$t('app.name')} + + + + + + +
+
+ +
+

{$t('proxies.form.editTitle')}

+
+ +{#if loading} +
+ + {$t('common.loading')} +
+{:else if error} + +{:else if proxy} + +
+ +
+{/if} diff --git a/web/src/routes/proxies/[id]/edit/+page.ts b/web/src/routes/proxies/[id]/edit/+page.ts new file mode 100644 index 0000000..12c4939 --- /dev/null +++ b/web/src/routes/proxies/[id]/edit/+page.ts @@ -0,0 +1 @@ +// Client-side loading — proxy data is fetched in the component. diff --git a/web/src/routes/proxies/create/+page.svelte b/web/src/routes/proxies/create/+page.svelte new file mode 100644 index 0000000..2e32f76 --- /dev/null +++ b/web/src/routes/proxies/create/+page.svelte @@ -0,0 +1,52 @@ + + + + + {$t('proxies.form.title')} - {$t('app.name')} + + + + + + +
+
+ +
+

{$t('proxies.form.title')}

+
+ + +
+ +
diff --git a/web/src/routes/proxies/create/+page.ts b/web/src/routes/proxies/create/+page.ts new file mode 100644 index 0000000..c480e82 --- /dev/null +++ b/web/src/routes/proxies/create/+page.ts @@ -0,0 +1 @@ +// Client-side loading — ProxyForm handles data fetching. diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 9cff48f..3ad60a3 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -20,6 +20,7 @@ let pollingInterval = $state(''); let baseVolumePath = $state(''); let notificationUrl = $state(''); + let staleThresholdDays = $state('7'); let sslCertificateId = $state(0); let sslCertName = $state(''); @@ -79,6 +80,7 @@ baseVolumePath = settings.base_volume_path ?? ''; sslCertificateId = settings.ssl_certificate_id ?? 0; notificationUrl = settings.notification_url ?? ''; + staleThresholdDays = String(settings.stale_threshold_days ?? 7); } catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed')); } finally { @@ -101,7 +103,8 @@ domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(), subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(), base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(), - ssl_certificate_id: sslCertificateId + ssl_certificate_id: sslCertificateId, + stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7) }); toasts.success($t('settingsGeneral.saved')); } catch (err) { @@ -242,6 +245,21 @@
+ +
+

{$t('stale.title')}

+
+ +
+
+