refactor(settings): split General into focused pages
Build / build (push) Successful in 10m38s

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:
2026-04-23 14:53:48 +03:00
parent 03d58a072c
commit e08acf5c0e
8 changed files with 771 additions and 459 deletions
+27
View File
@@ -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"
}
}
+27
View File
@@ -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": "Опасная зона"
}
}
+93 -43
View File
@@ -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>
+87 -398
View File
@@ -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>
+242
View File
@@ -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; }}
/>