feat: proxy provider UI, webhook URL fix, and dev-server key persistence

- Add proxy provider selector (None/NPM) to settings page with radio cards
- Show warning when switching to None about existing NPM routes
- Conditionally hide SSL certificate picker and NPM credentials when provider is None
- Fix webhook URL regenerate not updating UI (field name mismatch: url vs webhook_url)
- Fix dev-server.sh to persist ENCRYPTION_KEY across restarts via data/.dev-key
- Fix selected radio card background visibility in dark mode
This commit is contained in:
2026-04-04 20:33:08 +03:00
parent 7d6719da12
commit 97d2980e95
7 changed files with 77 additions and 12 deletions
+14 -2
View File
@@ -15,8 +15,20 @@ if [ -n "$PID" ] && [ "$PID" != "0" ]; then
sleep 1 sleep 1
fi fi
# Generate a random encryption key if not set. # Use a stable encryption key for development.
export ENCRYPTION_KEY="${ENCRYPTION_KEY:-$(openssl rand -hex 32)}" # Generate once and save to data/.dev-key so encrypted tokens survive restarts.
if [ -z "${ENCRYPTION_KEY:-}" ]; then
KEY_FILE="./data/.dev-key"
mkdir -p ./data
if [ -f "$KEY_FILE" ]; then
ENCRYPTION_KEY=$(cat "$KEY_FILE")
else
ENCRYPTION_KEY=$(openssl rand -hex 32)
echo "$ENCRYPTION_KEY" > "$KEY_FILE"
echo "Generated new dev encryption key (saved to $KEY_FILE)"
fi
fi
export ENCRYPTION_KEY
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}" export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
export LISTEN_ADDR="${PORT}" export LISTEN_ADDR="${PORT}"
+4 -4
View File
@@ -260,12 +260,12 @@ export function updateSettings(data: Partial<Settings>): Promise<Settings> {
return put<Settings>('/api/settings', data); return put<Settings>('/api/settings', data);
} }
export function getWebhookUrl(): Promise<{ url: string }> { export function getWebhookUrl(): Promise<{ webhook_url: string }> {
return get<{ url: string }>('/api/settings/webhook-url'); return get<{ webhook_url: string }>('/api/settings/webhook-url');
} }
export function regenerateWebhookUrl(): Promise<{ url: string }> { export function regenerateWebhookUrl(): Promise<{ webhook_url: string }> {
return post<{ url: string }>('/api/settings/webhook-url/regenerate'); return post<{ webhook_url: string }>('/api/settings/webhook-url/regenerate');
} }
export function listNpmCertificates(): Promise<NpmCertificate[]> { export function listNpmCertificates(): Promise<NpmCertificate[]> {
+8 -1
View File
@@ -238,7 +238,14 @@
"backup": "Backups", "backup": "Backups",
"appearance": "Appearance", "appearance": "Appearance",
"staleThreshold": "Stale threshold (days)", "staleThreshold": "Stale threshold (days)",
"staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale." "staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale.",
"proxyProvider": "Proxy Provider",
"proxyProviderHelp": "Select how reverse proxy routes are managed for deployed containers.",
"proxyNone": "None",
"proxyNoneDesc": "No proxy — containers are accessed directly by port",
"proxyNpm": "Nginx Proxy Manager",
"proxyNpmDesc": "Routes managed via NPM API (configure credentials below)",
"proxyNoneWarning": "Switching to None does not remove existing proxy routes from NPM. You can delete them manually from your NPM dashboard."
}, },
"settingsGeneral": { "settingsGeneral": {
"title": "General Settings", "title": "General Settings",
+8 -1
View File
@@ -238,7 +238,14 @@
"backup": "Резервные копии", "backup": "Резервные копии",
"appearance": "Внешний вид", "appearance": "Внешний вид",
"staleThreshold": "Порог устаревания (дни)", "staleThreshold": "Порог устаревания (дни)",
"staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие." "staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие.",
"proxyProvider": "Провайдер прокси",
"proxyProviderHelp": "Выберите способ управления обратным прокси для развёрнутых контейнеров.",
"proxyNone": "Нет",
"proxyNoneDesc": "Без прокси — контейнеры доступны напрямую по порту",
"proxyNpm": "Nginx Proxy Manager",
"proxyNpmDesc": "Маршруты через NPM API (настройте учётные данные ниже)",
"proxyNoneWarning": "Переключение на «Нет» не удаляет существующие прокси-маршруты из NPM. Вы можете удалить их вручную в панели NPM."
}, },
"settingsGeneral": { "settingsGeneral": {
"title": "Общие настройки", "title": "Общие настройки",
+1
View File
@@ -115,6 +115,7 @@ export interface Settings {
dns_provider: string; dns_provider: string;
has_cloudflare_api_token: boolean; has_cloudflare_api_token: boolean;
cloudflare_zone_id: string; cloudflare_zone_id: string;
proxy_provider: string;
backup_enabled: boolean; backup_enabled: boolean;
backup_interval_hours: number; backup_interval_hours: number;
backup_retention_count: number; backup_retention_count: number;
+38 -4
View File
@@ -28,6 +28,9 @@
let certPickerItems = $state<EntityPickerItem[]>([]); let certPickerItems = $state<EntityPickerItem[]>([]);
let loadingCerts = $state(false); let loadingCerts = $state(false);
// Proxy provider state.
let proxyProvider = $state('npm');
// DNS settings state. // DNS settings state.
let wildcardDns = $state(true); let wildcardDns = $state(true);
let dnsProvider = $state(''); let dnsProvider = $state('');
@@ -93,6 +96,7 @@
sslCertificateId = settings.ssl_certificate_id ?? 0; sslCertificateId = settings.ssl_certificate_id ?? 0;
notificationUrl = settings.notification_url ?? ''; notificationUrl = settings.notification_url ?? '';
staleThresholdDays = String(settings.stale_threshold_days ?? 7); staleThresholdDays = String(settings.stale_threshold_days ?? 7);
proxyProvider = settings.proxy_provider ?? 'npm';
wildcardDns = settings.wildcard_dns ?? true; wildcardDns = settings.wildcard_dns ?? true;
dnsProvider = settings.dns_provider ?? ''; dnsProvider = settings.dns_provider ?? '';
hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false; hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false;
@@ -107,7 +111,7 @@
async function loadWebhookUrlValue() { async function loadWebhookUrlValue() {
try { try {
const result = await getWebhookUrl(); const result = await getWebhookUrl();
webhookUrl = result.url; webhookUrl = result.webhook_url;
} catch { /* may not be configured */ } } catch { /* may not be configured */ }
} }
@@ -119,7 +123,8 @@
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(), domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(), subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(), base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
ssl_certificate_id: sslCertificateId, proxy_provider: proxyProvider,
ssl_certificate_id: proxyProvider === 'npm' ? sslCertificateId : 0,
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7), stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
wildcard_dns: wildcardDns, wildcard_dns: wildcardDns,
dns_provider: wildcardDns ? '' : dnsProvider, dns_provider: wildcardDns ? '' : dnsProvider,
@@ -141,7 +146,7 @@
regenerating = true; regenerating = true;
try { try {
const result = await regenerateWebhookUrl(); const result = await regenerateWebhookUrl();
webhookUrl = result.url; webhookUrl = result.webhook_url;
toasts.success($t('settingsGeneral.regenerated')); toasts.success($t('settingsGeneral.regenerated'));
} catch (err) { } catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.regenerateFailed')); toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.regenerateFailed'));
@@ -293,7 +298,35 @@
<FormField label={$t('settingsGeneral.baseVolumePath')} name="baseVolumePath" bind:value={baseVolumePath} placeholder="/data" helpText={$t('settingsGeneral.baseVolumePathHelp')} /> <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')} /> <FormField label={$t('settingsGeneral.notificationUrl')} name="notificationUrl" bind:value={notificationUrl} placeholder="https://notify.example.com/webhook" error={errors.notificationUrl ?? ''} helpText={$t('settingsGeneral.notificationUrlHelp')} />
</div> </div>
<!-- SSL Certificate --> <!-- 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>
</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>
<!-- SSL Certificate (NPM only) -->
{#if proxyProvider === 'npm'}
<div class="mt-6 border-t border-[var(--border-primary)] pt-4"> <div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="flex-1"> <div class="flex-1">
@@ -328,6 +361,7 @@
</div> </div>
</div> </div>
</div> </div>
{/if}
<!-- Stale Detection --> <!-- Stale Detection -->
<div class="mt-6 border-t border-[var(--border-primary)] pt-4"> <div class="mt-6 border-t border-[var(--border-primary)] pt-4">
@@ -8,6 +8,7 @@
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let proxyProvider = $state('npm');
let npmUrl = $state(''); let npmUrl = $state('');
let npmEmail = $state(''); let npmEmail = $state('');
let npmPassword = $state(''); let npmPassword = $state('');
@@ -28,6 +29,7 @@
loading = true; loading = true;
try { try {
const settings = await getSettings(); const settings = await getSettings();
proxyProvider = settings.proxy_provider ?? 'npm';
npmUrl = settings.npm_url ?? ''; npmUrl = settings.npm_url ?? '';
npmEmail = settings.npm_email ?? ''; npmEmail = settings.npm_email ?? '';
npmHasCredentials = !!(settings.npm_url && settings.npm_email); npmHasCredentials = !!(settings.npm_url && settings.npm_email);
@@ -65,6 +67,7 @@
{#if loading} {#if loading}
<div class="space-y-4"><Skeleton height="12rem" /></div> <div class="space-y-4"><Skeleton height="12rem" /></div>
{:else} {:else}
{#if proxyProvider === 'npm'}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
@@ -113,6 +116,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.registryTokens')}</h3> <h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsCredentials.registryTokens')}</h3>