feat: daemon health panel, brand-rail status chips, user timezone selector
Build / build (push) Successful in 10m35s
Build / build (push) Successful in 10m35s
- Health API now surfaces Docker /info + /version (version, platform, kernel, container/image counts, storage driver, memory, latency) and NPM aggregates (proxy host total, managed-by-Tinyforge count, access lists, certificates, endpoint URL). - Docker/NPM indicators moved out of the sidebar footer and into a compact mono-styled rail directly under the Tinyforge brand title, with pulse/fault animations and click-to-expand error hints. - New SystemDaemonsCard on the dashboard: two terminal-styled panels (Docker Engine + Proxy) with a running/paused/stopped stacked bar, key-value diagnostics, and a total-vs-managed proportion meter on the proxy-hosts tile. - Shared health store so the sidebar and dashboard share a single 30 s poll instead of duplicating traffic. - User-facing timezone preference with auto-detect fallback; all dates across projects, sites, stacks, settings, backup, event log and stale containers now render through \$fmt.date / \$fmt.datetime. - en/ru translations for both features.
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
<script lang="ts">
|
||||
import type { EventLogEntry } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconTrash } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
@@ -17,24 +18,6 @@
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(dateStr).getTime();
|
||||
const diffSec = Math.floor((now - then) / 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`;
|
||||
return `${Math.floor(diffDay / 30)}mo ago`;
|
||||
}
|
||||
|
||||
function formatFull(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
const severityBar: Record<string, string> = {
|
||||
info: 'bg-blue-400 dark:bg-blue-500',
|
||||
warn: 'bg-amber-400 dark:bg-amber-500',
|
||||
@@ -81,8 +64,8 @@
|
||||
<span class="font-medium {severityBadge[entry.severity] ?? severityBadge.info}">
|
||||
{$t(`events.severity.${entry.severity}`)}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-[var(--text-tertiary)] tabular-nums" title={formatFull(entry.created_at)}>
|
||||
{timeAgo(entry.created_at)}
|
||||
<span class="ml-auto shrink-0 text-[var(--text-tertiary)] tabular-nums" title={$fmt.dateTime(entry.created_at)}>
|
||||
{$fmt.relative(entry.created_at)}
|
||||
</span>
|
||||
{#if ondelete}
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { StaleContainer } from '$lib/types';
|
||||
import { IconClock, IconTag, IconTrash } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
interface Props {
|
||||
container: StaleContainer;
|
||||
@@ -24,11 +25,6 @@
|
||||
`${container.project_name}-${container.stage_name}-${container.instance.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)]">
|
||||
@@ -63,7 +59,7 @@
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<IconClock size={12} />
|
||||
{$t('stale.lastAlive')}: {formatDate(container.instance.last_alive_at)}
|
||||
{$t('stale.lastAlive')}: {$fmt.shortDate(container.instance.last_alive_at)}
|
||||
</span>
|
||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono text-[10px]">
|
||||
{container.instance.status}
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
<!--
|
||||
Dashboard daemon panel. Renders detailed runtime info for the Docker engine
|
||||
and the configured proxy provider (NPM or Traefik). Subscribes to the
|
||||
shared health store so there is no extra network traffic beyond the
|
||||
single /api/health poll already running for the sidebar chips.
|
||||
|
||||
Aesthetic: control-room / forge — two terminal-styled panels side by side,
|
||||
with key/value rows, a container-state bar graph, and registration marks
|
||||
that reveal on hover. Dimensions are generous but everything is rendered
|
||||
in mono to reinforce the "instrumentation" feel.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { health, refreshHealth } from '$lib/stores/health';
|
||||
import { IconRefresh, IconContainer, IconServer, IconShield, IconWifi } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
const docker = $derived($health.docker);
|
||||
const proxy = $derived($health.proxy);
|
||||
const checked = $derived($health.checked);
|
||||
|
||||
const dockerConnected = $derived(docker?.connected ?? false);
|
||||
const proxyConnected = $derived(proxy?.connected ?? false);
|
||||
const proxyProvider = $derived(proxy?.provider ?? 'none');
|
||||
|
||||
const totalContainers = $derived(
|
||||
(docker?.running ?? 0) + (docker?.paused ?? 0) + (docker?.stopped ?? 0)
|
||||
);
|
||||
|
||||
const runningPct = $derived(
|
||||
totalContainers > 0 ? ((docker?.running ?? 0) / totalContainers) * 100 : 0
|
||||
);
|
||||
const pausedPct = $derived(
|
||||
totalContainers > 0 ? ((docker?.paused ?? 0) / totalContainers) * 100 : 0
|
||||
);
|
||||
const stoppedPct = $derived(
|
||||
totalContainers > 0 ? ((docker?.stopped ?? 0) / totalContainers) * 100 : 0
|
||||
);
|
||||
|
||||
function formatBytes(n: number | undefined): string {
|
||||
if (!n || n <= 0) return '—';
|
||||
const gb = n / 1024 ** 3;
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||
const mb = n / 1024 ** 2;
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function formatMs(n: number | undefined): string {
|
||||
if (typeof n !== 'number') return '—';
|
||||
return `${n} ms`;
|
||||
}
|
||||
|
||||
let refreshing = $state(false);
|
||||
async function onRefresh(): Promise<void> {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
await refreshHealth();
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="daemons">
|
||||
<header class="daemons-head">
|
||||
<div>
|
||||
<span class="eyebrow">SYSTEMS // DAEMONS</span>
|
||||
<h2 class="daemons-title">{$t('daemons.title')}<span class="accent">.</span></h2>
|
||||
</div>
|
||||
<button class="refresh-btn" type="button" onclick={onRefresh} disabled={refreshing} title={$t('daemons.refresh')}>
|
||||
<span class:spin={refreshing} class="refresh-icon"><IconRefresh size={14} /></span>
|
||||
<span>{refreshing ? $t('daemons.refreshing') : $t('daemons.refresh')}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="daemons-grid">
|
||||
<!-- ═══════════ DOCKER PANEL ═══════════ -->
|
||||
<article class="panel" class:panel-down={checked && !dockerConnected}>
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<IconContainer size={15} />
|
||||
<span>{$t('daemons.docker')}</span>
|
||||
</div>
|
||||
<span class="status-pill" class:live={dockerConnected} class:down={!dockerConnected}>
|
||||
<span class="status-dot"></span>
|
||||
{dockerConnected ? $t('daemons.online') : $t('daemons.offline')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !checked}
|
||||
<div class="panel-skeleton">
|
||||
<div class="skel-bar"></div>
|
||||
<div class="skel-bar skel-bar-narrow"></div>
|
||||
<div class="skel-bar"></div>
|
||||
</div>
|
||||
{:else if !dockerConnected}
|
||||
<div class="panel-error">
|
||||
<code>{docker?.error ?? 'Docker daemon is not reachable.'}</code>
|
||||
<p>{$t('daemons.dockerHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Container state breakdown — stacked bar -->
|
||||
<div class="state-block">
|
||||
<div class="state-head">
|
||||
<span class="state-caption">{$t('daemons.containers')}</span>
|
||||
<span class="state-total">{totalContainers}</span>
|
||||
</div>
|
||||
{#if totalContainers > 0}
|
||||
<div class="state-bar" aria-hidden="true">
|
||||
<span class="seg seg-run" style="width: {runningPct}%"></span>
|
||||
<span class="seg seg-pause" style="width: {pausedPct}%"></span>
|
||||
<span class="seg seg-stop" style="width: {stoppedPct}%"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="state-bar empty" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<div class="state-legend">
|
||||
<span class="leg"><i class="ld ld-run"></i>{$t('daemons.running')} <b>{docker?.running ?? 0}</b></span>
|
||||
<span class="leg"><i class="ld ld-pause"></i>{$t('daemons.paused')} <b>{docker?.paused ?? 0}</b></span>
|
||||
<span class="leg"><i class="ld ld-stop"></i>{$t('daemons.stopped')} <b>{docker?.stopped ?? 0}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key/value grid -->
|
||||
<dl class="kv">
|
||||
<div>
|
||||
<dt>{$t('daemons.version')}</dt>
|
||||
<dd>{docker?.version ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.apiVersion')}</dt>
|
||||
<dd>{docker?.api_version ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.platform')}</dt>
|
||||
<dd>{docker?.os ?? '—'}{docker?.arch ? ` · ${docker.arch}` : ''}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.kernel')}</dt>
|
||||
<dd class="truncate" title={docker?.kernel ?? ''}>{docker?.kernel ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.cpu')}</dt>
|
||||
<dd>{docker?.ncpu ? `${docker.ncpu} cores` : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.memory')}</dt>
|
||||
<dd>{formatBytes(docker?.memory_total)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.storage')}</dt>
|
||||
<dd>{docker?.storage_driver ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.images')}</dt>
|
||||
<dd>{docker?.images ?? 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.latency')}</dt>
|
||||
<dd>{formatMs(docker?.latency_ms)}</dd>
|
||||
</div>
|
||||
<div class="kv-wide">
|
||||
<dt>{$t('daemons.rootDir')}</dt>
|
||||
<dd class="truncate" title={docker?.root_dir ?? ''}>{docker?.root_dir ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<!-- ═══════════ PROXY PANEL ═══════════ -->
|
||||
<article class="panel" class:panel-down={checked && !proxyConnected && proxyProvider !== 'none'}>
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
{#if proxyProvider === 'npm'}
|
||||
<IconShield size={15} />
|
||||
{:else}
|
||||
<IconWifi size={15} />
|
||||
{/if}
|
||||
<span>
|
||||
{proxyProvider === 'npm'
|
||||
? $t('daemons.npm')
|
||||
: proxyProvider === 'traefik'
|
||||
? $t('daemons.traefik')
|
||||
: $t('daemons.proxy')}
|
||||
</span>
|
||||
</div>
|
||||
{#if proxyProvider === 'none' || !proxy}
|
||||
<span class="status-pill idle">
|
||||
<span class="status-dot"></span>
|
||||
{$t('daemons.notConfigured')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="status-pill" class:live={proxyConnected} class:down={!proxyConnected}>
|
||||
<span class="status-dot"></span>
|
||||
{proxyConnected ? $t('daemons.online') : $t('daemons.offline')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !checked}
|
||||
<div class="panel-skeleton">
|
||||
<div class="skel-bar"></div>
|
||||
<div class="skel-bar skel-bar-narrow"></div>
|
||||
<div class="skel-bar"></div>
|
||||
</div>
|
||||
{:else if proxyProvider === 'none' || !proxy}
|
||||
<div class="panel-empty">
|
||||
<IconServer size={20} />
|
||||
<p>{$t('daemons.noProxyDesc')}</p>
|
||||
<a href="/settings" class="inline-link">{$t('daemons.configureProxy')} →</a>
|
||||
</div>
|
||||
{:else if !proxyConnected}
|
||||
<div class="panel-error">
|
||||
<code>{proxy.error ?? `${proxyProvider.toUpperCase()} is not reachable.`}</code>
|
||||
<p>{$t('daemons.proxyHint')}</p>
|
||||
{#if proxy.url}
|
||||
<p class="url"><span class="kdim">URL</span> <code>{proxy.url}</code></p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if proxyProvider === 'npm'}
|
||||
{@const total = proxy.proxy_hosts ?? 0}
|
||||
{@const managed = proxy.proxy_hosts_managed ?? 0}
|
||||
{@const external = Math.max(0, total - managed)}
|
||||
{@const managedPct = total > 0 ? (managed / total) * 100 : 0}
|
||||
|
||||
<!-- NPM stat tiles -->
|
||||
<div class="proxy-stats">
|
||||
<a href="/proxies" class="pstat pstat-wide">
|
||||
<span class="pstat-label">{$t('daemons.proxyHosts')}</span>
|
||||
<div class="pstat-split">
|
||||
<span class="pstat-value">{total}</span>
|
||||
<span class="pstat-sep">·</span>
|
||||
<span class="pstat-sub">
|
||||
<span class="pstat-chip managed">
|
||||
<i class="pstat-dot managed"></i>
|
||||
{managed} <em>{$t('daemons.managed')}</em>
|
||||
</span>
|
||||
<span class="pstat-chip external">
|
||||
<i class="pstat-dot external"></i>
|
||||
{external} <em>{$t('daemons.external')}</em>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{#if total > 0}
|
||||
<div class="pstat-meter" aria-hidden="true">
|
||||
<span class="meter-fill" style="width: {managedPct}%"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
<a href="/settings/npm" class="pstat">
|
||||
<span class="pstat-label">{$t('daemons.accessLists')}</span>
|
||||
<span class="pstat-value">{proxy.access_lists ?? 0}</span>
|
||||
</a>
|
||||
<a href="/settings/npm" class="pstat">
|
||||
<span class="pstat-label">{$t('daemons.certificates')}</span>
|
||||
<span class="pstat-value">{proxy.certificates ?? 0}</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<dl class="kv">
|
||||
<div>
|
||||
<dt>{$t('daemons.provider')}</dt>
|
||||
<dd class="mono">{proxyProvider.toUpperCase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{$t('daemons.latency')}</dt>
|
||||
<dd>{formatMs(proxy.latency_ms)}</dd>
|
||||
</div>
|
||||
<div class="kv-wide">
|
||||
<dt>{$t('daemons.endpoint')}</dt>
|
||||
<dd class="truncate" title={proxy.url ?? ''}>{proxy.url ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.daemons {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────── */
|
||||
.daemons-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.eyebrow {
|
||||
display: block;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.daemons-title {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.daemons-title .accent {
|
||||
color: var(--forge-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.refresh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.38rem 0.7rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-400);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.refresh-btn:disabled { opacity: 0.55; cursor: wait; }
|
||||
.refresh-icon { display: inline-flex; }
|
||||
.refresh-icon.spin :global(svg) { animation: daemon-spin 0.9s linear infinite; }
|
||||
|
||||
@keyframes daemon-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Grid ───────────────────────────────────────────────── */
|
||||
.daemons-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.daemons-grid { grid-template-columns: 1.15fr 1fr; }
|
||||
}
|
||||
|
||||
/* ── Panel base ─────────────────────────────────────────── */
|
||||
.panel {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel::before {
|
||||
/* Registration mark: top-left corner tick */
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 14px; height: 14px;
|
||||
border-top: 2px solid var(--color-brand-500);
|
||||
border-left: 2px solid var(--color-brand-500);
|
||||
border-top-left-radius: 14px;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.panel:hover::before { opacity: 1; }
|
||||
.panel::after {
|
||||
/* Subtle orange wash top */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--color-brand-500), transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 250ms ease;
|
||||
}
|
||||
.panel:hover::after { opacity: 0.55; }
|
||||
.panel-down {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--color-danger) 3%, var(--surface-card));
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px dashed var(--border-secondary);
|
||||
}
|
||||
.panel-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.panel-title :global(svg) { color: var(--color-brand-600); }
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.status-pill .status-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.status-pill.live {
|
||||
color: var(--color-success-dark);
|
||||
background: color-mix(in srgb, var(--color-success) 9%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
|
||||
}
|
||||
.status-pill.live .status-dot {
|
||||
background: var(--color-success);
|
||||
animation: daemon-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.status-pill.down {
|
||||
color: var(--color-danger-dark);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
.status-pill.down .status-dot {
|
||||
background: var(--color-danger);
|
||||
animation: daemon-blink 0.9s steps(2) infinite;
|
||||
}
|
||||
.status-pill.idle { opacity: 0.75; }
|
||||
|
||||
:global([data-theme='dark']) .status-pill.live { color: #86efac; }
|
||||
:global([data-theme='dark']) .status-pill.down { color: #fca5a5; }
|
||||
|
||||
@keyframes daemon-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-success) 40%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-success) 0%, transparent); }
|
||||
}
|
||||
@keyframes daemon-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* ── Container state bar ───────────────────────────────── */
|
||||
.state-block {
|
||||
margin-bottom: 0.95rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: color-mix(in srgb, var(--color-brand-500) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.state-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.state-caption {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.state-total {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state-bar {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--border-secondary);
|
||||
}
|
||||
.state-bar.empty {
|
||||
background: repeating-linear-gradient(45deg, var(--border-secondary), var(--border-secondary) 4px, transparent 4px, transparent 8px);
|
||||
border: 1px dashed var(--border-primary);
|
||||
}
|
||||
.seg { display: block; height: 100%; transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1); }
|
||||
.seg-run { background: var(--color-success); }
|
||||
.seg-pause { background: var(--color-warning); }
|
||||
.seg-stop { background: var(--text-tertiary); }
|
||||
.state-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.55rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.state-legend b { color: var(--text-primary); font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 0.25rem; }
|
||||
.leg { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.ld { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
|
||||
.ld-run { background: var(--color-success); }
|
||||
.ld-pause { background: var(--color-warning); }
|
||||
.ld-stop { background: var(--text-tertiary); }
|
||||
|
||||
/* ── Key/Value grid ────────────────────────────────────── */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.kv > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.kv-wide { grid-column: 1 / -1; }
|
||||
.kv dt {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.54rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
.kv dd {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.kv dd.mono { letter-spacing: 0.04em; }
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Proxy stat tiles ─────────────────────────────────── */
|
||||
.proxy-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
'wide wide'
|
||||
'a b';
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.95rem;
|
||||
}
|
||||
.pstat-wide { grid-area: wide; }
|
||||
.pstat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: color-mix(in srgb, var(--color-brand-500) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-500) 16%, transparent);
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.pstat:hover {
|
||||
border-color: var(--color-brand-500);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 9%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.pstat-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.52rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.pstat-value {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Split-stat layout for proxy hosts (total / managed / external). */
|
||||
.pstat-split {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.pstat-split .pstat-value {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.pstat-sep {
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.pstat-sub {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pstat-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.12rem 0.45rem 0.12rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pstat-chip em {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
.pstat-chip.managed {
|
||||
border-color: color-mix(in srgb, var(--color-brand-500) 40%, transparent);
|
||||
color: var(--color-brand-700);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 10%, var(--surface-card));
|
||||
}
|
||||
.pstat-chip.external {
|
||||
border-color: var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
:global([data-theme='dark']) .pstat-chip.managed {
|
||||
color: #c7d2fe;
|
||||
background: color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
||||
}
|
||||
.pstat-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.pstat-dot.managed { background: var(--color-brand-500); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
.pstat-dot.external { background: var(--text-tertiary); opacity: 0.6; }
|
||||
|
||||
/* Proportion meter under the total. */
|
||||
.pstat-meter {
|
||||
margin-top: 0.55rem;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.meter-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-brand-500), var(--color-brand-600));
|
||||
border-radius: inherit;
|
||||
transition: width 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ── Error / empty states ─────────────────────────────── */
|
||||
.panel-error {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||
}
|
||||
.panel-error code {
|
||||
display: block;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.55;
|
||||
color: var(--color-danger-dark);
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.panel-error p {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.panel-error .url {
|
||||
margin-top: 0.45rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.panel-error .url code {
|
||||
display: inline;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
.kdim {
|
||||
display: inline-block;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.54rem;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
:global([data-theme='dark']) .panel-error code { color: #fca5a5; }
|
||||
|
||||
.panel-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.panel-empty :global(svg) { color: var(--text-tertiary); }
|
||||
.panel-empty p { margin: 0; font-size: 0.8rem; }
|
||||
.inline-link {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-brand-600);
|
||||
text-decoration: none;
|
||||
}
|
||||
.inline-link:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────── */
|
||||
.panel-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
.skel-bar {
|
||||
height: 10px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, var(--surface-card-hover) 0%, var(--border-secondary) 50%, var(--surface-card-hover) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shift 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skel-bar-narrow { width: 55%; }
|
||||
@keyframes skel-shift {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<!--
|
||||
TimezoneSelector — "Forge Chronograph"
|
||||
|
||||
A calibrated instrument for picking the display timezone. Shows a live
|
||||
tabular-nums clock in the chosen zone so the user sees exactly what timestamps
|
||||
across the app will look like. Supports auto-detect (follows the browser) or
|
||||
a manual IANA zone via the command-palette picker.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
timezonePreference,
|
||||
setTimezonePreference,
|
||||
detectBrowserTimezone,
|
||||
effectiveTimezone,
|
||||
listAllTimezones,
|
||||
formatOffsetLabel,
|
||||
COMMON_TIMEZONES,
|
||||
AUTO_TIMEZONE
|
||||
} from '$lib/stores/timezone';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { IconClock, IconGlobe, IconCheck } from '$lib/components/icons';
|
||||
|
||||
let pickerOpen = $state(false);
|
||||
let now = $state(new Date());
|
||||
|
||||
// 1-second tick so the clock actually ticks. Cleared on destroy.
|
||||
const ticker = setInterval(() => { now = new Date(); }, 1000);
|
||||
onDestroy(() => clearInterval(ticker));
|
||||
|
||||
const detected = $derived(detectBrowserTimezone());
|
||||
const isAuto = $derived($timezonePreference === AUTO_TIMEZONE);
|
||||
|
||||
// Build the picker catalogue once: curated shortlist first (grouped as
|
||||
// "Popular"), then the full IANA list. This keeps the UX fast for most
|
||||
// users while still permitting "Antarctica/Vostok" style edge cases.
|
||||
const pickerItems = $derived.by<EntityPickerItem[]>(() => {
|
||||
const items: EntityPickerItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
items.push({
|
||||
value: AUTO_TIMEZONE,
|
||||
label: $t('timezone.autoDetect'),
|
||||
description: `${detected} · ${formatOffsetLabel(detected, now)}`,
|
||||
group: $t('timezone.groupAuto')
|
||||
});
|
||||
seen.add(AUTO_TIMEZONE);
|
||||
|
||||
for (const tz of COMMON_TIMEZONES) {
|
||||
if (seen.has(tz)) continue;
|
||||
seen.add(tz);
|
||||
items.push({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, ' '),
|
||||
description: formatOffsetLabel(tz, now),
|
||||
group: $t('timezone.groupPopular')
|
||||
});
|
||||
}
|
||||
|
||||
for (const tz of listAllTimezones()) {
|
||||
if (seen.has(tz)) continue;
|
||||
seen.add(tz);
|
||||
items.push({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, ' '),
|
||||
description: formatOffsetLabel(tz, now),
|
||||
group: $t('timezone.groupAll')
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
function handleSelect(value: string) {
|
||||
setTimezonePreference(value as typeof AUTO_TIMEZONE | string);
|
||||
pickerOpen = false;
|
||||
}
|
||||
|
||||
// Sample timestamps surfaced below the clock — demonstrates exactly where
|
||||
// the user's choice takes effect. Ticking clock keeps them live.
|
||||
const previewNow = $derived($fmt.dateTime(now));
|
||||
const previewDate = $derived($fmt.date(now));
|
||||
const clockReading = $derived($fmt.clock(now));
|
||||
const currentOffset = $derived(formatOffsetLabel($effectiveTimezone, now));
|
||||
const currentZoneLabel = $derived($effectiveTimezone.replace(/_/g, ' '));
|
||||
</script>
|
||||
|
||||
<section class="tz-card">
|
||||
<!-- Header: label + mode toggle (auto vs manual) -->
|
||||
<header class="tz-card__head">
|
||||
<div class="tz-card__title-group">
|
||||
<span class="tz-card__eyebrow">
|
||||
<IconClock size={12} />
|
||||
<span>{$t('timezone.eyebrow')}</span>
|
||||
</span>
|
||||
<h3 class="tz-card__title">{$t('timezone.title')}</h3>
|
||||
<p class="tz-card__subtitle">{$t('timezone.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="tz-card__mode" role="tablist" aria-label={$t('timezone.modeLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isAuto}
|
||||
class="tz-mode-btn"
|
||||
class:is-active={isAuto}
|
||||
onclick={() => setTimezonePreference(AUTO_TIMEZONE)}
|
||||
>
|
||||
<IconGlobe size={13} />
|
||||
<span>{$t('timezone.modeAuto')}</span>
|
||||
{#if isAuto}<IconCheck size={12} />{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={!isAuto}
|
||||
class="tz-mode-btn"
|
||||
class:is-active={!isAuto}
|
||||
onclick={() => {
|
||||
// Moving out of auto: pin to whatever is currently effective so
|
||||
// the UI doesn't silently drift if the browser zone changes.
|
||||
if (isAuto) setTimezonePreference($effectiveTimezone);
|
||||
pickerOpen = true;
|
||||
}}
|
||||
>
|
||||
<IconClock size={13} />
|
||||
<span>{$t('timezone.modeManual')}</span>
|
||||
{#if !isAuto}<IconCheck size={12} />{/if}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Instrument display: live clock + zone plate -->
|
||||
<div class="tz-instrument">
|
||||
<div class="tz-instrument__clock" aria-live="off">
|
||||
<span class="tz-digit-group">{clockReading}</span>
|
||||
<span class="tz-instrument__offset">{currentOffset}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tz-instrument__zone"
|
||||
onclick={() => { pickerOpen = true; }}
|
||||
aria-label={$t('timezone.changeZone')}
|
||||
>
|
||||
<span class="tz-instrument__zone-label">{$t('timezone.activeZone')}</span>
|
||||
<span class="tz-instrument__zone-value">
|
||||
<IconGlobe size={14} />
|
||||
<span>{currentZoneLabel}</span>
|
||||
{#if isAuto}
|
||||
<span class="tz-auto-badge">{$t('timezone.autoBadge')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="tz-instrument__zone-hint">{$t('timezone.clickToChange')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview — "this is how dates will render across the app" -->
|
||||
<div class="tz-preview">
|
||||
<div class="tz-preview__row">
|
||||
<span class="tz-preview__label">{$t('timezone.previewFull')}</span>
|
||||
<code class="tz-preview__value">{previewNow}</code>
|
||||
</div>
|
||||
<div class="tz-preview__row">
|
||||
<span class="tz-preview__label">{$t('timezone.previewDate')}</span>
|
||||
<code class="tz-preview__value">{previewDate}</code>
|
||||
</div>
|
||||
<p class="tz-preview__hint">{$t('timezone.previewHint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={pickerOpen}
|
||||
items={pickerItems}
|
||||
current={$timezonePreference}
|
||||
title={$t('timezone.pickerTitle')}
|
||||
placeholder={$t('timezone.pickerPlaceholder')}
|
||||
onselect={handleSelect}
|
||||
onclose={() => { pickerOpen = false; }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.tz-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
background:
|
||||
radial-gradient(120% 60% at 100% 0%, color-mix(in srgb, var(--color-brand-500) 6%, transparent) 0%, transparent 60%),
|
||||
var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tz-card::before {
|
||||
/* Subtle tick-mark texture along the top edge — evokes a chronometer bezel. */
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 8px;
|
||||
background-image: repeating-linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--color-brand-500) 30%, transparent) 0 1px,
|
||||
transparent 1px 10px
|
||||
);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tz-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tz-card__title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tz-card__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-brand-600);
|
||||
}
|
||||
:global([data-theme='dark']) .tz-card__eyebrow {
|
||||
color: var(--color-brand-300);
|
||||
}
|
||||
|
||||
.tz-card__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tz-card__subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.tz-card__mode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-lg, 10px);
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.tz-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.tz-mode-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tz-mode-btn.is-active {
|
||||
background: var(--surface-card);
|
||||
color: var(--color-brand-700);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
:global([data-theme='dark']) .tz-mode-btn.is-active {
|
||||
color: var(--color-brand-300);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ── Instrument display ────────────────────────────────────────────── */
|
||||
.tz-instrument {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
border: 1px solid var(--border-primary);
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--color-brand-500) 4%, transparent), transparent 60%),
|
||||
var(--surface-card-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tz-instrument { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.tz-instrument__clock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tz-digit-group {
|
||||
font-family: var(--font-family-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.8rem, 2.6vw + 1rem, 2.6rem);
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: 0 1px 0 color-mix(in srgb, var(--color-brand-500) 8%, transparent);
|
||||
}
|
||||
|
||||
.tz-instrument__offset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 2px 8px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-brand-700);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-500) 24%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
:global([data-theme='dark']) .tz-instrument__offset { color: var(--color-brand-200); }
|
||||
|
||||
.tz-instrument__zone {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
border: 1px dashed color-mix(in srgb, var(--color-brand-500) 28%, transparent);
|
||||
background: var(--surface-card);
|
||||
transition: border-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.tz-instrument__zone:hover {
|
||||
border-color: var(--color-brand-500);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tz-instrument__zone:focus-visible {
|
||||
outline: 2px solid var(--color-brand-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tz-instrument__zone-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tz-instrument__zone-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tz-auto-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-success-dark);
|
||||
background: var(--color-success-light);
|
||||
border-radius: 999px;
|
||||
}
|
||||
:global([data-theme='dark']) .tz-auto-badge {
|
||||
color: #86efac;
|
||||
background: color-mix(in srgb, var(--color-success) 18%, transparent);
|
||||
}
|
||||
|
||||
.tz-instrument__zone-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Preview rows ──────────────────────────────────────────────────── */
|
||||
.tz-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--surface-page);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.tz-preview__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.tz-preview__label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.tz-preview__value {
|
||||
font-family: var(--font-family-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tz-preview__hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Timezone-aware date/time formatting.
|
||||
*
|
||||
* Every call site in the app should format through this module — the point of
|
||||
* the user-selected timezone is defeated if any page slips back to the raw
|
||||
* `toLocaleString()` which silently uses the browser zone.
|
||||
*
|
||||
* All functions accept the same input shapes: an ISO string, a `Date`, or a
|
||||
* unix timestamp (seconds since epoch — matches Docker API). Falsy inputs
|
||||
* return an em dash placeholder so callers don't need a guard.
|
||||
*/
|
||||
|
||||
import { derived, get, type Readable } from 'svelte/store';
|
||||
import { effectiveTimezone } from '$lib/stores/timezone';
|
||||
import { locale } from '$lib/i18n';
|
||||
|
||||
export type DateInput = string | number | Date | null | undefined;
|
||||
|
||||
const PLACEHOLDER = '—';
|
||||
|
||||
function toDate(input: DateInput): Date | null {
|
||||
if (input === null || input === undefined || input === '') return null;
|
||||
if (input instanceof Date) return isNaN(input.getTime()) ? null : input;
|
||||
if (typeof input === 'number') {
|
||||
// Docker timestamps come in as unix seconds; JS Date wants ms.
|
||||
// Values below 1e12 are plausibly seconds, above are ms.
|
||||
const ms = input < 1e12 ? input * 1000 : input;
|
||||
const d = new Date(ms);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
const d = new Date(input);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function localeTag(loc: string): string {
|
||||
// Map our app locale codes to BCP-47 tags. Unknown codes fall back to en-GB
|
||||
// which gives an ISO-like day-month order — less confusing internationally
|
||||
// than US en-US month-first.
|
||||
switch (loc) {
|
||||
case 'ru':
|
||||
return 'ru-RU';
|
||||
case 'en':
|
||||
return 'en-GB';
|
||||
default:
|
||||
return loc || 'en-GB';
|
||||
}
|
||||
}
|
||||
|
||||
function makeFormatters(tz: string, loc: string) {
|
||||
const tag = localeTag(loc);
|
||||
const baseOpts: Intl.DateTimeFormatOptions = { timeZone: tz };
|
||||
|
||||
// Instantiate once per (tz, locale) pair — Intl.DateTimeFormat construction
|
||||
// is surprisingly expensive when called inside tight loops (event log list).
|
||||
const dateTimeFmt = new Intl.DateTimeFormat(tag, {
|
||||
...baseOpts,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const dateFmt = new Intl.DateTimeFormat(tag, {
|
||||
...baseOpts,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit'
|
||||
});
|
||||
const timeFmt = new Intl.DateTimeFormat(tag, {
|
||||
...baseOpts,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const shortDateFmt = new Intl.DateTimeFormat(tag, {
|
||||
...baseOpts,
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
const compactFmt = new Intl.DateTimeFormat(tag, {
|
||||
...baseOpts,
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const clockFmt = new Intl.DateTimeFormat(tag, {
|
||||
...baseOpts,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
return {
|
||||
/** Full timestamp: "23 Apr 2026, 14:05:32". Use for event log detail, logs. */
|
||||
dateTime(input: DateInput): string {
|
||||
const d = toDate(input);
|
||||
return d ? dateTimeFmt.format(d) : PLACEHOLDER;
|
||||
},
|
||||
/** Date only: "23 Apr 2026". Use for created_at columns. */
|
||||
date(input: DateInput): string {
|
||||
const d = toDate(input);
|
||||
return d ? dateFmt.format(d) : PLACEHOLDER;
|
||||
},
|
||||
/** Clock time: "14:05:32". Use for live headers and the settings preview. */
|
||||
time(input: DateInput): string {
|
||||
const d = toDate(input);
|
||||
return d ? timeFmt.format(d) : PLACEHOLDER;
|
||||
},
|
||||
/** "Apr 23, 2026" — matches legacy StaleContainerCard look. */
|
||||
shortDate(input: DateInput): string {
|
||||
const d = toDate(input);
|
||||
return d ? shortDateFmt.format(d) : PLACEHOLDER;
|
||||
},
|
||||
/** Compact: "Apr 23, 14:05". Use in dense tables. */
|
||||
compact(input: DateInput): string {
|
||||
const d = toDate(input);
|
||||
return d ? compactFmt.format(d) : PLACEHOLDER;
|
||||
},
|
||||
/** Clock HH:MM:SS — used by the live clock in the timezone card. */
|
||||
clock(input: DateInput): string {
|
||||
const d = toDate(input);
|
||||
return d ? clockFmt.format(d) : PLACEHOLDER;
|
||||
},
|
||||
/** Relative "5m ago" — timezone-independent but locale-aware. */
|
||||
relative(input: DateInput): string {
|
||||
const d = toDate(input);
|
||||
if (!d) return PLACEHOLDER;
|
||||
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (diffSec < 60) return relativeLabel(loc, diffSec, 's');
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return relativeLabel(loc, diffMin, 'm');
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return relativeLabel(loc, diffHour, 'h');
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay < 30) return relativeLabel(loc, diffDay, 'd');
|
||||
return relativeLabel(loc, Math.floor(diffDay / 30), 'mo');
|
||||
},
|
||||
/** Currently active zone — exposed so UIs can show "rendered in X". */
|
||||
timezone: tz,
|
||||
locale: tag
|
||||
};
|
||||
}
|
||||
|
||||
function relativeLabel(loc: string, value: number, unit: 's' | 'm' | 'h' | 'd' | 'mo'): string {
|
||||
if (loc === 'ru') {
|
||||
const ruUnits: Record<typeof unit, string> = { s: 'с', m: 'м', h: 'ч', d: 'д', mo: 'мес' };
|
||||
return `${value}${ruUnits[unit]} назад`;
|
||||
}
|
||||
return `${value}${unit} ago`;
|
||||
}
|
||||
|
||||
export type DateFormatter = ReturnType<typeof makeFormatters>;
|
||||
|
||||
/**
|
||||
* Reactive formatter — re-derives whenever the user changes timezone or
|
||||
* locale. Consume with `$fmt.dateTime(...)` in templates.
|
||||
*/
|
||||
export const fmt: Readable<DateFormatter> = derived(
|
||||
[effectiveTimezone, locale],
|
||||
([$tz, $loc]) => makeFormatters($tz, $loc)
|
||||
);
|
||||
|
||||
/** One-shot formatter snapshot for imperative code (event handlers, tooltips). */
|
||||
export function currentFormatter(): DateFormatter {
|
||||
return get(fmt);
|
||||
}
|
||||
@@ -859,6 +859,43 @@
|
||||
"proxies": "Proxies",
|
||||
"recentErrors": "Recent Errors"
|
||||
},
|
||||
"daemons": {
|
||||
"title": "Daemons",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing",
|
||||
"docker": "Docker Engine",
|
||||
"npm": "Nginx Proxy Manager",
|
||||
"traefik": "Traefik",
|
||||
"proxy": "Proxy",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"notConfigured": "Not configured",
|
||||
"containers": "Containers",
|
||||
"running": "Running",
|
||||
"paused": "Paused",
|
||||
"stopped": "Stopped",
|
||||
"version": "Version",
|
||||
"apiVersion": "API Version",
|
||||
"platform": "Platform",
|
||||
"kernel": "Kernel",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"storage": "Storage Driver",
|
||||
"images": "Images",
|
||||
"latency": "Latency",
|
||||
"rootDir": "Root Dir",
|
||||
"provider": "Provider",
|
||||
"endpoint": "Endpoint",
|
||||
"proxyHosts": "Proxy Hosts",
|
||||
"managed": "managed",
|
||||
"external": "external",
|
||||
"accessLists": "Access Lists",
|
||||
"certificates": "Certificates",
|
||||
"dockerHint": "Check that the Docker daemon is running and that the socket is reachable.",
|
||||
"proxyHint": "Verify the proxy URL, credentials, and that the service is listening.",
|
||||
"noProxyDesc": "No proxy provider is configured. Tinyforge can manage routes via Nginx Proxy Manager or Traefik.",
|
||||
"configureProxy": "Configure in Settings"
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS Records",
|
||||
"description": "View and manage DNS records created by Tinyforge.",
|
||||
@@ -1021,5 +1058,26 @@
|
||||
"fetchLogs": "Failed to load logs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
"eyebrow": "The Forge // Chronograph",
|
||||
"title": "Display timezone",
|
||||
"subtitle": "All dates across Tinyforge — event log, deploys, backups, sites — render in this zone.",
|
||||
"modeLabel": "Detection mode",
|
||||
"modeAuto": "Auto-detect",
|
||||
"modeManual": "Manual",
|
||||
"autoDetect": "Auto-detect from browser",
|
||||
"autoBadge": "Auto",
|
||||
"activeZone": "Active zone",
|
||||
"changeZone": "Change timezone",
|
||||
"clickToChange": "Click to pick a zone →",
|
||||
"pickerTitle": "Select timezone",
|
||||
"pickerPlaceholder": "Search zones — city, region, UTC offset…",
|
||||
"groupAuto": "Detection",
|
||||
"groupPopular": "Popular",
|
||||
"groupAll": "All timezones",
|
||||
"previewFull": "Full timestamp",
|
||||
"previewDate": "Date only",
|
||||
"previewHint": "Timestamps like the event log will look exactly like this."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,6 +859,43 @@
|
||||
"proxies": "Прокси",
|
||||
"recentErrors": "Недавние ошибки"
|
||||
},
|
||||
"daemons": {
|
||||
"title": "Демоны",
|
||||
"refresh": "Обновить",
|
||||
"refreshing": "Обновление",
|
||||
"docker": "Docker Engine",
|
||||
"npm": "Nginx Proxy Manager",
|
||||
"traefik": "Traefik",
|
||||
"proxy": "Прокси",
|
||||
"online": "Онлайн",
|
||||
"offline": "Оффлайн",
|
||||
"notConfigured": "Не настроено",
|
||||
"containers": "Контейнеры",
|
||||
"running": "Запущено",
|
||||
"paused": "Пауза",
|
||||
"stopped": "Остановлено",
|
||||
"version": "Версия",
|
||||
"apiVersion": "Версия API",
|
||||
"platform": "Платформа",
|
||||
"kernel": "Ядро",
|
||||
"cpu": "CPU",
|
||||
"memory": "Память",
|
||||
"storage": "Хранилище",
|
||||
"images": "Образы",
|
||||
"latency": "Задержка",
|
||||
"rootDir": "Корневой каталог",
|
||||
"provider": "Провайдер",
|
||||
"endpoint": "Адрес",
|
||||
"proxyHosts": "Прокси-хосты",
|
||||
"managed": "наши",
|
||||
"external": "внешние",
|
||||
"accessLists": "Списки доступа",
|
||||
"certificates": "Сертификаты",
|
||||
"dockerHint": "Проверьте, что Docker-демон запущен и сокет доступен.",
|
||||
"proxyHint": "Проверьте URL прокси, учётные данные и доступность сервиса.",
|
||||
"noProxyDesc": "Провайдер прокси не настроен. Tinyforge поддерживает Nginx Proxy Manager или Traefik.",
|
||||
"configureProxy": "Настроить в параметрах"
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS-записи",
|
||||
"description": "Просмотр и управление DNS-записями, созданными Tinyforge.",
|
||||
@@ -1021,5 +1058,26 @@
|
||||
"fetchLogs": "Не удалось загрузить логи"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
"eyebrow": "The Forge // Хронограф",
|
||||
"title": "Часовой пояс отображения",
|
||||
"subtitle": "Все даты в Tinyforge — лог событий, деплои, бэкапы, сайты — показываются в этом поясе.",
|
||||
"modeLabel": "Режим определения",
|
||||
"modeAuto": "Автоопределение",
|
||||
"modeManual": "Вручную",
|
||||
"autoDetect": "Автоопределение из браузера",
|
||||
"autoBadge": "Авто",
|
||||
"activeZone": "Активный пояс",
|
||||
"changeZone": "Сменить часовой пояс",
|
||||
"clickToChange": "Нажмите, чтобы выбрать пояс →",
|
||||
"pickerTitle": "Выбор часового пояса",
|
||||
"pickerPlaceholder": "Поиск — город, регион, смещение UTC…",
|
||||
"groupAuto": "Определение",
|
||||
"groupPopular": "Популярные",
|
||||
"groupAll": "Все пояса",
|
||||
"previewFull": "Полная метка времени",
|
||||
"previewDate": "Только дата",
|
||||
"previewHint": "Метки времени в логе событий будут выглядеть именно так."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Shared health store. Both the sidebar status chips and the dashboard
|
||||
* daemon panel subscribe to this single source so the server is not
|
||||
* polled twice for the same data.
|
||||
*
|
||||
* Poll cadence is 30 seconds — matching the previous layout-local
|
||||
* implementation. An out-of-band `refreshHealth()` call is exposed for
|
||||
* user-initiated "retry now" actions.
|
||||
*/
|
||||
|
||||
import { writable, type Readable } from 'svelte/store';
|
||||
import * as api from '$lib/api';
|
||||
import { isAuthenticated } from '$lib/auth';
|
||||
import type { DockerHealth, ProxyHealth } from '$lib/types';
|
||||
|
||||
export interface HealthSnapshot {
|
||||
docker: DockerHealth | null;
|
||||
proxy: ProxyHealth | null;
|
||||
/** true once the first poll (success or failure) has completed. */
|
||||
checked: boolean;
|
||||
/** ms epoch of the last successful or attempted poll. */
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
const EMPTY: HealthSnapshot = {
|
||||
docker: null,
|
||||
proxy: null,
|
||||
checked: false,
|
||||
lastUpdated: 0
|
||||
};
|
||||
|
||||
const store = writable<HealthSnapshot>(EMPTY);
|
||||
|
||||
export const health: Readable<HealthSnapshot> = { subscribe: store.subscribe };
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight = false;
|
||||
|
||||
async function refreshOnce(): Promise<void> {
|
||||
if (inFlight || !isAuthenticated()) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const h = await api.getHealth();
|
||||
store.set({
|
||||
docker: h.docker,
|
||||
proxy: h.proxy ?? null,
|
||||
checked: true,
|
||||
lastUpdated: Date.now()
|
||||
});
|
||||
} catch {
|
||||
store.update((prev) => ({
|
||||
docker: prev.docker ?? { connected: false },
|
||||
proxy: prev.proxy,
|
||||
checked: true,
|
||||
lastUpdated: Date.now()
|
||||
}));
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startHealthPolling(intervalMs = 30_000): void {
|
||||
if (pollTimer) return;
|
||||
void refreshOnce();
|
||||
pollTimer = setInterval(() => void refreshOnce(), intervalMs);
|
||||
}
|
||||
|
||||
export function stopHealthPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshHealth(): Promise<void> {
|
||||
return refreshOnce();
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Timezone preference store.
|
||||
*
|
||||
* The user's IANA timezone is a purely client-side preference — it controls how
|
||||
* server-supplied ISO timestamps are rendered. A reserved sentinel ('auto')
|
||||
* means "use whatever the browser reports right now". Stored in localStorage
|
||||
* so it survives page reloads and roams per-browser.
|
||||
*/
|
||||
|
||||
import { writable, derived, type Readable } from 'svelte/store';
|
||||
|
||||
const TIMEZONE_KEY = 'dw_timezone';
|
||||
|
||||
/** Sentinel meaning "follow the browser's Intl-reported zone on every read". */
|
||||
export const AUTO_TIMEZONE = 'auto' as const;
|
||||
|
||||
export type TimezonePreference = typeof AUTO_TIMEZONE | string;
|
||||
|
||||
/** Resolve the browser's current IANA timezone, with a safe UTC fallback. */
|
||||
export function detectBrowserTimezone(): string {
|
||||
try {
|
||||
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if (resolved) return resolved;
|
||||
} catch {
|
||||
// ignore and fall through
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an IANA timezone string by asking Intl to format with it. Intl throws
|
||||
* `RangeError` for unknown zones — exactly what we want for user-facing hints.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
if (!tz) return false;
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialPreference(): TimezonePreference {
|
||||
if (typeof localStorage === 'undefined') return AUTO_TIMEZONE;
|
||||
const stored = localStorage.getItem(TIMEZONE_KEY);
|
||||
if (!stored) return AUTO_TIMEZONE;
|
||||
if (stored === AUTO_TIMEZONE) return AUTO_TIMEZONE;
|
||||
return isValidTimezone(stored) ? stored : AUTO_TIMEZONE;
|
||||
}
|
||||
|
||||
/** Raw preference — may be 'auto' or a specific IANA zone. */
|
||||
export const timezonePreference = writable<TimezonePreference>(getInitialPreference());
|
||||
|
||||
timezonePreference.subscribe((value) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(TIMEZONE_KEY, value);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Effective timezone — always a concrete IANA string. When preference is
|
||||
* 'auto', re-resolves from the browser. Components should consume this store
|
||||
* for formatting, never the raw preference.
|
||||
*/
|
||||
export const effectiveTimezone: Readable<string> = derived(
|
||||
timezonePreference,
|
||||
($pref) => ($pref === AUTO_TIMEZONE ? detectBrowserTimezone() : $pref)
|
||||
);
|
||||
|
||||
export function setTimezonePreference(value: TimezonePreference): void {
|
||||
if (value !== AUTO_TIMEZONE && !isValidTimezone(value)) return;
|
||||
timezonePreference.set(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated shortlist shown at the top of the picker. The full catalogue comes
|
||||
* from `Intl.supportedValuesOf('timeZone')` when available.
|
||||
*/
|
||||
export const COMMON_TIMEZONES: readonly string[] = [
|
||||
'UTC',
|
||||
'Europe/London',
|
||||
'Europe/Berlin',
|
||||
'Europe/Moscow',
|
||||
'Europe/Minsk',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Sao_Paulo',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Singapore',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Full list of IANA zones supported by the browser. Falls back to the curated
|
||||
* shortlist on older runtimes that lack `Intl.supportedValuesOf`.
|
||||
*/
|
||||
export function listAllTimezones(): string[] {
|
||||
const intlAny = Intl as unknown as { supportedValuesOf?: (kind: string) => string[] };
|
||||
if (typeof intlAny.supportedValuesOf === 'function') {
|
||||
try {
|
||||
return intlAny.supportedValuesOf('timeZone');
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return [...COMMON_TIMEZONES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable offset label like "UTC+03:00" for a given zone at a given
|
||||
* instant (defaults to now). Used inline in the picker list.
|
||||
*/
|
||||
export function formatOffsetLabel(tz: string, at: Date = new Date()): string {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'shortOffset'
|
||||
}).formatToParts(at);
|
||||
const offset = parts.find((p) => p.type === 'timeZoneName')?.value ?? '';
|
||||
// Intl reports "GMT", "GMT+3", "GMT-05:30" — normalise to "UTC±HH:MM".
|
||||
if (!offset || offset === 'GMT') return 'UTC+00:00';
|
||||
const m = offset.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
|
||||
if (!m) return offset.replace('GMT', 'UTC');
|
||||
const sign = m[1];
|
||||
const hours = m[2].padStart(2, '0');
|
||||
const minutes = (m[3] ?? '00').padStart(2, '0');
|
||||
return `UTC${sign}${hours}:${minutes}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -256,12 +256,36 @@ export interface DockerHealth {
|
||||
hints?: string[];
|
||||
platform?: string;
|
||||
checked_at?: string;
|
||||
latency_ms?: number;
|
||||
version?: string;
|
||||
api_version?: string;
|
||||
os?: string;
|
||||
arch?: string;
|
||||
kernel?: string;
|
||||
operating_system?: string;
|
||||
storage_driver?: string;
|
||||
root_dir?: string;
|
||||
name?: string;
|
||||
ncpu?: number;
|
||||
memory_total?: number;
|
||||
containers?: number;
|
||||
running?: number;
|
||||
paused?: number;
|
||||
stopped?: number;
|
||||
images?: number;
|
||||
}
|
||||
|
||||
export interface ProxyHealth {
|
||||
connected: boolean;
|
||||
provider: string;
|
||||
error?: string;
|
||||
latency_ms?: number;
|
||||
url?: string;
|
||||
proxy_hosts?: number;
|
||||
/** Routes deployed by Tinyforge itself (instances + static sites). */
|
||||
proxy_hosts_managed?: number;
|
||||
access_lists?: number;
|
||||
certificates?: number;
|
||||
}
|
||||
|
||||
/** A local Docker image. */
|
||||
|
||||
+227
-98
@@ -10,10 +10,10 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||
import { logout as apiLogout, getHealth } from '$lib/api';
|
||||
import type { DockerHealth, ProxyHealth } from '$lib/types';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -47,13 +47,13 @@
|
||||
}
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
let dockerHealth = $state<DockerHealth | null>(null);
|
||||
let proxyHealth = $state<ProxyHealth | null>(null);
|
||||
let healthChecked = $state(false);
|
||||
let healthInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let hintsExpanded = $state(false);
|
||||
let proxyHintsExpanded = $state(false);
|
||||
|
||||
const dockerHealth = $derived($health.docker);
|
||||
const proxyHealth = $derived($health.proxy);
|
||||
const healthChecked = $derived($health.checked);
|
||||
|
||||
// Live UTC forge clock (refreshes every second). A small thing, but it makes
|
||||
// the sidebar feel alive and reinforces the "control room" aesthetic.
|
||||
let nowUtc = $state('');
|
||||
@@ -142,33 +142,19 @@
|
||||
refreshNavCounts();
|
||||
});
|
||||
|
||||
// Start health polling when authenticated.
|
||||
// Uses $effect to react to route changes (e.g., after login navigation).
|
||||
// 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() || healthInterval) return;
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const h = await getHealth();
|
||||
dockerHealth = h.docker;
|
||||
proxyHealth = h.proxy ?? null;
|
||||
} catch {
|
||||
dockerHealth = { connected: false };
|
||||
proxyHealth = null;
|
||||
}
|
||||
healthChecked = true;
|
||||
}
|
||||
checkHealth();
|
||||
healthInterval = setInterval(checkHealth, 30_000);
|
||||
if (!isAuthenticated()) return;
|
||||
startHealthPolling();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (healthInterval) clearInterval(healthInterval);
|
||||
if (clockTimer) clearInterval(clockTimer);
|
||||
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
|
||||
stopNavCountsPolling();
|
||||
stopHealthPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -191,19 +177,83 @@
|
||||
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'}"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="brand flex h-16 items-center gap-2.5 border-b border-[var(--border-primary)] px-5">
|
||||
<span class="forge-ember brand-ember"></span>
|
||||
<span class="brand-name">{$t('app.name')}</span>
|
||||
<!-- 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>
|
||||
<!-- 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="Service status">
|
||||
{#if healthChecked}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:chip-live={dockerConnected}
|
||||
class:chip-down={!dockerConnected}
|
||||
title={dockerConnected ? `Docker daemon · ${dockerHealth?.version ?? 'reachable'}` : dockerHealth?.error ?? 'Docker unreachable'}
|
||||
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 ?? 'Proxy unreachable'}
|
||||
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 -->
|
||||
@@ -254,65 +304,6 @@
|
||||
|
||||
<!-- Footer controls -->
|
||||
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
||||
{#if healthChecked}
|
||||
<div class="flex items-center gap-3 px-1 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 {dockerConnected ? 'text-emerald-600' : 'text-red-500'}"
|
||||
title={dockerConnected ? 'Docker connected' : dockerHealth?.error ?? 'Docker disconnected'}
|
||||
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
|
||||
>
|
||||
<span class="relative flex h-2 w-2 shrink-0">
|
||||
{#if dockerConnected}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full {dockerConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||
</span>
|
||||
Docker
|
||||
</button>
|
||||
{#if proxyHealth && proxyProviderName !== 'none'}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 {proxyConnected ? 'text-emerald-600' : 'text-red-500'}"
|
||||
title={proxyConnected ? (proxyProviderName === 'npm' ? 'NPM' : 'Traefik') + ' connected' : proxyHealth.error ?? 'Proxy disconnected'}
|
||||
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
|
||||
>
|
||||
<span class="relative flex h-2 w-2 shrink-0">
|
||||
{#if proxyConnected}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full {proxyConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||
</span>
|
||||
{proxyProviderName === 'npm' ? 'NPM' : 'Traefik'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
|
||||
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{dockerHealth.error}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1.5 w-full rounded border border-red-300 dark:border-red-700 px-2 py-0.5 text-[10px] font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
onclick={async () => {
|
||||
try {
|
||||
const h = await getHealth();
|
||||
dockerHealth = h.docker;
|
||||
proxyHealth = h.proxy ?? null;
|
||||
} catch {
|
||||
dockerHealth = { connected: false };
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('health.retryNow')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
|
||||
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{proxyHealth.error}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex items-center justify-between">
|
||||
<ThemeToggle />
|
||||
<LocaleSwitcher />
|
||||
@@ -392,8 +383,8 @@
|
||||
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||
}
|
||||
|
||||
.brand {
|
||||
gap: 0.75rem;
|
||||
.brand-block {
|
||||
position: relative;
|
||||
}
|
||||
.brand-ember {
|
||||
width: 10px; height: 10px;
|
||||
@@ -407,6 +398,144 @@
|
||||
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;
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||
import SystemDaemonsCard from '$lib/components/SystemDaemonsCard.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
||||
@@ -181,6 +183,9 @@
|
||||
<!-- System health summary -->
|
||||
<SystemHealthCard />
|
||||
|
||||
<!-- Detailed daemon panel: Docker engine + NPM/Traefik proxy -->
|
||||
<SystemDaemonsCard />
|
||||
|
||||
<!-- Static sites summary -->
|
||||
{#if !loading}
|
||||
<section class="section">
|
||||
@@ -219,7 +224,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if site.last_sync_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {new Date(site.last_sync_at).toLocaleString()}</p>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {$fmt.dateTime(site.last_sync_at)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Project, EntityPickerItem } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconPlus, IconSearch, IconLoader } from '$lib/components/icons';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
||||
@@ -284,7 +285,7 @@
|
||||
{project.registry || '-'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{new Date(project.created_at).toLocaleDateString()}
|
||||
{$fmt.date(project.created_at)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
|
||||
<a href="/projects/{project.id}" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { IconShield } from '$lib/components/icons';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let stages = $state<Stage[]>([]);
|
||||
@@ -525,7 +526,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{new Date(project.created_at).toLocaleDateString()}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{$fmt.date(project.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -757,7 +758,7 @@
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs font-mono text-[var(--text-tertiary)]">{img.id.substring(7, 19)}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{(img.size / (1024 * 1024)).toFixed(1)} MB</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-tertiary)]">{new Date(img.created * 1000).toLocaleDateString()}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-tertiary)]">{$fmt.date(img.created)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -790,11 +791,11 @@
|
||||
{#if deploy.started_at}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<IconClock size={12} />
|
||||
{new Date(deploy.started_at).toLocaleString()}
|
||||
{$fmt.dateTime(deploy.started_at)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if deploy.finished_at}
|
||||
<span>→ {new Date(deploy.finished_at).toLocaleString()}</span>
|
||||
<span>→ {$fmt.dateTime(deploy.finished_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if deploy.error}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconLoader, IconChevronRight } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
@@ -49,12 +50,6 @@
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async function loadDir(path: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
@@ -227,7 +222,7 @@
|
||||
{entry.is_dir ? '—' : formatSize(entry.size)}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-[var(--text-tertiary)]">
|
||||
{formatDate(entry.mod_time)}
|
||||
{$fmt.compact(entry.mod_time)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconCopy, IconRefresh, IconX, IconInfo } from '$lib/components/icons';
|
||||
@@ -274,6 +275,8 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TimezoneSelector />
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.globalConfig')}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { getAuthToken } from '$lib/auth';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
@@ -123,10 +124,10 @@
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
/** Backend returns naive (no-offset) timestamps — treat as UTC by appending Z. */
|
||||
function toUtcIso(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr + 'Z');
|
||||
return d.toLocaleString();
|
||||
return /Z|[+-]\d{2}:?\d{2}$/.test(dateStr) ? dateStr : dateStr + 'Z';
|
||||
}
|
||||
|
||||
$effect(() => { loadData(); });
|
||||
@@ -236,7 +237,7 @@
|
||||
{backup.backup_type === 'auto' ? $t('settingsBackup.typeAuto') : $t('settingsBackup.typeManual')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">{formatDate(backup.created_at)}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">{$fmt.dateTime(toUtcIso(backup.created_at))}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button onclick={() => handleDownload(backup.id)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { StaticSite } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconPlus, IconSearch, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
|
||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
@@ -204,7 +205,7 @@
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{#if site.last_sync_at}
|
||||
{new Date(site.last_sync_at).toLocaleString()}
|
||||
{$fmt.dateTime(site.last_sync_at)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { StaticSite, StaticSiteSecret, StaticSiteStorageUsage } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
@@ -231,7 +232,7 @@
|
||||
<span class="text-[var(--text-primary)]">{site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.lastSync')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.last_sync_at ? new Date(site.last_sync_at).toLocaleString() : '-'}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.last_sync_at ? $fmt.dateTime(site.last_sync_at) : '-'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.commitSha')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let stacks = $state<Stack[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -46,11 +47,6 @@
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function fmtTime(ts: string): string {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleString(); } catch { return ts; }
|
||||
}
|
||||
|
||||
onMount(loadStacks);
|
||||
</script>
|
||||
|
||||
@@ -133,7 +129,7 @@
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="meta-k">{$t('stacks.card.updated')}</span>
|
||||
<span class="meta-v">{fmtTime(s.updated_at)}</span>
|
||||
<span class="meta-v">{$fmt.dateTime(s.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { IconArrowLeft, IconRefresh, IconPlay, IconStop, IconTrash } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
@@ -97,10 +98,6 @@
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function fmtTime(ts: string): string {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleString(); } catch { return ts; }
|
||||
}
|
||||
function serviceState(s: string): string {
|
||||
if (!s) return 'unknown';
|
||||
return s.toLowerCase();
|
||||
@@ -301,7 +298,7 @@
|
||||
<span class="tl-badge">{$t('stacks.detail.revisions.current')}</span>
|
||||
{/if}
|
||||
<span class="tl-status">{rev.status}</span>
|
||||
<span class="tl-time">{fmtTime(rev.created_at)}</span>
|
||||
<span class="tl-time">{$fmt.dateTime(rev.created_at)}</span>
|
||||
</div>
|
||||
<div class="tl-meta">
|
||||
{$t('stacks.detail.revisions.by')} <strong>{rev.author || 'operator'}</strong>
|
||||
|
||||
Reference in New Issue
Block a user