feat: unified THE FORGE // SECTION headers and merged proxy routes
Build / build (push) Successful in 10m37s
Build / build (push) Successful in 10m37s
UI consistency
- ForgeHero now supports backHref, mono kicker, stats snippet, staggered
entrance animation, and a registration-tick divider
- Every route now opens with the same "THE FORGE // SECTION" eyebrow: projects,
sites, stacks, proxies, events, dns, deploy, settings, stale containers,
site/project detail + env/volumes/browse, new site wizard
- Stacks list/detail/new moved to the shared hero and brand-anchor eyebrow
- Toolbars migrated from bespoke buttons to the shared .forge-btn utilities
- Sidebar footline adds a live UTC "forge clock" and a vim-style g-prefix
quick-nav hint (g d/p/s/k/x/r/e/c jumps to each section)
Proxies page
- Server-side: merge static site proxy routes with instance routes and sort
by domain (internal/api/proxies.go, internal/store/static_sites.go)
- ProxyRoute gains a Source field ("instance" | "static_site")
- Frontend adds source filter tabs and per-source labels/badges
This commit is contained in:
@@ -1,27 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { listProxyRoutes } from '$lib/api';
|
||||
import type { ProxyRoute } from '$lib/types';
|
||||
import type { ProxyRoute, ProxyRouteSource } from '$lib/types';
|
||||
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;
|
||||
|
||||
let routes = $state<ProxyRoute[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
let sourceFilter = $state<SourceFilter>('all');
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim()
|
||||
? routes.filter((r) => {
|
||||
const q = search.toLowerCase();
|
||||
return r.domain?.toLowerCase().includes(q)
|
||||
|| r.project_name.toLowerCase().includes(q)
|
||||
|| r.stage_name.toLowerCase().includes(q)
|
||||
|| r.image_tag.toLowerCase().includes(q);
|
||||
})
|
||||
: routes
|
||||
);
|
||||
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)]';
|
||||
}
|
||||
|
||||
function targetHref(route: ProxyRoute): string {
|
||||
return route.source === 'static_site' ? `/sites/${route.instance_id}` : `/projects/${route.project_id}`;
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
loading = true;
|
||||
@@ -34,7 +63,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { loadRoutes(); });
|
||||
$effect(() => {
|
||||
loadRoutes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -42,12 +73,12 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('proxies.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ForgeHero
|
||||
eyebrowSuffix="PROXIES"
|
||||
title={$t('proxies.title')}
|
||||
lede={$t('proxies.description')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
@@ -58,19 +89,43 @@
|
||||
{:else if routes.length === 0}
|
||||
<EmptyState title={$t('proxies.noRoutes')} description={$t('proxies.noRoutesDesc')} icon="instances" />
|
||||
{:else}
|
||||
<!-- Search -->
|
||||
<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)]"
|
||||
/>
|
||||
<!-- 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>
|
||||
@@ -79,7 +134,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each filtered as route (route.instance_id)}
|
||||
{#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}
|
||||
@@ -91,15 +146,24 @@
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/projects/{route.project_id}" class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors">
|
||||
<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 text-sm text-[var(--text-secondary)]">{route.stage_name || '—'}</td>
|
||||
<td class="px-4 py-3">
|
||||
<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>
|
||||
{#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}</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>
|
||||
@@ -109,7 +173,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0 && search}
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('proxies.noMatch')}</p>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user