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:
@@ -6,7 +6,7 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX } from '$lib/components/icons';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconProxies, IconEvents, IconSettings, IconMenu, IconX } from '$lib/components/icons';
|
||||
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||
import { instanceStatusStore } from '$lib/stores/instance-status';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
@@ -22,6 +22,8 @@
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||
] as const;
|
||||
|
||||
@@ -128,6 +130,10 @@
|
||||
<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'}
|
||||
<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'}
|
||||
<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}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<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 ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.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';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
||||
let staleContainers = $state<StaleContainer[]>([]);
|
||||
let loading = $state(true);
|
||||
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[]> = {};
|
||||
for (const r of results) {
|
||||
mapped[r.projectId] = r.instances;
|
||||
}
|
||||
instancesByProject = mapped;
|
||||
staleContainers = staleResult;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
|
||||
} finally {
|
||||
@@ -59,6 +64,7 @@
|
||||
.flat()
|
||||
.filter((i) => i.status === 'failed').length
|
||||
);
|
||||
const totalStale = $derived(staleContainers.length);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -79,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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 baseVolumePath = $state('');
|
||||
let notificationUrl = $state('');
|
||||
let staleThresholdDays = $state('7');
|
||||
|
||||
let sslCertificateId = $state(0);
|
||||
let sslCertName = $state('');
|
||||
@@ -79,6 +80,7 @@
|
||||
baseVolumePath = settings.base_volume_path ?? '';
|
||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
@@ -101,7 +103,8 @@
|
||||
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||
ssl_certificate_id: sslCertificateId
|
||||
ssl_certificate_id: sslCertificateId,
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7)
|
||||
});
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
@@ -242,6 +245,21 @@
|
||||
</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">
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user