feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
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:
2026-05-16 06:00:21 +03:00
parent 234c3c711e
commit 739b67856a
101 changed files with 1116 additions and 20768 deletions
+7 -17
View File
@@ -6,7 +6,7 @@
import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, 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+pprojects, g+ssites, g+kstacks, g+x → deploy,
// g+d → dashboard, g+aapps, g+ncontainers, g+ttriggers,
// 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
View File
@@ -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>
+6 -80
View File
@@ -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 ───────────────────────────── -->
+6 -20
View File
@@ -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 {
+128 -9
View File
@@ -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 !== ''}
-389
View File
@@ -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}
-302
View File
@@ -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>
-915
View File
@@ -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
View File
@@ -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;
+6 -1
View File
@@ -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() {
-271
View File
@@ -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}
-484
View File
@@ -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}
-702
View File
@@ -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>
-535
View File
@@ -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>
-953
View File
@@ -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>
-595
View File
@@ -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>