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
+75 -552
View File
@@ -8,27 +8,19 @@ import type {
SystemStats,
SystemStatsSample,
TopContainerSample,
Deploy,
DeployLog,
DockerHealth,
ProxyHealth,
EventLogEntry,
EventLogStats,
InspectResult,
Instance,
LocalImage,
NpmCertificate,
NpmAccessList,
ProxyRoute,
Project,
ProjectDetail,
Registry,
RegistryImage,
Settings,
StaleContainer,
Stage,
StageEnv,
Volume,
VolumeScopeInfo,
BrowseResult,
DnsZone,
@@ -174,125 +166,15 @@ function patch<T>(path: string, body: unknown): Promise<T> {
});
}
// ── Projects ────────────────────────────────────────────────────────
export function listProjects(signal?: AbortSignal): Promise<Project[]> {
return get<Project[]>('/api/projects', signal);
}
export function getProject(id: string, signal?: AbortSignal): Promise<ProjectDetail> {
return get<ProjectDetail>(`/api/projects/${id}`, signal);
}
export function createProject(data: Partial<Project>): Promise<Project> {
return post<Project>('/api/projects', data);
}
export function updateProject(id: string, data: Partial<Project>): Promise<Project> {
return put<Project>(`/api/projects/${id}`, data);
}
export function deleteProject(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/projects/${id}`);
}
// ── Stages ─────────────────────────────────────────────────────────
export function createStage(projectId: string, data: Partial<Stage>): Promise<Stage> {
return post<Stage>(`/api/projects/${projectId}/stages`, data);
}
export function updateStage(projectId: string, stageId: string, data: Partial<Stage>): Promise<Stage> {
return put<Stage>(`/api/projects/${projectId}/stages/${stageId}`, data);
}
export function deleteStage(projectId: string, stageId: string): Promise<void> {
return del<void>(`/api/projects/${projectId}/stages/${stageId}`);
}
// ── Instances ───────────────────────────────────────────────────────
export function listInstances(projectId: string, stageId: string, signal?: AbortSignal): Promise<Instance[]> {
return get<Instance[]>(`/api/projects/${projectId}/stages/${stageId}/instances`, signal);
}
export function deployInstance(
projectId: string,
stageId: string,
imageTag: string
): Promise<{ status: string }> {
return post<{ status: string }>(`/api/projects/${projectId}/stages/${stageId}/instances`, {
image_tag: imageTag
});
}
export function removeInstance(
projectId: string,
stageId: string,
instanceId: string
): Promise<{ deleted: string }> {
return del<{ deleted: string }>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}`
);
}
export function stopInstance(
projectId: string,
stageId: string,
instanceId: string
): Promise<{ status: string }> {
return post<{ status: string }>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stop`
);
}
export function startInstance(
projectId: string,
stageId: string,
instanceId: string
): Promise<{ status: string }> {
return post<{ status: string }>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/start`
);
}
export function restartInstance(
projectId: string,
stageId: string,
instanceId: string
): Promise<{ status: string }> {
return post<{ status: string }>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/restart`
);
}
// ── Deploys ─────────────────────────────────────────────────────────
export function listDeploys(limit = 50, signal?: AbortSignal): Promise<Deploy[]> {
return get<Deploy[]>(`/api/deploys?limit=${limit}`, signal);
}
export function getDeployLogs(deployId: string): Promise<DeployLog[]> {
return get<DeployLog[]>(`/api/deploys/${deployId}/logs`);
}
// ── Deploys (inspect only; quick-deploy retired with /deploy page) ────
// `inspectImage` survives because the new-app wizard can use it to pre-fill
// image port/healthcheck. `quickDeploy` (POST /api/deploy/quick) is gone:
// it created a legacy Project + Stage in the now-dead path.
export function inspectImage(image: string): Promise<InspectResult> {
return post<InspectResult>('/api/deploy/inspect', { image });
}
export function quickDeploy(data: {
name?: string;
image: string;
tag?: string;
registry?: string;
port?: number;
force?: boolean;
enable_proxy?: boolean;
auto_deploy?: boolean;
}): Promise<{ project: Project; status: string }> {
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
}
// ── Registries ──────────────────────────────────────────────────────
export function listRegistries(): Promise<Registry[]> {
@@ -335,7 +217,8 @@ export function updateSettings(data: Partial<Settings>): Promise<Settings> {
return put<Settings>('/api/settings', data);
}
// ── Webhooks ───────────────────────────────────────────────────────
// ── Webhook envelopes ──────────────────────────────────────────────
// These shapes are reused by the workload + trigger webhook flows.
export interface WebhookUrlResponse {
webhook_url: string;
@@ -348,49 +231,9 @@ export interface SigningSecretResponse {
signing_secret: string;
}
export function getProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
return get<WebhookUrlResponse>(`/api/projects/${projectId}/webhook`);
}
export function regenerateProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
return post<WebhookUrlResponse>(`/api/projects/${projectId}/webhook/regenerate`);
}
export function regenerateProjectSigningSecret(projectId: string): Promise<SigningSecretResponse> {
return post<SigningSecretResponse>(`/api/projects/${projectId}/webhook/signing-secret/regenerate`);
}
export async function disableProjectSigningSecret(projectId: string): Promise<void> {
await del<void>(`/api/projects/${projectId}/webhook/signing-secret`);
}
export async function setProjectRequireSignature(projectId: string, require: boolean): Promise<void> {
await put<void>(`/api/projects/${projectId}/webhook/require-signature`, { require_signature: require });
}
export function getStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
return get<WebhookUrlResponse>(`/api/sites/${siteId}/webhook`);
}
export function regenerateStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
}
export function regenerateStaticSiteSigningSecret(siteId: string): Promise<SigningSecretResponse> {
return post<SigningSecretResponse>(`/api/sites/${siteId}/webhook/signing-secret/regenerate`);
}
export async function disableStaticSiteSigningSecret(siteId: string): Promise<void> {
await del<void>(`/api/sites/${siteId}/webhook/signing-secret`);
}
export async function setStaticSiteRequireSignature(siteId: string, require: boolean): Promise<void> {
await put<void>(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require });
}
export interface WebhookDelivery {
id: number;
target_type: 'project' | 'site';
target_type: 'project' | 'site' | 'workload' | 'trigger';
target_id: string;
target_name: string;
received_at: string;
@@ -402,15 +245,10 @@ export interface WebhookDelivery {
body_size: number;
}
export function listProjectWebhookDeliveries(projectId: string, signal?: AbortSignal): Promise<WebhookDelivery[]> {
return get<WebhookDelivery[]>(`/api/projects/${projectId}/webhook/deliveries`, signal);
}
export function listStaticSiteWebhookDeliveries(siteId: string, signal?: AbortSignal): Promise<WebhookDelivery[]> {
return get<WebhookDelivery[]>(`/api/sites/${siteId}/webhook/deliveries`, signal);
}
// ── Outgoing-webhook signing & test ────────────────────────────────
// ── Outgoing-webhook signing & test (settings tier only) ───────────
// Per-project, per-stage, per-site tiers were dropped with the legacy
// endpoints. Per-workload signing is exposed via the workload webhook
// flow below.
export interface NotificationSecretResponse {
secret: string;
@@ -419,7 +257,7 @@ export interface NotificationSecretResponse {
export interface NotificationTestResult {
url: string;
tier: 'settings' | 'project' | 'stage' | 'site';
tier: 'settings' | 'project' | 'stage' | 'site' | 'workload' | 'trigger';
status_code: number;
latency_ms: number;
signature_sent: boolean;
@@ -428,7 +266,6 @@ export interface NotificationTestResult {
error?: string;
}
// Settings (global) tier.
export function getSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
return get<NotificationSecretResponse>('/api/settings/notification-secret');
}
@@ -442,66 +279,14 @@ export function testSettingsNotification(): Promise<NotificationTestResult> {
return post<NotificationTestResult>('/api/settings/notification-test');
}
// Project tier.
export function getProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
return get<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret`);
}
export function regenerateProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/regenerate`);
}
export function disableProjectNotificationSigning(projectId: string): Promise<NotificationSecretResponse> {
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/disable`);
}
export function testProjectNotification(projectId: string): Promise<NotificationTestResult> {
return post<NotificationTestResult>(`/api/projects/${projectId}/notification-test`);
}
// Stage tier.
export function getStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
return get<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret`);
}
export function regenerateStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/regenerate`);
}
export function disableStageNotificationSigning(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/disable`);
}
export function testStageNotification(projectId: string, stageId: string): Promise<NotificationTestResult> {
return post<NotificationTestResult>(`/api/projects/${projectId}/stages/${stageId}/notification-test`);
}
// Static-site tier.
export function getStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
return get<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret`);
}
export function regenerateStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/regenerate`);
}
export function disableStaticSiteNotificationSigning(siteId: string): Promise<NotificationSecretResponse> {
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/disable`);
}
export function testStaticSiteNotification(siteId: string): Promise<NotificationTestResult> {
return post<NotificationTestResult>(`/api/sites/${siteId}/notification-test`);
}
// ── Proxy Routes ───────────────────────────────────────────────────
export function listProxyRoutes(): Promise<ProxyRoute[]> {
return get<ProxyRoute[]>('/api/proxies');
export function listProxyRoutes(signal?: AbortSignal): Promise<ProxyRoute[]> {
return get<ProxyRoute[]>('/api/proxies', signal);
}
// ── Docker Management ──────────────────────────────────────────────
export function fetchContainerLogs(
projectId: string, stageId: string, instanceId: string, tail = 200
): Promise<string[]> {
return get<string[]>(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`);
}
export function listProjectImages(projectId: string, signal?: AbortSignal): Promise<LocalImage[]> {
return get<LocalImage[]>(`/api/projects/${projectId}/images`, signal);
}
export function getUnusedImageStats(signal?: AbortSignal): Promise<{
total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean;
}> {
@@ -524,6 +309,15 @@ export function listNpmAccessLists(): Promise<NpmAccessList[]> {
return get<NpmAccessList[]>('/api/settings/npm-access-lists');
}
// ── Volume scopes (metadata only) ───────────────────────────────────
// Per-project volume CRUD endpoints died with the legacy routes; the
// workload volume endpoints cover the new path. Scope metadata stays
// because the volume editor for workloads still needs it.
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
}
// ── DNS ────────────────────────────────────────────────────────────
export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> {
@@ -635,107 +429,48 @@ export function exportConfigUrl(): string {
return '/api/config/export';
}
// ── Stage Env Overrides ──────────────────────────────────────────────
export function listStageEnv(projectId: string, stageId: string): Promise<StageEnv[]> {
return get<StageEnv[]>(`/api/projects/${projectId}/stages/${stageId}/env`);
}
export function createStageEnv(
projectId: string,
stageId: string,
data: { key: string; value: string; encrypted?: boolean }
): Promise<StageEnv> {
return post<StageEnv>(`/api/projects/${projectId}/stages/${stageId}/env`, data);
}
export function updateStageEnv(
projectId: string,
stageId: string,
envId: string,
data: { key?: string; value?: string; encrypted?: boolean }
): Promise<StageEnv> {
return put<StageEnv>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`, data);
}
export function deleteStageEnv(
projectId: string,
stageId: string,
envId: string
): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`);
}
// ── Volumes ──────────────────────────────────────────────────────────
export function listVolumes(projectId: string): Promise<Volume[]> {
return get<Volume[]>(`/api/projects/${projectId}/volumes`);
}
export function createVolume(
projectId: string,
data: { source: string; target: string; scope: string; name?: string; mode?: string }
): Promise<Volume> {
return post<Volume>(`/api/projects/${projectId}/volumes`, data);
}
export function updateVolume(
projectId: string,
volId: string,
data: { source?: string; target?: string; scope?: string; name?: string; mode?: string }
): Promise<Volume> {
return put<Volume>(`/api/projects/${projectId}/volumes/${volId}`, data);
}
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
}
export function deleteVolume(
projectId: string,
volId: string
): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`);
}
// ── Workload volume browse / download / upload ─────────────────────
// The browse/download/upload helpers now target /api/workloads/{id}
// instead of the deleted project-scoped path. Source/path/scope params
// retain the same query keys for compatibility with the volume editor.
export function browseVolume(
projectId: string,
workloadId: string,
volId: string,
params?: { path?: string; stage?: string; tag?: string }
params?: { path?: string; reference?: string }
): Promise<BrowseResult> {
const query = new URLSearchParams();
if (params?.path) query.set('path', params.path);
if (params?.stage) query.set('stage', params.stage);
if (params?.tag) query.set('tag', params.tag);
if (params?.reference) query.set('reference', params.reference);
const qs = query.toString();
return get<BrowseResult>(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`);
return get<BrowseResult>(
`/api/workloads/${workloadId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`
);
}
export function volumeDownloadUrl(
projectId: string,
workloadId: string,
volId: string,
params?: { path?: string; stage?: string; tag?: string }
params?: { path?: string; reference?: string }
): string {
const query = new URLSearchParams();
if (params?.path) query.set('path', params.path);
if (params?.stage) query.set('stage', params.stage);
if (params?.tag) query.set('tag', params.tag);
if (params?.reference) query.set('reference', params.reference);
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token) query.set('token', token);
const qs = query.toString();
return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`;
return `/api/workloads/${workloadId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`;
}
export async function uploadToVolume(
projectId: string,
workloadId: string,
volId: string,
files: FileList,
params?: { path?: string; stage?: string; tag?: string }
params?: { path?: string; reference?: string }
): Promise<{ uploaded: string[]; count: number }> {
const query = new URLSearchParams();
if (params?.path) query.set('path', params.path);
if (params?.stage) query.set('stage', params.stage);
if (params?.tag) query.set('tag', params.tag);
if (params?.reference) query.set('reference', params.reference);
const qs = query.toString();
const formData = new FormData();
@@ -747,11 +482,14 @@ export async function uploadToVolume(
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, {
method: 'POST',
headers,
body: formData,
});
const res = await fetch(
`/api/workloads/${workloadId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`,
{
method: 'POST',
headers,
body: formData
}
);
const envelope = await res.json();
if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed');
@@ -779,8 +517,8 @@ export function fetchEventLog(params?: {
return get<EventLogEntry[]>(`/api/events/log${qs ? `?${qs}` : ''}`);
}
export function fetchEventLogStats(): Promise<EventLogStats> {
return get<EventLogStats>('/api/events/log/stats');
export function fetchEventLogStats(signal?: AbortSignal): Promise<EventLogStats> {
return get<EventLogStats>('/api/events/log/stats', signal);
}
export function deleteEvent(id: number): Promise<{ status: string }> {
@@ -805,32 +543,7 @@ export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> {
return post<{ deleted: number }>('/api/containers/stale/cleanup');
}
// ── Container Stats ────────────────────────────────────────────────
export function fetchContainerStats(
projectId: string,
stageId: string,
instanceId: string,
signal?: AbortSignal
): Promise<ContainerStats> {
return get<ContainerStats>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`,
signal
);
}
export function fetchInstanceStatsHistory(
projectId: string,
stageId: string,
instanceId: string,
window = '2h',
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
// ── System Stats ───────────────────────────────────────────────────
export function fetchSystemStats(signal?: AbortSignal): Promise<SystemStats> {
return get<SystemStats>('/api/system/stats', signal);
@@ -851,212 +564,32 @@ export function fetchTopContainers(
return get<TopContainerSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
}
export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise<ContainerStats> {
return get<ContainerStats>(`/api/sites/${id}/stats`, signal);
}
// ── Per-container stats (workload-scoped) ──────────────────────────
// Project / stage / instance + static-site stats endpoints died with
// the legacy routes. Use the workload-scoped endpoint for any per-
// container CPU/memory drill-down.
export function fetchStaticSiteStatsHistory(
id: string,
window = '2h',
export function fetchWorkloadContainerStats(
workloadId: string,
containerRowId: string,
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(
`/api/sites/${id}/stats/history?window=${encodeURIComponent(window)}`,
): Promise<ContainerStats> {
return get<ContainerStats>(
`/api/workloads/${workloadId}/containers/${containerRowId}/stats`,
signal
);
}
export async function fetchStaticSiteLogs(id: string, tail = 200): Promise<string[]> {
const result = await get<string[] | null>(`/api/sites/${id}/logs?tail=${tail}`);
return result ?? [];
}
// ── Static Sites ──────────────────────────────────────────────────────
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
export function listStaticSites(signal?: AbortSignal): Promise<StaticSite[]> {
return get<StaticSite[]>('/api/sites', signal);
}
export function getStaticSite(id: string): Promise<StaticSite> {
return get<StaticSite>(`/api/sites/${id}`);
}
export function createStaticSite(data: Partial<StaticSite>): Promise<StaticSite> {
return post<StaticSite>('/api/sites', data);
}
export function updateStaticSite(id: string, data: Partial<StaticSite>): Promise<StaticSite> {
return put<StaticSite>(`/api/sites/${id}`, data);
}
export function deleteStaticSite(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${id}`);
}
export function deployStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/deploy`);
}
export function stopStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/stop`);
}
export function startStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/start`);
}
export function listStaticSiteRepos(data: {
provider?: string;
gitea_url: string;
access_token?: string;
query?: string;
}): Promise<RepoInfo[]> {
return post<RepoInfo[]>('/api/sites/repos', data);
}
export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> {
return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url });
}
export function testStaticSiteConnection(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<{ status: string }> {
return post<{ status: string }>('/api/sites/test-connection', data);
}
export function listStaticSiteBranches(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<string[]> {
return post<string[]>('/api/sites/branches', data);
}
export function listStaticSiteTree(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
branch: string;
}): Promise<FolderEntry[]> {
return post<FolderEntry[]>('/api/sites/tree', data);
}
export function listStaticSiteSecrets(siteId: string): Promise<StaticSiteSecret[]> {
return get<StaticSiteSecret[]>(`/api/sites/${siteId}/secrets`);
}
export function createStaticSiteSecret(
siteId: string,
data: { key: string; value: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return post<StaticSiteSecret>(`/api/sites/${siteId}/secrets`, data);
}
export function updateStaticSiteSecret(
siteId: string,
secretId: string,
data: { key?: string; value?: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return put<StaticSiteSecret>(`/api/sites/${siteId}/secrets/${secretId}`, data);
}
export function deleteStaticSiteSecret(
siteId: string,
secretId: string
): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`);
}
export function getStaticSiteStorage(
siteId: string
): Promise<import('./types').StaticSiteStorageUsage> {
return get<import('./types').StaticSiteStorageUsage>(`/api/sites/${siteId}/storage`);
}
// ── Stacks (docker-compose) ─────────────────────────────────────────
import type { Stack, StackRevision, StackService } from './types';
export function listStacks(signal?: AbortSignal): Promise<Stack[]> {
return get<Stack[]>('/api/stacks', signal);
}
export function getStack(id: string, signal?: AbortSignal): Promise<Stack> {
return get<Stack>(`/api/stacks/${id}`, signal);
}
export function createStack(data: {
name: string;
description?: string;
yaml: string;
deploy?: boolean;
}): Promise<{ stack: Stack; revision: StackRevision }> {
return post<{ stack: Stack; revision: StackRevision }>('/api/stacks', data);
}
export function updateStack(id: string, data: { name?: string; description?: string }): Promise<Stack> {
return put<Stack>(`/api/stacks/${id}`, data);
}
export function deleteStack(id: string, removeVolumes = false): Promise<{ deleted: string }> {
const qs = removeVolumes ? '?remove_volumes=true' : '';
return del<{ deleted: string }>(`/api/stacks/${id}${qs}`);
}
export function listStackRevisions(id: string, signal?: AbortSignal): Promise<StackRevision[]> {
return get<StackRevision[]>(`/api/stacks/${id}/revisions`, signal);
}
export function getStackRevision(id: string, revId: string): Promise<StackRevision> {
return get<StackRevision>(`/api/stacks/${id}/revisions/${revId}`);
}
export function createStackRevision(id: string, yaml: string): Promise<StackRevision> {
return post<StackRevision>(`/api/stacks/${id}/revisions`, { yaml });
}
export function rollbackStack(id: string, revId: string): Promise<StackRevision> {
return post<StackRevision>(`/api/stacks/${id}/rollback/${revId}`);
}
export function stopStack(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/stacks/${id}/stop`);
}
export function startStack(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/stacks/${id}/start`);
}
export function getStackServices(id: string, signal?: AbortSignal): Promise<StackService[]> {
return get<StackService[]>(`/api/stacks/${id}/services`, signal);
}
export async function getStackLogs(
id: string,
service?: string,
tail = 200
): Promise<string> {
const params = new URLSearchParams();
if (service) params.set('service', service);
params.set('tail', String(tail));
const token = getAuthToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`/api/stacks/${id}/logs?${params.toString()}`, { headers });
if (!res.ok) {
throw new ApiError(`Failed to fetch logs: ${res.status}`, res.status);
}
return res.text();
export function fetchWorkloadContainerStatsHistory(
workloadId: string,
containerRowId: string,
window = '2h',
signal?: AbortSignal
): Promise<ContainerStatsSample[]> {
return get<ContainerStatsSample[]>(
`/api/workloads/${workloadId}/containers/${containerRowId}/stats/history?window=${encodeURIComponent(window)}`,
signal
);
}
// ── Workloads ───────────────────────────────────────────────────────
@@ -1126,20 +659,10 @@ export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted:
return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`);
}
export interface WorkloadWebhook {
webhook_url: string;
webhook_secret: string;
has_signing_secret: boolean;
webhook_require_signature: boolean;
}
export function getWorkloadWebhook(id: string, signal?: AbortSignal): Promise<WorkloadWebhook> {
return get<WorkloadWebhook>(`/api/workloads/${id}/webhook`, signal);
}
export function regenerateWorkloadWebhook(id: string): Promise<WorkloadWebhook> {
return post<WorkloadWebhook>(`/api/workloads/${id}/webhook/regenerate`);
}
// Workload-level webhook URL accessors were removed in the hard legacy
// cutover: inbound webhooks are now first-class Triggers. To wire a
// workload to inbound deploys, create or bind a Trigger via the
// /triggers UI (which mints a /api/webhook/triggers/{secret} URL).
export function fetchWorkloadContainerLogs(
workloadId: string,
+11 -25
View File
@@ -1,24 +1,22 @@
<!--
Container log viewer with tail line limit and auto-scroll.
Works for both project instances and static sites — pass a `source`
discriminated union to point at the right endpoint.
Workload-scoped only after the hard cutover. The legacy `instance` and
`site` source variants targeted /api/projects/.../logs and
/api/sites/.../logs respectively — both gone with their routes.
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import {
fetchContainerLogs,
fetchStaticSiteLogs,
fetchWorkloadContainerLogs
} from '$lib/api';
import { fetchWorkloadContainerLogs } from '$lib/api';
import { getAuthToken } from '$lib/auth';
import { t } from '$lib/i18n';
import { IconLoader, IconX } from '$lib/components/icons';
export type LogSource =
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
| { kind: 'site'; siteId: string }
| { kind: 'workload'; workloadId: string; containerRowId: string };
export type LogSource = {
kind: 'workload';
workloadId: string;
containerRowId: string;
};
interface Props {
source: LogSource;
@@ -60,23 +58,11 @@
function buildFollowUrl(token: string | null): string {
const tokenParam = token ? `&token=${token}` : '';
if (source.kind === 'instance') {
return `/api/projects/${source.projectId}/stages/${source.stageId}/instances/${source.instanceId}/logs?follow=true&tail=0${tokenParam}`;
}
if (source.kind === 'workload') {
return `/api/workloads/${source.workloadId}/containers/${source.containerRowId}/logs?follow=true&tail=0${tokenParam}`;
}
return `/api/sites/${source.siteId}/logs?follow=true&tail=0${tokenParam}`;
return `/api/workloads/${source.workloadId}/containers/${source.containerRowId}/logs?follow=true&tail=0${tokenParam}`;
}
async function fetchLogs(tail: number): Promise<string[]> {
if (source.kind === 'instance') {
return fetchContainerLogs(source.projectId, source.stageId, source.instanceId, tail);
}
if (source.kind === 'workload') {
return fetchWorkloadContainerLogs(source.workloadId, source.containerRowId, tail);
}
return fetchStaticSiteLogs(source.siteId, tail);
return fetchWorkloadContainerLogs(source.workloadId, source.containerRowId, tail);
}
async function loadLogs() {
+15 -19
View File
@@ -1,7 +1,8 @@
<!--
Compact CPU/memory stats bars with an optional expandable history
chart. Works for both project instances and static sites via the
`source` discriminated union.
chart. Workload-scoped only after the hard cutover — pass the
workload + container-row identifiers; the legacy project-instance and
static-site variants targeted dead endpoints.
-->
<script lang="ts">
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
@@ -12,9 +13,11 @@
import Sparkline from './Sparkline.svelte';
import type { EChartsOption } from 'echarts';
export type StatsSource =
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
| { kind: 'site'; siteId: string };
export type StatsSource = {
kind: 'workload';
workloadId: string;
containerRowId: string;
};
interface Props {
source: StatsSource;
@@ -34,23 +37,16 @@
let expanded = $state(false);
async function fetchStats(signal: AbortSignal): Promise<ContainerStats> {
if (source.kind === 'instance') {
return api.fetchContainerStats(source.projectId, source.stageId, source.instanceId, signal);
}
return api.fetchStaticSiteStats(source.siteId, signal);
return api.fetchWorkloadContainerStats(source.workloadId, source.containerRowId, signal);
}
async function fetchHistory(signal: AbortSignal, win: string = historyWindow): Promise<ContainerStatsSample[]> {
if (source.kind === 'instance') {
return api.fetchInstanceStatsHistory(
source.projectId,
source.stageId,
source.instanceId,
win,
signal
);
}
return api.fetchStaticSiteStatsHistory(source.siteId, win, signal);
return api.fetchWorkloadContainerStatsHistory(
source.workloadId,
source.containerRowId,
win,
signal
);
}
$effect(() => {
-176
View File
@@ -1,176 +0,0 @@
<!--
Task 5: Instance card with inline status badges, icon action buttons, improved layout.
-->
<script lang="ts">
import type { Instance } from '$lib/types';
import StatusBadge from './StatusBadge.svelte';
import ContainerStats from './ContainerStats.svelte';
import ContainerLogs from './ContainerLogs.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink, IconEvents } from '$lib/components/icons';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
import * as api from '$lib/api';
interface Props {
instance: Instance;
projectId: string;
stageId: string;
domain?: string;
onchange?: () => void;
}
const { instance, projectId, stageId, domain = '', onchange }: Props = $props();
let loading = $state(false);
let error = $state('');
let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null);
let showLogs = $state(false);
const subdomainUrl = $derived(
instance.subdomain && domain
? `https://${instance.subdomain}.${domain}`
: instance.subdomain ? `https://${instance.subdomain}` : ''
);
const timeSinceCreated = $derived($fmt.relative(instance.created_at));
async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') {
loading = true;
error = '';
confirmAction = null;
try {
switch (action) {
case 'stop':
await api.stopInstance(projectId, stageId, instance.id);
break;
case 'start':
await api.startInstance(projectId, stageId, instance.id);
break;
case 'restart':
await api.restartInstance(projectId, stageId, instance.id);
break;
case 'remove':
await api.removeInstance(projectId, stageId, instance.id);
break;
}
onchange?.();
} catch (e) {
error = e instanceof Error ? e.message : $t('instance.actionFailed');
} finally {
loading = false;
}
}
function requestConfirm(action: 'stop' | 'restart' | 'remove') {
confirmAction = action;
}
</script>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)]">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-mono text-sm font-medium text-[var(--text-primary)]">
{instance.image_tag}
</span>
<StatusBadge status={instance.state} size="sm" />
</div>
{#if subdomainUrl}
<a
href={subdomainUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-1.5 inline-flex items-center gap-1 text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
>
{instance.subdomain}
<IconExternalLink size={12} />
</a>
{/if}
<div class="mt-1.5 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{instance.port}</span>
<span>{timeSinceCreated}</span>
</div>
</div>
<!-- Action buttons -->
<div class="ml-3 flex items-center gap-1">
{#if instance.state === 'running'}
<button
type="button"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-amber-50 hover:text-amber-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.stop')}
disabled={loading}
onclick={() => requestConfirm('stop')}
>
<IconStop size={16} />
</button>
<button
type="button"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.restart')}
disabled={loading}
onclick={() => requestConfirm('restart')}
>
<IconRestart size={16} />
</button>
{:else if instance.state === 'stopped'}
<button
type="button"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.start')}
disabled={loading}
onclick={() => handleAction('start')}
>
<IconPlay size={16} />
</button>
{/if}
<button
type="button"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 transition-all duration-150"
title={$t('logs.title')}
onclick={() => { showLogs = !showLogs; }}
>
<IconEvents size={16} />
</button>
<button
type="button"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.remove')}
disabled={loading}
onclick={() => requestConfirm('remove')}
>
<IconTrash size={16} />
</button>
</div>
</div>
{#if instance.state === 'running'}
<ContainerStats source={{ kind: 'instance', projectId, stageId: stageId, instanceId: instance.id }} />
{/if}
{#if showLogs}
<div class="mt-2">
<ContainerLogs
source={{ kind: 'instance', projectId, stageId: stageId, instanceId: instance.id }}
onclose={() => { showLogs = false; }}
/>
</div>
{/if}
{#if error}
<p class="mt-2 text-xs text-[var(--color-danger)]">{error}</p>
{/if}
</div>
<ConfirmDialog
open={confirmAction !== null}
title={confirmAction ? $t(`confirm.${confirmAction}Instance`) : ''}
message={confirmAction ? $t(`instance.${confirmAction}Confirm`) : ''}
confirmLabel={confirmAction ? $t(`confirm.${confirmAction}Action`) : ''}
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
onconfirm={() => { if (confirmAction) handleAction(confirmAction); }}
oncancel={() => { confirmAction = null; }}
/>
-83
View File
@@ -1,83 +0,0 @@
<!--
Task 4: Redesigned project card with status indicators, instance count badges, hover effects.
-->
<script lang="ts">
import type { Project, Instance } from '$lib/types';
import StatusBadge from './StatusBadge.svelte';
import { t } from '$lib/i18n';
import { IconContainer, IconBox } from '$lib/components/icons';
interface Props {
project: Project;
instances?: Instance[];
}
const { project, instances = [] }: Props = $props();
const runningCount = $derived(instances.filter((i) => i.state === 'running').length);
const stoppedCount = $derived(instances.filter((i) => i.state === 'stopped').length);
const failedCount = $derived(instances.filter((i) => i.state === 'failed').length);
const totalCount = $derived(instances.length);
const overallStatus = $derived.by<'failed' | 'running' | 'stopped'>(() => {
if (failedCount > 0) return 'failed';
if (runningCount > 0) return 'running';
if (stoppedCount > 0) return 'stopped';
return 'stopped';
});
</script>
<a
href="/projects/{project.id}"
class="group block rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-all duration-200 hover:border-[var(--color-brand-300)] hover:shadow-[var(--shadow-md)] hover:-translate-y-0.5"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-brand-50)] text-[var(--color-brand-600)] transition-colors group-hover:bg-[var(--color-brand-100)]">
<IconBox size={16} />
</div>
<h3 class="truncate text-base font-semibold text-[var(--text-primary)]">{project.name}</h3>
</div>
<p class="mt-2 truncate font-mono text-xs text-[var(--text-tertiary)]">{project.image}</p>
</div>
<StatusBadge status={overallStatus} size="sm" />
</div>
<!-- Instance count badges -->
<div class="mt-4 flex items-center gap-3 text-sm">
{#if totalCount > 0}
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
<span class="text-xs font-medium">{runningCount}</span>
</span>
{#if stoppedCount > 0}
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
<span class="text-xs font-medium">{stoppedCount}</span>
</span>
{/if}
{#if failedCount > 0}
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
<span class="text-xs font-medium">{failedCount}</span>
</span>
{/if}
<span class="ml-auto rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">
{totalCount} {totalCount === 1 ? $t('common.instance') : $t('common.instances')}
</span>
{:else}
<span class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</span>
{/if}
</div>
<!-- Meta info -->
<div class="mt-3 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
{#if project.port}
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{project.port}</span>
{/if}
{#if project.healthcheck}
<span class="truncate">{project.healthcheck}</span>
{/if}
</div>
</a>
@@ -1,85 +0,0 @@
<!--
Card displaying a single stale container with cleanup action.
-->
<script lang="ts">
import type { StaleContainer } from '$lib/types';
import { IconClock, IconTag, IconTrash } from '$lib/components/icons';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
interface Props {
container: StaleContainer;
cleaning?: boolean;
oncleanup: (id: string) => void;
}
const { container, cleaning = false, oncleanup }: Props = $props();
const badgeClass = $derived(
container.days_stale >= 14
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
);
const displayName = $derived(
container.role
? `${container.workload_name}-${container.role}-${container.container.image_tag}`
: `${container.workload_name}-${container.container.image_tag}`
);
</script>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
<!-- Header row -->
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-[var(--text-primary)]" title={displayName}>
{displayName}
</h3>
<div class="mt-1.5 flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-md bg-[var(--color-brand-50)] px-2 py-0.5 text-xs font-medium text-[var(--color-brand-600)]">
{container.workload_name}
</span>
{#if container.role}
<span class="inline-flex items-center gap-1 rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
{container.role}
</span>
{/if}
</div>
</div>
<!-- Days stale badge -->
<span class="inline-flex flex-shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold {badgeClass}">
<IconClock size={12} />
{container.days_stale} {$t('stale.daysStale')}
</span>
</div>
<!-- Details -->
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-[var(--text-secondary)]">
<span class="inline-flex items-center gap-1">
<IconTag size={12} />
{container.container.image_tag}
</span>
<span class="inline-flex items-center gap-1">
<IconClock size={12} />
{$t('stale.lastAlive')}: {$fmt.shortDate(container.container.last_seen_at)}
</span>
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono text-[10px]">
{container.container.state}
</span>
</div>
<!-- Cleanup button -->
<div class="mt-4 flex justify-end">
<button
type="button"
disabled={cleaning}
onclick={() => oncleanup(container.container.id)}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-xs font-medium text-[var(--color-danger)] transition-colors hover:bg-[var(--color-danger-light)] disabled:opacity-50 active:animate-press"
>
<IconTrash size={14} />
{$t('stale.cleanup')}
</button>
</div>
</div>
+5 -3
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import type { InstanceStatus, DeployStatus } from '$lib/types';
type Status = InstanceStatus | DeployStatus | string;
// The badge accepts any status string — known container / deploy /
// site states render with a colored variant; unknown values fall
// through to the neutral pill. Typed as plain string after the
// legacy InstanceStatus / DeployStatus unions were retired.
type Status = string;
interface Props {
status: Status;
+19 -30
View File
@@ -1,8 +1,12 @@
<!--
Dashboard summary card: container counts and recent errors.
Workload-first: pulls running/total counts straight from the global
containers index instead of fanning out project → stage → instance
queries the way the legacy implementation did.
-->
<script lang="ts">
import type { Instance, EventLogStats } from '$lib/types';
import type { EventLogStats } from '$lib/types';
import * as api from '$lib/api';
import { IconServer, IconAlert } from '$lib/components/icons';
import { t } from '$lib/i18n';
@@ -13,37 +17,24 @@
let loading = $state(true);
$effect(() => {
let cancelled = false;
const ac = new AbortController();
async function load() {
try {
const [projects, eventStats] = await Promise.all([
api.listProjects(),
api.fetchEventLogStats().catch(() => ({ info: 0, warn: 0, error: 0, total: 0 }) as EventLogStats)
const [containers, eventStats] = await Promise.all([
api.listContainers({}, ac.signal).catch(() => []),
api.fetchEventLogStats(ac.signal).catch(
() => ({ info: 0, warn: 0, error: 0, total: 0 }) as EventLogStats
)
]);
// Gather all instances across projects/stages.
const allInstances: Instance[] = [];
for (const project of projects) {
try {
const detail = await api.getProject(project.id);
for (const stage of detail.stages ?? []) {
const instances = await api.listInstances(project.id, stage.id);
allInstances.push(...instances);
}
} catch {
// Skip projects that fail to load.
}
}
if (!cancelled) {
runningCount = allInstances.filter((i) => i.state === 'running').length;
stoppedCount = allInstances.filter((i) => i.state !== 'running').length;
recentErrors = eventStats.error;
loading = false;
}
if (ac.signal.aborted) return;
runningCount = containers.filter((c) => c.state === 'running').length;
stoppedCount = containers.filter((c) => c.state !== 'running').length;
recentErrors = eventStats.error;
loading = false;
} catch {
if (!cancelled) {
if (!ac.signal.aborted) {
loading = false;
}
}
@@ -51,9 +42,7 @@
load();
return () => {
cancelled = true;
};
return () => ac.abort();
});
</script>
@@ -62,7 +51,7 @@
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<!-- Containers -->
<a href="/projects" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
<a href="/containers" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
<IconServer size={18} />
</div>
+1 -1
View File
@@ -176,7 +176,7 @@
>
<span class="truncate font-mono">{item.tag}</span>
<span class="ml-2 shrink-0 rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
{item.source === 'registry' ? $t('projectDetail.registryTag') : $t('projectDetail.localTag')}
{item.source === 'registry' ? $t('tagPicker.registry') : $t('tagPicker.local')}
</span>
</button>
{/each}
@@ -1,9 +1,10 @@
<!--
WebhookDeliveryLog
Recent inbound webhook activity panel. Used on the project + site detail
pages so users can debug "why didn't my deploy fire?" without grepping
daemon logs. Polls the audit table every 30s while the panel is mounted.
Recent inbound webhook activity panel. Mounted on the Trigger detail
page (the project + site detail pages it once served were dropped in
the hard legacy cutover). Polls the audit table every 30s so users can
debug "why didn't my deploy fire?" without grepping daemon logs.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
+30 -576
View File
@@ -18,38 +18,27 @@
"eventTriggers": "Triggers",
"logScanRules": "Log Rules",
"triggers": "Triggers",
"projects": "Projects",
"deploy": "Deploy",
"proxies": "Proxies",
"events": "Events",
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records",
"sites": "Sites",
"stacks": "Stacks",
"containers": "Containers"
},
"dashboard": {
"title": "Dashboard",
"quickDeploy": "Quick Deploy",
"totalProjects": "Total Projects",
"runningInstances": "Running Instances",
"failedInstances": "Failed Instances",
"projects": "Projects",
"newApp": "New app",
"totalWorkloads": "Total Workloads",
"runningContainers": "Running Containers",
"failedContainers": "Failed Containers",
"recentWorkloads": "Recent Workloads",
"retry": "Retry",
"noProjects": "No projects yet.",
"addFirst": "Add your first project",
"noWorkloads": "No workloads yet.",
"noWorkloadsDesc": "Create an app and forge your first workload to get started.",
"loadFailed": "Failed to load dashboard",
"staleContainers": "Stale Containers",
"unusedImagesWarning": "Unused Docker images are taking up disk space",
"unusedImages": "unused images",
"staticSites": "Static Sites",
"totalSites": "Total Sites",
"deployedSites": "deployed",
"failedSites": "failed",
"noSites": "No static sites yet.",
"addFirstSite": "Deploy your first site",
"viewAllSites": "View all sites",
"systemHealth": "System health",
"daemons": "Daemons",
"systemResources": "System resources",
@@ -92,240 +81,9 @@
"retentionLabel": "Stats retention (hours)",
"retentionHelp": "How long resource samples are kept. 0 disables collection. Range: 024h."
},
"projects": {
"title": "Projects",
"addProject": "Add Project",
"cancel": "Cancel",
"newProject": "New Project",
"name": "Name",
"image": "Image",
"port": "Port",
"tagPicker": {
"registry": "Registry",
"created": "Created",
"view": "View",
"noProjects": "No projects configured yet.",
"getStarted": "Click \"Add Project\" to get started.",
"createProject": "Create Project",
"creating": "Creating...",
"healthcheck": "Healthcheck Path",
"nameRequired": "Name and image are required.",
"loadFailed": "Failed to load projects",
"createFailed": "Failed to create project",
"browseImages": "Browse Images",
"selectImage": "Select an image",
"noImages": "No images found",
"loadingImages": "Loading images...",
"imageLoadFailed": "Failed to load images",
"alreadyAdded": "Already added",
"portHelpText": "Auto-detected from EXPOSE if empty",
"healthcheckHelpText": "Auto-detected from image if empty",
"searchPlaceholder": "Search projects by name, image, or registry...",
"noMatchingProjects": "No projects match your search."
},
"projectDetail": {
"webhookTitle": "Project webhook",
"webhookDesc": "POST an image reference to this URL from your CI pipeline to trigger a deploy. Stage routing uses each stage's tag pattern.",
"outgoingWebhookTitle": "Outgoing webhook (project)",
"outgoingWebhookDesc": "Where Tinyforge posts deploy events for this project. Stages can override; if none set, inherits from global settings.",
"outgoingFallbackGlobal": "the global integrations setting",
"notificationUrlLabel": "Outgoing webhook URL",
"notificationUrlHelp": "Leave empty to inherit from global settings. Stages can override per-stage.",
"stageNotificationUrlLabel": "Outgoing webhook URL (this stage)",
"stageNotificationUrlHelp": "Leave empty to inherit from the project, then global settings.",
"stageOutgoingTitle": "Outgoing webhook (stage)",
"stageOutgoingDesc": "Where Tinyforge posts deploy events for this stage. Most-specific tier wins.",
"stageFallbackLabel": "the project or global settings",
"deleteProject": "Delete Project",
"envVars": "Environment Variables",
"volumes": "Volume Mounts",
"stages": "Stages",
"noStages": "No stages configured for this project.",
"pattern": "Pattern",
"autoDeploy": "auto-deploy",
"requiresConfirm": "requires confirm",
"instances": "instances",
"deployNewVersion": "Deploy new version",
"selectTag": "Select tag to deploy",
"loadingTags": "Loading tags...",
"chooseTag": "Choose a tag...",
"enterTag": "Enter image tag (e.g., dev-abc123)",
"registryTag": "Registry",
"localTag": "Local",
"alsoLocal": "Also available locally",
"searchTags": "Search tags...",
"deployTag": "Tag",
"deploy": "Deploy",
"deploying": "Deploying...",
"recentDeploys": "Recent Deploys",
"noDeployHistory": "No deploy history for this project.",
"tag": "Tag",
"status": "Status",
"started": "Started",
"finished": "Finished",
"error": "Error",
"noInstancesRunning": "No instances running",
"deleteConfirmTitle": "Delete Project",
"deleteConfirmMessage": "This will permanently delete the project '{name}' and all its stages, instances, and deploy history. This cannot be undone.",
"loadFailed": "Failed to load project",
"deleteFailed": "Failed to delete project",
"deployFailed": "Deploy failed",
"nameLabel": "Name *",
"imageLabel": "Image *",
"portLabel": "Port",
"healthcheckLabel": "Healthcheck Path",
"saving": "Saving...",
"addStage": "Add Stage",
"tagPattern": "Tag Pattern",
"tagPatternHelp": "Glob pattern (e.g., dev-*, v*)",
"maxInstances": "Max Instances",
"autoDeployLabel": "Auto Deploy",
"enableProxy": "Enable Proxy",
"accessListId": "NPM Access List ID",
"accessListIdHelp": "Override the global access list for this project. Clear to inherit from NPM settings.",
"localImages": "Local Docker Images",
"imageTag": "Tag",
"imageId": "Image ID",
"imageSize": "Size",
"imageCreated": "Created",
"cpuLimit": "CPU Limit (cores)",
"cpuLimitHelp": "e.g., 0.5, 1, 2. Leave 0 for unlimited",
"memoryLimit": "Memory Limit (MB)",
"memoryLimitHelp": "e.g., 256, 512, 1024. Leave 0 for unlimited",
"npmProxy": "NPM Proxy",
"creating": "Creating...",
"createStage": "Create Stage",
"noProxy": "No Proxy",
"deleteStage": "Delete stage",
"deleteStageConfirm": "Delete stage \"{name}\"?",
"stageCreated": "Stage \"{name}\" created",
"stageUpdated": "Stage updated",
"stageUpdateFailed": "Failed to update stage",
"stageDeleted": "Stage \"{name}\" deleted",
"projectUpdated": "Project updated",
"updateFailed": "Failed to update project",
"stageCreateFailed": "Failed to create stage",
"stageDeleteFailed": "Failed to delete stage"
},
"envEditor": {
"title": "Environment Variables",
"description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.",
"stage": "Stage",
"projectDefaults": "Project-Level Defaults",
"noProjectEnv": "No project-level environment variables defined yet.",
"stageOverrides": "Stage Overrides",
"key": "Key",
"value": "Value",
"secret": "Secret",
"source": "Source",
"actions": "Actions",
"overridden": "overridden",
"inherited": "inherited",
"overridesProject": "overrides project",
"stageOnly": "stage only",
"edit": "Edit",
"change": "Change",
"delete": "Delete",
"save": "Save",
"add": "Add",
"adding": "Adding...",
"noStages": "No stages configured. Add stages to the project first.",
"loadFailed": "Failed to load project",
"envAdded": "Environment variable added",
"envUpdated": "Environment variable updated",
"envDeleted": "Environment variable deleted",
"addFailed": "Failed to add env var",
"updateFailed": "Failed to update env var",
"deleteFailed": "Failed to delete env var",
"loadEnvFailed": "Failed to load env vars",
"leaveEmptyToKeep": "Leave empty to keep current",
"deleteTitle": "Delete Environment Variable",
"deleteMessage": "Are you sure you want to delete this environment variable? This action cannot be undone."
},
"volumeEditor": {
"title": "Volume Mounts",
"description": "Configure volume mounts for containers. Choose a scope to control how volumes are shared between deploys.",
"sourceHost": "Source (Host)",
"targetContainer": "Target (Container)",
"scope": "Scope",
"nameColumn": "Name",
"namePlaceholder": "e.g. shared-db",
"requiresName": "requires name",
"noHostPath": "no host path",
"tmpfs": "tmpfs (in-memory)",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"add": "Add",
"adding": "Adding...",
"scopeGuide": "Volume Scopes",
"noVolumes": "No volumes configured yet. Add one above.",
"volumeAdded": "Volume added",
"volumeUpdated": "Volume updated",
"volumeDeleted": "Volume deleted",
"loadFailed": "Failed to load volumes",
"addFailed": "Failed to add volume",
"updateFailed": "Failed to update volume",
"deleteFailed": "Failed to delete volume"
},
"volumeBrowser": {
"title": "Volume Browser",
"loadFailed": "Failed to load directory",
"empty": "This directory is empty.",
"name": "Name",
"size": "Size",
"modified": "Modified",
"downloadAll": "Download volume as ZIP",
"downloadFolder": "Download folder as ZIP",
"upload": "Upload files",
"uploaded": "Uploaded",
"files": "file(s)",
"uploadFailed": "Failed to upload files",
"browse": "Browse",
"download": "Download"
},
"quickDeploy": {
"title": "Quick Deploy",
"description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.",
"step1": "1. Enter Image URL",
"imageUrl": "Image URL",
"imageUrlHelp": "Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)",
"inspect": "Inspect",
"inspecting": "Inspecting...",
"step2": "2. Review Configuration",
"reviewDesc": "These defaults were detected from the image. Adjust as needed before deploying.",
"projectName": "Project Name",
"port": "Port",
"portHelp": "Container port to expose (1-65535)",
"healthCheckPath": "Health Check Path",
"healthCheckHelp": "Optional HTTP path for health verification",
"stage": "Stage",
"development": "Development",
"release": "Release",
"production": "Production",
"stageHelp": "Deployment stage for this image",
"subdomainOverride": "Subdomain Override",
"subdomainHelp": "Leave empty to use the default subdomain pattern",
"envVars": "Environment Variables",
"envVarsHelp": "One per line, KEY=VALUE format",
"step3": "3. Deploy",
"deployDesc": "A new project will be created and the container will be deployed immediately.",
"deployBtn": "Deploy",
"inspectedSuccess": "Image inspected successfully",
"deployedSuccess": "Deployed {name} successfully!",
"inspectFailed": "Failed to inspect image",
"deployFailed": "Deployment failed",
"browseImages": "Browse",
"selectImage": "Select an image from a registry",
"noImages": "No images found",
"loadingImages": "Loading...",
"imageLoadFailed": "Failed to load images",
"autoDeployLabel": "Deploy immediately",
"lowercaseHint": "Lowercase with hyphens",
"imageAlreadyExists": "Image already deployed",
"conflictDescription": "A project using this image already exists. You can open the existing project to deploy a new version, or create a separate project.",
"openProject": "Open project \u2192",
"createNewAnyway": "Create New Project"
"local": "Local"
},
"settings": {
"title": "Settings",
@@ -489,7 +247,7 @@
"testing": "Testing...",
"testSuccess": "NPM connection successful",
"testFailed": "NPM connection failed",
"saveFailedConnection": "Cannot save \u2014 connection test failed",
"saveFailedConnection": "Cannot save connection test failed",
"remoteMode": "Remote NPM",
"remoteModeHelp": "Enable when NPM runs on a different machine than Docker. Forwards to Server IP with published host ports.",
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.",
@@ -613,117 +371,28 @@
"networkError": "Network error"
},
"proxies": {
"title": "Proxy Manager",
"create": "Create Proxy",
"standalone": "Standalone Proxies",
"managed": "Managed Proxies",
"noProxies": "No proxies found",
"noProxiesDesc": "Create a standalone proxy or deploy a project with proxy enabled.",
"filter": {
"search": "Search by domain or destination...",
"health": "Health",
"type": "Type",
"all": "All",
"clear": "Clear filters"
},
"health": {
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"unknown": "Unknown"
},
"lastChecked": "Last checked"
},
"sites": {
"webhookTitle": "Site webhook",
"webhookDesc": "Point your Git provider's push webhook at this URL. Tinyforge will re-sync the site on matching refs (branch for push trigger, tag pattern for tag trigger). Send an empty body for an unconditional sync.",
"outgoingUrlTitle": "Outgoing webhook URL (this site)",
"outgoingUrlDesc": "Where Tinyforge posts site_sync_success / site_sync_failure events for this site. Empty falls through to global settings.",
"outgoingWebhookTitle": "Outgoing webhook (site)",
"outgoingWebhookDesc": "HMAC signing secret and test sender for the resolved outgoing URL.",
"outgoingFallbackGlobal": "the global integrations setting",
"title": "Static Sites",
"addSite": "New Site",
"newSite": "New Static Site",
"createSite": "Create Site",
"noSites": "No static sites",
"noSitesDesc": "Deploy static content from a Git repository folder.",
"searchPlaceholder": "Search sites by name, domain, or repo...",
"noMatching": "No sites match your search.",
"name": "Name",
"title": "Proxy Routes",
"description": "Active proxy routes from deployed containers and static sites.",
"domain": "Domain",
"mode": "Mode",
"project": "Project / Site",
"stage": "Stage / Mode",
"tag": "Tag",
"port": "Port",
"status": "Status",
"lastSync": "Last Sync",
"deploy": "Deploy",
"stop": "Stop",
"start": "Start",
"openSite": "Open Site",
"confirmDelete": "Delete Site",
"confirmDeleteMsg": "This will permanently delete the site and remove its container",
"confirmDeleteSecret": "Delete Secret",
"confirmDeleteSecretMsg": "Are you sure you want to delete secret",
"siteInfo": "Site Information",
"folder": "Folder",
"syncTrigger": "Sync Trigger",
"commitSha": "Commit SHA",
"secrets": "Secrets",
"addSecret": "Add Secret",
"noSecrets": "No secrets configured. Add secrets if your site needs server-side API keys.",
"secretKey": "Key",
"secretValue": "Value",
"encryptSecret": "Encrypt value",
"saveSecret": "Add Secret",
"step1Title": "1. Repository",
"step2Title": "2. Select Branch",
"step3Title": "3. Select Folder",
"step4Title": "4. Configuration",
"step5Title": "5. Review & Create",
"fullRepoUrl": "Repository URL",
"fullRepoUrlHelp": "Paste a full URL to auto-fill the fields below (e.g., https://git.example.com/owner/repo)",
"serverUrl": "Server URL",
"repoUrl": "Git Server URL",
"repoUrlHelp": "Paste a full repo URL or enter the server base URL (Gitea, Forgejo, Gogs)",
"repoOwner": "Owner",
"repoName": "Repository",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Optional — for private repos",
"accessTokenHelp": "Personal access token with repo read permissions. Leave empty for public repos.",
"noToken": "None (public repo)",
"testConnection": "Test Connection",
"connectionSuccess": "Repository is accessible",
"loadingBranches": "Loading branches...",
"selectBranch": "Select a branch",
"chooseBranch": "Choose a branch...",
"branch": "Branch",
"loadingTree": "Loading repository tree...",
"selectFolder": "Select the folder containing your site files",
"selectedFolder": "Selected folder",
"siteName": "Site Name",
"domainHelp": "Public domain for the site. Proxy will be configured automatically.",
"modeStaticDesc": "HTML, CSS, JS, images served via Nginx",
"modeDenoDesc": "Static files + server-side API from api/ folder",
"triggerManual": "Manual",
"triggerPush": "On Push",
"triggerTag": "On Tag",
"tagPattern": "Tag Pattern",
"tagPatternHelp": "Glob pattern for matching tags (e.g., v*, pages-*)",
"renderMarkdown": "Render Markdown files to HTML",
"provider": "Git Provider",
"detectedProvider": "Auto-detected",
"browseRepos": "Browse repositories",
"selectRepo": "Select a repository",
"storage": "Persistent Storage",
"enableStorage": "Enable persistent storage",
"storageHelp": "Mounts a Docker volume at /app/data for your Deno backend to read and write files that persist across deployments.",
"storageLimitMB": "Storage Limit (MB)",
"storageLimitHelp": "Maximum storage size in megabytes. 0 = unlimited.",
"storageVolume": "Volume",
"dataPath": "Data Path",
"storageMountPath": "Mount Path",
"storageLimit": "Limit",
"storageUsed": "Used",
"storageOfLimit": "of limit used",
"unlimited": "Unlimited"
"source": "Source",
"sourceContainer": "Container",
"sourceStatic": "Static Site",
"sourceDeno": "Deno Site",
"filterAll": "All",
"filterContainers": "Containers",
"filterSites": "Sites",
"noRoutes": "No proxy routes",
"noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled or publish a static site.",
"searchPlaceholder": "Search by domain, project, or tag...",
"noMatch": "No routes match your search.",
"loadFailed": "Failed to load proxy routes",
"route": "route",
"routes": "routes"
},
"common": {
"cancel": "Cancel",
@@ -775,24 +444,9 @@
"lastSeen": "Last seen"
}
},
"instance": {
"stopConfirm": "This will stop the running container. The instance can be started again later.",
"restartConfirm": "This will restart the container, causing brief downtime.",
"removeConfirm": "This will permanently remove the container and its proxy configuration. This cannot be undone.",
"actionFailed": "Action failed"
},
"empty": {
"noProjects": "No projects yet",
"noProjectsDesc": "Get started by creating your first project or use Quick Deploy.",
"createProject": "Create Project",
"noInstances": "No instances",
"noInstancesDesc": "Deploy a new version to see instances here.",
"noDeploys": "No deploy history",
"noDeploysDesc": "Deploy history will appear here after your first deployment.",
"noRegistries": "No registries",
"noRegistriesDesc": "Add a container registry to enable image detection.",
"noVolumes": "No volumes",
"noVolumesDesc": "Configure volume mounts for persistent data.",
"noUsers": "No users",
"noUsersDesc": "Add local users to manage access."
},
@@ -808,15 +462,6 @@
"requiredWhenUpdating": "{field} is required when updating credentials",
"requiredForNew": "{field} is required for new registries"
},
"confirm": {
"stopInstance": "Stop Instance",
"startInstance": "Start Instance",
"restartInstance": "Restart Instance",
"removeInstance": "Remove Instance",
"stopAction": "Stop",
"restartAction": "Restart",
"removeAction": "Remove"
},
"theme": {
"light": "Light",
"dark": "Dark",
@@ -842,75 +487,6 @@
"cleanupFailed": "Cleanup failed",
"loadFailed": "Failed to load stale containers"
},
"proxies": {
"title": "Proxies",
"create": "Create Proxy",
"noProxies": "No proxies configured yet.",
"noProxiesDesc": "Create a standalone proxy or deploy a project to see proxies here.",
"standalone": "Standalone Proxies",
"managed": "Managed",
"lastChecked": "Last checked",
"health": {
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"unknown": "Unknown"
},
"filter": {
"search": "Search proxies...",
"health": "Health",
"type": "Type",
"all": "All",
"clear": "Clear filters"
},
"form": {
"title": "Create Proxy",
"editTitle": "Edit Proxy",
"destination": "Destination URL / IP",
"port": "Port",
"domain": "Domain",
"domainHelp": "The public domain for this proxy.",
"validate": "Validate",
"validating": "Validating...",
"create": "Create Proxy",
"save": "Save Changes",
"cancel": "Cancel",
"delete": "Delete",
"deleteConfirm": "Delete this proxy? This cannot be undone."
},
"validation": {
"title": "Destination Validation",
"syntax": "URL syntax",
"dns": "DNS resolution",
"tcp": "TCP connection",
"http": "HTTP response",
"checking": "Checking...",
"skipped": "Skipped"
}
},
"proxies": {
"title": "Proxy Routes",
"description": "Active proxy routes from deployed containers and static sites.",
"domain": "Domain",
"project": "Project / Site",
"stage": "Stage / Mode",
"tag": "Tag",
"port": "Port",
"status": "Status",
"source": "Source",
"sourceContainer": "Container",
"sourceStatic": "Static Site",
"sourceDeno": "Deno Site",
"filterAll": "All",
"filterContainers": "Containers",
"filterSites": "Sites",
"noRoutes": "No proxy routes",
"noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled or publish a static site.",
"searchPlaceholder": "Search by domain, project, or tag...",
"noMatch": "No routes match your search.",
"loadFailed": "Failed to load proxy routes",
"route": "route",
"routes": "routes"
},
"logs": {
"title": "Container Logs",
"lines": "lines",
@@ -1049,128 +625,6 @@
"en": "English",
"ru": "Russian"
},
"stacks": {
"eyebrow": "THE FORGE",
"title": "Stacks",
"lede": "Compose blueprints, forged as <em>atomic units</em>. Spin up services, iterate on revisions, roll back without breaking a sweat.",
"newStack": "New stack",
"refresh": "Refresh",
"total": "Total",
"running": "Running",
"deploying": "Forging",
"failed": "Failed",
"stopped": "Cold",
"empty": {
"title": "The anvil is cold.",
"desc": "Upload a docker-compose.yml to forge your first stack."
},
"card": {
"noDescription": "No description",
"updated": "Updated",
"start": "Start",
"stop": "Stop",
"delete": "Delete",
"open": "Open"
},
"new": {
"eyebrow": "NEW BLUEPRINT",
"title": "Forge a new stack.",
"lede": "Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint deploy as a single atomic unit.",
"back": "Stacks",
"name": "Name",
"namePlaceholder": "my-app-stack",
"nameHint": "Lowercase, hyphenated. Used as the compose project name.",
"description": "Description",
"descriptionPlaceholder": "What does this stack do?",
"composeYaml": "Compose YAML",
"required": "required",
"optional": "optional",
"loadSample": "Load sample",
"uploadFile": "Upload file",
"dropHere": "Drop a docker-compose.yml here",
"dropSub": "or click to browse · or use <strong>Load sample</strong> above",
"lines": "{n} lines",
"bytes": "{n} bytes",
"clear": "Clear",
"deployImmediate": "Deploy immediately",
"deployHint": "Strike while the iron's hot. If unchecked, the stack is saved cold.",
"cancel": "Cancel",
"forging": "Forging…",
"forgeAndDeploy": "Forge & deploy",
"saveBlueprint": "Save blueprint",
"errorRequired": "Name and compose YAML are required.",
"errorCreate": "Failed to create stack"
},
"detail": {
"manifest": "MANIFEST",
"loading": "Loading blueprint…",
"composeProject": "COMPOSE PROJECT",
"noDescription": "No description",
"refresh": "Refresh",
"start": "Start",
"stop": "Stop",
"delete": "Delete",
"fault": "FAULT",
"err": "ERR",
"stats": {
"services": "Services",
"servicesSub": "in blueprint",
"running": "Running",
"runningSub": "active containers",
"revisions": "Revisions",
"revisionsSub": "in history",
"current": "Current",
"currentSub": "deployed"
},
"services": {
"title": "Services",
"count": "{n} on the floor",
"empty": "— no containers running —"
},
"tabs": {
"blueprint": "Blueprint",
"revisions": "Revisions",
"logs": "Logs"
},
"yaml": {
"currentRevision": "Current revision",
"edit": "Edit & redeploy",
"cancel": "Cancel",
"forging": "Forging…",
"deployNew": "Deploy new revision"
},
"revisions": {
"current": "CURRENT",
"by": "by",
"rollback": "← Rollback to this revision",
"rollbackTitle": "Rollback to revision?",
"rollbackMessage": "Create a new revision from rev {n} and redeploy the stack.",
"rollbackConfirm": "Rollback"
},
"logs": {
"service": "Service:",
"allServices": "All services",
"fetching": "Fetching…",
"fetch": "Fetch logs",
"empty": "— no logs loaded. tap fetch. —"
},
"delete": {
"title": "Delete stack?",
"messageBase": "This runs 'docker compose down' and removes \"{name}\".",
"messageVolumes": " Named volumes will also be removed.",
"confirm": "Delete"
},
"errors": {
"load": "Failed to load stack",
"stop": "Stop failed",
"start": "Start failed",
"update": "Update failed",
"rollback": "Rollback failed",
"delete": "Delete failed",
"fetchLogs": "Failed to load logs"
}
}
},
"timezone": {
"eyebrow": "The Forge // Chronograph",
"title": "Display timezone",
+29 -575
View File
@@ -18,38 +18,27 @@
"eventTriggers": "Триггеры",
"logScanRules": "Лог-правила",
"triggers": "Триггеры",
"projects": "Проекты",
"deploy": "Деплой",
"proxies": "Прокси",
"events": "События",
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи",
"sites": "Сайты",
"stacks": "Стеки",
"containers": "Контейнеры"
},
"dashboard": {
"title": "Панель управления",
"quickDeploy": "Быстрый деплой",
"totalProjects": "Всего проектов",
"runningInstances": "Запущенных экземпляров",
"failedInstances": "Сбойных экземпляров",
"projects": "Проекты",
"newApp": "Новое приложение",
"totalWorkloads": "Всего нагрузок",
"runningContainers": "Запущенных контейнеров",
"failedContainers": "Сбойных контейнеров",
"recentWorkloads": "Недавние нагрузки",
"retry": "Повторить",
"noProjects": "Проектов пока нет.",
"addFirst": "Добавьте первый проект",
"noWorkloads": "Нагрузок пока нет.",
"noWorkloadsDesc": "Создайте приложение и выкуйте первую нагрузку, чтобы начать.",
"loadFailed": "Не удалось загрузить панель",
"staleContainers": "Устаревшие контейнеры",
"unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство",
"unusedImages": "неиспользуемых образов",
"staticSites": "Статические сайты",
"totalSites": "Всего сайтов",
"deployedSites": "развёрнуто",
"failedSites": "с ошибкой",
"noSites": "Статических сайтов пока нет.",
"addFirstSite": "Разверните первый сайт",
"viewAllSites": "Все сайты",
"systemHealth": "Состояние системы",
"daemons": "Демоны",
"systemResources": "Системные ресурсы",
@@ -92,240 +81,9 @@
"retentionLabel": "Хранение статистики (часы)",
"retentionHelp": "Как долго хранятся замеры ресурсов. 0 отключает сбор. Диапазон: 0–24ч."
},
"projects": {
"title": "Проекты",
"addProject": "Добавить проект",
"cancel": "Отмена",
"newProject": "Новый проект",
"name": "Название",
"image": "Образ",
"port": "Порт",
"tagPicker": {
"registry": "Реестр",
"created": "Создан",
"view": "Открыть",
"noProjects": "Проекты ещё не настроены.",
"getStarted": "Нажмите «Добавить проект» для начала.",
"createProject": "Создать проект",
"creating": "Создание...",
"healthcheck": "Путь проверки здоровья",
"nameRequired": "Название и образ обязательны.",
"loadFailed": "Не удалось загрузить проекты",
"createFailed": "Не удалось создать проект",
"browseImages": "Обзор образов",
"selectImage": "Выберите образ",
"noImages": "Образы не найдены",
"loadingImages": "Загрузка образов...",
"imageLoadFailed": "Не удалось загрузить образы",
"alreadyAdded": "Уже добавлен",
"portHelpText": "Автоопределение из EXPOSE, если пусто",
"healthcheckHelpText": "Автоопределение из образа, если пусто",
"searchPlaceholder": "Поиск по имени, образу или реестру...",
"noMatchingProjects": "Проекты не найдены."
},
"projectDetail": {
"webhookTitle": "Webhook проекта",
"webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.",
"outgoingWebhookTitle": "Исходящий webhook (проект)",
"outgoingWebhookDesc": "Куда Tinyforge отправляет события деплоя для этого проекта. Стейджи могут переопределить; если нигде не задано — используется глобальная настройка.",
"outgoingFallbackGlobal": "глобальной настройки интеграций",
"notificationUrlLabel": "URL исходящего webhook",
"notificationUrlHelp": "Оставьте пустым для наследования из глобальных настроек. Стейджи могут переопределить.",
"stageNotificationUrlLabel": "URL исходящего webhook (этот стейдж)",
"stageNotificationUrlHelp": "Оставьте пустым для наследования от проекта, затем — из глобальных настроек.",
"stageOutgoingTitle": "Исходящий webhook (стейдж)",
"stageOutgoingDesc": "Куда Tinyforge отправляет события деплоя этого стейджа. Побеждает самый конкретный уровень.",
"stageFallbackLabel": "проектной или глобальной настройки",
"deleteProject": "Удалить проект",
"envVars": "Переменные окружения",
"volumes": "Тома",
"stages": "Стадии",
"noStages": "Для этого проекта не настроены стадии.",
"pattern": "Шаблон",
"autoDeploy": "авто-деплой",
"requiresConfirm": "нужно подтверждение",
"instances": "экземпляров",
"deployNewVersion": "Развернуть новую версию",
"selectTag": "Выберите тег для деплоя",
"loadingTags": "Загрузка тегов...",
"chooseTag": "Выберите тег...",
"enterTag": "Введите тег образа (напр., dev-abc123)",
"registryTag": "Реестр",
"localTag": "Локальный",
"alsoLocal": "Также доступен локально",
"searchTags": "Поиск тегов...",
"deployTag": "Тег",
"deploy": "Развернуть",
"deploying": "Развёртывание...",
"recentDeploys": "Последние деплои",
"noDeployHistory": "Нет истории деплоев для этого проекта.",
"tag": "Тег",
"status": "Статус",
"started": "Начат",
"finished": "Завершён",
"error": "Ошибка",
"noInstancesRunning": "Нет запущенных экземпляров",
"deleteConfirmTitle": "Удалить проект",
"deleteConfirmMessage": "Это безвозвратно удалит проект '{name}' и все его стадии, экземпляры и историю деплоев.",
"loadFailed": "Не удалось загрузить проект",
"deleteFailed": "Не удалось удалить проект",
"deployFailed": "Деплой не удался",
"nameLabel": "Название *",
"imageLabel": "Образ *",
"portLabel": "Порт",
"healthcheckLabel": "Путь проверки",
"saving": "Сохранение...",
"addStage": "Добавить стадию",
"tagPattern": "Шаблон тега",
"tagPatternHelp": "Glob-шаблон (напр., dev-*, v*)",
"maxInstances": "Макс. экземпляров",
"autoDeployLabel": "Авто-деплой",
"enableProxy": "Включить прокси",
"accessListId": "ID списка доступа NPM",
"accessListIdHelp": "Переопределить глобальный список доступа для этого проекта. Очистите, чтобы наследовать из настроек NPM.",
"localImages": "Локальные Docker-образы",
"imageTag": "Тег",
"imageId": "ID образа",
"imageSize": "Размер",
"imageCreated": "Создан",
"cpuLimit": "Лимит CPU (ядра)",
"cpuLimitHelp": "напр., 0.5, 1, 2. Оставьте 0 для без ограничений",
"memoryLimit": "Лимит памяти (МБ)",
"memoryLimitHelp": "напр., 256, 512, 1024. Оставьте 0 для без ограничений",
"npmProxy": "NPM прокси",
"creating": "Создание...",
"createStage": "Создать стадию",
"noProxy": "Без прокси",
"deleteStage": "Удалить стадию",
"deleteStageConfirm": "Удалить стадию \"{name}\"?",
"stageCreated": "Стадия \"{name}\" создана",
"stageUpdated": "Стадия обновлена",
"stageUpdateFailed": "Не удалось обновить стадию",
"stageDeleted": "Стадия \"{name}\" удалена",
"projectUpdated": "Проект обновлён",
"updateFailed": "Не удалось обновить проект",
"stageCreateFailed": "Не удалось создать стадию",
"stageDeleteFailed": "Не удалось удалить стадию"
},
"envEditor": {
"title": "Переменные окружения",
"description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.",
"stage": "Стадия",
"projectDefaults": "Значения проекта по умолчанию",
"noProjectEnv": "Переменные окружения на уровне проекта ещё не определены.",
"stageOverrides": "Переопределения стадии",
"key": "Ключ",
"value": "Значение",
"secret": "Секрет",
"source": "Источник",
"actions": "Действия",
"overridden": "переопределено",
"inherited": "наследуется",
"overridesProject": "переопределяет проект",
"stageOnly": "только стадия",
"edit": "Изменить",
"change": "Изменить",
"delete": "Удалить",
"save": "Сохранить",
"add": "Добавить",
"adding": "Добавление...",
"noStages": "Стадии не настроены. Сначала добавьте стадии к проекту.",
"loadFailed": "Не удалось загрузить проект",
"envAdded": "Переменная окружения добавлена",
"envUpdated": "Переменная окружения обновлена",
"envDeleted": "Переменная окружения удалена",
"addFailed": "Не удалось добавить переменную",
"updateFailed": "Не удалось обновить переменную",
"deleteFailed": "Не удалось удалить переменную",
"loadEnvFailed": "Не удалось загрузить переменные",
"leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее",
"deleteTitle": "Удалить переменную окружения",
"deleteMessage": "Вы уверены, что хотите удалить эту переменную окружения? Это действие нельзя отменить."
},
"volumeEditor": {
"title": "Тома",
"description": "Настройка монтирования томов для контейнеров. Выберите область видимости для управления общим доступом между развёртываниями.",
"sourceHost": "Источник (хост)",
"targetContainer": "Цель (контейнер)",
"scope": "Область",
"nameColumn": "Имя",
"namePlaceholder": "напр. shared-db",
"requiresName": "требуется имя",
"noHostPath": "нет пути на хосте",
"tmpfs": "tmpfs (в памяти)",
"actions": "Действия",
"edit": "Изменить",
"delete": "Удалить",
"save": "Сохранить",
"add": "Добавить",
"adding": "Добавление...",
"scopeGuide": "Области видимости томов",
"noVolumes": "Тома ещё не настроены. Добавьте один выше.",
"volumeAdded": "Том добавлен",
"volumeUpdated": "Том обновлён",
"volumeDeleted": "Том удалён",
"loadFailed": "Не удалось загрузить тома",
"addFailed": "Не удалось добавить том",
"updateFailed": "Не удалось обновить том",
"deleteFailed": "Не удалось удалить том"
},
"volumeBrowser": {
"title": "Обзор тома",
"loadFailed": "Не удалось загрузить каталог",
"empty": "Этот каталог пуст.",
"name": "Имя",
"size": "Размер",
"modified": "Изменён",
"downloadAll": "Скачать том как ZIP",
"downloadFolder": "Скачать папку как ZIP",
"upload": "Загрузить файлы",
"uploaded": "Загружено",
"files": "файл(ов)",
"uploadFailed": "Не удалось загрузить файлы",
"browse": "Обзор",
"download": "Скачать"
},
"quickDeploy": {
"title": "Быстрый деплой",
"description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.",
"step1": "1. Введите URL образа",
"imageUrl": "URL образа",
"imageUrlHelp": "Полный URL образа с тегом (напр., git.example.com/user/app:dev-abc123)",
"inspect": "Проверить",
"inspecting": "Проверка...",
"step2": "2. Проверка конфигурации",
"reviewDesc": "Эти параметры были обнаружены из образа. Измените при необходимости перед деплоем.",
"projectName": "Имя проекта",
"port": "Порт",
"portHelp": "Порт контейнера (1-65535)",
"healthCheckPath": "Путь проверки здоровья",
"healthCheckHelp": "Необязательный HTTP-путь для проверки работоспособности",
"stage": "Стадия",
"development": "Разработка",
"release": "Релиз",
"production": "Продакшн",
"stageHelp": "Стадия развёртывания для этого образа",
"subdomainOverride": "Переопределение поддомена",
"subdomainHelp": "Оставьте пустым для использования шаблона по умолчанию",
"envVars": "Переменные окружения",
"envVarsHelp": "По одной на строку, формат KEY=VALUE",
"step3": "3. Развёртывание",
"deployDesc": "Будет создан новый проект и контейнер будет развёрнут немедленно.",
"deployBtn": "Развернуть",
"inspectedSuccess": "Образ успешно проверен",
"deployedSuccess": "{name} успешно развёрнут!",
"inspectFailed": "Не удалось проверить образ",
"deployFailed": "Развёртывание не удалось",
"browseImages": "Обзор",
"selectImage": "Выберите образ из реестра",
"noImages": "Образы не найдены",
"loadingImages": "Загрузка...",
"imageLoadFailed": "Не удалось загрузить образы",
"autoDeployLabel": "Развернуть сразу",
"lowercaseHint": "Строчные буквы и дефисы",
"imageAlreadyExists": "Образ уже развёрнут",
"conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.",
"openProject": "Открыть проект \u2192",
"createNewAnyway": "Создать новый проект"
"local": "Локальный"
},
"settings": {
"title": "Настройки",
@@ -613,117 +371,28 @@
"networkError": "Ошибка сети"
},
"proxies": {
"title": "Менеджер прокси",
"create": "Создать прокси",
"standalone": "Автономные прокси",
"managed": "Управляемые прокси",
"noProxies": "Прокси не найдены",
"noProxiesDesc": "Создайте автономный прокси или разверните проект с включённым прокси.",
"filter": {
"search": "Поиск по домену или назначению...",
"health": "Здоровье",
"type": "Тип",
"all": "Все",
"clear": "Сбросить фильтры"
},
"health": {
"healthy": "Здоров",
"unhealthy": "Нездоров",
"unknown": "Неизвестно"
},
"lastChecked": "Последняя проверка"
},
"sites": {
"webhookTitle": "Webhook сайта",
"webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.",
"outgoingUrlTitle": "URL исходящего webhook (этот сайт)",
"outgoingUrlDesc": "Куда Tinyforge отправляет события site_sync_success / site_sync_failure. Пусто — наследовать из глобальных настроек.",
"outgoingWebhookTitle": "Исходящий webhook (сайт)",
"outgoingWebhookDesc": "HMAC-секрет и тестовая отправка для разрешённого исходящего URL.",
"outgoingFallbackGlobal": "глобальной настройки интеграций",
"title": "Статические сайты",
"addSite": "Новый сайт",
"newSite": "Новый статический сайт",
"createSite": "Создать сайт",
"noSites": "Нет статических сайтов",
"noSitesDesc": "Разверните статический контент из папки Git-репозитория.",
"searchPlaceholder": "Поиск по имени, домену или репозиторию...",
"noMatching": "Нет сайтов, соответствующих поиску.",
"name": "Имя",
"title": "Прокси-маршруты",
"description": "Активные прокси-маршруты от контейнеров и статических сайтов.",
"domain": "Домен",
"mode": "Режим",
"project": "Проект / Сайт",
"stage": "Этап / Режим",
"tag": "Тег",
"port": "Порт",
"status": "Статус",
"lastSync": "Последняя синхр.",
"deploy": "Развернуть",
"stop": "Остановить",
"start": "Запустить",
"openSite": "Открыть сайт",
"confirmDelete": "Удалить сайт",
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
"confirmDeleteSecret": "Удалить секрет",
"confirmDeleteSecretMsg": "Вы уверены, что хотите удалить секрет",
"siteInfo": "Информация о сайте",
"folder": "Папка",
"syncTrigger": "Триггер синхр.",
"commitSha": "Коммит SHA",
"secrets": "Секреты",
"addSecret": "Добавить секрет",
"noSecrets": "Секреты не настроены. Добавьте их, если сайту нужны серверные API-ключи.",
"secretKey": "Ключ",
"secretValue": "Значение",
"encryptSecret": "Шифровать значение",
"saveSecret": "Добавить секрет",
"step1Title": "1. Репозиторий",
"step2Title": "2. Выбор ветки",
"step3Title": "3. Выбор папки",
"step4Title": "4. Настройки",
"step5Title": "5. Проверка и создание",
"fullRepoUrl": "URL репозитория",
"fullRepoUrlHelp": "Вставьте полный URL для автозаполнения полей ниже (напр., https://git.example.com/owner/repo)",
"serverUrl": "URL сервера",
"repoUrl": "URL Git-сервера",
"repoUrlHelp": "Вставьте полный URL репозитория или базовый URL сервера (Gitea, Forgejo, Gogs)",
"repoOwner": "Владелец",
"repoName": "Репозиторий",
"accessToken": "Токен доступа",
"accessTokenPlaceholder": "Необязательно — для приватных репозиториев",
"accessTokenHelp": "Персональный токен с правами на чтение репозитория. Оставьте пустым для публичных.",
"noToken": "Нет (публичный репо)",
"testConnection": "Проверить соединение",
"connectionSuccess": "Репозиторий доступен",
"loadingBranches": "Загрузка веток...",
"selectBranch": "Выберите ветку",
"chooseBranch": "Выберите ветку...",
"branch": "Ветка",
"loadingTree": "Загрузка дерева репозитория...",
"selectFolder": "Выберите папку с файлами сайта",
"selectedFolder": "Выбранная папка",
"siteName": "Имя сайта",
"domainHelp": "Публичный домен сайта. Прокси будет настроен автоматически.",
"modeStaticDesc": "HTML, CSS, JS, изображения через Nginx",
"modeDenoDesc": "Статические файлы + серверный API из папки api/",
"triggerManual": "Вручную",
"triggerPush": "При пуше",
"triggerTag": "По тегу",
"tagPattern": "Паттерн тега",
"tagPatternHelp": "Glob-паттерн для тегов (напр., v*, pages-*)",
"renderMarkdown": "Рендерить Markdown-файлы в HTML",
"provider": "Git-провайдер",
"detectedProvider": "Автоопределён",
"browseRepos": "Обзор репозиториев",
"selectRepo": "Выберите репозиторий",
"storage": "Хранилище данных",
"enableStorage": "Включить хранилище данных",
"storageHelp": "Подключает Docker-том в /app/data, чтобы Deno-бэкенд мог читать и записывать файлы, сохраняющиеся между деплоями.",
"storageLimitMB": "Лимит хранилища (МБ)",
"storageLimitHelp": "Максимальный размер хранилища в мегабайтах. 0 = без ограничений.",
"storageVolume": "Том",
"dataPath": "Путь к данным",
"storageMountPath": "Путь монтирования",
"storageLimit": "Лимит",
"storageUsed": "Использовано",
"storageOfLimit": "от лимита использовано",
"unlimited": "Без ограничений"
"source": "Источник",
"sourceContainer": "Контейнер",
"sourceStatic": "Статический сайт",
"sourceDeno": "Deno-сайт",
"filterAll": "Все",
"filterContainers": "Контейнеры",
"filterSites": "Сайты",
"noRoutes": "Нет прокси-маршрутов",
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с прокси или публикации статического сайта.",
"searchPlaceholder": "Поиск по домену, проекту или тегу...",
"noMatch": "Нет маршрутов, соответствующих поиску.",
"loadFailed": "Не удалось загрузить прокси-маршруты",
"route": "маршрут",
"routes": "маршрутов"
},
"common": {
"cancel": "Отмена",
@@ -775,24 +444,9 @@
"lastSeen": "Замечен"
}
},
"instance": {
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
"restartConfirm": "Контейнер будет перезапущен с кратковременным простоем.",
"removeConfirm": "Контейнер и его прокси-конфигурация будут безвозвратно удалены.",
"actionFailed": "Действие не удалось"
},
"empty": {
"noProjects": "Проектов пока нет",
"noProjectsDesc": "Начните с создания первого проекта или используйте быстрый деплой.",
"createProject": "Создать проект",
"noInstances": "Нет экземпляров",
"noInstancesDesc": "Разверните новую версию, чтобы увидеть экземпляры здесь.",
"noDeploys": "Нет истории деплоев",
"noDeploysDesc": "История деплоев появится здесь после первого развёртывания.",
"noRegistries": "Нет реестров",
"noRegistriesDesc": "Добавьте реестр контейнеров для обнаружения образов.",
"noVolumes": "Нет томов",
"noVolumesDesc": "Настройте монтирование томов для постоянных данных.",
"noUsers": "Нет пользователей",
"noUsersDesc": "Добавьте локальных пользователей для управления доступом."
},
@@ -808,15 +462,6 @@
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
"requiredForNew": "Поле {field} обязательно для новых реестров"
},
"confirm": {
"stopInstance": "Остановить экземпляр",
"startInstance": "Запустить экземпляр",
"restartInstance": "Перезапустить экземпляр",
"removeInstance": "Удалить экземпляр",
"stopAction": "Остановить",
"restartAction": "Перезапустить",
"removeAction": "Удалить"
},
"theme": {
"light": "Светлая",
"dark": "Тёмная",
@@ -842,75 +487,6 @@
"cleanupFailed": "Не удалось очистить",
"loadFailed": "Не удалось загрузить устаревшие контейнеры"
},
"proxies": {
"title": "Прокси",
"create": "Создать прокси",
"noProxies": "Прокси ещё не настроены.",
"noProxiesDesc": "Создайте автономный прокси или разверните проект, чтобы увидеть прокси здесь.",
"standalone": "Автономные прокси",
"managed": "Управляемые",
"lastChecked": "Последняя проверка",
"health": {
"healthy": "Работает",
"unhealthy": "Недоступен",
"unknown": "Неизвестно"
},
"filter": {
"search": "Поиск прокси...",
"health": "Здоровье",
"type": "Тип",
"all": "Все",
"clear": "Сбросить фильтры"
},
"form": {
"title": "Создать прокси",
"editTitle": "Редактировать прокси",
"destination": "URL / IP назначения",
"port": "Порт",
"domain": "Домен",
"domainHelp": "Публичный домен для этого прокси.",
"validate": "Проверить",
"validating": "Проверка...",
"create": "Создать прокси",
"save": "Сохранить изменения",
"cancel": "Отмена",
"delete": "Удалить",
"deleteConfirm": "Удалить этот прокси? Это действие необратимо."
},
"validation": {
"title": "Проверка назначения",
"syntax": "Синтаксис URL",
"dns": "DNS разрешение",
"tcp": "TCP подключение",
"http": "HTTP ответ",
"checking": "Проверка...",
"skipped": "Пропущено"
}
},
"proxies": {
"title": "Прокси-маршруты",
"description": "Активные прокси-маршруты от контейнеров и статических сайтов.",
"domain": "Домен",
"project": "Проект / Сайт",
"stage": "Этап / Режим",
"tag": "Тег",
"port": "Порт",
"status": "Статус",
"source": "Источник",
"sourceContainer": "Контейнер",
"sourceStatic": "Статический сайт",
"sourceDeno": "Deno-сайт",
"filterAll": "Все",
"filterContainers": "Контейнеры",
"filterSites": "Сайты",
"noRoutes": "Нет прокси-маршрутов",
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с прокси или публикации статического сайта.",
"searchPlaceholder": "Поиск по домену, проекту или тегу...",
"noMatch": "Нет маршрутов, соответствующих поиску.",
"loadFailed": "Не удалось загрузить прокси-маршруты",
"route": "маршрут",
"routes": "маршрутов"
},
"logs": {
"title": "Логи контейнера",
"lines": "строк",
@@ -1049,128 +625,6 @@
"en": "Английский",
"ru": "Русский"
},
"stacks": {
"eyebrow": "КУЗНИЦА",
"title": "Стеки",
"lede": "Compose-чертежи, выкованные как <em>атомарные единицы</em>. Запускайте сервисы, меняйте ревизии и откатывайтесь без нервов.",
"newStack": "Новый стек",
"refresh": "Обновить",
"total": "Всего",
"running": "Работают",
"deploying": "Куются",
"failed": "Сбой",
"stopped": "Холодные",
"empty": {
"title": "Наковальня остыла.",
"desc": "Загрузите docker-compose.yml, чтобы выковать первый стек."
},
"card": {
"noDescription": "Без описания",
"updated": "Обновлён",
"start": "Запустить",
"stop": "Остановить",
"delete": "Удалить",
"open": "Открыть"
},
"new": {
"eyebrow": "НОВЫЙ ЧЕРТЁЖ",
"title": "Выковать новый стек.",
"lede": "Загрузите или вставьте <code>docker-compose.yml</code>. Все сервисы чертежа разворачиваются как одна атомарная единица.",
"back": "Стеки",
"name": "Имя",
"namePlaceholder": "мой-стек",
"nameHint": "Строчные буквы, через дефис. Используется как имя compose-проекта.",
"description": "Описание",
"descriptionPlaceholder": "Что делает этот стек?",
"composeYaml": "Compose YAML",
"required": "обязательно",
"optional": "необязательно",
"loadSample": "Загрузить пример",
"uploadFile": "Загрузить файл",
"dropHere": "Перетащите сюда docker-compose.yml",
"dropSub": "или нажмите для выбора · или используйте <strong>Загрузить пример</strong> выше",
"lines": "{n} строк",
"bytes": "{n} байт",
"clear": "Очистить",
"deployImmediate": "Развернуть сразу",
"deployHint": "Куй железо, пока горячо. Без галочки стек сохраняется холодным.",
"cancel": "Отмена",
"forging": "Куём…",
"forgeAndDeploy": "Выковать и развернуть",
"saveBlueprint": "Сохранить чертёж",
"errorRequired": "Имя и compose YAML обязательны.",
"errorCreate": "Не удалось создать стек"
},
"detail": {
"manifest": "МАНИФЕСТ",
"loading": "Загрузка чертежа…",
"composeProject": "COMPOSE-ПРОЕКТ",
"noDescription": "Без описания",
"refresh": "Обновить",
"start": "Запустить",
"stop": "Остановить",
"delete": "Удалить",
"fault": "СБОЙ",
"err": "ОШБ",
"stats": {
"services": "Сервисы",
"servicesSub": "в чертеже",
"running": "Работают",
"runningSub": "активных контейнеров",
"revisions": "Ревизии",
"revisionsSub": "в истории",
"current": "Текущая",
"currentSub": "развёрнута"
},
"services": {
"title": "Сервисы",
"count": "{n} в работе",
"empty": "— нет запущенных контейнеров —"
},
"tabs": {
"blueprint": "Чертёж",
"revisions": "Ревизии",
"logs": "Логи"
},
"yaml": {
"currentRevision": "Текущая ревизия",
"edit": "Править и развернуть",
"cancel": "Отмена",
"forging": "Куём…",
"deployNew": "Развернуть новую ревизию"
},
"revisions": {
"current": "ТЕКУЩАЯ",
"by": "автор",
"rollback": "← Откатиться к этой ревизии",
"rollbackTitle": "Откатить ревизию?",
"rollbackMessage": "Создать новую ревизию из rev {n} и развернуть стек заново.",
"rollbackConfirm": "Откатить"
},
"logs": {
"service": "Сервис:",
"allServices": "Все сервисы",
"fetching": "Загрузка…",
"fetch": "Получить логи",
"empty": "— логи не загружены. нажмите получить. —"
},
"delete": {
"title": "Удалить стек?",
"messageBase": "Будет выполнен 'docker compose down' и удалён \"{name}\".",
"messageVolumes": " Именованные тома также будут удалены.",
"confirm": "Удалить"
},
"errors": {
"load": "Не удалось загрузить стек",
"stop": "Остановка не удалась",
"start": "Запуск не удался",
"update": "Обновление не удалось",
"rollback": "Откат не удался",
"delete": "Удаление не удалось",
"fetchLogs": "Не удалось загрузить логи"
}
}
},
"timezone": {
"eyebrow": "The Forge // Хронограф",
"title": "Часовой пояс отображения",
+3 -26
View File
@@ -156,32 +156,9 @@ export function connectSSE(url: string, options: SSEOptions): SSEConnection {
}
// ── Convenience Factories ──────────────────────────────────────────
/**
* Connect to deploy log SSE stream for a specific deploy.
* Streams existing logs first, then real-time updates.
*/
export function connectDeployLogs(
deployId: string,
callbacks: {
onLog: (log: DeployLogPayload) => void;
onStatus?: (status: DeployStatusPayload) => void;
onOpen?: () => void;
onError?: (attempt: number) => void;
}
): SSEConnection {
return connectSSE(`/api/deploys/${deployId}/logs`, {
onEvent(event) {
if (event.type === 'deploy_log') {
callbacks.onLog(event.payload as DeployLogPayload);
} else if (event.type === 'deploy_status') {
callbacks.onStatus?.(event.payload as DeployStatusPayload);
}
},
onOpen: callbacks.onOpen,
onError: callbacks.onError
});
}
// connectDeployLogs (legacy /api/deploys/{id}/logs SSE) was retired
// with the hard cutover; deploy progress now surfaces through the
// global /api/events stream below.
/**
* Connect to the global events SSE stream.
+26 -19
View File
@@ -6,6 +6,11 @@
* so the UI can dim the badge if desired. The poller is intentionally
* forgiving: if the user is unauthenticated or the backend isn't ready,
* it silently retries on the next tick.
*
* Workload-first: legacy project / site / stack counts were retired with
* the hard cutover. `workloads` is now the single "things you have"
* counter, supplementing `apps` (the grouping primitive) and
* `containers` (the running instances).
*/
import { writable, type Readable } from 'svelte/store';
@@ -13,9 +18,8 @@ import * as api from '$lib/api';
import { isAuthenticated } from '$lib/auth';
export interface NavCounts {
projects: number | null;
sites: number | null;
stacks: number | null;
apps: number | null;
workloads: number | null;
proxies: number | null;
containers: number | null;
/** Error-severity events only; dashboard surfaces total separately. */
@@ -23,9 +27,8 @@ export interface NavCounts {
}
const EMPTY: NavCounts = {
projects: null,
sites: null,
stacks: null,
apps: null,
workloads: null,
proxies: null,
containers: null,
eventsErrors: null
@@ -36,31 +39,31 @@ const store = writable<NavCounts>(EMPTY);
export const navCounts: Readable<NavCounts> = { subscribe: store.subscribe };
let pollTimer: ReturnType<typeof setInterval> | null = null;
let inFlight = false;
let inFlight: AbortController | null = null;
async function refreshOnce(): Promise<void> {
if (inFlight || !isAuthenticated()) return;
inFlight = true;
const ac = new AbortController();
inFlight = ac;
try {
const [projects, sites, stacks, proxies, containers, eventStats] = await Promise.allSettled([
api.listProjects(),
api.listStaticSites(),
api.listStacks(),
api.listProxyRoutes(),
api.listContainers({}),
api.fetchEventLogStats()
const [apps, workloads, proxies, containers, eventStats] = await Promise.allSettled([
api.listApps(ac.signal),
api.listWorkloads(undefined, ac.signal),
api.listProxyRoutes(ac.signal),
api.listContainers({}, ac.signal),
api.fetchEventLogStats(ac.signal)
]);
if (ac.signal.aborted) return;
store.update((prev) => ({
projects: projects.status === 'fulfilled' ? projects.value.length : prev.projects,
sites: sites.status === 'fulfilled' ? sites.value.length : prev.sites,
stacks: stacks.status === 'fulfilled' ? stacks.value.length : prev.stacks,
apps: apps.status === 'fulfilled' ? apps.value.length : prev.apps,
workloads: workloads.status === 'fulfilled' ? workloads.value.length : prev.workloads,
proxies: proxies.status === 'fulfilled' ? proxies.value.length : prev.proxies,
containers: containers.status === 'fulfilled' ? containers.value.length : prev.containers,
eventsErrors: eventStats.status === 'fulfilled' ? eventStats.value.error : prev.eventsErrors
}));
} finally {
inFlight = false;
if (inFlight === ac) inFlight = null;
}
}
@@ -79,6 +82,10 @@ export function stopNavCountsPolling(): void {
clearInterval(pollTimer);
pollTimer = null;
}
if (inFlight) {
inFlight.abort();
inFlight = null;
}
}
/** Trigger an out-of-band refresh (e.g. after a mutation). */
+21 -216
View File
@@ -1,83 +1,13 @@
// Types matching the Go backend store models (internal/store/models.go).
export interface Project {
id: string;
name: string;
registry: string;
image: string;
port: number;
healthcheck: string;
env: string;
volumes: string;
npm_access_list_id: number;
notification_url: string;
created_at: string;
updated_at: string;
}
export interface Stage {
id: string;
project_id: string;
name: string;
tag_pattern: string;
auto_deploy: boolean;
max_instances: number;
confirm: boolean;
enable_proxy: boolean;
promote_from: string;
subdomain: string;
notification_url: string;
cpu_limit: number;
memory_limit: number;
created_at: string;
updated_at: string;
}
/**
* Instance is a back-compat alias: project deploys used to live in a
* dedicated `instances` table, but after the workload refactor the canonical
* row is a Container. New code should use Container directly. The fields the
* deployer always populates for project containers (workload_id, role,
* stage_id, container_id, etc.) are required on Container; the alias is a
* straight rename, not a relaxation of the type contract.
*/
export type Instance = Container;
/**
* @deprecated Use {@link ContainerState} for new code. Kept around for older
* components that still narrow on the legacy four-state union.
*/
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'removing';
export interface Deploy {
id: string;
project_id: string;
stage_id: string;
instance_id: string;
image_tag: string;
status: DeployStatus;
started_at: string;
finished_at: string;
error: string;
}
export type DeployStatus =
| 'pending'
| 'pulling'
| 'starting'
| 'configuring_proxy'
| 'health_checking'
| 'success'
| 'failed'
| 'rolled_back';
export interface DeployLog {
id: number;
deploy_id: string;
message: string;
level: 'info' | 'warn' | 'error';
created_at: string;
}
//
// Workload-first refactor: the canonical primitive is a Workload (with
// a Container row per instance). Legacy Project / Stage / Stack /
// StaticSite / Deploy / Instance / Volume types were retired with the
// hard cutover — the WebUI talks to /api/workloads, /api/containers,
// /api/triggers, and friends only. The few "envelope" types still
// useful across the surviving endpoints (ApiEnvelope, BrowseResult,
// FileEntry, registries, settings, DNS, backups, NPM, system stats,
// container stats, etc.) live below.
export interface Registry {
id: string;
@@ -181,12 +111,6 @@ export interface ApiEnvelope<T> {
error?: string;
}
/** Response shape for GET /api/projects/:id */
export interface ProjectDetail {
project: Project;
stages: Stage[];
}
/** Response shape for POST /api/deploy/inspect */
export interface InspectResult {
image: string;
@@ -194,17 +118,6 @@ export interface InspectResult {
healthcheck: string;
}
/** Stage environment variable override. */
export interface StageEnv {
id: string;
stage_id: string;
key: string;
value: string;
encrypted: boolean;
created_at: string;
updated_at: string;
}
/** Item for the EntityPicker command-palette component. */
export interface EntityPickerItem {
value: string;
@@ -217,20 +130,14 @@ export interface EntityPickerItem {
}
/** Volume scope determines the sharing level. */
export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral' | 'absolute';
/** Volume mount configuration for a project. */
export interface Volume {
id: string;
project_id: string;
source: string;
target: string;
mode?: string;
scope: VolumeScope;
name: string;
created_at: string;
updated_at: string;
}
export type VolumeScope =
| 'instance'
| 'stage'
| 'project'
| 'project_named'
| 'named'
| 'ephemeral'
| 'absolute';
/** Scope metadata returned by GET /api/volumes/scopes. */
export interface VolumeScopeInfo {
@@ -353,9 +260,7 @@ export interface EventLogStats {
*
* Shape matches the Go side after the workload refactor:
* the embedded Container row is the canonical state and the workload/role
* fields decorate it for display. The legacy `instance` / `project_name` /
* `stage_name` aliases are exposed as optional getters via the StaleContainerCard
* adapter so we don't have to update every consumer at once.
* fields decorate it for display.
*/
export interface StaleContainer {
container: Container;
@@ -365,106 +270,6 @@ export interface StaleContainer {
days_stale: number;
}
/** A static site deployed from a Git repository folder. */
export interface StaticSite {
id: string;
name: string;
provider: GitProvider;
gitea_url: string;
repo_owner: string;
repo_name: string;
branch: string;
folder_path: string;
access_token: string;
domain: string;
mode: 'static' | 'deno';
render_markdown: boolean;
sync_trigger: 'push' | 'tag' | 'manual';
tag_pattern: string;
container_id: string;
proxy_route_id: string;
status: StaticSiteStatus;
last_sync_at: string;
last_commit_sha: string;
error: string;
storage_enabled: boolean;
storage_limit_mb: number;
notification_url: string;
created_at: string;
updated_at: string;
}
export interface StaticSiteStorageUsage {
enabled: boolean;
used_bytes: number;
limit_mb: number;
}
export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped';
export type StackStatus = 'stopped' | 'deploying' | 'running' | 'failed';
export interface Stack {
id: string;
name: string;
description: string;
compose_project_name: string;
status: StackStatus;
error: string;
current_revision_id: string;
created_at: string;
updated_at: string;
}
export interface StackRevision {
id: string;
stack_id: string;
revision: number;
yaml: string;
author: string;
deploy_id: string;
status: string;
created_at: string;
}
export interface StackService {
Name: string;
Service: string;
State: string;
Status: string;
Health: string;
ExitCode: number;
}
export type GitProvider = '' | 'gitea' | 'github' | 'gitlab';
/** An encrypted environment variable for a static site's Deno backend. */
export interface StaticSiteSecret {
id: string;
site_id: string;
key: string;
value: string;
encrypted: boolean;
created_at: string;
updated_at: string;
}
/** A repository from the Git provider's API. */
export interface RepoInfo {
owner: string;
name: string;
full_name: string;
description: string;
private: boolean;
html_url: string;
}
/** A folder entry from the Gitea repo tree. */
export interface FolderEntry {
path: string;
is_dir: boolean;
}
/** Container CPU, memory, network, and block I/O stats from the Docker stats API. */
export interface ContainerStats {
timestamp?: string;
@@ -539,8 +344,9 @@ export interface SystemStatsSample {
export type WorkloadKind = 'project' | 'stack' | 'site' | 'plugin' | (string & {});
/**
* Workload is the unifying primitive over Project / Stack / StaticSite,
* plus plugin-native rows whose source/trigger kinds are populated.
* Workload is the unifying primitive after the legacy Project / Stack /
* StaticSite types were retired. Plugin-native rows whose source/trigger
* kinds are populated are the only rows the WebUI creates now.
*/
export interface Workload {
id: string;
@@ -647,4 +453,3 @@ export interface App {
created_at: string;
updated_at: string;
}
+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>