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:
+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