General was a 547-line catch-all mixing seven concerns, destructive
actions (image prune) inches away from form fields, and Cloudflare DNS
buried under four unrelated cards. A single "Save" committed everything
at once — one invalid field blocked valid edits elsewhere.
Splits:
- /settings Overview: timezone, core infra, proxy choice
- /settings/integrations outgoing notification URL + incoming webhook
- /settings/dns wildcard + Cloudflare provider
- /settings/maintenance stale threshold, prune threshold, prune action
(in a dedicated "Danger zone" card)
- /settings/credentials removed (was an 18-line redirect stub)
Sidebar is grouped (Overview / Routing / System / Security) with
section headers; NPM & Traefik items remain conditional on the
proxy-provider choice. Each page loads settings and PUTs only its own
subset, so mistakes on one page can't block edits on another.
No backend changes — the API already accepts Partial<Settings>.
This commit is contained in:
@@ -269,11 +269,18 @@
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"general": "General",
|
||||
"integrations": "Integrations",
|
||||
"dns": "DNS",
|
||||
"maintenance": "Maintenance",
|
||||
"registries": "Registries",
|
||||
"credentials": "Credentials",
|
||||
"authentication": "Authentication",
|
||||
"backup": "Backups",
|
||||
"appearance": "Appearance",
|
||||
"groupMain": "Overview",
|
||||
"groupProxy": "Routing",
|
||||
"groupSystem": "System",
|
||||
"groupSecurity": "Security",
|
||||
"staleThreshold": "Stale threshold (days)",
|
||||
"staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale.",
|
||||
"dockerCleanup": "Docker Image Cleanup",
|
||||
@@ -310,6 +317,10 @@
|
||||
"settingsGeneral": {
|
||||
"title": "General Settings",
|
||||
"globalConfig": "Global Configuration",
|
||||
"globalConfigDesc": "Core infrastructure: the base domain, network, and polling cadence Tinyforge uses to orchestrate containers.",
|
||||
"configureNpm": "Nginx Proxy Manager is selected.",
|
||||
"configureTraefik": "Traefik is selected.",
|
||||
"configureLink": "Configure provider",
|
||||
"domain": "Domain",
|
||||
"domainHelp": "Base domain for subdomain routing (e.g., example.com → stage-dev-app.example.com)",
|
||||
"serverIp": "Server IP (Docker Host)",
|
||||
@@ -1079,5 +1090,21 @@
|
||||
"previewFull": "Full timestamp",
|
||||
"previewDate": "Date only",
|
||||
"previewHint": "Timestamps like the event log will look exactly like this."
|
||||
},
|
||||
"settingsDns": {
|
||||
"title": "DNS Configuration",
|
||||
"description": "Choose whether routes rely on a wildcard record or per-subdomain records managed by a DNS provider."
|
||||
},
|
||||
"settingsIntegrations": {
|
||||
"title": "Integrations",
|
||||
"outgoing": "Outgoing notifications",
|
||||
"outgoingDesc": "Where Tinyforge posts deploy and alert events. Paste a webhook URL (Apprise, Discord, Slack, your own handler).",
|
||||
"incoming": "Incoming webhook"
|
||||
},
|
||||
"settingsMaintenance": {
|
||||
"title": "Maintenance",
|
||||
"thresholds": "Thresholds",
|
||||
"thresholdsDesc": "Tune when Tinyforge flags stale containers and warns about unused image disk usage.",
|
||||
"dangerZone": "Danger zone"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,11 +269,18 @@
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
"general": "Общие",
|
||||
"integrations": "Интеграции",
|
||||
"dns": "DNS",
|
||||
"maintenance": "Обслуживание",
|
||||
"registries": "Реестры",
|
||||
"credentials": "Учётные данные",
|
||||
"authentication": "Аутентификация",
|
||||
"backup": "Резервные копии",
|
||||
"appearance": "Внешний вид",
|
||||
"groupMain": "Обзор",
|
||||
"groupProxy": "Маршрутизация",
|
||||
"groupSystem": "Система",
|
||||
"groupSecurity": "Безопасность",
|
||||
"staleThreshold": "Порог устаревания (дни)",
|
||||
"staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.",
|
||||
"dockerCleanup": "Очистка Docker-образов",
|
||||
@@ -310,6 +317,10 @@
|
||||
"settingsGeneral": {
|
||||
"title": "Общие настройки",
|
||||
"globalConfig": "Глобальная конфигурация",
|
||||
"globalConfigDesc": "Базовая инфраструктура: домен, сеть и интервал опроса, используемые Tinyforge для оркестрации контейнеров.",
|
||||
"configureNpm": "Выбран Nginx Proxy Manager.",
|
||||
"configureTraefik": "Выбран Traefik.",
|
||||
"configureLink": "Настроить провайдера",
|
||||
"domain": "Домен",
|
||||
"domainHelp": "Базовый домен для маршрутизации (напр., example.com → stage-dev-app.example.com)",
|
||||
"serverIp": "IP сервера (Docker Host)",
|
||||
@@ -1079,5 +1090,21 @@
|
||||
"previewFull": "Полная метка времени",
|
||||
"previewDate": "Только дата",
|
||||
"previewHint": "Метки времени в логе событий будут выглядеть именно так."
|
||||
},
|
||||
"settingsDns": {
|
||||
"title": "Настройка DNS",
|
||||
"description": "Выберите, использовать ли wildcard-запись или отдельные поддомены, управляемые DNS-провайдером."
|
||||
},
|
||||
"settingsIntegrations": {
|
||||
"title": "Интеграции",
|
||||
"outgoing": "Исходящие уведомления",
|
||||
"outgoingDesc": "Куда Tinyforge отправляет события деплоев и алертов. Укажите webhook-URL (Apprise, Discord, Slack, свой обработчик).",
|
||||
"incoming": "Входящий вебхук"
|
||||
},
|
||||
"settingsMaintenance": {
|
||||
"title": "Обслуживание",
|
||||
"thresholds": "Пороги",
|
||||
"thresholdsDesc": "Настройте, когда Tinyforge помечает контейнеры как устаревшие и предупреждает о неиспользуемых образах.",
|
||||
"dangerZone": "Опасная зона"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,35 +3,69 @@
|
||||
import { page } from '$app/stores';
|
||||
import { getSettings } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconSettings, IconDatabase, IconShield, IconHardDrive, IconWifi } from '$lib/components/icons';
|
||||
import {
|
||||
IconSettings,
|
||||
IconDatabase,
|
||||
IconShield,
|
||||
IconHardDrive,
|
||||
IconWifi,
|
||||
IconGlobe,
|
||||
IconRefresh,
|
||||
IconServer
|
||||
} from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
interface Props { children: Snippet; }
|
||||
let { children }: Props = $props();
|
||||
|
||||
let proxyProvider = $state('npm');
|
||||
|
||||
// Load the proxy provider setting to show/hide tabs.
|
||||
$effect(() => {
|
||||
getSettings().then((s) => {
|
||||
proxyProvider = s.proxy_provider ?? 'npm';
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
const baseItems = [
|
||||
{ href: '/settings', labelKey: 'settings.general', icon: 'general', always: true },
|
||||
{ href: '/settings/registries', labelKey: 'settings.registries', icon: 'registries', always: true },
|
||||
{ href: '/settings/npm', labelKey: 'settings.npm', icon: 'npm', provider: 'npm' },
|
||||
{ href: '/settings/traefik', labelKey: 'settings.traefik', icon: 'traefik', provider: 'traefik' },
|
||||
{ href: '/settings/auth', labelKey: 'settings.authentication', icon: 'auth', always: true },
|
||||
{ href: '/settings/backup', labelKey: 'settings.backup', icon: 'backup', always: true }
|
||||
type NavGroup = 'main' | 'proxy' | 'system' | 'security';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
group: NavGroup;
|
||||
provider?: 'npm' | 'traefik';
|
||||
}
|
||||
|
||||
// Sidebar layout: grouped, with clear separators. Provider-specific items
|
||||
// (NPM / Traefik) only appear under "Proxy" when that provider is active.
|
||||
const baseItems: NavItem[] = [
|
||||
{ href: '/settings', labelKey: 'settings.general', icon: 'general', group: 'main' },
|
||||
{ href: '/settings/integrations', labelKey: 'settings.integrations', icon: 'integrations', group: 'main' },
|
||||
|
||||
{ href: '/settings/registries', labelKey: 'settings.registries', icon: 'registries', group: 'proxy' },
|
||||
{ href: '/settings/npm', labelKey: 'settings.npm', icon: 'npm', group: 'proxy', provider: 'npm' },
|
||||
{ href: '/settings/traefik', labelKey: 'settings.traefik', icon: 'traefik', group: 'proxy', provider: 'traefik' },
|
||||
{ href: '/settings/dns', labelKey: 'settings.dns', icon: 'dns', group: 'proxy' },
|
||||
|
||||
{ href: '/settings/maintenance', labelKey: 'settings.maintenance', icon: 'maintenance', group: 'system' },
|
||||
{ href: '/settings/backup', labelKey: 'settings.backup', icon: 'backup', group: 'system' },
|
||||
|
||||
{ href: '/settings/auth', labelKey: 'settings.authentication', icon: 'auth', group: 'security' }
|
||||
];
|
||||
|
||||
const navItems = $derived(
|
||||
baseItems.filter((item) => item.always || item.provider === proxyProvider)
|
||||
);
|
||||
const navItems = $derived(baseItems.filter((i) => !i.provider || i.provider === proxyProvider));
|
||||
|
||||
// Order of group rendering + headings.
|
||||
const groupOrder: { key: NavGroup; labelKey: string }[] = [
|
||||
{ key: 'main', labelKey: 'settings.groupMain' },
|
||||
{ key: 'proxy', labelKey: 'settings.groupProxy' },
|
||||
{ key: 'system', labelKey: 'settings.groupSystem' },
|
||||
{ key: 'security', labelKey: 'settings.groupSecurity' }
|
||||
];
|
||||
|
||||
const grouped = $derived(groupOrder
|
||||
.map((g) => ({ ...g, items: navItems.filter((i) => i.group === g.key) }))
|
||||
.filter((g) => g.items.length > 0));
|
||||
|
||||
let currentPath = $derived($page.url.pathname);
|
||||
|
||||
@@ -41,7 +75,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<ForgeHero
|
||||
eyebrowSuffix="SETTINGS"
|
||||
title={$t('settings.title')}
|
||||
@@ -49,37 +83,53 @@
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-6 sm:flex-row">
|
||||
<!-- Sub-navigation -->
|
||||
<nav class="w-full flex-shrink-0 sm:w-48">
|
||||
<ul class="flex gap-1 overflow-x-auto sm:flex-col sm:space-y-0.5">
|
||||
{#each navItems as item}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-all duration-150
|
||||
{isActive(item.href)
|
||||
? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
|
||||
>
|
||||
{#if item.icon === 'general'}
|
||||
<IconSettings size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'registries'}
|
||||
<IconDatabase size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'npm' || item.icon === 'traefik'}
|
||||
<IconWifi size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'auth'}
|
||||
<IconShield size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'backup'}
|
||||
<IconHardDrive size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{/if}
|
||||
{$t(item.labelKey)}
|
||||
</a>
|
||||
<!-- Sub-navigation (grouped) -->
|
||||
<nav class="w-full flex-shrink-0 sm:w-56">
|
||||
<ul class="flex gap-1 overflow-x-auto sm:flex-col sm:gap-0">
|
||||
{#each grouped as group, gIdx}
|
||||
{#if gIdx > 0}
|
||||
<li class="hidden sm:block" aria-hidden="true">
|
||||
<div class="my-2 border-t border-[var(--border-secondary)]"></div>
|
||||
</li>
|
||||
{/if}
|
||||
<li class="hidden sm:block px-3 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--text-tertiary)]">
|
||||
{$t(group.labelKey)}
|
||||
</li>
|
||||
{#each group.items as item}
|
||||
{@const active = isActive(item.href)}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-all duration-150
|
||||
{active
|
||||
? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
|
||||
>
|
||||
{#if item.icon === 'general'}
|
||||
<IconSettings size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{:else if item.icon === 'integrations'}
|
||||
<IconRefresh size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{:else if item.icon === 'registries'}
|
||||
<IconDatabase size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{:else if item.icon === 'npm' || item.icon === 'traefik'}
|
||||
<IconWifi size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{:else if item.icon === 'dns'}
|
||||
<IconGlobe size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{:else if item.icon === 'maintenance'}
|
||||
<IconServer size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{:else if item.icon === 'backup'}
|
||||
<IconHardDrive size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{:else if item.icon === 'auth'}
|
||||
<IconShield size={16} class={active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'} />
|
||||
{/if}
|
||||
{$t(item.labelKey)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Settings content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<!--
|
||||
Settings › Overview (General)
|
||||
|
||||
Display preferences (timezone), core infrastructure (domain/IPs/network/
|
||||
subdomain/polling/volume path), and the proxy-provider choice. Everything
|
||||
else — DNS, integrations, maintenance — lives on its own page now.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, testDnsConnection, listDnsZones, pruneImages } from '$lib/api';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { getSettings, updateSettings } from '$lib/api';
|
||||
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 Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconCopy, IconRefresh, IconX, IconInfo } from '$lib/components/icons';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { IconLoader, IconInfo } from '$lib/components/icons';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let webhookUrl = $state('');
|
||||
let regenerating = $state(false);
|
||||
|
||||
let domain = $state('');
|
||||
let serverIp = $state('');
|
||||
@@ -22,41 +24,10 @@
|
||||
let subdomainPattern = $state('');
|
||||
let pollingInterval = $state('');
|
||||
let baseVolumePath = $state('');
|
||||
let notificationUrl = $state('');
|
||||
let staleThresholdDays = $state('7');
|
||||
let imagePruneThresholdMb = $state('1024');
|
||||
|
||||
|
||||
// Proxy provider state.
|
||||
let proxyProvider = $state('npm');
|
||||
|
||||
// DNS settings state.
|
||||
let wildcardDns = $state(true);
|
||||
let dnsProvider = $state('');
|
||||
let cloudflareApiToken = $state('');
|
||||
let hasCloudflareApiToken = $state(false);
|
||||
let cloudflareZoneId = $state('');
|
||||
let zonePickerOpen = $state(false);
|
||||
let zonePickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingZones = $state(false);
|
||||
let zoneName = $state('');
|
||||
let testingDns = $state(false);
|
||||
|
||||
let pruning = $state(false);
|
||||
let showPruneConfirm = $state(false);
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
async function handlePruneImages() {
|
||||
pruning = true;
|
||||
try {
|
||||
const result = await pruneImages();
|
||||
toasts.success($t('settings.pruneResult', { count: String(result.images_removed), mb: String(result.space_reclaimed_mb) }));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settings.pruneFailed'));
|
||||
} finally { pruning = false; }
|
||||
}
|
||||
|
||||
// Convert Go duration string (e.g., "5m", "300s", "1h") to seconds string.
|
||||
// Go duration ("5m", "300s", "1h") ↔ seconds string, for the number input.
|
||||
function parseDurationToSeconds(dur: string): string {
|
||||
if (!dur) return '60';
|
||||
const m = dur.match(/^(\d+)(s|m|h)?$/);
|
||||
@@ -69,7 +40,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Convert seconds to a Go duration string for the API.
|
||||
function secondsToDuration(sec: string | number): string {
|
||||
const n = parseInt(String(sec), 10);
|
||||
if (isNaN(n) || n <= 0) return '60s';
|
||||
@@ -98,44 +68,27 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateUrl(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
const domainErr = validateDomain(domain);
|
||||
if (domainErr) newErrors.domain = domainErr;
|
||||
const ipErr = validateIp(serverIp);
|
||||
if (ipErr) newErrors.serverIp = ipErr;
|
||||
const intervalErr = validatePollingInterval(pollingInterval);
|
||||
if (intervalErr) newErrors.pollingInterval = intervalErr;
|
||||
const urlErr = validateUrl(notificationUrl);
|
||||
if (urlErr) newErrors.notificationUrl = urlErr;
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
const next: Record<string, string> = {};
|
||||
const dErr = validateDomain(domain); if (dErr) next.domain = dErr;
|
||||
const ipErr = validateIp(serverIp); if (ipErr) next.serverIp = ipErr;
|
||||
const piErr = validatePollingInterval(pollingInterval); if (piErr) next.pollingInterval = piErr;
|
||||
errors = next;
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
domain = settings.domain ?? '';
|
||||
serverIp = settings.server_ip ?? '';
|
||||
publicIp = settings.public_ip ?? '';
|
||||
network = settings.network ?? '';
|
||||
subdomainPattern = settings.subdomain_pattern ?? '';
|
||||
pollingInterval = parseDurationToSeconds(settings.polling_interval ?? '60');
|
||||
baseVolumePath = settings.base_volume_path ?? '';
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
||||
imagePruneThresholdMb = String(settings.image_prune_threshold_mb ?? 1024);
|
||||
proxyProvider = settings.proxy_provider ?? 'npm';
|
||||
wildcardDns = settings.wildcard_dns ?? true;
|
||||
dnsProvider = settings.dns_provider ?? '';
|
||||
hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false;
|
||||
cloudflareZoneId = settings.cloudflare_zone_id ?? '';
|
||||
const s = await getSettings();
|
||||
domain = s.domain ?? '';
|
||||
serverIp = s.server_ip ?? '';
|
||||
publicIp = s.public_ip ?? '';
|
||||
network = s.network ?? '';
|
||||
subdomainPattern = s.subdomain_pattern ?? '';
|
||||
pollingInterval = parseDurationToSeconds(s.polling_interval ?? '60');
|
||||
baseVolumePath = s.base_volume_path ?? '';
|
||||
proxyProvider = s.proxy_provider ?? 'npm';
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
@@ -143,32 +96,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhookUrlValue() {
|
||||
try {
|
||||
const result = await getWebhookUrl();
|
||||
webhookUrl = result.webhook_url;
|
||||
} catch { /* may not be configured */ }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validateAll()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
domain: domain.trim(), server_ip: serverIp.trim(), public_ip: publicIp.trim(), network: network.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: secondsToDuration(pollingInterval),
|
||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||
proxy_provider: proxyProvider,
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||||
image_prune_threshold_mb: Math.max(0, parseInt(String(imagePruneThresholdMb), 10) || 0),
|
||||
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({
|
||||
domain: domain.trim(),
|
||||
server_ip: serverIp.trim(),
|
||||
public_ip: publicIp.trim(),
|
||||
network: network.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(),
|
||||
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'));
|
||||
@@ -177,87 +118,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateWebhook() {
|
||||
regenerating = true;
|
||||
try {
|
||||
const result = await regenerateWebhookUrl();
|
||||
webhookUrl = result.webhook_url;
|
||||
toasts.success($t('settingsGeneral.regenerated'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.regenerateFailed'));
|
||||
} finally {
|
||||
regenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function openZonePicker() {
|
||||
loadingZones = true;
|
||||
zonePickerOpen = true;
|
||||
try {
|
||||
const token = cloudflareApiToken || undefined;
|
||||
const zones = await listDnsZones(token);
|
||||
zonePickerItems = zones.map((zone): EntityPickerItem => ({
|
||||
value: zone.id,
|
||||
label: zone.name,
|
||||
description: zone.id
|
||||
}));
|
||||
if (zonePickerItems.length === 0) {
|
||||
toasts.error($t('settingsGeneral.noZonesFound'));
|
||||
zonePickerOpen = false;
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noZonesFound'));
|
||||
zonePickerOpen = false;
|
||||
} finally {
|
||||
loadingZones = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoneSelect(value: string) {
|
||||
cloudflareZoneId = value;
|
||||
const item = zonePickerItems.find((i) => i.value === value);
|
||||
zoneName = item?.label ?? '';
|
||||
zonePickerOpen = false;
|
||||
}
|
||||
|
||||
async function handleTestDns() {
|
||||
testingDns = true;
|
||||
try {
|
||||
const token = cloudflareApiToken || '';
|
||||
const result = await testDnsConnection('cloudflare', token, cloudflareZoneId);
|
||||
if (result.success) {
|
||||
toasts.success($t('settingsGeneral.connectionSuccess'));
|
||||
} else {
|
||||
toasts.error(`${$t('settingsGeneral.connectionFailed')}: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.connectionFailed'));
|
||||
} finally {
|
||||
testingDns = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveZoneName() {
|
||||
if (!cloudflareZoneId) return;
|
||||
try {
|
||||
const zones = await listDnsZones();
|
||||
const match = zones.find((z) => z.id === cloudflareZoneId);
|
||||
zoneName = match?.name ?? cloudflareZoneId;
|
||||
} catch {
|
||||
zoneName = cloudflareZoneId;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadSettings();
|
||||
if (!wildcardDns && cloudflareZoneId) {
|
||||
resolveZoneName();
|
||||
}
|
||||
loadWebhookUrlValue();
|
||||
}
|
||||
|
||||
$effect(() => { init(); });
|
||||
$effect(() => { loadSettings(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -265,6 +126,9 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Timezone selector (display preference) -->
|
||||
<TimezoneSelector />
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="2rem" width="12rem" />
|
||||
@@ -275,15 +139,17 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<TimezoneSelector />
|
||||
|
||||
<!-- Core infrastructure -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.globalConfig')}</h2>
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.globalConfig')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsGeneral.globalConfigDesc')}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField label={$t('settingsGeneral.domain')} name="domain" bind:value={domain} placeholder="example.com" error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} />
|
||||
<FormField label={$t('settingsGeneral.serverIp')} name="serverIp" bind:value={serverIp} placeholder="192.168.1.100" error={errors.serverIp ?? ''} helpText={$t('settingsGeneral.serverIpHelp')} />
|
||||
<FormField label={$t('settingsGeneral.publicIp')} name="publicIp" bind:value={publicIp} placeholder="93.84.96.191" helpText={$t('settingsGeneral.publicIpHelp')} />
|
||||
<FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} />
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<label for="subdomainPattern" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.subdomainPattern')}</label>
|
||||
@@ -312,236 +178,59 @@
|
||||
/>
|
||||
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.subdomainPatternHelp')}</p>
|
||||
</div>
|
||||
|
||||
<FormField label={$t('settingsGeneral.pollingInterval')} name="pollingInterval" type="number" bind:value={pollingInterval} placeholder="60" error={errors.pollingInterval ?? ''} helpText={$t('settingsGeneral.pollingIntervalHelp')} />
|
||||
<FormField label={$t('settingsGeneral.baseVolumePath')} name="baseVolumePath" bind:value={baseVolumePath} placeholder="/data" helpText={$t('settingsGeneral.baseVolumePathHelp')} />
|
||||
<FormField label={$t('settingsGeneral.notificationUrl')} name="notificationUrl" bind:value={notificationUrl} placeholder="https://notify.example.com/webhook" error={errors.notificationUrl ?? ''} helpText={$t('settingsGeneral.notificationUrlHelp')} />
|
||||
</div>
|
||||
<!-- Proxy Provider -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
<h3 class="mb-1 text-sm font-semibold text-[var(--text-primary)]">{$t('settings.proxyProvider')}</h3>
|
||||
<p class="mb-3 text-xs text-[var(--text-tertiary)]">{$t('settings.proxyProviderHelp')}</p>
|
||||
<div class="flex gap-3">
|
||||
<label class="flex flex-1 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {proxyProvider === 'none' ? 'border-[var(--color-brand-500)] bg-[var(--surface-card-hover)]' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}">
|
||||
<input type="radio" bind:group={proxyProvider} value="none" class="mt-0.5 h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settings.proxyNone')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyNoneDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-1 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {proxyProvider === 'npm' ? 'border-[var(--color-brand-500)] bg-[var(--surface-card-hover)]' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}">
|
||||
<input type="radio" bind:group={proxyProvider} value="npm" class="mt-0.5 h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settings.proxyNpm')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyNpmDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-1 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {proxyProvider === 'traefik' ? 'border-[var(--color-brand-500)] bg-[var(--surface-card-hover)]' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}">
|
||||
<input type="radio" bind:group={proxyProvider} value="traefik" class="mt-0.5 h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settings.proxyTraefik')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyTraefikDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{#if proxyProvider === 'none'}
|
||||
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-3">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-300">{$t('settings.proxyNoneWarning')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stale Detection -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-[var(--text-primary)]">{$t('stale.title')}</h3>
|
||||
<div class="max-w-xs">
|
||||
<FormField
|
||||
label={$t('settings.staleThreshold')}
|
||||
name="staleThresholdDays"
|
||||
type="number"
|
||||
bind:value={staleThresholdDays}
|
||||
placeholder="7"
|
||||
helpText={$t('settings.staleThresholdHelp')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docker Image Cleanup -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
<h3 class="mb-1 text-sm font-semibold text-[var(--text-primary)]">{$t('settings.dockerCleanup')}</h3>
|
||||
<p class="mb-3 text-xs text-[var(--text-tertiary)]">{$t('settings.dockerCleanupHelp')}</p>
|
||||
<div class="mb-3 max-w-xs">
|
||||
<FormField
|
||||
label={$t('settings.pruneThreshold')}
|
||||
name="imagePruneThresholdMb"
|
||||
type="number"
|
||||
bind:value={imagePruneThresholdMb}
|
||||
placeholder="1024"
|
||||
helpText={$t('settings.pruneThresholdHelp')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPruneConfirm = true; }}
|
||||
disabled={pruning}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger)] hover:text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if pruning}
|
||||
<IconLoader size={16} />
|
||||
{$t('settings.pruning')}
|
||||
{:else}
|
||||
{$t('settings.pruneImages')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- DNS Configuration -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.dnsConfig')}</h3>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{#if !wildcardDns}
|
||||
<div class="mt-4 space-y-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
|
||||
<!-- DNS Provider -->
|
||||
<div>
|
||||
<label for="dnsProvider" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.dnsProvider')}</label>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.dnsProviderHelp')}</p>
|
||||
<select id="dnsProvider" bind:value={dnsProvider}
|
||||
class="mt-1.5 w-full max-w-xs rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if dnsProvider === 'cloudflare'}
|
||||
<!-- Cloudflare API Token -->
|
||||
<div>
|
||||
<FormField
|
||||
label={$t('settingsGeneral.cloudflareApiToken')}
|
||||
name="cloudflareApiToken"
|
||||
type="password"
|
||||
bind:value={cloudflareApiToken}
|
||||
placeholder={hasCloudflareApiToken ? '••••••••' : $t('settingsGeneral.cloudflareApiTokenPlaceholder')}
|
||||
helpText={hasCloudflareApiToken ? $t('settingsGeneral.cloudflareApiTokenConfigured') : $t('settingsGeneral.cloudflareApiTokenHelp')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zone Picker -->
|
||||
<div>
|
||||
<label for="cloudflareZoneBtn" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.cloudflareZone')}</label>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.cloudflareZoneHelp')}</p>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
id="cloudflareZoneBtn"
|
||||
type="button"
|
||||
onclick={openZonePicker}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
>
|
||||
{#if loadingZones}
|
||||
{$t('settingsGeneral.loadingZones')}
|
||||
{:else if cloudflareZoneId && zoneName}
|
||||
{zoneName}
|
||||
{:else}
|
||||
{$t('settingsGeneral.noZone')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if cloudflareZoneId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { cloudflareZoneId = ''; zoneName = ''; }}
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Connection -->
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleTestDns}
|
||||
disabled={testingDns || (!cloudflareApiToken && !hasCloudflareApiToken)}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-[var(--color-brand-600)] hover:bg-[var(--color-brand-50)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{#if testingDns}<IconLoader size={16} />{/if}
|
||||
{testingDns ? $t('settingsGeneral.testingConnection') : $t('settingsGeneral.testConnection')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook URL -->
|
||||
<!-- Proxy provider -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.webhookUrl')}</h2>
|
||||
<p class="mb-3 text-sm text-[var(--text-secondary)]">{$t('settingsGeneral.webhookDesc')}</p>
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settings.proxyProvider')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settings.proxyProviderHelp')}</p>
|
||||
|
||||
{#if webhookUrl}
|
||||
<div class="flex items-center gap-3">
|
||||
<code class="flex-1 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)] break-all">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => { navigator.clipboard.writeText(webhookUrl); toasts.info($t('settingsGeneral.copied')); }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
{$t('settingsGeneral.copy')}
|
||||
</button>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<label class="flex flex-1 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {proxyProvider === 'none' ? 'border-[var(--color-brand-500)] bg-[var(--surface-card-hover)]' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}">
|
||||
<input type="radio" bind:group={proxyProvider} value="none" class="mt-0.5 h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settings.proxyNone')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyNoneDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-1 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {proxyProvider === 'npm' ? 'border-[var(--color-brand-500)] bg-[var(--surface-card-hover)]' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}">
|
||||
<input type="radio" bind:group={proxyProvider} value="npm" class="mt-0.5 h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settings.proxyNpm')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyNpmDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex flex-1 cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors {proxyProvider === 'traefik' ? 'border-[var(--color-brand-500)] bg-[var(--surface-card-hover)]' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}">
|
||||
<input type="radio" bind:group={proxyProvider} value="traefik" class="mt-0.5 h-4 w-4 text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settings.proxyTraefik')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settings.proxyTraefikDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if proxyProvider === 'none'}
|
||||
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-3">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-300">{$t('settings.proxyNoneWarning')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-tertiary)] italic">{$t('settingsGeneral.noWebhookUrl')}</p>
|
||||
<p class="mt-3 text-xs text-[var(--text-tertiary)]">
|
||||
{proxyProvider === 'npm' ? $t('settingsGeneral.configureNpm') : $t('settingsGeneral.configureTraefik')}
|
||||
<a href="/settings/{proxyProvider}" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] underline">→ {$t('settingsGeneral.configureLink')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
onclick={handleRegenerateWebhook}
|
||||
disabled={regenerating}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if regenerating}<IconLoader size={16} />{/if}
|
||||
<IconRefresh size={16} />
|
||||
{regenerating ? $t('settingsGeneral.regenerating') : $t('settingsGeneral.regenerateUrl')}
|
||||
</button>
|
||||
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.regenerateWarning')}</p>
|
||||
</div>
|
||||
<!-- Single save for everything on this page -->
|
||||
<div class="sticky bottom-4 z-10 flex justify-end">
|
||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-5 py-2.5 text-sm font-medium text-white shadow-lg transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={zonePickerOpen}
|
||||
items={zonePickerItems}
|
||||
current={cloudflareZoneId}
|
||||
title={$t('settingsGeneral.selectZone')}
|
||||
onselect={handleZoneSelect}
|
||||
onclose={() => { zonePickerOpen = false; }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showPruneConfirm}
|
||||
title={$t('settings.pruneImages')}
|
||||
message={$t('settings.pruneConfirmMessage')}
|
||||
confirmLabel={$t('settings.pruneImages')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={() => { showPruneConfirm = false; handlePruneImages(); }}
|
||||
oncancel={() => { showPruneConfirm = false; }}
|
||||
/>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('settingsCredentials.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.registryTokens')}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{$t('settingsCredentials.registryTokensDesc')}
|
||||
<a href="/settings/registries" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] underline transition-colors">{$t('settingsCredentials.registriesLink')}</a>
|
||||
{$t('settingsCredentials.registryTokensSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!--
|
||||
Settings › DNS
|
||||
|
||||
Wildcard vs. per-subdomain DNS, and Cloudflare provider configuration.
|
||||
Split out of the General page so the token, zone picker, and "test
|
||||
connection" flow aren't buried under unrelated infra fields.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, testDnsConnection, listDnsZones } from '$lib/api';
|
||||
import type { EntityPickerItem } 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 { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconX } from '$lib/components/icons';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
|
||||
let wildcardDns = $state(true);
|
||||
let dnsProvider = $state('');
|
||||
let cloudflareApiToken = $state('');
|
||||
let hasCloudflareApiToken = $state(false);
|
||||
let cloudflareZoneId = $state('');
|
||||
let zonePickerOpen = $state(false);
|
||||
let zonePickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingZones = $state(false);
|
||||
let zoneName = $state('');
|
||||
let testingDns = $state(false);
|
||||
|
||||
async function loadSettings() {
|
||||
loading = true;
|
||||
try {
|
||||
const s = await getSettings();
|
||||
wildcardDns = s.wildcard_dns ?? true;
|
||||
dnsProvider = s.dns_provider ?? '';
|
||||
hasCloudflareApiToken = s.has_cloudflare_api_token ?? false;
|
||||
cloudflareZoneId = s.cloudflare_zone_id ?? '';
|
||||
if (!wildcardDns && cloudflareZoneId) {
|
||||
resolveZoneName();
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
wildcard_dns: wildcardDns,
|
||||
dns_provider: wildcardDns ? '' : dnsProvider,
|
||||
cloudflare_zone_id: cloudflareZoneId
|
||||
};
|
||||
if (cloudflareApiToken) payload.cloudflare_api_token = cloudflareApiToken;
|
||||
await updateSettings(payload as any);
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
cloudflareApiToken = '';
|
||||
hasCloudflareApiToken = hasCloudflareApiToken || Boolean(payload.cloudflare_api_token);
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openZonePicker() {
|
||||
loadingZones = true;
|
||||
zonePickerOpen = true;
|
||||
try {
|
||||
const token = cloudflareApiToken || undefined;
|
||||
const zones = await listDnsZones(token);
|
||||
zonePickerItems = zones.map((zone): EntityPickerItem => ({
|
||||
value: zone.id,
|
||||
label: zone.name,
|
||||
description: zone.id
|
||||
}));
|
||||
if (zonePickerItems.length === 0) {
|
||||
toasts.error($t('settingsGeneral.noZonesFound'));
|
||||
zonePickerOpen = false;
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noZonesFound'));
|
||||
zonePickerOpen = false;
|
||||
} finally {
|
||||
loadingZones = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoneSelect(value: string) {
|
||||
cloudflareZoneId = value;
|
||||
const item = zonePickerItems.find((i) => i.value === value);
|
||||
zoneName = item?.label ?? '';
|
||||
zonePickerOpen = false;
|
||||
}
|
||||
|
||||
async function handleTestDns() {
|
||||
testingDns = true;
|
||||
try {
|
||||
const token = cloudflareApiToken || '';
|
||||
const result = await testDnsConnection('cloudflare', token, cloudflareZoneId);
|
||||
if (result.success) {
|
||||
toasts.success($t('settingsGeneral.connectionSuccess'));
|
||||
} else {
|
||||
toasts.error(`${$t('settingsGeneral.connectionFailed')}: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.connectionFailed'));
|
||||
} finally {
|
||||
testingDns = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveZoneName() {
|
||||
if (!cloudflareZoneId) return;
|
||||
try {
|
||||
const zones = await listDnsZones();
|
||||
const match = zones.find((z) => z.id === cloudflareZoneId);
|
||||
zoneName = match?.name ?? cloudflareZoneId;
|
||||
} catch {
|
||||
zoneName = cloudflareZoneId;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { loadSettings(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('settingsDns.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="2rem" width="12rem" />
|
||||
<Skeleton height="4rem" />
|
||||
<Skeleton height="12rem" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{#if !wildcardDns}
|
||||
<div class="mt-4 space-y-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
|
||||
<div>
|
||||
<label for="dnsProvider" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.dnsProvider')}</label>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.dnsProviderHelp')}</p>
|
||||
<select id="dnsProvider" bind:value={dnsProvider}
|
||||
class="mt-1.5 w-full max-w-xs rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="cloudflare">Cloudflare</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if dnsProvider === 'cloudflare'}
|
||||
<div>
|
||||
<FormField
|
||||
label={$t('settingsGeneral.cloudflareApiToken')}
|
||||
name="cloudflareApiToken"
|
||||
type="password"
|
||||
bind:value={cloudflareApiToken}
|
||||
placeholder={hasCloudflareApiToken ? '••••••••' : $t('settingsGeneral.cloudflareApiTokenPlaceholder')}
|
||||
helpText={hasCloudflareApiToken ? $t('settingsGeneral.cloudflareApiTokenConfigured') : $t('settingsGeneral.cloudflareApiTokenHelp')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="cloudflareZoneBtn" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.cloudflareZone')}</label>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.cloudflareZoneHelp')}</p>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
id="cloudflareZoneBtn"
|
||||
type="button"
|
||||
onclick={openZonePicker}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
>
|
||||
{#if loadingZones}
|
||||
{$t('settingsGeneral.loadingZones')}
|
||||
{:else if cloudflareZoneId && zoneName}
|
||||
{zoneName}
|
||||
{:else}
|
||||
{$t('settingsGeneral.noZone')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if cloudflareZoneId}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { cloudflareZoneId = ''; zoneName = ''; }}
|
||||
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleTestDns}
|
||||
disabled={testingDns || (!cloudflareApiToken && !hasCloudflareApiToken)}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-[var(--color-brand-600)] hover:bg-[var(--color-brand-50)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{#if testingDns}<IconLoader size={16} />{/if}
|
||||
{testingDns ? $t('settingsGeneral.testingConnection') : $t('settingsGeneral.testConnection')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6">
|
||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={zonePickerOpen}
|
||||
items={zonePickerItems}
|
||||
current={cloudflareZoneId}
|
||||
title={$t('settingsGeneral.selectZone')}
|
||||
onselect={handleZoneSelect}
|
||||
onclose={() => { zonePickerOpen = false; }}
|
||||
/>
|
||||
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
Settings › Integrations
|
||||
|
||||
Outward-facing hooks: where Tinyforge *sends* events (notification URL)
|
||||
and where other systems send events *to* Tinyforge (webhook URL).
|
||||
Keeps discovery in one place instead of burying webhook regen at the
|
||||
bottom of the General page.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl } from '$lib/api';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconCopy, IconRefresh } from '$lib/components/icons';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let regenerating = $state(false);
|
||||
|
||||
let notificationUrl = $state('');
|
||||
let webhookUrl = $state('');
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateUrl(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [settings, hook] = await Promise.all([getSettings(), getWebhookUrl().catch(() => ({ webhook_url: '' }))]);
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
webhookUrl = hook.webhook_url ?? '';
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const urlErr = validateUrl(notificationUrl);
|
||||
errors = urlErr ? { notificationUrl: urlErr } : {};
|
||||
if (urlErr) return;
|
||||
saving = true;
|
||||
try {
|
||||
await updateSettings({ notification_url: notificationUrl.trim() } as any);
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateWebhook() {
|
||||
regenerating = true;
|
||||
try {
|
||||
const result = await regenerateWebhookUrl();
|
||||
webhookUrl = result.webhook_url;
|
||||
toasts.success($t('settingsGeneral.regenerated'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.regenerateFailed'));
|
||||
} finally {
|
||||
regenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('settingsIntegrations.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="2rem" width="12rem" />
|
||||
<Skeleton height="6rem" />
|
||||
<Skeleton height="6rem" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Outgoing: notification URL -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.outgoing')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsIntegrations.outgoingDesc')}</p>
|
||||
|
||||
<FormField
|
||||
label={$t('settingsGeneral.notificationUrl')}
|
||||
name="notificationUrl"
|
||||
bind:value={notificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
error={errors.notificationUrl ?? ''}
|
||||
helpText={$t('settingsGeneral.notificationUrlHelp')}
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming: webhook URL + regenerate -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.incoming')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsGeneral.webhookDesc')}</p>
|
||||
|
||||
{#if webhookUrl}
|
||||
<div class="flex items-center gap-3">
|
||||
<code class="flex-1 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)] break-all">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
<button
|
||||
onclick={() => { navigator.clipboard.writeText(webhookUrl); toasts.info($t('settingsGeneral.copied')); }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
{$t('settingsGeneral.copy')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-tertiary)] italic">{$t('settingsGeneral.noWebhookUrl')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
onclick={handleRegenerateWebhook}
|
||||
disabled={regenerating}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if regenerating}<IconLoader size={16} />{/if}
|
||||
<IconRefresh size={16} />
|
||||
{regenerating ? $t('settingsGeneral.regenerating') : $t('settingsGeneral.regenerateUrl')}
|
||||
</button>
|
||||
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.regenerateWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,151 @@
|
||||
<!--
|
||||
Settings › Maintenance
|
||||
|
||||
Housekeeping that's adjacent to destructive operations: stale-container
|
||||
thresholds and Docker-image pruning. Isolated so the prune button is
|
||||
never within casual miss-click distance of general form fields.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, pruneImages } from '$lib/api';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconAlert } from '$lib/components/icons';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let pruning = $state(false);
|
||||
let showPruneConfirm = $state(false);
|
||||
|
||||
let staleThresholdDays = $state('7');
|
||||
let imagePruneThresholdMb = $state('1024');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const s = await getSettings();
|
||||
staleThresholdDays = String(s.stale_threshold_days ?? 7);
|
||||
imagePruneThresholdMb = String(s.image_prune_threshold_mb ?? 1024);
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
await updateSettings({
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||||
image_prune_threshold_mb: Math.max(0, parseInt(imagePruneThresholdMb, 10) || 0)
|
||||
} as any);
|
||||
toasts.success($t('settingsGeneral.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePruneImages() {
|
||||
pruning = true;
|
||||
try {
|
||||
const result = await pruneImages();
|
||||
toasts.success($t('settings.pruneResult', { count: String(result.images_removed), mb: String(result.space_reclaimed_mb) }));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settings.pruneFailed'));
|
||||
} finally {
|
||||
pruning = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('settingsMaintenance.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="2rem" width="12rem" />
|
||||
<Skeleton height="6rem" />
|
||||
<Skeleton height="8rem" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Thresholds card -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsMaintenance.thresholds')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsMaintenance.thresholdsDesc')}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label={$t('settings.staleThreshold')}
|
||||
name="staleThresholdDays"
|
||||
type="number"
|
||||
bind:value={staleThresholdDays}
|
||||
placeholder="7"
|
||||
helpText={$t('settings.staleThresholdHelp')}
|
||||
/>
|
||||
<FormField
|
||||
label={$t('settings.pruneThreshold')}
|
||||
name="imagePruneThresholdMb"
|
||||
type="number"
|
||||
bind:value={imagePruneThresholdMb}
|
||||
placeholder="1024"
|
||||
helpText={$t('settings.pruneThresholdHelp')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<button onclick={handleSave} disabled={saving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsGeneral.saving') : $t('settingsGeneral.saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger zone: prune images -->
|
||||
<div class="rounded-xl border border-[var(--color-danger)]/40 bg-[var(--color-danger-light)]/30 p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0 rounded-lg bg-[var(--color-danger-light)] p-2 text-[var(--color-danger)]">
|
||||
<IconAlert size={18} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-[var(--color-danger-dark)]">{$t('settingsMaintenance.dangerZone')}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('settings.dockerCleanupHelp')}</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showPruneConfirm = true; }}
|
||||
disabled={pruning}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger)] hover:text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if pruning}
|
||||
<IconLoader size={16} />
|
||||
{$t('settings.pruning')}
|
||||
{:else}
|
||||
{$t('settings.pruneImages')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showPruneConfirm}
|
||||
title={$t('settings.pruneImages')}
|
||||
message={$t('settings.pruneConfirmMessage')}
|
||||
confirmLabel={$t('settings.pruneImages')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={() => { showPruneConfirm = false; handlePruneImages(); }}
|
||||
oncancel={() => { showPruneConfirm = false; }}
|
||||
/>
|
||||
Reference in New Issue
Block a user