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
+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);
}