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 @@ + + + +
+
+
+ SYSTEMS // DAEMONS +

{$t('daemons.title')}.

+
+ +
+ +
+ +
+
+
+ + {$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} +
+ +

{$t('daemons.noProxyDesc')}

+ {$t('daemons.configureProxy')} → +
+ {: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)}

{#if deploy.error} diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte index 2e7da67..f8e3c7a 100644 --- a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte +++ b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte @@ -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)} - {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))}
{$t('stacks.detail.revisions.by')} {rev.author || 'operator'}