refactor: remove standalone proxies, add Traefik provider with Docker labels
Standalone proxy removal: - Delete store, API handlers, proxy manager, health monitor, validator, hints - Delete frontend pages (proxies list, create, edit) and components (ProxyCard, ProxyForm, ProxyFilter, ProxyGroup, ValidationChecklist) - Remove proxy routes from router, nav items, dashboard references - Clean up SystemHealthCard to remove proxy section Traefik provider: - Add TraefikProvider implementing proxy.Provider via Docker labels - ContainerLabels() returns traefik.enable, router rule, entrypoints, service port, TLS cert resolver, docker network - ConfigureRoute() returns router name (labels handle routing at container creation) - DeleteRoute() is no-op (container removal auto-deregisters) - Ping() checks Traefik API health (optional) - Wire ContainerLabels into deployer (executeDeploy + blueGreenDeploy) - Add Traefik settings: entrypoint, cert_resolver, network, api_url - Add traefik option to proxy provider selector in settings UI - Show conditional Traefik config fields - Add i18n keys (EN + RU)
This commit is contained in:
@@ -1,232 +0,0 @@
|
||||
<!--
|
||||
Phase 4: Unified Proxy Viewer — shows all proxies (managed + standalone)
|
||||
with grouping, filtering, and real-time health indicators.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ProxyView, ProxyHealthStatus } from '$lib/types';
|
||||
import { listAllProxies } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import ProxyCard from '$lib/components/ProxyCard.svelte';
|
||||
import ProxyGroup from '$lib/components/ProxyGroup.svelte';
|
||||
import ProxyFilter from '$lib/components/ProxyFilter.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { IconGlobe, IconLoader, IconPlus } from '$lib/components/icons';
|
||||
|
||||
let proxies = $state<ProxyView[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// Filter state
|
||||
let search = $state('');
|
||||
let healthFilter = $state<ProxyHealthStatus | 'all'>('all');
|
||||
let typeFilter = $state<'all' | 'managed' | 'standalone'>('all');
|
||||
|
||||
// Filtered proxies
|
||||
const filtered = $derived(() => {
|
||||
let result = proxies;
|
||||
|
||||
// Text search
|
||||
if (search.length > 0) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.domain.toLowerCase().includes(q) ||
|
||||
p.destination.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
// Health filter
|
||||
if (healthFilter !== 'all') {
|
||||
result = result.filter((p) => p.health_status === healthFilter);
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all') {
|
||||
result = result.filter((p) => p.type === typeFilter);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Split into standalone and managed
|
||||
const standaloneProxies = $derived(filtered().filter((p) => p.type === 'standalone'));
|
||||
const managedProxies = $derived(filtered().filter((p) => p.type === 'managed'));
|
||||
|
||||
// Group managed proxies by project, then stage within each project
|
||||
interface StageGroup {
|
||||
stageName: string;
|
||||
proxies: ProxyView[];
|
||||
}
|
||||
|
||||
interface ProjectGroup {
|
||||
projectName: string;
|
||||
stages: StageGroup[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const managedGroups = $derived<ProjectGroup[]>(() => {
|
||||
const projectMap = new Map<string, Map<string, ProxyView[]>>();
|
||||
|
||||
for (const proxy of managedProxies) {
|
||||
const projName = proxy.project_name ?? 'Unknown';
|
||||
const stageName = proxy.stage_name ?? 'default';
|
||||
|
||||
if (!projectMap.has(projName)) {
|
||||
projectMap.set(projName, new Map());
|
||||
}
|
||||
const stageMap = projectMap.get(projName)!;
|
||||
|
||||
if (!stageMap.has(stageName)) {
|
||||
stageMap.set(stageName, []);
|
||||
}
|
||||
stageMap.get(stageName)!.push(proxy);
|
||||
}
|
||||
|
||||
const groups: ProjectGroup[] = [];
|
||||
for (const [projectName, stageMap] of projectMap) {
|
||||
const stages: StageGroup[] = [];
|
||||
let totalCount = 0;
|
||||
for (const [stageName, stageProxies] of stageMap) {
|
||||
stages.push({ stageName, proxies: stageProxies });
|
||||
totalCount += stageProxies.length;
|
||||
}
|
||||
groups.push({ projectName, stages, totalCount });
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => a.projectName.localeCompare(b.projectName));
|
||||
});
|
||||
|
||||
function clearFilters(): void {
|
||||
search = '';
|
||||
healthFilter = 'all';
|
||||
typeFilter = 'all';
|
||||
}
|
||||
|
||||
async function loadProxies(): Promise<void> {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
proxies = await listAllProxies();
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load proxies';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadProxies();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('proxies.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||
<IconGlobe size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1>
|
||||
{#if !loading && proxies.length > 0}
|
||||
<p class="text-sm text-[var(--text-tertiary)]">
|
||||
{proxies.length} {proxies.length === 1 ? 'proxy' : 'proxies'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/proxies/create"
|
||||
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)] active:animate-press"
|
||||
>
|
||||
<IconPlus size={16} />
|
||||
{$t('proxies.create')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<IconLoader size={24} class="animate-spin text-[var(--color-brand-500)]" />
|
||||
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProxies}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if proxies.length === 0}
|
||||
<!-- Empty state -->
|
||||
<EmptyState
|
||||
title={$t('proxies.noProxies')}
|
||||
description={$t('proxies.noProxiesDesc')}
|
||||
actionLabel={$t('proxies.create')}
|
||||
actionHref="/proxies/create"
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Filter bar -->
|
||||
<div class="mb-6">
|
||||
<ProxyFilter
|
||||
{search}
|
||||
{healthFilter}
|
||||
{typeFilter}
|
||||
onsearchchange={(v) => { search = v; }}
|
||||
onhealthchange={(v) => { healthFilter = v; }}
|
||||
ontypechange={(v) => { typeFilter = v; }}
|
||||
onclear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No filter results -->
|
||||
{#if filtered().length === 0}
|
||||
<div class="rounded-xl border-2 border-dashed border-[var(--border-primary)] px-6 py-16 text-center">
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('proxies.noProxies')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearFilters}
|
||||
class="mt-3 text-sm font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
||||
>
|
||||
{$t('proxies.filter.clear')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Standalone proxies section -->
|
||||
{#if standaloneProxies.length > 0}
|
||||
<ProxyGroup title={$t('proxies.standalone')} count={standaloneProxies.length}>
|
||||
{#each standaloneProxies as proxy (proxy.id)}
|
||||
<ProxyCard {proxy} />
|
||||
{/each}
|
||||
</ProxyGroup>
|
||||
{/if}
|
||||
|
||||
<!-- Managed proxies grouped by project -->
|
||||
{#if managedGroups().length > 0}
|
||||
{#each managedGroups() as group (group.projectName)}
|
||||
<ProxyGroup title={group.projectName} count={group.totalCount}>
|
||||
{#each group.stages as stage (stage.stageName)}
|
||||
{#if group.stages.length > 1}
|
||||
<div class="col-span-full">
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{stage.stageName}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#each stage.proxies as proxy (proxy.id)}
|
||||
<ProxyCard {proxy} />
|
||||
{/each}
|
||||
{/each}
|
||||
</ProxyGroup>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1 +0,0 @@
|
||||
// Client-side loading — data is fetched in the component via $effect.
|
||||
@@ -1,89 +0,0 @@
|
||||
<!--
|
||||
Phase 6: Edit Proxy page — loads a standalone proxy and wraps ProxyForm in edit mode.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import type { StandaloneProxy } from '$lib/types';
|
||||
import { getProxy } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import ProxyForm from '$lib/components/ProxyForm.svelte';
|
||||
import { IconGlobe, IconLoader, IconArrowLeft } from '$lib/components/icons';
|
||||
|
||||
let proxy: StandaloneProxy | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
const proxyId = $derived($page.params.id);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
proxy = await getProxy(proxyId);
|
||||
} catch (err: unknown) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load proxy';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSave(_proxy: StandaloneProxy): void {
|
||||
goto('/proxies');
|
||||
}
|
||||
|
||||
function handleDelete(_id: string): void {
|
||||
goto('/proxies');
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
goto('/proxies');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('proxies.form.editTitle')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/proxies"
|
||||
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
|
||||
>
|
||||
<IconArrowLeft size={16} />
|
||||
{$t('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||
<IconGlobe size={22} />
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.form.editTitle')}</h1>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<IconLoader size={24} class="text-[var(--color-brand-500)]" />
|
||||
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<a href="/proxies" class="mt-2 inline-block text-sm font-medium text-[var(--color-danger)] underline hover:no-underline">
|
||||
{$t('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
{:else if proxy}
|
||||
<!-- Form card -->
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<ProxyForm
|
||||
mode="edit"
|
||||
{proxy}
|
||||
onsave={handleSave}
|
||||
ondelete={handleDelete}
|
||||
oncancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1 +0,0 @@
|
||||
// Client-side loading — proxy data is fetched in the component.
|
||||
@@ -1,50 +0,0 @@
|
||||
<!--
|
||||
Phase 6: Create Proxy page — wraps ProxyForm in create mode.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { StandaloneProxy } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
import ProxyForm from '$lib/components/ProxyForm.svelte';
|
||||
import { IconGlobe, IconArrowLeft } from '$lib/components/icons';
|
||||
|
||||
function handleSave(_proxy: StandaloneProxy): void {
|
||||
goto('/proxies');
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
goto('/proxies');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('proxies.form.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/proxies"
|
||||
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
|
||||
>
|
||||
<IconArrowLeft size={16} />
|
||||
{$t('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||
<IconGlobe size={22} />
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('proxies.form.title')}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Form card -->
|
||||
<div class="mx-auto max-w-2xl rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<ProxyForm
|
||||
mode="create"
|
||||
onsave={handleSave}
|
||||
oncancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
// Client-side loading — ProxyForm handles data fetching.
|
||||
@@ -26,6 +26,12 @@
|
||||
// Proxy provider state.
|
||||
let proxyProvider = $state('npm');
|
||||
|
||||
// Traefik settings state.
|
||||
let traefikEntrypoint = $state('websecure');
|
||||
let traefikCertResolver = $state('letsencrypt');
|
||||
let traefikNetwork = $state('');
|
||||
let traefikApiUrl = $state('');
|
||||
|
||||
// DNS settings state.
|
||||
let wildcardDns = $state(true);
|
||||
let dnsProvider = $state('');
|
||||
@@ -91,6 +97,10 @@
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
||||
proxyProvider = settings.proxy_provider ?? 'npm';
|
||||
traefikEntrypoint = settings.traefik_entrypoint ?? 'websecure';
|
||||
traefikCertResolver = settings.traefik_cert_resolver ?? 'letsencrypt';
|
||||
traefikNetwork = settings.traefik_network ?? '';
|
||||
traefikApiUrl = settings.traefik_api_url ?? '';
|
||||
wildcardDns = settings.wildcard_dns ?? true;
|
||||
dnsProvider = settings.dns_provider ?? '';
|
||||
hasCloudflareApiToken = settings.has_cloudflare_api_token ?? false;
|
||||
@@ -118,6 +128,10 @@
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||
proxy_provider: proxyProvider,
|
||||
traefik_entrypoint: traefikEntrypoint.trim() || 'websecure',
|
||||
traefik_cert_resolver: traefikCertResolver.trim(),
|
||||
traefik_network: traefikNetwork.trim(),
|
||||
traefik_api_url: traefikApiUrl.trim(),
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||||
wildcard_dns: wildcardDns,
|
||||
dns_provider: wildcardDns ? '' : dnsProvider,
|
||||
@@ -290,12 +304,27 @@
|
||||
<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}
|
||||
{#if proxyProvider === 'traefik'}
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-4">
|
||||
<FormField label={$t('settings.traefikEntrypoint')} name="traefikEntrypoint" bind:value={traefikEntrypoint} placeholder="websecure" helpText={$t('settings.traefikEntrypointHelp')} />
|
||||
<FormField label={$t('settings.traefikCertResolver')} name="traefikCertResolver" bind:value={traefikCertResolver} placeholder="letsencrypt" helpText={$t('settings.traefikCertResolverHelp')} />
|
||||
<FormField label={$t('settings.traefikNetwork')} name="traefikNetwork" bind:value={traefikNetwork} placeholder="" helpText={$t('settings.traefikNetworkHelp')} />
|
||||
<FormField label={$t('settings.traefikApiUrl')} name="traefikApiUrl" bind:value={traefikApiUrl} placeholder="http://traefik:8080" helpText={$t('settings.traefikApiUrlHelp')} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- SSL Certificate moved to Credentials page -->
|
||||
|
||||
Reference in New Issue
Block a user