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:
@@ -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 IconWifi } from './IconWifi.svelte';
|
||||
export { default as IconRefresh } from './IconRefresh.svelte';
|
||||
export { default as IconProxies } from './IconProxies.svelte';
|
||||
export { default as IconEvents } from './IconEvents.svelte';
|
||||
|
||||
Reference in New Issue
Block a user