feat: daemon health panel, brand-rail status chips, user timezone selector
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:
2026-04-23 14:32:30 +03:00
parent a182a93950
commit 90e6e59d9e
24 changed files with 2267 additions and 177 deletions
+3 -20
View File
@@ -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>
+173
View File
@@ -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);
}
+58
View File
@@ -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."
}
}
+58
View File
@@ -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": "Метки времени в логе событий будут выглядеть именно так."
}
}
+77
View File
@@ -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();
}
+138
View File
@@ -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 '';
}
}
+24
View File
@@ -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
View File
@@ -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;
+6 -1
View File
@@ -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 -1
View File
@@ -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">
+5 -4
View File
@@ -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}
+3
View File
@@ -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">
+5 -4
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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>
+2 -6
View File
@@ -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">
+2 -5
View File
@@ -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>