192204a51c
Build / build (push) Failing after 4m51s
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).
212 lines
9.0 KiB
Svelte
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>
|