fix: UI/UX consistency overhaul — fix 8 bugs, standardize design system

Bug fixes:
- Backup refresh no longer re-renders entire page (separate refreshing state)
- SSL cert button no longer flickers when no certs available
- Volume mode selector rewritten to use proper scope system (7 scopes)
- Navigation flicker eliminated when returning from env/volumes pages
- Logout button moved to sidebar footer near theme/locale controls
- Subdomain pattern now shows variable hint tooltip ({project}, {stage}, etc.)
- SSL certificate selector moved to Credentials page with auto-save
- Projects page now has search/filter by name, image, or registry

Consistency improvements:
- New Breadcrumb component replaces 5 inline implementations
- New IconArrowLeft, IconChevronDown components replace inline SVGs
- All inline spinners replaced with IconLoader component
- 10 semantic badge classes with dark mode variants in tokens.css
- Global disabled button cursor-not-allowed rule
- Raw inputs in auth page replaced with FormField components
- Missing aria-labels added to icon-only buttons
- Error panels standardized to use design tokens
This commit is contained in:
2026-04-04 21:34:36 +03:00
parent 27ec23921d
commit 216bd7e2db
23 changed files with 494 additions and 251 deletions
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { IconChevronRight } from '$lib/components/icons';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
items: BreadcrumbItem[];
}
const { items }: Props = $props();
</script>
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
{#each items as item, i}
{#if i > 0}
<IconChevronRight size={14} />
{/if}
{#if item.href}
<a href={item.href} class="hover:text-[var(--text-link)] transition-colors">{item.label}</a>
{:else}
<span class="text-[var(--text-secondary)]">{item.label}</span>
{/if}
{/each}
</nav>
@@ -0,0 +1,7 @@
<script lang="ts">
interface Props { size?: number; class?: string; }
const { size = 20, class: c = '' }: Props = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class={c} aria-hidden="true">
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
</svg>
@@ -0,0 +1,7 @@
<script lang="ts">
interface Props { size?: number; class?: string; }
const { size = 20, class: c = '' }: Props = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class={c} aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
+2
View File
@@ -48,3 +48,5 @@ 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';
export { default as IconChevronDown } from './IconChevronDown.svelte';
+9 -1
View File
@@ -58,7 +58,9 @@
"imageLoadFailed": "Failed to load images",
"alreadyAdded": "Already added",
"portHelpText": "Auto-detected from EXPOSE if empty",
"healthcheckHelpText": "Auto-detected from image if empty"
"healthcheckHelpText": "Auto-detected from image if empty",
"searchPlaceholder": "Search projects by name, image, or registry...",
"noMatchingProjects": "No projects match your search."
},
"projectDetail": {
"deleteProject": "Delete Project",
@@ -162,6 +164,7 @@
"save": "Save",
"add": "Add",
"adding": "Adding...",
"scopeGuide": "Volume Scopes",
"noVolumes": "No volumes configured yet. Add one above.",
"volumeAdded": "Volume added",
"volumeUpdated": "Volume updated",
@@ -258,6 +261,11 @@
"dockerNetworkHelp": "Docker network for deployed containers",
"subdomainPattern": "Subdomain Pattern",
"subdomainPatternHelp": "Pattern for auto-generated subdomains",
"subdomainVarsTitle": "Available variables",
"varProject": "Project name",
"varStage": "Stage name",
"varTag": "Image tag",
"varPort": "Container port",
"pollingInterval": "Polling Interval (seconds)",
"pollingIntervalHelp": "How often to check registries for new tags (10-86400)",
"notificationUrl": "Notification URL",
+9 -1
View File
@@ -58,7 +58,9 @@
"imageLoadFailed": "Не удалось загрузить образы",
"alreadyAdded": "Уже добавлен",
"portHelpText": "Автоопределение из EXPOSE, если пусто",
"healthcheckHelpText": "Автоопределение из образа, если пусто"
"healthcheckHelpText": "Автоопределение из образа, если пусто",
"searchPlaceholder": "Поиск по имени, образу или реестру...",
"noMatchingProjects": "Проекты не найдены."
},
"projectDetail": {
"deleteProject": "Удалить проект",
@@ -162,6 +164,7 @@
"save": "Сохранить",
"add": "Добавить",
"adding": "Добавление...",
"scopeGuide": "Области видимости томов",
"noVolumes": "Тома ещё не настроены. Добавьте один выше.",
"volumeAdded": "Том добавлен",
"volumeUpdated": "Том обновлён",
@@ -258,6 +261,11 @@
"dockerNetworkHelp": "Docker-сеть для развёрнутых контейнеров",
"subdomainPattern": "Шаблон поддомена",
"subdomainPatternHelp": "Шаблон для автоматически генерируемых поддоменов",
"subdomainVarsTitle": "Доступные переменные",
"varProject": "Имя проекта",
"varStage": "Имя стадии",
"varTag": "Тег образа",
"varPort": "Порт контейнера",
"pollingInterval": "Интервал опроса (секунды)",
"pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)",
"notificationUrl": "URL уведомлений",
+33
View File
@@ -210,6 +210,13 @@
animation: button-press 150ms ease-in-out;
}
/* ── Disabled Buttons ────────────────────────────────────────────── */
button:disabled,
a[aria-disabled="true"] {
cursor: not-allowed;
}
/* ── Skeleton Loader ──────────────────────────────────────────────── */
.skeleton {
@@ -226,6 +233,32 @@
/* ── Toggle Switch ────────────────────────────────────────────────── */
/* ── Badge Tokens ────────────────────────────────────────────────── */
.badge-success { background: #ecfdf5; color: #047857; }
.badge-warning { background: #fffbeb; color: #b45309; }
.badge-danger { background: #fef2f2; color: #dc2626; }
.badge-info { background: #eff6ff; color: #2563eb; }
.badge-purple { background: #faf5ff; color: #7c3aed; }
.badge-cyan { background: #ecfeff; color: #0e7490; }
.badge-gray { background: #f3f4f6; color: #4b5563; }
.badge-amber { background: #fffbeb; color: #b45309; }
.badge-indigo { background: #eef2ff; color: #4f46e5; }
.badge-rose { background: #fff1f2; color: #e11d48; }
[data-theme="dark"] .badge-success { background: rgba(6, 78, 59, 0.3); color: #34d399; }
[data-theme="dark"] .badge-warning { background: rgba(120, 53, 15, 0.3); color: #fbbf24; }
[data-theme="dark"] .badge-danger { background: rgba(127, 29, 29, 0.3); color: #f87171; }
[data-theme="dark"] .badge-info { background: rgba(30, 58, 138, 0.3); color: #60a5fa; }
[data-theme="dark"] .badge-purple { background: rgba(76, 29, 149, 0.3); color: #a78bfa; }
[data-theme="dark"] .badge-cyan { background: rgba(14, 116, 144, 0.3); color: #22d3ee; }
[data-theme="dark"] .badge-gray { background: rgba(55, 65, 81, 0.5); color: #9ca3af; }
[data-theme="dark"] .badge-amber { background: rgba(120, 53, 15, 0.3); color: #fbbf24; }
[data-theme="dark"] .badge-indigo { background: rgba(67, 56, 202, 0.3); color: #818cf8; }
[data-theme="dark"] .badge-rose { background: rgba(159, 18, 57, 0.3); color: #fb7185; }
/* ── Toggle Switch ────────────────────────────────────────────────── */
.toggle-switch {
position: relative;
width: 2.75rem;
+16 -18
View File
@@ -6,7 +6,7 @@
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, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons';
import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX, IconLogout, IconChevronDown } from '$lib/components/icons';
import { goto } from '$app/navigation';
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { instanceStatusStore } from '$lib/stores/instance-status';
@@ -142,24 +142,9 @@
</div>
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
<!-- Logout button -->
<button
type="button"
title={$t('nav.logout')}
aria-label={$t('nav.logout')}
onclick={async () => {
try { await apiLogout(); } catch { /* best effort */ }
clearAuth();
goto('/login');
}}
class="ml-auto rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconLogout size={18} />
</button>
<!-- Close sidebar (mobile) -->
<button
class="rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
onclick={() => { sidebarOpen = false; }}
aria-label="Close sidebar"
>
@@ -213,7 +198,7 @@
</span>
<span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span>
{#if !dockerConnected && dockerHealth?.error}
<svg class="h-3 w-3 transition-transform {hintsExpanded ? 'rotate-180' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
<IconChevronDown size={12} class="transition-transform {hintsExpanded ? 'rotate-180' : ''}" />
{/if}
</button>
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
@@ -240,6 +225,19 @@
<div class="flex items-center justify-between">
<ThemeToggle />
<LocaleSwitcher />
<button
type="button"
title={$t('nav.logout')}
aria-label={$t('nav.logout')}
onclick={async () => {
try { await apiLogout(); } catch { /* best effort */ }
clearAuth();
goto('/login');
}}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconLogout size={16} />
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p>
</div>
+5 -5
View File
@@ -77,11 +77,11 @@
function statusColor(status: string): string {
switch (status) {
case 'synced': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'missing': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
case 'orphaned': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'wildcard': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
default: return 'bg-gray-100 text-gray-700';
case 'synced': return 'badge-success';
case 'missing': return 'badge-danger';
case 'orphaned': return 'badge-warning';
case 'wildcard': return 'badge-info';
default: return 'badge-gray';
}
}
+3 -8
View File
@@ -11,6 +11,7 @@
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { IconLoader } from '$lib/components/icons';
// ── State ─────────────────────────────────────────────────────
@@ -244,10 +245,7 @@
<!-- Event list -->
{#if loading}
<div class="flex items-center justify-center py-16">
<svg class="h-5 w-5 animate-spin text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
</div>
{:else if filteredEvents.length === 0}
<EmptyState
@@ -279,10 +277,7 @@
disabled={loadingMore}
>
{#if loadingMore}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<IconLoader size={16} class="animate-spin" />
{/if}
{$t('events.loadMore')}
</button>
+30 -1
View File
@@ -12,6 +12,18 @@
let loading = $state(true);
let error = $state('');
let showAddForm = $state(false);
let searchQuery = $state('');
const filteredProjects = $derived(
searchQuery.trim()
? projects.filter(p => {
const q = searchQuery.toLowerCase();
return p.name.toLowerCase().includes(q)
|| p.image.toLowerCase().includes(q)
|| (p.registry ?? '').toLowerCase().includes(q);
})
: projects
);
let formName = $state('');
let formImage = $state('');
@@ -220,6 +232,22 @@
icon="projects"
/>
{:else}
<!-- Search filter -->
<div class="relative">
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
type="text"
bind:value={searchQuery}
placeholder={$t('projects.searchPlaceholder')}
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 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>
{#if filteredProjects.length === 0}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
<p class="text-sm text-[var(--text-tertiary)]">{$t('projects.noMatchingProjects')}</p>
</div>
{:else}
<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)]">
@@ -233,7 +261,7 @@
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each projects as project (project.id)}
{#each filteredProjects as project (project.id)}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
<td class="whitespace-nowrap px-6 py-4">
<a href="/projects/{project.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
@@ -262,5 +290,6 @@
</tbody>
</table>
</div>
{/if}
{/if}
</div>
+7 -9
View File
@@ -7,7 +7,8 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconChevronRight, IconClock, IconTag, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import { toasts } from '$lib/stores/toast';
@@ -111,7 +112,7 @@
const projectId = $derived($page.params.id);
async function loadProject() {
loading = true;
if (!project) loading = true;
error = '';
try {
const detail = await api.getProject(projectId);
@@ -229,10 +230,7 @@
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
<a href="/projects" class="hover:text-[var(--text-link)] transition-colors">{$t('projects.title')}</a>
<IconChevronRight size={14} />
</div>
<Breadcrumb items={[{ label: $t('projects.title'), href: '/projects' }]} />
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{project.name}</h1>
<p class="mt-1 font-mono text-sm text-[var(--text-tertiary)]">{project.image}</p>
</div>
@@ -385,13 +383,13 @@
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
{#if stage.auto_deploy}
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">{$t('projectDetail.autoDeploy')}</span>
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
{/if}
{#if stage.confirm}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('projectDetail.requiresConfirm')}</span>
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
{/if}
{#if !stage.enable_proxy}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">{$t('projectDetail.noProxy')}</span>
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
{/if}
</div>
<div class="flex items-center gap-3">
+9 -11
View File
@@ -4,7 +4,8 @@
import * as api from '$lib/api';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
@@ -33,7 +34,7 @@
const projectId = $derived($page.params.id);
async function loadProject() {
loading = true;
if (stages.length === 0) loading = true;
error = '';
try {
const detail = await api.getProject(projectId);
@@ -153,10 +154,7 @@
<div class="space-y-6">
<!-- Header -->
<div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
<IconChevronRight size={14} />
</div>
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('envEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('envEditor.description')}</p>
</div>
@@ -208,9 +206,9 @@
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{value}</td>
<td class="px-4 py-2.5 text-sm">
{#if isOverridden(key)}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('envEditor.overridden')}</span>
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridden')}</span>
{:else}
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">{$t('envEditor.inherited')}</span>
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span>
{/if}
</td>
</tr>
@@ -275,7 +273,7 @@
</td>
<td class="px-4 py-2.5">
{#if env.encrypted}
<span class="inline-flex items-center gap-1 rounded-full bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700">
<span class="inline-flex items-center gap-1 rounded-full badge-purple rounded-full px-2 py-0.5 text-xs font-medium">
<IconLock size={12} />
{$t('envEditor.secret')}
</span>
@@ -283,9 +281,9 @@
</td>
<td class="px-4 py-2.5 text-sm">
{#if env.key in projectEnv}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('envEditor.overridesProject')}</span>
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridesProject')}</span>
{:else}
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('envEditor.stageOnly')}</span>
<span class="rounded-full badge-info rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.stageOnly')}</span>
{/if}
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right">
+128 -38
View File
@@ -1,37 +1,67 @@
<script lang="ts">
import { page } from '$app/stores';
import type { Volume } from '$lib/types';
import type { Volume, VolumeScopeInfo, VolumeScope } from '$lib/types';
import * as api from '$lib/api';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons';
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconInfo } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let volumes = $state<Volume[]>([]);
let scopes = $state<VolumeScopeInfo[]>([]);
let loading = $state(true);
let error = $state('');
let newSource = $state('');
let newTarget = $state('');
let newMode = $state<'shared' | 'isolated'>('shared');
let newScope = $state<VolumeScope>('project');
let newName = $state('');
let saving = $state(false);
let editingId = $state('');
let editSource = $state('');
let editTarget = $state('');
let editMode = $state<'shared' | 'isolated'>('shared');
let editScope = $state<VolumeScope>('project');
let editName = $state('');
let volumeDeleteTarget = $state<string | null>(null);
const projectId = $derived($page.params.id);
const newScopeNeedsName = $derived(scopes.find(s => s.scope === newScope)?.needs_name ?? false);
const editScopeNeedsName = $derived(scopes.find(s => s.scope === editScope)?.needs_name ?? false);
const newScopeIsEphemeral = $derived(newScope === 'ephemeral');
const editScopeIsEphemeral = $derived(editScope === 'ephemeral');
function scopeColor(scope: string): string {
switch (scope) {
case 'instance': return 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'stage': return 'bg-cyan-50 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400';
case 'project': return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
case 'project_named': return 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400';
case 'named': return 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
case 'ephemeral': return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
case 'absolute': return 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400';
default: return 'bg-gray-100 text-gray-600';
}
}
function scopeLabel(scope: string): string {
return scope.replaceAll('_', ' ');
}
async function loadVolumes() {
loading = true;
if (volumes.length === 0) loading = true;
error = '';
try {
volumes = await api.listVolumes(projectId);
const [vols, scopeList] = await Promise.all([
api.listVolumes(projectId),
scopes.length === 0 ? api.listVolumeScopes() : Promise.resolve(scopes)
]);
volumes = vols;
scopes = scopeList;
} catch (e) {
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
} finally {
@@ -40,13 +70,21 @@
}
async function handleAdd() {
if (!newSource.trim() || !newTarget.trim()) return;
if (newScope !== 'ephemeral' && !newSource.trim()) return;
if (!newTarget.trim()) return;
if (newScopeNeedsName && !newName.trim()) return;
saving = true;
try {
await api.createVolume(projectId, { source: newSource.trim(), target: newTarget.trim(), mode: newMode });
await api.createVolume(projectId, {
source: newSource.trim(),
target: newTarget.trim(),
scope: newScope,
name: newScopeNeedsName ? newName.trim() : undefined
});
newSource = '';
newTarget = '';
newMode = 'shared';
newScope = 'project';
newName = '';
toasts.success($t('volumeEditor.volumeAdded'));
await loadVolumes();
} catch (e) {
@@ -60,16 +98,24 @@
editingId = vol.id;
editSource = vol.source;
editTarget = vol.target;
editMode = vol.mode;
editScope = (vol.scope || 'project') as VolumeScope;
editName = vol.name || '';
}
function cancelEdit() { editingId = ''; }
async function handleUpdate() {
if (!editSource.trim() || !editTarget.trim()) return;
if (editScope !== 'ephemeral' && !editSource.trim()) return;
if (!editTarget.trim()) return;
if (editScopeNeedsName && !editName.trim()) return;
saving = true;
try {
await api.updateVolume(projectId, editingId, { source: editSource.trim(), target: editTarget.trim(), mode: editMode });
await api.updateVolume(projectId, editingId, {
source: editSource.trim(),
target: editTarget.trim(),
scope: editScope,
name: editScopeNeedsName ? editName.trim() : undefined
});
editingId = '';
toasts.success($t('volumeEditor.volumeUpdated'));
await loadVolumes();
@@ -103,18 +149,32 @@
<div class="space-y-6">
<!-- Header -->
<div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
<IconChevronRight size={14} />
</div>
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">
{$t('volumeEditor.description')}
<strong>{$t('volumeEditor.shared')}</strong>{$t('volumeEditor.sharedDesc')}
<strong>{$t('volumeEditor.isolated')}</strong>{$t('volumeEditor.isolatedDesc')}
</p>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p>
</div>
<!-- Scope legend -->
{#if scopes.length > 0 && !loading}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
<div class="flex items-center gap-2 mb-3">
<IconInfo size={16} class="text-[var(--text-tertiary)]" />
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('volumeEditor.scopeGuide')}</h3>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each scopes as scope}
<div class="flex items-start gap-2 rounded-lg bg-[var(--surface-card-hover)] px-3 py-2">
<span class="mt-0.5 inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(scope.scope)}">{scopeLabel(scope.scope)}</span>
<div class="min-w-0">
<p class="text-xs text-[var(--text-secondary)]">{scope.description}</p>
<p class="mt-0.5 font-mono text-[10px] text-[var(--text-tertiary)]">{scope.path_example}</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if loading}
<div class="space-y-4">
<Skeleton height="12rem" />
@@ -133,7 +193,8 @@
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.mode')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.scope')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.nameColumn')}</th>
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
</tr>
</thead>
@@ -142,17 +203,29 @@
{#if editingId === vol.id}
<tr class="bg-[var(--color-brand-50)]/30">
<td class="px-4 py-2.5">
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{#if editScopeIsEphemeral}
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
{:else}
<input type="text" bind:value={editSource} placeholder={editScope === 'absolute' ? '/mnt/data' : 'uploads'} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{/if}
</td>
<td class="px-4 py-2.5">
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2.5">
<select bind:value={editMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="shared">{$t('volumeEditor.shared')}</option>
<option value="isolated">{$t('volumeEditor.isolated')}</option>
<select bind:value={editScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
{#each scopes as s}
<option value={s.scope}>{scopeLabel(s.scope)}</option>
{/each}
</select>
</td>
<td class="px-4 py-2.5">
{#if editScopeNeedsName}
<input type="text" bind:value={editName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{:else}
<span class="text-xs text-[var(--text-tertiary)]"></span>
{/if}
</td>
<td class="px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
@@ -162,14 +235,19 @@
</tr>
{:else}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{vol.source}</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">
{#if vol.scope === 'ephemeral'}
<span class="italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
{:else}
{vol.source}
{/if}
</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
<td class="px-4 py-2.5">
{#if vol.mode === 'shared'}
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('volumeEditor.shared')}</span>
{:else}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('volumeEditor.isolated')}</span>
{/if}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope)}">{scopeLabel(vol.scope)}</span>
</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
{vol.name || '—'}
</td>
<td class="whitespace-nowrap px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
@@ -184,22 +262,34 @@
<!-- Add new row -->
<tr class="bg-[var(--surface-card-hover)]">
<td class="px-4 py-2.5">
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{#if newScopeIsEphemeral}
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
{:else}
<input type="text" bind:value={newSource} placeholder={newScope === 'absolute' ? '/mnt/nfs/data' : 'uploads'} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{/if}
</td>
<td class="px-4 py-2.5">
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2.5">
<select bind:value={newMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="shared">{$t('volumeEditor.shared')}</option>
<option value="isolated">{$t('volumeEditor.isolated')}</option>
<select bind:value={newScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
{#each scopes as s}
<option value={s.scope}>{scopeLabel(s.scope)}</option>
{/each}
</select>
</td>
<td class="px-4 py-2.5">
{#if newScopeNeedsName}
<input type="text" bind:value={newName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{:else}
<span class="text-xs text-[var(--text-tertiary)]"></span>
{/if}
</td>
<td class="px-4 py-2.5 text-right">
<button
type="button"
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
disabled={!newSource.trim() || !newTarget.trim() || saving}
disabled={(!newScopeIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newScopeNeedsName && !newName.trim()) || saving}
onclick={handleAdd}
>
<IconPlus size={14} />
@@ -4,7 +4,8 @@
import * as api from '$lib/api';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconChevronRight, IconLoader } from '$lib/components/icons';
import { IconLoader, IconChevronRight } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
const projectId = $derived($page.params.id ?? '');
@@ -124,12 +125,10 @@
<div class="space-y-4">
<!-- Header -->
<div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
<IconChevronRight size={14} />
<a href="/projects/{projectId}/volumes" class="hover:text-[var(--text-link)] transition-colors">{$t('volumeEditor.title')}</a>
<IconChevronRight size={14} />
</div>
<Breadcrumb items={[
{ label: $t('common.project'), href: `/projects/${projectId}` },
{ label: $t('volumeEditor.title'), href: `/projects/${projectId}/volumes` }
]} />
<div class="mt-1 flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('volumeBrowser.title')}</h1>
<div class="flex items-center gap-2">
+5 -12
View File
@@ -11,7 +11,7 @@
import ProxyGroup from '$lib/components/ProxyGroup.svelte';
import ProxyFilter from '$lib/components/ProxyFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { IconGlobe, IconLoader } from '$lib/components/icons';
import { IconGlobe, IconLoader, IconPlus } from '$lib/components/icons';
let proxies = $state<ProxyView[]>([]);
let loading = $state(true);
@@ -144,9 +144,7 @@
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"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
<IconPlus size={16} />
{$t('proxies.create')}
</a>
</div>
@@ -158,14 +156,9 @@
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
</div>
{:else if error}
<!-- Error state -->
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900 dark:bg-red-950">
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
<button
type="button"
onclick={loadProxies}
class="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
>
<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>
+5 -10
View File
@@ -9,7 +9,7 @@
import { getProxy } from '$lib/api';
import { t } from '$lib/i18n';
import ProxyForm from '$lib/components/ProxyForm.svelte';
import { IconGlobe, IconLoader } from '$lib/components/icons';
import { IconGlobe, IconLoader, IconArrowLeft } from '$lib/components/icons';
let proxy: StandaloneProxy | null = $state(null);
let loading = $state(true);
@@ -50,9 +50,7 @@
href="/proxies"
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
</svg>
<IconArrowLeft size={16} />
{$t('common.back')}
</a>
</div>
@@ -71,12 +69,9 @@
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
</div>
{:else if error}
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900 dark:bg-red-950">
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
<a
href="/proxies"
class="mt-3 inline-block rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
>
<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>
+2 -4
View File
@@ -6,7 +6,7 @@
import type { StandaloneProxy } from '$lib/types';
import { t } from '$lib/i18n';
import ProxyForm from '$lib/components/ProxyForm.svelte';
import { IconGlobe } from '$lib/components/icons';
import { IconGlobe, IconArrowLeft } from '$lib/components/icons';
function handleSave(_proxy: StandaloneProxy): void {
goto('/proxies');
@@ -27,9 +27,7 @@
href="/proxies"
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
</svg>
<IconArrowLeft size={16} />
{$t('common.back')}
</a>
</div>
+31 -103
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, listNpmCertificates, testDnsConnection, listDnsZones } from '$lib/api';
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, testDnsConnection, listDnsZones } from '$lib/api';
import type { EntityPickerItem } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconLoader, IconCopy, IconRefresh, IconShield, IconX } from '$lib/components/icons';
import { IconLoader, IconCopy, IconRefresh, IconX, IconInfo } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte';
let loading = $state(true);
@@ -22,11 +22,6 @@
let notificationUrl = $state('');
let staleThresholdDays = $state('7');
let sslCertificateId = $state(0);
let sslCertName = $state('');
let certPickerOpen = $state(false);
let certPickerItems = $state<EntityPickerItem[]>([]);
let loadingCerts = $state(false);
// Proxy provider state.
let proxyProvider = $state('npm');
@@ -93,7 +88,6 @@
subdomainPattern = settings.subdomain_pattern ?? '';
pollingInterval = settings.polling_interval ?? '';
baseVolumePath = settings.base_volume_path ?? '';
sslCertificateId = settings.ssl_certificate_id ?? 0;
notificationUrl = settings.notification_url ?? '';
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
proxyProvider = settings.proxy_provider ?? 'npm';
@@ -124,7 +118,6 @@
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
proxy_provider: proxyProvider,
ssl_certificate_id: proxyProvider === 'npm' ? sslCertificateId : 0,
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
wildcard_dns: wildcardDns,
dns_provider: wildcardDns ? '' : dnsProvider,
@@ -155,52 +148,6 @@
}
}
async function openCertPicker() {
loadingCerts = true;
certPickerOpen = true;
try {
const certs = await listNpmCertificates();
certPickerItems = certs.map((cert): EntityPickerItem => ({
value: String(cert.id),
label: cert.nice_name || `Certificate #${cert.id}`,
description: cert.domain_names.join(', ')
}));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noCertificatesFound'));
certPickerOpen = false;
} finally {
loadingCerts = false;
}
}
function handleCertSelect(value: string) {
const id = parseInt(value, 10);
sslCertificateId = id;
const item = certPickerItems.find((i) => i.value === value);
sslCertName = item?.label ?? '';
certPickerOpen = false;
}
function clearCertificate() {
sslCertificateId = 0;
sslCertName = '';
}
// When loading settings, try to resolve cert name if an ID is set.
async function resolveCertName() {
if (sslCertificateId <= 0) return;
try {
const certs = await listNpmCertificates();
const match = certs.find((c) => c.id === sslCertificateId);
if (match) {
sslCertName = match.nice_name || `Certificate #${match.id}`;
} else {
sslCertName = `Certificate #${sslCertificateId}`;
}
} catch {
sslCertName = `Certificate #${sslCertificateId}`;
}
}
async function openZonePicker() {
loadingZones = true;
@@ -262,7 +209,6 @@
async function init() {
await loadSettings();
await resolveCertName();
if (!wildcardDns && cloudflareZoneId) {
resolveZoneName();
}
@@ -293,7 +239,34 @@
<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.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')} />
<FormField label={$t('settingsGeneral.subdomainPattern')} name="subdomainPattern" bind:value={subdomainPattern} placeholder="stage-{'{stage}'}-{'{project}'}" helpText={$t('settingsGeneral.subdomainPatternHelp')} />
<div>
<div class="flex items-center gap-1.5">
<label for="subdomainPattern" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.subdomainPattern')}</label>
<div class="group relative">
<button type="button" class="inline-flex items-center justify-center rounded-full text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors" aria-label={$t('settingsGeneral.subdomainVarsTitle')}>
<IconInfo size={14} />
</button>
<div class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-64 -translate-x-1/2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3 shadow-lg opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
<p class="mb-2 text-xs font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.subdomainVarsTitle')}</p>
<ul class="space-y-1 text-xs text-[var(--text-secondary)]">
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{project}'}</code>{$t('settingsGeneral.varProject')}</li>
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{stage}'}</code>{$t('settingsGeneral.varStage')}</li>
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{tag}'}</code>{$t('settingsGeneral.varTag')}</li>
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{port}'}</code>{$t('settingsGeneral.varPort')}</li>
</ul>
<div class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-[var(--border-primary)]"></div>
</div>
</div>
</div>
<input
id="subdomainPattern"
type="text"
bind:value={subdomainPattern}
placeholder="stage-{'{stage}'}-{'{project}'}"
class="mt-1.5 block w-full 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)]"
/>
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.subdomainPatternHelp')}</p>
</div>
<FormField label={$t('settingsGeneral.pollingInterval')} name="pollingInterval" type="number" bind:value={pollingInterval} placeholder="60" error={errors.pollingInterval ?? ''} helpText={$t('settingsGeneral.pollingIntervalHelp')} />
<FormField label={$t('settingsGeneral.baseVolumePath')} name="baseVolumePath" bind:value={baseVolumePath} placeholder="/data" helpText={$t('settingsGeneral.baseVolumePathHelp')} />
<FormField label={$t('settingsGeneral.notificationUrl')} name="notificationUrl" bind:value={notificationUrl} placeholder="https://notify.example.com/webhook" error={errors.notificationUrl ?? ''} helpText={$t('settingsGeneral.notificationUrlHelp')} />
@@ -325,43 +298,7 @@
{/if}
</div>
<!-- SSL Certificate (NPM only) -->
{#if proxyProvider === 'npm'}
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<div class="flex items-start gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.sslCertificate')}</label>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.sslCertificateHelp')}</p>
<div class="mt-2 flex items-center gap-2">
<button
type="button"
onclick={openCertPicker}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconShield size={16} />
{#if loadingCerts}
{$t('settingsGeneral.loadingCertificates')}
{:else if sslCertificateId > 0 && sslCertName}
{sslCertName}
{:else}
{$t('settingsGeneral.noCertificate')}
{/if}
</button>
{#if sslCertificateId > 0}
<button
type="button"
onclick={clearCertificate}
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
title={$t('settingsGeneral.clearCertificate')}
>
<IconX size={14} />
</button>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- SSL Certificate moved to Credentials page -->
<!-- Stale Detection -->
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
@@ -511,15 +448,6 @@
{/if}
</div>
<EntityPicker
bind:open={certPickerOpen}
items={certPickerItems}
current={String(sslCertificateId)}
title={$t('settingsGeneral.selectCertificate')}
onselect={handleCertSelect}
onclose={() => { certPickerOpen = false; }}
/>
<EntityPicker
bind:open={zonePickerOpen}
items={zonePickerItems}
+12 -8
View File
@@ -3,6 +3,7 @@
import { t } from '$lib/i18n';
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
import EmptyState from '$lib/components/EmptyState.svelte';
import FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import {
getAuthSettings,
@@ -197,7 +198,7 @@
</td>
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
<td class="px-4 py-2.5 text-right">
<button onclick={() => handleDeleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors">
<button onclick={() => handleDeleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}>
<IconTrash size={16} />
</button>
</td>
@@ -215,13 +216,16 @@
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<h4 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsAuth.addUser')}</h4>
<div class="mt-3 grid grid-cols-2 gap-3">
<input type="text" bind:value={newUsername} placeholder={$t('settingsAuth.username')} class="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-2 focus:ring-[var(--color-brand-500)]" />
<input type="password" bind:value={newPassword} placeholder={$t('settingsAuth.password')} class="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-2 focus:ring-[var(--color-brand-500)]" />
<input type="email" bind:value={newEmail} placeholder="{$t('settingsAuth.email')} (optional)" class="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-2 focus:ring-[var(--color-brand-500)]" />
<select bind:value={newRole} 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:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]">
<option value="viewer">{$t('settingsAuth.viewer')}</option>
<option value="admin">{$t('settingsAuth.admin')}</option>
</select>
<FormField label={$t('settingsAuth.username')} name="newUsername" bind:value={newUsername} placeholder={$t('settingsAuth.username')} required />
<FormField label={$t('settingsAuth.password')} name="newPassword" type="password" bind:value={newPassword} placeholder={$t('settingsAuth.password')} required />
<FormField label={$t('settingsAuth.email')} name="newEmail" type="email" bind:value={newEmail} placeholder={$t('settingsAuth.email')} />
<div>
<label for="newRole" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsAuth.role')}</label>
<select id="newRole" bind:value={newRole} class="mt-1.5 block w-full 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:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]">
<option value="viewer">{$t('settingsAuth.viewer')}</option>
<option value="admin">{$t('settingsAuth.admin')}</option>
</select>
</div>
</div>
<button onclick={addUser} class="mt-3 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)] transition-colors active:animate-press">
<IconPlus size={16} />
+18 -9
View File
@@ -13,6 +13,7 @@
let loading = $state(true);
let saving = $state(false);
let creatingBackup = $state(false);
let refreshing = $state(false);
let backupEnabled = $state(false);
let backupIntervalHours = $state('24');
@@ -22,8 +23,12 @@
let confirmDeleteId = $state('');
let confirmRestoreId = $state('');
async function loadData() {
loading = true;
async function loadData(refresh = false) {
if (refresh) {
refreshing = true;
} else {
loading = true;
}
try {
const [settings, backupList] = await Promise.all([
getSettings(),
@@ -37,6 +42,7 @@
toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings');
} finally {
loading = false;
refreshing = false;
}
}
@@ -193,9 +199,13 @@
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-primary)]">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsBackup.backupList')}</h2>
<button onclick={() => loadData()}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconRefresh size={14} />
<button onclick={() => loadData(true)} disabled={refreshing}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50">
{#if refreshing}
<IconLoader size={14} class="animate-spin" />
{:else}
<IconRefresh size={14} />
{/if}
</button>
</div>
@@ -222,9 +232,7 @@
<td class="px-4 py-3 text-[var(--text-secondary)]">{formatSize(backup.size_bytes)}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{backup.backup_type === 'auto'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'}">
{backup.backup_type === 'auto' ? 'badge-info' : 'badge-success'}">
{backup.backup_type === 'auto' ? $t('settingsBackup.typeAuto') : $t('settingsBackup.typeManual')}
</span>
</td>
@@ -240,7 +248,8 @@
{$t('settingsBackup.restore')}
</button>
<button onclick={() => { confirmDeleteId = backup.id; }}
class="rounded-lg px-2 py-1 text-xs text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors">
class="rounded-lg px-2 py-1 text-xs text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors"
title={$t('settingsBackup.delete')} aria-label={$t('settingsBackup.delete')}>
<IconTrash size={14} />
</button>
</div>
@@ -1,10 +1,12 @@
<script lang="ts">
import { getSettings, updateSettings } from '$lib/api';
import { getSettings, updateSettings, listNpmCertificates } 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 } from '$lib/components/icons';
import { IconLoader, IconCheck, IconEdit, IconShield, IconX } from '$lib/components/icons';
let loading = $state(true);
let saving = $state(false);
@@ -16,6 +18,13 @@
let editingNpm = $state(false);
let errors = $state<Record<string, string>>({});
// SSL certificate state
let sslCertificateId = $state(0);
let sslCertName = $state('');
let certPickerOpen = $state(false);
let certPickerItems = $state<EntityPickerItem[]>([]);
let loadingCerts = $state(false);
function validateNpmForm(): boolean {
const newErrors: Record<string, string> = {};
if (!npmUrl.trim()) { newErrors.npmUrl = $t('validation.required', { field: 'NPM URL' }); } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = $t('validation.invalidUrl'); } }
@@ -34,6 +43,7 @@
npmEmail = settings.npm_email ?? '';
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
npmPassword = '';
sslCertificateId = settings.ssl_certificate_id ?? 0;
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
}
@@ -41,7 +51,7 @@
if (!validateNpmForm()) return;
saving = true;
try {
const payload: Record<string, string> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
await updateSettings(payload);
npmHasCredentials = true;
@@ -51,7 +61,67 @@
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); } finally { saving = false; }
}
$effect(() => { loadCredentials(); });
async function openCertPicker() {
loadingCerts = true;
try {
const certs = await listNpmCertificates();
if (certs.length === 0) {
toasts.info($t('settingsGeneral.noCertificatesFound'));
return;
}
certPickerItems = certs.map((cert): EntityPickerItem => ({
value: String(cert.id),
label: cert.nice_name || `Certificate #${cert.id}`,
description: cert.domain_names.join(', ')
}));
certPickerOpen = true;
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noCertificatesFound'));
} finally {
loadingCerts = false;
}
}
async function saveCertificate(id: number) {
try {
await updateSettings({ ssl_certificate_id: id });
toasts.success($t('settingsCredentials.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed'));
}
}
function handleCertSelect(value: string) {
sslCertificateId = parseInt(value, 10);
const item = certPickerItems.find((i) => i.value === value);
sslCertName = item?.label ?? '';
certPickerOpen = false;
saveCertificate(sslCertificateId);
}
function clearCertificate() {
sslCertificateId = 0;
sslCertName = '';
saveCertificate(0);
}
async function resolveCertName() {
if (sslCertificateId <= 0) return;
try {
const certs = await listNpmCertificates();
const match = certs.find((c) => c.id === sslCertificateId);
sslCertName = match ? (match.nice_name || `Certificate #${match.id}`) : `Certificate #${sslCertificateId}`;
} catch {
sslCertName = `Certificate #${sslCertificateId}`;
}
}
async function init() {
await loadCredentials();
await resolveCertName();
}
$effect(() => { init(); });
</script>
<svelte:head>
@@ -68,6 +138,7 @@
<div class="space-y-4"><Skeleton height="12rem" /></div>
{:else}
{#if proxyProvider === 'npm'}
<!-- NPM Credentials -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="flex items-center justify-between mb-4">
<div>
@@ -116,6 +187,43 @@
</div>
{/if}
</div>
<!-- SSL Certificate -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="flex items-start gap-3">
<div class="flex-1">
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.sslCertificate')}</h3>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.sslCertificateHelp')}</p>
<div class="mt-3 flex items-center gap-2">
<button
type="button"
onclick={openCertPicker}
disabled={loadingCerts}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
<IconShield size={16} />
{#if loadingCerts}
{$t('settingsGeneral.loadingCertificates')}
{:else if sslCertificateId > 0 && sslCertName}
{sslCertName}
{:else}
{$t('settingsGeneral.noCertificate')}
{/if}
</button>
{#if sslCertificateId > 0}
<button
type="button"
onclick={clearCertificate}
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
title={$t('settingsGeneral.clearCertificate')}
>
<IconX size={14} />
</button>
{/if}
</div>
</div>
</div>
</div>
{/if}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
@@ -128,3 +236,12 @@
</div>
{/if}
</div>
<EntityPicker
bind:open={certPickerOpen}
items={certPickerItems}
current={String(sslCertificateId)}
title={$t('settingsGeneral.selectCertificate')}
onselect={handleCertSelect}
onclose={() => { certPickerOpen = false; }}
/>
@@ -175,8 +175,8 @@
{#if testingId === registry.id}<IconLoader size={14} />{:else}<IconWifi size={14} />{/if}
{testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')}
</button>
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"><IconEdit size={16} /></button>
<button onclick={() => { registryDeleteTarget = registry; }} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"><IconTrash size={16} /></button>
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" title={$t('common.edit')} aria-label={$t('common.edit')}><IconEdit size={16} /></button>
<button onclick={() => { registryDeleteTarget = registry; }} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
</div>
</div>
{/each}