feat(observability): phases 4-7 - complete frontend UI (big bang)
Add all frontend pages for observability & proxy management: - Proxy Viewer: /proxies with grouped view, filtering, health indicators - Proxy Creation: form with live validation, diagnostic hints, edit/delete - Stale Containers: /containers/stale with dashboard widget, cleanup actions - Event Log: /events with filters, pagination, real-time SSE streaming - Navigation: proxies and events links in sidebar - i18n: full EN/RU translations for all new features - Settings: stale threshold configuration
This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
|||||||
Registry,
|
Registry,
|
||||||
RegistryImage,
|
RegistryImage,
|
||||||
Settings,
|
Settings,
|
||||||
|
StaleContainer,
|
||||||
Stage,
|
Stage,
|
||||||
StageEnv,
|
StageEnv,
|
||||||
StandaloneProxy,
|
StandaloneProxy,
|
||||||
@@ -405,4 +406,18 @@ export function listAllProxies(): Promise<ProxyView[]> {
|
|||||||
return get<ProxyView[]>('/api/proxies/all');
|
return get<ProxyView[]>('/api/proxies/all');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stale Containers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function fetchStaleContainers(): Promise<StaleContainer[]> {
|
||||||
|
return get<StaleContainer[]>('/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 };
|
export { ApiError };
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<!--
|
||||||
|
Event log entry display component.
|
||||||
|
Shows timestamp, severity badge, source icon, message, and expandable metadata.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { EventLogEntry } from '$lib/types';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: EventLogEntry;
|
||||||
|
isNew?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entry, isNew = false }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
// ── Relative time formatting ──────────────────────────────────
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
|
||||||
|
if (diffSec < 60) return `${diffSec}s ago`;
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
if (diffHour < 24) return `${diffHour}h ago`;
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
if (diffDay < 30) return `${diffDay}d ago`;
|
||||||
|
const diffMonth = Math.floor(diffDay / 30);
|
||||||
|
return `${diffMonth}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFull(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Severity styling ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const severityClasses: Record<string, string> = {
|
||||||
|
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
warn: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
error: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityLabelKeys: Record<string, string> = {
|
||||||
|
info: 'events.severity.info',
|
||||||
|
warn: 'events.severity.warn',
|
||||||
|
error: 'events.severity.error'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Metadata parsing ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const parsedMetadata = $derived.by<Record<string, unknown> | null>(() => {
|
||||||
|
if (!entry.metadata || entry.metadata === '{}' || entry.metadata === 'null') return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(entry.metadata);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length > 0) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMetadata = $derived(parsedMetadata !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="group rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-3 transition-all duration-200 hover:border-[var(--border-secondary)]
|
||||||
|
{isNew ? 'animate-fade-in ring-2 ring-[var(--color-brand-200)] dark:ring-[var(--color-brand-800)]' : ''}"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Source icon -->
|
||||||
|
<div class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-[var(--surface-card-hover)] text-[var(--text-tertiary)]">
|
||||||
|
{#if entry.source === 'deploy'}
|
||||||
|
<!-- Rocket -->
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
|
||||||
|
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" />
|
||||||
|
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 3 0 3 0" /><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-3 0-3" />
|
||||||
|
</svg>
|
||||||
|
{:else if entry.source === 'container'}
|
||||||
|
<!-- Box -->
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||||
|
<path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" />
|
||||||
|
</svg>
|
||||||
|
{:else if entry.source === 'proxy'}
|
||||||
|
<!-- Globe -->
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Cog (system) -->
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<!-- Severity badge -->
|
||||||
|
<span class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs font-medium {severityClasses[entry.severity] ?? severityClasses.info}">
|
||||||
|
{$t(severityLabelKeys[entry.severity] ?? 'events.severity.info')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Source label -->
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{$t(`events.source.${entry.source}`)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Timestamp -->
|
||||||
|
<span class="ml-auto shrink-0 text-xs text-[var(--text-tertiary)]" title={formatFull(entry.created_at)}>
|
||||||
|
{timeAgo(entry.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<p class="mt-1 text-sm text-[var(--text-primary)] leading-relaxed">
|
||||||
|
{entry.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Expandable metadata -->
|
||||||
|
{#if hasMetadata}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
||||||
|
onclick={() => { expanded = !expanded; }}
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3 transition-transform duration-200 {expanded ? 'rotate-90' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
{$t('events.metadata')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="mt-2 rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] p-3 animate-fade-in">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries(parsedMetadata ?? {}) as [key, value]}
|
||||||
|
<tr class="border-b border-[var(--border-primary)] last:border-0">
|
||||||
|
<td class="py-1 pr-3 font-medium text-[var(--text-secondary)] whitespace-nowrap align-top">{key}</td>
|
||||||
|
<td class="py-1 text-[var(--text-primary)] break-all">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<!--
|
||||||
|
Event log filter controls component.
|
||||||
|
Severity + source multi-select, date range presets, free-text search.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
severities: string[];
|
||||||
|
sources: string[];
|
||||||
|
dateRange: string;
|
||||||
|
searchText: string;
|
||||||
|
onseveritieschange: (v: string[]) => void;
|
||||||
|
onsourceschange: (v: string[]) => void;
|
||||||
|
ondaterangechange: (v: string) => void;
|
||||||
|
onsearchchange: (v: string) => void;
|
||||||
|
onclear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
severities,
|
||||||
|
sources,
|
||||||
|
dateRange,
|
||||||
|
searchText,
|
||||||
|
onseveritieschange,
|
||||||
|
onsourceschange,
|
||||||
|
ondaterangechange,
|
||||||
|
onsearchchange,
|
||||||
|
onclear
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const allSeverities = ['info', 'warn', 'error'] as const;
|
||||||
|
const allSources = ['deploy', 'container', 'proxy', 'system'] as const;
|
||||||
|
|
||||||
|
const dateRangeOptions = [
|
||||||
|
{ value: '1h', labelKey: 'events.filter.lastHour' },
|
||||||
|
{ value: '24h', labelKey: 'events.filter.last24h' },
|
||||||
|
{ value: '7d', labelKey: 'events.filter.last7d' },
|
||||||
|
{ value: 'all', labelKey: 'events.filter.allTime' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ── Active filter count ──────────────────────────────────────
|
||||||
|
|
||||||
|
const activeFilterCount = $derived(
|
||||||
|
(severities.length < allSeverities.length ? 1 : 0) +
|
||||||
|
(sources.length < allSources.length ? 1 : 0) +
|
||||||
|
(dateRange !== 'all' ? 1 : 0) +
|
||||||
|
(searchText.trim() !== '' ? 1 : 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Toggle helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleSeverity(sev: string): void {
|
||||||
|
const next = severities.includes(sev)
|
||||||
|
? severities.filter((s) => s !== sev)
|
||||||
|
: [...severities, sev];
|
||||||
|
if (next.length > 0) onseveritieschange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSource(src: string): void {
|
||||||
|
const next = sources.includes(src)
|
||||||
|
? sources.filter((s) => s !== src)
|
||||||
|
: [...sources, src];
|
||||||
|
if (next.length > 0) onsourceschange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Severity checkbox colors ─────────────────────────────────
|
||||||
|
|
||||||
|
const severityCheckboxColors: Record<string, string> = {
|
||||||
|
info: 'accent-blue-600',
|
||||||
|
warn: 'accent-amber-600',
|
||||||
|
error: 'accent-red-600'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-4">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:gap-6">
|
||||||
|
<!-- Severity filter -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.severity')}</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#each allSeverities as sev}
|
||||||
|
<label class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={severities.includes(sev)}
|
||||||
|
onchange={() => toggleSeverity(sev)}
|
||||||
|
class="h-3.5 w-3.5 rounded border-[var(--border-primary)] {severityCheckboxColors[sev]}"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-primary)]">{$t(`events.severity.${sev}`)}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source filter -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.source')}</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#each allSources as src}
|
||||||
|
<label class="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sources.includes(src)}
|
||||||
|
onchange={() => toggleSource(src)}
|
||||||
|
class="h-3.5 w-3.5 rounded border-[var(--border-primary)] accent-[var(--color-brand-600)]"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-[var(--text-primary)]">{$t(`events.source.${src}`)}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date range -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-xs font-medium text-[var(--text-secondary)]">{$t('events.filter.dateRange')}</label>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#each dateRangeOptions as opt}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-2 py-1 text-xs font-medium transition-colors
|
||||||
|
{dateRange === opt.value
|
||||||
|
? 'bg-[var(--color-brand-600)] text-white'
|
||||||
|
: 'bg-[var(--surface-card-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
|
||||||
|
onclick={() => ondaterangechange(opt.value)}
|
||||||
|
>
|
||||||
|
{$t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex-1 space-y-1.5 min-w-0">
|
||||||
|
<label class="text-xs font-medium text-[var(--text-secondary)]"> </label>
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--text-tertiary)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={$t('events.filter.search')}
|
||||||
|
value={searchText}
|
||||||
|
oninput={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear button + active count -->
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
onclick={onclear}
|
||||||
|
disabled={activeFilterCount === 0}
|
||||||
|
>
|
||||||
|
{$t('events.filter.clear')}
|
||||||
|
{#if activeFilterCount > 0}
|
||||||
|
<span class="inline-flex h-4 w-4 items-center justify-center rounded-full bg-[var(--color-brand-600)] text-[10px] font-bold text-white">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<!--
|
||||||
|
Phase 4: Individual proxy display card showing domain, destination,
|
||||||
|
type badge, health indicator, SSL badge, and project/stage labels.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ProxyView } from '$lib/types';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import { IconEdit, IconExternalLink, IconLock } from '$lib/components/icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
proxy: ProxyView;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { proxy }: Props = $props();
|
||||||
|
|
||||||
|
const healthColors: Record<string, { dot: string; ring: string }> = {
|
||||||
|
healthy: { dot: 'bg-emerald-500', ring: 'bg-emerald-500' },
|
||||||
|
unhealthy: { dot: 'bg-red-500', ring: 'bg-red-500' },
|
||||||
|
unknown: { dot: 'bg-amber-400', ring: 'bg-amber-400' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthColor = $derived(healthColors[proxy.health_status] ?? healthColors.unknown);
|
||||||
|
const isHealthy = $derived(proxy.health_status === 'healthy');
|
||||||
|
|
||||||
|
const typeBadgeClass = $derived(
|
||||||
|
proxy.type === 'managed'
|
||||||
|
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
|
||||||
|
: 'bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300'
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthLabel = $derived($t(`proxies.health.${proxy.health_status}`));
|
||||||
|
|
||||||
|
function formatTimestamp(iso: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-200 hover:border-[var(--color-brand-300)] hover:shadow-[var(--shadow-md)]">
|
||||||
|
<!-- Top row: domain + health dot -->
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Health indicator -->
|
||||||
|
<span class="relative flex h-2.5 w-2.5 shrink-0" title={healthLabel}>
|
||||||
|
{#if isHealthy}
|
||||||
|
<span class="absolute inline-flex h-full w-full animate-ping rounded-full {healthColor.ring} opacity-50"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="relative inline-flex h-2.5 w-2.5 rounded-full {healthColor.dot}"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Domain link -->
|
||||||
|
<a
|
||||||
|
href="https://{proxy.domain}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="group inline-flex items-center gap-1 truncate text-sm font-semibold text-[var(--text-primary)] hover:text-[var(--color-brand-600)] transition-colors"
|
||||||
|
>
|
||||||
|
<span class="truncate">{proxy.domain}</span>
|
||||||
|
<IconExternalLink size={13} class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destination -->
|
||||||
|
<p class="mt-1 truncate font-mono text-xs text-[var(--text-tertiary)]">
|
||||||
|
{proxy.destination}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type badge -->
|
||||||
|
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {typeBadgeClass}">
|
||||||
|
{proxy.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badges row -->
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<!-- SSL badge -->
|
||||||
|
{#if proxy.ssl_enabled}
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
<IconLock size={11} />
|
||||||
|
SSL
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Health status label -->
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
||||||
|
{healthLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Project / stage labels for managed proxies -->
|
||||||
|
{#if proxy.type === 'managed' && proxy.project_name}
|
||||||
|
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||||
|
{proxy.project_name}
|
||||||
|
</span>
|
||||||
|
{#if proxy.stage_name}
|
||||||
|
<span class="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300">
|
||||||
|
{proxy.stage_name}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer row: edit link (standalone only) + timestamp -->
|
||||||
|
<div class="mt-3 flex items-center justify-between">
|
||||||
|
{#if proxy.type === 'standalone'}
|
||||||
|
<a
|
||||||
|
href="/proxies/{proxy.id}/edit"
|
||||||
|
class="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
||||||
|
>
|
||||||
|
<IconEdit size={12} />
|
||||||
|
{$t('common.edit')}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if proxy.created_at}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{$t('proxies.lastChecked')}: {formatTimestamp(proxy.created_at)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<!--
|
||||||
|
Phase 4: Filter bar for the unified proxy viewer.
|
||||||
|
Provides text search, health status dropdown, type dropdown, and clear filters.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ProxyHealthStatus } from '$lib/types';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import { IconSearch, IconX } from '$lib/components/icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
search: string;
|
||||||
|
healthFilter: ProxyHealthStatus | 'all';
|
||||||
|
typeFilter: 'all' | 'managed' | 'standalone';
|
||||||
|
onsearchchange: (value: string) => void;
|
||||||
|
onhealthchange: (value: ProxyHealthStatus | 'all') => void;
|
||||||
|
ontypechange: (value: 'all' | 'managed' | 'standalone') => void;
|
||||||
|
onclear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
healthFilter,
|
||||||
|
typeFilter,
|
||||||
|
onsearchchange,
|
||||||
|
onhealthchange,
|
||||||
|
ontypechange,
|
||||||
|
onclear
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const hasFilters = $derived(
|
||||||
|
search.length > 0 || healthFilter !== 'all' || typeFilter !== 'all'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<!-- Text search -->
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<IconSearch
|
||||||
|
size={16}
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
oninput={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Health filter -->
|
||||||
|
<select
|
||||||
|
value={healthFilter}
|
||||||
|
onchange={(e) => onhealthchange(e.currentTarget.value as ProxyHealthStatus | 'all')}
|
||||||
|
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||||
|
>
|
||||||
|
<option value="all">{$t('proxies.filter.health')}: {$t('proxies.filter.all')}</option>
|
||||||
|
<option value="healthy">{$t('proxies.health.healthy')}</option>
|
||||||
|
<option value="unhealthy">{$t('proxies.health.unhealthy')}</option>
|
||||||
|
<option value="unknown">{$t('proxies.health.unknown')}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Type filter -->
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onchange={(e) => ontypechange(e.currentTarget.value as 'all' | 'managed' | 'standalone')}
|
||||||
|
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||||
|
>
|
||||||
|
<option value="all">{$t('proxies.filter.type')}: {$t('proxies.filter.all')}</option>
|
||||||
|
<option value="managed">{$t('proxies.managed')}</option>
|
||||||
|
<option value="standalone">{$t('proxies.standalone')}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Clear filters -->
|
||||||
|
{#if hasFilters}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onclear}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
{$t('proxies.filter.clear')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
<!--
|
||||||
|
Phase 6: Create/edit form for standalone proxies.
|
||||||
|
Supports live destination validation with debounce.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { StandaloneProxy, ValidationResult } from '$lib/types';
|
||||||
|
import { validateProxy, createProxy, updateProxy, deleteProxy } from '$lib/api';
|
||||||
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
|
import ValidationChecklist from '$lib/components/ValidationChecklist.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import { IconLoader } from '$lib/components/icons';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
proxy?: StandaloneProxy;
|
||||||
|
onsave?: (proxy: StandaloneProxy) => void;
|
||||||
|
ondelete?: (id: string) => void;
|
||||||
|
oncancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mode, proxy, onsave, ondelete, oncancel }: Props = $props();
|
||||||
|
|
||||||
|
// ── Form state ────────────────────────────────────────────────────
|
||||||
|
let destinationUrl = $state(proxy?.destination_url ?? '');
|
||||||
|
let port = $state(proxy?.destination_port?.toString() ?? '');
|
||||||
|
let domain = $state(proxy?.domain ?? '');
|
||||||
|
|
||||||
|
// ── Validation state ──────────────────────────────────────────────
|
||||||
|
let validationResult: ValidationResult | null = $state(null);
|
||||||
|
let validating = $state(false);
|
||||||
|
let validationTimer: ReturnType<typeof setTimeout> | null = $state(null);
|
||||||
|
|
||||||
|
// ── Submit state ──────────────────────────────────────────────────
|
||||||
|
let submitting = $state(false);
|
||||||
|
let submitError = $state('');
|
||||||
|
|
||||||
|
// ── Delete state ──────────────────────────────────────────────────
|
||||||
|
let deleteConfirmOpen = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
// ── Derived ───────────────────────────────────────────────────────
|
||||||
|
const portNum = $derived(parseInt(port, 10));
|
||||||
|
const portValid = $derived(!isNaN(portNum) && portNum >= 1 && portNum <= 65535);
|
||||||
|
const canSubmit = $derived(
|
||||||
|
destinationUrl.trim().length > 0 &&
|
||||||
|
port.trim().length > 0 &&
|
||||||
|
portValid &&
|
||||||
|
domain.trim().length > 0 &&
|
||||||
|
!submitting
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
mode === 'create' ? $t('proxies.form.title') : $t('proxies.form.editTitle')
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitLabel = $derived(
|
||||||
|
submitting
|
||||||
|
? (mode === 'create' ? $t('proxies.form.create') : $t('proxies.form.save'))
|
||||||
|
: (mode === 'create' ? $t('proxies.form.create') : $t('proxies.form.save'))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Domain auto-suggestion ────────────────────────────────────────
|
||||||
|
function suggestDomain(dest: string): string {
|
||||||
|
if (!dest) return '';
|
||||||
|
try {
|
||||||
|
// If it looks like a URL, parse the hostname
|
||||||
|
const withScheme = dest.includes('://') ? dest : `http://${dest}`;
|
||||||
|
const url = new URL(withScheme);
|
||||||
|
const host = url.hostname;
|
||||||
|
// Strip common prefixes and use as subdomain suggestion
|
||||||
|
const cleaned = host
|
||||||
|
.replace(/^(www|api|app)\./, '')
|
||||||
|
.replace(/\.\w+$/, '')
|
||||||
|
.replace(/[^a-z0-9.-]/gi, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
return cleaned || '';
|
||||||
|
} catch {
|
||||||
|
// If it's a plain IP or hostname, use it directly
|
||||||
|
return dest.replace(/[^a-z0-9.-]/gi, '-').toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live validation with debounce ─────────────────────────────────
|
||||||
|
function scheduleValidation(): void {
|
||||||
|
if (validationTimer !== null) {
|
||||||
|
clearTimeout(validationTimer);
|
||||||
|
}
|
||||||
|
validationResult = null;
|
||||||
|
|
||||||
|
if (!destinationUrl.trim() || !port.trim() || !portValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validationTimer = setTimeout(() => {
|
||||||
|
runValidation();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runValidation(): Promise<void> {
|
||||||
|
if (!destinationUrl.trim() || !portValid) return;
|
||||||
|
|
||||||
|
validating = true;
|
||||||
|
try {
|
||||||
|
validationResult = await validateProxy(destinationUrl.trim(), portNum);
|
||||||
|
} catch {
|
||||||
|
// Validation is advisory -- don't block the UI
|
||||||
|
validationResult = null;
|
||||||
|
} finally {
|
||||||
|
validating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDestinationInput(): void {
|
||||||
|
// Auto-suggest domain only when creating and domain is empty or was auto-generated
|
||||||
|
if (mode === 'create') {
|
||||||
|
const suggested = suggestDomain(destinationUrl);
|
||||||
|
if (!domain || domain === suggestDomain(destinationUrl.slice(0, -1))) {
|
||||||
|
domain = suggested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePortInput(): void {
|
||||||
|
scheduleValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleValidateClick(): void {
|
||||||
|
if (validationTimer !== null) {
|
||||||
|
clearTimeout(validationTimer);
|
||||||
|
}
|
||||||
|
runValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit ────────────────────────────────────────────────────────
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
|
||||||
|
submitting = true;
|
||||||
|
submitError = '';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
domain: domain.trim(),
|
||||||
|
destination_url: destinationUrl.trim(),
|
||||||
|
destination_port: portNum
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = mode === 'create'
|
||||||
|
? await createProxy(data)
|
||||||
|
: await updateProxy(proxy!.id, data);
|
||||||
|
onsave?.(saved);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
submitError = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────
|
||||||
|
async function handleDeleteConfirm(): Promise<void> {
|
||||||
|
if (!proxy) return;
|
||||||
|
deleting = true;
|
||||||
|
try {
|
||||||
|
await deleteProxy(proxy.id);
|
||||||
|
deleteConfirmOpen = false;
|
||||||
|
ondelete?.(proxy.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
submitError = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
} finally {
|
||||||
|
deleting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||||
|
|
||||||
|
<!-- Form fields -->
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label={$t('proxies.form.destination')}
|
||||||
|
name="destination_url"
|
||||||
|
bind:value={destinationUrl}
|
||||||
|
placeholder="192.168.1.100 or http://my-service"
|
||||||
|
required
|
||||||
|
oninput={handleDestinationInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={$t('proxies.form.port')}
|
||||||
|
name="destination_port"
|
||||||
|
type="number"
|
||||||
|
bind:value={port}
|
||||||
|
placeholder="8080"
|
||||||
|
required
|
||||||
|
error={port && !portValid ? $t('validation.invalidPort') : ''}
|
||||||
|
oninput={handlePortInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label={$t('proxies.form.domain')}
|
||||||
|
name="domain"
|
||||||
|
bind:value={domain}
|
||||||
|
placeholder="my-service.example.com"
|
||||||
|
required
|
||||||
|
helpText={$t('proxies.form.domainHelp')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Validation checklist -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<ValidationChecklist result={validationResult} loading={validating} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={!destinationUrl.trim() || !portValid || validating}
|
||||||
|
onclick={handleValidateClick}
|
||||||
|
>
|
||||||
|
{#if validating}
|
||||||
|
<IconLoader size={14} />
|
||||||
|
{$t('proxies.form.validating')}
|
||||||
|
{:else}
|
||||||
|
{$t('proxies.form.validate')}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Validation warning (non-blocking) -->
|
||||||
|
{#if validationResult && !validationResult.valid}
|
||||||
|
<p class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Validation reported issues but you can still create the proxy.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit error -->
|
||||||
|
{#if submitError}
|
||||||
|
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex items-center justify-between pt-2">
|
||||||
|
<div>
|
||||||
|
{#if mode === 'edit'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-950 transition-colors"
|
||||||
|
onclick={() => { deleteConfirmOpen = true; }}
|
||||||
|
>
|
||||||
|
{$t('proxies.form.delete')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||||
|
onclick={() => oncancel?.()}
|
||||||
|
>
|
||||||
|
{$t('proxies.form.cancel')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand-600)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{#if submitting}
|
||||||
|
<IconLoader size={14} />
|
||||||
|
{/if}
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation dialog -->
|
||||||
|
{#if mode === 'edit'}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
title={$t('proxies.form.delete')}
|
||||||
|
message={$t('proxies.form.deleteConfirm')}
|
||||||
|
confirmLabel={deleting ? $t('common.loading') : $t('proxies.form.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={handleDeleteConfirm}
|
||||||
|
oncancel={() => { deleteConfirmOpen = false; }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!--
|
||||||
|
Phase 4: Collapsible group for proxies by project/stage.
|
||||||
|
Shows a header with project name, proxy count, and expandable body.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { IconChevronRight } from '$lib/components/icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, count, defaultExpanded = true, children }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(defaultExpanded);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { expanded = !expanded; }}
|
||||||
|
class="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors hover:bg-[var(--surface-card-hover)]"
|
||||||
|
>
|
||||||
|
<IconChevronRight
|
||||||
|
size={16}
|
||||||
|
class="shrink-0 text-[var(--text-tertiary)] transition-transform duration-200 {expanded ? 'rotate-90' : ''}"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-semibold text-[var(--text-primary)]">{title}</span>
|
||||||
|
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Expandable body -->
|
||||||
|
{#if expanded}
|
||||||
|
<div class="border-t border-[var(--border-primary)] p-4 animate-fade-in">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<!--
|
||||||
|
Card displaying a single stale container with cleanup action.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { StaleContainer } from '$lib/types';
|
||||||
|
import { IconClock, IconTag, IconTrash } from '$lib/components/icons';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
container: StaleContainer;
|
||||||
|
cleaning?: boolean;
|
||||||
|
oncleanup: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container, cleaning = false, oncleanup }: Props = $props();
|
||||||
|
|
||||||
|
const badgeClass = $derived(
|
||||||
|
container.days_stale >= 14
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayName = $derived(
|
||||||
|
`${container.project_name}-${container.stage_name}-${container.image_tag}`
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
if (!iso) return '-';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="truncate text-sm font-semibold text-[var(--text-primary)]" title={displayName}>
|
||||||
|
{displayName}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-md bg-[var(--color-brand-50)] px-2 py-0.5 text-xs font-medium text-[var(--color-brand-600)]">
|
||||||
|
{container.project_name}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
||||||
|
{container.stage_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Days stale badge -->
|
||||||
|
<span class="inline-flex flex-shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold {badgeClass}">
|
||||||
|
<IconClock size={12} />
|
||||||
|
{container.days_stale} {$t('stale.daysStale')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<IconTag size={12} />
|
||||||
|
{container.image_tag}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<IconClock size={12} />
|
||||||
|
{$t('stale.lastAlive')}: {formatDate(container.last_alive_at)}
|
||||||
|
</span>
|
||||||
|
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
|
{container.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cleanup button -->
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={cleaning}
|
||||||
|
onclick={() => oncleanup(container.id)}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-xs font-medium text-[var(--color-danger)] transition-colors hover:bg-[var(--color-danger-light)] disabled:opacity-50 active:animate-press"
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
{$t('stale.cleanup')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!--
|
||||||
|
Phase 6: Validation checklist for proxy destination validation.
|
||||||
|
Shows each validation step with pass/fail/pending status indicators.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ValidationResult } from '$lib/types';
|
||||||
|
import { IconCheck, IconX, IconLoader } from '$lib/components/icons';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
result: ValidationResult | null;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result, loading = false }: Props = $props();
|
||||||
|
|
||||||
|
/** Map step names to i18n keys. */
|
||||||
|
const stepLabelKeys: Record<string, string> = {
|
||||||
|
syntax: 'proxies.validation.syntax',
|
||||||
|
dns: 'proxies.validation.dns',
|
||||||
|
tcp: 'proxies.validation.tcp',
|
||||||
|
http: 'proxies.validation.http'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStepLabel(name: string): string {
|
||||||
|
const key = stepLabelKeys[name];
|
||||||
|
return key ? $t(key) : name;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading || result}
|
||||||
|
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-4">
|
||||||
|
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||||
|
{$t('proxies.validation.title')}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{#if loading && !result}
|
||||||
|
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||||
|
<IconLoader size={16} />
|
||||||
|
<span>{$t('proxies.validation.checking')}</span>
|
||||||
|
</div>
|
||||||
|
{:else if result}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each result.steps as step}
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if step.passed}
|
||||||
|
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
|
||||||
|
<IconCheck size={14} class="text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">{getStepLabel(step.name)}</span>
|
||||||
|
{#if step.message}
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">— {step.message}</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-950">
|
||||||
|
<IconX size={14} class="text-red-600 dark:text-red-400" />
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">{getStepLabel(step.name)}</span>
|
||||||
|
{#if step.message}
|
||||||
|
<span class="text-xs text-[var(--text-tertiary)]">— {step.message}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !step.passed && step.hint}
|
||||||
|
<p class="ml-7 mt-1 text-xs text-amber-600 dark:text-amber-400">{step.hint}</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props { size?: number; class?: string; }
|
||||||
|
const { size = 20, class: c = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class={c} aria-hidden="true">
|
||||||
|
<path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z" /><path d="M15 3v4a2 2 0 0 0 2 2h4" /><path d="M8 13h.01" /><path d="M8 17h.01" /><path d="M12 13h4" /><path d="M12 17h4" />
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props { size?: number; class?: string; }
|
||||||
|
const { size = 20, class: c = '' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class={c} aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" />
|
||||||
|
</svg>
|
||||||
@@ -45,3 +45,5 @@ export { default as IconContainer } from './IconContainer.svelte';
|
|||||||
export { default as IconHardDrive } from './IconHardDrive.svelte';
|
export { default as IconHardDrive } from './IconHardDrive.svelte';
|
||||||
export { default as IconWifi } from './IconWifi.svelte';
|
export { default as IconWifi } from './IconWifi.svelte';
|
||||||
export { default as IconRefresh } from './IconRefresh.svelte';
|
export { default as IconRefresh } from './IconRefresh.svelte';
|
||||||
|
export { default as IconProxies } from './IconProxies.svelte';
|
||||||
|
export { default as IconEvents } from './IconEvents.svelte';
|
||||||
|
|||||||
+119
-2
@@ -7,6 +7,8 @@
|
|||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"projects": "Projects",
|
"projects": "Projects",
|
||||||
"deploy": "Deploy",
|
"deploy": "Deploy",
|
||||||
|
"proxies": "Proxies",
|
||||||
|
"events": "Events",
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -19,7 +21,8 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"noProjects": "No projects yet.",
|
"noProjects": "No projects yet.",
|
||||||
"addFirst": "Add your first project",
|
"addFirst": "Add your first project",
|
||||||
"loadFailed": "Failed to load dashboard"
|
"loadFailed": "Failed to load dashboard",
|
||||||
|
"staleContainers": "Stale Containers"
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Projects",
|
"title": "Projects",
|
||||||
@@ -176,7 +179,9 @@
|
|||||||
"registries": "Registries",
|
"registries": "Registries",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
"appearance": "Appearance"
|
"appearance": "Appearance",
|
||||||
|
"staleThreshold": "Stale threshold (days)",
|
||||||
|
"staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale."
|
||||||
},
|
},
|
||||||
"settingsGeneral": {
|
"settingsGeneral": {
|
||||||
"title": "General Settings",
|
"title": "General Settings",
|
||||||
@@ -320,6 +325,27 @@
|
|||||||
"loginFailed": "Login failed",
|
"loginFailed": "Login failed",
|
||||||
"networkError": "Network error"
|
"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": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@@ -387,6 +413,97 @@
|
|||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"noResults": "No results found"
|
"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": {
|
"language": {
|
||||||
"en": "English",
|
"en": "English",
|
||||||
"ru": "Russian"
|
"ru": "Russian"
|
||||||
|
|||||||
+119
-2
@@ -7,6 +7,8 @@
|
|||||||
"dashboard": "Панель",
|
"dashboard": "Панель",
|
||||||
"projects": "Проекты",
|
"projects": "Проекты",
|
||||||
"deploy": "Деплой",
|
"deploy": "Деплой",
|
||||||
|
"proxies": "Прокси",
|
||||||
|
"events": "События",
|
||||||
"settings": "Настройки"
|
"settings": "Настройки"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -19,7 +21,8 @@
|
|||||||
"retry": "Повторить",
|
"retry": "Повторить",
|
||||||
"noProjects": "Проектов пока нет.",
|
"noProjects": "Проектов пока нет.",
|
||||||
"addFirst": "Добавьте первый проект",
|
"addFirst": "Добавьте первый проект",
|
||||||
"loadFailed": "Не удалось загрузить панель"
|
"loadFailed": "Не удалось загрузить панель",
|
||||||
|
"staleContainers": "Устаревшие контейнеры"
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Проекты",
|
"title": "Проекты",
|
||||||
@@ -176,7 +179,9 @@
|
|||||||
"registries": "Реестры",
|
"registries": "Реестры",
|
||||||
"credentials": "Учётные данные",
|
"credentials": "Учётные данные",
|
||||||
"authentication": "Аутентификация",
|
"authentication": "Аутентификация",
|
||||||
"appearance": "Внешний вид"
|
"appearance": "Внешний вид",
|
||||||
|
"staleThreshold": "Порог устаревания (дни)",
|
||||||
|
"staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие."
|
||||||
},
|
},
|
||||||
"settingsGeneral": {
|
"settingsGeneral": {
|
||||||
"title": "Общие настройки",
|
"title": "Общие настройки",
|
||||||
@@ -320,6 +325,27 @@
|
|||||||
"loginFailed": "Ошибка входа",
|
"loginFailed": "Ошибка входа",
|
||||||
"networkError": "Ошибка сети"
|
"networkError": "Ошибка сети"
|
||||||
},
|
},
|
||||||
|
"proxies": {
|
||||||
|
"title": "Менеджер прокси",
|
||||||
|
"create": "Создать прокси",
|
||||||
|
"standalone": "Автономные прокси",
|
||||||
|
"managed": "Управляемые прокси",
|
||||||
|
"noProxies": "Прокси не найдены",
|
||||||
|
"noProxiesDesc": "Создайте автономный прокси или разверните проект с включённым прокси.",
|
||||||
|
"filter": {
|
||||||
|
"search": "Поиск по домену или назначению...",
|
||||||
|
"health": "Здоровье",
|
||||||
|
"type": "Тип",
|
||||||
|
"all": "Все",
|
||||||
|
"clear": "Сбросить фильтры"
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"healthy": "Здоров",
|
||||||
|
"unhealthy": "Нездоров",
|
||||||
|
"unknown": "Неизвестно"
|
||||||
|
},
|
||||||
|
"lastChecked": "Последняя проверка"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
@@ -387,6 +413,97 @@
|
|||||||
"search": "Поиск...",
|
"search": "Поиск...",
|
||||||
"noResults": "Ничего не найдено"
|
"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": {
|
"language": {
|
||||||
"en": "Английский",
|
"en": "Английский",
|
||||||
"ru": "Русский"
|
"ru": "Русский"
|
||||||
|
|||||||
+14
-2
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status';
|
export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log';
|
||||||
|
|
||||||
export interface SSEEvent<T = unknown> {
|
export interface SSEEvent<T = unknown> {
|
||||||
type: SSEEventType;
|
type: SSEEventType;
|
||||||
@@ -36,7 +36,16 @@ export interface DeployStatusPayload {
|
|||||||
error?: string;
|
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 {
|
export interface SSEOptions {
|
||||||
/** Called for each SSE event received. */
|
/** Called for each SSE event received. */
|
||||||
@@ -179,6 +188,7 @@ export function connectDeployLogs(
|
|||||||
export function connectGlobalEvents(callbacks: {
|
export function connectGlobalEvents(callbacks: {
|
||||||
onInstanceStatus?: (payload: InstanceStatusPayload) => void;
|
onInstanceStatus?: (payload: InstanceStatusPayload) => void;
|
||||||
onDeployStatus?: (payload: DeployStatusPayload) => void;
|
onDeployStatus?: (payload: DeployStatusPayload) => void;
|
||||||
|
onEventLog?: (payload: EventLogSSEPayload) => void;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
onError?: (attempt: number) => void;
|
onError?: (attempt: number) => void;
|
||||||
}): SSEConnection {
|
}): SSEConnection {
|
||||||
@@ -188,6 +198,8 @@ export function connectGlobalEvents(callbacks: {
|
|||||||
callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload);
|
callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload);
|
||||||
} else if (event.type === 'deploy_status') {
|
} else if (event.type === 'deploy_status') {
|
||||||
callbacks.onDeployStatus?.(event.payload as DeployStatusPayload);
|
callbacks.onDeployStatus?.(event.payload as DeployStatusPayload);
|
||||||
|
} else if (event.type === 'event_log') {
|
||||||
|
callbacks.onEventLog?.(event.payload as EventLogSSEPayload);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpen: callbacks.onOpen,
|
onOpen: callbacks.onOpen,
|
||||||
|
|||||||
@@ -207,6 +207,19 @@ export interface StandaloneProxy {
|
|||||||
/** Health status for a proxy. */
|
/** Health status for a proxy. */
|
||||||
export type ProxyHealthStatus = 'unknown' | 'healthy' | 'unhealthy';
|
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. */
|
/** A single step in the validation pipeline. */
|
||||||
export interface ValidationStep {
|
export interface ValidationStep {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.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 { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||||
import { instanceStatusStore } from '$lib/stores/instance-status';
|
import { instanceStatusStore } from '$lib/stores/instance-status';
|
||||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
||||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
{ 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' }
|
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -128,6 +130,10 @@
|
|||||||
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
{:else if item.icon === 'deploy'}
|
{:else if item.icon === 'deploy'}
|
||||||
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
|
{:else if item.icon === 'proxies'}
|
||||||
|
<IconProxies size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
|
{:else if item.icon === 'events'}
|
||||||
|
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
{:else if item.icon === 'settings'}
|
{:else if item.icon === 'settings'}
|
||||||
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Project, Instance } from '$lib/types';
|
import type { Project, Instance, StaleContainer } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import { IconDeploy, IconBox, IconServer, IconAlert } from '$lib/components/icons';
|
import { IconDeploy, IconBox, IconServer, IconAlert, IconClock } from '$lib/components/icons';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let projects = $state<Project[]>([]);
|
let projects = $state<Project[]>([]);
|
||||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
let instancesByProject = $state<Record<string, Instance[]>>({});
|
||||||
|
let staleContainers = $state<StaleContainer[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@@ -31,12 +32,16 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(detailPromises);
|
const [results, staleResult] = await Promise.all([
|
||||||
|
Promise.all(detailPromises),
|
||||||
|
api.fetchStaleContainers().catch(() => [] as StaleContainer[])
|
||||||
|
]);
|
||||||
const mapped: Record<string, Instance[]> = {};
|
const mapped: Record<string, Instance[]> = {};
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
mapped[r.projectId] = r.instances;
|
mapped[r.projectId] = r.instances;
|
||||||
}
|
}
|
||||||
instancesByProject = mapped;
|
instancesByProject = mapped;
|
||||||
|
staleContainers = staleResult;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
|
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -59,6 +64,7 @@
|
|||||||
.flat()
|
.flat()
|
||||||
.filter((i) => i.status === 'failed').length
|
.filter((i) => i.status === 'failed').length
|
||||||
);
|
);
|
||||||
|
const totalStale = $derived(staleContainers.length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -79,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats cards -->
|
<!-- Stats cards -->
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||||
<IconBox size={24} />
|
<IconBox size={24} />
|
||||||
@@ -107,6 +113,15 @@
|
|||||||
<p class="mt-0.5 text-2xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-[var(--text-primary)]'}">{totalFailed}</p>
|
<p class="mt-0.5 text-2xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-[var(--text-primary)]'}">{totalFailed}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<a href="/containers/stale" class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalStale > 0 ? 'bg-amber-50 text-amber-600' : 'bg-gray-50 text-gray-400'}">
|
||||||
|
<IconClock size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.staleContainers')}</p>
|
||||||
|
<p class="mt-0.5 text-2xl font-bold {totalStale > 0 ? 'text-amber-600' : 'text-[var(--text-primary)]'}">{totalStale}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project cards -->
|
<!-- Project cards -->
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { StaleContainer } from '$lib/types';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import StaleContainerCard from '$lib/components/StaleContainerCard.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||||
|
import { IconTrash, IconLoader } from '$lib/components/icons';
|
||||||
|
import { toasts } from '$lib/stores/toast';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
let containers = $state<StaleContainer[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
let confirmSingleId = $state('');
|
||||||
|
let confirmBulk = $state(false);
|
||||||
|
let cleaningIds = $state<Set<string>>(new Set());
|
||||||
|
let bulkCleaning = $state(false);
|
||||||
|
|
||||||
|
async function loadStale() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
containers = await api.fetchStaleContainers();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $t('stale.loadFailed');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCleanup(id: string) {
|
||||||
|
confirmSingleId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmCleanup() {
|
||||||
|
const id = confirmSingleId;
|
||||||
|
confirmSingleId = '';
|
||||||
|
cleaningIds = new Set([...cleaningIds, id]);
|
||||||
|
try {
|
||||||
|
await api.cleanupStaleContainer(id);
|
||||||
|
containers = containers.filter((c) => c.id !== id);
|
||||||
|
toasts.success($t('stale.cleanedUp'));
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
|
||||||
|
} finally {
|
||||||
|
const next = new Set(cleaningIds);
|
||||||
|
next.delete(id);
|
||||||
|
cleaningIds = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmBulkCleanup() {
|
||||||
|
confirmBulk = false;
|
||||||
|
bulkCleaning = true;
|
||||||
|
try {
|
||||||
|
const result = await api.bulkCleanupStaleContainers();
|
||||||
|
containers = [];
|
||||||
|
toasts.success($t('stale.bulkCleanedUp', { count: String(result.deleted) }));
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
|
||||||
|
} finally {
|
||||||
|
bulkCleaning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadStale();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('stale.title')} - {$t('app.name')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('stale.title')}</h1>
|
||||||
|
{#if containers.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={bulkCleaning}
|
||||||
|
onclick={() => { confirmBulk = true; }}
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2.5 text-sm font-medium text-[var(--color-danger)] transition-colors hover:bg-[var(--color-danger-light)] disabled:opacity-50 active:animate-press"
|
||||||
|
>
|
||||||
|
{#if bulkCleaning}<IconLoader size={16} />{/if}
|
||||||
|
<IconTrash size={16} />
|
||||||
|
{$t('stale.cleanupAll')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<SkeletonCard />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||||
|
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline"
|
||||||
|
onclick={loadStale}
|
||||||
|
>
|
||||||
|
{$t('common.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if containers.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
title={$t('stale.noStale')}
|
||||||
|
description={$t('stale.noStaleDesc')}
|
||||||
|
icon="instances"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each containers as container (container.id)}
|
||||||
|
<StaleContainerCard
|
||||||
|
{container}
|
||||||
|
cleaning={cleaningIds.has(container.id)}
|
||||||
|
oncleanup={requestCleanup}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single cleanup confirm -->
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmSingleId !== ''}
|
||||||
|
title={$t('stale.cleanup')}
|
||||||
|
message={$t('stale.confirmCleanup')}
|
||||||
|
confirmLabel={$t('stale.cleanup')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={handleConfirmCleanup}
|
||||||
|
oncancel={() => { confirmSingleId = ''; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Bulk cleanup confirm -->
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmBulk}
|
||||||
|
title={$t('stale.cleanupAll')}
|
||||||
|
message={$t('stale.confirmBulkCleanup')}
|
||||||
|
confirmLabel={$t('stale.cleanupAll')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={handleConfirmBulkCleanup}
|
||||||
|
oncancel={() => { confirmBulk = false; }}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Client-side only — data is fetched in the component.
|
||||||
|
export const ssr = false;
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<!--
|
||||||
|
Event Log page.
|
||||||
|
Displays a filterable, paginated, real-time event log.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import { fetchEventLog, fetchEventLogStats } from '$lib/api';
|
||||||
|
import { connectGlobalEvents, type SSEConnection, type EventLogSSEPayload } from '$lib/sse';
|
||||||
|
import type { EventLogEntry, EventLogStats } from '$lib/types';
|
||||||
|
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
||||||
|
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let events = $state<EventLogEntry[]>([]);
|
||||||
|
let stats = $state<EventLogStats>({ info: 0, warn: 0, error: 0, total: 0 });
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
|
let hasMore = $state(true);
|
||||||
|
let newEventIds = $state<Set<number>>(new Set());
|
||||||
|
let pendingNewEvents = $state<EventLogEntry[]>([]);
|
||||||
|
let scrolledDown = $state(false);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
let severities = $state<string[]>(['info', 'warn', 'error']);
|
||||||
|
let sources = $state<string[]>(['deploy', 'container', 'proxy', 'system']);
|
||||||
|
let dateRange = $state('all');
|
||||||
|
let searchText = $state('');
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
let offset = $state(0);
|
||||||
|
|
||||||
|
let sseConnection: SSEConnection | null = null;
|
||||||
|
let listEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
// ── Date range to ISO string ─────────────────────────────────
|
||||||
|
|
||||||
|
function getDateRangeSince(range: string): string | undefined {
|
||||||
|
if (range === 'all') return undefined;
|
||||||
|
const now = Date.now();
|
||||||
|
const offsets: Record<string, number> = {
|
||||||
|
'1h': 60 * 60 * 1000,
|
||||||
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
|
'7d': 7 * 24 * 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
const ms = offsets[range];
|
||||||
|
if (!ms) return undefined;
|
||||||
|
return new Date(now - ms).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load data ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadEvents(append = false): Promise<void> {
|
||||||
|
const currentOffset = append ? offset : 0;
|
||||||
|
if (append) {
|
||||||
|
loadingMore = true;
|
||||||
|
} else {
|
||||||
|
loading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const severityParam = severities.length < 3 ? severities.join(',') : undefined;
|
||||||
|
const sourceParam = sources.length < 4 ? sources.join(',') : undefined;
|
||||||
|
const sinceParam = getDateRangeSince(dateRange);
|
||||||
|
|
||||||
|
const result = await fetchEventLog({
|
||||||
|
severity: severityParam,
|
||||||
|
source: sourceParam,
|
||||||
|
since: sinceParam,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: currentOffset
|
||||||
|
});
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
events = [...events, ...result];
|
||||||
|
} else {
|
||||||
|
events = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore = result.length === PAGE_SIZE;
|
||||||
|
offset = (append ? currentOffset : 0) + result.length;
|
||||||
|
} catch {
|
||||||
|
// Error silently for now — user sees empty state.
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats(): Promise<void> {
|
||||||
|
try {
|
||||||
|
stats = await fetchEventLogStats();
|
||||||
|
} catch {
|
||||||
|
// Keep default stats on error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore(): void {
|
||||||
|
loadEvents(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter change handlers ───────────────────────────────────
|
||||||
|
|
||||||
|
function handleSeveritiesChange(v: string[]): void {
|
||||||
|
severities = v;
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSourcesChange(v: string[]): void {
|
||||||
|
sources = v;
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDateRangeChange(v: string): void {
|
||||||
|
dateRange = v;
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchChange(v: string): void {
|
||||||
|
searchText = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear(): void {
|
||||||
|
severities = ['info', 'warn', 'error'];
|
||||||
|
sources = ['deploy', 'container', 'proxy', 'system'];
|
||||||
|
dateRange = 'all';
|
||||||
|
searchText = '';
|
||||||
|
loadEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Client-side text filter ──────────────────────────────────
|
||||||
|
|
||||||
|
const filteredEvents = $derived(
|
||||||
|
searchText.trim() === ''
|
||||||
|
? events
|
||||||
|
: events.filter((e) => e.message.toLowerCase().includes(searchText.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── SSE real-time events ─────────────────────────────────────
|
||||||
|
|
||||||
|
function handleSSEEvent(payload: EventLogSSEPayload): void {
|
||||||
|
const newEntry: EventLogEntry = {
|
||||||
|
id: payload.id,
|
||||||
|
source: payload.source,
|
||||||
|
severity: payload.severity as EventLogEntry['severity'],
|
||||||
|
message: payload.message,
|
||||||
|
metadata: payload.metadata,
|
||||||
|
created_at: payload.created_at
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update stats.
|
||||||
|
stats = {
|
||||||
|
...stats,
|
||||||
|
[newEntry.severity]: (stats[newEntry.severity] ?? 0) + 1,
|
||||||
|
total: stats.total + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scrolledDown) {
|
||||||
|
pendingNewEvents = [newEntry, ...pendingNewEvents];
|
||||||
|
} else {
|
||||||
|
events = [newEntry, ...events];
|
||||||
|
newEventIds = new Set([...newEventIds, newEntry.id]);
|
||||||
|
// Clear "new" highlight after animation.
|
||||||
|
setTimeout(() => {
|
||||||
|
newEventIds = new Set([...newEventIds].filter((id) => id !== newEntry.id));
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPendingEvents(): void {
|
||||||
|
events = [...pendingNewEvents, ...events];
|
||||||
|
const ids = new Set([...newEventIds, ...pendingNewEvents.map((e) => e.id)]);
|
||||||
|
newEventIds = ids;
|
||||||
|
pendingNewEvents = [];
|
||||||
|
|
||||||
|
// Scroll to top.
|
||||||
|
listEl?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
|
||||||
|
// Clear highlights after animation.
|
||||||
|
setTimeout(() => {
|
||||||
|
newEventIds = new Set();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll tracking ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleScroll(): void {
|
||||||
|
if (!listEl) return;
|
||||||
|
scrolledDown = listEl.scrollTop > 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lifecycle ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadEvents();
|
||||||
|
loadStats();
|
||||||
|
|
||||||
|
sseConnection = connectGlobalEvents({
|
||||||
|
onEventLog(payload) {
|
||||||
|
handleSSEEvent(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
sseConnection?.close();
|
||||||
|
sseConnection = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('events.title')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats bar -->
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<div class="flex items-center gap-1.5 rounded-md bg-blue-50 px-2.5 py-1 dark:bg-blue-900/30">
|
||||||
|
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
|
||||||
|
<span class="text-xs font-medium text-blue-700 dark:text-blue-300">{$t('events.severity.info')}</span>
|
||||||
|
<span class="text-xs font-bold text-blue-800 dark:text-blue-200">{stats.info}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 dark:bg-amber-900/30">
|
||||||
|
<div class="h-2 w-2 rounded-full bg-amber-500"></div>
|
||||||
|
<span class="text-xs font-medium text-amber-700 dark:text-amber-300">{$t('events.severity.warn')}</span>
|
||||||
|
<span class="text-xs font-bold text-amber-800 dark:text-amber-200">{stats.warn}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 rounded-md bg-red-50 px-2.5 py-1 dark:bg-red-900/30">
|
||||||
|
<div class="h-2 w-2 rounded-full bg-red-500"></div>
|
||||||
|
<span class="text-xs font-medium text-red-700 dark:text-red-300">{$t('events.severity.error')}</span>
|
||||||
|
<span class="text-xs font-bold text-red-800 dark:text-red-200">{stats.error}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 rounded-md bg-[var(--surface-card-hover)] px-2.5 py-1">
|
||||||
|
<span class="text-xs text-[var(--text-secondary)]">Total</span>
|
||||||
|
<span class="text-xs font-bold text-[var(--text-primary)]">{stats.total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<EventLogFilter
|
||||||
|
{severities}
|
||||||
|
{sources}
|
||||||
|
{dateRange}
|
||||||
|
{searchText}
|
||||||
|
onseveritieschange={handleSeveritiesChange}
|
||||||
|
onsourceschange={handleSourcesChange}
|
||||||
|
ondaterangechange={handleDateRangeChange}
|
||||||
|
onsearchchange={handleSearchChange}
|
||||||
|
onclear={handleClear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- "N new events" banner -->
|
||||||
|
{#if pendingNewEvents.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-[var(--color-brand-700)] animate-fade-in"
|
||||||
|
onclick={showPendingEvents}
|
||||||
|
>
|
||||||
|
{pendingNewEvents.length} {$t('events.newEvents')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Event list -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-16">
|
||||||
|
<svg class="h-6 w-6 animate-spin text-[var(--color-brand-600)]" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else if filteredEvents.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
title={$t('events.noEvents')}
|
||||||
|
description={$t('events.noEventsDesc')}
|
||||||
|
icon="deploys"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
bind:this={listEl}
|
||||||
|
onscroll={handleScroll}
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
{#each filteredEvents as entry (entry.id)}
|
||||||
|
<EventLogEntryComponent
|
||||||
|
{entry}
|
||||||
|
isNew={newEventIds.has(entry.id)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
{#if hasMore && searchText.trim() === ''}
|
||||||
|
<div class="flex justify-center pt-4 pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||||
|
onclick={loadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
>
|
||||||
|
{#if loadingMore}
|
||||||
|
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{$t('events.loadMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Event log page — all data loaded client-side.
|
||||||
|
export const ssr = false;
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<!--
|
||||||
|
Phase 4: Unified Proxy Viewer — shows all proxies (managed + standalone)
|
||||||
|
with grouping, filtering, and real-time health indicators.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ProxyView, ProxyHealthStatus } from '$lib/types';
|
||||||
|
import { listAllProxies } from '$lib/api';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import ProxyCard from '$lib/components/ProxyCard.svelte';
|
||||||
|
import ProxyGroup from '$lib/components/ProxyGroup.svelte';
|
||||||
|
import ProxyFilter from '$lib/components/ProxyFilter.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import { IconGlobe, IconLoader } from '$lib/components/icons';
|
||||||
|
|
||||||
|
let proxies = $state<ProxyView[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
let search = $state('');
|
||||||
|
let healthFilter = $state<ProxyHealthStatus | 'all'>('all');
|
||||||
|
let typeFilter = $state<'all' | 'managed' | 'standalone'>('all');
|
||||||
|
|
||||||
|
// Filtered proxies
|
||||||
|
const filtered = $derived(() => {
|
||||||
|
let result = proxies;
|
||||||
|
|
||||||
|
// Text search
|
||||||
|
if (search.length > 0) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(p) =>
|
||||||
|
p.domain.toLowerCase().includes(q) ||
|
||||||
|
p.destination.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health filter
|
||||||
|
if (healthFilter !== 'all') {
|
||||||
|
result = result.filter((p) => p.health_status === healthFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
if (typeFilter !== 'all') {
|
||||||
|
result = result.filter((p) => p.type === typeFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Split into standalone and managed
|
||||||
|
const standaloneProxies = $derived(filtered().filter((p) => p.type === 'standalone'));
|
||||||
|
const managedProxies = $derived(filtered().filter((p) => p.type === 'managed'));
|
||||||
|
|
||||||
|
// Group managed proxies by project, then stage within each project
|
||||||
|
interface StageGroup {
|
||||||
|
stageName: string;
|
||||||
|
proxies: ProxyView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectGroup {
|
||||||
|
projectName: string;
|
||||||
|
stages: StageGroup[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const managedGroups = $derived<ProjectGroup[]>(() => {
|
||||||
|
const projectMap = new Map<string, Map<string, ProxyView[]>>();
|
||||||
|
|
||||||
|
for (const proxy of managedProxies) {
|
||||||
|
const projName = proxy.project_name ?? 'Unknown';
|
||||||
|
const stageName = proxy.stage_name ?? 'default';
|
||||||
|
|
||||||
|
if (!projectMap.has(projName)) {
|
||||||
|
projectMap.set(projName, new Map());
|
||||||
|
}
|
||||||
|
const stageMap = projectMap.get(projName)!;
|
||||||
|
|
||||||
|
if (!stageMap.has(stageName)) {
|
||||||
|
stageMap.set(stageName, []);
|
||||||
|
}
|
||||||
|
stageMap.get(stageName)!.push(proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: ProjectGroup[] = [];
|
||||||
|
for (const [projectName, stageMap] of projectMap) {
|
||||||
|
const stages: StageGroup[] = [];
|
||||||
|
let totalCount = 0;
|
||||||
|
for (const [stageName, stageProxies] of stageMap) {
|
||||||
|
stages.push({ stageName, proxies: stageProxies });
|
||||||
|
totalCount += stageProxies.length;
|
||||||
|
}
|
||||||
|
groups.push({ projectName, stages, totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.sort((a, b) => a.projectName.localeCompare(b.projectName));
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearFilters(): void {
|
||||||
|
search = '';
|
||||||
|
healthFilter = 'all';
|
||||||
|
typeFilter = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProxies(): Promise<void> {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
proxies = await listAllProxies();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to load proxies';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadProxies();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('proxies.title')} - {$t('app.name')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||||
|
<IconGlobe size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1>
|
||||||
|
{#if !loading && proxies.length > 0}
|
||||||
|
<p class="text-sm text-[var(--text-tertiary)]">
|
||||||
|
{proxies.length} {proxies.length === 1 ? 'proxy' : 'proxies'}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/proxies/create"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||||
|
</svg>
|
||||||
|
{$t('proxies.create')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-20">
|
||||||
|
<IconLoader size={24} class="animate-spin text-[var(--color-brand-500)]" />
|
||||||
|
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<!-- Error state -->
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900 dark:bg-red-950">
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={loadProxies}
|
||||||
|
class="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
{$t('common.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if proxies.length === 0}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<EmptyState
|
||||||
|
title={$t('proxies.noProxies')}
|
||||||
|
description={$t('proxies.noProxiesDesc')}
|
||||||
|
actionLabel={$t('proxies.create')}
|
||||||
|
actionHref="/proxies/create"
|
||||||
|
icon="projects"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<ProxyFilter
|
||||||
|
{search}
|
||||||
|
{healthFilter}
|
||||||
|
{typeFilter}
|
||||||
|
onsearchchange={(v) => { search = v; }}
|
||||||
|
onhealthchange={(v) => { healthFilter = v; }}
|
||||||
|
ontypechange={(v) => { typeFilter = v; }}
|
||||||
|
onclear={clearFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No filter results -->
|
||||||
|
{#if filtered().length === 0}
|
||||||
|
<div class="rounded-xl border-2 border-dashed border-[var(--border-primary)] px-6 py-16 text-center">
|
||||||
|
<p class="text-sm text-[var(--text-secondary)]">{$t('proxies.noProxies')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearFilters}
|
||||||
|
class="mt-3 text-sm font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
||||||
|
>
|
||||||
|
{$t('proxies.filter.clear')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Standalone proxies section -->
|
||||||
|
{#if standaloneProxies.length > 0}
|
||||||
|
<ProxyGroup title={$t('proxies.standalone')} count={standaloneProxies.length}>
|
||||||
|
{#each standaloneProxies as proxy (proxy.id)}
|
||||||
|
<ProxyCard {proxy} />
|
||||||
|
{/each}
|
||||||
|
</ProxyGroup>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Managed proxies grouped by project -->
|
||||||
|
{#if managedGroups().length > 0}
|
||||||
|
{#each managedGroups() as group (group.projectName)}
|
||||||
|
<ProxyGroup title={group.projectName} count={group.totalCount}>
|
||||||
|
{#each group.stages as stage (stage.stageName)}
|
||||||
|
{#if group.stages.length > 1}
|
||||||
|
<div class="col-span-full">
|
||||||
|
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||||
|
{stage.stageName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each stage.proxies as proxy (proxy.id)}
|
||||||
|
<ProxyCard {proxy} />
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</ProxyGroup>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Client-side loading — data is fetched in the component via $effect.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!--
|
||||||
|
Phase 6: Edit Proxy page — loads a standalone proxy and wraps ProxyForm in edit mode.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { StandaloneProxy } from '$lib/types';
|
||||||
|
import { getProxy } from '$lib/api';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import ProxyForm from '$lib/components/ProxyForm.svelte';
|
||||||
|
import { IconGlobe, IconLoader } from '$lib/components/icons';
|
||||||
|
|
||||||
|
let proxy: StandaloneProxy | null = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
const proxyId = $derived($page.params.id);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
proxy = await getProxy(proxyId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error = err instanceof Error ? err.message : 'Failed to load proxy';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSave(_proxy: StandaloneProxy): void {
|
||||||
|
goto('/proxies');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(_id: string): void {
|
||||||
|
goto('/proxies');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel(): void {
|
||||||
|
goto('/proxies');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('proxies.form.editTitle')} - {$t('app.name')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Back link -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a
|
||||||
|
href="/proxies"
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
{$t('common.back')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||||
|
<IconGlobe size={22} />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.form.editTitle')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center py-20">
|
||||||
|
<IconLoader size={24} class="text-[var(--color-brand-500)]" />
|
||||||
|
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900 dark:bg-red-950">
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||||
|
<a
|
||||||
|
href="/proxies"
|
||||||
|
class="mt-3 inline-block rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
{$t('common.back')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else if proxy}
|
||||||
|
<!-- Form card -->
|
||||||
|
<div class="mx-auto max-w-2xl rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<ProxyForm
|
||||||
|
mode="edit"
|
||||||
|
{proxy}
|
||||||
|
onsave={handleSave}
|
||||||
|
ondelete={handleDelete}
|
||||||
|
oncancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Client-side loading — proxy data is fetched in the component.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!--
|
||||||
|
Phase 6: Create Proxy page — wraps ProxyForm in create mode.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { StandaloneProxy } from '$lib/types';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import ProxyForm from '$lib/components/ProxyForm.svelte';
|
||||||
|
import { IconGlobe } from '$lib/components/icons';
|
||||||
|
|
||||||
|
function handleSave(_proxy: StandaloneProxy): void {
|
||||||
|
goto('/proxies');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel(): void {
|
||||||
|
goto('/proxies');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('proxies.form.title')} - {$t('app.name')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Back link -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a
|
||||||
|
href="/proxies"
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
{$t('common.back')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||||
|
<IconGlobe size={22} />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.form.title')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form card -->
|
||||||
|
<div class="mx-auto max-w-2xl rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||||
|
<ProxyForm
|
||||||
|
mode="create"
|
||||||
|
onsave={handleSave}
|
||||||
|
oncancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// Client-side loading — ProxyForm handles data fetching.
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
let pollingInterval = $state('');
|
let pollingInterval = $state('');
|
||||||
let baseVolumePath = $state('');
|
let baseVolumePath = $state('');
|
||||||
let notificationUrl = $state('');
|
let notificationUrl = $state('');
|
||||||
|
let staleThresholdDays = $state('7');
|
||||||
|
|
||||||
let sslCertificateId = $state(0);
|
let sslCertificateId = $state(0);
|
||||||
let sslCertName = $state('');
|
let sslCertName = $state('');
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
baseVolumePath = settings.base_volume_path ?? '';
|
baseVolumePath = settings.base_volume_path ?? '';
|
||||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||||
notificationUrl = settings.notification_url ?? '';
|
notificationUrl = settings.notification_url ?? '';
|
||||||
|
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,7 +103,8 @@
|
|||||||
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
||||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.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'));
|
toasts.success($t('settingsGeneral.saved'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -242,6 +245,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Stale Detection -->
|
||||||
|
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||||
|
<h3 class="mb-3 text-sm font-semibold text-[var(--text-primary)]">{$t('stale.title')}</h3>
|
||||||
|
<div class="max-w-xs">
|
||||||
|
<FormField
|
||||||
|
label={$t('settings.staleThreshold')}
|
||||||
|
name="staleThresholdDays"
|
||||||
|
type="number"
|
||||||
|
bind:value={staleThresholdDays}
|
||||||
|
placeholder="7"
|
||||||
|
helpText={$t('settings.staleThresholdHelp')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||||
{#if saving}<IconLoader size={16} />{/if}
|
{#if saving}<IconLoader size={16} />{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user