diff --git a/internal/api/health.go b/internal/api/health.go
index 823a956..748977f 100644
--- a/internal/api/health.go
+++ b/internal/api/health.go
@@ -4,11 +4,19 @@ import (
"context"
"net/http"
"time"
+
+ "github.com/alexei/tinyforge/internal/proxy"
)
// getHealth handles GET /api/health.
+//
+// Returns the connectivity state and (when connected) rich diagnostics for the
+// Docker daemon and the active proxy provider. This endpoint is polled by the
+// UI every 30 seconds — keep the calls cheap. The expensive NPM list calls
+// are only issued when the initial ping succeeds, so a down proxy never
+// amplifies latency.
func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) {
- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
now := time.Now().UTC().Format(time.RFC3339)
@@ -16,37 +24,176 @@ func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) {
"checked_at": now,
}
- // Check database connectivity.
+ // ── Database ─────────────────────────────────────────────────────
if err := s.store.DB().PingContext(ctx); err != nil {
result["database"] = map[string]any{"connected": false, "error": "database unreachable"}
} else {
result["database"] = map[string]any{"connected": true}
}
- // Check Docker connectivity.
- if s.docker == nil {
- result["docker"] = map[string]any{
- "connected": false,
- "error": "docker client not initialized",
- }
- } else if err := s.docker.Ping(ctx); err != nil {
- result["docker"] = map[string]any{
- "connected": false,
- "error": err.Error(),
- }
- } else {
- result["docker"] = map[string]any{"connected": true}
- }
+ // ── Docker daemon ────────────────────────────────────────────────
+ result["docker"] = s.dockerHealth(ctx)
- // Check proxy provider connectivity.
+ // ── Proxy provider ───────────────────────────────────────────────
if s.proxyProvider != nil {
- providerName := s.proxyProvider.Name()
- if err := s.proxyProvider.Ping(ctx); err != nil {
- result["proxy"] = map[string]any{"provider": providerName, "connected": false, "error": providerName + " unreachable"}
- } else {
- result["proxy"] = map[string]any{"provider": providerName, "connected": true}
- }
+ result["proxy"] = s.proxyHealth(ctx)
}
respondJSON(w, http.StatusOK, result)
}
+
+// dockerHealth probes the Docker daemon and, if reachable, attaches a full
+// DaemonInfo snapshot. The caller does not need to error-check the Info()
+// call — if it fails, the connected flag remains true (ping succeeded) but
+// the detail fields are simply omitted.
+func (s *Server) dockerHealth(ctx context.Context) map[string]any {
+ if s.docker == nil {
+ return map[string]any{
+ "connected": false,
+ "error": "docker client not initialized",
+ }
+ }
+
+ start := time.Now()
+ if err := s.docker.Ping(ctx); err != nil {
+ return map[string]any{
+ "connected": false,
+ "error": err.Error(),
+ "latency_ms": time.Since(start).Milliseconds(),
+ }
+ }
+
+ out := map[string]any{
+ "connected": true,
+ "latency_ms": time.Since(start).Milliseconds(),
+ }
+
+ // Info enriches the payload; failures are non-fatal.
+ info, err := s.docker.Info(ctx)
+ if err == nil {
+ if info.Version != "" {
+ out["version"] = info.Version
+ }
+ if info.APIVersion != "" {
+ out["api_version"] = info.APIVersion
+ }
+ if info.OS != "" {
+ out["os"] = info.OS
+ }
+ if info.Arch != "" {
+ out["arch"] = info.Arch
+ }
+ if info.Kernel != "" {
+ out["kernel"] = info.Kernel
+ }
+ if info.OperatingSystem != "" {
+ out["operating_system"] = info.OperatingSystem
+ }
+ if info.StorageDriver != "" {
+ out["storage_driver"] = info.StorageDriver
+ }
+ if info.RootDir != "" {
+ out["root_dir"] = info.RootDir
+ }
+ if info.Name != "" {
+ out["name"] = info.Name
+ }
+ if info.NCPU > 0 {
+ out["ncpu"] = info.NCPU
+ }
+ if info.MemoryTotal > 0 {
+ out["memory_total"] = info.MemoryTotal
+ }
+ out["containers"] = info.Containers
+ out["running"] = info.Running
+ out["paused"] = info.Paused
+ out["stopped"] = info.Stopped
+ out["images"] = info.Images
+ }
+
+ return out
+}
+
+// proxyHealth probes the configured proxy provider. For NPM, attaches
+// aggregate counts (proxy hosts, access lists, certificates) which the
+// dashboard surfaces alongside the connection indicator.
+func (s *Server) proxyHealth(ctx context.Context) map[string]any {
+ providerName := s.proxyProvider.Name()
+
+ start := time.Now()
+ err := s.proxyProvider.Ping(ctx)
+ latency := time.Since(start).Milliseconds()
+
+ if err != nil {
+ return map[string]any{
+ "provider": providerName,
+ "connected": false,
+ "error": providerName + " unreachable: " + err.Error(),
+ "latency_ms": latency,
+ }
+ }
+
+ out := map[string]any{
+ "provider": providerName,
+ "connected": true,
+ "latency_ms": latency,
+ }
+
+ // Attach configured URL from settings for both NPM and Traefik.
+ if settings, serr := s.store.GetSettings(); serr == nil {
+ switch providerName {
+ case "npm":
+ if settings.NpmURL != "" {
+ out["url"] = settings.NpmURL
+ }
+ case "traefik":
+ if settings.TraefikAPIURL != "" {
+ out["url"] = settings.TraefikAPIURL
+ }
+ }
+ }
+
+ // NPM-specific aggregates — a quick glance at route/list/cert counts.
+ // These calls require an authenticated NPM session, so we trigger the
+ // provider's auth step first (it's cheap: cached JWT is reused for 1h).
+ if providerName == "npm" && s.npm != nil {
+ if np, ok := s.proxyProvider.(*proxy.NpmProvider); ok {
+ if err := np.Authenticate(ctx); err == nil {
+ if hosts, herr := s.npm.ListProxyHosts(ctx); herr == nil {
+ out["proxy_hosts"] = len(hosts)
+ }
+ if lists, lerr := s.npm.ListAccessLists(ctx); lerr == nil {
+ out["access_lists"] = len(lists)
+ }
+ if certs, cerr := s.npm.ListCertificates(ctx); cerr == nil {
+ out["certificates"] = len(certs)
+ }
+ }
+ }
+ }
+
+ // Managed-route count — how many of the proxy's routes were deployed
+ // by Tinyforge itself, counting both Docker instances and static sites.
+ // This works for every provider (NPM, Traefik, …) because it reads from
+ // our own store, not the external proxy API.
+ if managed, merr := s.managedRouteCount(); merr == nil {
+ out["proxy_hosts_managed"] = managed
+ }
+
+ return out
+}
+
+// managedRouteCount returns the number of proxy routes Tinyforge manages
+// (Docker instances + static sites combined). The domain argument doesn't
+// affect the count so we pass an empty string to skip FQDN rendering.
+func (s *Server) managedRouteCount() (int, error) {
+ instanceRoutes, err := s.store.ListProxyRoutes("")
+ if err != nil {
+ return 0, err
+ }
+ siteRoutes, err := s.store.ListStaticSiteProxyRoutes("")
+ if err != nil {
+ return 0, err
+ }
+ return len(instanceRoutes) + len(siteRoutes), nil
+}
diff --git a/internal/docker/client.go b/internal/docker/client.go
index 8716242..b4ab8ff 100644
--- a/internal/docker/client.go
+++ b/internal/docker/client.go
@@ -48,3 +48,67 @@ func (c *Client) Ping(ctx context.Context) error {
}
return nil
}
+
+// DaemonInfo captures the subset of Docker daemon info surfaced in the UI.
+// Fields map directly to /info and /version; JSON tags match the wire format
+// consumed by the frontend's DockerHealth type.
+type DaemonInfo struct {
+ Version string `json:"version,omitempty"`
+ APIVersion string `json:"api_version,omitempty"`
+ OS string `json:"os,omitempty"`
+ Arch string `json:"arch,omitempty"`
+ Kernel string `json:"kernel,omitempty"`
+ OperatingSystem string `json:"operating_system,omitempty"`
+ StorageDriver string `json:"storage_driver,omitempty"`
+ RootDir string `json:"root_dir,omitempty"`
+ Name string `json:"name,omitempty"`
+ NCPU int `json:"ncpu,omitempty"`
+ MemoryTotal int64 `json:"memory_total,omitempty"`
+ Containers int `json:"containers,omitempty"`
+ Running int `json:"running,omitempty"`
+ Paused int `json:"paused,omitempty"`
+ Stopped int `json:"stopped,omitempty"`
+ Images int `json:"images,omitempty"`
+}
+
+// Info returns a compact snapshot of daemon health data. Missing pieces
+// (e.g. if ServerVersion fails but Info succeeds) are returned as zero
+// values rather than bubbling up — the endpoint should degrade gracefully.
+func (c *Client) Info(ctx context.Context) (DaemonInfo, error) {
+ info, err := c.api.Info(ctx, client.InfoOptions{})
+ if err != nil {
+ return DaemonInfo{}, fmt.Errorf("docker info: %w", err)
+ }
+ out := DaemonInfo{
+ OperatingSystem: info.Info.OperatingSystem,
+ Kernel: info.Info.KernelVersion,
+ Arch: info.Info.Architecture,
+ OS: info.Info.OSType,
+ StorageDriver: info.Info.Driver,
+ RootDir: info.Info.DockerRootDir,
+ Name: info.Info.Name,
+ NCPU: info.Info.NCPU,
+ MemoryTotal: info.Info.MemTotal,
+ Containers: info.Info.Containers,
+ Running: info.Info.ContainersRunning,
+ Paused: info.Info.ContainersPaused,
+ Stopped: info.Info.ContainersStopped,
+ Images: info.Info.Images,
+ Version: info.Info.ServerVersion,
+ }
+
+ if ver, verr := c.api.ServerVersion(ctx, client.ServerVersionOptions{}); verr == nil {
+ if ver.Version != "" {
+ out.Version = ver.Version
+ }
+ out.APIVersion = ver.APIVersion
+ if out.Arch == "" {
+ out.Arch = ver.Arch
+ }
+ if out.OS == "" {
+ out.OS = ver.Os
+ }
+ }
+
+ return out, nil
+}
diff --git a/internal/proxy/npm_provider.go b/internal/proxy/npm_provider.go
index 92cee2e..b7f4426 100644
--- a/internal/proxy/npm_provider.go
+++ b/internal/proxy/npm_provider.go
@@ -121,6 +121,14 @@ func (p *NpmProvider) Ping(ctx context.Context) error {
return p.client.Ping(ctx)
}
+// Authenticate is a public wrapper over the internal auth step. It is used by
+// health checks that want to make authenticated list calls without going
+// through the full ConfigureRoute path. Returns an error if credentials are
+// not configured or the NPM API rejects them.
+func (p *NpmProvider) Authenticate(ctx context.Context) error {
+ return p.auth(ctx)
+}
+
// auth authenticates to NPM if credentials are available.
func (p *NpmProvider) auth(ctx context.Context) error {
if p.email == "" {
diff --git a/web/src/lib/components/EventLogEntry.svelte b/web/src/lib/components/EventLogEntry.svelte
index 1475fad..f520f30 100644
--- a/web/src/lib/components/EventLogEntry.svelte
+++ b/web/src/lib/components/EventLogEntry.svelte
@@ -5,6 +5,7 @@
@@ -63,7 +59,7 @@
- {$t('stale.lastAlive')}: {formatDate(container.instance.last_alive_at)}
+ {$t('stale.lastAlive')}: {$fmt.shortDate(container.instance.last_alive_at)}
{container.instance.status}
diff --git a/web/src/lib/components/SystemDaemonsCard.svelte b/web/src/lib/components/SystemDaemonsCard.svelte
new file mode 100644
index 0000000..2a5ce4d
--- /dev/null
+++ b/web/src/lib/components/SystemDaemonsCard.svelte
@@ -0,0 +1,780 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {$t('daemons.docker')}
+
+
+
+ {dockerConnected ? $t('daemons.online') : $t('daemons.offline')}
+
+
+
+ {#if !checked}
+
+ {:else if !dockerConnected}
+
+
{docker?.error ?? 'Docker daemon is not reachable.'}
+
{$t('daemons.dockerHint')}
+
+ {:else}
+
+
+
+ {$t('daemons.containers')}
+ {totalContainers}
+
+ {#if totalContainers > 0}
+
+
+
+
+
+ {:else}
+
+ {/if}
+
+ {$t('daemons.running')} {docker?.running ?? 0}
+ {$t('daemons.paused')} {docker?.paused ?? 0}
+ {$t('daemons.stopped')} {docker?.stopped ?? 0}
+
+
+
+
+
+
+
- {$t('daemons.version')}
+ - {docker?.version ?? '—'}
+
+
+
- {$t('daemons.apiVersion')}
+ - {docker?.api_version ?? '—'}
+
+
+
- {$t('daemons.platform')}
+ - {docker?.os ?? '—'}{docker?.arch ? ` · ${docker.arch}` : ''}
+
+
+
- {$t('daemons.kernel')}
+ - {docker?.kernel ?? '—'}
+
+
+
- {$t('daemons.cpu')}
+ - {docker?.ncpu ? `${docker.ncpu} cores` : '—'}
+
+
+
- {$t('daemons.memory')}
+ - {formatBytes(docker?.memory_total)}
+
+
+
- {$t('daemons.storage')}
+ - {docker?.storage_driver ?? '—'}
+
+
+
- {$t('daemons.images')}
+ - {docker?.images ?? 0}
+
+
+
- {$t('daemons.latency')}
+ - {formatMs(docker?.latency_ms)}
+
+
+
- {$t('daemons.rootDir')}
+ - {docker?.root_dir ?? '—'}
+
+
+ {/if}
+
+
+
+
+
+
+ {#if proxyProvider === 'npm'}
+
+ {:else}
+
+ {/if}
+
+ {proxyProvider === 'npm'
+ ? $t('daemons.npm')
+ : proxyProvider === 'traefik'
+ ? $t('daemons.traefik')
+ : $t('daemons.proxy')}
+
+
+ {#if proxyProvider === 'none' || !proxy}
+
+
+ {$t('daemons.notConfigured')}
+
+ {:else}
+
+
+ {proxyConnected ? $t('daemons.online') : $t('daemons.offline')}
+
+ {/if}
+
+
+ {#if !checked}
+
+ {:else if proxyProvider === 'none' || !proxy}
+
+ {:else if !proxyConnected}
+
+
{proxy.error ?? `${proxyProvider.toUpperCase()} is not reachable.`}
+
{$t('daemons.proxyHint')}
+ {#if proxy.url}
+
URL {proxy.url}
+ {/if}
+
+ {: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}
+
+
+
+ {/if}
+
+
+
+
- {$t('daemons.provider')}
+ - {proxyProvider.toUpperCase()}
+
+
+
- {$t('daemons.latency')}
+ - {formatMs(proxy.latency_ms)}
+
+
+
- {$t('daemons.endpoint')}
+ - {proxy.url ?? '—'}
+
+
+ {/if}
+
+
+
+
+
diff --git a/web/src/lib/components/TimezoneSelector.svelte b/web/src/lib/components/TimezoneSelector.svelte
new file mode 100644
index 0000000..54b562c
--- /dev/null
+++ b/web/src/lib/components/TimezoneSelector.svelte
@@ -0,0 +1,454 @@
+
+
+
+
+
+
+
+
+
+ {$t('timezone.eyebrow')}
+
+
{$t('timezone.title')}
+
{$t('timezone.subtitle')}
+
+
+
+
+
+
+
+
+
+
+
+ {clockReading}
+ {currentOffset}
+
+
+
+
+
+
+
+
+ {$t('timezone.previewFull')}
+ {previewNow}
+
+
+ {$t('timezone.previewDate')}
+ {previewDate}
+
+
{$t('timezone.previewHint')}
+
+
+
+ { pickerOpen = false; }}
+/>
+
+
diff --git a/web/src/lib/format/datetime.ts b/web/src/lib/format/datetime.ts
new file mode 100644
index 0000000..777cc1a
--- /dev/null
+++ b/web/src/lib/format/datetime.ts
@@ -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 = { s: 'с', m: 'м', h: 'ч', d: 'д', mo: 'мес' };
+ return `${value}${ruUnits[unit]} назад`;
+ }
+ return `${value}${unit} ago`;
+}
+
+export type DateFormatter = ReturnType;
+
+/**
+ * Reactive formatter — re-derives whenever the user changes timezone or
+ * locale. Consume with `$fmt.dateTime(...)` in templates.
+ */
+export const fmt: Readable = 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);
+}
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index fd90e02..f3311f3 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -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."
}
}
diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json
index 1380e0f..ee89dae 100644
--- a/web/src/lib/i18n/ru.json
+++ b/web/src/lib/i18n/ru.json
@@ -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": "Метки времени в логе событий будут выглядеть именно так."
}
}
diff --git a/web/src/lib/stores/health.ts b/web/src/lib/stores/health.ts
new file mode 100644
index 0000000..395671b
--- /dev/null
+++ b/web/src/lib/stores/health.ts
@@ -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(EMPTY);
+
+export const health: Readable = { subscribe: store.subscribe };
+
+let pollTimer: ReturnType | null = null;
+let inFlight = false;
+
+async function refreshOnce(): Promise {
+ 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 {
+ return refreshOnce();
+}
diff --git a/web/src/lib/stores/timezone.ts b/web/src/lib/stores/timezone.ts
new file mode 100644
index 0000000..5578265
--- /dev/null
+++ b/web/src/lib/stores/timezone.ts
@@ -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(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 = 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 '';
+ }
+}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 525bb5e..12db3ee 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -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. */
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 31fd22c..181ec1d 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -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(null);
- let proxyHealth = $state(null);
- let healthChecked = $state(false);
- let healthInterval: ReturnType | 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();
});
@@ -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'}"
>
-
-
-
-
{$t('app.name')}
+
+
+
+
+ {$t('app.name')}
-
-
+
+
+
+
+
+
+ {#if healthChecked}
+
+
+ {#if proxyHealth && proxyProviderName !== 'none'}
+
+ {/if}
+ {:else}
+
+
+ BOOT
+
+ {/if}
+
+
+
+ {#if healthChecked && !dockerConnected && hintsExpanded && dockerHealth?.error}
+
+ {dockerHealth.error}
+
+
+ {/if}
+ {#if healthChecked && !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
+
+ {proxyHealth.error}
+
+
+ {/if}
@@ -254,65 +304,6 @@
- {#if healthChecked}
-
-
- {#if proxyHealth && proxyProviderName !== 'none'}
-
- {/if}
-
- {#if !dockerConnected && hintsExpanded && dockerHealth?.error}
-
- {dockerHealth.error}
-
-
- {/if}
- {#if !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
-
- {proxyHealth.error}
-
- {/if}
- {/if}
@@ -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;
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte
index 87e89d3..be4da1a 100644
--- a/web/src/routes/+page.svelte
+++ b/web/src/routes/+page.svelte
@@ -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
([]);
let instancesByProject = $state>({});
@@ -181,6 +183,9 @@
+
+
+
{#if !loading}
@@ -219,7 +224,7 @@
{/if}
{#if site.last_sync_at}
-
{$t('sites.lastSync')}: {new Date(site.last_sync_at).toLocaleString()}
+
{$t('sites.lastSync')}: {$fmt.dateTime(site.last_sync_at)}
{/if}
{/each}
diff --git a/web/src/routes/projects/+page.svelte b/web/src/routes/projects/+page.svelte
index 2c97b03..95f4f14 100644
--- a/web/src/routes/projects/+page.svelte
+++ b/web/src/routes/projects/+page.svelte
@@ -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 || '-'}
- {new Date(project.created_at).toLocaleDateString()}
+ {$fmt.date(project.created_at)}
|
diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte
index d79fed2..c147945 100644
--- a/web/src/routes/projects/[id]/+page.svelte
+++ b/web/src/routes/projects/[id]/+page.svelte
@@ -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(null);
let stages = $state([]);
@@ -525,7 +526,7 @@
{$t('projects.created')}
- {new Date(project.created_at).toLocaleDateString()}
+ {$fmt.date(project.created_at)}
|
- {formatDate(entry.mod_time)}
+ {$fmt.compact(entry.mod_time)}
|
{/each}
diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte
index 93382c4..bc3fe33 100644
--- a/web/src/routes/settings/+page.svelte
+++ b/web/src/routes/settings/+page.svelte
@@ -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 @@
{:else}
+
+
{$t('settingsGeneral.globalConfig')}
diff --git a/web/src/routes/settings/backup/+page.svelte b/web/src/routes/settings/backup/+page.svelte
index d66825c..4b61fd0 100644
--- a/web/src/routes/settings/backup/+page.svelte
+++ b/web/src/routes/settings/backup/+page.svelte
@@ -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')}
-
{formatDate(backup.created_at)} |
+
{$fmt.dateTime(toUtcIso(backup.created_at))} |
|
{#if site.last_sync_at}
- {new Date(site.last_sync_at).toLocaleString()}
+ {$fmt.dateTime(site.last_sync_at)}
{:else}
-
{/if}
diff --git a/web/src/routes/sites/[id]/+page.svelte b/web/src/routes/sites/[id]/+page.svelte
index aeaeb14..0745c5d 100644
--- a/web/src/routes/sites/[id]/+page.svelte
+++ b/web/src/routes/sites/[id]/+page.svelte
@@ -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 @@
{site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''}
{$t('sites.lastSync')}
- {site.last_sync_at ? new Date(site.last_sync_at).toLocaleString() : '-'}
+ {site.last_sync_at ? $fmt.dateTime(site.last_sync_at) : '-'}
{$t('sites.commitSha')}
{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}
diff --git a/web/src/routes/stacks/+page.svelte b/web/src/routes/stacks/+page.svelte
index 1fc5937..11fafe0 100644
--- a/web/src/routes/stacks/+page.svelte
+++ b/web/src/routes/stacks/+page.svelte
@@ -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([]);
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);
@@ -133,7 +129,7 @@
{$t('stacks.card.updated')}
- {fmtTime(s.updated_at)}
+ {$fmt.dateTime(s.updated_at)}
|