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:
@@ -11,15 +11,12 @@ import type {
|
||||
NpmCertificate,
|
||||
Project,
|
||||
ProjectDetail,
|
||||
ProxyView,
|
||||
Registry,
|
||||
RegistryImage,
|
||||
Settings,
|
||||
StaleContainer,
|
||||
Stage,
|
||||
StageEnv,
|
||||
StandaloneProxy,
|
||||
ValidationResult,
|
||||
Volume,
|
||||
VolumeScopeInfo,
|
||||
BrowseResult,
|
||||
@@ -531,43 +528,6 @@ export function fetchEventLogStats(): Promise<EventLogStats> {
|
||||
return get<EventLogStats>('/api/events/log/stats');
|
||||
}
|
||||
|
||||
// ── Proxies ─────────────────────────────────────────────────────────
|
||||
|
||||
export function validateProxy(host: string, port: number): Promise<ValidationResult> {
|
||||
return post<ValidationResult>('/api/proxies/validate', { host, port });
|
||||
}
|
||||
|
||||
export function createProxy(data: {
|
||||
domain: string;
|
||||
destination_url: string;
|
||||
destination_port: number;
|
||||
}): Promise<StandaloneProxy> {
|
||||
return post<StandaloneProxy>('/api/proxies', data);
|
||||
}
|
||||
|
||||
export function listProxies(): Promise<StandaloneProxy[]> {
|
||||
return get<StandaloneProxy[]>('/api/proxies');
|
||||
}
|
||||
|
||||
export function getProxy(id: string): Promise<StandaloneProxy> {
|
||||
return get<StandaloneProxy>(`/api/proxies/${id}`);
|
||||
}
|
||||
|
||||
export function updateProxy(
|
||||
id: string,
|
||||
data: { domain: string; destination_url: string; destination_port: number }
|
||||
): Promise<StandaloneProxy> {
|
||||
return put<StandaloneProxy>(`/api/proxies/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteProxy(id: string): Promise<{ deleted: string }> {
|
||||
return del<{ deleted: string }>(`/api/proxies/${id}`);
|
||||
}
|
||||
|
||||
export function listAllProxies(): Promise<ProxyView[]> {
|
||||
return get<ProxyView[]>('/api/proxies/all');
|
||||
}
|
||||
|
||||
// ── Stale Containers ────────────────────────────────────────────────
|
||||
|
||||
export function fetchStaleContainers(): Promise<StaleContainer[]> {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<!--
|
||||
Phase 4: Individual proxy display card showing domain, destination,
|
||||
type badge, health indicator, SSL badge, and project/stage labels.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ProxyView } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconEdit, IconExternalLink, IconLock } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
proxy: ProxyView;
|
||||
}
|
||||
|
||||
const { proxy }: Props = $props();
|
||||
|
||||
const healthColors: Record<string, { dot: string; ring: string }> = {
|
||||
healthy: { dot: 'bg-emerald-500', ring: 'bg-emerald-500' },
|
||||
unhealthy: { dot: 'bg-red-500', ring: 'bg-red-500' },
|
||||
unknown: { dot: 'bg-amber-400', ring: 'bg-amber-400' }
|
||||
};
|
||||
|
||||
const healthColor = $derived(healthColors[proxy.health_status] ?? healthColors.unknown);
|
||||
const isHealthy = $derived(proxy.health_status === 'healthy');
|
||||
|
||||
const typeBadgeClass = $derived(
|
||||
proxy.type === 'managed'
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
|
||||
: 'bg-purple-50 text-purple-700 dark:bg-purple-950 dark:text-purple-300'
|
||||
);
|
||||
|
||||
const healthLabel = $derived($t(`proxies.health.${proxy.health_status}`));
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-200 hover:border-[var(--color-brand-300)] hover:shadow-[var(--shadow-md)]">
|
||||
<!-- Top row: domain + health dot -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Health indicator -->
|
||||
<span class="relative flex h-2.5 w-2.5 shrink-0" title={healthLabel}>
|
||||
{#if isHealthy}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full {healthColor.ring} opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex h-2.5 w-2.5 rounded-full {healthColor.dot}"></span>
|
||||
</span>
|
||||
|
||||
<!-- Domain link -->
|
||||
<a
|
||||
href="https://{proxy.domain}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group inline-flex items-center gap-1 truncate text-sm font-semibold text-[var(--text-primary)] hover:text-[var(--color-brand-600)] transition-colors"
|
||||
>
|
||||
<span class="truncate">{proxy.domain}</span>
|
||||
<IconExternalLink size={13} class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Destination -->
|
||||
<p class="mt-1 truncate font-mono text-xs text-[var(--text-tertiary)]">
|
||||
{proxy.destination}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Type badge -->
|
||||
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {typeBadgeClass}">
|
||||
{proxy.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Badges row -->
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<!-- SSL badge -->
|
||||
{#if proxy.ssl_enabled}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<IconLock size={11} />
|
||||
SSL
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Health status label -->
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
||||
{healthLabel}
|
||||
</span>
|
||||
|
||||
<!-- Project / stage labels for managed proxies -->
|
||||
{#if proxy.type === 'managed' && proxy.project_name}
|
||||
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||
{proxy.project_name}
|
||||
</span>
|
||||
{#if proxy.stage_name}
|
||||
<span class="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300">
|
||||
{proxy.stage_name}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer row: edit link (standalone only) + timestamp -->
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
{#if proxy.type === 'standalone'}
|
||||
<a
|
||||
href="/proxies/{proxy.id}/edit"
|
||||
class="inline-flex items-center gap-1 text-xs font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)] transition-colors"
|
||||
>
|
||||
<IconEdit size={12} />
|
||||
{$t('common.edit')}
|
||||
</a>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
|
||||
{#if proxy.created_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">
|
||||
{$t('proxies.lastChecked')}: {formatTimestamp(proxy.created_at)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,85 +0,0 @@
|
||||
<!--
|
||||
Phase 4: Filter bar for the unified proxy viewer.
|
||||
Provides text search, health status dropdown, type dropdown, and clear filters.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ProxyHealthStatus } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconSearch, IconX } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
healthFilter: ProxyHealthStatus | 'all';
|
||||
typeFilter: 'all' | 'managed' | 'standalone';
|
||||
onsearchchange: (value: string) => void;
|
||||
onhealthchange: (value: ProxyHealthStatus | 'all') => void;
|
||||
ontypechange: (value: 'all' | 'managed' | 'standalone') => void;
|
||||
onclear: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
search,
|
||||
healthFilter,
|
||||
typeFilter,
|
||||
onsearchchange,
|
||||
onhealthchange,
|
||||
ontypechange,
|
||||
onclear
|
||||
}: Props = $props();
|
||||
|
||||
const hasFilters = $derived(
|
||||
search.length > 0 || healthFilter !== 'all' || typeFilter !== 'all'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<!-- Text search -->
|
||||
<div class="relative flex-1">
|
||||
<IconSearch
|
||||
size={16}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
oninput={(e) => onsearchchange(e.currentTarget.value)}
|
||||
placeholder={$t('proxies.filter.search')}
|
||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2 pl-9 pr-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Health filter -->
|
||||
<select
|
||||
value={healthFilter}
|
||||
onchange={(e) => onhealthchange(e.currentTarget.value as ProxyHealthStatus | 'all')}
|
||||
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
>
|
||||
<option value="all">{$t('proxies.filter.health')}: {$t('proxies.filter.all')}</option>
|
||||
<option value="healthy">{$t('proxies.health.healthy')}</option>
|
||||
<option value="unhealthy">{$t('proxies.health.unhealthy')}</option>
|
||||
<option value="unknown">{$t('proxies.health.unknown')}</option>
|
||||
</select>
|
||||
|
||||
<!-- Type filter -->
|
||||
<select
|
||||
value={typeFilter}
|
||||
onchange={(e) => ontypechange(e.currentTarget.value as 'all' | 'managed' | 'standalone')}
|
||||
class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] transition-colors focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
>
|
||||
<option value="all">{$t('proxies.filter.type')}: {$t('proxies.filter.all')}</option>
|
||||
<option value="managed">{$t('proxies.managed')}</option>
|
||||
<option value="standalone">{$t('proxies.standalone')}</option>
|
||||
</select>
|
||||
|
||||
<!-- Clear filters -->
|
||||
{#if hasFilters}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclear}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<IconX size={14} />
|
||||
{$t('proxies.filter.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,292 +0,0 @@
|
||||
<!--
|
||||
Phase 6: Create/edit form for standalone proxies.
|
||||
Supports live destination validation with debounce.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { StandaloneProxy, ValidationResult } from '$lib/types';
|
||||
import { validateProxy, createProxy, updateProxy, deleteProxy } from '$lib/api';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ValidationChecklist from '$lib/components/ValidationChecklist.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { IconLoader } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
mode: 'create' | 'edit';
|
||||
proxy?: StandaloneProxy;
|
||||
onsave?: (proxy: StandaloneProxy) => void;
|
||||
ondelete?: (id: string) => void;
|
||||
oncancel?: () => void;
|
||||
}
|
||||
|
||||
const { mode, proxy, onsave, ondelete, oncancel }: Props = $props();
|
||||
|
||||
// ── Form state ────────────────────────────────────────────────────
|
||||
let destinationUrl = $state(proxy?.destination_url ?? '');
|
||||
let port = $state(proxy?.destination_port?.toString() ?? '');
|
||||
let domain = $state(proxy?.domain ?? '');
|
||||
|
||||
// ── Validation state ──────────────────────────────────────────────
|
||||
let validationResult: ValidationResult | null = $state(null);
|
||||
let validating = $state(false);
|
||||
let validationTimer: ReturnType<typeof setTimeout> | null = $state(null);
|
||||
|
||||
// ── Submit state ──────────────────────────────────────────────────
|
||||
let submitting = $state(false);
|
||||
let submitError = $state('');
|
||||
|
||||
// ── Delete state ──────────────────────────────────────────────────
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────
|
||||
const portNum = $derived(parseInt(port, 10));
|
||||
const portValid = $derived(!isNaN(portNum) && portNum >= 1 && portNum <= 65535);
|
||||
const canSubmit = $derived(
|
||||
destinationUrl.trim().length > 0 &&
|
||||
port.trim().length > 0 &&
|
||||
portValid &&
|
||||
domain.trim().length > 0 &&
|
||||
!submitting
|
||||
);
|
||||
|
||||
const title = $derived(
|
||||
mode === 'create' ? $t('proxies.form.title') : $t('proxies.form.editTitle')
|
||||
);
|
||||
|
||||
const submitLabel = $derived(
|
||||
submitting
|
||||
? (mode === 'create' ? $t('proxies.form.create') : $t('proxies.form.save'))
|
||||
: (mode === 'create' ? $t('proxies.form.create') : $t('proxies.form.save'))
|
||||
);
|
||||
|
||||
// ── Domain auto-suggestion ────────────────────────────────────────
|
||||
function suggestDomain(dest: string): string {
|
||||
if (!dest) return '';
|
||||
try {
|
||||
// If it looks like a URL, parse the hostname
|
||||
const withScheme = dest.includes('://') ? dest : `http://${dest}`;
|
||||
const url = new URL(withScheme);
|
||||
const host = url.hostname;
|
||||
// Strip common prefixes and use as subdomain suggestion
|
||||
const cleaned = host
|
||||
.replace(/^(www|api|app)\./, '')
|
||||
.replace(/\.\w+$/, '')
|
||||
.replace(/[^a-z0-9.-]/gi, '-')
|
||||
.toLowerCase();
|
||||
return cleaned || '';
|
||||
} catch {
|
||||
// If it's a plain IP or hostname, use it directly
|
||||
return dest.replace(/[^a-z0-9.-]/gi, '-').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live validation with debounce ─────────────────────────────────
|
||||
function scheduleValidation(): void {
|
||||
if (validationTimer !== null) {
|
||||
clearTimeout(validationTimer);
|
||||
}
|
||||
validationResult = null;
|
||||
|
||||
if (!destinationUrl.trim() || !port.trim() || !portValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
validationTimer = setTimeout(() => {
|
||||
runValidation();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function runValidation(): Promise<void> {
|
||||
if (!destinationUrl.trim() || !portValid) return;
|
||||
|
||||
validating = true;
|
||||
try {
|
||||
validationResult = await validateProxy(destinationUrl.trim(), portNum);
|
||||
} catch {
|
||||
// Validation is advisory -- don't block the UI
|
||||
validationResult = null;
|
||||
} finally {
|
||||
validating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDestinationInput(): void {
|
||||
// Auto-suggest domain only when creating and domain is empty or was auto-generated
|
||||
if (mode === 'create') {
|
||||
const suggested = suggestDomain(destinationUrl);
|
||||
if (!domain || domain === suggestDomain(destinationUrl.slice(0, -1))) {
|
||||
domain = suggested;
|
||||
}
|
||||
}
|
||||
scheduleValidation();
|
||||
}
|
||||
|
||||
function handlePortInput(): void {
|
||||
scheduleValidation();
|
||||
}
|
||||
|
||||
function handleValidateClick(): void {
|
||||
if (validationTimer !== null) {
|
||||
clearTimeout(validationTimer);
|
||||
}
|
||||
runValidation();
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!canSubmit) return;
|
||||
|
||||
submitting = true;
|
||||
submitError = '';
|
||||
|
||||
const data = {
|
||||
domain: domain.trim(),
|
||||
destination_url: destinationUrl.trim(),
|
||||
destination_port: portNum
|
||||
};
|
||||
|
||||
try {
|
||||
const saved = mode === 'create'
|
||||
? await createProxy(data)
|
||||
: await updateProxy(proxy!.id, data);
|
||||
onsave?.(saved);
|
||||
} catch (err: unknown) {
|
||||
submitError = err instanceof Error ? err.message : 'Unknown error';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────
|
||||
async function handleDeleteConfirm(): Promise<void> {
|
||||
if (!proxy) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteProxy(proxy.id);
|
||||
deleteConfirmOpen = false;
|
||||
ondelete?.(proxy.id);
|
||||
} catch (err: unknown) {
|
||||
submitError = err instanceof Error ? err.message : 'Unknown error';
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
|
||||
<!-- Form fields -->
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<FormField
|
||||
label={$t('proxies.form.destination')}
|
||||
name="destination_url"
|
||||
bind:value={destinationUrl}
|
||||
placeholder="192.168.1.100 or http://my-service"
|
||||
required
|
||||
oninput={handleDestinationInput}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label={$t('proxies.form.port')}
|
||||
name="destination_port"
|
||||
type="number"
|
||||
bind:value={port}
|
||||
placeholder="8080"
|
||||
required
|
||||
error={port && !portValid ? $t('validation.invalidPort') : ''}
|
||||
oninput={handlePortInput}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label={$t('proxies.form.domain')}
|
||||
name="domain"
|
||||
bind:value={domain}
|
||||
placeholder="my-service.example.com"
|
||||
required
|
||||
helpText={$t('proxies.form.domainHelp')}
|
||||
/>
|
||||
|
||||
<!-- Validation checklist -->
|
||||
<div class="space-y-2">
|
||||
<ValidationChecklist result={validationResult} loading={validating} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!destinationUrl.trim() || !portValid || validating}
|
||||
onclick={handleValidateClick}
|
||||
>
|
||||
{#if validating}
|
||||
<IconLoader size={14} />
|
||||
{$t('proxies.form.validating')}
|
||||
{:else}
|
||||
{$t('proxies.form.validate')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Validation warning (non-blocking) -->
|
||||
{#if validationResult && !validationResult.valid}
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400">
|
||||
Validation reported issues but you can still create the proxy.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Submit error -->
|
||||
{#if submitError}
|
||||
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
{#if mode === 'edit'}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-950 transition-colors"
|
||||
onclick={() => { deleteConfirmOpen = true; }}
|
||||
>
|
||||
{$t('proxies.form.delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={() => oncancel?.()}
|
||||
>
|
||||
{$t('proxies.form.cancel')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-brand-600)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{#if submitting}
|
||||
<IconLoader size={14} />
|
||||
{/if}
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
{#if mode === 'edit'}
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
title={$t('proxies.form.delete')}
|
||||
message={$t('proxies.form.deleteConfirm')}
|
||||
confirmLabel={deleting ? $t('common.loading') : $t('proxies.form.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDeleteConfirm}
|
||||
oncancel={() => { deleteConfirmOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,46 +0,0 @@
|
||||
<!--
|
||||
Phase 4: Collapsible group for proxies by project/stage.
|
||||
Shows a header with project name, proxy count, and expandable body.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { IconChevronRight } from '$lib/components/icons';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
count: number;
|
||||
defaultExpanded?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
const { title, count, defaultExpanded = true, children }: Props = $props();
|
||||
|
||||
let expanded = $state(defaultExpanded);
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<!-- Header -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { expanded = !expanded; }}
|
||||
class="flex w-full items-center gap-3 px-5 py-3.5 text-left transition-colors hover:bg-[var(--surface-card-hover)]"
|
||||
>
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
class="shrink-0 text-[var(--text-tertiary)] transition-transform duration-200 {expanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-[var(--text-primary)]">{title}</span>
|
||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Expandable body -->
|
||||
{#if expanded}
|
||||
<div class="border-t border-[var(--border-primary)] p-4 animate-fade-in">
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,16 +1,14 @@
|
||||
<!--
|
||||
Dashboard summary card: container counts, proxy health, recent errors.
|
||||
Dashboard summary card: container counts and recent errors.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Instance, ProxyView, EventLogStats } from '$lib/types';
|
||||
import type { Instance, EventLogStats } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconServer, IconProxies, IconAlert } from '$lib/components/icons';
|
||||
import { IconServer, IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let runningCount = $state(0);
|
||||
let stoppedCount = $state(0);
|
||||
let healthyProxies = $state(0);
|
||||
let unhealthyProxies = $state(0);
|
||||
let recentErrors = $state(0);
|
||||
let loading = $state(true);
|
||||
|
||||
@@ -19,9 +17,8 @@
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [projects, proxies, eventStats] = await Promise.all([
|
||||
const [projects, eventStats] = await Promise.all([
|
||||
api.listProjects(),
|
||||
api.listAllProxies().catch(() => [] as ProxyView[]),
|
||||
api.fetchEventLogStats().catch(() => ({ info: 0, warn: 0, error: 0, total: 0 }) as EventLogStats)
|
||||
]);
|
||||
|
||||
@@ -42,8 +39,6 @@
|
||||
if (!cancelled) {
|
||||
runningCount = allInstances.filter((i) => i.status === 'running').length;
|
||||
stoppedCount = allInstances.filter((i) => i.status !== 'running').length;
|
||||
healthyProxies = proxies.filter((p) => p.health_status === 'healthy').length;
|
||||
unhealthyProxies = proxies.filter((p) => p.health_status === 'unhealthy').length;
|
||||
recentErrors = eventStats.error;
|
||||
loading = false;
|
||||
}
|
||||
@@ -65,10 +60,10 @@
|
||||
{#if !loading}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<!-- Containers -->
|
||||
<a href="/projects" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
|
||||
<IconServer size={18} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -81,26 +76,9 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Proxies -->
|
||||
<a href="/proxies" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg {unhealthyProxies > 0 ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'}">
|
||||
<IconProxies size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)]">{$t('systemHealth.proxies')}</p>
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)]">
|
||||
<span class="text-emerald-600">{healthyProxies}</span>
|
||||
{#if unhealthyProxies > 0}
|
||||
<span class="text-[var(--text-tertiary)]"> / </span>
|
||||
<span class="text-red-600">{unhealthyProxies}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Recent errors -->
|
||||
<a href="/events" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg {recentErrors > 0 ? 'bg-red-50 text-red-600' : 'bg-gray-50 text-gray-400'}">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg {recentErrors > 0 ? 'bg-red-50 dark:bg-red-950/30 text-red-600' : 'bg-gray-50 dark:bg-gray-900/30 text-gray-400'}">
|
||||
<IconAlert size={18} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<!--
|
||||
Phase 6: Validation checklist for proxy destination validation.
|
||||
Shows each validation step with pass/fail/pending status indicators.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ValidationResult } from '$lib/types';
|
||||
import { IconCheck, IconX, IconLoader } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
interface Props {
|
||||
result: ValidationResult | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const { result, loading = false }: Props = $props();
|
||||
|
||||
/** Map step names to i18n keys. */
|
||||
const stepLabelKeys: Record<string, string> = {
|
||||
syntax: 'proxies.validation.syntax',
|
||||
dns: 'proxies.validation.dns',
|
||||
tcp: 'proxies.validation.tcp',
|
||||
http: 'proxies.validation.http'
|
||||
};
|
||||
|
||||
function getStepLabel(name: string): string {
|
||||
const key = stepLabelKeys[name];
|
||||
return key ? $t(key) : name;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading || result}
|
||||
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-4">
|
||||
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">
|
||||
{$t('proxies.validation.title')}
|
||||
</h4>
|
||||
|
||||
{#if loading && !result}
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<IconLoader size={16} />
|
||||
<span>{$t('proxies.validation.checking')}</span>
|
||||
</div>
|
||||
{:else if result}
|
||||
<ul class="space-y-2">
|
||||
{#each result.steps as step}
|
||||
<li>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if step.passed}
|
||||
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950">
|
||||
<IconCheck size={14} class="text-emerald-600 dark:text-emerald-400" />
|
||||
</span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{getStepLabel(step.name)}</span>
|
||||
{#if step.message}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">— {step.message}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-950">
|
||||
<IconX size={14} class="text-red-600 dark:text-red-400" />
|
||||
</span>
|
||||
<span class="text-sm text-[var(--text-primary)]">{getStepLabel(step.name)}</span>
|
||||
{#if step.message}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">— {step.message}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if !step.passed && step.hint}
|
||||
<p class="ml-7 mt-1 text-xs text-amber-600 dark:text-amber-400">{step.hint}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -45,7 +45,6 @@ export { default as IconContainer } from './IconContainer.svelte';
|
||||
export { default as IconHardDrive } from './IconHardDrive.svelte';
|
||||
export { default as IconWifi } from './IconWifi.svelte';
|
||||
export { default as IconRefresh } from './IconRefresh.svelte';
|
||||
export { default as IconProxies } from './IconProxies.svelte';
|
||||
export { default as IconEvents } from './IconEvents.svelte';
|
||||
export { default as IconLogout } from './IconLogout.svelte';
|
||||
export { default as IconArrowLeft } from './IconArrowLeft.svelte';
|
||||
|
||||
@@ -248,7 +248,17 @@
|
||||
"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."
|
||||
"proxyTraefik": "Traefik",
|
||||
"proxyTraefikDesc": "Auto-discovery via Docker labels — no API calls needed",
|
||||
"proxyNoneWarning": "Switching to None does not remove existing proxy routes. You may need to clean them up manually.",
|
||||
"traefikEntrypoint": "Entrypoint",
|
||||
"traefikEntrypointHelp": "Traefik entrypoint name for HTTPS routes",
|
||||
"traefikCertResolver": "Cert Resolver",
|
||||
"traefikCertResolverHelp": "TLS certificate resolver name (e.g., letsencrypt)",
|
||||
"traefikNetwork": "Docker Network",
|
||||
"traefikNetworkHelp": "Network Traefik listens on (leave empty to use global network)",
|
||||
"traefikApiUrl": "Traefik API URL",
|
||||
"traefikApiUrlHelp": "Optional — for health checks (e.g., http://traefik:8080)"
|
||||
},
|
||||
"settingsGeneral": {
|
||||
"title": "General Settings",
|
||||
|
||||
@@ -248,7 +248,17 @@
|
||||
"proxyNoneDesc": "Без прокси — контейнеры доступны напрямую по порту",
|
||||
"proxyNpm": "Nginx Proxy Manager",
|
||||
"proxyNpmDesc": "Маршруты через NPM API (настройте учётные данные ниже)",
|
||||
"proxyNoneWarning": "Переключение на «Нет» не удаляет существующие прокси-маршруты из NPM. Вы можете удалить их вручную в панели NPM."
|
||||
"proxyTraefik": "Traefik",
|
||||
"proxyTraefikDesc": "Автообнаружение через Docker-метки — без API-вызовов",
|
||||
"proxyNoneWarning": "Переключение на «Нет» не удаляет существующие прокси-маршруты. Возможно, потребуется очистить их вручную.",
|
||||
"traefikEntrypoint": "Точка входа",
|
||||
"traefikEntrypointHelp": "Имя точки входа Traefik для HTTPS-маршрутов",
|
||||
"traefikCertResolver": "Резолвер сертификатов",
|
||||
"traefikCertResolverHelp": "Имя резолвера TLS-сертификатов (напр., letsencrypt)",
|
||||
"traefikNetwork": "Docker-сеть",
|
||||
"traefikNetworkHelp": "Сеть, которую слушает Traefik (оставьте пустым для глобальной сети)",
|
||||
"traefikApiUrl": "URL API Traefik",
|
||||
"traefikApiUrlHelp": "Необязательно — для проверки состояния (напр., http://traefik:8080)"
|
||||
},
|
||||
"settingsGeneral": {
|
||||
"title": "Общие настройки",
|
||||
|
||||
+4
-44
@@ -116,6 +116,10 @@ export interface Settings {
|
||||
has_cloudflare_api_token: boolean;
|
||||
cloudflare_zone_id: string;
|
||||
proxy_provider: string;
|
||||
traefik_entrypoint: string;
|
||||
traefik_cert_resolver: string;
|
||||
traefik_network: string;
|
||||
traefik_api_url: string;
|
||||
backup_enabled: boolean;
|
||||
backup_interval_hours: number;
|
||||
backup_retention_count: number;
|
||||
@@ -265,23 +269,6 @@ export interface EventLogStats {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** A standalone reverse proxy not tied to a project. */
|
||||
export interface StandaloneProxy {
|
||||
id: string;
|
||||
domain: string;
|
||||
destination_url: string;
|
||||
destination_port: number;
|
||||
ssl_certificate_id: number;
|
||||
npm_proxy_id: number;
|
||||
health_status: ProxyHealthStatus;
|
||||
health_checked_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Health status for a proxy. */
|
||||
export type ProxyHealthStatus = 'unknown' | 'healthy' | 'unhealthy';
|
||||
|
||||
/** A container detected as stale by the backend poller. */
|
||||
export interface StaleContainer {
|
||||
instance: {
|
||||
@@ -303,20 +290,6 @@ export interface StaleContainer {
|
||||
days_stale: number;
|
||||
}
|
||||
|
||||
/** A single step in the validation pipeline. */
|
||||
export interface ValidationStep {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
message?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** Result of the proxy destination validation pipeline. */
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
steps: ValidationStep[];
|
||||
}
|
||||
|
||||
/** Container CPU and memory stats from the Docker stats API. */
|
||||
export interface ContainerStats {
|
||||
cpu_percent: number;
|
||||
@@ -325,16 +298,3 @@ export interface ContainerStats {
|
||||
memory_percent: number;
|
||||
}
|
||||
|
||||
/** Unified view of standalone + deploy-managed proxies (from /api/proxies/all). */
|
||||
export interface ProxyView {
|
||||
id: string;
|
||||
domain: string;
|
||||
destination: string;
|
||||
type: 'standalone' | 'managed';
|
||||
project_name?: string;
|
||||
stage_name?: string;
|
||||
health_status: ProxyHealthStatus;
|
||||
ssl_enabled: boolean;
|
||||
npm_proxy_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -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