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
@@ -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; }}
/>
+2
View File
@@ -0,0 +1,2 @@
// Client-side only — data is fetched in the component.
export const ssr = false;