feat: proxy routes page, OIDC login fix, NPM test connection, webhook URL fix, and UX improvements
- Add /proxies page showing deploy-managed proxy routes with project/stage links, search, and status - Add GET /api/proxies endpoint joining instances with project/stage names - Add POST /api/settings/npm/test endpoint for NPM connection validation - Add GET /api/auth/mode public endpoint for auth mode detection - Add NPM Test Connection button with validation on save - Fix OIDC SSO button only shown when auth_mode is oidc - Fix webhook URL showing empty when domain not set (fallback to request host) - Fix quick deploy double-tag (image:latest:latest) by splitting tag from image URL - Fix trim() errors on number inputs in deploy and settings forms - Fix NPM client auto-append /api to base URL - Sanitize NPM test error messages (no raw HTML) - Remove healthcheck field from Quick Deploy form - Fix env vars placeholder newline - Make domain field optional in settings - Set polling interval minimum to 60s - Add Proxies and Events to sidebar navigation - Fix SSL cert name flash on NPM settings page - Fix empty state icon on proxies page
This commit is contained in:
+14
-2
@@ -4,11 +4,13 @@ import type {
|
||||
Deploy,
|
||||
DeployLog,
|
||||
DockerHealth,
|
||||
ProxyHealth,
|
||||
EventLogEntry,
|
||||
EventLogStats,
|
||||
InspectResult,
|
||||
Instance,
|
||||
NpmCertificate,
|
||||
ProxyRoute,
|
||||
Project,
|
||||
ProjectDetail,
|
||||
Registry,
|
||||
@@ -265,6 +267,16 @@ export function regenerateWebhookUrl(): Promise<{ webhook_url: string }> {
|
||||
return post<{ webhook_url: string }>('/api/settings/webhook-url/regenerate');
|
||||
}
|
||||
|
||||
// ── Proxy Routes ───────────────────────────────────────────────────
|
||||
|
||||
export function listProxyRoutes(): Promise<ProxyRoute[]> {
|
||||
return get<ProxyRoute[]>('/api/proxies');
|
||||
}
|
||||
|
||||
export function testNpmConnection(data: { npm_url?: string; npm_email?: string; npm_password?: string }): Promise<{ status: string }> {
|
||||
return post<{ status: string }>('/api/settings/npm/test', data);
|
||||
}
|
||||
|
||||
export function listNpmCertificates(): Promise<NpmCertificate[]> {
|
||||
return get<NpmCertificate[]>('/api/settings/npm-certificates');
|
||||
}
|
||||
@@ -315,8 +327,8 @@ export function backupDownloadUrl(id: string): string {
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getHealth(): Promise<{ docker: DockerHealth }> {
|
||||
return get<{ docker: DockerHealth }>('/api/health');
|
||||
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {
|
||||
return get<{ docker: DockerHealth; proxy?: ProxyHealth }>('/api/health');
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
"varTag": "Image tag",
|
||||
"varPort": "Container port",
|
||||
"pollingInterval": "Polling Interval (seconds)",
|
||||
"pollingIntervalHelp": "How often to check registries for new tags (10-86400)",
|
||||
"pollingIntervalHelp": "How often to check registries for new tags (60-86400)",
|
||||
"notificationUrl": "Notification URL",
|
||||
"notificationUrlHelp": "Webhook URL for deploy notifications",
|
||||
"saveSettings": "Save Settings",
|
||||
@@ -367,6 +367,13 @@
|
||||
"healthConnected": "Connected",
|
||||
"healthUnreachable": "Unreachable"
|
||||
},
|
||||
"settingsNpm": {
|
||||
"testConnection": "Test Connection",
|
||||
"testing": "Testing...",
|
||||
"testSuccess": "NPM connection successful",
|
||||
"testFailed": "NPM connection failed",
|
||||
"saveFailedConnection": "Cannot save \u2014 connection test failed"
|
||||
},
|
||||
"settingsCredentials": {
|
||||
"title": "Credentials",
|
||||
"description": "Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at rest.",
|
||||
@@ -544,7 +551,7 @@
|
||||
"invalidIp": "Invalid IP format",
|
||||
"invalidEmail": "Invalid email format",
|
||||
"invalidPort": "Port must be between 1 and 65535",
|
||||
"invalidPollingInterval": "Polling interval must be between 10 and 86400 seconds",
|
||||
"invalidPollingInterval": "Polling interval must be between 60 and 86400 seconds",
|
||||
"invalidProjectName": "Only lowercase letters, numbers, and hyphens allowed",
|
||||
"requiredWhenUpdating": "{field} is required when updating credentials",
|
||||
"requiredForNew": "{field} is required for new registries"
|
||||
@@ -628,6 +635,23 @@
|
||||
"skipped": "Skipped"
|
||||
}
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Proxy Routes",
|
||||
"description": "Active proxy routes created by deployments.",
|
||||
"domain": "Domain",
|
||||
"project": "Project",
|
||||
"stage": "Stage",
|
||||
"tag": "Tag",
|
||||
"port": "Port",
|
||||
"status": "Status",
|
||||
"noRoutes": "No proxy routes",
|
||||
"noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled.",
|
||||
"searchPlaceholder": "Search by domain, project, or tag...",
|
||||
"noMatch": "No routes match your search.",
|
||||
"loadFailed": "Failed to load proxy routes",
|
||||
"route": "route",
|
||||
"routes": "routes"
|
||||
},
|
||||
"events": {
|
||||
"title": "Event Log",
|
||||
"noEvents": "No events found",
|
||||
|
||||
@@ -281,7 +281,7 @@
|
||||
"varTag": "Тег образа",
|
||||
"varPort": "Порт контейнера",
|
||||
"pollingInterval": "Интервал опроса (секунды)",
|
||||
"pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)",
|
||||
"pollingIntervalHelp": "Как часто проверять реестры на новые теги (60-86400)",
|
||||
"notificationUrl": "URL уведомлений",
|
||||
"notificationUrlHelp": "URL вебхука для уведомлений о деплоях",
|
||||
"saveSettings": "Сохранить настройки",
|
||||
@@ -367,6 +367,13 @@
|
||||
"healthConnected": "Подключено",
|
||||
"healthUnreachable": "Недоступно"
|
||||
},
|
||||
"settingsNpm": {
|
||||
"testConnection": "Проверить соединение",
|
||||
"testing": "Проверка...",
|
||||
"testSuccess": "Подключение к NPM успешно",
|
||||
"testFailed": "Не удалось подключиться к NPM",
|
||||
"saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена"
|
||||
},
|
||||
"settingsCredentials": {
|
||||
"title": "Учётные данные",
|
||||
"description": "Управление учётными данными для Nginx Proxy Manager и токенами реестров. Все значения зашифрованы.",
|
||||
@@ -544,7 +551,7 @@
|
||||
"invalidIp": "Неверный формат IP",
|
||||
"invalidEmail": "Неверный формат email",
|
||||
"invalidPort": "Порт должен быть от 1 до 65535",
|
||||
"invalidPollingInterval": "Интервал опроса должен быть от 10 до 86400 секунд",
|
||||
"invalidPollingInterval": "Интервал опроса должен быть от 60 до 86400 секунд",
|
||||
"invalidProjectName": "Допускаются только строчные буквы, цифры и дефисы",
|
||||
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
|
||||
"requiredForNew": "Поле {field} обязательно для новых реестров"
|
||||
@@ -628,6 +635,23 @@
|
||||
"skipped": "Пропущено"
|
||||
}
|
||||
},
|
||||
"proxies": {
|
||||
"title": "Прокси-маршруты",
|
||||
"description": "Активные прокси-маршруты, созданные при развёртывании.",
|
||||
"domain": "Домен",
|
||||
"project": "Проект",
|
||||
"stage": "Этап",
|
||||
"tag": "Тег",
|
||||
"port": "Порт",
|
||||
"status": "Статус",
|
||||
"noRoutes": "Нет прокси-маршрутов",
|
||||
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с включённым прокси.",
|
||||
"searchPlaceholder": "Поиск по домену, проекту или тегу...",
|
||||
"noMatch": "Нет маршрутов, соответствующих поиску.",
|
||||
"loadFailed": "Не удалось загрузить прокси-маршруты",
|
||||
"route": "маршрут",
|
||||
"routes": "маршрутов"
|
||||
},
|
||||
"events": {
|
||||
"title": "Журнал событий",
|
||||
"noEvents": "Событий не найдено",
|
||||
|
||||
@@ -251,6 +251,30 @@ export interface DockerHealth {
|
||||
checked_at?: string;
|
||||
}
|
||||
|
||||
export interface ProxyHealth {
|
||||
connected: boolean;
|
||||
provider: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A proxy route managed by a deployed instance. */
|
||||
export interface ProxyRoute {
|
||||
instance_id: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
stage_id: string;
|
||||
stage_name: string;
|
||||
image_tag: string;
|
||||
subdomain: string;
|
||||
domain: string;
|
||||
container_id: string;
|
||||
port: number;
|
||||
proxy_route_id: string;
|
||||
npm_proxy_id: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** A persistent event log entry. */
|
||||
export interface EventLogEntry {
|
||||
id: number;
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconSettings, IconMenu, IconX, IconLogout, IconChevronDown } from '$lib/components/icons';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||
import { instanceStatusStore } from '$lib/stores/instance-status';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||
import { logout as apiLogout, getHealth } from '$lib/api';
|
||||
import type { DockerHealth } from '$lib/types';
|
||||
import type { DockerHealth, ProxyHealth } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +26,7 @@
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||
] as const;
|
||||
@@ -38,11 +39,15 @@
|
||||
let sseConnection: SSEConnection | null = null;
|
||||
let sidebarOpen = $state(false);
|
||||
let dockerHealth = $state<DockerHealth | null>(null);
|
||||
let proxyHealth = $state<ProxyHealth | null>(null);
|
||||
let healthChecked = $state(false);
|
||||
let healthInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let hintsExpanded = $state(false);
|
||||
let proxyHintsExpanded = $state(false);
|
||||
|
||||
const dockerConnected = $derived(dockerHealth?.connected ?? false);
|
||||
const proxyConnected = $derived(proxyHealth?.connected ?? true);
|
||||
const proxyProviderName = $derived(proxyHealth?.provider ?? '');
|
||||
|
||||
// Hide sidebar and chrome on the login page.
|
||||
const isLoginPage = $derived($page.url.pathname === '/login');
|
||||
@@ -99,8 +104,10 @@
|
||||
try {
|
||||
const h = await getHealth();
|
||||
dockerHealth = h.docker;
|
||||
proxyHealth = h.proxy ?? null;
|
||||
} catch {
|
||||
dockerHealth = { connected: false };
|
||||
proxyHealth = null;
|
||||
}
|
||||
healthChecked = true;
|
||||
}
|
||||
@@ -170,6 +177,8 @@
|
||||
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'deploy'}
|
||||
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'proxies'}
|
||||
<IconWifi size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'events'}
|
||||
<IconEvents size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'settings'}
|
||||
@@ -186,44 +195,63 @@
|
||||
<!-- Footer controls -->
|
||||
<div class="space-y-3 border-t border-[var(--border-primary)] px-4 py-3">
|
||||
{#if healthChecked}
|
||||
<div class="rounded-md {dockerConnected ? '' : 'bg-red-50 dark:bg-red-950/30'}">
|
||||
<div class="flex items-center gap-3 px-1 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 text-xs {dockerConnected ? 'text-emerald-600' : 'text-red-500 cursor-pointer'}"
|
||||
class="flex items-center gap-1.5 {dockerConnected ? 'text-emerald-600' : 'text-red-500'}"
|
||||
title={dockerConnected ? 'Docker connected' : dockerHealth?.error ?? 'Docker disconnected'}
|
||||
onclick={() => { if (!dockerConnected) hintsExpanded = !hintsExpanded; }}
|
||||
disabled={dockerConnected}
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="relative flex h-2 w-2 shrink-0">
|
||||
{#if dockerConnected}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full {dockerConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||
</span>
|
||||
<span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span>
|
||||
{#if !dockerConnected && dockerHealth?.error}
|
||||
<IconChevronDown size={12} class="transition-transform {hintsExpanded ? 'rotate-180' : ''}" />
|
||||
{/if}
|
||||
Docker
|
||||
</button>
|
||||
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
|
||||
<div class="px-2 pb-2">
|
||||
<code class="block text-[11px] text-red-600 dark:text-red-400 break-all leading-relaxed">{dockerHealth.error}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 w-full rounded border border-red-300 dark:border-red-700 px-2 py-1 text-[11px] font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
onclick={async () => {
|
||||
try {
|
||||
const h = await getHealth();
|
||||
dockerHealth = h.docker;
|
||||
} catch {
|
||||
dockerHealth = { connected: false };
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('health.retryNow')}
|
||||
</button>
|
||||
</div>
|
||||
{#if proxyHealth && proxyProviderName !== 'none'}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 {proxyConnected ? 'text-emerald-600' : 'text-red-500'}"
|
||||
title={proxyConnected ? (proxyProviderName === 'npm' ? 'NPM' : 'Traefik') + ' connected' : proxyHealth.error ?? 'Proxy disconnected'}
|
||||
onclick={() => { if (!proxyConnected) proxyHintsExpanded = !proxyHintsExpanded; }}
|
||||
>
|
||||
<span class="relative flex h-2 w-2 shrink-0">
|
||||
{#if proxyConnected}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full {proxyConnected ? 'bg-emerald-500' : 'bg-red-500'}"></span>
|
||||
</span>
|
||||
{proxyProviderName === 'npm' ? 'NPM' : 'Traefik'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
|
||||
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{dockerHealth.error}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1.5 w-full rounded border border-red-300 dark:border-red-700 px-2 py-0.5 text-[10px] font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
onclick={async () => {
|
||||
try {
|
||||
const h = await getHealth();
|
||||
dockerHealth = h.docker;
|
||||
proxyHealth = h.proxy ?? null;
|
||||
} catch {
|
||||
dockerHealth = { connected: false };
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('health.retryNow')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !proxyConnected && proxyHintsExpanded && proxyHealth?.error}
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-950/30 px-2 py-2">
|
||||
<code class="block text-[10px] text-red-600 dark:text-red-400 break-all leading-relaxed">{proxyHealth.error}</code>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex items-center justify-between">
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
let projectName = $state('');
|
||||
let port = $state('');
|
||||
let healthcheck = $state('');
|
||||
let stage = $state('dev');
|
||||
let subdomain = $state('');
|
||||
let envVars = $state('');
|
||||
@@ -78,9 +77,10 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePort(value: string): string {
|
||||
if (!value.trim()) return $t('validation.required', { field: 'Port' });
|
||||
const num = parseInt(value, 10);
|
||||
function validatePort(value: string | number): string {
|
||||
const s = String(value ?? '');
|
||||
if (!s.trim()) return $t('validation.required', { field: 'Port' });
|
||||
const num = parseInt(s, 10);
|
||||
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
|
||||
return '';
|
||||
}
|
||||
@@ -122,7 +122,7 @@
|
||||
inspectResult = result;
|
||||
projectName = deriveProjectName(result.image);
|
||||
port = result.port?.toString() ?? '';
|
||||
healthcheck = result.healthcheck ?? '';
|
||||
// Healthcheck auto-detected but not shown — user can configure later on project page.
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
@@ -151,8 +151,7 @@
|
||||
inspectResult = null;
|
||||
projectName = '';
|
||||
port = '';
|
||||
healthcheck = '';
|
||||
stage = 'dev';
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
}
|
||||
@@ -274,7 +273,6 @@
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText={$t('quickDeploy.lowercaseHint')} />
|
||||
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
|
||||
<FormField label={$t('quickDeploy.healthCheckPath')} name="healthcheck" bind:value={healthcheck} placeholder="/api/health" helpText={$t('quickDeploy.healthCheckHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
|
||||
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
@@ -288,7 +286,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder="KEY=value ANOTHER_KEY=another_value" helpText={$t('quickDeploy.envVarsHelp')} />
|
||||
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder={"KEY=value\nANOTHER_KEY=another_value"} helpText={$t('quickDeploy.envVarsHelp')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
let showPassword = $state(false);
|
||||
let authMode = $state('local');
|
||||
|
||||
// Apply theme on login page too.
|
||||
$effect(() => {
|
||||
@@ -19,6 +20,15 @@
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Check if OIDC is enabled.
|
||||
try {
|
||||
const res = await fetch('/api/auth/mode');
|
||||
if (res.ok) {
|
||||
const envelope = await res.json();
|
||||
authMode = envelope.data?.auth_mode ?? 'local';
|
||||
}
|
||||
} catch { /* default to local */ }
|
||||
|
||||
const urlToken = $page.url.searchParams.get('token');
|
||||
if (urlToken) {
|
||||
// Validate the token against the backend before trusting it.
|
||||
@@ -140,6 +150,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if authMode === 'oidc'}
|
||||
<div class="mt-5">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
@@ -157,6 +168,7 @@
|
||||
{$t('login.ssoButton')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { listProxyRoutes } from '$lib/api';
|
||||
import type { ProxyRoute } from '$lib/types';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
|
||||
let routes = $state<ProxyRoute[]>([]);
|
||||
let loading = $state(true);
|
||||
let search = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim()
|
||||
? routes.filter((r) => {
|
||||
const q = search.toLowerCase();
|
||||
return r.domain?.toLowerCase().includes(q)
|
||||
|| r.project_name.toLowerCase().includes(q)
|
||||
|| r.stage_name.toLowerCase().includes(q)
|
||||
|| r.image_tag.toLowerCase().includes(q);
|
||||
})
|
||||
: routes
|
||||
);
|
||||
|
||||
async function loadRoutes() {
|
||||
loading = true;
|
||||
try {
|
||||
routes = await listProxyRoutes();
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('proxies.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { loadRoutes(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('proxies.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('proxies.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<Skeleton height="4rem" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if routes.length === 0}
|
||||
<EmptyState title={$t('proxies.noRoutes')} description={$t('proxies.noRoutesDesc')} icon="instances" />
|
||||
{:else}
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder={$t('proxies.searchPlaceholder')}
|
||||
class="w-full max-w-md rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.domain')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.project')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.stage')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.tag')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.port')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each filtered as route (route.instance_id)}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
{#if route.domain}
|
||||
<a href="https://{route.domain}" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] underline transition-colors">
|
||||
{route.domain}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm text-[var(--text-tertiary)]">{route.subdomain || '—'}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<a href="/projects/{route.project_id}" class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors">
|
||||
{route.project_name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">{route.stage_name}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{route.image_tag}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-[var(--text-secondary)]">{route.port}</td>
|
||||
<td class="px-4 py-3">
|
||||
<StatusBadge status={route.status} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0 && search}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('proxies.noMatch')}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-[var(--text-tertiary)]">
|
||||
{filtered.length} {filtered.length === 1 ? $t('proxies.route') : $t('proxies.routes')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
function validateDomain(value: string): string {
|
||||
if (!value.trim()) return $t('validation.required', { field: 'Domain' });
|
||||
if (!value.trim()) return '';
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) return $t('validation.invalidDomain');
|
||||
return '';
|
||||
}
|
||||
@@ -52,10 +52,11 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePollingInterval(value: string): string {
|
||||
if (!value.trim()) return '';
|
||||
const num = parseInt(value, 10);
|
||||
if (isNaN(num) || num < 10 || num > 86400) return $t('validation.invalidPollingInterval');
|
||||
function validatePollingInterval(value: string | number): string {
|
||||
const s = String(value ?? '');
|
||||
if (!s.trim()) return '';
|
||||
const num = parseInt(s, 10);
|
||||
if (isNaN(num) || num < 60 || num > 86400) return $t('validation.invalidPollingInterval');
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -115,7 +116,7 @@
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
domain: domain.trim(), server_ip: serverIp.trim(), network: network.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: String(pollingInterval ?? '').trim(),
|
||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||
proxy_provider: proxyProvider,
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||||
@@ -236,7 +237,7 @@
|
||||
<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>
|
||||
<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" required error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} />
|
||||
<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="93.84.96.191" error={errors.serverIp ?? ''} helpText={$t('settingsGeneral.serverIpHelp')} />
|
||||
<FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} />
|
||||
<div>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, listNpmCertificates } from '$lib/api';
|
||||
import { getSettings, updateSettings, listNpmCertificates, testNpmConnection } 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, IconCheck, IconEdit, IconShield, IconX } from '$lib/components/icons';
|
||||
import { IconLoader, IconCheck, IconEdit, IconShield, IconX, IconWifi } from '$lib/components/icons';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let testing = $state(false);
|
||||
let npmUrl = $state('');
|
||||
let npmEmail = $state('');
|
||||
let npmPassword = $state('');
|
||||
@@ -41,12 +42,40 @@
|
||||
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
||||
npmPassword = '';
|
||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||
if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`;
|
||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!validateNpmForm()) return;
|
||||
testing = true;
|
||||
try {
|
||||
await testNpmConnection({
|
||||
npm_url: npmUrl.trim(),
|
||||
npm_email: npmEmail.trim(),
|
||||
npm_password: npmPassword.trim() || undefined
|
||||
});
|
||||
toasts.success($t('settingsNpm.testSuccess'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsNpm.testFailed'));
|
||||
} finally { testing = false; }
|
||||
}
|
||||
|
||||
async function handleSaveNpm() {
|
||||
if (!validateNpmForm()) return;
|
||||
saving = true;
|
||||
try {
|
||||
// Validate connection before saving.
|
||||
await testNpmConnection({
|
||||
npm_url: npmUrl.trim(),
|
||||
npm_email: npmEmail.trim(),
|
||||
npm_password: npmPassword.trim() || undefined
|
||||
});
|
||||
} catch (err) {
|
||||
toasts.error($t('settingsNpm.saveFailedConnection') + ': ' + (err instanceof Error ? err.message : ''));
|
||||
saving = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
|
||||
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
|
||||
@@ -148,10 +177,14 @@
|
||||
<FormField label={$t('settingsCredentials.email')} name="npmEmail" type="email" bind:value={npmEmail} placeholder="admin@example.com" required error={errors.npmEmail ?? ''} helpText={$t('settingsCredentials.emailHelp')} />
|
||||
<FormField label={$t('settingsCredentials.password')} name="npmPassword" type="password" bind:value={npmPassword} placeholder={npmHasCredentials ? '(enter new password)' : 'npm-password'} required={editingNpm} error={errors.npmPassword ?? ''} helpText={npmHasCredentials ? $t('settingsCredentials.passwordHelpEdit') : $t('settingsCredentials.passwordHelpNew')} />
|
||||
<div class="flex gap-3">
|
||||
<button onclick={handleSaveNpm} 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 hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
<button onclick={handleSaveNpm} disabled={saving || testing} 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 hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsCredentials.saving') : $t('settingsCredentials.save')}
|
||||
</button>
|
||||
<button onclick={handleTestConnection} disabled={testing || saving} class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if testing}<IconLoader size={16} />{:else}<IconWifi size={16} />{/if}
|
||||
{testing ? $t('settingsNpm.testing') : $t('settingsNpm.testConnection')}
|
||||
</button>
|
||||
{#if npmHasCredentials}
|
||||
<button onclick={() => { editingNpm = false; npmPassword = ''; errors = {}; }} class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
{$t('common.cancel')}
|
||||
|
||||
Reference in New Issue
Block a user