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:
2026-03-30 11:29:10 +03:00
parent 7a85441b81
commit 79a40f3d9c
29 changed files with 2237 additions and 12 deletions
+239
View File
@@ -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}