a4362b842d
Build / build (push) Successful in 11m42s
Security: - rate limit /api/webhook routes per-IP and cap concurrent site syncs - global SSE connection cap (256) with new sse_gate - validate ?tail= and cap JSON log responses at 4 MiB - strip ANSI/CSI/OSC and control bytes from streamed log lines - redact webhook secret from request log middleware - scrub host details from /api/health for non-admin viewers - drop container_id from /api/system/stats/top for non-admins - generate webhook secrets via crypto/rand; require >=32 chars on insert - verify iid path consistency in streamContainerLogs - LimitReader on site webhook body; reject malformed non-empty bodies Concurrency / correctness: - stats collector: Stop() no longer hangs without Start(), semaphore acquired in parent loop so ctx cancellation short-circuits the queue, in-flight tick cancellable via shared base context, zero-ts guard - webhook handler: replace fire-and-forget goroutine with WaitGroup-tracked workers + Drain() wired into graceful shutdown - $derived(() => ...) mis-idiom fixed in ContainerStats / InstanceCard / ProjectCard (returned function instead of value) - SystemResourcesCard: rename `window` and `t` locals to avoid shadowing globalThis.window and the i18n `t` import Quality / performance: - replace O(n^2) insertion sort with sort.Slice in stats top - runMigrations only swallows duplicate-column / already-exists errors - PruneStatsSamplesBefore wrapped in a transaction - collapse N+1 in unusedImageStats / pruneImages to one ListAllInstances pass; surface DB errors instead of silently treating them as inactive - run Docker Info + DiskUsage in parallel via errgroup - container log SSE emits `: ping` heartbeat every 20 s - imageMatches case-insensitive on registry host (RFC behaviour) - log warning on invalid stage tag pattern instead of silent skip - reject malformed non-empty site webhook payloads Frontend / i18n: - shared formatBytes utility replaces three local copies - statsInterval store drives dynamic "no samples / collection disabled" copy across ContainerStats and SystemResourcesCard - top consumers row now shows owner_name (project/stage or site name) - drop seven `as any` casts on the Settings type; add cloudflare_api_token write-only field - move "Service status", "Docker daemon", "Docker unreachable", "Proxy unreachable", "reachable", and "Docker daemon is not reachable." strings into en/ru i18n bundles
708 lines
24 KiB
Svelte
708 lines
24 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
import type { Snippet } from 'svelte';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { page } from '$app/stores';
|
|
import Toast from '$lib/components/Toast.svelte';
|
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
|
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
|
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox } from '$lib/components/icons';
|
|
import { goto } from '$app/navigation';
|
|
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
|
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
|
import { logout as apiLogout } from '$lib/api';
|
|
import { t } from '$lib/i18n';
|
|
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
|
|
import { health, startHealthPolling, stopHealthPolling, refreshHealth } from '$lib/stores/health';
|
|
import { effectiveTimezone, formatOffsetLabel } from '$lib/stores/timezone';
|
|
import { fmt } from '$lib/format/datetime';
|
|
|
|
interface Props {
|
|
children: Snippet;
|
|
}
|
|
|
|
const { children }: Props = $props();
|
|
|
|
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'eventsErrors';
|
|
|
|
const navItems: ReadonlyArray<{
|
|
href: string;
|
|
labelKey: string;
|
|
icon: string;
|
|
countKey?: NavCountKey;
|
|
/** When true the badge uses a danger style (red). */
|
|
alert?: boolean;
|
|
}> = [
|
|
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
|
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
|
|
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
|
|
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
|
|
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
|
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
|
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
|
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
|
];
|
|
|
|
function isActive(href: string, pathname: string): boolean {
|
|
if (href === '/') return pathname === '/';
|
|
return pathname.startsWith(href);
|
|
}
|
|
|
|
let sidebarOpen = $state(false);
|
|
let hintsExpanded = $state(false);
|
|
let proxyHintsExpanded = $state(false);
|
|
|
|
const dockerHealth = $derived($health.docker);
|
|
const proxyHealth = $derived($health.proxy);
|
|
const healthChecked = $derived($health.checked);
|
|
|
|
// Live forge clock (refreshes every second). A small thing, but it makes
|
|
// the sidebar feel alive and reinforces the "control room" aesthetic.
|
|
// Renders in the user's chosen timezone via the shared formatter.
|
|
let nowTick = $state(new Date());
|
|
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
|
function tickClock() {
|
|
nowTick = new Date();
|
|
}
|
|
const clockDisplay = $derived($fmt.clock(nowTick));
|
|
const clockOffset = $derived(formatOffsetLabel($effectiveTimezone, nowTick));
|
|
const clockTitle = $derived(`${$effectiveTimezone.replace(/_/g, ' ')} · ${clockOffset}`);
|
|
|
|
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
|
|
// g+d → dashboard, g+p → projects, g+s → sites, g+k → stacks, g+x → deploy,
|
|
// g+r → proxies, g+e → events, g+c → settings
|
|
let gPressedAt = 0;
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
// Ignore when typing in inputs/textareas/contenteditable.
|
|
const target = e.target as HTMLElement | null;
|
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
|
|
if (e.key === 'g') {
|
|
gPressedAt = Date.now();
|
|
return;
|
|
}
|
|
if (Date.now() - gPressedAt > 1200) return;
|
|
const map: Record<string, string> = {
|
|
d: '/', p: '/projects', s: '/sites', k: '/stacks',
|
|
x: '/deploy', r: '/proxies', e: '/events', c: '/settings'
|
|
};
|
|
const dest = map[e.key.toLowerCase()];
|
|
if (dest) {
|
|
e.preventDefault();
|
|
gPressedAt = 0;
|
|
goto(dest);
|
|
}
|
|
}
|
|
|
|
const dockerConnected = $derived(dockerHealth?.connected ?? false);
|
|
const proxyConnected = $derived(proxyHealth?.connected ?? true);
|
|
const proxyProviderName = $derived(proxyHealth?.provider ?? '');
|
|
|
|
// Hide sidebar and chrome on the login page.
|
|
const isLoginPage = $derived($page.url.pathname === '/login');
|
|
|
|
// Apply theme reactively.
|
|
$effect(() => {
|
|
applyTheme($resolvedTheme);
|
|
});
|
|
|
|
// Listen for system theme changes when in "system" mode.
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
const handler = () => applyTheme($resolvedTheme);
|
|
mq.addEventListener('change', handler);
|
|
return () => mq.removeEventListener('change', handler);
|
|
});
|
|
|
|
// Close sidebar on route change (mobile).
|
|
$effect(() => {
|
|
void $page.url.pathname;
|
|
sidebarOpen = false;
|
|
});
|
|
|
|
onMount(async () => {
|
|
// Handle OIDC redirect: exchange the HttpOnly session cookie for a bearer token.
|
|
if ($page.url.searchParams.get('oidc') === 'success') {
|
|
const token = await exchangeOidcToken();
|
|
if (token) {
|
|
setAuthToken(token);
|
|
goto('/', { replaceState: true });
|
|
}
|
|
}
|
|
tickClock();
|
|
clockTimer = setInterval(tickClock, 1000);
|
|
window.addEventListener('keydown', handleKeydown);
|
|
});
|
|
|
|
// Keep nav badges fresh. Poll kicks off once the user is authenticated;
|
|
// a route change also triggers a one-shot refresh so badges reflect any
|
|
// mutations just performed on the page we're leaving.
|
|
$effect(() => {
|
|
void $page.url.pathname;
|
|
if (!isAuthenticated()) return;
|
|
startNavCountsPolling();
|
|
refreshNavCounts();
|
|
});
|
|
|
|
// Start health polling when authenticated. Shared store handles the
|
|
// timer; this effect just nudges it whenever auth flips on.
|
|
$effect(() => {
|
|
void $page.url.pathname;
|
|
if (!isAuthenticated()) return;
|
|
startHealthPolling();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (clockTimer) clearInterval(clockTimer);
|
|
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
|
|
stopNavCountsPolling();
|
|
stopHealthPolling();
|
|
});
|
|
</script>
|
|
|
|
{#if isLoginPage}
|
|
<!-- Login page: no sidebar, no chrome -->
|
|
{@render children()}
|
|
{:else}
|
|
<div class="flex h-screen overflow-hidden bg-[var(--surface-page)]">
|
|
<!-- Mobile overlay -->
|
|
{#if sidebarOpen}
|
|
<div
|
|
class="fixed inset-0 z-40 bg-[var(--surface-overlay)] lg:hidden animate-fade-in"
|
|
role="presentation"
|
|
onclick={() => { sidebarOpen = false; }}
|
|
></div>
|
|
{/if}
|
|
|
|
<!-- Sidebar -->
|
|
<aside
|
|
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-[var(--border-primary)] bg-[var(--surface-sidebar)] transition-transform duration-300 lg:static lg:translate-x-0
|
|
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}"
|
|
>
|
|
<!-- Brand block: title + daemon status chips -->
|
|
<div class="brand-block border-b border-[var(--border-primary)] px-5 pt-4 pb-3">
|
|
<div class="flex items-center gap-2.5">
|
|
<span class="forge-ember brand-ember"></span>
|
|
<span class="brand-name">{$t('app.name')}</span>
|
|
|
|
<!-- Close sidebar (mobile) -->
|
|
<button
|
|
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
|
|
onclick={() => { sidebarOpen = false; }}
|
|
aria-label="Close sidebar"
|
|
>
|
|
<IconX size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Daemon health chips (Docker + proxy provider) -->
|
|
<div class="brand-rail" aria-label={$t('layout.serviceStatus')}>
|
|
{#if healthChecked}
|
|
<button
|
|
type="button"
|
|
class="chip"
|
|
class:chip-live={dockerConnected}
|
|
class:chip-down={!dockerConnected}
|
|
title={dockerConnected
|
|
? `${$t('daemons.docker')} · ${dockerHealth?.version ?? $t('daemons.reachable')}`
|
|
: dockerHealth?.error ?? $t('daemons.dockerUnreachable')}
|
|
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
|
|
>
|
|
<span class="chip-dot" aria-hidden="true"></span>
|
|
<span class="chip-label">DKR</span>
|
|
{#if dockerConnected && typeof dockerHealth?.running === 'number'}
|
|
<span class="chip-meter" aria-hidden="true"></span>
|
|
<span class="chip-count">{dockerHealth.running}</span>
|
|
{/if}
|
|
</button>
|
|
|
|
{#if proxyHealth && proxyProviderName !== 'none'}
|
|
<button
|
|
type="button"
|
|
class="chip"
|
|
class:chip-live={proxyConnected}
|
|
class:chip-down={!proxyConnected}
|
|
title={proxyConnected
|
|
? `${proxyProviderName.toUpperCase()} · ${proxyHealth.latency_ms ?? '?'} ms`
|
|
: proxyHealth.error ?? $t('daemons.proxyUnreachable')}
|
|
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
|
|
>
|
|
<span class="chip-dot" aria-hidden="true"></span>
|
|
<span class="chip-label">{proxyProviderName === 'npm' ? 'NPM' : 'TRF'}</span>
|
|
{#if proxyConnected && typeof proxyHealth.proxy_hosts === 'number'}
|
|
<span class="chip-meter" aria-hidden="true"></span>
|
|
<span class="chip-count">{proxyHealth.proxy_hosts}</span>
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
{:else}
|
|
<span class="chip chip-idle">
|
|
<span class="chip-dot" aria-hidden="true"></span>
|
|
<span class="chip-label">BOOT</span>
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Expandable error hints -->
|
|
{#if healthChecked && !dockerConnected && hintsExpanded && dockerHealth?.error}
|
|
<div class="chip-error">
|
|
<code>{dockerHealth.error}</code>
|
|
<button type="button" class="chip-retry" onclick={() => void refreshHealth()}>
|
|
{$t('health.retryNow')}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{#if healthChecked && !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
|
|
<div class="chip-error">
|
|
<code>{proxyHealth.error}</code>
|
|
<button type="button" class="chip-retry" onclick={() => void refreshHealth()}>
|
|
{$t('health.retryNow')}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<nav class="flex-1 space-y-0.5 px-3 py-3">
|
|
{#each navItems as item}
|
|
{@const active = isActive(item.href, $page.url.pathname)}
|
|
<a
|
|
href={item.href}
|
|
class="nav-item group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
|
{active ? 'nav-active' : 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
|
|
>
|
|
{#if item.icon === 'dashboard'}
|
|
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{:else if item.icon === 'projects'}
|
|
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{:else if item.icon === 'globe'}
|
|
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{:else if item.icon === 'stacks'}
|
|
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{:else if item.icon === 'deploy'}
|
|
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{:else if item.icon === 'proxies'}
|
|
<IconWifi size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{:else if item.icon === 'events'}
|
|
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{:else if item.icon === 'settings'}
|
|
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
|
{/if}
|
|
<span class="nav-label">{$t(item.labelKey)}</span>
|
|
|
|
{#if item.countKey}
|
|
{@const count = $navCounts[item.countKey]}
|
|
{#if count !== null && count > 0}
|
|
<span class="nav-badge" class:nav-badge-alert={item.alert} class:nav-badge-active={active}>
|
|
{count > 99 ? '99+' : count}
|
|
</span>
|
|
{:else if count !== null && !item.alert}
|
|
<span class="nav-badge nav-badge-muted" class:nav-badge-active={active}>0</span>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if active}
|
|
<div class="nav-active-dot"></div>
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
</nav>
|
|
|
|
<!-- Footer controls -->
|
|
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
|
<div class="flex items-center justify-between">
|
|
<ThemeToggle />
|
|
<LocaleSwitcher />
|
|
<button
|
|
type="button"
|
|
title={$t('nav.logout')}
|
|
aria-label={$t('nav.logout')}
|
|
onclick={async () => {
|
|
try { await apiLogout(); } catch { /* best effort */ }
|
|
clearAuth();
|
|
goto('/login');
|
|
}}
|
|
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
|
|
>
|
|
<IconLogout size={16} />
|
|
</button>
|
|
</div>
|
|
<div class="forge-footline">
|
|
<span class="forge-footline-version">{$t('app.name')} {$t('app.version')}</span>
|
|
<span class="forge-footline-clock" title={clockTitle}>
|
|
<span class="clock-dot"></span>
|
|
<span class="clock-time">{clockDisplay}</span>
|
|
<span class="clock-suffix">{clockOffset}</span>
|
|
</span>
|
|
</div>
|
|
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
|
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>p</kbd><kbd>s</kbd><kbd>k</kbd>
|
|
<span class="hint-label">quick-nav</span>
|
|
</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Main content -->
|
|
<div class="flex flex-1 flex-col overflow-hidden">
|
|
<!-- Top bar (mobile) -->
|
|
<header class="flex h-14 items-center gap-3 border-b border-[var(--border-primary)] bg-[var(--surface-sidebar)] px-4 lg:hidden">
|
|
<button
|
|
class="rounded-md p-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
|
onclick={() => { sidebarOpen = true; }}
|
|
aria-label="Open sidebar"
|
|
>
|
|
<IconMenu size={22} />
|
|
</button>
|
|
<span class="forge-ember"></span>
|
|
<span class="brand-name">{$t('app.name')}</span>
|
|
</header>
|
|
|
|
<!-- Page content -->
|
|
<main class="flex-1 overflow-y-auto">
|
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8">
|
|
{@render children()}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<Toast />
|
|
|
|
<style>
|
|
/* ── Forge-themed layout shell ─────────────────────────────────── */
|
|
/* Page titles — larger + tighter tracking, but using the app's sans stack */
|
|
:global(main h1) {
|
|
font-family: var(--font-family-sans) !important;
|
|
font-weight: 700 !important;
|
|
letter-spacing: -0.02em !important;
|
|
font-size: clamp(1.875rem, 4vw, 2.5rem) !important;
|
|
line-height: 1.1 !important;
|
|
color: var(--text-primary);
|
|
}
|
|
:global(main h2) {
|
|
font-family: var(--font-family-sans);
|
|
font-weight: 600;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
:global(main code) {
|
|
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
|
}
|
|
|
|
.brand-block {
|
|
position: relative;
|
|
}
|
|
.brand-ember {
|
|
width: 10px; height: 10px;
|
|
}
|
|
.brand-name {
|
|
font-family: var(--font-family-sans);
|
|
font-weight: 700;
|
|
font-size: 1.05rem;
|
|
line-height: 1;
|
|
letter-spacing: -0.02em;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* ── Daemon status chips under the brand title ─────────────────── */
|
|
.brand-rail {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
margin-top: 0.55rem;
|
|
padding-left: 1.15rem; /* align with brand text (after ember) */
|
|
}
|
|
|
|
.chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.38rem;
|
|
padding: 0.2rem 0.55rem 0.2rem 0.45rem;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 999px;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.58rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
cursor: default;
|
|
transition: border-color 150ms ease, background 150ms ease, color 150ms ease, transform 150ms ease;
|
|
line-height: 1;
|
|
}
|
|
.chip:hover {
|
|
transform: translateY(-1px);
|
|
}
|
|
.chip-idle {
|
|
opacity: 0.6;
|
|
}
|
|
.chip-idle .chip-dot {
|
|
background: var(--text-tertiary);
|
|
}
|
|
|
|
.chip-dot {
|
|
width: 6px; height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--text-tertiary);
|
|
box-shadow: 0 0 0 0 transparent;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.chip-live {
|
|
color: var(--color-success-dark);
|
|
border-color: color-mix(in srgb, var(--color-success) 40%, transparent);
|
|
background: color-mix(in srgb, var(--color-success) 7%, var(--surface-card));
|
|
}
|
|
.chip-live .chip-dot {
|
|
background: var(--color-success);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);
|
|
animation: chip-pulse 1.9s ease-in-out infinite;
|
|
}
|
|
.chip-down {
|
|
color: var(--color-danger-dark);
|
|
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
|
|
background: color-mix(in srgb, var(--color-danger) 9%, var(--surface-card));
|
|
cursor: pointer;
|
|
}
|
|
.chip-down .chip-dot {
|
|
background: var(--color-danger);
|
|
animation: chip-fault 0.9s steps(2) infinite;
|
|
}
|
|
.chip-down:hover {
|
|
background: color-mix(in srgb, var(--color-danger) 14%, var(--surface-card));
|
|
}
|
|
|
|
:global([data-theme='dark']) .chip-live {
|
|
color: #86efac;
|
|
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
|
|
}
|
|
:global([data-theme='dark']) .chip-down {
|
|
color: #fca5a5;
|
|
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
|
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
|
|
}
|
|
|
|
.chip-meter {
|
|
width: 1px;
|
|
height: 0.7rem;
|
|
background: currentColor;
|
|
opacity: 0.25;
|
|
}
|
|
.chip-count {
|
|
font-variant-numeric: tabular-nums;
|
|
letter-spacing: 0.04em;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
@keyframes chip-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent); }
|
|
50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--color-success) 12%, transparent); }
|
|
}
|
|
@keyframes chip-fault {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.35; }
|
|
}
|
|
|
|
.chip-error {
|
|
margin-top: 0.55rem;
|
|
padding: 0.45rem 0.6rem;
|
|
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
|
|
border-radius: 8px;
|
|
}
|
|
.chip-error code {
|
|
display: block;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
line-height: 1.5;
|
|
color: var(--color-danger-dark);
|
|
word-break: break-word;
|
|
}
|
|
:global([data-theme='dark']) .chip-error code { color: #fca5a5; }
|
|
.chip-retry {
|
|
margin-top: 0.4rem;
|
|
width: 100%;
|
|
padding: 0.28rem 0.5rem;
|
|
border-radius: 6px;
|
|
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
|
|
background: transparent;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--color-danger-dark);
|
|
cursor: pointer;
|
|
transition: background 150ms ease;
|
|
}
|
|
.chip-retry:hover {
|
|
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
|
}
|
|
:global([data-theme='dark']) .chip-retry { color: #fca5a5; }
|
|
|
|
.nav-item :global(svg) { flex-shrink: 0; }
|
|
.nav-label {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.nav-active {
|
|
background: var(--surface-card-hover);
|
|
color: var(--text-primary) !important;
|
|
position: relative;
|
|
}
|
|
.nav-active::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: -12px; top: 20%; bottom: 20%;
|
|
width: 3px;
|
|
background: var(--color-brand-600);
|
|
border-radius: 0 3px 3px 0;
|
|
}
|
|
.nav-active-dot {
|
|
width: 6px; height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-brand-600);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Nav count badges ──────────────────────────────────────── */
|
|
.nav-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 1.35rem;
|
|
height: 1.1rem;
|
|
padding: 0 0.4rem;
|
|
border-radius: 999px;
|
|
background: var(--surface-card-hover);
|
|
color: var(--text-secondary);
|
|
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 1;
|
|
border: 1px solid var(--border-primary);
|
|
flex-shrink: 0;
|
|
}
|
|
.nav-badge-muted {
|
|
opacity: 0.55;
|
|
}
|
|
.nav-badge-alert {
|
|
background: var(--color-danger-light);
|
|
color: var(--color-danger-dark);
|
|
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
|
|
}
|
|
:global([data-theme='dark']) .nav-badge-alert {
|
|
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
|
|
color: #fca5a5;
|
|
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
|
|
}
|
|
.nav-active .nav-badge:not(.nav-badge-alert) {
|
|
background: var(--surface-card);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* ── Sidebar footline (version + live timezone-aware clock) ───────────── */
|
|
.forge-footline {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
padding-top: 0.1rem;
|
|
}
|
|
.forge-footline-version {
|
|
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
|
font-size: 0.66rem;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.forge-footline-clock {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
|
font-size: 0.66rem;
|
|
color: var(--text-secondary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.clock-dot {
|
|
width: 5px; height: 5px; border-radius: 50%;
|
|
background: var(--color-brand-500);
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-brand-500) 20%, transparent);
|
|
animation: forge-breathe 2.4s ease-in-out infinite;
|
|
}
|
|
.clock-suffix {
|
|
font-size: 0.56rem;
|
|
letter-spacing: 0.18em;
|
|
color: var(--text-tertiary);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* ── Keyboard quick-nav hint ───────────────────────────────── */
|
|
.forge-nav-hint {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
margin: 0.55rem 0 0;
|
|
padding: 0;
|
|
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
|
font-size: 0.58rem;
|
|
color: var(--text-tertiary);
|
|
letter-spacing: 0.06em;
|
|
}
|
|
.forge-nav-hint kbd {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 14px;
|
|
height: 14px;
|
|
padding: 0 3px;
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: 3px;
|
|
background: var(--surface-card);
|
|
color: var(--text-secondary);
|
|
font-family: inherit;
|
|
font-size: 0.6rem;
|
|
line-height: 1;
|
|
box-shadow: 0 1px 0 var(--border-primary);
|
|
}
|
|
.forge-nav-hint .arr {
|
|
color: var(--text-tertiary);
|
|
opacity: 0.5;
|
|
font-size: 0.55rem;
|
|
padding: 0 0.15rem;
|
|
}
|
|
.forge-nav-hint .hint-label {
|
|
margin-left: auto;
|
|
text-transform: uppercase;
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* Apply dot-grid backdrop to main content */
|
|
:global(main) {
|
|
position: relative;
|
|
isolation: isolate;
|
|
/* Reserve space for the scrollbar even when content fits, so switching
|
|
between short and tall pages (e.g. inside Settings) doesn't shift the
|
|
layout horizontally by the scrollbar width. */
|
|
scrollbar-gutter: stable;
|
|
}
|
|
:global(main)::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; height: 480px;
|
|
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
|
background-size: 22px 22px;
|
|
-webkit-mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
|
mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
|
pointer-events: none;
|
|
z-index: -1;
|
|
opacity: 0.7;
|
|
}
|
|
</style>
|