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:
+14
-2
@@ -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
@@ -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[]> {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Общие настройки",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user