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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user