feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s
Build / build (push) Successful in 10m39s
The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.
Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
internal/stack/manager.go gone (the rest of those packages stay as
helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
table (projects, stages, stage_env, volumes, deploys, deploy_logs,
poll_states, stacks, stack_revisions, stack_deploys, static_sites,
static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
so api + store paths share one secret-generation impl (no
panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
+ static-site label paths; only canonical tinyforge.workload.id
dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
private (no external callers)
Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
helper + types (Project, Stage, Stack, StaticSite, Deploy,
Instance, Volume, etc.); kept Workload, Container, App, Settings,
Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
/deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
listWorkloads + listContainers only; 4-card stat grid
(workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
proxies/+page.svelte, containers/+page.svelte all rewired to the
workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
instance.*, confirm.* namespaces; en/ru parity preserved (1042
keys each)
Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):
- Sec H1: dead-end workload webhook URL handlers (would mint URLs
that 404 the new trigger-only ingress) deleted across backend +
frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
field names, workloadIDRow rationale, webhook_deliveries.target_type
enum, WebhookDeliveryLog component header
Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
items are now shipped. Next focus is Priority 3 polish (apps.* i18n
+ codemap entries) and Priority 4 tests.
Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
/api/webhook/sites/{secret} return 404; CI configs must repoint to
/api/webhook/triggers/{secret} (the trigger-split boot backfill
lifted any embedded workload secret onto a Trigger row, so the
secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
links replaced with /apps and /triggers.
This commit is contained in:
@@ -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, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox, IconContainer } from '$lib/components/icons';
|
||||
import { IconDashboard, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconBox, IconContainer } from '$lib/components/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
const { children }: Props = $props();
|
||||
|
||||
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'containers' | 'eventsErrors';
|
||||
type NavCountKey = 'apps' | 'workloads' | 'proxies' | 'containers' | 'eventsErrors';
|
||||
|
||||
const navItems: ReadonlyArray<{
|
||||
href: string;
|
||||
@@ -36,12 +36,8 @@
|
||||
labelOverride?: string;
|
||||
}> = [
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ href: '/apps', labelKey: 'nav.apps', icon: 'box' },
|
||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
|
||||
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
|
||||
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
|
||||
{ href: '/apps', labelKey: 'nav.apps', icon: 'box', countKey: 'apps' },
|
||||
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
|
||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
||||
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
|
||||
@@ -76,7 +72,7 @@
|
||||
const clockTitle = $derived(`${$effectiveTimezone.replace(/_/g, ' ')} · ${clockOffset}`);
|
||||
|
||||
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
|
||||
// g+d → dashboard, g+p → projects, g+s → sites, g+k → stacks, g+x → deploy,
|
||||
// g+d → dashboard, g+a → apps, g+n → containers, g+t → triggers,
|
||||
// g+r → proxies, g+e → events, g+c → settings
|
||||
let gPressedAt = 0;
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -91,8 +87,8 @@
|
||||
}
|
||||
if (Date.now() - gPressedAt > 1200) return;
|
||||
const map: Record<string, string> = {
|
||||
d: '/', p: '/projects', s: '/sites', k: '/stacks',
|
||||
x: '/deploy', r: '/proxies', e: '/events', c: '/settings'
|
||||
d: '/', a: '/apps', n: '/containers', t: '/triggers',
|
||||
r: '/proxies', e: '/events', c: '/settings'
|
||||
};
|
||||
const dest = map[e.key.toLowerCase()];
|
||||
if (dest) {
|
||||
@@ -282,14 +278,8 @@
|
||||
>
|
||||
{#if item.icon === 'dashboard'}
|
||||
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'projects'}
|
||||
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'box'}
|
||||
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'globe'}
|
||||
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'stacks'}
|
||||
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'containers'}
|
||||
<IconContainer size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'deploy'}
|
||||
@@ -349,7 +339,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
||||
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>p</kbd><kbd>s</kbd><kbd>k</kbd>
|
||||
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>a</kbd><kbd>n</kbd><kbd>t</kbd>
|
||||
<span class="hint-label">quick-nav</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+92
-161
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Project, Instance, StaleContainer, StaticSite } from '$lib/types';
|
||||
// Workload-first dashboard. Replaces the legacy project / site
|
||||
// summaries with a single workload count grouped by source_kind and
|
||||
// a running-container tally pulled from /api/containers.
|
||||
//
|
||||
// We no longer fan out N+1 fetches per project to gather instance
|
||||
// status — the global containers index already carries the workload
|
||||
// reference and state.
|
||||
import type { ContainerView, StaleContainer, Workload } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||
@@ -9,17 +15,17 @@
|
||||
import SystemResourcesCard from '$lib/components/SystemResourcesCard.svelte';
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import { IconBox, IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
||||
let workloads = $state<Workload[]>([]);
|
||||
let containers = $state<ContainerView[]>([]);
|
||||
let staleContainers = $state<StaleContainer[]>([]);
|
||||
let unusedImagesMB = $state(0);
|
||||
let unusedImagesCount = $state(0);
|
||||
let unusedImagesExceeded = $state(false);
|
||||
let sites = $state<StaticSite[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let loadController: AbortController | null = null;
|
||||
@@ -33,35 +39,17 @@
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.listProjects(signal);
|
||||
|
||||
// Fetch project details sequentially to avoid exhausting
|
||||
// browser connection pool (HTTP/1.1 allows only 6 per host).
|
||||
const results: { projectId: string; instances: Instance[] }[] = [];
|
||||
for (const p of projects) {
|
||||
try {
|
||||
const detail = await api.getProject(p.id, signal);
|
||||
const stages = detail.stages ?? [];
|
||||
const stageInstances: Instance[][] = [];
|
||||
for (const s of stages) {
|
||||
stageInstances.push(await api.listInstances(p.id, s.id, signal));
|
||||
}
|
||||
results.push({ projectId: p.id, instances: stageInstances.flat() });
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
||||
results.push({ projectId: p.id, instances: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const mapped: Record<string, Instance[]> = {};
|
||||
for (const r of results) {
|
||||
mapped[r.projectId] = r.instances;
|
||||
}
|
||||
instancesByProject = mapped;
|
||||
|
||||
staleContainers = await api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[]);
|
||||
|
||||
sites = await api.listStaticSites(signal).catch(() => [] as StaticSite[]);
|
||||
// Parallelize the cheap top-level reads. Each falls back to an
|
||||
// empty list so a single slow daemon (e.g. Docker stats) does
|
||||
// not blank the entire dashboard.
|
||||
const [wls, ctrs, stale] = await Promise.all([
|
||||
api.listWorkloads(undefined, signal),
|
||||
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
|
||||
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
|
||||
]);
|
||||
workloads = wls;
|
||||
containers = ctrs;
|
||||
staleContainers = stale;
|
||||
|
||||
try {
|
||||
const imgStats = await api.getUnusedImageStats(signal);
|
||||
@@ -82,35 +70,32 @@
|
||||
return () => { loadController?.abort(); };
|
||||
});
|
||||
|
||||
const totalProjects = $derived(projects.length);
|
||||
const totalRunning = $derived(
|
||||
Object.values(instancesByProject)
|
||||
.flat()
|
||||
.filter((i) => i.state === 'running').length
|
||||
);
|
||||
const totalFailed = $derived(
|
||||
Object.values(instancesByProject)
|
||||
.flat()
|
||||
.filter((i) => i.state === 'failed').length
|
||||
);
|
||||
const totalWorkloads = $derived(workloads.length);
|
||||
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
|
||||
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
|
||||
const totalStale = $derived(staleContainers.length);
|
||||
const totalSites = $derived(sites.length);
|
||||
const deployedSites = $derived(sites.filter((s) => s.status === 'deployed').length);
|
||||
const failedSitesCount = $derived(sites.filter((s) => s.status === 'failed').length);
|
||||
|
||||
function siteStatusBadge(status: string): { text: string; cls: string } {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
return { text: 'Deployed', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing':
|
||||
return { text: 'Syncing', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
case 'stopped':
|
||||
return { text: 'Stopped', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
default:
|
||||
return { text: 'Idle', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
|
||||
// recent-activity strip without paging the entire list.
|
||||
const recentWorkloads = $derived(
|
||||
[...workloads]
|
||||
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
|
||||
.slice(0, 6)
|
||||
);
|
||||
|
||||
function containerStateFor(workloadID: string): string {
|
||||
// Pick the most informative state across this workload's
|
||||
// containers: failed > running > stopped > anything else.
|
||||
const states = containers.filter((c) => c.workload_id === workloadID).map((c) => c.state);
|
||||
if (states.length === 0) return 'idle';
|
||||
if (states.includes('failed')) return 'failed';
|
||||
if (states.includes('running')) return 'running';
|
||||
if (states.includes('stopped')) return 'stopped';
|
||||
return states[0];
|
||||
}
|
||||
|
||||
function containerCountFor(workloadID: string): number {
|
||||
return containers.filter((c) => c.workload_id === workloadID).length;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,9 +106,9 @@
|
||||
<div class="space-y-6 dashboard">
|
||||
<!-- Hero -->
|
||||
{#snippet heroToolbar()}
|
||||
<a href="/deploy" class="forge-btn">
|
||||
<IconDeploy size={14} />
|
||||
<span>{$t('dashboard.quickDeploy')}</span>
|
||||
<a href="/apps/new" class="forge-btn">
|
||||
<IconBox size={14} />
|
||||
<span>{$t('dashboard.newApp')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
@@ -137,35 +122,26 @@
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="forge-stat-grid">
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalProjects')}</span>
|
||||
<span class="forge-stat-value">{String(totalProjects).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">active</span>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningInstances')}</span>
|
||||
<a href="/apps" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
|
||||
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">workloads →</span>
|
||||
</a>
|
||||
<a href="/containers" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
|
||||
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">instances</span>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedInstances')}</span>
|
||||
<span class="forge-stat-sub">running</span>
|
||||
</a>
|
||||
<a href="/containers?state=failed" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
|
||||
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">need attention</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/containers/stale" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
||||
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">stale →</span>
|
||||
</a>
|
||||
<a href="/sites" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalSites')}</span>
|
||||
<span class="forge-stat-value">{String(totalSites).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">
|
||||
{#if deployedSites > 0}<span class="tag ok">{deployedSites} up</span>{/if}
|
||||
{#if failedSitesCount > 0}<span class="tag bad">{failedSitesCount} fail</span>{/if}
|
||||
{#if deployedSites === 0 && failedSitesCount === 0}static sites →{/if}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Unused images warning -->
|
||||
@@ -201,59 +177,11 @@
|
||||
<SystemResourcesCard />
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Static sites summary -->
|
||||
{#if !loading}
|
||||
{#snippet sitesActions()}
|
||||
{#if sites.length > 0}
|
||||
<a href="/sites" class="text-xs font-medium text-[var(--color-brand-600)] hover:underline">
|
||||
{$t('dashboard.viewAllSites')} →
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<CollapsibleSection
|
||||
id="dashboard-sites"
|
||||
title={$t('dashboard.staticSites')}
|
||||
badge={sites.length > 0 ? String(sites.length) : ''}
|
||||
actions={sitesActions}
|
||||
>
|
||||
{#if sites.length === 0}
|
||||
<EmptyState
|
||||
title={$t('dashboard.noSites')}
|
||||
description={$t('dashboard.addFirstSite')}
|
||||
actionLabel={$t('sites.title')}
|
||||
actionHref="/sites"
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each sites as site (site.id)}
|
||||
{@const badge = siteStatusBadge(site.status)}
|
||||
<a href="/sites/{site.id}" class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-[var(--text-primary)]">{site.name}</span>
|
||||
<span class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.cls}">{badge.text}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<span class="truncate">{site.repo_owner}/{site.repo_name}</span>
|
||||
{#if site.domain}
|
||||
<span class="truncate text-[var(--color-brand-600)]">{site.domain}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if site.last_sync_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {$fmt.dateTime(site.last_sync_at)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
<!-- Project cards -->
|
||||
<!-- Recent workloads strip -->
|
||||
<CollapsibleSection
|
||||
id="dashboard-projects"
|
||||
title={$t('dashboard.projects')}
|
||||
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
|
||||
id="dashboard-workloads"
|
||||
title={$t('dashboard.recentWorkloads')}
|
||||
badge={!loading && workloads.length > 0 ? String(workloads.length) : ''}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -272,18 +200,36 @@
|
||||
{$t('dashboard.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
{:else if workloads.length === 0}
|
||||
<EmptyState
|
||||
title={$t('empty.noProjects')}
|
||||
description={$t('empty.noProjectsDesc')}
|
||||
actionLabel={$t('empty.createProject')}
|
||||
actionHref="/projects"
|
||||
title={$t('dashboard.noWorkloads')}
|
||||
description={$t('dashboard.noWorkloadsDesc')}
|
||||
actionLabel={$t('dashboard.newApp')}
|
||||
actionHref="/apps/new"
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each projects as project (project.id)}
|
||||
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each recentWorkloads as wl (wl.id)}
|
||||
{@const state = containerStateFor(wl.id)}
|
||||
{@const count = containerCountFor(wl.id)}
|
||||
<a
|
||||
href={wl.app_id ? `/apps/${wl.app_id}` : '/apps'}
|
||||
class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-[var(--text-primary)]">{wl.name}</span>
|
||||
<StatusBadge status={state} size="sm" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<span class="truncate">{wl.source_kind || wl.kind}</span>
|
||||
<span class="opacity-60">·</span>
|
||||
<span>{count} {count === 1 ? $t('common.instance') : $t('common.instances')}</span>
|
||||
</div>
|
||||
{#if wl.updated_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$fmt.dateTime(wl.updated_at)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -298,19 +244,4 @@
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.stat-link:hover { background: var(--surface-card-hover); }
|
||||
.stat-link .forge-stat-sub .tag {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
:global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
.section { margin-top: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -328,11 +328,9 @@
|
||||
let newEnvValue = $state('');
|
||||
let newEnvEncrypted = $state(true);
|
||||
|
||||
// ── Webhook ────────────────────────────────────────────────
|
||||
let webhook = $state<api.WorkloadWebhook | null>(null);
|
||||
let webhookLoading = $state(false);
|
||||
let webhookError = $state('');
|
||||
let regenerating = $state(false);
|
||||
// Workload-side webhook UI was removed in the hard legacy cutover —
|
||||
// inbound webhooks are now first-class Triggers. Use the bindings
|
||||
// panel + the /triggers detail page to manage the webhook URL.
|
||||
|
||||
// ── Logs viewer ────────────────────────────────────────────
|
||||
let logContainerRowID = $state<string | null>(null);
|
||||
@@ -501,18 +499,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhook() {
|
||||
webhookLoading = true;
|
||||
webhookError = '';
|
||||
try {
|
||||
webhook = await api.getWorkloadWebhook(id);
|
||||
} catch (e) {
|
||||
webhookError = e instanceof Error ? e.message : 'Failed to load webhook';
|
||||
} finally {
|
||||
webhookLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addEnv() {
|
||||
envError = '';
|
||||
const key = newEnvKey.trim();
|
||||
@@ -547,18 +533,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateWebhook() {
|
||||
regenerating = true;
|
||||
webhookError = '';
|
||||
try {
|
||||
webhook = await api.regenerateWorkloadWebhook(id);
|
||||
} catch (e) {
|
||||
webhookError = e instanceof Error ? e.message : 'Failed to rotate secret';
|
||||
} finally {
|
||||
regenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deploy() {
|
||||
deploying = true;
|
||||
lastDeployMsg = '';
|
||||
@@ -2231,57 +2205,9 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── Webhook ──────────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head split">
|
||||
<h2 class="panel-title">Webhook<span class="title-accent">.</span></h2>
|
||||
{#if !webhook}
|
||||
<button class="forge-btn-ghost" onclick={loadWebhook} disabled={webhookLoading}>
|
||||
{webhookLoading ? 'Loading…' : 'Reveal URL'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
{#if webhookError}
|
||||
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{webhookError}</span></div>
|
||||
{/if}
|
||||
{#if webhook}
|
||||
<p class="hint">
|
||||
Point your registry or CI here. The URL itself is the credential — treat it as a
|
||||
secret. Rotate any time without disrupting deploys (the next call uses the new URL).
|
||||
</p>
|
||||
<div class="webhook-row">
|
||||
<code class="webhook-url">{webhook.webhook_url}</code>
|
||||
<button
|
||||
class="forge-btn-ghost"
|
||||
onclick={() => copyToClipboard('webhook', webhook!.webhook_url)}
|
||||
aria-label="Copy webhook URL"
|
||||
>
|
||||
{#if copied.webhook}
|
||||
<IconCheck size={13} /><span>Copied</span>
|
||||
{:else}
|
||||
<IconCopy size={13} /><span>Copy</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="webhook-meta">
|
||||
<span class="meta-chip" class:active={webhook.has_signing_secret}>
|
||||
{webhook.has_signing_secret ? 'HMAC SIGNED' : 'UNSIGNED'}
|
||||
</span>
|
||||
<span class="meta-chip" class:active={webhook.webhook_require_signature}>
|
||||
{webhook.webhook_require_signature ? 'SIGNATURE REQUIRED' : 'SIGNATURE OPTIONAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="webhook-actions">
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={regenerateWebhook}
|
||||
disabled={regenerating}
|
||||
>
|
||||
{regenerating ? 'Rotating…' : 'Rotate secret'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<!-- Webhook URL panel removed — inbound webhooks live on
|
||||
the bound Triggers panel above. The trigger detail page
|
||||
(/triggers/{id}) carries the URL + rotate action. -->
|
||||
{/if}
|
||||
|
||||
<!-- ── Config viewers ───────────────────────────── -->
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
// client-side so the tab counters reflect the whole population, not the
|
||||
// current narrowed view (otherwise picking "Project" would show All=0).
|
||||
let allContainers = $state<ContainerView[]>([]);
|
||||
let refIDByWorkload = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let error = $state('');
|
||||
@@ -40,15 +39,9 @@
|
||||
try {
|
||||
// Race-safety: keep the latest fetch's result and discard stragglers.
|
||||
const seq = ++loadSeq;
|
||||
const [containers, workloads] = await Promise.all([
|
||||
api.listContainers({}),
|
||||
api.listWorkloads()
|
||||
]);
|
||||
const containers = await api.listContainers({});
|
||||
if (seq !== loadSeq) return;
|
||||
allContainers = containers;
|
||||
const map: Record<string, string> = {};
|
||||
for (const wl of workloads) map[wl.id] = wl.ref_id;
|
||||
refIDByWorkload = map;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('containers.errLoad');
|
||||
} finally {
|
||||
@@ -127,18 +120,11 @@
|
||||
}
|
||||
|
||||
function detailHref(c: ContainerView): string | undefined {
|
||||
const refID = refIDByWorkload[c.workload_id];
|
||||
if (!refID) return undefined;
|
||||
switch (c.workload_kind) {
|
||||
case 'project':
|
||||
return `/projects/${refID}`;
|
||||
case 'stack':
|
||||
return `/stacks/${refID}`;
|
||||
case 'site':
|
||||
return `/sites/${refID}`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
// Legacy project / stack / site detail pages were retired with the
|
||||
// hard cutover. The workload-first equivalent lives under /apps —
|
||||
// every workload now belongs to an app, so the row deep-links to
|
||||
// the app detail page when one is attached, otherwise stays flat.
|
||||
return c.app_id ? `/apps/${c.app_id}` : undefined;
|
||||
}
|
||||
|
||||
function tabClass(active: boolean): string {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { StaleContainer } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import StaleContainerCard from '$lib/components/StaleContainerCard.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
@@ -9,6 +8,7 @@
|
||||
import { IconTrash, IconLoader } from '$lib/components/icons';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let containers = $state<StaleContainer[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -19,15 +19,26 @@
|
||||
let cleaningIds = $state<Set<string>>(new Set());
|
||||
let bulkCleaning = $state(false);
|
||||
|
||||
let loadController: AbortController | null = null;
|
||||
|
||||
async function loadStale() {
|
||||
loadController?.abort();
|
||||
const ac = new AbortController();
|
||||
loadController = ac;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
containers = await api.fetchStaleContainers();
|
||||
const rows = await api.fetchStaleContainers(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
containers = rows;
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return;
|
||||
error = e instanceof Error ? e.message : $t('stale.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
if (loadController === ac) {
|
||||
loading = false;
|
||||
loadController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +79,7 @@
|
||||
|
||||
$effect(() => {
|
||||
loadStale();
|
||||
return () => loadController?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -124,17 +136,124 @@
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each containers as container (container.container.id)}
|
||||
<StaleContainerCard
|
||||
{container}
|
||||
cleaning={cleaningIds.has(container.container.id)}
|
||||
oncleanup={requestCleanup}
|
||||
/>
|
||||
{#each containers as entry (entry.container.id)}
|
||||
{@const c = entry.container}
|
||||
{@const cleaning = cleaningIds.has(c.id)}
|
||||
<article class="stale-card">
|
||||
<header class="stale-card-head">
|
||||
<div class="stale-card-title">
|
||||
<span class="stale-workload">{entry.workload_name || c.workload_id || '—'}</span>
|
||||
{#if entry.role}
|
||||
<span class="stale-role">/ {entry.role}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="stale-pill" title={$t('stale.daysStale')}>{entry.days_stale}d</span>
|
||||
</header>
|
||||
<dl class="stale-meta">
|
||||
<div><dt>{$t('common.running')}</dt><dd>{c.state}</dd></div>
|
||||
<div><dt>image</dt><dd class="truncate">{c.image_ref}{c.image_tag ? ':' + c.image_tag : ''}</dd></div>
|
||||
{#if c.last_seen_at}
|
||||
<div><dt>{$t('stale.lastAlive')}</dt><dd>{$fmt.dateTime(c.last_seen_at)}</dd></div>
|
||||
{/if}
|
||||
</dl>
|
||||
<footer class="stale-card-foot">
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost forge-btn-danger"
|
||||
disabled={cleaning}
|
||||
onclick={() => requestCleanup(c.id)}
|
||||
>
|
||||
{#if cleaning}<IconLoader size={14} />{/if}
|
||||
<IconTrash size={14} />
|
||||
<span>{$t('stale.cleanup')}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stale-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md, 0.75rem);
|
||||
background: var(--surface-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stale-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stale-card-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.stale-workload {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stale-role {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
.stale-pill {
|
||||
flex-shrink: 0;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-danger-dark);
|
||||
background: var(--color-danger-light);
|
||||
}
|
||||
.stale-meta {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.stale-meta > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stale-meta dt {
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.stale-meta dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||
}
|
||||
.stale-card-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Single cleanup confirm -->
|
||||
<ConfirmDialog
|
||||
open={confirmSingleId !== ''}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { inspectImage, quickDeploy, listProjects, listRegistries, listRegistryImages } from '$lib/api';
|
||||
import type { InspectResult, EntityPickerItem, Project } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
|
||||
|
||||
let imageUrl = $state('');
|
||||
let inspecting = $state(false);
|
||||
let deploying = $state(false);
|
||||
let inspected = $state(false);
|
||||
let inspectResult: InspectResult | null = $state(null);
|
||||
|
||||
let projectName = $state('');
|
||||
let port = $state('');
|
||||
let stage = $state('dev');
|
||||
let subdomain = $state('');
|
||||
let envVars = $state('');
|
||||
let enableProxy = $state(true);
|
||||
let autoDeploy = $state(false);
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
// Duplicate detection state
|
||||
let conflictProjects = $state<Project[]>([]);
|
||||
let showConflictDialog = $state(false);
|
||||
|
||||
// Image picker state
|
||||
let showImagePicker = $state(false);
|
||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
||||
let imagePickerLoading = $state(false);
|
||||
|
||||
async function handleBrowseImages() {
|
||||
showImagePicker = true;
|
||||
if (imagePickerItems.length > 0) return;
|
||||
|
||||
imagePickerLoading = true;
|
||||
try {
|
||||
const registries = await listRegistries();
|
||||
const items: EntityPickerItem[] = [];
|
||||
for (const reg of registries) {
|
||||
if (!reg.owner) continue;
|
||||
try {
|
||||
const images = await listRegistryImages(reg.id);
|
||||
for (const img of images) {
|
||||
items.push({
|
||||
value: img.full_ref + ':latest',
|
||||
label: img.full_ref,
|
||||
description: reg.name,
|
||||
group: reg.name
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip registries that fail.
|
||||
}
|
||||
}
|
||||
imagePickerItems = items;
|
||||
} catch {
|
||||
toasts.error($t('quickDeploy.imageLoadFailed'));
|
||||
} finally {
|
||||
imagePickerLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPickedImage(value: string) {
|
||||
imageUrl = value;
|
||||
showImagePicker = false;
|
||||
}
|
||||
|
||||
function validateImageUrl(url: string): string {
|
||||
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
|
||||
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
||||
return $t('validation.invalidUrl');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePort(value: string | number): string {
|
||||
const s = String(value ?? '');
|
||||
if (!s.trim()) return $t('validation.required', { field: 'Port' });
|
||||
const num = parseInt(s, 10);
|
||||
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateProjectName(value: string): string {
|
||||
if (!value.trim()) return $t('validation.required', { field: 'Project name' });
|
||||
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
|
||||
return $t('validation.invalidProjectName');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
const nameErr = validateProjectName(projectName);
|
||||
if (nameErr) newErrors.projectName = nameErr;
|
||||
const portErr = validatePort(port);
|
||||
if (portErr) newErrors.port = portErr;
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
function deriveProjectName(image: string): string {
|
||||
const withoutTag = image.split(':')[0] ?? image;
|
||||
const segments = withoutTag.split('/');
|
||||
return (segments[segments.length - 1] ?? 'unknown').toLowerCase().replace(/[^a-z0-9\-]/g, '-');
|
||||
}
|
||||
|
||||
async function handleInspect() {
|
||||
const urlError = validateImageUrl(imageUrl);
|
||||
if (urlError) {
|
||||
errors = { imageUrl: urlError };
|
||||
return;
|
||||
}
|
||||
errors = {};
|
||||
inspecting = true;
|
||||
try {
|
||||
const result = await inspectImage(imageUrl.trim());
|
||||
inspectResult = result;
|
||||
projectName = deriveProjectName(result.image);
|
||||
port = result.port?.toString() ?? '';
|
||||
// Healthcheck auto-detected but not shown — user can configure later on project page.
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
inspected = true;
|
||||
toasts.success($t('quickDeploy.inspectedSuccess'));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : $t('quickDeploy.inspectFailed');
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
inspecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy(force = false) {
|
||||
if (!validateAll()) return;
|
||||
deploying = true;
|
||||
try {
|
||||
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force, enable_proxy: enableProxy, auto_deploy: autoDeploy });
|
||||
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
|
||||
// Redirect to the new project page.
|
||||
if (result.project?.id) {
|
||||
goto(`/projects/${result.project.id}`);
|
||||
} else {
|
||||
imageUrl = '';
|
||||
inspected = false;
|
||||
inspectResult = null;
|
||||
projectName = '';
|
||||
port = '';
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Handle 409 Conflict — existing project with same image.
|
||||
if (err instanceof Error && 'status' in err && (err as any).status === 409) {
|
||||
try {
|
||||
// Find existing projects with the same image.
|
||||
const allProjects = await listProjects();
|
||||
const imageBase = imageUrl.trim().split(':')[0];
|
||||
const matching = allProjects.filter(p => p.image === imageBase || p.image === imageUrl.trim());
|
||||
if (matching.length > 0) {
|
||||
conflictProjects = matching;
|
||||
showConflictDialog = true;
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
toasts.error($t('quickDeploy.imageAlreadyExists'));
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
|
||||
toasts.error(message);
|
||||
}
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeployToExisting(project: Project) {
|
||||
showConflictDialog = false;
|
||||
conflictProjects = [];
|
||||
goto(`/projects/${project.id}`);
|
||||
}
|
||||
|
||||
async function handleForceNewProject() {
|
||||
showConflictDialog = false;
|
||||
conflictProjects = [];
|
||||
await handleDeploy(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('quickDeploy.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<ForgeHero
|
||||
eyebrowSuffix="DEPLOY"
|
||||
title={$t('quickDeploy.title')}
|
||||
lede={$t('quickDeploy.description')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-4 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step1')}</h2>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<FormField
|
||||
label={$t('quickDeploy.imageUrl')}
|
||||
name="imageUrl"
|
||||
bind:value={imageUrl}
|
||||
placeholder="registry.example.com/org/app:tag"
|
||||
required
|
||||
error={errors.imageUrl ?? ''}
|
||||
helpText={$t('quickDeploy.imageUrlHelp')}
|
||||
disabled={inspecting}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 pt-[26px]">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBrowseImages}
|
||||
title={$t('quickDeploy.browseImages')}
|
||||
aria-label={$t('quickDeploy.browseImages')}
|
||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
{#if imagePickerLoading}
|
||||
<IconLoader size={16} />
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleInspect}
|
||||
disabled={inspecting || !imageUrl.trim()}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-info)] px-4 py-2 text-sm font-medium text-white transition-all duration-150 hover:bg-[var(--color-info-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if inspecting}
|
||||
<IconLoader size={16} />
|
||||
{$t('quickDeploy.inspecting')}
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{$t('quickDeploy.inspect')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EntityPicker
|
||||
bind:open={showImagePicker}
|
||||
items={imagePickerItems}
|
||||
current={imageUrl}
|
||||
title={$t('quickDeploy.selectImage')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={selectPickedImage}
|
||||
onclose={() => { showImagePicker = false; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
{#if inspected}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
|
||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step2')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.reviewDesc')}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText={$t('quickDeploy.lowercaseHint')} />
|
||||
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
|
||||
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
<option value="dev">{$t('quickDeploy.development')}</option>
|
||||
<option value="rel">{$t('quickDeploy.release')}</option>
|
||||
<option value="prod">{$t('quickDeploy.production')}</option>
|
||||
</select>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.stageHelp')}</p>
|
||||
</div>
|
||||
<FormField label={$t('quickDeploy.subdomainOverride')} name="subdomain" bind:value={subdomain} placeholder="auto-generated" helpText={$t('quickDeploy.subdomainHelp')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder={"KEY=value\nANOTHER_KEY=another_value"} helpText={$t('quickDeploy.envVarsHelp')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch bind:checked={enableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch bind:checked={autoDeploy} label={$t('quickDeploy.autoDeployLabel')} />
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('quickDeploy.autoDeployLabel')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
|
||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step3')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => handleDeploy()}
|
||||
disabled={deploying}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if deploying}
|
||||
<IconLoader size={16} />
|
||||
{$t('projectDetail.deploying')}
|
||||
{:else}
|
||||
<IconDeploy size={16} />
|
||||
{$t('quickDeploy.deployBtn')}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { inspected = false; inspectResult = null; }}
|
||||
disabled={deploying}
|
||||
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conflict dialog: image already deployed -->
|
||||
{#if showConflictDialog}
|
||||
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={() => { showConflictDialog = false; }}></div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="conflict-dialog-title"
|
||||
tabindex="-1"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showConflictDialog = false; }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="w-full max-w-lg rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in"
|
||||
>
|
||||
<h3 id="conflict-dialog-title" class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{$t('quickDeploy.imageAlreadyExists')}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{$t('quickDeploy.conflictDescription')}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each conflictProjects as project (project.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeployToExisting(project)}
|
||||
class="flex w-full items-center justify-between rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-3 text-left hover:border-[var(--color-brand-500)] transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{project.name}</span>
|
||||
<span class="ml-2 text-xs text-[var(--text-tertiary)]">{project.image}</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.openProject')}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end 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={() => { showConflictDialog = false; }}
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
|
||||
onclick={handleForceNewProject}
|
||||
>
|
||||
{$t('quickDeploy.createNewAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,302 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Project, EntityPickerItem } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconPlus, IconSearch, IconLoader } from '$lib/components/icons';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
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('');
|
||||
let formRegistry = $state('');
|
||||
let formPort = $state('');
|
||||
let formHealthcheck = $state('');
|
||||
let formSubmitting = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
// Image picker state
|
||||
let showImagePicker = $state(false);
|
||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
||||
let imagePickerLoading = $state(false);
|
||||
|
||||
async function handleBrowseImages() {
|
||||
showImagePicker = true;
|
||||
if (imagePickerItems.length > 0) return;
|
||||
|
||||
imagePickerLoading = true;
|
||||
try {
|
||||
const registries = await api.listRegistries();
|
||||
// Collect existing project images to mark as already added.
|
||||
const existingImages = new Set(projects.map(p => p.image.toLowerCase()));
|
||||
const items: EntityPickerItem[] = [];
|
||||
for (const reg of registries) {
|
||||
if (!reg.owner) continue;
|
||||
try {
|
||||
const images = await api.listRegistryImages(reg.id);
|
||||
for (const img of images) {
|
||||
const alreadyAdded = existingImages.has(img.full_ref.toLowerCase());
|
||||
items.push({
|
||||
value: JSON.stringify({ full_ref: img.full_ref, registryName: reg.name }),
|
||||
label: img.full_ref,
|
||||
description: alreadyAdded ? undefined : reg.name,
|
||||
group: reg.name,
|
||||
disabled: alreadyAdded,
|
||||
disabledHint: alreadyAdded ? $t('projects.alreadyAdded') : undefined
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip registries that fail (e.g., no owner configured).
|
||||
}
|
||||
}
|
||||
imagePickerItems = items;
|
||||
} catch {
|
||||
imagePickerItems = [];
|
||||
} finally {
|
||||
imagePickerLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nameFromImage(imageRef: string): string {
|
||||
// Extract last path segment: "git.example.com/owner/my-app" → "my-app"
|
||||
const parts = imageRef.split('/');
|
||||
return parts[parts.length - 1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
}
|
||||
|
||||
function selectPickedImage(value: string) {
|
||||
const parsed = JSON.parse(value) as { full_ref: string; registryName: string };
|
||||
formImage = parsed.full_ref;
|
||||
formRegistry = parsed.registryName;
|
||||
// Auto-fill name if empty.
|
||||
if (!formName.trim()) {
|
||||
formName = nameFromImage(parsed.full_ref);
|
||||
}
|
||||
showImagePicker = false;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.listProjects();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('projects.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddProject() {
|
||||
if (!formName.trim() || !formImage.trim()) {
|
||||
formError = $t('projects.nameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
formSubmitting = true;
|
||||
formError = '';
|
||||
try {
|
||||
await api.createProject({
|
||||
name: formName.trim(),
|
||||
image: formImage.trim(),
|
||||
registry: formRegistry.trim(),
|
||||
port: parseInt(formPort, 10) || 3000,
|
||||
healthcheck: formHealthcheck.trim()
|
||||
});
|
||||
formName = '';
|
||||
formImage = '';
|
||||
formRegistry = '';
|
||||
formPort = '';
|
||||
formHealthcheck = '';
|
||||
showAddForm = false;
|
||||
await loadProjects();
|
||||
} catch (e) {
|
||||
formError = e instanceof Error ? e.message : $t('projects.createFailed');
|
||||
} finally {
|
||||
formSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadProjects();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('projects.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#snippet heroToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
class={showAddForm ? 'forge-btn-ghost' : 'forge-btn'}
|
||||
onclick={() => { showAddForm = !showAddForm; }}
|
||||
>
|
||||
{#if !showAddForm}<IconPlus size={14} />{/if}
|
||||
<span>{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrowSuffix="PROJECTS"
|
||||
title={$t('projects.title')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
/>
|
||||
|
||||
<!-- Add project form -->
|
||||
{#if showAddForm}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projects.newProject')}</h2>
|
||||
|
||||
{#if formError}
|
||||
<div class="mt-3 rounded-lg bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{formError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="{$t('projects.name')} *" name="name" bind:value={formName} placeholder="my-web-app" required />
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<FormField label="{$t('projects.image')} *" name="image" bind:value={formImage} placeholder="registry.example.com/org/app" required />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBrowseImages}
|
||||
title={$t('projects.browseImages')}
|
||||
aria-label={$t('projects.browseImages')}
|
||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
{#if imagePickerLoading}
|
||||
<IconLoader size={16} />
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<EntityPicker
|
||||
bind:open={showImagePicker}
|
||||
items={imagePickerItems}
|
||||
current={formImage}
|
||||
title={$t('projects.selectImage')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={selectPickedImage}
|
||||
onclose={() => { showImagePicker = false; }}
|
||||
/>
|
||||
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} placeholder="3000" helpText={$t('projects.portHelpText')} />
|
||||
<FormField label={$t('projects.healthcheck')} name="healthcheck" bind:value={formHealthcheck} placeholder="/api/health" helpText={$t('projects.healthcheckHelpText')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all duration-150 active:animate-press"
|
||||
disabled={formSubmitting}
|
||||
onclick={handleAddProject}
|
||||
>
|
||||
{formSubmitting ? $t('projects.creating') : $t('projects.createProject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Projects list -->
|
||||
{#if loading}
|
||||
<SkeletonTable rows={4} cols={5} />
|
||||
{: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={loadProjects}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<EmptyState
|
||||
title={$t('empty.noProjects')}
|
||||
description={$t('empty.noProjectsDesc')}
|
||||
actionLabel={$t('projects.addProject')}
|
||||
onaction={() => { showAddForm = true; }}
|
||||
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)]">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.name')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.image')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</th>
|
||||
<th class="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#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">
|
||||
{project.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-[var(--text-tertiary)]">
|
||||
{project.image}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{project.port || '-'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{project.registry || '-'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{$fmt.date(project.created_at)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
|
||||
<a href="/projects/{project.id}" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
{$t('projects.view')}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,915 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Project, Stage, Instance, Deploy, LocalImage } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import InstanceCard from '$lib/components/InstanceCard.svelte';
|
||||
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, IconClock, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
||||
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
|
||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { IconShield } from '$lib/components/icons';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let stages = $state<Stage[]>([]);
|
||||
let instancesByStage = $state<Record<string, Instance[]>>({});
|
||||
let deploys = $state<Deploy[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let deployStageId = $state('');
|
||||
let deployTag = $state('');
|
||||
let deployLoading = $state(false);
|
||||
let deployError = $state('');
|
||||
|
||||
|
||||
// Edit stage
|
||||
let editingStageId = $state('');
|
||||
let editStageName = $state('');
|
||||
let editStageTagPattern = $state('');
|
||||
let editStageAutoDeploy = $state(true);
|
||||
let editStageEnableProxy = $state(true);
|
||||
let editStageMaxInstances = $state('1');
|
||||
let editStageCpuLimit = $state('');
|
||||
let editStageMemoryLimit = $state('');
|
||||
let editStageNotificationUrl = $state('');
|
||||
let savingStage = $state(false);
|
||||
|
||||
function startEditStage(stage: Stage) {
|
||||
editingStageId = stage.id;
|
||||
editStageName = stage.name;
|
||||
editStageTagPattern = stage.tag_pattern;
|
||||
editStageAutoDeploy = stage.auto_deploy;
|
||||
editStageEnableProxy = stage.enable_proxy;
|
||||
editStageMaxInstances = String(stage.max_instances);
|
||||
editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : '';
|
||||
editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : '';
|
||||
editStageNotificationUrl = stage.notification_url ?? '';
|
||||
}
|
||||
|
||||
async function handleUpdateStage() {
|
||||
if (!editStageName.trim()) return;
|
||||
savingStage = true;
|
||||
try {
|
||||
await api.updateStage(projectId, editingStageId, {
|
||||
name: editStageName.trim(),
|
||||
tag_pattern: editStageTagPattern.trim() || '*',
|
||||
auto_deploy: editStageAutoDeploy,
|
||||
enable_proxy: editStageEnableProxy,
|
||||
max_instances: parseInt(editStageMaxInstances) || 1,
|
||||
cpu_limit: parseFloat(editStageCpuLimit) || 0,
|
||||
memory_limit: parseInt(editStageMemoryLimit) || 0,
|
||||
notification_url: editStageNotificationUrl.trim(),
|
||||
});
|
||||
toasts.success($t('projectDetail.stageUpdated'));
|
||||
editingStageId = '';
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageUpdateFailed'));
|
||||
} finally {
|
||||
savingStage = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add stage form
|
||||
let showAddStage = $state(false);
|
||||
let stageName = $state('');
|
||||
let stageTagPattern = $state('*');
|
||||
let stageAutoDeploy = $state(true);
|
||||
let stageEnableProxy = $state(true);
|
||||
let stageMaxInstances = $state('1');
|
||||
let stageCpuLimit = $state('');
|
||||
let stageMemoryLimit = $state('');
|
||||
let addingStage = $state(false);
|
||||
|
||||
async function handleAddStage() {
|
||||
if (!stageName.trim()) return;
|
||||
addingStage = true;
|
||||
try {
|
||||
await api.createStage(projectId, {
|
||||
name: stageName.trim(),
|
||||
tag_pattern: stageTagPattern.trim() || '*',
|
||||
auto_deploy: stageAutoDeploy,
|
||||
enable_proxy: stageEnableProxy,
|
||||
max_instances: parseInt(stageMaxInstances) || 1,
|
||||
cpu_limit: parseFloat(stageCpuLimit) || 0,
|
||||
memory_limit: parseInt(stageMemoryLimit) || 0,
|
||||
});
|
||||
toasts.success($t('projectDetail.stageCreated', { name: stageName }));
|
||||
stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1'; stageCpuLimit = ''; stageMemoryLimit = '';
|
||||
showAddStage = false;
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageCreateFailed'));
|
||||
} finally {
|
||||
addingStage = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Edit project
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
let editImage = $state('');
|
||||
let editPort = $state('');
|
||||
let editHealthcheck = $state('');
|
||||
let editAccessListId = $state(0);
|
||||
let editAccessListName = $state('');
|
||||
let editNotificationUrl = $state('');
|
||||
let accessListPickerOpen = $state(false);
|
||||
let accessListPickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingAccessLists = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
async function openProjectAccessListPicker() {
|
||||
loadingAccessLists = true;
|
||||
try {
|
||||
const lists = await api.listNpmAccessLists();
|
||||
if (lists.length === 0) { toasts.info($t('settingsNpm.noAccessLists')); return; }
|
||||
accessListPickerItems = lists.map((al): EntityPickerItem => ({
|
||||
value: String(al.id),
|
||||
label: al.name || `Access List #${al.id}`,
|
||||
}));
|
||||
accessListPickerOpen = true;
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsNpm.accessListLoadFailed'));
|
||||
} finally { loadingAccessLists = false; }
|
||||
}
|
||||
|
||||
function handleProjectAccessListSelect(value: string) {
|
||||
editAccessListId = parseInt(value, 10);
|
||||
const item = accessListPickerItems.find((i) => i.value === value);
|
||||
editAccessListName = item?.label ?? '';
|
||||
accessListPickerOpen = false;
|
||||
}
|
||||
|
||||
function clearProjectAccessList() {
|
||||
editAccessListId = 0;
|
||||
editAccessListName = '';
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (!project) return;
|
||||
editName = project.name;
|
||||
editImage = project.image;
|
||||
editPort = String(project.port || '');
|
||||
editHealthcheck = project.healthcheck || '';
|
||||
editAccessListId = project.npm_access_list_id || 0;
|
||||
editAccessListName = editAccessListId > 0 ? `Access List #${editAccessListId}` : '';
|
||||
editNotificationUrl = project.notification_url ?? '';
|
||||
editing = true;
|
||||
// Resolve access list name in background.
|
||||
if (editAccessListId > 0) {
|
||||
api.listNpmAccessLists().then(lists => {
|
||||
const match = lists.find(al => al.id === editAccessListId);
|
||||
if (match) editAccessListName = match.name;
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
if (!editName.trim() || !editImage.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.updateProject(projectId, {
|
||||
name: editName.trim(),
|
||||
image: editImage.trim(),
|
||||
port: parseInt(editPort) || 0,
|
||||
healthcheck: editHealthcheck.trim(),
|
||||
npm_access_list_id: editAccessListId,
|
||||
notification_url: editNotificationUrl.trim(),
|
||||
});
|
||||
toasts.success($t('projectDetail.projectUpdated'));
|
||||
editing = false;
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.updateFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteStage(stageId: string, name: string) {
|
||||
try {
|
||||
await api.deleteStage(projectId, stageId);
|
||||
// Update local state immediately so the UI reflects the change.
|
||||
stages = stages.filter((s) => s.id !== stageId);
|
||||
const { [stageId]: _, ...rest } = instancesByStage;
|
||||
instancesByStage = rest;
|
||||
toasts.success($t('projectDetail.stageDeleted', { name }));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
|
||||
}
|
||||
}
|
||||
let settingsDomain = $state('');
|
||||
let localImages = $state<LocalImage[]>([]);
|
||||
|
||||
let showDeleteConfirm = $state(false);
|
||||
let stageDeleteTarget = $state<{ id: string; name: string } | null>(null);
|
||||
let loadController: AbortController | null = null;
|
||||
|
||||
const projectId = $derived($page.params.id!); // always present on [id] route
|
||||
|
||||
async function loadProject() {
|
||||
// Abort any previous in-flight load before starting a new one.
|
||||
loadController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadController = controller;
|
||||
const signal = controller.signal;
|
||||
|
||||
if (!project) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const detail = await api.getProject(projectId, signal);
|
||||
project = detail.project;
|
||||
stages = detail.stages ?? [];
|
||||
|
||||
const instanceResults = await Promise.all(
|
||||
stages.map(async (s) => {
|
||||
try {
|
||||
const instances = await api.listInstances(projectId, s.id, signal);
|
||||
return { stageId: s.id, instances };
|
||||
} catch {
|
||||
return { stageId: s.id, instances: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const mapped: Record<string, Instance[]> = {};
|
||||
for (const r of instanceResults) {
|
||||
mapped[r.stageId] = r.instances;
|
||||
}
|
||||
instancesByStage = mapped;
|
||||
|
||||
// Fetch deploys, settings, and images in parallel (independent of each other).
|
||||
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
|
||||
api.listDeploys(20, signal),
|
||||
api.getSettings(signal),
|
||||
api.listProjectImages(projectId, signal)
|
||||
]);
|
||||
|
||||
deploys = deploysResult.status === 'fulfilled'
|
||||
? deploysResult.value.filter((d) => d.project_id === projectId)
|
||||
: [];
|
||||
settingsDomain = settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.domain ?? '')
|
||||
: settingsDomain;
|
||||
localImages = imagesResult.status === 'fulfilled'
|
||||
? imagesResult.value
|
||||
: [];
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let tagPickerOpen = $state(false);
|
||||
let tagPickerItems = $state<EntityPickerItem[]>([]);
|
||||
|
||||
async function openTagPicker(stageId: string) {
|
||||
deployStageId = stageId;
|
||||
deployTag = '';
|
||||
|
||||
// Build local image suggestions.
|
||||
const imgs = localImages;
|
||||
const localItems: EntityPickerItem[] = imgs
|
||||
.filter((img) => img.tag)
|
||||
.map((img) => ({
|
||||
value: img.tag,
|
||||
label: img.tag,
|
||||
group: $t('projectDetail.localTag'),
|
||||
description: `${(img.size / (1024 * 1024)).toFixed(0)} MB`
|
||||
}));
|
||||
|
||||
// Try to fetch registry tags.
|
||||
let registryItems: EntityPickerItem[] = [];
|
||||
try {
|
||||
const registries = await api.listRegistries();
|
||||
// Match by registry URL hostname (project.registry stores the hostname)
|
||||
// or by name, or try all registries if project.registry is empty.
|
||||
const projectRegistry = project?.registry || '';
|
||||
const projectImage = project?.image || '';
|
||||
|
||||
let reg = registries.find(r => {
|
||||
if (!projectRegistry) return false;
|
||||
const urlHost = new URL(r.url).hostname;
|
||||
return r.name === projectRegistry || urlHost === projectRegistry;
|
||||
});
|
||||
|
||||
// If project has no registry set but image contains a hostname, try matching by image prefix.
|
||||
if (!reg && projectImage.includes('/')) {
|
||||
const imageHost = projectImage.split('/')[0];
|
||||
if (imageHost.includes('.')) {
|
||||
reg = registries.find(r => {
|
||||
try { return new URL(r.url).hostname === imageHost; } catch { return false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (reg) {
|
||||
// Strip registry hostname from image if present (registry API expects owner/name).
|
||||
let imageForRegistry = projectImage;
|
||||
try {
|
||||
const urlHost = new URL(reg.url).hostname;
|
||||
if (imageForRegistry.startsWith(urlHost + '/')) {
|
||||
imageForRegistry = imageForRegistry.substring(urlHost.length + 1);
|
||||
}
|
||||
} catch { /* keep as-is */ }
|
||||
|
||||
const tags = await api.listRegistryTags(reg.id, imageForRegistry);
|
||||
const localTagSet = new Set(imgs.map((img) => img.tag));
|
||||
registryItems = tags.map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
group: $t('projectDetail.registryTag'),
|
||||
description: localTagSet.has(tag) ? $t('projectDetail.alsoLocal') : undefined
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore registry errors */ }
|
||||
|
||||
// Merge: registry tags first, then local-only tags.
|
||||
if (registryItems.length > 0) {
|
||||
const registryTagSet = new Set(registryItems.map((item) => item.value));
|
||||
const localOnly = localItems.filter((item) => !registryTagSet.has(item.value));
|
||||
tagPickerItems = [...registryItems, ...localOnly];
|
||||
} else {
|
||||
tagPickerItems = localItems;
|
||||
}
|
||||
|
||||
tagPickerOpen = true;
|
||||
}
|
||||
|
||||
function handleTagSelect(tag: string) {
|
||||
deployTag = tag;
|
||||
tagPickerOpen = false;
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!deployTag.trim() || !deployStageId) return;
|
||||
|
||||
deployLoading = true;
|
||||
deployError = '';
|
||||
try {
|
||||
await api.deployInstance(projectId, deployStageId, deployTag.trim());
|
||||
deployTag = '';
|
||||
deployStageId = '';
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
deployError = e instanceof Error ? e.message : $t('projectDetail.deployFailed');
|
||||
} finally {
|
||||
deployLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = $state(false);
|
||||
|
||||
async function handleDeleteProject() {
|
||||
showDeleteConfirm = false;
|
||||
deleted = true;
|
||||
try {
|
||||
await api.deleteProject(projectId);
|
||||
goto('/projects');
|
||||
} catch (e) {
|
||||
deleted = false;
|
||||
error = e instanceof Error ? e.message : $t('projectDetail.deleteFailed');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
untrack(() => {
|
||||
if (!deleted) loadProject();
|
||||
});
|
||||
|
||||
return () => {
|
||||
loadController?.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{project?.name ?? $t('common.project')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="4rem" height="0.875rem" />
|
||||
<Skeleton width="12rem" height="1.75rem" />
|
||||
<Skeleton width="16rem" height="0.875rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
{#each Array(4) as _}
|
||||
<Skeleton height="3rem" />
|
||||
{/each}
|
||||
</div>
|
||||
</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={loadProject}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if project}
|
||||
{@const p = project}
|
||||
<div class="space-y-6">
|
||||
{#snippet projectToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost forge-btn-danger"
|
||||
onclick={() => { showDeleteConfirm = true; }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
<span>{$t('projectDetail.deleteProject')}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref="/projects"
|
||||
eyebrowSuffix="PROJECT"
|
||||
title={p.name}
|
||||
kicker={p.image}
|
||||
size="lg"
|
||||
toolbar={projectToolbar}
|
||||
/>
|
||||
|
||||
<!-- Project settings links -->
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="/projects/{projectId}/env"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconKey size={16} />
|
||||
{$t('projectDetail.envVars')}
|
||||
</a>
|
||||
<a
|
||||
href="/projects/{projectId}/volumes"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconHardDrive size={16} />
|
||||
{$t('projectDetail.volumes')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Project info -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
{#if editing}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="editName" bind:value={editName} />
|
||||
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} />
|
||||
<FormField label={$t('projectDetail.portLabel')} name="editPort" type="number" bind:value={editPort} />
|
||||
<FormField label={$t('projectDetail.healthcheckLabel')} name="editHealthcheck" bind:value={editHealthcheck} placeholder="/api/health" />
|
||||
<FormField label={$t('projectDetail.notificationUrlLabel')} name="editNotificationUrl" bind:value={editNotificationUrl} placeholder="https://notify.example.com/webhook" helpText={$t('projectDetail.notificationUrlHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsNpm.accessList')}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick={openProjectAccessListPicker} disabled={loadingAccessLists}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50">
|
||||
<IconShield size={14} />
|
||||
{#if loadingAccessLists}
|
||||
{$t('common.loading')}
|
||||
{:else if editAccessListId > 0 && editAccessListName}
|
||||
{editAccessListName}
|
||||
{:else}
|
||||
{$t('settingsNpm.noAccessList')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if editAccessListId > 0}
|
||||
<button type="button" onclick={clearProjectAccessList}
|
||||
class="rounded-lg border border-[var(--border-input)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.accessListIdHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { editing = false; }}
|
||||
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"
|
||||
>
|
||||
<IconX size={14} />
|
||||
{$t('projects.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveProject}
|
||||
disabled={saving || !editName.trim() || !editImage.trim()}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<IconCheck size={14} />
|
||||
{saving ? $t('projectDetail.saving') : $t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="grid grid-cols-2 gap-4 flex-1 sm:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.port || 'Auto'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.healthcheck')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.healthcheck || 'Auto'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.registry || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{$fmt.date(project.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={startEditing}
|
||||
title={$t('common.edit')}
|
||||
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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stages & Instances -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.stages')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showAddStage = !showAddStage; }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg {showAddStage ? 'border border-[var(--border-primary)] text-[var(--text-secondary)]' : 'bg-[var(--color-brand-600)] text-white'} px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90"
|
||||
>
|
||||
{#if !showAddStage}<IconPlus size={14} />{/if}
|
||||
{showAddStage ? $t('projects.cancel') : $t('projectDetail.addStage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddStage}
|
||||
<div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="stageName" bind:value={stageName} placeholder="dev" />
|
||||
<FormField label={$t('projectDetail.tagPattern')} name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText={$t('projectDetail.tagPatternHelp')} />
|
||||
<FormField label={$t('projectDetail.maxInstances')} name="stageMax" type="number" bind:value={stageMaxInstances} />
|
||||
<FormField label={$t('projectDetail.cpuLimit')} name="stageCpu" type="number" bind:value={stageCpuLimit} placeholder="0" helpText={$t('projectDetail.cpuLimitHelp')} />
|
||||
<FormField label={$t('projectDetail.memoryLimit')} name="stageMem" type="number" bind:value={stageMemoryLimit} placeholder="0" helpText={$t('projectDetail.memoryLimitHelp')} />
|
||||
<div class="flex gap-4 items-end pb-1">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
||||
<ToggleSwitch bind:checked={stageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
<ToggleSwitch bind:checked={stageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleAddStage}
|
||||
disabled={addingStage || !stageName.trim()}
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all"
|
||||
>
|
||||
{addingStage ? $t('projectDetail.creating') : $t('projectDetail.createStage')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stages.length === 0 && !showAddStage}
|
||||
<div class="mt-4">
|
||||
<EmptyState title={$t('projectDetail.noStages')} icon="instances" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 space-y-4">
|
||||
{#each stages as stage (stage.id)}
|
||||
{@const stageInstances = instancesByStage[stage.id] ?? []}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<!-- Stage header -->
|
||||
{#if editingStageId === stage.id}
|
||||
<div class="border-b border-[var(--border-secondary)] px-5 py-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="editStageName" bind:value={editStageName} />
|
||||
<FormField label={$t('projectDetail.tagPattern')} name="editStagePattern" bind:value={editStageTagPattern} />
|
||||
<FormField label={$t('projectDetail.maxInstances')} name="editStageMax" type="number" bind:value={editStageMaxInstances} />
|
||||
<FormField label={$t('projectDetail.cpuLimit')} name="editStageCpu" type="number" bind:value={editStageCpuLimit} placeholder="0" />
|
||||
<FormField label={$t('projectDetail.memoryLimit')} name="editStageMem" type="number" bind:value={editStageMemoryLimit} placeholder="0" />
|
||||
<div class="flex gap-4 items-end pb-1">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
||||
<ToggleSwitch bind:checked={editStageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
<ToggleSwitch bind:checked={editStageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<FormField
|
||||
label={$t('projectDetail.stageNotificationUrlLabel')}
|
||||
name="editStageNotificationUrl"
|
||||
bind:value={editStageNotificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
helpText={$t('projectDetail.stageNotificationUrlHelp')}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2 justify-end">
|
||||
<button type="button" onclick={() => { editingStageId = ''; }}
|
||||
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">
|
||||
<IconX size={14} />
|
||||
{$t('projects.cancel')}
|
||||
</button>
|
||||
<button type="button" onclick={handleUpdateStage} disabled={savingStage || !editStageName.trim()}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors">
|
||||
<IconCheck size={14} />
|
||||
{savingStage ? $t('projectDetail.saving') : $t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Stage-scoped outgoing webhook controls. Lives inside the
|
||||
edit panel so operators see signing + test alongside the
|
||||
URL they're configuring; collapses on save/cancel. -->
|
||||
<div class="mt-4">
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('projectDetail.stageOutgoingTitle')}
|
||||
description={$t('projectDetail.stageOutgoingDesc')}
|
||||
hasUrl={!!stage.notification_url}
|
||||
fallbackLabel={$t('projectDetail.stageFallbackLabel')}
|
||||
fetchSecret={() => api.getStageNotificationSecret(projectId, stage.id)}
|
||||
regenerateSecret={() => api.regenerateStageNotificationSecret(projectId, stage.id)}
|
||||
disableSigning={() => api.disableStageNotificationSigning(projectId, stage.id)}
|
||||
sendTest={() => api.testStageNotification(projectId, stage.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<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 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 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 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">
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
|
||||
onclick={() => openTagPicker(stage.id)}
|
||||
>
|
||||
<IconDeploy size={14} />
|
||||
{$t('projectDetail.deployNewVersion')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={$t('common.edit')}
|
||||
onclick={() => startEditStage(stage)}
|
||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={$t('projectDetail.deleteStage')}
|
||||
onclick={() => { stageDeleteTarget = { id: stage.id, name: stage.name }; }}
|
||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Deploy confirmation -->
|
||||
{#if deployStageId === stage.id && deployTag}
|
||||
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.deployTag')}:</span>
|
||||
<span class="rounded-md bg-[var(--surface-card)] px-2.5 py-1 font-mono text-sm font-medium text-[var(--text-primary)] border border-[var(--border-primary)]">{deployTag}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
|
||||
onclick={() => openTagPicker(stage.id)}
|
||||
>
|
||||
{$t('common.change')}
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||
disabled={deployLoading}
|
||||
onclick={handleDeploy}
|
||||
>
|
||||
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
onclick={() => { deployStageId = ''; deployTag = ''; }}
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if deployError}
|
||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Instances -->
|
||||
<div class="p-5">
|
||||
{#if stageInstances.length === 0}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each stageInstances as instance (instance.id)}
|
||||
<InstanceCard
|
||||
{instance}
|
||||
{projectId}
|
||||
stageId={stage.id}
|
||||
domain={settingsDomain}
|
||||
onchange={loadProject}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Local Docker Images -->
|
||||
{#if localImages.length > 0}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.localImages')}</h2>
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageTag')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageId')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageSize')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageCreated')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each localImages as img (img.id + img.tag)}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{img.tag || 'untagged'}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs font-mono text-[var(--text-tertiary)]">{img.id.substring(7, 19)}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{(img.size / (1024 * 1024)).toFixed(1)} MB</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-tertiary)]">{$fmt.date(img.created)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Webhook (inbound: trigger deploys via this URL). -->
|
||||
<WebhookPanel
|
||||
title={$t('projectDetail.webhookTitle')}
|
||||
description={$t('projectDetail.webhookDesc')}
|
||||
fetchWebhook={() => api.getProjectWebhook(projectId)}
|
||||
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
||||
regenerateSigningSecret={() => api.regenerateProjectSigningSecret(projectId)}
|
||||
disableSigning={() => api.disableProjectSigningSecret(projectId)}
|
||||
setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)}
|
||||
/>
|
||||
|
||||
<!-- Recent inbound webhook activity (debug + audit). -->
|
||||
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listProjectWebhookDeliveries(projectId, signal)} />
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('projectDetail.outgoingWebhookTitle')}
|
||||
description={$t('projectDetail.outgoingWebhookDesc')}
|
||||
hasUrl={!!project.notification_url}
|
||||
fallbackLabel={$t('projectDetail.outgoingFallbackGlobal')}
|
||||
fetchSecret={() => api.getProjectNotificationSecret(projectId)}
|
||||
regenerateSecret={() => api.regenerateProjectNotificationSecret(projectId)}
|
||||
disableSigning={() => api.disableProjectNotificationSigning(projectId)}
|
||||
sendTest={() => api.testProjectNotification(projectId)}
|
||||
/>
|
||||
|
||||
<!-- Deploy History Timeline -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
||||
|
||||
{#if deploys.length === 0}
|
||||
<p class="mt-4 text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noDeployHistory')}</p>
|
||||
{:else}
|
||||
<div class="mt-4 space-y-3">
|
||||
{#each deploys as deploy (deploy.id)}
|
||||
<div class="flex items-start gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
|
||||
<!-- Timeline dot -->
|
||||
<div class="mt-1 flex flex-col items-center">
|
||||
<div class="h-3 w-3 rounded-full {deploy.status === 'success' ? 'bg-emerald-500' : deploy.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}"></div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono text-sm font-medium text-[var(--text-primary)]">{deploy.image_tag}</span>
|
||||
<StatusBadge status={deploy.status} size="sm" />
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-4 text-xs text-[var(--text-tertiary)]">
|
||||
{#if deploy.started_at}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<IconClock size={12} />
|
||||
{$fmt.dateTime(deploy.started_at)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if deploy.finished_at}
|
||||
<span>→ {$fmt.dateTime(deploy.finished_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if deploy.error}
|
||||
<p class="mt-1 text-xs text-[var(--color-danger)] truncate">{deploy.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
title={$t('projectDetail.deleteConfirmTitle')}
|
||||
message={$t('projectDetail.deleteConfirmMessage', { name: project.name })}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDeleteProject}
|
||||
oncancel={() => { showDeleteConfirm = false; }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={stageDeleteTarget !== null}
|
||||
title={$t('projectDetail.deleteStage')}
|
||||
message={stageDeleteTarget ? $t('projectDetail.deleteStageConfirm', { name: stageDeleteTarget.name }) : ''}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const target = stageDeleteTarget;
|
||||
stageDeleteTarget = null;
|
||||
if (target) await handleDeleteStage(target.id, target.name);
|
||||
}}
|
||||
oncancel={() => { stageDeleteTarget = null; }}
|
||||
/>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={accessListPickerOpen}
|
||||
items={accessListPickerItems}
|
||||
current={String(editAccessListId)}
|
||||
title={$t('settingsNpm.selectAccessList')}
|
||||
onselect={handleProjectAccessListSelect}
|
||||
onclose={() => { accessListPickerOpen = false; }}
|
||||
/>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={tagPickerOpen}
|
||||
items={tagPickerItems}
|
||||
current={deployTag}
|
||||
title={$t('projectDetail.selectTag')}
|
||||
placeholder={$t('projectDetail.searchTags')}
|
||||
onselect={handleTagSelect}
|
||||
onclose={() => { tagPickerOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
-471
@@ -1,471 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Stage, StageEnv } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
let stages = $state<Stage[]>([]);
|
||||
let selectedStageId = $state('');
|
||||
let envVars = $state<StageEnv[]>([]);
|
||||
let projectEnv = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let envLoading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let newKey = $state('');
|
||||
let newValue = $state('');
|
||||
let newEncrypted = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state('');
|
||||
let editKey = $state('');
|
||||
let editValue = $state('');
|
||||
let editEncrypted = $state(false);
|
||||
|
||||
let envDeleteTarget = $state<string | null>(null);
|
||||
|
||||
// Project-level env editing
|
||||
let newProjectKey = $state('');
|
||||
let newProjectValue = $state('');
|
||||
let savingProject = $state(false);
|
||||
let editingProjectKey = $state('');
|
||||
let editProjectValue = $state('');
|
||||
let projectEnvDeleteTarget = $state<string | null>(null);
|
||||
|
||||
// $page.params.id is typed string | undefined because SvelteKit can't
|
||||
// statically prove the [id] segment is present, but inside this route file
|
||||
// it always is — assert non-null so call sites don't need their own guards.
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
|
||||
async function handleAddProjectEnv() {
|
||||
if (!newProjectKey.trim()) return;
|
||||
savingProject = true;
|
||||
try {
|
||||
const updated = { ...projectEnv, [newProjectKey.trim()]: newProjectValue };
|
||||
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
|
||||
projectEnv = updated;
|
||||
newProjectKey = '';
|
||||
newProjectValue = '';
|
||||
toasts.success($t('envEditor.envAdded'));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
|
||||
} finally {
|
||||
savingProject = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditProjectEnv(key: string) {
|
||||
editingProjectKey = key;
|
||||
editProjectValue = projectEnv[key] ?? '';
|
||||
}
|
||||
|
||||
async function handleUpdateProjectEnv() {
|
||||
if (!editingProjectKey) return;
|
||||
savingProject = true;
|
||||
try {
|
||||
const updated = { ...projectEnv, [editingProjectKey]: editProjectValue };
|
||||
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
|
||||
projectEnv = updated;
|
||||
editingProjectKey = '';
|
||||
toasts.success($t('envEditor.envUpdated'));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
|
||||
} finally {
|
||||
savingProject = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProjectEnv(key: string) {
|
||||
savingProject = true;
|
||||
try {
|
||||
const { [key]: _, ...rest } = projectEnv;
|
||||
await api.updateProject(projectId!, { env: JSON.stringify(rest) });
|
||||
projectEnv = rest;
|
||||
toasts.success($t('envEditor.envDeleted'));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
|
||||
} finally {
|
||||
savingProject = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject() {
|
||||
if (stages.length === 0) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const detail = await api.getProject(projectId);
|
||||
stages = detail.stages ?? [];
|
||||
try {
|
||||
projectEnv = JSON.parse(detail.project.env || '{}');
|
||||
} catch {
|
||||
projectEnv = {};
|
||||
}
|
||||
if (stages.length > 0 && !selectedStageId) {
|
||||
selectedStageId = stages[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('envEditor.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStageEnv(stageId: string) {
|
||||
if (!stageId) return;
|
||||
envLoading = true;
|
||||
try {
|
||||
envVars = await api.listStageEnv(projectId, stageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.loadEnvFailed'));
|
||||
envVars = [];
|
||||
} finally {
|
||||
envLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newKey.trim() || !selectedStageId) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.createStageEnv(projectId, selectedStageId, {
|
||||
key: newKey.trim(),
|
||||
value: newValue,
|
||||
encrypted: newEncrypted
|
||||
});
|
||||
newKey = '';
|
||||
newValue = '';
|
||||
newEncrypted = false;
|
||||
toasts.success($t('envEditor.envAdded'));
|
||||
await loadStageEnv(selectedStageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(env: StageEnv) {
|
||||
editingId = env.id;
|
||||
editKey = env.key;
|
||||
editValue = env.encrypted ? '' : env.value;
|
||||
editEncrypted = env.encrypted;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = '';
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!editKey.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const data: { key?: string; value?: string; encrypted?: boolean } = {
|
||||
key: editKey.trim(),
|
||||
encrypted: editEncrypted
|
||||
};
|
||||
if (editValue) {
|
||||
data.value = editValue;
|
||||
}
|
||||
await api.updateStageEnv(projectId, selectedStageId, editingId, data);
|
||||
editingId = '';
|
||||
toasts.success($t('envEditor.envUpdated'));
|
||||
await loadStageEnv(selectedStageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(envId: string) {
|
||||
try {
|
||||
await api.deleteStageEnv(projectId, selectedStageId, envId);
|
||||
toasts.success($t('envEditor.envDeleted'));
|
||||
await loadStageEnv(selectedStageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function isOverridden(key: string): boolean {
|
||||
return envVars.some((e) => e.key === key);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
untrack(() => loadProject());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sid = selectedStageId;
|
||||
if (sid) {
|
||||
untrack(() => loadStageEnv(sid));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('envEditor.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<ForgeHero
|
||||
backHref={`/projects/${projectId}`}
|
||||
eyebrowSuffix="ENV"
|
||||
title={$t('envEditor.title')}
|
||||
lede={$t('envEditor.description')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton width="16rem" height="2.5rem" />
|
||||
<Skeleton height="12rem" />
|
||||
</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>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Project-level env -->
|
||||
{#if stages.length === 0}
|
||||
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
|
||||
{:else}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
|
||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each Object.entries(projectEnv) as [key, val] (key)}
|
||||
{#if editingProjectKey === key}
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={editProjectValue} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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"></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={savingProject} onclick={handleUpdateProjectEnv}><IconCheck size={16} /></button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { editingProjectKey = ''; }}><IconX size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors {isOverridden(key) ? 'opacity-50' : ''}">
|
||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{val}</td>
|
||||
<td class="px-4 py-2.5 text-sm">
|
||||
{#if isOverridden(key)}
|
||||
<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 badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap 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-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEditProjectEnv(key)}><IconEdit size={16} /></button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { projectEnvDeleteTarget = key; }}><IconTrash size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Add new project env row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newProjectKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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">
|
||||
<input type="text" bind:value={newProjectValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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"></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={!newProjectKey.trim() || savingProject}
|
||||
onclick={handleAddProjectEnv}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{savingProject ? $t('envEditor.adding') : $t('envEditor.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if Object.keys(projectEnv).length === 0}
|
||||
<p class="mt-2 text-center text-xs text-[var(--text-tertiary)]">{$t('envEditor.noProjectEnv')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stage-level overrides -->
|
||||
<div>
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2>
|
||||
<select
|
||||
id="stage-select"
|
||||
bind:value={selectedStageId}
|
||||
class="block w-48 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
||||
>
|
||||
{#each stages as stage (stage.id)}
|
||||
<option value={stage.id}>{stage.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if envLoading}
|
||||
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
|
||||
<IconLoader size={20} />
|
||||
<span class="text-sm">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.secret')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each envVars as env (env.id)}
|
||||
{#if editingId === env.id}
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={editKey} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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">
|
||||
<input type={editEncrypted ? 'password' : 'text'} bind:value={editValue} placeholder={env.encrypted ? $t('envEditor.leaveEmptyToKeep') : ''} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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">
|
||||
<ToggleSwitch bind:checked={editEncrypted} label={$t('envEditor.secret')} />
|
||||
</td>
|
||||
<td class="px-4 py-2.5"></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} title={$t('envEditor.save')}>
|
||||
<IconCheck size={16} />
|
||||
</button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit} title={$t('common.cancel')}>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{env.key}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{env.encrypted ? '••••••••' : env.value}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if env.encrypted}
|
||||
<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>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm">
|
||||
{#if env.key in projectEnv}
|
||||
<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 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">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(env)} title={$t('envEditor.edit')}>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { envDeleteTarget = env.id; }} title={$t('envEditor.delete')}>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Add new row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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">
|
||||
<input type={newEncrypted ? 'password' : 'text'} bind:value={newValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] 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">
|
||||
<ToggleSwitch bind:checked={newEncrypted} label={$t('envEditor.secret')} />
|
||||
</td>
|
||||
<td class="px-4 py-2.5"></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={!newKey.trim() || saving}
|
||||
onclick={handleAdd}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{saving ? $t('envEditor.adding') : $t('envEditor.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={envDeleteTarget !== null}
|
||||
title={$t('envEditor.deleteTitle')}
|
||||
message={$t('envEditor.deleteMessage')}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const envId = envDeleteTarget;
|
||||
envDeleteTarget = null;
|
||||
if (envId) await handleDelete(envId);
|
||||
}}
|
||||
oncancel={() => { envDeleteTarget = null; }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={projectEnvDeleteTarget !== null}
|
||||
title={$t('envEditor.deleteTitle')}
|
||||
message={$t('envEditor.deleteMessage')}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const key = projectEnvDeleteTarget;
|
||||
projectEnvDeleteTarget = null;
|
||||
if (key) await handleDeleteProjectEnv(key);
|
||||
}}
|
||||
oncancel={() => { projectEnvDeleteTarget = null; }}
|
||||
/>
|
||||
@@ -1,324 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
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 { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconInfo } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.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 newScope = $state<VolumeScope>('project');
|
||||
let newName = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state('');
|
||||
let editSource = $state('');
|
||||
let editTarget = $state('');
|
||||
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() {
|
||||
if (volumes.length === 0) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
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 {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
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(),
|
||||
scope: newScope,
|
||||
name: newScopeNeedsName ? newName.trim() : undefined
|
||||
});
|
||||
newSource = '';
|
||||
newTarget = '';
|
||||
newScope = 'project';
|
||||
newName = '';
|
||||
toasts.success($t('volumeEditor.volumeAdded'));
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(vol: Volume) {
|
||||
editingId = vol.id;
|
||||
editSource = vol.source;
|
||||
editTarget = vol.target;
|
||||
editScope = (vol.scope || 'project') as VolumeScope;
|
||||
editName = vol.name || '';
|
||||
}
|
||||
|
||||
function cancelEdit() { editingId = ''; }
|
||||
|
||||
async function handleUpdate() {
|
||||
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(),
|
||||
scope: editScope,
|
||||
name: editScopeNeedsName ? editName.trim() : undefined
|
||||
});
|
||||
editingId = '';
|
||||
toasts.success($t('volumeEditor.volumeUpdated'));
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(volId: string) {
|
||||
try {
|
||||
await api.deleteVolume(projectId, volId);
|
||||
toasts.success($t('volumeEditor.volumeDeleted'));
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
untrack(() => loadVolumes());
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('volumeEditor.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<ForgeHero
|
||||
backHref={`/projects/${projectId}`}
|
||||
eyebrowSuffix="VOLUMES"
|
||||
title={$t('volumeEditor.title')}
|
||||
lede={$t('volumeEditor.description')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<!-- 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" />
|
||||
</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={loadVolumes}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</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)]">
|
||||
<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.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>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each volumes as vol (vol.id)}
|
||||
{#if editingId === vol.id}
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<td class="px-4 py-2.5">
|
||||
{#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={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>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit}><IconX size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</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)]">
|
||||
{#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">
|
||||
<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">
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { volumeDeleteTarget = vol.id; }} title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Add new row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<td class="px-4 py-2.5">
|
||||
{#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={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={(!newScopeIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newScopeNeedsName && !newName.trim()) || saving}
|
||||
onclick={handleAdd}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{saving ? $t('volumeEditor.adding') : $t('volumeEditor.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if volumes.length === 0}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={volumeDeleteTarget !== null}
|
||||
title={$t('volumeEditor.deleteTitle')}
|
||||
message={$t('volumeEditor.deleteMessage')}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const volId = volumeDeleteTarget;
|
||||
volumeDeleteTarget = null;
|
||||
if (volId) await handleDelete(volId);
|
||||
}}
|
||||
oncancel={() => { volumeDeleteTarget = null; }}
|
||||
/>
|
||||
@@ -1,233 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { FileEntry } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconLoader, IconChevronRight } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
const volId = $derived($page.params.volId ?? '');
|
||||
|
||||
let entries = $state<FileEntry[]>([]);
|
||||
let currentPath = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let uploading = $state(false);
|
||||
|
||||
// Query params for instance/stage scoped volumes.
|
||||
const stage = $derived($page.url.searchParams.get('stage') ?? '');
|
||||
const tag = $derived($page.url.searchParams.get('tag') ?? '');
|
||||
|
||||
const breadcrumbs = $derived(() => {
|
||||
if (!currentPath) return [];
|
||||
return currentPath.split('/').filter(Boolean);
|
||||
});
|
||||
|
||||
function fileIcon(entry: FileEntry): string {
|
||||
if (entry.is_dir) return '📁';
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const icons: Record<string, string> = {
|
||||
jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
||||
txt: '📄', md: '📄', log: '📄', csv: '📄',
|
||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋', xml: '📋',
|
||||
js: '📜', ts: '📜', go: '📜', py: '📜', rs: '📜', sh: '📜',
|
||||
zip: '📦', tar: '📦', gz: '📦', rar: '📦',
|
||||
db: '🗄️', sqlite: '🗄️', sql: '🗄️',
|
||||
};
|
||||
return icons[ext] ?? '📄';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let size = bytes;
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
async function loadDir(path: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await api.browseVolume(projectId, volId, { path, stage, tag });
|
||||
entries = result.entries;
|
||||
currentPath = result.path || '';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('volumeBrowser.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
loadDir(path);
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index: number) {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const path = parts.slice(0, index + 1).join('/');
|
||||
navigateTo(path);
|
||||
}
|
||||
|
||||
function handleEntryClick(entry: FileEntry) {
|
||||
if (entry.is_dir) {
|
||||
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||
navigateTo(newPath);
|
||||
} else {
|
||||
// Download single file.
|
||||
const filePath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||
window.open(api.volumeDownloadUrl(projectId, volId, { path: filePath, stage, tag }), '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
window.open(api.volumeDownloadUrl(projectId, volId, { path: currentPath, stage, tag }), '_blank');
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
async function handleUpload() {
|
||||
if (!fileInput.files?.length) return;
|
||||
uploading = true;
|
||||
try {
|
||||
const result = await api.uploadToVolume(projectId, volId, fileInput.files, { path: currentPath, stage, tag });
|
||||
toasts.success(`${$t('volumeBrowser.uploaded')} ${result.count} ${$t('volumeBrowser.files')}`);
|
||||
fileInput.value = '';
|
||||
await loadDir(currentPath);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeBrowser.uploadFailed'));
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
void volId;
|
||||
loadDir('');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('volumeBrowser.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#snippet browserToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={downloadCurrent}
|
||||
>
|
||||
📦 {currentPath ? $t('volumeBrowser.downloadFolder') : $t('volumeBrowser.downloadAll')}
|
||||
</button>
|
||||
<label
|
||||
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors {uploading ? 'opacity-50 pointer-events-none' : ''}"
|
||||
>
|
||||
{#if uploading}
|
||||
<IconLoader size={14} class="animate-spin" />
|
||||
{/if}
|
||||
{$t('volumeBrowser.upload')}
|
||||
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
|
||||
</label>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref={`/projects/${projectId}/volumes`}
|
||||
eyebrowSuffix="VOLUME BROWSER"
|
||||
title={$t('volumeBrowser.title')}
|
||||
size="lg"
|
||||
toolbar={browserToolbar}
|
||||
/>
|
||||
|
||||
<!-- Path breadcrumbs -->
|
||||
<nav class="flex items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {currentPath === '' ? 'font-semibold' : ''}"
|
||||
onclick={() => navigateTo('')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{#each breadcrumbs() as segment, i}
|
||||
<IconChevronRight size={12} class="text-[var(--text-tertiary)]" />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {i === breadcrumbs().length - 1 ? 'font-semibold text-[var(--text-primary)]' : ''}"
|
||||
onclick={() => navigateToBreadcrumb(i)}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton height="16rem" />
|
||||
{: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={() => loadDir(currentPath)}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if entries.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('volumeBrowser.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.name')}</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.size')}</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.modified')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#if currentPath}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] cursor-pointer transition-colors" onclick={() => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
navigateTo(parts.join('/'));
|
||||
}}>
|
||||
<td class="px-4 py-2 text-sm text-[var(--text-link)]">
|
||||
<span class="mr-2">📁</span>..
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each entries.sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}) as entry (entry.name)}
|
||||
<tr
|
||||
class="hover:bg-[var(--surface-card-hover)] transition-colors {entry.is_dir ? 'cursor-pointer' : ''}"
|
||||
onclick={() => handleEntryClick(entry)}
|
||||
>
|
||||
<td class="px-4 py-2 text-sm text-[var(--text-primary)]">
|
||||
<span class="mr-2">{fileIcon(entry)}</span>
|
||||
{#if entry.is_dir}
|
||||
<span class="text-[var(--text-link)]">{entry.name}</span>
|
||||
{:else}
|
||||
{entry.name}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
{entry.is_dir ? '—' : formatSize(entry.size)}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-[var(--text-tertiary)]">
|
||||
{$fmt.compact(entry.mod_time)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
export const ssr = false;
|
||||
@@ -48,8 +48,13 @@
|
||||
: 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)] dark:text-[var(--color-brand-200)]';
|
||||
}
|
||||
|
||||
// Legacy /projects/{id} and /sites/{id} routes were retired with the
|
||||
// hard cutover. Proxy rows now point at the workload-first containers
|
||||
// page filtered by name; the app deep-link is not available because
|
||||
// proxy_route rows don't carry an app_id today.
|
||||
function targetHref(route: ProxyRoute): string {
|
||||
return route.source === 'static_site' ? `/sites/${route.instance_id}` : `/projects/${route.project_id}`;
|
||||
const q = encodeURIComponent(route.project_name ?? '');
|
||||
return q ? `/containers?q=${q}` : '/containers';
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { StaticSite } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconPlus, IconSearch, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
|
||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
|
||||
let sites = $state<StaticSite[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let deploying = $state<Record<string, boolean>>({});
|
||||
let confirmDelete = $state<StaticSite | null>(null);
|
||||
|
||||
const filteredSites = $derived(
|
||||
searchQuery.trim()
|
||||
? sites.filter(s => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return s.name.toLowerCase().includes(q)
|
||||
|| s.domain.toLowerCase().includes(q)
|
||||
|| s.repo_name.toLowerCase().includes(q);
|
||||
})
|
||||
: sites
|
||||
);
|
||||
|
||||
async function loadSites() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
sites = await api.listStaticSites();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load sites';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy(site: StaticSite) {
|
||||
deploying = { ...deploying, [site.id]: true };
|
||||
try {
|
||||
await api.deployStaticSite(site.id);
|
||||
// Refresh after a short delay to pick up status change.
|
||||
setTimeout(() => loadSites(), 2000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Deploy failed';
|
||||
} finally {
|
||||
deploying = { ...deploying, [site.id]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(site: StaticSite) {
|
||||
try {
|
||||
await api.stopStaticSite(site.id);
|
||||
setTimeout(() => loadSites(), 2000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Stop failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(site: StaticSite) {
|
||||
try {
|
||||
await api.startStaticSite(site.id);
|
||||
setTimeout(() => loadSites(), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Start failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try {
|
||||
await api.deleteStaticSite(id);
|
||||
await loadSites();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string): { text: string; class: string } {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing':
|
||||
return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
default:
|
||||
return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
}
|
||||
|
||||
function modeBadge(mode: string): { text: string; class: string } {
|
||||
if (mode === 'deno') {
|
||||
return { text: 'Deno', class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' };
|
||||
}
|
||||
return { text: 'Static', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadSites();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('sites.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#snippet heroToolbar()}
|
||||
<a href="/sites/new" class="forge-btn">
|
||||
<IconPlus size={14} />
|
||||
<span>{$t('sites.addSite')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrowSuffix="SITES"
|
||||
title={$t('sites.title')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<SkeletonTable rows={4} cols={5} />
|
||||
{: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={loadSites}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if sites.length === 0}
|
||||
<EmptyState
|
||||
title={$t('sites.noSites')}
|
||||
description={$t('sites.noSitesDesc')}
|
||||
actionLabel={$t('sites.addSite')}
|
||||
onaction={() => { window.location.href = '/sites/new'; }}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Search -->
|
||||
<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('sites.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 filteredSites.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('sites.noMatching')}</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)]">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.name')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.domain')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.mode')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.status')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.lastSync')}</th>
|
||||
<th class="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each filteredSites as site (site.id)}
|
||||
{@const status = statusBadge(site.status)}
|
||||
{@const mode = modeBadge(site.mode)}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<a href="/sites/{site.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
{site.name}
|
||||
</a>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name}</p>
|
||||
</td>
|
||||
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm">
|
||||
{#if site.domain}
|
||||
<a href="https://{site.domain}" target="_blank" rel="noopener noreferrer" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
{site.domain}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-[var(--text-tertiary)]">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {mode.class}">
|
||||
{mode.text}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {status.class}">
|
||||
{status.text}
|
||||
</span>
|
||||
{#if site.error}
|
||||
<p class="mt-0.5 max-w-[200px] truncate text-xs text-red-500" title={site.error}>{site.error}</p>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{#if site.last_sync_at}
|
||||
{$fmt.dateTime(site.last_sync_at)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title={$t('sites.deploy')}
|
||||
disabled={deploying[site.id]}
|
||||
onclick={() => handleDeploy(site)}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<IconRefresh size={16} class={deploying[site.id] ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
{#if site.status === 'stopped'}
|
||||
<button
|
||||
type="button"
|
||||
title={$t('sites.start')}
|
||||
onclick={() => handleStart(site)}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconPlay size={16} />
|
||||
</button>
|
||||
{:else if site.status === 'deployed'}
|
||||
<button
|
||||
type="button"
|
||||
title={$t('sites.stop')}
|
||||
onclick={() => handleStop(site)}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconStop size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
title={$t('common.delete')}
|
||||
onclick={() => { confirmDelete = site; }}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirmDelete}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title={$t('sites.confirmDelete')}
|
||||
message={`${$t('sites.confirmDeleteMsg')} "${confirmDelete.name}"?`}
|
||||
confirmLabel={$t('common.delete')}
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = null; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,484 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { StaticSite, StaticSiteSecret, StaticSiteStorageUsage } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
||||
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
|
||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
||||
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
||||
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
|
||||
|
||||
let site = $state<StaticSite | null>(null);
|
||||
let secrets = $state<StaticSiteSecret[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let deploying = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let confirmDeleteSecretId = $state<string | null>(null);
|
||||
|
||||
// Outgoing notification URL inline editor. The site has no full edit
|
||||
// form on this page; this small input lets operators set/clear the
|
||||
// per-site URL without going back to the create wizard.
|
||||
let editNotificationUrl = $state('');
|
||||
let savingNotificationUrl = $state(false);
|
||||
|
||||
async function saveNotificationUrl() {
|
||||
if (!site) return;
|
||||
savingNotificationUrl = true;
|
||||
try {
|
||||
await api.updateStaticSite(site.id, { notification_url: editNotificationUrl.trim() });
|
||||
site = { ...site, notification_url: editNotificationUrl.trim() };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save notification URL';
|
||||
} finally {
|
||||
savingNotificationUrl = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the editor with the loaded site once it arrives.
|
||||
$effect(() => {
|
||||
if (site && editNotificationUrl === '') {
|
||||
editNotificationUrl = site.notification_url ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
// Secret form.
|
||||
let showSecretForm = $state(false);
|
||||
let secretKey = $state('');
|
||||
let secretValue = $state('');
|
||||
let secretEncrypted = $state(true);
|
||||
let secretSubmitting = $state(false);
|
||||
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
|
||||
let showLogs = $state(false);
|
||||
|
||||
const siteId = $derived($page.params.id);
|
||||
|
||||
async function loadSite() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
site = await api.getStaticSite(siteId!);
|
||||
secrets = await api.listStaticSiteSecrets(siteId!);
|
||||
if (site.storage_enabled) {
|
||||
storageUsage = await api.getStaticSiteStorage(siteId!);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load site';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!site) return;
|
||||
deploying = true;
|
||||
try {
|
||||
await api.deployStaticSite(site.id);
|
||||
setTimeout(() => loadSite(), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Deploy failed';
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!site) return;
|
||||
try {
|
||||
await api.stopStaticSite(site.id);
|
||||
setTimeout(() => loadSite(), 2000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Stop failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (!site) return;
|
||||
try {
|
||||
await api.startStaticSite(site.id);
|
||||
setTimeout(() => loadSite(), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Start failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!site) return;
|
||||
confirmDelete = false;
|
||||
try {
|
||||
await api.deleteStaticSite(site.id);
|
||||
goto('/sites');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSecret() {
|
||||
if (!site || !secretKey.trim()) return;
|
||||
secretSubmitting = true;
|
||||
try {
|
||||
await api.createStaticSiteSecret(site.id, {
|
||||
key: secretKey.trim(),
|
||||
value: secretValue,
|
||||
encrypted: secretEncrypted
|
||||
});
|
||||
secretKey = '';
|
||||
secretValue = '';
|
||||
secretEncrypted = true;
|
||||
showSecretForm = false;
|
||||
secrets = await api.listStaticSiteSecrets(site.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add secret';
|
||||
} finally {
|
||||
secretSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSecret() {
|
||||
if (!site || !confirmDeleteSecretId) return;
|
||||
try {
|
||||
await api.deleteStaticSiteSecret(site.id, confirmDeleteSecretId);
|
||||
secrets = await api.listStaticSiteSecrets(site.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete secret';
|
||||
} finally {
|
||||
confirmDeleteSecretId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string): { text: string; class: string } {
|
||||
switch (status) {
|
||||
case 'deployed': return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing': return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed': return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
default: return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void siteId;
|
||||
loadSite();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{site?.name ?? $t('sites.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 py-8">
|
||||
<IconLoader size={20} class="animate-spin text-[var(--text-tertiary)]" />
|
||||
<span class="text-[var(--text-tertiary)]">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{:else if error && !site}
|
||||
<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>
|
||||
</div>
|
||||
{:else if site}
|
||||
{@const s = site}
|
||||
{#snippet siteToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
disabled={deploying}
|
||||
onclick={handleDeploy}
|
||||
class="forge-btn"
|
||||
>
|
||||
<IconRefresh size={14} class={deploying ? 'animate-spin' : ''} />
|
||||
<span>{$t('sites.deploy')}</span>
|
||||
</button>
|
||||
{#if s.status === 'stopped'}
|
||||
<button type="button" onclick={handleStart} class="forge-btn-ghost">
|
||||
<IconPlay size={14} />
|
||||
<span>{$t('sites.start')}</span>
|
||||
</button>
|
||||
{:else if s.status === 'deployed'}
|
||||
<button type="button" onclick={handleStop} class="forge-btn-ghost">
|
||||
<IconStop size={14} />
|
||||
<span>{$t('sites.stop')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if s.domain}
|
||||
<a
|
||||
href="https://{s.domain}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="forge-btn-ghost"
|
||||
>
|
||||
<IconGlobe size={14} />
|
||||
<span>{$t('sites.openSite')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { confirmDelete = true; }}
|
||||
class="forge-btn-icon forge-btn-danger"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref="/sites"
|
||||
eyebrowSuffix="SITE"
|
||||
title={s.name}
|
||||
kicker="{s.repo_owner}/{s.repo_name} · {s.branch}"
|
||||
size="lg"
|
||||
toolbar={siteToolbar}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status & Info -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Site Info -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.siteInfo')}</h2>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.status')}</span>
|
||||
<span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusBadge(site.status).class}">{statusBadge(site.status).text}</span>
|
||||
</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.domain || '-'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.folder_path || '/ (root)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.lastSync')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.last_sync_at ? $fmt.dateTime(site.last_sync_at) : '-'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.commitSha')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}</span>
|
||||
|
||||
{#if site.mode === 'deno' && site.storage_enabled}
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.dataPath')}</span>
|
||||
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if site.error}
|
||||
<div class="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-3">
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{site.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secrets -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('sites.secrets')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showSecretForm = !showSecretForm; }}
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{$t('sites.addSecret')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showSecretForm}
|
||||
<div class="mb-4 space-y-3 rounded-lg bg-[var(--surface-card-hover)] p-4">
|
||||
<FormField label={$t('sites.secretKey')} name="secretKey" bind:value={secretKey} placeholder="API_KEY" required />
|
||||
<FormField label={$t('sites.secretValue')} name="secretValue" bind:value={secretValue} placeholder="sk-..." />
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<ToggleSwitch bind:checked={secretEncrypted} label={$t('sites.encryptSecret')} />
|
||||
<span>{$t('sites.encryptSecret')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!secretKey.trim() || secretSubmitting}
|
||||
onclick={handleAddSecret}
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{secretSubmitting ? $t('common.saving') : $t('sites.saveSecret')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if secrets.length === 0}
|
||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noSecrets')}</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each secrets as secret (secret.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-[var(--border-secondary)] px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if secret.encrypted}
|
||||
<IconLock size={14} class="text-[var(--text-tertiary)]" />
|
||||
{:else}
|
||||
<IconUnlock size={14} class="text-[var(--text-tertiary)]" />
|
||||
{/if}
|
||||
<span class="font-mono text-sm text-[var(--text-primary)]">{secret.key}</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{secret.value}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { confirmDeleteSecretId = secret.id; }}
|
||||
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Resource usage + logs for deployed sites. -->
|
||||
{#if site.container_id}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('resources.sectionTitle')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showLogs = !showLogs; }}
|
||||
class="rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
{showLogs ? $t('resources.hideLogs') : $t('resources.showLogs')}
|
||||
</button>
|
||||
</div>
|
||||
<ContainerStats source={{ kind: 'site', siteId: site.id }} />
|
||||
</div>
|
||||
|
||||
{#if showLogs}
|
||||
<ContainerLogs
|
||||
source={{ kind: 'site', siteId: site.id }}
|
||||
onclose={() => { showLogs = false; }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Webhook (inbound: triggers a re-sync from the Git provider). -->
|
||||
<WebhookPanel
|
||||
title={$t('sites.webhookTitle')}
|
||||
description={$t('sites.webhookDesc')}
|
||||
fetchWebhook={() => api.getStaticSiteWebhook(siteId!)}
|
||||
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
||||
regenerateSigningSecret={() => api.regenerateStaticSiteSigningSecret(siteId!)}
|
||||
disableSigning={() => api.disableStaticSiteSigningSecret(siteId!)}
|
||||
setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)}
|
||||
/>
|
||||
|
||||
<!-- Recent inbound webhook activity (debug + audit). -->
|
||||
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listStaticSiteWebhookDeliveries(siteId!, signal)} />
|
||||
|
||||
<!-- Outgoing notification URL (per-site override; falls through to global). -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('sites.outgoingUrlDesc')}</p>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<FormField
|
||||
label=""
|
||||
name="siteNotificationUrl"
|
||||
bind:value={editNotificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveNotificationUrl}
|
||||
disabled={savingNotificationUrl || editNotificationUrl === (site.notification_url ?? '')}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if savingNotificationUrl}<IconLoader size={16} />{/if}
|
||||
{$t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge posts site_sync_* events). -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('sites.outgoingWebhookTitle')}
|
||||
description={$t('sites.outgoingWebhookDesc')}
|
||||
hasUrl={!!site.notification_url}
|
||||
fallbackLabel={$t('sites.outgoingFallbackGlobal')}
|
||||
fetchSecret={() => api.getStaticSiteNotificationSecret(siteId!)}
|
||||
regenerateSecret={() => api.regenerateStaticSiteNotificationSecret(siteId!)}
|
||||
disableSigning={() => api.disableStaticSiteNotificationSigning(siteId!)}
|
||||
sendTest={() => api.testStaticSiteNotification(siteId!)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Storage -->
|
||||
{#if site.storage_enabled && site.mode === 'deno'}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.storage')}</h2>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageVolume')}</span>
|
||||
<span class="font-mono text-xs text-[var(--text-primary)]">tinyforge-site-{site.name}-data</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageMountPath')}</span>
|
||||
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageLimit')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.storage_limit_mb > 0 ? `${site.storage_limit_mb} MB` : $t('sites.unlimited')}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageUsed')}</span>
|
||||
<span class="text-[var(--text-primary)]">
|
||||
{#if storageUsage}
|
||||
{storageUsage.used_bytes < 1024 ? `${storageUsage.used_bytes} B` : storageUsage.used_bytes < 1048576 ? `${(storageUsage.used_bytes / 1024).toFixed(1)} KB` : `${(storageUsage.used_bytes / 1048576).toFixed(1)} MB`}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if storageUsage && site.storage_limit_mb > 0}
|
||||
{@const pct = Math.min(100, (storageUsage.used_bytes / (site.storage_limit_mb * 1048576)) * 100)}
|
||||
<div class="mt-4">
|
||||
<div class="h-2 rounded-full bg-[var(--surface-card-hover)] overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'}"
|
||||
style="width: {pct.toFixed(1)}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">{pct.toFixed(1)}% {$t('sites.storageOfLimit')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirmDelete}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title={$t('sites.confirmDelete')}
|
||||
message={`${$t('sites.confirmDeleteMsg')} "${site?.name}"?`}
|
||||
confirmLabel={$t('common.delete')}
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if confirmDeleteSecretId}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDeleteSecretId}
|
||||
title={$t('sites.confirmDeleteSecret')}
|
||||
message={`${$t('sites.confirmDeleteSecretMsg')} "${secrets.find(s => s.id === confirmDeleteSecretId)?.key}"?`}
|
||||
confirmLabel={$t('common.delete')}
|
||||
onconfirm={handleDeleteSecret}
|
||||
oncancel={() => { confirmDeleteSecretId = null; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,702 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { FolderEntry, GitProvider } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { IconCheck, IconLoader, IconChevronRight, IconSearch } from '$lib/components/icons';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
|
||||
// Provider options.
|
||||
const providerOptions: { value: GitProvider; label: string }[] = [
|
||||
{ value: '', label: 'Auto-detect' },
|
||||
{ value: 'gitea', label: 'Gitea / Forgejo / Gogs' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'gitlab', label: 'GitLab' },
|
||||
];
|
||||
|
||||
// Wizard state.
|
||||
let step = $state(1);
|
||||
const totalSteps = 5;
|
||||
|
||||
// Step 1: Repo URL.
|
||||
let fullRepoUrl = $state('');
|
||||
let provider = $state<GitProvider>('');
|
||||
let detectedProvider = $state<GitProvider>('');
|
||||
let detecting = $state(false);
|
||||
let giteaUrl = $state('');
|
||||
let repoOwner = $state('');
|
||||
let repoName = $state('');
|
||||
let accessToken = $state('');
|
||||
let connectionTested = $state(false);
|
||||
let connectionError = $state('');
|
||||
let testing = $state(false);
|
||||
|
||||
// Repo picker.
|
||||
let showRepoPicker = $state(false);
|
||||
let repoPickerItems = $state<EntityPickerItem[]>([]);
|
||||
let repoPickerLoading = $state(false);
|
||||
|
||||
// The effective provider (explicit selection or autodetected).
|
||||
const effectiveProvider = $derived(provider || detectedProvider || 'gitea');
|
||||
|
||||
// Step 2: Branch picker.
|
||||
let branches = $state<string[]>([]);
|
||||
let selectedBranch = $state('');
|
||||
let branchesLoading = $state(false);
|
||||
let showBranchPicker = $state(false);
|
||||
|
||||
// Step 3: Folder picker.
|
||||
let tree = $state<FolderEntry[]>([]);
|
||||
let selectedFolder = $state('');
|
||||
let treeLoading = $state(false);
|
||||
let expandedDirs = $state<Set<string>>(new Set());
|
||||
|
||||
// Step 4: Configuration.
|
||||
let siteName = $state('');
|
||||
let domain = $state('');
|
||||
let mode = $state<'static' | 'deno'>('static');
|
||||
let renderMarkdown = $state(false);
|
||||
let syncTrigger = $state<'push' | 'tag' | 'manual'>('manual');
|
||||
let tagPattern = $state('');
|
||||
let storageEnabled = $state(false);
|
||||
let storageLimitStr = $state('0');
|
||||
|
||||
// Step 5: Review + submit.
|
||||
let submitting = $state(false);
|
||||
let submitError = $state('');
|
||||
|
||||
// Parse repo URL into components and autodetect provider.
|
||||
function parseRepoUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length >= 2) {
|
||||
giteaUrl = `${parsed.protocol}//${parsed.host}`;
|
||||
repoOwner = pathParts[0];
|
||||
repoName = pathParts[1];
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL yet.
|
||||
}
|
||||
}
|
||||
|
||||
async function browseRepos() {
|
||||
if (!giteaUrl) return;
|
||||
showRepoPicker = true;
|
||||
if (repoPickerItems.length > 0) return;
|
||||
|
||||
repoPickerLoading = true;
|
||||
try {
|
||||
await autoDetectProvider();
|
||||
const repos = await api.listStaticSiteRepos({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
});
|
||||
repoPickerItems = repos.map(r => ({
|
||||
value: JSON.stringify({ owner: r.owner, name: r.name }),
|
||||
label: r.full_name,
|
||||
description: r.description || undefined,
|
||||
icon: r.private ? 'lock' : undefined,
|
||||
}));
|
||||
} catch {
|
||||
repoPickerItems = [];
|
||||
} finally {
|
||||
repoPickerLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPickedRepo(value: string) {
|
||||
const parsed = JSON.parse(value) as { owner: string; name: string };
|
||||
repoOwner = parsed.owner;
|
||||
repoName = parsed.name;
|
||||
showRepoPicker = false;
|
||||
}
|
||||
|
||||
async function autoDetectProvider() {
|
||||
if (!giteaUrl || provider) return; // skip if manually selected
|
||||
detecting = true;
|
||||
try {
|
||||
const result = await api.detectStaticSiteProvider(giteaUrl);
|
||||
detectedProvider = result.provider;
|
||||
} catch {
|
||||
detectedProvider = 'gitea';
|
||||
} finally {
|
||||
detecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing = true;
|
||||
connectionError = '';
|
||||
connectionTested = false;
|
||||
try {
|
||||
// Autodetect provider if not manually set.
|
||||
await autoDetectProvider();
|
||||
|
||||
await api.testStaticSiteConnection({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName
|
||||
});
|
||||
connectionTested = true;
|
||||
} catch (e) {
|
||||
connectionError = e instanceof Error ? e.message : 'Connection failed';
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBranches() {
|
||||
branchesLoading = true;
|
||||
try {
|
||||
branches = await api.listStaticSiteBranches({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName
|
||||
});
|
||||
if (branches.length > 0 && !selectedBranch) {
|
||||
// Default to main/master if available.
|
||||
selectedBranch = branches.find(b => b === 'main') ?? branches.find(b => b === 'master') ?? branches[0];
|
||||
}
|
||||
} catch {
|
||||
branches = [];
|
||||
} finally {
|
||||
branchesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
treeLoading = true;
|
||||
try {
|
||||
tree = await api.listStaticSiteTree({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName,
|
||||
branch: selectedBranch
|
||||
});
|
||||
} catch {
|
||||
tree = [];
|
||||
} finally {
|
||||
treeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToStep(s: number) {
|
||||
step = s;
|
||||
if (s === 2 && branches.length === 0) loadBranches();
|
||||
if (s === 3 && tree.length === 0) loadTree();
|
||||
if (s === 4) {
|
||||
if (!siteName) siteName = repoName;
|
||||
// Autodetect Deno mode: check if selected folder has an api/ subdirectory.
|
||||
const apiPrefix = selectedFolder ? selectedFolder + '/api' : 'api';
|
||||
const hasApi = tree.some(e => e.is_dir && (e.path === apiPrefix || e.path.startsWith(apiPrefix + '/')));
|
||||
if (hasApi) {
|
||||
mode = 'deno';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tree helpers.
|
||||
const folders = $derived(tree.filter(e => e.is_dir).sort((a, b) => a.path.localeCompare(b.path)));
|
||||
|
||||
function getTopLevelFolders(): FolderEntry[] {
|
||||
return folders.filter(f => !f.path.includes('/'));
|
||||
}
|
||||
|
||||
function getChildFolders(parentPath: string): FolderEntry[] {
|
||||
return folders.filter(f => {
|
||||
if (!f.path.startsWith(parentPath + '/')) return false;
|
||||
const rest = f.path.slice(parentPath.length + 1);
|
||||
return !rest.includes('/');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDir(path: string) {
|
||||
const next = new Set(expandedDirs);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
expandedDirs = next;
|
||||
}
|
||||
|
||||
function selectFolder(path: string) {
|
||||
selectedFolder = path;
|
||||
}
|
||||
|
||||
// Branch picker items.
|
||||
const branchPickerItems = $derived<EntityPickerItem[]>(
|
||||
branches.map(b => ({ value: b, label: b }))
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting = true;
|
||||
submitError = '';
|
||||
try {
|
||||
const site = await api.createStaticSite({
|
||||
name: siteName,
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName,
|
||||
branch: selectedBranch,
|
||||
folder_path: selectedFolder,
|
||||
access_token: accessToken || undefined,
|
||||
domain: domain || undefined,
|
||||
mode,
|
||||
render_markdown: renderMarkdown,
|
||||
sync_trigger: syncTrigger,
|
||||
tag_pattern: syncTrigger === 'tag' ? tagPattern : undefined,
|
||||
storage_enabled: storageEnabled,
|
||||
storage_limit_mb: parseInt(storageLimitStr, 10) || 0
|
||||
});
|
||||
goto(`/sites/${site.id}`);
|
||||
} catch (e) {
|
||||
submitError = e instanceof Error ? e.message : 'Failed to create site';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('sites.newSite')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<ForgeHero
|
||||
backHref="/sites"
|
||||
eyebrowSuffix="NEW SITE"
|
||||
title={$t('sites.newSite')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#each Array(totalSteps) as _, i}
|
||||
<div class="h-1.5 flex-1 rounded-full transition-colors {i < step ? 'bg-[var(--color-brand-600)]' : 'bg-[var(--border-primary)]'}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
|
||||
<!-- Step 1: Repository -->
|
||||
{#if step === 1}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step1Title')}</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Provider selector -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.provider')}</label>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#each providerOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-2 text-sm font-medium transition-colors {provider === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { provider = opt.value; detectedProvider = ''; }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if provider === '' && detectedProvider}
|
||||
<p class="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{$t('sites.detectedProvider')}: {providerOptions.find(o => o.value === detectedProvider)?.label ?? detectedProvider}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Paste full URL for auto-fill -->
|
||||
<FormField
|
||||
label={$t('sites.fullRepoUrl')}
|
||||
name="fullRepoUrl"
|
||||
bind:value={fullRepoUrl}
|
||||
placeholder="https://git.example.com/owner/repo"
|
||||
helpText={$t('sites.fullRepoUrlHelp')}
|
||||
oninput={(e) => {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
if (val.includes('/') && val.startsWith('http')) {
|
||||
parseRepoUrl(val);
|
||||
autoDetectProvider();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Individual fields (auto-filled or manual) -->
|
||||
<FormField label={$t('sites.serverUrl')} name="serverUrl" bind:value={giteaUrl} placeholder="https://git.example.com" required />
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label={$t('sites.repoOwner')} name="repoOwner" bind:value={repoOwner} placeholder="username" required />
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<FormField label={$t('sites.repoName')} name="repoName" bind:value={repoName} placeholder="my-app" required />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={browseRepos}
|
||||
title={$t('sites.browseRepos')}
|
||||
disabled={!giteaUrl}
|
||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{#if repoPickerLoading}
|
||||
<IconLoader size={16} class="animate-spin" />
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EntityPicker
|
||||
bind:open={showRepoPicker}
|
||||
items={repoPickerItems}
|
||||
current={repoOwner && repoName ? JSON.stringify({ owner: repoOwner, name: repoName }) : ''}
|
||||
title={$t('sites.selectRepo')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={selectPickedRepo}
|
||||
onclose={() => { showRepoPicker = false; }}
|
||||
/>
|
||||
<FormField
|
||||
label={$t('sites.accessToken')}
|
||||
name="accessToken"
|
||||
type="password"
|
||||
bind:value={accessToken}
|
||||
placeholder={$t('sites.accessTokenPlaceholder')}
|
||||
helpText={$t('sites.accessTokenHelp')}
|
||||
/>
|
||||
|
||||
{#if connectionError}
|
||||
<div class="rounded-lg bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{connectionError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionTested}
|
||||
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 p-3 flex items-center gap-2">
|
||||
<IconCheck size={16} class="text-emerald-600" />
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-400">{$t('sites.connectionSuccess')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
disabled={!giteaUrl || !repoOwner || !repoName || testing}
|
||||
onclick={testConnection}
|
||||
>
|
||||
{#if testing}
|
||||
<IconLoader size={14} class="inline mr-1 animate-spin" />
|
||||
{/if}
|
||||
{$t('sites.testConnection')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={!connectionTested}
|
||||
onclick={() => goToStep(2)}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Branch -->
|
||||
{:else if step === 2}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step2Title')}</h2>
|
||||
|
||||
{#if branchesLoading}
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
|
||||
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingBranches')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectBranch')}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left rounded-lg border border-[var(--border-primary)] px-4 py-3 text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={() => { showBranchPicker = true; }}
|
||||
>
|
||||
<span class="font-medium text-[var(--text-primary)]">{selectedBranch || $t('sites.chooseBranch')}</span>
|
||||
</button>
|
||||
<EntityPicker
|
||||
bind:open={showBranchPicker}
|
||||
items={branchPickerItems}
|
||||
current={selectedBranch}
|
||||
title={$t('sites.selectBranch')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={(val) => { selectedBranch = val; showBranchPicker = false; tree = []; }}
|
||||
onclose={() => { showBranchPicker = false; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 1; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={!selectedBranch}
|
||||
onclick={() => goToStep(3)}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Folder -->
|
||||
{:else if step === 3}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step3Title')}</h2>
|
||||
|
||||
{#if treeLoading}
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
|
||||
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingTree')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectFolder')}</p>
|
||||
|
||||
<!-- Root option -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left rounded-lg px-4 py-2 text-sm transition-colors mb-1 {selectedFolder === '' ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
|
||||
onclick={() => selectFolder('')}
|
||||
>
|
||||
/ (root)
|
||||
</button>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto rounded-lg border border-[var(--border-primary)] p-2">
|
||||
{#each getTopLevelFolders() as folder (folder.path)}
|
||||
{@const isSelected = selectedFolder === folder.path}
|
||||
{@const isExpanded = expandedDirs.has(folder.path)}
|
||||
{@const children = getChildFolders(folder.path)}
|
||||
<div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if children.length > 0}
|
||||
<button type="button" class="p-0.5 text-[var(--text-tertiary)]" onclick={() => toggleDir(folder.path)}>
|
||||
<IconChevronRight size={14} class="transition-transform {isExpanded ? 'rotate-90' : ''}" />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="w-5"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 text-left rounded px-2 py-1.5 text-sm transition-colors {isSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-primary)]'}"
|
||||
onclick={() => selectFolder(folder.path)}
|
||||
>
|
||||
{folder.path}
|
||||
</button>
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<div class="ml-5">
|
||||
{#each children as child (child.path)}
|
||||
{@const childSelected = selectedFolder === child.path}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left rounded px-2 py-1.5 text-sm transition-colors {childSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
|
||||
onclick={() => selectFolder(child.path)}
|
||||
>
|
||||
{child.path.split('/').pop()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedFolder}
|
||||
<p class="mt-2 text-xs text-[var(--text-tertiary)]">{$t('sites.selectedFolder')}: <strong>{selectedFolder || '/'}</strong></p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 2; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
|
||||
onclick={() => goToStep(4)}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Configuration -->
|
||||
{:else if step === 4}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step4Title')}</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label={$t('sites.siteName')} name="siteName" bind:value={siteName} placeholder="my-site" required />
|
||||
<FormField label={$t('sites.domain')} name="domain" bind:value={domain} placeholder="site.example.com" helpText={$t('sites.domainHelp')} />
|
||||
</div>
|
||||
|
||||
<!-- Mode -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.mode')}</label>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'static' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { mode = 'static'; }}
|
||||
>
|
||||
<div class="font-medium text-[var(--text-primary)]">Static</div>
|
||||
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeStaticDesc')}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'deno' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { mode = 'deno'; }}
|
||||
>
|
||||
<div class="font-medium text-[var(--text-primary)]">Deno</div>
|
||||
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeDenoDesc')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync trigger -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.syncTrigger')}</label>
|
||||
<div class="flex gap-3">
|
||||
{#each [
|
||||
{ value: 'manual', label: $t('sites.triggerManual') },
|
||||
{ value: 'push', label: $t('sites.triggerPush') },
|
||||
{ value: 'tag', label: $t('sites.triggerTag') }
|
||||
] as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-2.5 text-sm text-center font-medium transition-colors {syncTrigger === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { syncTrigger = opt.value as 'push' | 'tag' | 'manual'; }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if syncTrigger === 'tag'}
|
||||
<FormField label={$t('sites.tagPattern')} name="tagPattern" bind:value={tagPattern} placeholder="v*" helpText={$t('sites.tagPatternHelp')} />
|
||||
{/if}
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<ToggleSwitch bind:checked={renderMarkdown} label={$t('sites.renderMarkdown')} />
|
||||
<span>{$t('sites.renderMarkdown')}</span>
|
||||
</div>
|
||||
|
||||
<!-- Persistent Storage (Deno only) -->
|
||||
{#if mode === 'deno'}
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<ToggleSwitch bind:checked={storageEnabled} label={$t('sites.enableStorage')} />
|
||||
<span>{$t('sites.enableStorage')}</span>
|
||||
</div>
|
||||
{#if storageEnabled}
|
||||
<div class="space-y-3 rounded-lg border border-[var(--border-secondary)] p-4">
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.storageHelp')}</p>
|
||||
<FormField
|
||||
label={$t('sites.storageLimitMB')}
|
||||
name="storageLimitMB"
|
||||
type="number"
|
||||
bind:value={storageLimitStr}
|
||||
placeholder="0"
|
||||
helpText={$t('sites.storageLimitHelp')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 3; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={!siteName.trim()}
|
||||
onclick={() => { step = 5; }}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Review -->
|
||||
{:else if step === 5}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step5Title')}</h2>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg bg-[var(--surface-card-hover)] p-4">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.provider')}</span>
|
||||
<span class="text-[var(--text-primary)]">{providerOptions.find(o => o.value === effectiveProvider)?.label ?? effectiveProvider}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.repoUrl')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{giteaUrl}/{repoOwner}/{repoName}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.branch')}</span>
|
||||
<span class="text-[var(--text-primary)]">{selectedBranch}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
|
||||
<span class="text-[var(--text-primary)]">{selectedFolder || '/ (root)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.siteName')}</span>
|
||||
<span class="text-[var(--text-primary)]">{siteName}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
|
||||
<span class="text-[var(--text-primary)]">{domain || '-'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
|
||||
<span class="text-[var(--text-primary)]">{mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
|
||||
<span class="text-[var(--text-primary)]">{syncTrigger}{syncTrigger === 'tag' ? ` (${tagPattern})` : ''}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.renderMarkdown')}</span>
|
||||
<span class="text-[var(--text-primary)]">{renderMarkdown ? $t('common.yes') : $t('common.no')}</span>
|
||||
|
||||
{#if mode === 'deno'}
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storage')}</span>
|
||||
<span class="text-[var(--text-primary)]">{storageEnabled ? (parseInt(storageLimitStr, 10) > 0 ? `${storageLimitStr} MB` : $t('sites.unlimited')) : $t('common.no')}</span>
|
||||
{/if}
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.accessToken')}</span>
|
||||
<span class="text-[var(--text-primary)]">{accessToken ? '••••••••' : $t('sites.noToken')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if submitError}
|
||||
<div class="mt-4 rounded-lg bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 4; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={submitting}
|
||||
onclick={handleSubmit}
|
||||
>
|
||||
{#if submitting}
|
||||
<IconLoader size={14} class="inline mr-1 animate-spin" />
|
||||
{/if}
|
||||
{$t('sites.createSite')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,535 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Stack } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let stacks = $state<Stack[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<Stack | null>(null);
|
||||
let deleteRemoveVolumes = $state(false);
|
||||
|
||||
async function loadStacks() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try { stacks = await api.listStacks(); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Failed to load stacks'; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
|
||||
async function handleStop(s: Stack) {
|
||||
try { await api.stopStack(s.id); setTimeout(loadStacks, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
|
||||
}
|
||||
async function handleStart(s: Stack) {
|
||||
try { await api.startStack(s.id); setTimeout(loadStacks, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
|
||||
}
|
||||
async function handleDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
const removeVolumes = deleteRemoveVolumes;
|
||||
confirmDelete = null; deleteRemoveVolumes = false;
|
||||
try { await api.deleteStack(id, removeVolumes); await loadStacks(); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
onMount(loadStacks);
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
{#snippet stacksToolbar()}
|
||||
<button class="forge-btn-icon" onclick={loadStacks} aria-label={$t('stacks.refresh')}>
|
||||
<IconRefresh size={16} />
|
||||
</button>
|
||||
<a href="/stacks/new" class="forge-btn">
|
||||
<IconPlus size={14} />
|
||||
<span>{$t('stacks.newStack')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
{#snippet stacksStats()}
|
||||
<div><dt>{$t('stacks.total').toUpperCase()}</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
|
||||
<div><dt>{$t('stacks.running').toUpperCase()}</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
|
||||
<div><dt>{$t('stacks.deploying').toUpperCase()}</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
|
||||
<div><dt>{$t('stacks.failed').toUpperCase()}</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
|
||||
{/snippet}
|
||||
{#snippet stacksLede()}{@html $t('stacks.lede')}{/snippet}
|
||||
<ForgeHero
|
||||
eyebrow={$t('stacks.eyebrow')}
|
||||
eyebrowSuffix={$t('stacks.title').toUpperCase()}
|
||||
title={$t('stacks.title')}
|
||||
size="lg"
|
||||
toolbar={stacksToolbar}
|
||||
lede_html={stacksLede}
|
||||
stats={stacksStats}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="grid">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="skeleton" style:--i={i}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stacks.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-mark">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<h2>{$t('stacks.empty.title')}</h2>
|
||||
<p>{$t('stacks.empty.desc')}</p>
|
||||
<a href="/stacks/new" class="btn-primary">
|
||||
<IconPlus size={16} /><span>{$t('stacks.newStack')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each stacks as s, i (s.id)}
|
||||
{@const sm = statusMeta(s.status)}
|
||||
<article class="card {sm.cls}">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="card-head">
|
||||
<span class="card-ref">[{String(i + 1).padStart(2, '0')} / {String(stacks.length).padStart(2, '0')}]</span>
|
||||
<span class="status-pill">
|
||||
<span class="pulse"></span>
|
||||
{sm.label}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<a href="/stacks/{s.id}" class="card-title">{s.name}</a>
|
||||
{#if s.description}
|
||||
<p class="card-desc">{s.description}</p>
|
||||
{:else}
|
||||
<p class="card-desc dim">{$t('stacks.card.noDescription')}</p>
|
||||
{/if}
|
||||
|
||||
{#if s.error}
|
||||
<div class="card-err" title={s.error}>{s.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="meta-k">{$t('stacks.card.updated')}</span>
|
||||
<span class="meta-v">{$fmt.dateTime(s.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
{#if s.status === 'running'}
|
||||
<button class="act" onclick={() => handleStop(s)} aria-label={$t('stacks.card.stop')}>
|
||||
<IconStop size={13} /><span>{$t('stacks.card.stop')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="act" onclick={() => handleStart(s)} aria-label={$t('stacks.card.start')}>
|
||||
<IconPlay size={13} /><span>{$t('stacks.card.start')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label={$t('stacks.card.delete')}>
|
||||
<IconTrash size={13} /><span>{$t('stacks.card.delete')}</span>
|
||||
</button>
|
||||
<a class="act-link" href="/stacks/{s.id}">{$t('stacks.card.open')} <span class="arrow">→</span></a>
|
||||
</footer>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title={$t('stacks.detail.delete.title')}
|
||||
message={confirmDelete ? $t('stacks.detail.delete.messageBase', { name: confirmDelete.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = null; deleteRemoveVolumes = false; }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* subtle workshop dot grid behind hero */
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 480px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.head-top {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 1.5rem; gap: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.title-accent {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 60ch;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.lede :global(em) {
|
||||
color: var(--accent);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Alert ─────────────────────────────────────── */
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Empty ─────────────────────────────────────── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.empty-mark {
|
||||
display: inline-flex; gap: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.empty-mark span {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.empty-mark span:nth-child(2) { background: var(--accent); animation: breathe 2.4s ease-in-out infinite; }
|
||||
.empty h2 {
|
||||
font-family: var(--serif); font-weight: 700;
|
||||
font-size: 1.5rem; margin: 0 0 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.empty p { color: var(--text-secondary); margin: 0 0 1.5rem; font-size: 0.95rem; }
|
||||
.empty :global(.btn-primary) { display: inline-flex; }
|
||||
|
||||
/* ── Grid & Cards ──────────────────────────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.skeleton {
|
||||
height: 230px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(110deg,
|
||||
var(--surface-card) 20%,
|
||||
var(--surface-card-hover) 50%,
|
||||
var(--surface-card) 80%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
animation-delay: calc(var(--i) * 120ms);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 1.25rem 1.25rem 1.1rem;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute; left: 0; top: 18px; bottom: 18px;
|
||||
width: 3px; border-radius: 0 3px 3px 0;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.card.st-running::before { background: var(--color-success); }
|
||||
.card.st-deploying::before{
|
||||
background: repeating-linear-gradient(0deg,
|
||||
var(--color-info) 0 6px,
|
||||
color-mix(in srgb, var(--color-info) 35%, transparent) 6px 12px);
|
||||
}
|
||||
.card.st-failed::before { background: var(--color-danger); }
|
||||
.card:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
box-shadow: 0 0 0 1px var(--color-brand-400), 0 14px 30px -18px var(--glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* registration corners (precision marks) */
|
||||
.reg {
|
||||
position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid; border-width: 0;
|
||||
opacity: 0; transition: opacity 180ms ease;
|
||||
}
|
||||
.card:hover .reg { opacity: 1; }
|
||||
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; }
|
||||
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; }
|
||||
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; }
|
||||
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; }
|
||||
|
||||
.card-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.card-ref {
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-pill .pulse {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.st-running .status-pill { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.st-running .status-pill .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
|
||||
.st-deploying .status-pill { background: var(--color-info-light); color: var(--color-info-dark); }
|
||||
.st-deploying .status-pill .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
|
||||
.st-failed .status-pill { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
.st-failed .status-pill .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
|
||||
:global([data-theme='dark']) .st-running .status-pill { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .st-deploying .status-pill { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
|
||||
:global([data-theme='dark']) .st-failed .status-pill { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
70%, 90% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.15rem; line-height: 1.3;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.card-title:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.card-desc.dim { color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.card-err {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-danger-dark);
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.85rem;
|
||||
border-left: 2px solid var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
:global([data-theme='dark']) .card-err {
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex; gap: 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.55rem 0;
|
||||
margin-bottom: 0.9rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
border-bottom: 1px dashed var(--border-primary);
|
||||
}
|
||||
.card-meta .meta-k {
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.1em; text-transform: uppercase; font-size: 0.62rem;
|
||||
align-self: center;
|
||||
}
|
||||
.card-meta .meta-v { color: var(--text-secondary); }
|
||||
|
||||
.card-foot {
|
||||
display: flex; gap: 0.4rem; align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.act {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.38rem 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.act:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.act.danger { color: var(--color-danger); }
|
||||
.act.danger:hover {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .act.danger:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.act-link {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.act-link .arrow { display: inline-block; transition: transform 150ms ease; }
|
||||
.act-link:hover { color: var(--color-brand-700); }
|
||||
.act-link:hover .arrow { transform: translateX(3px); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.head-top { align-items: flex-start; }
|
||||
.display { font-size: 3rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,953 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Stack, StackRevision, StackService } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft, IconRefresh, IconPlay, IconStop, IconTrash } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
let stack = $state<Stack | null>(null);
|
||||
let revisions = $state<StackRevision[]>([]);
|
||||
let services = $state<StackService[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let editing = $state(false);
|
||||
let editYaml = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
let logsService = $state('');
|
||||
let logsText = $state('');
|
||||
let logsLoading = $state(false);
|
||||
|
||||
let confirmRollback = $state<StackRevision | null>(null);
|
||||
let confirmDelete = $state(false);
|
||||
let deleteRemoveVolumes = $state(false);
|
||||
|
||||
let tab = $state<'yaml' | 'revisions' | 'logs'>('yaml');
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function loadAll() {
|
||||
loading = true; error = '';
|
||||
try {
|
||||
const [s, revs, svcs] = await Promise.all([
|
||||
api.getStack(id),
|
||||
api.listStackRevisions(id),
|
||||
api.getStackServices(id).catch(() => [] as StackService[])
|
||||
]);
|
||||
stack = s; revisions = revs; services = svcs;
|
||||
if (!editing && revs.length > 0) editYaml = revs[0].yaml;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('stacks.detail.errors.load');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!stack) return;
|
||||
try { await api.stopStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.stop'); }
|
||||
}
|
||||
async function handleStart() {
|
||||
if (!stack) return;
|
||||
try { await api.startStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.start'); }
|
||||
}
|
||||
async function submitNewRevision() {
|
||||
if (!stack) return;
|
||||
submitting = true; error = '';
|
||||
try { await api.createStackRevision(stack.id, editYaml); editing = false; setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.update'); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
async function doRollback() {
|
||||
if (!stack || !confirmRollback) return;
|
||||
const revId = confirmRollback.id;
|
||||
confirmRollback = null;
|
||||
try { await api.rollbackStack(stack.id, revId); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.rollback'); }
|
||||
}
|
||||
async function doDelete() {
|
||||
if (!stack) return;
|
||||
const sid = stack.id;
|
||||
const rm = deleteRemoveVolumes;
|
||||
confirmDelete = false; deleteRemoveVolumes = false;
|
||||
try { await api.deleteStack(sid, rm); await goto('/stacks'); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.delete'); }
|
||||
}
|
||||
async function loadLogs() {
|
||||
if (!stack) return;
|
||||
logsLoading = true;
|
||||
try { logsText = await api.getStackLogs(stack.id, logsService || undefined, 300); }
|
||||
catch (e) { logsText = e instanceof Error ? e.message : $t('stacks.detail.errors.fetchLogs'); }
|
||||
finally { logsLoading = false; }
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function serviceState(s: string): string {
|
||||
if (!s) return 'unknown';
|
||||
return s.toLowerCase();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadAll();
|
||||
refreshTimer = setInterval(() => { if (!editing) loadAll(); }, 5000);
|
||||
});
|
||||
onDestroy(() => { if (refreshTimer) clearInterval(refreshTimer); });
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
<div class="dot-grid" aria-hidden="true"></div>
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>{$t('stacks.title').toUpperCase()}</span>
|
||||
</a>
|
||||
|
||||
{#if loading && !stack}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
<span>{$t('stacks.detail.loading')}</span>
|
||||
</div>
|
||||
{:else if error && !stack}
|
||||
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
|
||||
{:else if stack}
|
||||
{@const sm = statusMeta(stack.status)}
|
||||
<header class="head">
|
||||
<div class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span class="sep">//</span>
|
||||
<span>STACK</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="mono-id">{stack.id.slice(0, 16)}</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="status-pill {sm.cls}">
|
||||
<span class="pulse"></span>{sm.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="head-row">
|
||||
<div class="head-left">
|
||||
<h1 class="display">{stack.name}</h1>
|
||||
{#if stack.description}
|
||||
<p class="lede">{stack.description}</p>
|
||||
{:else}
|
||||
<p class="lede dim">{$t('stacks.detail.noDescription')}</p>
|
||||
{/if}
|
||||
<span class="project-chip">
|
||||
<span class="chip-k">{$t('stacks.detail.composeProject')}</span>
|
||||
<code>{stack.compose_project_name}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn-ghost" onclick={loadAll} aria-label={$t('stacks.detail.refresh')}>
|
||||
<IconRefresh size={15} />
|
||||
</button>
|
||||
{#if stack.status === 'running'}
|
||||
<button onclick={handleStop} class="chip-btn">
|
||||
<IconStop size={13} /> <span>{$t('stacks.detail.stop')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={handleStart} class="chip-btn primary">
|
||||
<IconPlay size={13} /> <span>{$t('stacks.detail.start')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => (confirmDelete = true)} class="chip-btn danger">
|
||||
<IconTrash size={13} /> <span>{$t('stacks.detail.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stack.error}
|
||||
<div class="alert">
|
||||
<span class="alert-tag">{$t('stacks.detail.fault')}</span>
|
||||
<span>{stack.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- ── Stat tiles ─────────────────────────────── -->
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.services')}</span>
|
||||
<span class="stat-value">{String(services.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.servicesSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.running')}</span>
|
||||
<span class="stat-value accent">
|
||||
{String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.runningSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.revisions')}</span>
|
||||
<span class="stat-value">{String(revisions.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.revisionsSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.current')}</span>
|
||||
<span class="stat-value">
|
||||
R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.currentSub')}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Services ───────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">{$t('stacks.detail.services.title')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-count">{$t('stacks.detail.services.count', { n: String(services.length) })}</span>
|
||||
</header>
|
||||
{#if services.length === 0}
|
||||
<p class="panel-empty">{$t('stacks.detail.services.empty')}</p>
|
||||
{:else}
|
||||
<ul class="svc-list">
|
||||
{#each services as svc (svc.Name)}
|
||||
{@const st = serviceState(svc.State)}
|
||||
<li class="svc-row" data-state={st}>
|
||||
<span class="svc-dot"></span>
|
||||
<div class="svc-main">
|
||||
<div class="svc-name">{svc.Service}</div>
|
||||
<div class="svc-id">{svc.Name}</div>
|
||||
</div>
|
||||
<div class="svc-status">
|
||||
<span class="svc-state">{svc.State}</span>
|
||||
<span class="svc-detail">{svc.Status}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ── Tabs ───────────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" class="tab" class:active={tab==='yaml'} onclick={() => tab='yaml'}>
|
||||
<span class="tab-num">I</span><span>{$t('stacks.detail.tabs.blueprint')}</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='revisions'} onclick={() => tab='revisions'}>
|
||||
<span class="tab-num">II</span><span>{$t('stacks.detail.tabs.revisions')}</span>
|
||||
<span class="tab-badge">{revisions.length}</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='logs'} onclick={() => tab='logs'}>
|
||||
<span class="tab-num">III</span><span>{$t('stacks.detail.tabs.logs')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'yaml'}
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<span class="dim">{$t('stacks.detail.yaml.currentRevision')}</span>
|
||||
{#if !editing}
|
||||
<button class="chip" onclick={() => (editing = true)}>{$t('stacks.detail.yaml.edit')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editing}
|
||||
<textarea
|
||||
bind:value={editYaml}
|
||||
rows="20"
|
||||
class="yaml-edit"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="panel-foot">
|
||||
<button class="btn-ghost" onclick={() => (editing = false)}>{$t('stacks.detail.yaml.cancel')}</button>
|
||||
<button class="btn-primary" onclick={submitNewRevision} disabled={submitting}>
|
||||
<span>{submitting ? $t('stacks.detail.yaml.forging') : $t('stacks.detail.yaml.deployNew')}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if revisions[0]}
|
||||
<div class="yaml-frame">
|
||||
<div class="yaml-frame-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="yaml-title">docker-compose.yml</span>
|
||||
</div>
|
||||
<pre class="yaml-view">{revisions[0].yaml}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === 'revisions'}
|
||||
<div class="panel-body">
|
||||
<ol class="timeline">
|
||||
{#each revisions as rev (rev.id)}
|
||||
<li class="tl-entry" class:current={rev.id === stack.current_revision_id}>
|
||||
<div class="tl-dot"></div>
|
||||
<div class="tl-content">
|
||||
<div class="tl-head">
|
||||
<span class="tl-rev">R{rev.revision.toString().padStart(2, '0')}</span>
|
||||
{#if rev.id === stack.current_revision_id}
|
||||
<span class="tl-badge">{$t('stacks.detail.revisions.current')}</span>
|
||||
{/if}
|
||||
<span class="tl-status">{rev.status}</span>
|
||||
<span class="tl-time">{$fmt.dateTime(rev.created_at)}</span>
|
||||
</div>
|
||||
<div class="tl-meta">
|
||||
{$t('stacks.detail.revisions.by')} <strong>{rev.author || 'operator'}</strong>
|
||||
</div>
|
||||
{#if rev.id !== stack.current_revision_id}
|
||||
<button class="tl-action" onclick={() => (confirmRollback = rev)}>
|
||||
{$t('stacks.detail.revisions.rollback')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{:else if tab === 'logs'}
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<label class="log-select">
|
||||
<span class="dim">{$t('stacks.detail.logs.service')}</span>
|
||||
<select bind:value={logsService}>
|
||||
<option value="">{$t('stacks.detail.logs.allServices')}</option>
|
||||
{#each services as svc (svc.Service)}
|
||||
<option value={svc.Service}>{svc.Service}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button onclick={loadLogs} class="chip" disabled={logsLoading}>
|
||||
{logsLoading ? $t('stacks.detail.logs.fetching') : $t('stacks.detail.logs.fetch')}
|
||||
</button>
|
||||
</div>
|
||||
{#if logsText}
|
||||
<div class="terminal">
|
||||
<div class="terminal-head">
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-title">~/forge/{stack.name}{logsService ? '/' + logsService : ''}.log</span>
|
||||
</div>
|
||||
<pre class="terminal-body">{logsText}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="panel-empty">{$t('stacks.detail.logs.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRollback !== null}
|
||||
title={$t('stacks.detail.revisions.rollbackTitle')}
|
||||
message={confirmRollback ? $t('stacks.detail.revisions.rollbackMessage', { n: String(confirmRollback.revision) }) : ''}
|
||||
confirmLabel={$t('stacks.detail.revisions.rollbackConfirm')}
|
||||
confirmVariant="primary"
|
||||
onconfirm={doRollback}
|
||||
oncancel={() => (confirmRollback = null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title={$t('stacks.detail.delete.title')}
|
||||
message={stack ? $t('stacks.detail.delete.messageBase', { name: stack.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => { confirmDelete = false; deleteRemoveVolumes = false; }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 480px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back:hover { color: var(--accent); }
|
||||
|
||||
.loading {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem; color: var(--text-tertiary);
|
||||
}
|
||||
.spinner {
|
||||
width: 12px; height: 12px;
|
||||
border: 2px solid var(--text-tertiary);
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
70%, 90% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.eyebrow {
|
||||
display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.eyebrow .sep { opacity: 0.5; }
|
||||
.mono-id { color: var(--text-secondary); }
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-pill .pulse {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.status-pill.st-running { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.status-pill.st-running .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
|
||||
.status-pill.st-deploying { background: var(--color-info-light); color: var(--color-info-dark); }
|
||||
.status-pill.st-deploying .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
|
||||
.status-pill.st-failed { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
.status-pill.st-failed .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
|
||||
:global([data-theme='dark']) .status-pill.st-running { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .status-pill.st-deploying { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
|
||||
:global([data-theme='dark']) .status-pill.st-failed { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
.head-row {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
gap: 1.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.head-left { flex: 1; min-width: 280px; }
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(1.875rem, 4vw, 2.5rem);
|
||||
font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.45;
|
||||
max-width: 56ch;
|
||||
}
|
||||
.lede.dim { color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.project-chip {
|
||||
display: inline-flex; gap: 0.55rem; align-items: center;
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.chip-k {
|
||||
font-family: var(--mono); font-size: 0.6rem;
|
||||
letter-spacing: 0.15em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.project-chip code {
|
||||
font-family: var(--mono); font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 0.45rem; align-items: center; flex-wrap: wrap; }
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.chip-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip-btn:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.chip-btn.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border-color: var(--text-primary);
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.chip-btn.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 3px var(--glow);
|
||||
}
|
||||
.chip-btn.danger { color: var(--color-danger); }
|
||||
.chip-btn.danger:hover {
|
||||
background: var(--color-danger-light);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .chip-btn.danger:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Stats ─────────────────────────────────────── */
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.stat {
|
||||
padding: 1rem 1.15rem;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
}
|
||||
.stat:last-child { border-right: 0; }
|
||||
.stat-label {
|
||||
font-family: var(--mono); font-size: 0.62rem;
|
||||
letter-spacing: 0.2em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.stat-value {
|
||||
font-family: var(--serif); font-size: 2rem; line-height: 1.1;
|
||||
font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-value.accent { color: var(--accent); }
|
||||
.stat-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Panels ────────────────────────────────────── */
|
||||
.panel {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
padding: 1rem 1.35rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: var(--serif); font-size: 1.35rem;
|
||||
margin: 0; font-weight: 600; line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.title-accent { color: var(--accent); font-weight: 700; }
|
||||
.panel-count {
|
||||
font-family: var(--mono); font-size: 0.66rem;
|
||||
letter-spacing: 0.12em; color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-empty {
|
||||
padding: 1.75rem; margin: 0;
|
||||
font-family: var(--serif); font-style: italic; color: var(--text-tertiary);
|
||||
text-align: center; font-size: 1rem;
|
||||
}
|
||||
.panel-body { padding: 1.15rem 1.35rem 1.35rem; }
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem; flex-wrap: wrap;
|
||||
}
|
||||
.dim {
|
||||
font-family: var(--mono);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.7rem; letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip:hover:not(:disabled) {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.chip:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Services list ─────────────────────────────── */
|
||||
.svc-list { list-style: none; margin: 0; padding: 0; }
|
||||
.svc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto;
|
||||
gap: 1rem; align-items: center;
|
||||
padding: 0.85rem 1.35rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.svc-row:last-child { border-bottom: 0; }
|
||||
.svc-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.svc-row[data-state='running'] .svc-dot {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);
|
||||
}
|
||||
.svc-row[data-state='exited'] .svc-dot,
|
||||
.svc-row[data-state='dead'] .svc-dot { background: var(--color-danger); }
|
||||
.svc-row[data-state='restarting'] .svc-dot { background: var(--color-warning); animation: blink 0.6s infinite; }
|
||||
.svc-name {
|
||||
font-family: var(--serif); font-size: 1.2rem;
|
||||
color: var(--text-primary); line-height: 1.2;
|
||||
}
|
||||
.svc-id {
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-tertiary); margin-top: 0.1rem;
|
||||
}
|
||||
.svc-status { text-align: right; }
|
||||
.svc-state {
|
||||
display: inline-block;
|
||||
font-family: var(--mono); font-size: 0.66rem;
|
||||
font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.svc-detail {
|
||||
display: block; margin-top: 0.25rem;
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Tabs ──────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex; gap: 0;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.tab {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.95rem 1.25rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer; position: relative;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.tab:hover { color: var(--text-secondary); }
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute; left: 0; right: 0; bottom: -1px;
|
||||
height: 2px; background: var(--accent);
|
||||
}
|
||||
.tab-num {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.15rem;
|
||||
font-style: italic;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tab-badge {
|
||||
font-size: 0.58rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── YAML view / edit ──────────────────────────── */
|
||||
.yaml-frame {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.yaml-frame-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.yaml-frame-head .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.yaml-frame-head .dot:nth-child(2) { background: var(--color-warning); }
|
||||
.yaml-frame-head .dot:nth-child(3) { background: var(--color-success); }
|
||||
.yaml-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.yaml-view {
|
||||
max-height: 440px; overflow: auto;
|
||||
padding: 0.9rem 1rem; margin: 0;
|
||||
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
.yaml-edit {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.yaml-edit:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.panel-foot {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.arrow { transition: transform 150ms ease; }
|
||||
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
|
||||
|
||||
/* ── Timeline ──────────────────────────────────── */
|
||||
.timeline { list-style: none; margin: 0; padding: 0.25rem 0 0; position: relative; }
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute; top: 1rem; bottom: 1rem; left: 8px;
|
||||
width: 1px; background: var(--border-primary);
|
||||
}
|
||||
.tl-entry {
|
||||
position: relative;
|
||||
padding: 0.6rem 0 0.6rem 2rem;
|
||||
}
|
||||
.tl-dot {
|
||||
position: absolute; left: 3px; top: 1.05rem;
|
||||
width: 11px; height: 11px;
|
||||
background: var(--surface-card);
|
||||
border: 2px solid var(--text-tertiary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tl-entry.current .tl-dot {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||
}
|
||||
.tl-head {
|
||||
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tl-rev {
|
||||
font-family: var(--serif); font-size: 1.5rem;
|
||||
letter-spacing: 0; color: var(--text-primary); line-height: 1;
|
||||
}
|
||||
.tl-badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--accent); color: #fff;
|
||||
font-size: 0.58rem; font-weight: 600; letter-spacing: 0.16em;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.tl-status { color: var(--text-secondary); }
|
||||
.tl-time { color: var(--text-tertiary); }
|
||||
.tl-meta {
|
||||
font-size: 0.82rem; color: var(--text-tertiary);
|
||||
margin-top: 0.25rem; font-family: var(--serif);
|
||||
}
|
||||
.tl-meta strong { color: var(--text-secondary); font-weight: 500; }
|
||||
.tl-action {
|
||||
margin-top: 0.5rem;
|
||||
background: transparent; border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent); font-family: var(--mono);
|
||||
font-size: 0.68rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tl-action:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* ── Logs / Terminal ───────────────────────────── */
|
||||
.log-select { display: inline-flex; align-items: center; gap: 0.55rem; }
|
||||
.log-select select {
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.terminal {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #0b1020;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global([data-theme='dark']) .terminal { background: #05070f; }
|
||||
.terminal-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
background: #141a2e;
|
||||
border-bottom: 1px solid #0a0e1c;
|
||||
}
|
||||
:global([data-theme='dark']) .terminal-head { background: #0a0e1c; }
|
||||
.t-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
.t-dot:nth-child(1) { background: color-mix(in srgb, var(--color-danger) 70%, black); }
|
||||
.t-dot:nth-child(2) { background: color-mix(in srgb, var(--color-warning) 70%, black); }
|
||||
.t-dot:nth-child(3) { background: color-mix(in srgb, var(--color-success) 70%, black); }
|
||||
.t-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.7rem;
|
||||
color: rgba(255,255,255,0.45);
|
||||
}
|
||||
.terminal-body {
|
||||
max-height: 480px; overflow: auto;
|
||||
margin: 0; padding: 1rem 1.1rem;
|
||||
font-family: var(--mono); font-size: 0.76rem; line-height: 1.55;
|
||||
color: #c7d0e0;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.head-row { flex-direction: column; align-items: stretch; }
|
||||
.display { font-size: 2.5rem; }
|
||||
.svc-row { grid-template-columns: 14px 1fr; }
|
||||
.svc-status { grid-column: 2; text-align: left; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,595 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft } from '$lib/components/icons';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let yaml = $state('');
|
||||
let deployNow = $state(true);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let dragOver = $state(false);
|
||||
|
||||
const sample = `services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
cache:
|
||||
image: redis:7-alpine`;
|
||||
|
||||
async function handleFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
yaml = await file.text();
|
||||
}
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
yaml = await file.text();
|
||||
}
|
||||
function loadSample() { yaml = sample; }
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !yaml.trim()) {
|
||||
error = $t('stacks.new.errorRequired');
|
||||
return;
|
||||
}
|
||||
submitting = true; error = '';
|
||||
try {
|
||||
const { stack } = await api.createStack({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
yaml,
|
||||
deploy: deployNow
|
||||
});
|
||||
await goto(`/stacks/${stack.id}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('stacks.new.errorCreate');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineNumbers = $derived(
|
||||
yaml.split('\n').map((_, i) => String(i + 1).padStart(3, '0')).join('\n')
|
||||
);
|
||||
const lineCount = $derived(yaml ? yaml.split('\n').length : 0);
|
||||
const byteCount = $derived(new Blob([yaml]).size);
|
||||
|
||||
function syncScroll(e: Event) {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
const gutter = ta.parentElement?.querySelector('.gutter') as HTMLElement | null;
|
||||
if (gutter) gutter.scrollTop = ta.scrollTop;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
<div class="dot-grid" aria-hidden="true"></div>
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>{$t('stacks.new.back').toUpperCase()}</span>
|
||||
</a>
|
||||
|
||||
<header class="head">
|
||||
<span class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span class="sep">//</span>
|
||||
<span>NEW STACK</span>
|
||||
</span>
|
||||
<h1 class="display">{$t('stacks.new.title')}</h1>
|
||||
<p class="lede">{@html $t('stacks.new.lede')}</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={submit} class="form">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
<span class="lbl">{$t('stacks.new.name')}</span>
|
||||
<span class="req">{$t('stacks.new.required')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder={$t('stacks.new.namePlaceholder')}
|
||||
class="input"
|
||||
/>
|
||||
<p class="hint">{$t('stacks.new.nameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-desc" class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">{$t('stacks.new.description')}</span>
|
||||
<span class="opt">{$t('stacks.new.optional')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-desc"
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$t('stacks.new.descriptionPlaceholder')}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">{$t('stacks.new.composeYaml')}</span>
|
||||
<span class="req">{$t('stacks.new.required')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="chip" onclick={loadSample}>{$t('stacks.new.loadSample')}</button>
|
||||
<button type="button" class="chip" onclick={() => fileInput?.click()}>{$t('stacks.new.uploadFile')}</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".yml,.yaml"
|
||||
class="sr-only"
|
||||
onchange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !yaml}
|
||||
<button
|
||||
type="button"
|
||||
class="dropzone"
|
||||
class:drag-over={dragOver}
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<div class="dz-icon">⇣</div>
|
||||
<div class="dz-title">{$t('stacks.new.dropHere')}</div>
|
||||
<div class="dz-sub">{@html $t('stacks.new.dropSub')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="editor" class:hidden={!yaml}>
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">docker-compose.yml</span>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div class="gutter" aria-hidden="true"><pre>{lineNumbers}</pre></div>
|
||||
<textarea
|
||||
bind:value={yaml}
|
||||
onscroll={syncScroll}
|
||||
rows="20"
|
||||
spellcheck="false"
|
||||
placeholder={sample}
|
||||
class="yaml-area"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<span>{$t('stacks.new.lines', { n: String(lineCount) })}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{$t('stacks.new.bytes', { n: String(byteCount) })}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>YAML</span>
|
||||
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>{$t('stacks.new.clear')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="deploy-toggle">
|
||||
<ToggleSwitch bind:checked={deployNow} label={$t('stacks.new.deployImmediate')} />
|
||||
<span class="toggle-text">
|
||||
<strong>{$t('stacks.new.deployImmediate')}</strong>
|
||||
<span class="dim">{$t('stacks.new.deployHint')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/stacks" class="btn-ghost">{$t('stacks.new.cancel')}</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="btn-primary"
|
||||
>
|
||||
<span>{submitting ? $t('stacks.new.forging') : deployNow ? $t('stacks.new.forgeAndDeploy') : $t('stacks.new.saveBlueprint')}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 400px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back:hover { color: var(--accent); }
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.eyebrow .sep { opacity: 0.5; }
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(1.875rem, 4vw, 2.5rem);
|
||||
font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.display :global(em) {
|
||||
color: var(--accent);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 56ch;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.lede :global(code) {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Form ──────────────────────────────────────── */
|
||||
.form {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 1.75rem;
|
||||
}
|
||||
.reg {
|
||||
position: absolute; width: 10px; height: 10px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid; border-width: 0;
|
||||
}
|
||||
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; border-top-left-radius: var(--radius-2xl); }
|
||||
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; border-top-right-radius: var(--radius-2xl); }
|
||||
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; border-bottom-left-radius: var(--radius-2xl); }
|
||||
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; border-bottom-right-radius: var(--radius-2xl); }
|
||||
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
padding: 0.7rem 0.9rem; margin-bottom: 1.25rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Fields ────────────────────────────────────── */
|
||||
.field { margin-bottom: 1.5rem; }
|
||||
.field-label {
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
.field-label .num {
|
||||
display: inline-flex; width: 26px; height: 26px;
|
||||
justify-content: center; align-items: center;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
}
|
||||
.field-label .lbl {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.25rem; line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.field-label .req {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6rem; font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
}
|
||||
.field-label .opt {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6rem; font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
}
|
||||
.field-label .spacer { flex: 1; }
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem; color: var(--text-tertiary);
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
|
||||
/* ── Dropzone ──────────────────────────────────── */
|
||||
.dropzone {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%; min-height: 240px;
|
||||
background: var(--surface-card-hover);
|
||||
border: 2px dashed var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 2rem;
|
||||
transition: all 180ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dropzone:hover, .dropzone.drag-over {
|
||||
border-color: var(--color-brand-500);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dz-icon { font-size: 2.25rem; line-height: 1; color: var(--text-tertiary); transition: color 150ms ease; }
|
||||
.dropzone:hover .dz-icon, .dropzone.drag-over .dz-icon { color: var(--accent); }
|
||||
.dz-title {
|
||||
font-family: var(--serif); font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dz-title :global(em) { color: var(--accent); font-style: italic; }
|
||||
.dz-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.dz-sub :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
/* ── Editor ────────────────────────────────────── */
|
||||
.editor {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor.hidden { display: none; }
|
||||
.editor-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.editor-head .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.editor-head .dot:nth-child(2) { background: var(--color-warning); }
|
||||
.editor-head .dot:nth-child(3) { background: var(--color-success); }
|
||||
.editor-head .editor-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.gutter {
|
||||
flex-shrink: 0;
|
||||
width: 54px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-card-hover);
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
.gutter pre {
|
||||
margin: 0; padding: 0.85rem 0.6rem 0.85rem 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
.yaml-area {
|
||||
flex: 1; display: block;
|
||||
padding: 0.85rem 1rem;
|
||||
background: transparent;
|
||||
border: 0; outline: 0; resize: vertical;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
min-height: 300px;
|
||||
}
|
||||
.yaml-area::placeholder { color: var(--text-tertiary); }
|
||||
.editor-foot {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-foot .sep { opacity: 0.5; }
|
||||
.clear-btn {
|
||||
margin-left: auto;
|
||||
background: transparent; border: 0;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.clear-btn:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Deploy toggle ─────────────────────────────── */
|
||||
.deploy-toggle {
|
||||
display: flex; align-items: flex-start; gap: 0.8rem;
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 1.25rem;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.deploy-toggle:hover { border-color: var(--color-brand-300); }
|
||||
.deploy-toggle :global(.toggle-switch) { margin-top: 2px; }
|
||||
.toggle-text strong {
|
||||
display: block; font-family: var(--serif);
|
||||
font-size: 1.15rem; font-weight: 400; line-height: 1.2;
|
||||
color: var(--text-primary); margin-bottom: 0.15rem;
|
||||
}
|
||||
.toggle-text .dim { color: var(--text-tertiary); font-size: 0.82rem; }
|
||||
|
||||
/* ── Actions ───────────────────────────────────── */
|
||||
.actions {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
}
|
||||
.btn-ghost {
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.65rem 1.2rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.74rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.arrow { transition: transform 150ms ease; }
|
||||
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user