Files
tiny-forge/web/src/routes/proxies/+page.svelte
T
alexei.dolgolyov 192204a51c
Build / build (push) Failing after 4m51s
feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
Sidebar tabs, Settings, and drill-in detail pages re-fetched on every
visit (loading=true + onMount), flashing an empty skeleton frame on each
navigation. Add an SWR cache layer so revisiting a view renders cached
data instantly while refreshing in the background.

- resourceCache.ts: single-value + keyed (per-id) SWR cache factories
- caches.ts: per-resource cache instances; resetAllCaches() on logout
- eventsSnapshot.ts: warm-seed snapshot for the SSE/paginated events page
- List/sidebar pages read $cache.value via $derived, refresh() on mount;
  mutations refresh the cache
- Settings forms seed once from settingsCache (edit-safe) and refetch
  after save (PUT /api/settings returns {status}, not the Settings object)
- Detail [id] pages warm-seed per id; apps/[id] seeds {workload,containers},
  resets non-seeded panels on warm nav, clears workload on 404, and
  invalidates its cache entry on delete

Deferred (still cold-fetch): triggers/[id] (webhook secret + multi-fetch
body gate), apps/new (create wizard).
2026-06-08 15:39:25 +03:00

212 lines
9.0 KiB
Svelte

<script lang="ts">
import { get } from 'svelte/store';
import type { ProxyRoute, ProxyRouteSource } from '$lib/types';
import { proxyRoutesCache } from '$lib/stores/caches';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
type SourceFilter = 'all' | ProxyRouteSource;
// Cache-backed (stale-while-revalidate) so revisiting this tab renders the
// routes immediately instead of flashing the cold skeleton. See caches.ts.
const routes = $derived<ProxyRoute[]>($proxyRoutesCache.value);
const loading = $derived($proxyRoutesCache.loading);
let search = $state('');
let sourceFilter = $state<SourceFilter>('all');
const counts = $derived({
all: routes.length,
instance: routes.filter((r) => r.source === 'instance').length,
static_site: routes.filter((r) => r.source === 'static_site').length,
});
const filtered = $derived.by(() => {
const q = search.trim().toLowerCase();
return routes.filter((r) => {
if (sourceFilter !== 'all' && r.source !== sourceFilter) return false;
if (!q) return true;
return (
r.domain?.toLowerCase().includes(q) ||
r.project_name.toLowerCase().includes(q) ||
r.stage_name.toLowerCase().includes(q) ||
r.image_tag.toLowerCase().includes(q)
);
});
});
function sourceLabel(route: ProxyRoute): string {
if (route.source === 'static_site') {
return route.stage_name === 'deno' ? $t('proxies.sourceDeno') : $t('proxies.sourceStatic');
}
return $t('proxies.sourceContainer');
}
function sourceBadgeClass(source: ProxyRouteSource): string {
return source === 'static_site'
? 'bg-[var(--color-accent-50)] text-[var(--color-accent-700)] dark:bg-[var(--color-accent-900)] dark:text-[var(--color-accent-200)]'
: 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)] dark:text-[var(--color-brand-200)]';
}
// Legacy /projects/{id} and /sites/{id} routes were retired with the
// hard cutover. Proxy rows now point at the workload-first containers
// page filtered by name; the app deep-link is not available because
// proxy_route rows don't carry an app_id today.
function targetHref(route: ProxyRoute): string {
const q = encodeURIComponent(route.project_name ?? '');
return q ? `/containers?q=${q}` : '/containers';
}
// `project_id` on a ProxyRoute is actually the workload ID
// (back-compat naming — see internal/store/models.go:110-113). Anchor
// at the bindings section on /apps/[id] so the operator lands directly
// on the trigger list for this workload.
function triggersHref(route: ProxyRoute): string {
return route.project_id ? `/apps/${route.project_id}#bindings` : '/triggers';
}
async function loadRoutes() {
await proxyRoutesCache.refresh();
// Cache stores the error rather than throwing; surface it as a toast to
// preserve the page's prior failure UX.
const { error } = get(proxyRoutesCache);
if (error) toasts.error(error || $t('proxies.loadFailed'));
}
$effect(() => {
loadRoutes();
});
</script>
<svelte:head>
<title>{$t('proxies.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
<ForgeHero
eyebrowSuffix={$t('nav.proxies').toUpperCase()}
title={$t('proxies.title')}
lede={$t('proxies.description')}
size="lg"
/>
{#if loading}
<div class="space-y-3">
{#each Array(3) as _}
<Skeleton height="4rem" />
{/each}
</div>
{:else if routes.length === 0}
<EmptyState title={$t('proxies.noRoutes')} description={$t('proxies.noRoutesDesc')} icon="instances" />
{:else}
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<input
type="text"
bind:value={search}
placeholder={$t('proxies.searchPlaceholder')}
class="w-full max-w-md rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm 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 class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5 text-sm" role="tablist" aria-label={$t('proxies.source')}>
{#each [
{ value: 'all' as const, label: $t('proxies.filterAll'), count: counts.all },
{ value: 'instance' as const, label: $t('proxies.filterContainers'), count: counts.instance },
{ value: 'static_site' as const, label: $t('proxies.filterSites'), count: counts.static_site },
] as opt}
<button
type="button"
role="tab"
aria-selected={sourceFilter === opt.value}
onclick={() => (sourceFilter = opt.value)}
class="rounded-md px-3 py-1.5 transition-colors {sourceFilter === opt.value
? 'bg-[var(--color-brand-500)] text-white shadow-[var(--shadow-xs)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
>
{opt.label}
<span class="ml-1 text-xs opacity-75">({opt.count})</span>
</button>
{/each}
</div>
</div>
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.domain')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.source')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.project')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.stage')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.tag')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.port')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.status')}</th>
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.actions')}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each filtered as route (route.source + ':' + route.instance_id)}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-3">
{#if route.domain}
<a href="https://{route.domain}" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] underline transition-colors">
{route.domain}
</a>
{:else}
<span class="text-sm text-[var(--text-tertiary)]">{route.subdomain || '—'}</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {sourceBadgeClass(route.source)}">
{sourceLabel(route)}
</span>
</td>
<td class="px-4 py-3">
<a href={targetHref(route)} class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors">
{route.project_name}
</a>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">{route.stage_name || '—'}</td>
<td class="px-4 py-3">
{#if route.image_tag}
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{route.image_tag}</span>
{:else}
<span class="text-sm text-[var(--text-tertiary)]"></span>
{/if}
</td>
<td class="px-4 py-3 text-sm font-mono text-[var(--text-secondary)]">{route.port > 0 ? route.port : '—'}</td>
<td class="px-4 py-3">
<StatusBadge status={route.status} />
</td>
<td class="px-4 py-3 text-right">
{#if route.project_id}
<a
href={triggersHref(route)}
title={$t('proxies.viewTriggersTitle')}
class="inline-flex items-center rounded-md border border-[var(--border-primary)] bg-[var(--surface-card)] px-2.5 py-1 text-xs font-medium text-[var(--text-secondary)] transition-colors hover:border-[var(--color-brand-500)] hover:text-[var(--text-primary)]"
>
{$t('proxies.viewTriggers')}
</a>
{:else}
<span class="text-xs text-[var(--text-tertiary)]"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if filtered.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('proxies.noMatch')}</p>
{/if}
<p class="text-xs text-[var(--text-tertiary)]">
{filtered.length} {filtered.length === 1 ? $t('proxies.route') : $t('proxies.routes')}
</p>
{/if}
</div>