fix: harden security, fix concurrency bugs, and address review findings
Build / build (push) Successful in 11m42s
Build / build (push) Successful in 11m42s
Security: - rate limit /api/webhook routes per-IP and cap concurrent site syncs - global SSE connection cap (256) with new sse_gate - validate ?tail= and cap JSON log responses at 4 MiB - strip ANSI/CSI/OSC and control bytes from streamed log lines - redact webhook secret from request log middleware - scrub host details from /api/health for non-admin viewers - drop container_id from /api/system/stats/top for non-admins - generate webhook secrets via crypto/rand; require >=32 chars on insert - verify iid path consistency in streamContainerLogs - LimitReader on site webhook body; reject malformed non-empty bodies Concurrency / correctness: - stats collector: Stop() no longer hangs without Start(), semaphore acquired in parent loop so ctx cancellation short-circuits the queue, in-flight tick cancellable via shared base context, zero-ts guard - webhook handler: replace fire-and-forget goroutine with WaitGroup-tracked workers + Drain() wired into graceful shutdown - $derived(() => ...) mis-idiom fixed in ContainerStats / InstanceCard / ProjectCard (returned function instead of value) - SystemResourcesCard: rename `window` and `t` locals to avoid shadowing globalThis.window and the i18n `t` import Quality / performance: - replace O(n^2) insertion sort with sort.Slice in stats top - runMigrations only swallows duplicate-column / already-exists errors - PruneStatsSamplesBefore wrapped in a transaction - collapse N+1 in unusedImageStats / pruneImages to one ListAllInstances pass; surface DB errors instead of silently treating them as inactive - run Docker Info + DiskUsage in parallel via errgroup - container log SSE emits `: ping` heartbeat every 20 s - imageMatches case-insensitive on registry host (RFC behaviour) - log warning on invalid stage tag pattern instead of silent skip - reject malformed non-empty site webhook payloads Frontend / i18n: - shared formatBytes utility replaces three local copies - statsInterval store drives dynamic "no samples / collection disabled" copy across ContainerStats and SystemResourcesCard - top consumers row now shows owner_name (project/stage or site name) - drop seven `as any` casts on the Settings type; add cloudflare_api_token write-only field - move "Service status", "Docker daemon", "Docker unreachable", "Proxy unreachable", "reachable", and "Docker daemon is not reachable." strings into en/ru i18n bundles
This commit is contained in:
+3
-2
@@ -4,6 +4,7 @@ import type {
|
||||
ContainerStatsSample,
|
||||
SystemStats,
|
||||
SystemStatsSample,
|
||||
TopContainerSample,
|
||||
Deploy,
|
||||
DeployLog,
|
||||
DockerHealth,
|
||||
@@ -708,8 +709,8 @@ export function fetchTopContainers(
|
||||
by: 'cpu' | 'memory' = 'cpu',
|
||||
limit = 5,
|
||||
signal?: AbortSignal
|
||||
): Promise<ContainerStatsSample[]> {
|
||||
return get<ContainerStatsSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
|
||||
): Promise<TopContainerSample[]> {
|
||||
return get<TopContainerSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
|
||||
}
|
||||
|
||||
export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise<ContainerStats> {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { statsInterval } from '$lib/stores/statsInterval';
|
||||
import ResourceChart from './ResourceChart.svelte';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
@@ -74,24 +75,16 @@
|
||||
};
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(0)} KB`;
|
||||
const mb = kb / 1024;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
}
|
||||
import { formatBytes } from '$lib/format/bytes';
|
||||
|
||||
const cpuColor = $derived(() => {
|
||||
const cpuColor = $derived.by(() => {
|
||||
if (!stats) return 'bg-gray-300';
|
||||
if (stats.cpu_percent > 80) return 'bg-red-500';
|
||||
if (stats.cpu_percent > 50) return 'bg-amber-500';
|
||||
return 'bg-emerald-500';
|
||||
});
|
||||
|
||||
const memColor = $derived(() => {
|
||||
const memColor = $derived.by(() => {
|
||||
if (!stats) return 'bg-gray-300';
|
||||
if (stats.memory_percent > 80) return 'bg-red-500';
|
||||
if (stats.memory_percent > 50) return 'bg-amber-500';
|
||||
@@ -151,7 +144,7 @@
|
||||
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.cpu')}</span>
|
||||
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {cpuColor()}"
|
||||
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {cpuColor}"
|
||||
style="width: {Math.min(stats.cpu_percent, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
@@ -164,7 +157,7 @@
|
||||
<span class="w-8 text-[10px] font-medium text-[var(--text-tertiary)]">{$t('stats.mem')}</span>
|
||||
<div class="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--surface-card-hover)]">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {memColor()}"
|
||||
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500 {memColor}"
|
||||
style="width: {Math.min(stats.memory_percent, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
@@ -184,7 +177,9 @@
|
||||
{#if expanded}
|
||||
{#if history.length === 0}
|
||||
<p class="mt-1 text-[10px] text-[var(--text-tertiary)]">
|
||||
{$t('resources.noSamples', { interval: '15' })}
|
||||
{$statsInterval > 0
|
||||
? $t('resources.noSamples', { interval: String($statsInterval) })
|
||||
: $t('resources.collectionDisabled')}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-1 rounded-md border border-[var(--border-primary)] bg-[var(--surface-page)] p-2">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
: instance.subdomain ? `https://${instance.subdomain}` : ''
|
||||
);
|
||||
|
||||
const timeSinceCreated = $derived(() => $fmt.relative(instance.created_at));
|
||||
const timeSinceCreated = $derived($fmt.relative(instance.created_at));
|
||||
|
||||
async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') {
|
||||
loading = true;
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
<div class="mt-1.5 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
|
||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{instance.port}</span>
|
||||
<span>{timeSinceCreated()}</span>
|
||||
<span>{timeSinceCreated}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
const failedCount = $derived(instances.filter((i) => i.status === 'failed').length);
|
||||
const totalCount = $derived(instances.length);
|
||||
|
||||
const overallStatus = $derived<string>(() => {
|
||||
const overallStatus = $derived.by<'failed' | 'running' | 'stopped'>(() => {
|
||||
if (failedCount > 0) return 'failed';
|
||||
if (runningCount > 0) return 'running';
|
||||
if (stoppedCount > 0) return 'stopped';
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<p class="mt-2 truncate font-mono text-xs text-[var(--text-tertiary)]">{project.image}</p>
|
||||
</div>
|
||||
<StatusBadge status={overallStatus()} size="sm" />
|
||||
<StatusBadge status={overallStatus} size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- Instance count badges -->
|
||||
|
||||
@@ -36,12 +36,11 @@
|
||||
totalContainers > 0 ? ((docker?.stopped ?? 0) / totalContainers) * 100 : 0
|
||||
);
|
||||
|
||||
import { formatBytes as formatBytesShared } from '$lib/format/bytes';
|
||||
|
||||
function formatBytes(n: number | undefined): string {
|
||||
if (!n || n <= 0) return '—';
|
||||
const gb = n / 1024 ** 3;
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||
const mb = n / 1024 ** 2;
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
return formatBytesShared(n);
|
||||
}
|
||||
|
||||
function formatMs(n: number | undefined): string {
|
||||
@@ -95,7 +94,7 @@
|
||||
</div>
|
||||
{:else if !dockerConnected}
|
||||
<div class="panel-error">
|
||||
<code>{docker?.error ?? 'Docker daemon is not reachable.'}</code>
|
||||
<code>{docker?.error ?? $t('daemons.dockerNotReachable')}</code>
|
||||
<p>{$t('daemons.dockerHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -3,31 +3,23 @@
|
||||
breakdown + top consumers. Drops into the dashboard as its own section.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { SystemStats, SystemStatsSample, ContainerStatsSample } from '$lib/types';
|
||||
import type { SystemStats, SystemStatsSample, TopContainerSample } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import ResourceChart from './ResourceChart.svelte';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { t } from '$lib/i18n';
|
||||
import { formatBytes } from '$lib/format/bytes';
|
||||
import { statsInterval, ensureStatsIntervalLoaded } from '$lib/stores/statsInterval';
|
||||
|
||||
let current = $state<SystemStats | null>(null);
|
||||
let history = $state<SystemStatsSample[]>([]);
|
||||
let top = $state<ContainerStatsSample[]>([]);
|
||||
let top = $state<TopContainerSample[]>([]);
|
||||
let topBy = $state<'cpu' | 'memory'>('cpu');
|
||||
let window = $state<'30m' | '2h' | '6h' | '24h'>('2h');
|
||||
let historyWindow = $state<'30m' | '2h' | '6h' | '24h'>('2h');
|
||||
let dockerDown = $state(false);
|
||||
let otherError = $state('');
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(0)} KB`;
|
||||
const mb = kb / 1024;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
if (gb < 1024) return `${gb.toFixed(2)} GB`;
|
||||
const tb = gb / 1024;
|
||||
return `${tb.toFixed(2)} TB`;
|
||||
}
|
||||
ensureStatsIntervalLoaded();
|
||||
|
||||
async function load(signal?: AbortSignal) {
|
||||
// Each request is handled independently so a 503 on `current` does
|
||||
@@ -35,7 +27,7 @@
|
||||
// which is available even when Docker is down).
|
||||
const [currRes, histRes, topRes] = await Promise.allSettled([
|
||||
api.fetchSystemStats(signal),
|
||||
api.fetchSystemStatsHistory(window, signal),
|
||||
api.fetchSystemStatsHistory(historyWindow, signal),
|
||||
api.fetchTopContainers(topBy, 5, signal)
|
||||
]);
|
||||
|
||||
@@ -75,15 +67,15 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Read window/topBy so this effect re-runs when they change.
|
||||
void window;
|
||||
// Read historyWindow/topBy so this effect re-runs when they change.
|
||||
void historyWindow;
|
||||
void topBy;
|
||||
const controller = new AbortController();
|
||||
load(controller.signal);
|
||||
const t = setInterval(() => load(controller.signal), 15_000);
|
||||
const intervalId = setInterval(() => load(controller.signal), 15_000);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearInterval(t);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -180,7 +172,7 @@
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium text-[var(--text-secondary)]">{$t('resources.workloadUtilization')}</span>
|
||||
<select
|
||||
bind:value={window}
|
||||
bind:value={historyWindow}
|
||||
class="rounded border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-0.5 text-xs text-[var(--text-secondary)] focus:outline-none"
|
||||
>
|
||||
<option value="30m">{$t('resources.windowMinutes', { n: '30' })}</option>
|
||||
@@ -191,7 +183,9 @@
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="py-6 text-center text-xs text-[var(--text-tertiary)]">
|
||||
{$t('resources.noSamples', { interval: '15' })}
|
||||
{$statsInterval > 0
|
||||
? $t('resources.noSamples', { interval: String($statsInterval) })
|
||||
: $t('resources.collectionDisabled')}
|
||||
</p>
|
||||
{:else}
|
||||
<ResourceChart option={chartOption} height="180px" ariaLabel={$t('resources.workloadUtilization')} />
|
||||
@@ -253,8 +247,8 @@
|
||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
|
||||
{s.owner_type === 'site' ? $t('resources.site') : $t('resources.instance')}
|
||||
</span>
|
||||
<span class="ml-2 font-mono text-[10px] text-[var(--text-tertiary)]">
|
||||
{s.container_id.slice(0, 12)}
|
||||
<span class="ml-2 truncate text-[var(--text-primary)]">
|
||||
{s.owner_name || (s.container_id ? s.container_id.slice(0, 12) : '')}
|
||||
</span>
|
||||
</span>
|
||||
<span class="tabular-nums text-[var(--text-primary)]">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Formats a byte count using the closest binary unit (B / KB / MB / GB / TB).
|
||||
* Returns "0 B" for zero or negative inputs so the UI never renders "NaN".
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(0)} KB`;
|
||||
const mb = kb / 1024;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
if (gb < 1024) return `${gb.toFixed(2)} GB`;
|
||||
const tb = gb / 1024;
|
||||
return `${tb.toFixed(2)} TB`;
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
"name": "Tinyforge",
|
||||
"version": "v0.1"
|
||||
},
|
||||
"layout": {
|
||||
"serviceStatus": "Service status"
|
||||
},
|
||||
"health": {
|
||||
"connected": "connected",
|
||||
"disconnected": "disconnected",
|
||||
@@ -56,6 +59,7 @@
|
||||
"windowMinutes": "{n} minutes",
|
||||
"windowHours": "{n} hours",
|
||||
"noSamples": "No samples yet — the collector samples every {interval}s.",
|
||||
"collectionDisabled": "Stats collection is disabled. Enable it in Settings to populate this chart.",
|
||||
"diskImages": "Images",
|
||||
"diskContainers": "Containers",
|
||||
"diskVolumes": "Volumes",
|
||||
@@ -949,7 +953,11 @@
|
||||
"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"
|
||||
"configureProxy": "Configure in Settings",
|
||||
"dockerNotReachable": "Docker daemon is not reachable.",
|
||||
"dockerUnreachable": "Docker unreachable",
|
||||
"proxyUnreachable": "Proxy unreachable",
|
||||
"reachable": "reachable"
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS Records",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"name": "Tinyforge",
|
||||
"version": "v0.1"
|
||||
},
|
||||
"layout": {
|
||||
"serviceStatus": "Состояние служб"
|
||||
},
|
||||
"health": {
|
||||
"connected": "подключён",
|
||||
"disconnected": "отключён",
|
||||
@@ -56,6 +59,7 @@
|
||||
"windowMinutes": "{n} минут",
|
||||
"windowHours": "{n} часов",
|
||||
"noSamples": "Пока нет данных — сбор идёт каждые {interval}с.",
|
||||
"collectionDisabled": "Сбор статистики отключён. Включите его в Настройках, чтобы заполнить график.",
|
||||
"diskImages": "Образы",
|
||||
"diskContainers": "Контейнеры",
|
||||
"diskVolumes": "Тома",
|
||||
@@ -949,7 +953,11 @@
|
||||
"dockerHint": "Проверьте, что Docker-демон запущен и сокет доступен.",
|
||||
"proxyHint": "Проверьте URL прокси, учётные данные и доступность сервиса.",
|
||||
"noProxyDesc": "Провайдер прокси не настроен. Tinyforge поддерживает Nginx Proxy Manager или Traefik.",
|
||||
"configureProxy": "Настроить в параметрах"
|
||||
"configureProxy": "Настроить в параметрах",
|
||||
"dockerNotReachable": "Docker-демон недоступен.",
|
||||
"dockerUnreachable": "Docker недоступен",
|
||||
"proxyUnreachable": "Прокси недоступен",
|
||||
"reachable": "доступен"
|
||||
},
|
||||
"dns": {
|
||||
"title": "DNS-записи",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getSettings } from '$lib/api';
|
||||
|
||||
/**
|
||||
* Reactive view of the configured stats collection interval (seconds).
|
||||
* Set to 0 when collection is disabled. Refreshed on mount and after the
|
||||
* settings page saves. Components can subscribe with `$statsInterval` to
|
||||
* render contextual messages ("samples every Ns", "collection disabled").
|
||||
*/
|
||||
export const statsInterval = writable<number>(15);
|
||||
|
||||
let loaded = false;
|
||||
|
||||
export async function refreshStatsInterval(): Promise<void> {
|
||||
try {
|
||||
const s = await getSettings();
|
||||
const v = s?.stats_interval_seconds;
|
||||
statsInterval.set(typeof v === 'number' ? v : 15);
|
||||
loaded = true;
|
||||
} catch {
|
||||
// Leave the previous value if the request fails.
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureStatsIntervalLoaded(): void {
|
||||
if (!loaded) void refreshStatsInterval();
|
||||
}
|
||||
@@ -120,6 +120,8 @@ export interface Settings {
|
||||
wildcard_dns: boolean;
|
||||
dns_provider: string;
|
||||
has_cloudflare_api_token: boolean;
|
||||
/** Sent on PUT to update the Cloudflare API token; never returned by GET. */
|
||||
cloudflare_api_token?: string;
|
||||
cloudflare_zone_id: string;
|
||||
image_prune_threshold_mb: number;
|
||||
proxy_provider: string;
|
||||
@@ -492,6 +494,15 @@ export interface ContainerStatsSample {
|
||||
block_write_bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A container sample augmented with the human-readable owner name returned
|
||||
* by the /system/stats/top endpoint. Container ID is empty for non-admin
|
||||
* viewers to avoid leaking workload identifiers across access boundaries.
|
||||
*/
|
||||
export interface TopContainerSample extends ContainerStatsSample {
|
||||
owner_name: string;
|
||||
}
|
||||
|
||||
/** Host-level snapshot returned by /api/system/stats. */
|
||||
export interface SystemStats {
|
||||
timestamp: string;
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
|
||||
import { health, startHealthPolling, stopHealthPolling, refreshHealth } from '$lib/stores/health';
|
||||
import { effectiveTimezone, formatOffsetLabel } from '$lib/stores/timezone';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -54,15 +56,17 @@
|
||||
const proxyHealth = $derived($health.proxy);
|
||||
const healthChecked = $derived($health.checked);
|
||||
|
||||
// Live UTC forge clock (refreshes every second). A small thing, but it makes
|
||||
// Live forge clock (refreshes every second). A small thing, but it makes
|
||||
// the sidebar feel alive and reinforces the "control room" aesthetic.
|
||||
let nowUtc = $state('');
|
||||
// Renders in the user's chosen timezone via the shared formatter.
|
||||
let nowTick = $state(new Date());
|
||||
let clockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
function tickClock() {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
nowUtc = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
nowTick = new Date();
|
||||
}
|
||||
const clockDisplay = $derived($fmt.clock(nowTick));
|
||||
const clockOffset = $derived(formatOffsetLabel($effectiveTimezone, nowTick));
|
||||
const clockTitle = $derived(`${$effectiveTimezone.replace(/_/g, ' ')} · ${clockOffset}`);
|
||||
|
||||
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
|
||||
// g+d → dashboard, g+p → projects, g+s → sites, g+k → stacks, g+x → deploy,
|
||||
@@ -194,14 +198,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Daemon health chips (Docker + proxy provider) -->
|
||||
<div class="brand-rail" aria-label="Service status">
|
||||
<div class="brand-rail" aria-label={$t('layout.serviceStatus')}>
|
||||
{#if healthChecked}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:chip-live={dockerConnected}
|
||||
class:chip-down={!dockerConnected}
|
||||
title={dockerConnected ? `Docker daemon · ${dockerHealth?.version ?? 'reachable'}` : dockerHealth?.error ?? 'Docker unreachable'}
|
||||
title={dockerConnected
|
||||
? `${$t('daemons.docker')} · ${dockerHealth?.version ?? $t('daemons.reachable')}`
|
||||
: dockerHealth?.error ?? $t('daemons.dockerUnreachable')}
|
||||
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
|
||||
>
|
||||
<span class="chip-dot" aria-hidden="true"></span>
|
||||
@@ -218,7 +224,9 @@
|
||||
class="chip"
|
||||
class:chip-live={proxyConnected}
|
||||
class:chip-down={!proxyConnected}
|
||||
title={proxyConnected ? `${proxyProviderName.toUpperCase()} · ${proxyHealth.latency_ms ?? '?'} ms` : proxyHealth.error ?? 'Proxy unreachable'}
|
||||
title={proxyConnected
|
||||
? `${proxyProviderName.toUpperCase()} · ${proxyHealth.latency_ms ?? '?'} ms`
|
||||
: proxyHealth.error ?? $t('daemons.proxyUnreachable')}
|
||||
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
|
||||
>
|
||||
<span class="chip-dot" aria-hidden="true"></span>
|
||||
@@ -323,10 +331,10 @@
|
||||
</div>
|
||||
<div class="forge-footline">
|
||||
<span class="forge-footline-version">{$t('app.name')} {$t('app.version')}</span>
|
||||
<span class="forge-footline-clock" title="UTC">
|
||||
<span class="forge-footline-clock" title={clockTitle}>
|
||||
<span class="clock-dot"></span>
|
||||
<span class="clock-time">{nowUtc || '--:--:--'}</span>
|
||||
<span class="clock-suffix">UTC</span>
|
||||
<span class="clock-time">{clockDisplay}</span>
|
||||
<span class="clock-suffix">{clockOffset}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
||||
@@ -599,7 +607,7 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Sidebar footline (version + live UTC clock) ───────────── */
|
||||
/* ── Sidebar footline (version + live timezone-aware clock) ───────────── */
|
||||
.forge-footline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
polling_interval: secondsToDuration(pollingInterval),
|
||||
base_volume_path: baseVolumePath.trim(),
|
||||
proxy_provider: proxyProvider
|
||||
} as any);
|
||||
});
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
backup_enabled: backupEnabled,
|
||||
backup_interval_hours: Math.max(1, parseInt(backupIntervalHours, 10) || 24),
|
||||
backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10)
|
||||
} as any);
|
||||
});
|
||||
toasts.success($t('settingsBackup.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.saveFailed'));
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, testDnsConnection, listDnsZones } from '$lib/api';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import type { EntityPickerItem, Settings } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconX } from '$lib/components/icons';
|
||||
@@ -50,13 +51,13 @@
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: Partial<Settings> = {
|
||||
wildcard_dns: wildcardDns,
|
||||
dns_provider: wildcardDns ? '' : dnsProvider,
|
||||
cloudflare_zone_id: cloudflareZoneId
|
||||
};
|
||||
if (cloudflareApiToken) payload.cloudflare_api_token = cloudflareApiToken;
|
||||
await updateSettings(payload as any);
|
||||
await updateSettings(payload);
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
cloudflareApiToken = '';
|
||||
hasCloudflareApiToken = hasCloudflareApiToken || Boolean(payload.cloudflare_api_token);
|
||||
@@ -144,14 +145,13 @@
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsDns.title')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsDns.description')}</p>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={wildcardDns}
|
||||
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div class="flex items-center gap-3">
|
||||
<ToggleSwitch bind:checked={wildcardDns} label={$t('settingsGeneral.wildcardDns')} />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.wildcardDns')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.wildcardDnsHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !wildcardDns}
|
||||
<div class="mt-4 space-y-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
if (urlErr) return;
|
||||
saving = true;
|
||||
try {
|
||||
await updateSettings({ notification_url: notificationUrl.trim() } as any);
|
||||
await updateSettings({ notification_url: notificationUrl.trim() });
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0),
|
||||
stats_interval_seconds: interval,
|
||||
stats_retention_hours: retention
|
||||
} as any);
|
||||
});
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
}
|
||||
|
||||
async function saveAccessList(id: number) {
|
||||
try { await updateSettings({ npm_access_list_id: id } as any); toasts.success($t('settingsCredentials.saved')); }
|
||||
try { await updateSettings({ npm_access_list_id: id }); toasts.success($t('settingsCredentials.saved')); }
|
||||
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); }
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
|
||||
async function handleNpmRemoteChange() {
|
||||
try {
|
||||
await updateSettings({ npm_remote: npmRemote } as any);
|
||||
await updateSettings({ npm_remote: npmRemote });
|
||||
toasts.success($t('settingsCredentials.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed'));
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
traefik_cert_resolver: traefikCertResolver.trim(),
|
||||
traefik_network: traefikNetwork.trim(),
|
||||
traefik_api_url: traefikApiUrl.trim()
|
||||
} as any);
|
||||
});
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
|
||||
Reference in New Issue
Block a user