feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s
Build / build (push) Successful in 10m39s
The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.
Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
internal/stack/manager.go gone (the rest of those packages stay as
helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
table (projects, stages, stage_env, volumes, deploys, deploy_logs,
poll_states, stacks, stack_revisions, stack_deploys, static_sites,
static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
so api + store paths share one secret-generation impl (no
panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
+ static-site label paths; only canonical tinyforge.workload.id
dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
private (no external callers)
Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
helper + types (Project, Stage, Stack, StaticSite, Deploy,
Instance, Volume, etc.); kept Workload, Container, App, Settings,
Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
/deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
listWorkloads + listContainers only; 4-card stat grid
(workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
proxies/+page.svelte, containers/+page.svelte all rewired to the
workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
instance.*, confirm.* namespaces; en/ru parity preserved (1042
keys each)
Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):
- Sec H1: dead-end workload webhook URL handlers (would mint URLs
that 404 the new trigger-only ingress) deleted across backend +
frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
field names, workloadIDRow rationale, webhook_deliveries.target_type
enum, WebhookDeliveryLog component header
Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
items are now shipped. Next focus is Priority 3 polish (apps.* i18n
+ codemap entries) and Priority 4 tests.
Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
/api/webhook/sites/{secret} return 404; CI configs must repoint to
/api/webhook/triggers/{secret} (the trigger-split boot backfill
lifted any embedded workload secret onto a Trigger row, so the
secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
links replaced with /apps and /triggers.
This commit is contained in:
+75
-552
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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; }}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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: 0–24h."
|
||||
},
|
||||
"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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox, IconContainer } from '$lib/components/icons';
|
||||
import { IconDashboard, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconBox, IconContainer } from '$lib/components/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
const { children }: Props = $props();
|
||||
|
||||
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'containers' | 'eventsErrors';
|
||||
type NavCountKey = 'apps' | 'workloads' | 'proxies' | 'containers' | 'eventsErrors';
|
||||
|
||||
const navItems: ReadonlyArray<{
|
||||
href: string;
|
||||
@@ -36,12 +36,8 @@
|
||||
labelOverride?: string;
|
||||
}> = [
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ href: '/apps', labelKey: 'nav.apps', icon: 'box' },
|
||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
|
||||
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
|
||||
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
|
||||
{ href: '/apps', labelKey: 'nav.apps', icon: 'box', countKey: 'apps' },
|
||||
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
|
||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
||||
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
|
||||
@@ -76,7 +72,7 @@
|
||||
const clockTitle = $derived(`${$effectiveTimezone.replace(/_/g, ' ')} · ${clockOffset}`);
|
||||
|
||||
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
|
||||
// g+d → dashboard, g+p → projects, g+s → sites, g+k → stacks, g+x → deploy,
|
||||
// g+d → dashboard, g+a → apps, g+n → containers, g+t → triggers,
|
||||
// g+r → proxies, g+e → events, g+c → settings
|
||||
let gPressedAt = 0;
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -91,8 +87,8 @@
|
||||
}
|
||||
if (Date.now() - gPressedAt > 1200) return;
|
||||
const map: Record<string, string> = {
|
||||
d: '/', p: '/projects', s: '/sites', k: '/stacks',
|
||||
x: '/deploy', r: '/proxies', e: '/events', c: '/settings'
|
||||
d: '/', a: '/apps', n: '/containers', t: '/triggers',
|
||||
r: '/proxies', e: '/events', c: '/settings'
|
||||
};
|
||||
const dest = map[e.key.toLowerCase()];
|
||||
if (dest) {
|
||||
@@ -282,14 +278,8 @@
|
||||
>
|
||||
{#if item.icon === 'dashboard'}
|
||||
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'projects'}
|
||||
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'box'}
|
||||
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'globe'}
|
||||
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'stacks'}
|
||||
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'containers'}
|
||||
<IconContainer size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'deploy'}
|
||||
@@ -349,7 +339,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
||||
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>p</kbd><kbd>s</kbd><kbd>k</kbd>
|
||||
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>a</kbd><kbd>n</kbd><kbd>t</kbd>
|
||||
<span class="hint-label">quick-nav</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+92
-161
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Project, Instance, StaleContainer, StaticSite } from '$lib/types';
|
||||
// Workload-first dashboard. Replaces the legacy project / site
|
||||
// summaries with a single workload count grouped by source_kind and
|
||||
// a running-container tally pulled from /api/containers.
|
||||
//
|
||||
// We no longer fan out N+1 fetches per project to gather instance
|
||||
// status — the global containers index already carries the workload
|
||||
// reference and state.
|
||||
import type { ContainerView, StaleContainer, Workload } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||
@@ -9,17 +15,17 @@
|
||||
import SystemResourcesCard from '$lib/components/SystemResourcesCard.svelte';
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import { IconBox, IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
||||
let workloads = $state<Workload[]>([]);
|
||||
let containers = $state<ContainerView[]>([]);
|
||||
let staleContainers = $state<StaleContainer[]>([]);
|
||||
let unusedImagesMB = $state(0);
|
||||
let unusedImagesCount = $state(0);
|
||||
let unusedImagesExceeded = $state(false);
|
||||
let sites = $state<StaticSite[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let loadController: AbortController | null = null;
|
||||
@@ -33,35 +39,17 @@
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.listProjects(signal);
|
||||
|
||||
// Fetch project details sequentially to avoid exhausting
|
||||
// browser connection pool (HTTP/1.1 allows only 6 per host).
|
||||
const results: { projectId: string; instances: Instance[] }[] = [];
|
||||
for (const p of projects) {
|
||||
try {
|
||||
const detail = await api.getProject(p.id, signal);
|
||||
const stages = detail.stages ?? [];
|
||||
const stageInstances: Instance[][] = [];
|
||||
for (const s of stages) {
|
||||
stageInstances.push(await api.listInstances(p.id, s.id, signal));
|
||||
}
|
||||
results.push({ projectId: p.id, instances: stageInstances.flat() });
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
||||
results.push({ projectId: p.id, instances: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const mapped: Record<string, Instance[]> = {};
|
||||
for (const r of results) {
|
||||
mapped[r.projectId] = r.instances;
|
||||
}
|
||||
instancesByProject = mapped;
|
||||
|
||||
staleContainers = await api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[]);
|
||||
|
||||
sites = await api.listStaticSites(signal).catch(() => [] as StaticSite[]);
|
||||
// Parallelize the cheap top-level reads. Each falls back to an
|
||||
// empty list so a single slow daemon (e.g. Docker stats) does
|
||||
// not blank the entire dashboard.
|
||||
const [wls, ctrs, stale] = await Promise.all([
|
||||
api.listWorkloads(undefined, signal),
|
||||
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
|
||||
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
|
||||
]);
|
||||
workloads = wls;
|
||||
containers = ctrs;
|
||||
staleContainers = stale;
|
||||
|
||||
try {
|
||||
const imgStats = await api.getUnusedImageStats(signal);
|
||||
@@ -82,35 +70,32 @@
|
||||
return () => { loadController?.abort(); };
|
||||
});
|
||||
|
||||
const totalProjects = $derived(projects.length);
|
||||
const totalRunning = $derived(
|
||||
Object.values(instancesByProject)
|
||||
.flat()
|
||||
.filter((i) => i.state === 'running').length
|
||||
);
|
||||
const totalFailed = $derived(
|
||||
Object.values(instancesByProject)
|
||||
.flat()
|
||||
.filter((i) => i.state === 'failed').length
|
||||
);
|
||||
const totalWorkloads = $derived(workloads.length);
|
||||
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
|
||||
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
|
||||
const totalStale = $derived(staleContainers.length);
|
||||
const totalSites = $derived(sites.length);
|
||||
const deployedSites = $derived(sites.filter((s) => s.status === 'deployed').length);
|
||||
const failedSitesCount = $derived(sites.filter((s) => s.status === 'failed').length);
|
||||
|
||||
function siteStatusBadge(status: string): { text: string; cls: string } {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
return { text: 'Deployed', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing':
|
||||
return { text: 'Syncing', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
case 'stopped':
|
||||
return { text: 'Stopped', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
default:
|
||||
return { text: 'Idle', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
|
||||
// recent-activity strip without paging the entire list.
|
||||
const recentWorkloads = $derived(
|
||||
[...workloads]
|
||||
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
|
||||
.slice(0, 6)
|
||||
);
|
||||
|
||||
function containerStateFor(workloadID: string): string {
|
||||
// Pick the most informative state across this workload's
|
||||
// containers: failed > running > stopped > anything else.
|
||||
const states = containers.filter((c) => c.workload_id === workloadID).map((c) => c.state);
|
||||
if (states.length === 0) return 'idle';
|
||||
if (states.includes('failed')) return 'failed';
|
||||
if (states.includes('running')) return 'running';
|
||||
if (states.includes('stopped')) return 'stopped';
|
||||
return states[0];
|
||||
}
|
||||
|
||||
function containerCountFor(workloadID: string): number {
|
||||
return containers.filter((c) => c.workload_id === workloadID).length;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,9 +106,9 @@
|
||||
<div class="space-y-6 dashboard">
|
||||
<!-- Hero -->
|
||||
{#snippet heroToolbar()}
|
||||
<a href="/deploy" class="forge-btn">
|
||||
<IconDeploy size={14} />
|
||||
<span>{$t('dashboard.quickDeploy')}</span>
|
||||
<a href="/apps/new" class="forge-btn">
|
||||
<IconBox size={14} />
|
||||
<span>{$t('dashboard.newApp')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
@@ -137,35 +122,26 @@
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="forge-stat-grid">
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalProjects')}</span>
|
||||
<span class="forge-stat-value">{String(totalProjects).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">active</span>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningInstances')}</span>
|
||||
<a href="/apps" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
|
||||
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">workloads →</span>
|
||||
</a>
|
||||
<a href="/containers" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
|
||||
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">instances</span>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedInstances')}</span>
|
||||
<span class="forge-stat-sub">running</span>
|
||||
</a>
|
||||
<a href="/containers?state=failed" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
|
||||
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">need attention</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/containers/stale" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
||||
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">stale →</span>
|
||||
</a>
|
||||
<a href="/sites" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalSites')}</span>
|
||||
<span class="forge-stat-value">{String(totalSites).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">
|
||||
{#if deployedSites > 0}<span class="tag ok">{deployedSites} up</span>{/if}
|
||||
{#if failedSitesCount > 0}<span class="tag bad">{failedSitesCount} fail</span>{/if}
|
||||
{#if deployedSites === 0 && failedSitesCount === 0}static sites →{/if}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Unused images warning -->
|
||||
@@ -201,59 +177,11 @@
|
||||
<SystemResourcesCard />
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Static sites summary -->
|
||||
{#if !loading}
|
||||
{#snippet sitesActions()}
|
||||
{#if sites.length > 0}
|
||||
<a href="/sites" class="text-xs font-medium text-[var(--color-brand-600)] hover:underline">
|
||||
{$t('dashboard.viewAllSites')} →
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<CollapsibleSection
|
||||
id="dashboard-sites"
|
||||
title={$t('dashboard.staticSites')}
|
||||
badge={sites.length > 0 ? String(sites.length) : ''}
|
||||
actions={sitesActions}
|
||||
>
|
||||
{#if sites.length === 0}
|
||||
<EmptyState
|
||||
title={$t('dashboard.noSites')}
|
||||
description={$t('dashboard.addFirstSite')}
|
||||
actionLabel={$t('sites.title')}
|
||||
actionHref="/sites"
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each sites as site (site.id)}
|
||||
{@const badge = siteStatusBadge(site.status)}
|
||||
<a href="/sites/{site.id}" class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-[var(--text-primary)]">{site.name}</span>
|
||||
<span class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.cls}">{badge.text}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<span class="truncate">{site.repo_owner}/{site.repo_name}</span>
|
||||
{#if site.domain}
|
||||
<span class="truncate text-[var(--color-brand-600)]">{site.domain}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if site.last_sync_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {$fmt.dateTime(site.last_sync_at)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
<!-- Project cards -->
|
||||
<!-- Recent workloads strip -->
|
||||
<CollapsibleSection
|
||||
id="dashboard-projects"
|
||||
title={$t('dashboard.projects')}
|
||||
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
|
||||
id="dashboard-workloads"
|
||||
title={$t('dashboard.recentWorkloads')}
|
||||
badge={!loading && workloads.length > 0 ? String(workloads.length) : ''}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -272,18 +200,36 @@
|
||||
{$t('dashboard.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
{:else if workloads.length === 0}
|
||||
<EmptyState
|
||||
title={$t('empty.noProjects')}
|
||||
description={$t('empty.noProjectsDesc')}
|
||||
actionLabel={$t('empty.createProject')}
|
||||
actionHref="/projects"
|
||||
title={$t('dashboard.noWorkloads')}
|
||||
description={$t('dashboard.noWorkloadsDesc')}
|
||||
actionLabel={$t('dashboard.newApp')}
|
||||
actionHref="/apps/new"
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each projects as project (project.id)}
|
||||
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each recentWorkloads as wl (wl.id)}
|
||||
{@const state = containerStateFor(wl.id)}
|
||||
{@const count = containerCountFor(wl.id)}
|
||||
<a
|
||||
href={wl.app_id ? `/apps/${wl.app_id}` : '/apps'}
|
||||
class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-[var(--text-primary)]">{wl.name}</span>
|
||||
<StatusBadge status={state} size="sm" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<span class="truncate">{wl.source_kind || wl.kind}</span>
|
||||
<span class="opacity-60">·</span>
|
||||
<span>{count} {count === 1 ? $t('common.instance') : $t('common.instances')}</span>
|
||||
</div>
|
||||
{#if wl.updated_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$fmt.dateTime(wl.updated_at)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -298,19 +244,4 @@
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.stat-link:hover { background: var(--surface-card-hover); }
|
||||
.stat-link .forge-stat-sub .tag {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
:global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
.section { margin-top: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -328,11 +328,9 @@
|
||||
let newEnvValue = $state('');
|
||||
let newEnvEncrypted = $state(true);
|
||||
|
||||
// ── Webhook ────────────────────────────────────────────────
|
||||
let webhook = $state<api.WorkloadWebhook | null>(null);
|
||||
let webhookLoading = $state(false);
|
||||
let webhookError = $state('');
|
||||
let regenerating = $state(false);
|
||||
// Workload-side webhook UI was removed in the hard legacy cutover —
|
||||
// inbound webhooks are now first-class Triggers. Use the bindings
|
||||
// panel + the /triggers detail page to manage the webhook URL.
|
||||
|
||||
// ── Logs viewer ────────────────────────────────────────────
|
||||
let logContainerRowID = $state<string | null>(null);
|
||||
@@ -501,18 +499,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhook() {
|
||||
webhookLoading = true;
|
||||
webhookError = '';
|
||||
try {
|
||||
webhook = await api.getWorkloadWebhook(id);
|
||||
} catch (e) {
|
||||
webhookError = e instanceof Error ? e.message : 'Failed to load webhook';
|
||||
} finally {
|
||||
webhookLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addEnv() {
|
||||
envError = '';
|
||||
const key = newEnvKey.trim();
|
||||
@@ -547,18 +533,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateWebhook() {
|
||||
regenerating = true;
|
||||
webhookError = '';
|
||||
try {
|
||||
webhook = await api.regenerateWorkloadWebhook(id);
|
||||
} catch (e) {
|
||||
webhookError = e instanceof Error ? e.message : 'Failed to rotate secret';
|
||||
} finally {
|
||||
regenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deploy() {
|
||||
deploying = true;
|
||||
lastDeployMsg = '';
|
||||
@@ -2231,57 +2205,9 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ── Webhook ──────────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head split">
|
||||
<h2 class="panel-title">Webhook<span class="title-accent">.</span></h2>
|
||||
{#if !webhook}
|
||||
<button class="forge-btn-ghost" onclick={loadWebhook} disabled={webhookLoading}>
|
||||
{webhookLoading ? 'Loading…' : 'Reveal URL'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
{#if webhookError}
|
||||
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{webhookError}</span></div>
|
||||
{/if}
|
||||
{#if webhook}
|
||||
<p class="hint">
|
||||
Point your registry or CI here. The URL itself is the credential — treat it as a
|
||||
secret. Rotate any time without disrupting deploys (the next call uses the new URL).
|
||||
</p>
|
||||
<div class="webhook-row">
|
||||
<code class="webhook-url">{webhook.webhook_url}</code>
|
||||
<button
|
||||
class="forge-btn-ghost"
|
||||
onclick={() => copyToClipboard('webhook', webhook!.webhook_url)}
|
||||
aria-label="Copy webhook URL"
|
||||
>
|
||||
{#if copied.webhook}
|
||||
<IconCheck size={13} /><span>Copied</span>
|
||||
{:else}
|
||||
<IconCopy size={13} /><span>Copy</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="webhook-meta">
|
||||
<span class="meta-chip" class:active={webhook.has_signing_secret}>
|
||||
{webhook.has_signing_secret ? 'HMAC SIGNED' : 'UNSIGNED'}
|
||||
</span>
|
||||
<span class="meta-chip" class:active={webhook.webhook_require_signature}>
|
||||
{webhook.webhook_require_signature ? 'SIGNATURE REQUIRED' : 'SIGNATURE OPTIONAL'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="webhook-actions">
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={regenerateWebhook}
|
||||
disabled={regenerating}
|
||||
>
|
||||
{regenerating ? 'Rotating…' : 'Rotate secret'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<!-- Webhook URL panel removed — inbound webhooks live on
|
||||
the bound Triggers panel above. The trigger detail page
|
||||
(/triggers/{id}) carries the URL + rotate action. -->
|
||||
{/if}
|
||||
|
||||
<!-- ── Config viewers ───────────────────────────── -->
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
// client-side so the tab counters reflect the whole population, not the
|
||||
// current narrowed view (otherwise picking "Project" would show All=0).
|
||||
let allContainers = $state<ContainerView[]>([]);
|
||||
let refIDByWorkload = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let error = $state('');
|
||||
@@ -40,15 +39,9 @@
|
||||
try {
|
||||
// Race-safety: keep the latest fetch's result and discard stragglers.
|
||||
const seq = ++loadSeq;
|
||||
const [containers, workloads] = await Promise.all([
|
||||
api.listContainers({}),
|
||||
api.listWorkloads()
|
||||
]);
|
||||
const containers = await api.listContainers({});
|
||||
if (seq !== loadSeq) return;
|
||||
allContainers = containers;
|
||||
const map: Record<string, string> = {};
|
||||
for (const wl of workloads) map[wl.id] = wl.ref_id;
|
||||
refIDByWorkload = map;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('containers.errLoad');
|
||||
} finally {
|
||||
@@ -127,18 +120,11 @@
|
||||
}
|
||||
|
||||
function detailHref(c: ContainerView): string | undefined {
|
||||
const refID = refIDByWorkload[c.workload_id];
|
||||
if (!refID) return undefined;
|
||||
switch (c.workload_kind) {
|
||||
case 'project':
|
||||
return `/projects/${refID}`;
|
||||
case 'stack':
|
||||
return `/stacks/${refID}`;
|
||||
case 'site':
|
||||
return `/sites/${refID}`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
// Legacy project / stack / site detail pages were retired with the
|
||||
// hard cutover. The workload-first equivalent lives under /apps —
|
||||
// every workload now belongs to an app, so the row deep-links to
|
||||
// the app detail page when one is attached, otherwise stays flat.
|
||||
return c.app_id ? `/apps/${c.app_id}` : undefined;
|
||||
}
|
||||
|
||||
function tabClass(active: boolean): string {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { StaleContainer } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import StaleContainerCard from '$lib/components/StaleContainerCard.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
@@ -9,6 +8,7 @@
|
||||
import { IconTrash, IconLoader } from '$lib/components/icons';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let containers = $state<StaleContainer[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -19,15 +19,26 @@
|
||||
let cleaningIds = $state<Set<string>>(new Set());
|
||||
let bulkCleaning = $state(false);
|
||||
|
||||
let loadController: AbortController | null = null;
|
||||
|
||||
async function loadStale() {
|
||||
loadController?.abort();
|
||||
const ac = new AbortController();
|
||||
loadController = ac;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
containers = await api.fetchStaleContainers();
|
||||
const rows = await api.fetchStaleContainers(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
containers = rows;
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return;
|
||||
error = e instanceof Error ? e.message : $t('stale.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
if (loadController === ac) {
|
||||
loading = false;
|
||||
loadController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +79,7 @@
|
||||
|
||||
$effect(() => {
|
||||
loadStale();
|
||||
return () => loadController?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -124,17 +136,124 @@
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each containers as container (container.container.id)}
|
||||
<StaleContainerCard
|
||||
{container}
|
||||
cleaning={cleaningIds.has(container.container.id)}
|
||||
oncleanup={requestCleanup}
|
||||
/>
|
||||
{#each containers as entry (entry.container.id)}
|
||||
{@const c = entry.container}
|
||||
{@const cleaning = cleaningIds.has(c.id)}
|
||||
<article class="stale-card">
|
||||
<header class="stale-card-head">
|
||||
<div class="stale-card-title">
|
||||
<span class="stale-workload">{entry.workload_name || c.workload_id || '—'}</span>
|
||||
{#if entry.role}
|
||||
<span class="stale-role">/ {entry.role}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="stale-pill" title={$t('stale.daysStale')}>{entry.days_stale}d</span>
|
||||
</header>
|
||||
<dl class="stale-meta">
|
||||
<div><dt>{$t('common.running')}</dt><dd>{c.state}</dd></div>
|
||||
<div><dt>image</dt><dd class="truncate">{c.image_ref}{c.image_tag ? ':' + c.image_tag : ''}</dd></div>
|
||||
{#if c.last_seen_at}
|
||||
<div><dt>{$t('stale.lastAlive')}</dt><dd>{$fmt.dateTime(c.last_seen_at)}</dd></div>
|
||||
{/if}
|
||||
</dl>
|
||||
<footer class="stale-card-foot">
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost forge-btn-danger"
|
||||
disabled={cleaning}
|
||||
onclick={() => requestCleanup(c.id)}
|
||||
>
|
||||
{#if cleaning}<IconLoader size={14} />{/if}
|
||||
<IconTrash size={14} />
|
||||
<span>{$t('stale.cleanup')}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stale-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md, 0.75rem);
|
||||
background: var(--surface-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stale-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stale-card-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.stale-workload {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stale-role {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
.stale-pill {
|
||||
flex-shrink: 0;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-danger-dark);
|
||||
background: var(--color-danger-light);
|
||||
}
|
||||
.stale-meta {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.stale-meta > div {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stale-meta dt {
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.stale-meta dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||
}
|
||||
.stale-card-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Single cleanup confirm -->
|
||||
<ConfirmDialog
|
||||
open={confirmSingleId !== ''}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { inspectImage, quickDeploy, listProjects, listRegistries, listRegistryImages } from '$lib/api';
|
||||
import type { InspectResult, EntityPickerItem, Project } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
|
||||
|
||||
let imageUrl = $state('');
|
||||
let inspecting = $state(false);
|
||||
let deploying = $state(false);
|
||||
let inspected = $state(false);
|
||||
let inspectResult: InspectResult | null = $state(null);
|
||||
|
||||
let projectName = $state('');
|
||||
let port = $state('');
|
||||
let stage = $state('dev');
|
||||
let subdomain = $state('');
|
||||
let envVars = $state('');
|
||||
let enableProxy = $state(true);
|
||||
let autoDeploy = $state(false);
|
||||
|
||||
let errors = $state<Record<string, string>>({});
|
||||
|
||||
// Duplicate detection state
|
||||
let conflictProjects = $state<Project[]>([]);
|
||||
let showConflictDialog = $state(false);
|
||||
|
||||
// Image picker state
|
||||
let showImagePicker = $state(false);
|
||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
||||
let imagePickerLoading = $state(false);
|
||||
|
||||
async function handleBrowseImages() {
|
||||
showImagePicker = true;
|
||||
if (imagePickerItems.length > 0) return;
|
||||
|
||||
imagePickerLoading = true;
|
||||
try {
|
||||
const registries = await listRegistries();
|
||||
const items: EntityPickerItem[] = [];
|
||||
for (const reg of registries) {
|
||||
if (!reg.owner) continue;
|
||||
try {
|
||||
const images = await listRegistryImages(reg.id);
|
||||
for (const img of images) {
|
||||
items.push({
|
||||
value: img.full_ref + ':latest',
|
||||
label: img.full_ref,
|
||||
description: reg.name,
|
||||
group: reg.name
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip registries that fail.
|
||||
}
|
||||
}
|
||||
imagePickerItems = items;
|
||||
} catch {
|
||||
toasts.error($t('quickDeploy.imageLoadFailed'));
|
||||
} finally {
|
||||
imagePickerLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPickedImage(value: string) {
|
||||
imageUrl = value;
|
||||
showImagePicker = false;
|
||||
}
|
||||
|
||||
function validateImageUrl(url: string): string {
|
||||
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
|
||||
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
||||
return $t('validation.invalidUrl');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validatePort(value: string | number): string {
|
||||
const s = String(value ?? '');
|
||||
if (!s.trim()) return $t('validation.required', { field: 'Port' });
|
||||
const num = parseInt(s, 10);
|
||||
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateProjectName(value: string): string {
|
||||
if (!value.trim()) return $t('validation.required', { field: 'Project name' });
|
||||
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
|
||||
return $t('validation.invalidProjectName');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
const newErrors: Record<string, string> = {};
|
||||
const nameErr = validateProjectName(projectName);
|
||||
if (nameErr) newErrors.projectName = nameErr;
|
||||
const portErr = validatePort(port);
|
||||
if (portErr) newErrors.port = portErr;
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
function deriveProjectName(image: string): string {
|
||||
const withoutTag = image.split(':')[0] ?? image;
|
||||
const segments = withoutTag.split('/');
|
||||
return (segments[segments.length - 1] ?? 'unknown').toLowerCase().replace(/[^a-z0-9\-]/g, '-');
|
||||
}
|
||||
|
||||
async function handleInspect() {
|
||||
const urlError = validateImageUrl(imageUrl);
|
||||
if (urlError) {
|
||||
errors = { imageUrl: urlError };
|
||||
return;
|
||||
}
|
||||
errors = {};
|
||||
inspecting = true;
|
||||
try {
|
||||
const result = await inspectImage(imageUrl.trim());
|
||||
inspectResult = result;
|
||||
projectName = deriveProjectName(result.image);
|
||||
port = result.port?.toString() ?? '';
|
||||
// Healthcheck auto-detected but not shown — user can configure later on project page.
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
inspected = true;
|
||||
toasts.success($t('quickDeploy.inspectedSuccess'));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : $t('quickDeploy.inspectFailed');
|
||||
toasts.error(message);
|
||||
} finally {
|
||||
inspecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy(force = false) {
|
||||
if (!validateAll()) return;
|
||||
deploying = true;
|
||||
try {
|
||||
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force, enable_proxy: enableProxy, auto_deploy: autoDeploy });
|
||||
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
|
||||
// Redirect to the new project page.
|
||||
if (result.project?.id) {
|
||||
goto(`/projects/${result.project.id}`);
|
||||
} else {
|
||||
imageUrl = '';
|
||||
inspected = false;
|
||||
inspectResult = null;
|
||||
projectName = '';
|
||||
port = '';
|
||||
stage = 'dev';
|
||||
subdomain = '';
|
||||
envVars = '';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Handle 409 Conflict — existing project with same image.
|
||||
if (err instanceof Error && 'status' in err && (err as any).status === 409) {
|
||||
try {
|
||||
// Find existing projects with the same image.
|
||||
const allProjects = await listProjects();
|
||||
const imageBase = imageUrl.trim().split(':')[0];
|
||||
const matching = allProjects.filter(p => p.image === imageBase || p.image === imageUrl.trim());
|
||||
if (matching.length > 0) {
|
||||
conflictProjects = matching;
|
||||
showConflictDialog = true;
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
toasts.error($t('quickDeploy.imageAlreadyExists'));
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
|
||||
toasts.error(message);
|
||||
}
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeployToExisting(project: Project) {
|
||||
showConflictDialog = false;
|
||||
conflictProjects = [];
|
||||
goto(`/projects/${project.id}`);
|
||||
}
|
||||
|
||||
async function handleForceNewProject() {
|
||||
showConflictDialog = false;
|
||||
conflictProjects = [];
|
||||
await handleDeploy(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('quickDeploy.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<ForgeHero
|
||||
eyebrowSuffix="DEPLOY"
|
||||
title={$t('quickDeploy.title')}
|
||||
lede={$t('quickDeploy.description')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-4 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step1')}</h2>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<FormField
|
||||
label={$t('quickDeploy.imageUrl')}
|
||||
name="imageUrl"
|
||||
bind:value={imageUrl}
|
||||
placeholder="registry.example.com/org/app:tag"
|
||||
required
|
||||
error={errors.imageUrl ?? ''}
|
||||
helpText={$t('quickDeploy.imageUrlHelp')}
|
||||
disabled={inspecting}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 pt-[26px]">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBrowseImages}
|
||||
title={$t('quickDeploy.browseImages')}
|
||||
aria-label={$t('quickDeploy.browseImages')}
|
||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
{#if imagePickerLoading}
|
||||
<IconLoader size={16} />
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleInspect}
|
||||
disabled={inspecting || !imageUrl.trim()}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-info)] px-4 py-2 text-sm font-medium text-white transition-all duration-150 hover:bg-[var(--color-info-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if inspecting}
|
||||
<IconLoader size={16} />
|
||||
{$t('quickDeploy.inspecting')}
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{$t('quickDeploy.inspect')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EntityPicker
|
||||
bind:open={showImagePicker}
|
||||
items={imagePickerItems}
|
||||
current={imageUrl}
|
||||
title={$t('quickDeploy.selectImage')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={selectPickedImage}
|
||||
onclose={() => { showImagePicker = false; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
{#if inspected}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
|
||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step2')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.reviewDesc')}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText={$t('quickDeploy.lowercaseHint')} />
|
||||
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
|
||||
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
<option value="dev">{$t('quickDeploy.development')}</option>
|
||||
<option value="rel">{$t('quickDeploy.release')}</option>
|
||||
<option value="prod">{$t('quickDeploy.production')}</option>
|
||||
</select>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.stageHelp')}</p>
|
||||
</div>
|
||||
<FormField label={$t('quickDeploy.subdomainOverride')} name="subdomain" bind:value={subdomain} placeholder="auto-generated" helpText={$t('quickDeploy.subdomainHelp')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder={"KEY=value\nANOTHER_KEY=another_value"} helpText={$t('quickDeploy.envVarsHelp')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch bind:checked={enableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ToggleSwitch bind:checked={autoDeploy} label={$t('quickDeploy.autoDeployLabel')} />
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('quickDeploy.autoDeployLabel')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
|
||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step3')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => handleDeploy()}
|
||||
disabled={deploying}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if deploying}
|
||||
<IconLoader size={16} />
|
||||
{$t('projectDetail.deploying')}
|
||||
{:else}
|
||||
<IconDeploy size={16} />
|
||||
{$t('quickDeploy.deployBtn')}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { inspected = false; inspectResult = null; }}
|
||||
disabled={deploying}
|
||||
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conflict dialog: image already deployed -->
|
||||
{#if showConflictDialog}
|
||||
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={() => { showConflictDialog = false; }}></div>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="conflict-dialog-title"
|
||||
tabindex="-1"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') showConflictDialog = false; }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="w-full max-w-lg rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in"
|
||||
>
|
||||
<h3 id="conflict-dialog-title" class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{$t('quickDeploy.imageAlreadyExists')}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{$t('quickDeploy.conflictDescription')}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each conflictProjects as project (project.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeployToExisting(project)}
|
||||
class="flex w-full items-center justify-between rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-3 text-left hover:border-[var(--color-brand-500)] transition-colors"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{project.name}</span>
|
||||
<span class="ml-2 text-xs text-[var(--text-tertiary)]">{project.image}</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.openProject')}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={() => { showConflictDialog = false; }}
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
|
||||
onclick={handleForceNewProject}
|
||||
>
|
||||
{$t('quickDeploy.createNewAnyway')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,302 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Project, EntityPickerItem } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconPlus, IconSearch, IconLoader } from '$lib/components/icons';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let showAddForm = $state(false);
|
||||
let searchQuery = $state('');
|
||||
|
||||
const filteredProjects = $derived(
|
||||
searchQuery.trim()
|
||||
? projects.filter(p => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return p.name.toLowerCase().includes(q)
|
||||
|| p.image.toLowerCase().includes(q)
|
||||
|| (p.registry ?? '').toLowerCase().includes(q);
|
||||
})
|
||||
: projects
|
||||
);
|
||||
|
||||
let formName = $state('');
|
||||
let formImage = $state('');
|
||||
let formRegistry = $state('');
|
||||
let formPort = $state('');
|
||||
let formHealthcheck = $state('');
|
||||
let formSubmitting = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
// Image picker state
|
||||
let showImagePicker = $state(false);
|
||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
||||
let imagePickerLoading = $state(false);
|
||||
|
||||
async function handleBrowseImages() {
|
||||
showImagePicker = true;
|
||||
if (imagePickerItems.length > 0) return;
|
||||
|
||||
imagePickerLoading = true;
|
||||
try {
|
||||
const registries = await api.listRegistries();
|
||||
// Collect existing project images to mark as already added.
|
||||
const existingImages = new Set(projects.map(p => p.image.toLowerCase()));
|
||||
const items: EntityPickerItem[] = [];
|
||||
for (const reg of registries) {
|
||||
if (!reg.owner) continue;
|
||||
try {
|
||||
const images = await api.listRegistryImages(reg.id);
|
||||
for (const img of images) {
|
||||
const alreadyAdded = existingImages.has(img.full_ref.toLowerCase());
|
||||
items.push({
|
||||
value: JSON.stringify({ full_ref: img.full_ref, registryName: reg.name }),
|
||||
label: img.full_ref,
|
||||
description: alreadyAdded ? undefined : reg.name,
|
||||
group: reg.name,
|
||||
disabled: alreadyAdded,
|
||||
disabledHint: alreadyAdded ? $t('projects.alreadyAdded') : undefined
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip registries that fail (e.g., no owner configured).
|
||||
}
|
||||
}
|
||||
imagePickerItems = items;
|
||||
} catch {
|
||||
imagePickerItems = [];
|
||||
} finally {
|
||||
imagePickerLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nameFromImage(imageRef: string): string {
|
||||
// Extract last path segment: "git.example.com/owner/my-app" → "my-app"
|
||||
const parts = imageRef.split('/');
|
||||
return parts[parts.length - 1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
}
|
||||
|
||||
function selectPickedImage(value: string) {
|
||||
const parsed = JSON.parse(value) as { full_ref: string; registryName: string };
|
||||
formImage = parsed.full_ref;
|
||||
formRegistry = parsed.registryName;
|
||||
// Auto-fill name if empty.
|
||||
if (!formName.trim()) {
|
||||
formName = nameFromImage(parsed.full_ref);
|
||||
}
|
||||
showImagePicker = false;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.listProjects();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('projects.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddProject() {
|
||||
if (!formName.trim() || !formImage.trim()) {
|
||||
formError = $t('projects.nameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
formSubmitting = true;
|
||||
formError = '';
|
||||
try {
|
||||
await api.createProject({
|
||||
name: formName.trim(),
|
||||
image: formImage.trim(),
|
||||
registry: formRegistry.trim(),
|
||||
port: parseInt(formPort, 10) || 3000,
|
||||
healthcheck: formHealthcheck.trim()
|
||||
});
|
||||
formName = '';
|
||||
formImage = '';
|
||||
formRegistry = '';
|
||||
formPort = '';
|
||||
formHealthcheck = '';
|
||||
showAddForm = false;
|
||||
await loadProjects();
|
||||
} catch (e) {
|
||||
formError = e instanceof Error ? e.message : $t('projects.createFailed');
|
||||
} finally {
|
||||
formSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadProjects();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('projects.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#snippet heroToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
class={showAddForm ? 'forge-btn-ghost' : 'forge-btn'}
|
||||
onclick={() => { showAddForm = !showAddForm; }}
|
||||
>
|
||||
{#if !showAddForm}<IconPlus size={14} />{/if}
|
||||
<span>{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrowSuffix="PROJECTS"
|
||||
title={$t('projects.title')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
/>
|
||||
|
||||
<!-- Add project form -->
|
||||
{#if showAddForm}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projects.newProject')}</h2>
|
||||
|
||||
{#if formError}
|
||||
<div class="mt-3 rounded-lg bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{formError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="{$t('projects.name')} *" name="name" bind:value={formName} placeholder="my-web-app" required />
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<FormField label="{$t('projects.image')} *" name="image" bind:value={formImage} placeholder="registry.example.com/org/app" required />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleBrowseImages}
|
||||
title={$t('projects.browseImages')}
|
||||
aria-label={$t('projects.browseImages')}
|
||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
{#if imagePickerLoading}
|
||||
<IconLoader size={16} />
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<EntityPicker
|
||||
bind:open={showImagePicker}
|
||||
items={imagePickerItems}
|
||||
current={formImage}
|
||||
title={$t('projects.selectImage')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={selectPickedImage}
|
||||
onclose={() => { showImagePicker = false; }}
|
||||
/>
|
||||
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} placeholder="3000" helpText={$t('projects.portHelpText')} />
|
||||
<FormField label={$t('projects.healthcheck')} name="healthcheck" bind:value={formHealthcheck} placeholder="/api/health" helpText={$t('projects.healthcheckHelpText')} />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all duration-150 active:animate-press"
|
||||
disabled={formSubmitting}
|
||||
onclick={handleAddProject}
|
||||
>
|
||||
{formSubmitting ? $t('projects.creating') : $t('projects.createProject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Projects list -->
|
||||
{#if loading}
|
||||
<SkeletonTable rows={4} cols={5} />
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProjects}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<EmptyState
|
||||
title={$t('empty.noProjects')}
|
||||
description={$t('empty.noProjectsDesc')}
|
||||
actionLabel={$t('projects.addProject')}
|
||||
onaction={() => { showAddForm = true; }}
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Search filter -->
|
||||
<div class="relative">
|
||||
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={$t('projects.searchPlaceholder')}
|
||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if filteredProjects.length === 0}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('projects.noMatchingProjects')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.name')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.image')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</th>
|
||||
<th class="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each filteredProjects as project (project.id)}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<a href="/projects/{project.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
{project.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-[var(--text-tertiary)]">
|
||||
{project.image}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{project.port || '-'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{project.registry || '-'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{$fmt.date(project.created_at)}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
|
||||
<a href="/projects/{project.id}" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
{$t('projects.view')}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,915 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Project, Stage, Instance, Deploy, LocalImage } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import InstanceCard from '$lib/components/InstanceCard.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
||||
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
|
||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
import { IconShield } from '$lib/components/icons';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let project = $state<Project | null>(null);
|
||||
let stages = $state<Stage[]>([]);
|
||||
let instancesByStage = $state<Record<string, Instance[]>>({});
|
||||
let deploys = $state<Deploy[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let deployStageId = $state('');
|
||||
let deployTag = $state('');
|
||||
let deployLoading = $state(false);
|
||||
let deployError = $state('');
|
||||
|
||||
|
||||
// Edit stage
|
||||
let editingStageId = $state('');
|
||||
let editStageName = $state('');
|
||||
let editStageTagPattern = $state('');
|
||||
let editStageAutoDeploy = $state(true);
|
||||
let editStageEnableProxy = $state(true);
|
||||
let editStageMaxInstances = $state('1');
|
||||
let editStageCpuLimit = $state('');
|
||||
let editStageMemoryLimit = $state('');
|
||||
let editStageNotificationUrl = $state('');
|
||||
let savingStage = $state(false);
|
||||
|
||||
function startEditStage(stage: Stage) {
|
||||
editingStageId = stage.id;
|
||||
editStageName = stage.name;
|
||||
editStageTagPattern = stage.tag_pattern;
|
||||
editStageAutoDeploy = stage.auto_deploy;
|
||||
editStageEnableProxy = stage.enable_proxy;
|
||||
editStageMaxInstances = String(stage.max_instances);
|
||||
editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : '';
|
||||
editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : '';
|
||||
editStageNotificationUrl = stage.notification_url ?? '';
|
||||
}
|
||||
|
||||
async function handleUpdateStage() {
|
||||
if (!editStageName.trim()) return;
|
||||
savingStage = true;
|
||||
try {
|
||||
await api.updateStage(projectId, editingStageId, {
|
||||
name: editStageName.trim(),
|
||||
tag_pattern: editStageTagPattern.trim() || '*',
|
||||
auto_deploy: editStageAutoDeploy,
|
||||
enable_proxy: editStageEnableProxy,
|
||||
max_instances: parseInt(editStageMaxInstances) || 1,
|
||||
cpu_limit: parseFloat(editStageCpuLimit) || 0,
|
||||
memory_limit: parseInt(editStageMemoryLimit) || 0,
|
||||
notification_url: editStageNotificationUrl.trim(),
|
||||
});
|
||||
toasts.success($t('projectDetail.stageUpdated'));
|
||||
editingStageId = '';
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageUpdateFailed'));
|
||||
} finally {
|
||||
savingStage = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add stage form
|
||||
let showAddStage = $state(false);
|
||||
let stageName = $state('');
|
||||
let stageTagPattern = $state('*');
|
||||
let stageAutoDeploy = $state(true);
|
||||
let stageEnableProxy = $state(true);
|
||||
let stageMaxInstances = $state('1');
|
||||
let stageCpuLimit = $state('');
|
||||
let stageMemoryLimit = $state('');
|
||||
let addingStage = $state(false);
|
||||
|
||||
async function handleAddStage() {
|
||||
if (!stageName.trim()) return;
|
||||
addingStage = true;
|
||||
try {
|
||||
await api.createStage(projectId, {
|
||||
name: stageName.trim(),
|
||||
tag_pattern: stageTagPattern.trim() || '*',
|
||||
auto_deploy: stageAutoDeploy,
|
||||
enable_proxy: stageEnableProxy,
|
||||
max_instances: parseInt(stageMaxInstances) || 1,
|
||||
cpu_limit: parseFloat(stageCpuLimit) || 0,
|
||||
memory_limit: parseInt(stageMemoryLimit) || 0,
|
||||
});
|
||||
toasts.success($t('projectDetail.stageCreated', { name: stageName }));
|
||||
stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1'; stageCpuLimit = ''; stageMemoryLimit = '';
|
||||
showAddStage = false;
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageCreateFailed'));
|
||||
} finally {
|
||||
addingStage = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Edit project
|
||||
let editing = $state(false);
|
||||
let editName = $state('');
|
||||
let editImage = $state('');
|
||||
let editPort = $state('');
|
||||
let editHealthcheck = $state('');
|
||||
let editAccessListId = $state(0);
|
||||
let editAccessListName = $state('');
|
||||
let editNotificationUrl = $state('');
|
||||
let accessListPickerOpen = $state(false);
|
||||
let accessListPickerItems = $state<EntityPickerItem[]>([]);
|
||||
let loadingAccessLists = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
async function openProjectAccessListPicker() {
|
||||
loadingAccessLists = true;
|
||||
try {
|
||||
const lists = await api.listNpmAccessLists();
|
||||
if (lists.length === 0) { toasts.info($t('settingsNpm.noAccessLists')); return; }
|
||||
accessListPickerItems = lists.map((al): EntityPickerItem => ({
|
||||
value: String(al.id),
|
||||
label: al.name || `Access List #${al.id}`,
|
||||
}));
|
||||
accessListPickerOpen = true;
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsNpm.accessListLoadFailed'));
|
||||
} finally { loadingAccessLists = false; }
|
||||
}
|
||||
|
||||
function handleProjectAccessListSelect(value: string) {
|
||||
editAccessListId = parseInt(value, 10);
|
||||
const item = accessListPickerItems.find((i) => i.value === value);
|
||||
editAccessListName = item?.label ?? '';
|
||||
accessListPickerOpen = false;
|
||||
}
|
||||
|
||||
function clearProjectAccessList() {
|
||||
editAccessListId = 0;
|
||||
editAccessListName = '';
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (!project) return;
|
||||
editName = project.name;
|
||||
editImage = project.image;
|
||||
editPort = String(project.port || '');
|
||||
editHealthcheck = project.healthcheck || '';
|
||||
editAccessListId = project.npm_access_list_id || 0;
|
||||
editAccessListName = editAccessListId > 0 ? `Access List #${editAccessListId}` : '';
|
||||
editNotificationUrl = project.notification_url ?? '';
|
||||
editing = true;
|
||||
// Resolve access list name in background.
|
||||
if (editAccessListId > 0) {
|
||||
api.listNpmAccessLists().then(lists => {
|
||||
const match = lists.find(al => al.id === editAccessListId);
|
||||
if (match) editAccessListName = match.name;
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
if (!editName.trim() || !editImage.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.updateProject(projectId, {
|
||||
name: editName.trim(),
|
||||
image: editImage.trim(),
|
||||
port: parseInt(editPort) || 0,
|
||||
healthcheck: editHealthcheck.trim(),
|
||||
npm_access_list_id: editAccessListId,
|
||||
notification_url: editNotificationUrl.trim(),
|
||||
});
|
||||
toasts.success($t('projectDetail.projectUpdated'));
|
||||
editing = false;
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.updateFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteStage(stageId: string, name: string) {
|
||||
try {
|
||||
await api.deleteStage(projectId, stageId);
|
||||
// Update local state immediately so the UI reflects the change.
|
||||
stages = stages.filter((s) => s.id !== stageId);
|
||||
const { [stageId]: _, ...rest } = instancesByStage;
|
||||
instancesByStage = rest;
|
||||
toasts.success($t('projectDetail.stageDeleted', { name }));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
|
||||
}
|
||||
}
|
||||
let settingsDomain = $state('');
|
||||
let localImages = $state<LocalImage[]>([]);
|
||||
|
||||
let showDeleteConfirm = $state(false);
|
||||
let stageDeleteTarget = $state<{ id: string; name: string } | null>(null);
|
||||
let loadController: AbortController | null = null;
|
||||
|
||||
const projectId = $derived($page.params.id!); // always present on [id] route
|
||||
|
||||
async function loadProject() {
|
||||
// Abort any previous in-flight load before starting a new one.
|
||||
loadController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadController = controller;
|
||||
const signal = controller.signal;
|
||||
|
||||
if (!project) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const detail = await api.getProject(projectId, signal);
|
||||
project = detail.project;
|
||||
stages = detail.stages ?? [];
|
||||
|
||||
const instanceResults = await Promise.all(
|
||||
stages.map(async (s) => {
|
||||
try {
|
||||
const instances = await api.listInstances(projectId, s.id, signal);
|
||||
return { stageId: s.id, instances };
|
||||
} catch {
|
||||
return { stageId: s.id, instances: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const mapped: Record<string, Instance[]> = {};
|
||||
for (const r of instanceResults) {
|
||||
mapped[r.stageId] = r.instances;
|
||||
}
|
||||
instancesByStage = mapped;
|
||||
|
||||
// Fetch deploys, settings, and images in parallel (independent of each other).
|
||||
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
|
||||
api.listDeploys(20, signal),
|
||||
api.getSettings(signal),
|
||||
api.listProjectImages(projectId, signal)
|
||||
]);
|
||||
|
||||
deploys = deploysResult.status === 'fulfilled'
|
||||
? deploysResult.value.filter((d) => d.project_id === projectId)
|
||||
: [];
|
||||
settingsDomain = settingsResult.status === 'fulfilled'
|
||||
? (settingsResult.value.domain ?? '')
|
||||
: settingsDomain;
|
||||
localImages = imagesResult.status === 'fulfilled'
|
||||
? imagesResult.value
|
||||
: [];
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let tagPickerOpen = $state(false);
|
||||
let tagPickerItems = $state<EntityPickerItem[]>([]);
|
||||
|
||||
async function openTagPicker(stageId: string) {
|
||||
deployStageId = stageId;
|
||||
deployTag = '';
|
||||
|
||||
// Build local image suggestions.
|
||||
const imgs = localImages;
|
||||
const localItems: EntityPickerItem[] = imgs
|
||||
.filter((img) => img.tag)
|
||||
.map((img) => ({
|
||||
value: img.tag,
|
||||
label: img.tag,
|
||||
group: $t('projectDetail.localTag'),
|
||||
description: `${(img.size / (1024 * 1024)).toFixed(0)} MB`
|
||||
}));
|
||||
|
||||
// Try to fetch registry tags.
|
||||
let registryItems: EntityPickerItem[] = [];
|
||||
try {
|
||||
const registries = await api.listRegistries();
|
||||
// Match by registry URL hostname (project.registry stores the hostname)
|
||||
// or by name, or try all registries if project.registry is empty.
|
||||
const projectRegistry = project?.registry || '';
|
||||
const projectImage = project?.image || '';
|
||||
|
||||
let reg = registries.find(r => {
|
||||
if (!projectRegistry) return false;
|
||||
const urlHost = new URL(r.url).hostname;
|
||||
return r.name === projectRegistry || urlHost === projectRegistry;
|
||||
});
|
||||
|
||||
// If project has no registry set but image contains a hostname, try matching by image prefix.
|
||||
if (!reg && projectImage.includes('/')) {
|
||||
const imageHost = projectImage.split('/')[0];
|
||||
if (imageHost.includes('.')) {
|
||||
reg = registries.find(r => {
|
||||
try { return new URL(r.url).hostname === imageHost; } catch { return false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (reg) {
|
||||
// Strip registry hostname from image if present (registry API expects owner/name).
|
||||
let imageForRegistry = projectImage;
|
||||
try {
|
||||
const urlHost = new URL(reg.url).hostname;
|
||||
if (imageForRegistry.startsWith(urlHost + '/')) {
|
||||
imageForRegistry = imageForRegistry.substring(urlHost.length + 1);
|
||||
}
|
||||
} catch { /* keep as-is */ }
|
||||
|
||||
const tags = await api.listRegistryTags(reg.id, imageForRegistry);
|
||||
const localTagSet = new Set(imgs.map((img) => img.tag));
|
||||
registryItems = tags.map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
group: $t('projectDetail.registryTag'),
|
||||
description: localTagSet.has(tag) ? $t('projectDetail.alsoLocal') : undefined
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore registry errors */ }
|
||||
|
||||
// Merge: registry tags first, then local-only tags.
|
||||
if (registryItems.length > 0) {
|
||||
const registryTagSet = new Set(registryItems.map((item) => item.value));
|
||||
const localOnly = localItems.filter((item) => !registryTagSet.has(item.value));
|
||||
tagPickerItems = [...registryItems, ...localOnly];
|
||||
} else {
|
||||
tagPickerItems = localItems;
|
||||
}
|
||||
|
||||
tagPickerOpen = true;
|
||||
}
|
||||
|
||||
function handleTagSelect(tag: string) {
|
||||
deployTag = tag;
|
||||
tagPickerOpen = false;
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!deployTag.trim() || !deployStageId) return;
|
||||
|
||||
deployLoading = true;
|
||||
deployError = '';
|
||||
try {
|
||||
await api.deployInstance(projectId, deployStageId, deployTag.trim());
|
||||
deployTag = '';
|
||||
deployStageId = '';
|
||||
await loadProject();
|
||||
} catch (e) {
|
||||
deployError = e instanceof Error ? e.message : $t('projectDetail.deployFailed');
|
||||
} finally {
|
||||
deployLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = $state(false);
|
||||
|
||||
async function handleDeleteProject() {
|
||||
showDeleteConfirm = false;
|
||||
deleted = true;
|
||||
try {
|
||||
await api.deleteProject(projectId);
|
||||
goto('/projects');
|
||||
} catch (e) {
|
||||
deleted = false;
|
||||
error = e instanceof Error ? e.message : $t('projectDetail.deleteFailed');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
untrack(() => {
|
||||
if (!deleted) loadProject();
|
||||
});
|
||||
|
||||
return () => {
|
||||
loadController?.abort();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{project?.name ?? $t('common.project')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="4rem" height="0.875rem" />
|
||||
<Skeleton width="12rem" height="1.75rem" />
|
||||
<Skeleton width="16rem" height="0.875rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
{#each Array(4) as _}
|
||||
<Skeleton height="3rem" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProject}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if project}
|
||||
{@const p = project}
|
||||
<div class="space-y-6">
|
||||
{#snippet projectToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
class="forge-btn-ghost forge-btn-danger"
|
||||
onclick={() => { showDeleteConfirm = true; }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
<span>{$t('projectDetail.deleteProject')}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref="/projects"
|
||||
eyebrowSuffix="PROJECT"
|
||||
title={p.name}
|
||||
kicker={p.image}
|
||||
size="lg"
|
||||
toolbar={projectToolbar}
|
||||
/>
|
||||
|
||||
<!-- Project settings links -->
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="/projects/{projectId}/env"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconKey size={16} />
|
||||
{$t('projectDetail.envVars')}
|
||||
</a>
|
||||
<a
|
||||
href="/projects/{projectId}/volumes"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconHardDrive size={16} />
|
||||
{$t('projectDetail.volumes')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Project info -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
{#if editing}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="editName" bind:value={editName} />
|
||||
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} />
|
||||
<FormField label={$t('projectDetail.portLabel')} name="editPort" type="number" bind:value={editPort} />
|
||||
<FormField label={$t('projectDetail.healthcheckLabel')} name="editHealthcheck" bind:value={editHealthcheck} placeholder="/api/health" />
|
||||
<FormField label={$t('projectDetail.notificationUrlLabel')} name="editNotificationUrl" bind:value={editNotificationUrl} placeholder="https://notify.example.com/webhook" helpText={$t('projectDetail.notificationUrlHelp')} />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsNpm.accessList')}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick={openProjectAccessListPicker} disabled={loadingAccessLists}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50">
|
||||
<IconShield size={14} />
|
||||
{#if loadingAccessLists}
|
||||
{$t('common.loading')}
|
||||
{:else if editAccessListId > 0 && editAccessListName}
|
||||
{editAccessListName}
|
||||
{:else}
|
||||
{$t('settingsNpm.noAccessList')}
|
||||
{/if}
|
||||
</button>
|
||||
{#if editAccessListId > 0}
|
||||
<button type="button" onclick={clearProjectAccessList}
|
||||
class="rounded-lg border border-[var(--border-input)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.accessListIdHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { editing = false; }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconX size={14} />
|
||||
{$t('projects.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveProject}
|
||||
disabled={saving || !editName.trim() || !editImage.trim()}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<IconCheck size={14} />
|
||||
{saving ? $t('projectDetail.saving') : $t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="grid grid-cols-2 gap-4 flex-1 sm:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.port || 'Auto'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.healthcheck')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.healthcheck || 'Auto'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.registry || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{$fmt.date(project.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={startEditing}
|
||||
title={$t('common.edit')}
|
||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stages & Instances -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.stages')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showAddStage = !showAddStage; }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg {showAddStage ? 'border border-[var(--border-primary)] text-[var(--text-secondary)]' : 'bg-[var(--color-brand-600)] text-white'} px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90"
|
||||
>
|
||||
{#if !showAddStage}<IconPlus size={14} />{/if}
|
||||
{showAddStage ? $t('projects.cancel') : $t('projectDetail.addStage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddStage}
|
||||
<div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="stageName" bind:value={stageName} placeholder="dev" />
|
||||
<FormField label={$t('projectDetail.tagPattern')} name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText={$t('projectDetail.tagPatternHelp')} />
|
||||
<FormField label={$t('projectDetail.maxInstances')} name="stageMax" type="number" bind:value={stageMaxInstances} />
|
||||
<FormField label={$t('projectDetail.cpuLimit')} name="stageCpu" type="number" bind:value={stageCpuLimit} placeholder="0" helpText={$t('projectDetail.cpuLimitHelp')} />
|
||||
<FormField label={$t('projectDetail.memoryLimit')} name="stageMem" type="number" bind:value={stageMemoryLimit} placeholder="0" helpText={$t('projectDetail.memoryLimitHelp')} />
|
||||
<div class="flex gap-4 items-end pb-1">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
||||
<ToggleSwitch bind:checked={stageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
<ToggleSwitch bind:checked={stageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleAddStage}
|
||||
disabled={addingStage || !stageName.trim()}
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all"
|
||||
>
|
||||
{addingStage ? $t('projectDetail.creating') : $t('projectDetail.createStage')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stages.length === 0 && !showAddStage}
|
||||
<div class="mt-4">
|
||||
<EmptyState title={$t('projectDetail.noStages')} icon="instances" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 space-y-4">
|
||||
{#each stages as stage (stage.id)}
|
||||
{@const stageInstances = instancesByStage[stage.id] ?? []}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<!-- Stage header -->
|
||||
{#if editingStageId === stage.id}
|
||||
<div class="border-b border-[var(--border-secondary)] px-5 py-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<FormField label={$t('projectDetail.nameLabel')} name="editStageName" bind:value={editStageName} />
|
||||
<FormField label={$t('projectDetail.tagPattern')} name="editStagePattern" bind:value={editStageTagPattern} />
|
||||
<FormField label={$t('projectDetail.maxInstances')} name="editStageMax" type="number" bind:value={editStageMaxInstances} />
|
||||
<FormField label={$t('projectDetail.cpuLimit')} name="editStageCpu" type="number" bind:value={editStageCpuLimit} placeholder="0" />
|
||||
<FormField label={$t('projectDetail.memoryLimit')} name="editStageMem" type="number" bind:value={editStageMemoryLimit} placeholder="0" />
|
||||
<div class="flex gap-4 items-end pb-1">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
||||
<ToggleSwitch bind:checked={editStageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
||||
<ToggleSwitch bind:checked={editStageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<FormField
|
||||
label={$t('projectDetail.stageNotificationUrlLabel')}
|
||||
name="editStageNotificationUrl"
|
||||
bind:value={editStageNotificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
helpText={$t('projectDetail.stageNotificationUrlHelp')}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2 justify-end">
|
||||
<button type="button" onclick={() => { editingStageId = ''; }}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<IconX size={14} />
|
||||
{$t('projects.cancel')}
|
||||
</button>
|
||||
<button type="button" onclick={handleUpdateStage} disabled={savingStage || !editStageName.trim()}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors">
|
||||
<IconCheck size={14} />
|
||||
{savingStage ? $t('projectDetail.saving') : $t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Stage-scoped outgoing webhook controls. Lives inside the
|
||||
edit panel so operators see signing + test alongside the
|
||||
URL they're configuring; collapses on save/cancel. -->
|
||||
<div class="mt-4">
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('projectDetail.stageOutgoingTitle')}
|
||||
description={$t('projectDetail.stageOutgoingDesc')}
|
||||
hasUrl={!!stage.notification_url}
|
||||
fallbackLabel={$t('projectDetail.stageFallbackLabel')}
|
||||
fetchSecret={() => api.getStageNotificationSecret(projectId, stage.id)}
|
||||
regenerateSecret={() => api.regenerateStageNotificationSecret(projectId, stage.id)}
|
||||
disableSigning={() => api.disableStageNotificationSigning(projectId, stage.id)}
|
||||
sendTest={() => api.testStageNotification(projectId, stage.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
|
||||
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
|
||||
{#if stage.auto_deploy}
|
||||
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
|
||||
{/if}
|
||||
{#if stage.confirm}
|
||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
|
||||
{/if}
|
||||
{#if !stage.enable_proxy}
|
||||
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
|
||||
onclick={() => openTagPicker(stage.id)}
|
||||
>
|
||||
<IconDeploy size={14} />
|
||||
{$t('projectDetail.deployNewVersion')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={$t('common.edit')}
|
||||
onclick={() => startEditStage(stage)}
|
||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={$t('projectDetail.deleteStage')}
|
||||
onclick={() => { stageDeleteTarget = { id: stage.id, name: stage.name }; }}
|
||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Deploy confirmation -->
|
||||
{#if deployStageId === stage.id && deployTag}
|
||||
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.deployTag')}:</span>
|
||||
<span class="rounded-md bg-[var(--surface-card)] px-2.5 py-1 font-mono text-sm font-medium text-[var(--text-primary)] border border-[var(--border-primary)]">{deployTag}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
|
||||
onclick={() => openTagPicker(stage.id)}
|
||||
>
|
||||
{$t('common.change')}
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||
disabled={deployLoading}
|
||||
onclick={handleDeploy}
|
||||
>
|
||||
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
||||
onclick={() => { deployStageId = ''; deployTag = ''; }}
|
||||
>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if deployError}
|
||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Instances -->
|
||||
<div class="p-5">
|
||||
{#if stageInstances.length === 0}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each stageInstances as instance (instance.id)}
|
||||
<InstanceCard
|
||||
{instance}
|
||||
{projectId}
|
||||
stageId={stage.id}
|
||||
domain={settingsDomain}
|
||||
onchange={loadProject}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Local Docker Images -->
|
||||
{#if localImages.length > 0}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.localImages')}</h2>
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageTag')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageId')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageSize')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageCreated')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each localImages as img (img.id + img.tag)}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{img.tag || 'untagged'}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs font-mono text-[var(--text-tertiary)]">{img.id.substring(7, 19)}</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{(img.size / (1024 * 1024)).toFixed(1)} MB</td>
|
||||
<td class="px-4 py-2.5 text-sm text-[var(--text-tertiary)]">{$fmt.date(img.created)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Webhook (inbound: trigger deploys via this URL). -->
|
||||
<WebhookPanel
|
||||
title={$t('projectDetail.webhookTitle')}
|
||||
description={$t('projectDetail.webhookDesc')}
|
||||
fetchWebhook={() => api.getProjectWebhook(projectId)}
|
||||
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
||||
regenerateSigningSecret={() => api.regenerateProjectSigningSecret(projectId)}
|
||||
disableSigning={() => api.disableProjectSigningSecret(projectId)}
|
||||
setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)}
|
||||
/>
|
||||
|
||||
<!-- Recent inbound webhook activity (debug + audit). -->
|
||||
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listProjectWebhookDeliveries(projectId, signal)} />
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('projectDetail.outgoingWebhookTitle')}
|
||||
description={$t('projectDetail.outgoingWebhookDesc')}
|
||||
hasUrl={!!project.notification_url}
|
||||
fallbackLabel={$t('projectDetail.outgoingFallbackGlobal')}
|
||||
fetchSecret={() => api.getProjectNotificationSecret(projectId)}
|
||||
regenerateSecret={() => api.regenerateProjectNotificationSecret(projectId)}
|
||||
disableSigning={() => api.disableProjectNotificationSigning(projectId)}
|
||||
sendTest={() => api.testProjectNotification(projectId)}
|
||||
/>
|
||||
|
||||
<!-- Deploy History Timeline -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
||||
|
||||
{#if deploys.length === 0}
|
||||
<p class="mt-4 text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noDeployHistory')}</p>
|
||||
{:else}
|
||||
<div class="mt-4 space-y-3">
|
||||
{#each deploys as deploy (deploy.id)}
|
||||
<div class="flex items-start gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
|
||||
<!-- Timeline dot -->
|
||||
<div class="mt-1 flex flex-col items-center">
|
||||
<div class="h-3 w-3 rounded-full {deploy.status === 'success' ? 'bg-emerald-500' : deploy.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}"></div>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-mono text-sm font-medium text-[var(--text-primary)]">{deploy.image_tag}</span>
|
||||
<StatusBadge status={deploy.status} size="sm" />
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-4 text-xs text-[var(--text-tertiary)]">
|
||||
{#if deploy.started_at}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<IconClock size={12} />
|
||||
{$fmt.dateTime(deploy.started_at)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if deploy.finished_at}
|
||||
<span>→ {$fmt.dateTime(deploy.finished_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if deploy.error}
|
||||
<p class="mt-1 text-xs text-[var(--color-danger)] truncate">{deploy.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteConfirm}
|
||||
title={$t('projectDetail.deleteConfirmTitle')}
|
||||
message={$t('projectDetail.deleteConfirmMessage', { name: project.name })}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDeleteProject}
|
||||
oncancel={() => { showDeleteConfirm = false; }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={stageDeleteTarget !== null}
|
||||
title={$t('projectDetail.deleteStage')}
|
||||
message={stageDeleteTarget ? $t('projectDetail.deleteStageConfirm', { name: stageDeleteTarget.name }) : ''}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const target = stageDeleteTarget;
|
||||
stageDeleteTarget = null;
|
||||
if (target) await handleDeleteStage(target.id, target.name);
|
||||
}}
|
||||
oncancel={() => { stageDeleteTarget = null; }}
|
||||
/>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={accessListPickerOpen}
|
||||
items={accessListPickerItems}
|
||||
current={String(editAccessListId)}
|
||||
title={$t('settingsNpm.selectAccessList')}
|
||||
onselect={handleProjectAccessListSelect}
|
||||
onclose={() => { accessListPickerOpen = false; }}
|
||||
/>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={tagPickerOpen}
|
||||
items={tagPickerItems}
|
||||
current={deployTag}
|
||||
title={$t('projectDetail.selectTag')}
|
||||
placeholder={$t('projectDetail.searchTags')}
|
||||
onselect={handleTagSelect}
|
||||
onclose={() => { tagPickerOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
-471
@@ -1,471 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Stage, StageEnv } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
let stages = $state<Stage[]>([]);
|
||||
let selectedStageId = $state('');
|
||||
let envVars = $state<StageEnv[]>([]);
|
||||
let projectEnv = $state<Record<string, string>>({});
|
||||
let loading = $state(true);
|
||||
let envLoading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let newKey = $state('');
|
||||
let newValue = $state('');
|
||||
let newEncrypted = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state('');
|
||||
let editKey = $state('');
|
||||
let editValue = $state('');
|
||||
let editEncrypted = $state(false);
|
||||
|
||||
let envDeleteTarget = $state<string | null>(null);
|
||||
|
||||
// Project-level env editing
|
||||
let newProjectKey = $state('');
|
||||
let newProjectValue = $state('');
|
||||
let savingProject = $state(false);
|
||||
let editingProjectKey = $state('');
|
||||
let editProjectValue = $state('');
|
||||
let projectEnvDeleteTarget = $state<string | null>(null);
|
||||
|
||||
// $page.params.id is typed string | undefined because SvelteKit can't
|
||||
// statically prove the [id] segment is present, but inside this route file
|
||||
// it always is — assert non-null so call sites don't need their own guards.
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
|
||||
async function handleAddProjectEnv() {
|
||||
if (!newProjectKey.trim()) return;
|
||||
savingProject = true;
|
||||
try {
|
||||
const updated = { ...projectEnv, [newProjectKey.trim()]: newProjectValue };
|
||||
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
|
||||
projectEnv = updated;
|
||||
newProjectKey = '';
|
||||
newProjectValue = '';
|
||||
toasts.success($t('envEditor.envAdded'));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
|
||||
} finally {
|
||||
savingProject = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditProjectEnv(key: string) {
|
||||
editingProjectKey = key;
|
||||
editProjectValue = projectEnv[key] ?? '';
|
||||
}
|
||||
|
||||
async function handleUpdateProjectEnv() {
|
||||
if (!editingProjectKey) return;
|
||||
savingProject = true;
|
||||
try {
|
||||
const updated = { ...projectEnv, [editingProjectKey]: editProjectValue };
|
||||
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
|
||||
projectEnv = updated;
|
||||
editingProjectKey = '';
|
||||
toasts.success($t('envEditor.envUpdated'));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
|
||||
} finally {
|
||||
savingProject = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProjectEnv(key: string) {
|
||||
savingProject = true;
|
||||
try {
|
||||
const { [key]: _, ...rest } = projectEnv;
|
||||
await api.updateProject(projectId!, { env: JSON.stringify(rest) });
|
||||
projectEnv = rest;
|
||||
toasts.success($t('envEditor.envDeleted'));
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
|
||||
} finally {
|
||||
savingProject = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject() {
|
||||
if (stages.length === 0) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const detail = await api.getProject(projectId);
|
||||
stages = detail.stages ?? [];
|
||||
try {
|
||||
projectEnv = JSON.parse(detail.project.env || '{}');
|
||||
} catch {
|
||||
projectEnv = {};
|
||||
}
|
||||
if (stages.length > 0 && !selectedStageId) {
|
||||
selectedStageId = stages[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('envEditor.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStageEnv(stageId: string) {
|
||||
if (!stageId) return;
|
||||
envLoading = true;
|
||||
try {
|
||||
envVars = await api.listStageEnv(projectId, stageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.loadEnvFailed'));
|
||||
envVars = [];
|
||||
} finally {
|
||||
envLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (!newKey.trim() || !selectedStageId) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.createStageEnv(projectId, selectedStageId, {
|
||||
key: newKey.trim(),
|
||||
value: newValue,
|
||||
encrypted: newEncrypted
|
||||
});
|
||||
newKey = '';
|
||||
newValue = '';
|
||||
newEncrypted = false;
|
||||
toasts.success($t('envEditor.envAdded'));
|
||||
await loadStageEnv(selectedStageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(env: StageEnv) {
|
||||
editingId = env.id;
|
||||
editKey = env.key;
|
||||
editValue = env.encrypted ? '' : env.value;
|
||||
editEncrypted = env.encrypted;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = '';
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!editKey.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
const data: { key?: string; value?: string; encrypted?: boolean } = {
|
||||
key: editKey.trim(),
|
||||
encrypted: editEncrypted
|
||||
};
|
||||
if (editValue) {
|
||||
data.value = editValue;
|
||||
}
|
||||
await api.updateStageEnv(projectId, selectedStageId, editingId, data);
|
||||
editingId = '';
|
||||
toasts.success($t('envEditor.envUpdated'));
|
||||
await loadStageEnv(selectedStageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(envId: string) {
|
||||
try {
|
||||
await api.deleteStageEnv(projectId, selectedStageId, envId);
|
||||
toasts.success($t('envEditor.envDeleted'));
|
||||
await loadStageEnv(selectedStageId);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function isOverridden(key: string): boolean {
|
||||
return envVars.some((e) => e.key === key);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
untrack(() => loadProject());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sid = selectedStageId;
|
||||
if (sid) {
|
||||
untrack(() => loadStageEnv(sid));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('envEditor.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<ForgeHero
|
||||
backHref={`/projects/${projectId}`}
|
||||
eyebrowSuffix="ENV"
|
||||
title={$t('envEditor.title')}
|
||||
lede={$t('envEditor.description')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton width="16rem" height="2.5rem" />
|
||||
<Skeleton height="12rem" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Project-level env -->
|
||||
{#if stages.length === 0}
|
||||
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
|
||||
{:else}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
|
||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each Object.entries(projectEnv) as [key, val] (key)}
|
||||
{#if editingProjectKey === key}
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={editProjectValue} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5"></td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={savingProject} onclick={handleUpdateProjectEnv}><IconCheck size={16} /></button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { editingProjectKey = ''; }}><IconX size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors {isOverridden(key) ? 'opacity-50' : ''}">
|
||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{val}</td>
|
||||
<td class="px-4 py-2.5 text-sm">
|
||||
{#if isOverridden(key)}
|
||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridden')}</span>
|
||||
{:else}
|
||||
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEditProjectEnv(key)}><IconEdit size={16} /></button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { projectEnvDeleteTarget = key; }}><IconTrash size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Add new project env row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newProjectKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newProjectValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5"></td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||
disabled={!newProjectKey.trim() || savingProject}
|
||||
onclick={handleAddProjectEnv}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{savingProject ? $t('envEditor.adding') : $t('envEditor.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if Object.keys(projectEnv).length === 0}
|
||||
<p class="mt-2 text-center text-xs text-[var(--text-tertiary)]">{$t('envEditor.noProjectEnv')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stage-level overrides -->
|
||||
<div>
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2>
|
||||
<select
|
||||
id="stage-select"
|
||||
bind:value={selectedStageId}
|
||||
class="block w-48 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
||||
>
|
||||
{#each stages as stage (stage.id)}
|
||||
<option value={stage.id}>{stage.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if envLoading}
|
||||
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
|
||||
<IconLoader size={20} />
|
||||
<span class="text-sm">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.secret')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each envVars as env (env.id)}
|
||||
{#if editingId === env.id}
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={editKey} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type={editEncrypted ? 'password' : 'text'} bind:value={editValue} placeholder={env.encrypted ? $t('envEditor.leaveEmptyToKeep') : ''} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<ToggleSwitch bind:checked={editEncrypted} label={$t('envEditor.secret')} />
|
||||
</td>
|
||||
<td class="px-4 py-2.5"></td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate} title={$t('envEditor.save')}>
|
||||
<IconCheck size={16} />
|
||||
</button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit} title={$t('common.cancel')}>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{env.key}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{env.encrypted ? '••••••••' : env.value}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if env.encrypted}
|
||||
<span class="inline-flex items-center gap-1 rounded-full badge-purple rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
<IconLock size={12} />
|
||||
{$t('envEditor.secret')}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm">
|
||||
{#if env.key in projectEnv}
|
||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridesProject')}</span>
|
||||
{:else}
|
||||
<span class="rounded-full badge-info rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.stageOnly')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(env)} title={$t('envEditor.edit')}>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { envDeleteTarget = env.id; }} title={$t('envEditor.delete')}>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Add new row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type={newEncrypted ? 'password' : 'text'} bind:value={newValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<ToggleSwitch bind:checked={newEncrypted} label={$t('envEditor.secret')} />
|
||||
</td>
|
||||
<td class="px-4 py-2.5"></td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||
disabled={!newKey.trim() || saving}
|
||||
onclick={handleAdd}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{saving ? $t('envEditor.adding') : $t('envEditor.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={envDeleteTarget !== null}
|
||||
title={$t('envEditor.deleteTitle')}
|
||||
message={$t('envEditor.deleteMessage')}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const envId = envDeleteTarget;
|
||||
envDeleteTarget = null;
|
||||
if (envId) await handleDelete(envId);
|
||||
}}
|
||||
oncancel={() => { envDeleteTarget = null; }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={projectEnvDeleteTarget !== null}
|
||||
title={$t('envEditor.deleteTitle')}
|
||||
message={$t('envEditor.deleteMessage')}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const key = projectEnvDeleteTarget;
|
||||
projectEnvDeleteTarget = null;
|
||||
if (key) await handleDeleteProjectEnv(key);
|
||||
}}
|
||||
oncancel={() => { projectEnvDeleteTarget = null; }}
|
||||
/>
|
||||
@@ -1,324 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Volume, VolumeScopeInfo, VolumeScope } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconInfo } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
let volumes = $state<Volume[]>([]);
|
||||
let scopes = $state<VolumeScopeInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let newSource = $state('');
|
||||
let newTarget = $state('');
|
||||
let newScope = $state<VolumeScope>('project');
|
||||
let newName = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state('');
|
||||
let editSource = $state('');
|
||||
let editTarget = $state('');
|
||||
let editScope = $state<VolumeScope>('project');
|
||||
let editName = $state('');
|
||||
|
||||
let volumeDeleteTarget = $state<string | null>(null);
|
||||
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
|
||||
const newScopeNeedsName = $derived(scopes.find(s => s.scope === newScope)?.needs_name ?? false);
|
||||
const editScopeNeedsName = $derived(scopes.find(s => s.scope === editScope)?.needs_name ?? false);
|
||||
const newScopeIsEphemeral = $derived(newScope === 'ephemeral');
|
||||
const editScopeIsEphemeral = $derived(editScope === 'ephemeral');
|
||||
|
||||
function scopeColor(scope: string): string {
|
||||
switch (scope) {
|
||||
case 'instance': return 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
||||
case 'stage': return 'bg-cyan-50 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400';
|
||||
case 'project': return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'project_named': return 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400';
|
||||
case 'named': return 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
|
||||
case 'ephemeral': return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
|
||||
case 'absolute': return 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400';
|
||||
default: return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(scope: string): string {
|
||||
return scope.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
async function loadVolumes() {
|
||||
if (volumes.length === 0) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const [vols, scopeList] = await Promise.all([
|
||||
api.listVolumes(projectId),
|
||||
scopes.length === 0 ? api.listVolumeScopes() : Promise.resolve(scopes)
|
||||
]);
|
||||
volumes = vols;
|
||||
scopes = scopeList;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
if (newScope !== 'ephemeral' && !newSource.trim()) return;
|
||||
if (!newTarget.trim()) return;
|
||||
if (newScopeNeedsName && !newName.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.createVolume(projectId, {
|
||||
source: newSource.trim(),
|
||||
target: newTarget.trim(),
|
||||
scope: newScope,
|
||||
name: newScopeNeedsName ? newName.trim() : undefined
|
||||
});
|
||||
newSource = '';
|
||||
newTarget = '';
|
||||
newScope = 'project';
|
||||
newName = '';
|
||||
toasts.success($t('volumeEditor.volumeAdded'));
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(vol: Volume) {
|
||||
editingId = vol.id;
|
||||
editSource = vol.source;
|
||||
editTarget = vol.target;
|
||||
editScope = (vol.scope || 'project') as VolumeScope;
|
||||
editName = vol.name || '';
|
||||
}
|
||||
|
||||
function cancelEdit() { editingId = ''; }
|
||||
|
||||
async function handleUpdate() {
|
||||
if (editScope !== 'ephemeral' && !editSource.trim()) return;
|
||||
if (!editTarget.trim()) return;
|
||||
if (editScopeNeedsName && !editName.trim()) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.updateVolume(projectId, editingId, {
|
||||
source: editSource.trim(),
|
||||
target: editTarget.trim(),
|
||||
scope: editScope,
|
||||
name: editScopeNeedsName ? editName.trim() : undefined
|
||||
});
|
||||
editingId = '';
|
||||
toasts.success($t('volumeEditor.volumeUpdated'));
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(volId: string) {
|
||||
try {
|
||||
await api.deleteVolume(projectId, volId);
|
||||
toasts.success($t('volumeEditor.volumeDeleted'));
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
untrack(() => loadVolumes());
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('volumeEditor.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<ForgeHero
|
||||
backHref={`/projects/${projectId}`}
|
||||
eyebrowSuffix="VOLUMES"
|
||||
title={$t('volumeEditor.title')}
|
||||
lede={$t('volumeEditor.description')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<!-- Scope legend -->
|
||||
{#if scopes.length > 0 && !loading}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<IconInfo size={16} class="text-[var(--text-tertiary)]" />
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('volumeEditor.scopeGuide')}</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each scopes as scope}
|
||||
<div class="flex items-start gap-2 rounded-lg bg-[var(--surface-card-hover)] px-3 py-2">
|
||||
<span class="mt-0.5 inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(scope.scope)}">{scopeLabel(scope.scope)}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-[var(--text-secondary)]">{scope.description}</p>
|
||||
<p class="mt-0.5 font-mono text-[10px] text-[var(--text-tertiary)]">{scope.path_example}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="12rem" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadVolumes}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.scope')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.nameColumn')}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each volumes as vol (vol.id)}
|
||||
{#if editingId === vol.id}
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<td class="px-4 py-2.5">
|
||||
{#if editScopeIsEphemeral}
|
||||
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
|
||||
{:else}
|
||||
<input type="text" bind:value={editSource} placeholder={editScope === 'absolute' ? '/mnt/data' : 'uploads'} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<select bind:value={editScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
{#each scopes as s}
|
||||
<option value={s.scope}>{scopeLabel(s.scope)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if editScopeNeedsName}
|
||||
<input type="text" bind:value={editName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit}><IconX size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">
|
||||
{#if vol.scope === 'ephemeral'}
|
||||
<span class="italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
|
||||
{:else}
|
||||
{vol.source}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope)}">{scopeLabel(vol.scope)}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{vol.name || '—'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
|
||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { volumeDeleteTarget = vol.id; }} title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Add new row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<td class="px-4 py-2.5">
|
||||
{#if newScopeIsEphemeral}
|
||||
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
|
||||
{:else}
|
||||
<input type="text" bind:value={newSource} placeholder={newScope === 'absolute' ? '/mnt/nfs/data' : 'uploads'} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<select bind:value={newScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||
{#each scopes as s}
|
||||
<option value={s.scope}>{scopeLabel(s.scope)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#if newScopeNeedsName}
|
||||
<input type="text" bind:value={newName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||
disabled={(!newScopeIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newScopeNeedsName && !newName.trim()) || saving}
|
||||
onclick={handleAdd}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{saving ? $t('volumeEditor.adding') : $t('volumeEditor.add')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if volumes.length === 0}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={volumeDeleteTarget !== null}
|
||||
title={$t('volumeEditor.deleteTitle')}
|
||||
message={$t('volumeEditor.deleteMessage')}
|
||||
confirmLabel={$t('common.delete')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={async () => {
|
||||
const volId = volumeDeleteTarget;
|
||||
volumeDeleteTarget = null;
|
||||
if (volId) await handleDelete(volId);
|
||||
}}
|
||||
oncancel={() => { volumeDeleteTarget = null; }}
|
||||
/>
|
||||
@@ -1,233 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { FileEntry } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconLoader, IconChevronRight } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
const volId = $derived($page.params.volId ?? '');
|
||||
|
||||
let entries = $state<FileEntry[]>([]);
|
||||
let currentPath = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let uploading = $state(false);
|
||||
|
||||
// Query params for instance/stage scoped volumes.
|
||||
const stage = $derived($page.url.searchParams.get('stage') ?? '');
|
||||
const tag = $derived($page.url.searchParams.get('tag') ?? '');
|
||||
|
||||
const breadcrumbs = $derived(() => {
|
||||
if (!currentPath) return [];
|
||||
return currentPath.split('/').filter(Boolean);
|
||||
});
|
||||
|
||||
function fileIcon(entry: FileEntry): string {
|
||||
if (entry.is_dir) return '📁';
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const icons: Record<string, string> = {
|
||||
jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
||||
txt: '📄', md: '📄', log: '📄', csv: '📄',
|
||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋', xml: '📋',
|
||||
js: '📜', ts: '📜', go: '📜', py: '📜', rs: '📜', sh: '📜',
|
||||
zip: '📦', tar: '📦', gz: '📦', rar: '📦',
|
||||
db: '🗄️', sqlite: '🗄️', sql: '🗄️',
|
||||
};
|
||||
return icons[ext] ?? '📄';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let size = bytes;
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
async function loadDir(path: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await api.browseVolume(projectId, volId, { path, stage, tag });
|
||||
entries = result.entries;
|
||||
currentPath = result.path || '';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('volumeBrowser.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
loadDir(path);
|
||||
}
|
||||
|
||||
function navigateToBreadcrumb(index: number) {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const path = parts.slice(0, index + 1).join('/');
|
||||
navigateTo(path);
|
||||
}
|
||||
|
||||
function handleEntryClick(entry: FileEntry) {
|
||||
if (entry.is_dir) {
|
||||
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||
navigateTo(newPath);
|
||||
} else {
|
||||
// Download single file.
|
||||
const filePath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||
window.open(api.volumeDownloadUrl(projectId, volId, { path: filePath, stage, tag }), '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
window.open(api.volumeDownloadUrl(projectId, volId, { path: currentPath, stage, tag }), '_blank');
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
async function handleUpload() {
|
||||
if (!fileInput.files?.length) return;
|
||||
uploading = true;
|
||||
try {
|
||||
const result = await api.uploadToVolume(projectId, volId, fileInput.files, { path: currentPath, stage, tag });
|
||||
toasts.success(`${$t('volumeBrowser.uploaded')} ${result.count} ${$t('volumeBrowser.files')}`);
|
||||
fileInput.value = '';
|
||||
await loadDir(currentPath);
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('volumeBrowser.uploadFailed'));
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
void volId;
|
||||
loadDir('');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('volumeBrowser.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#snippet browserToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={downloadCurrent}
|
||||
>
|
||||
📦 {currentPath ? $t('volumeBrowser.downloadFolder') : $t('volumeBrowser.downloadAll')}
|
||||
</button>
|
||||
<label
|
||||
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors {uploading ? 'opacity-50 pointer-events-none' : ''}"
|
||||
>
|
||||
{#if uploading}
|
||||
<IconLoader size={14} class="animate-spin" />
|
||||
{/if}
|
||||
{$t('volumeBrowser.upload')}
|
||||
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
|
||||
</label>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref={`/projects/${projectId}/volumes`}
|
||||
eyebrowSuffix="VOLUME BROWSER"
|
||||
title={$t('volumeBrowser.title')}
|
||||
size="lg"
|
||||
toolbar={browserToolbar}
|
||||
/>
|
||||
|
||||
<!-- Path breadcrumbs -->
|
||||
<nav class="flex items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {currentPath === '' ? 'font-semibold' : ''}"
|
||||
onclick={() => navigateTo('')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{#each breadcrumbs() as segment, i}
|
||||
<IconChevronRight size={12} class="text-[var(--text-tertiary)]" />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {i === breadcrumbs().length - 1 ? 'font-semibold text-[var(--text-primary)]' : ''}"
|
||||
onclick={() => navigateToBreadcrumb(i)}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton height="16rem" />
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={() => loadDir(currentPath)}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if entries.length === 0}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('volumeBrowser.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.name')}</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.size')}</th>
|
||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.modified')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#if currentPath}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] cursor-pointer transition-colors" onclick={() => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
navigateTo(parts.join('/'));
|
||||
}}>
|
||||
<td class="px-4 py-2 text-sm text-[var(--text-link)]">
|
||||
<span class="mr-2">📁</span>..
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#each entries.sort((a, b) => {
|
||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}) as entry (entry.name)}
|
||||
<tr
|
||||
class="hover:bg-[var(--surface-card-hover)] transition-colors {entry.is_dir ? 'cursor-pointer' : ''}"
|
||||
onclick={() => handleEntryClick(entry)}
|
||||
>
|
||||
<td class="px-4 py-2 text-sm text-[var(--text-primary)]">
|
||||
<span class="mr-2">{fileIcon(entry)}</span>
|
||||
{#if entry.is_dir}
|
||||
<span class="text-[var(--text-link)]">{entry.name}</span>
|
||||
{:else}
|
||||
{entry.name}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
{entry.is_dir ? '—' : formatSize(entry.size)}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-[var(--text-tertiary)]">
|
||||
{$fmt.compact(entry.mod_time)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
export const ssr = false;
|
||||
@@ -48,8 +48,13 @@
|
||||
: 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)] dark:text-[var(--color-brand-200)]';
|
||||
}
|
||||
|
||||
// Legacy /projects/{id} and /sites/{id} routes were retired with the
|
||||
// hard cutover. Proxy rows now point at the workload-first containers
|
||||
// page filtered by name; the app deep-link is not available because
|
||||
// proxy_route rows don't carry an app_id today.
|
||||
function targetHref(route: ProxyRoute): string {
|
||||
return route.source === 'static_site' ? `/sites/${route.instance_id}` : `/projects/${route.project_id}`;
|
||||
const q = encodeURIComponent(route.project_name ?? '');
|
||||
return q ? `/containers?q=${q}` : '/containers';
|
||||
}
|
||||
|
||||
async function loadRoutes() {
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { StaticSite } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { IconPlus, IconSearch, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
|
||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
|
||||
let sites = $state<StaticSite[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let deploying = $state<Record<string, boolean>>({});
|
||||
let confirmDelete = $state<StaticSite | null>(null);
|
||||
|
||||
const filteredSites = $derived(
|
||||
searchQuery.trim()
|
||||
? sites.filter(s => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
return s.name.toLowerCase().includes(q)
|
||||
|| s.domain.toLowerCase().includes(q)
|
||||
|| s.repo_name.toLowerCase().includes(q);
|
||||
})
|
||||
: sites
|
||||
);
|
||||
|
||||
async function loadSites() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
sites = await api.listStaticSites();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load sites';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy(site: StaticSite) {
|
||||
deploying = { ...deploying, [site.id]: true };
|
||||
try {
|
||||
await api.deployStaticSite(site.id);
|
||||
// Refresh after a short delay to pick up status change.
|
||||
setTimeout(() => loadSites(), 2000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Deploy failed';
|
||||
} finally {
|
||||
deploying = { ...deploying, [site.id]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(site: StaticSite) {
|
||||
try {
|
||||
await api.stopStaticSite(site.id);
|
||||
setTimeout(() => loadSites(), 2000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Stop failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(site: StaticSite) {
|
||||
try {
|
||||
await api.startStaticSite(site.id);
|
||||
setTimeout(() => loadSites(), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Start failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try {
|
||||
await api.deleteStaticSite(id);
|
||||
await loadSites();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string): { text: string; class: string } {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing':
|
||||
return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
default:
|
||||
return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
}
|
||||
|
||||
function modeBadge(mode: string): { text: string; class: string } {
|
||||
if (mode === 'deno') {
|
||||
return { text: 'Deno', class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' };
|
||||
}
|
||||
return { text: 'Static', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadSites();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('sites.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#snippet heroToolbar()}
|
||||
<a href="/sites/new" class="forge-btn">
|
||||
<IconPlus size={14} />
|
||||
<span>{$t('sites.addSite')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrowSuffix="SITES"
|
||||
title={$t('sites.title')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<SkeletonTable rows={4} cols={5} />
|
||||
{:else if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadSites}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if sites.length === 0}
|
||||
<EmptyState
|
||||
title={$t('sites.noSites')}
|
||||
description={$t('sites.noSitesDesc')}
|
||||
actionLabel={$t('sites.addSite')}
|
||||
onaction={() => { window.location.href = '/sites/new'; }}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={$t('sites.searchPlaceholder')}
|
||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if filteredSites.length === 0}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noMatching')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.name')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.domain')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.mode')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.status')}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.lastSync')}</th>
|
||||
<th class="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||
{#each filteredSites as site (site.id)}
|
||||
{@const status = statusBadge(site.status)}
|
||||
{@const mode = modeBadge(site.mode)}
|
||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<a href="/sites/{site.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
{site.name}
|
||||
</a>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name}</p>
|
||||
</td>
|
||||
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm">
|
||||
{#if site.domain}
|
||||
<a href="https://{site.domain}" target="_blank" rel="noopener noreferrer" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
||||
{site.domain}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-[var(--text-tertiary)]">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {mode.class}">
|
||||
{mode.text}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {status.class}">
|
||||
{status.text}
|
||||
</span>
|
||||
{#if site.error}
|
||||
<p class="mt-0.5 max-w-[200px] truncate text-xs text-red-500" title={site.error}>{site.error}</p>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
||||
{#if site.last_sync_at}
|
||||
{$fmt.dateTime(site.last_sync_at)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
title={$t('sites.deploy')}
|
||||
disabled={deploying[site.id]}
|
||||
onclick={() => handleDeploy(site)}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<IconRefresh size={16} class={deploying[site.id] ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
{#if site.status === 'stopped'}
|
||||
<button
|
||||
type="button"
|
||||
title={$t('sites.start')}
|
||||
onclick={() => handleStart(site)}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconPlay size={16} />
|
||||
</button>
|
||||
{:else if site.status === 'deployed'}
|
||||
<button
|
||||
type="button"
|
||||
title={$t('sites.stop')}
|
||||
onclick={() => handleStop(site)}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconStop size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
title={$t('common.delete')}
|
||||
onclick={() => { confirmDelete = site; }}
|
||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirmDelete}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title={$t('sites.confirmDelete')}
|
||||
message={`${$t('sites.confirmDeleteMsg')} "${confirmDelete.name}"?`}
|
||||
confirmLabel={$t('common.delete')}
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = null; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,484 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { StaticSite, StaticSiteSecret, StaticSiteStorageUsage } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
||||
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
|
||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
||||
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
||||
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
||||
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
|
||||
|
||||
let site = $state<StaticSite | null>(null);
|
||||
let secrets = $state<StaticSiteSecret[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let deploying = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let confirmDeleteSecretId = $state<string | null>(null);
|
||||
|
||||
// Outgoing notification URL inline editor. The site has no full edit
|
||||
// form on this page; this small input lets operators set/clear the
|
||||
// per-site URL without going back to the create wizard.
|
||||
let editNotificationUrl = $state('');
|
||||
let savingNotificationUrl = $state(false);
|
||||
|
||||
async function saveNotificationUrl() {
|
||||
if (!site) return;
|
||||
savingNotificationUrl = true;
|
||||
try {
|
||||
await api.updateStaticSite(site.id, { notification_url: editNotificationUrl.trim() });
|
||||
site = { ...site, notification_url: editNotificationUrl.trim() };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save notification URL';
|
||||
} finally {
|
||||
savingNotificationUrl = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the editor with the loaded site once it arrives.
|
||||
$effect(() => {
|
||||
if (site && editNotificationUrl === '') {
|
||||
editNotificationUrl = site.notification_url ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
// Secret form.
|
||||
let showSecretForm = $state(false);
|
||||
let secretKey = $state('');
|
||||
let secretValue = $state('');
|
||||
let secretEncrypted = $state(true);
|
||||
let secretSubmitting = $state(false);
|
||||
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
|
||||
let showLogs = $state(false);
|
||||
|
||||
const siteId = $derived($page.params.id);
|
||||
|
||||
async function loadSite() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
site = await api.getStaticSite(siteId!);
|
||||
secrets = await api.listStaticSiteSecrets(siteId!);
|
||||
if (site.storage_enabled) {
|
||||
storageUsage = await api.getStaticSiteStorage(siteId!);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load site';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
if (!site) return;
|
||||
deploying = true;
|
||||
try {
|
||||
await api.deployStaticSite(site.id);
|
||||
setTimeout(() => loadSite(), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Deploy failed';
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!site) return;
|
||||
try {
|
||||
await api.stopStaticSite(site.id);
|
||||
setTimeout(() => loadSite(), 2000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Stop failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (!site) return;
|
||||
try {
|
||||
await api.startStaticSite(site.id);
|
||||
setTimeout(() => loadSite(), 3000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Start failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!site) return;
|
||||
confirmDelete = false;
|
||||
try {
|
||||
await api.deleteStaticSite(site.id);
|
||||
goto('/sites');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSecret() {
|
||||
if (!site || !secretKey.trim()) return;
|
||||
secretSubmitting = true;
|
||||
try {
|
||||
await api.createStaticSiteSecret(site.id, {
|
||||
key: secretKey.trim(),
|
||||
value: secretValue,
|
||||
encrypted: secretEncrypted
|
||||
});
|
||||
secretKey = '';
|
||||
secretValue = '';
|
||||
secretEncrypted = true;
|
||||
showSecretForm = false;
|
||||
secrets = await api.listStaticSiteSecrets(site.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add secret';
|
||||
} finally {
|
||||
secretSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSecret() {
|
||||
if (!site || !confirmDeleteSecretId) return;
|
||||
try {
|
||||
await api.deleteStaticSiteSecret(site.id, confirmDeleteSecretId);
|
||||
secrets = await api.listStaticSiteSecrets(site.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete secret';
|
||||
} finally {
|
||||
confirmDeleteSecretId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string): { text: string; class: string } {
|
||||
switch (status) {
|
||||
case 'deployed': return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing': return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed': return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
default: return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void siteId;
|
||||
loadSite();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{site?.name ?? $t('sites.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center gap-2 py-8">
|
||||
<IconLoader size={20} class="animate-spin text-[var(--text-tertiary)]" />
|
||||
<span class="text-[var(--text-tertiary)]">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{:else if error && !site}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
</div>
|
||||
{:else if site}
|
||||
{@const s = site}
|
||||
{#snippet siteToolbar()}
|
||||
<button
|
||||
type="button"
|
||||
disabled={deploying}
|
||||
onclick={handleDeploy}
|
||||
class="forge-btn"
|
||||
>
|
||||
<IconRefresh size={14} class={deploying ? 'animate-spin' : ''} />
|
||||
<span>{$t('sites.deploy')}</span>
|
||||
</button>
|
||||
{#if s.status === 'stopped'}
|
||||
<button type="button" onclick={handleStart} class="forge-btn-ghost">
|
||||
<IconPlay size={14} />
|
||||
<span>{$t('sites.start')}</span>
|
||||
</button>
|
||||
{:else if s.status === 'deployed'}
|
||||
<button type="button" onclick={handleStop} class="forge-btn-ghost">
|
||||
<IconStop size={14} />
|
||||
<span>{$t('sites.stop')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if s.domain}
|
||||
<a
|
||||
href="https://{s.domain}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="forge-btn-ghost"
|
||||
>
|
||||
<IconGlobe size={14} />
|
||||
<span>{$t('sites.openSite')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { confirmDelete = true; }}
|
||||
class="forge-btn-icon forge-btn-danger"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref="/sites"
|
||||
eyebrowSuffix="SITE"
|
||||
title={s.name}
|
||||
kicker="{s.repo_owner}/{s.repo_name} · {s.branch}"
|
||||
size="lg"
|
||||
toolbar={siteToolbar}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status & Info -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Site Info -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.siteInfo')}</h2>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.status')}</span>
|
||||
<span>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusBadge(site.status).class}">{statusBadge(site.status).text}</span>
|
||||
</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.domain || '-'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.folder_path || '/ (root)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.lastSync')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.last_sync_at ? $fmt.dateTime(site.last_sync_at) : '-'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.commitSha')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}</span>
|
||||
|
||||
{#if site.mode === 'deno' && site.storage_enabled}
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.dataPath')}</span>
|
||||
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if site.error}
|
||||
<div class="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-3">
|
||||
<p class="text-xs text-red-600 dark:text-red-400">{site.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secrets -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('sites.secrets')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showSecretForm = !showSecretForm; }}
|
||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
{$t('sites.addSecret')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showSecretForm}
|
||||
<div class="mb-4 space-y-3 rounded-lg bg-[var(--surface-card-hover)] p-4">
|
||||
<FormField label={$t('sites.secretKey')} name="secretKey" bind:value={secretKey} placeholder="API_KEY" required />
|
||||
<FormField label={$t('sites.secretValue')} name="secretValue" bind:value={secretValue} placeholder="sk-..." />
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<ToggleSwitch bind:checked={secretEncrypted} label={$t('sites.encryptSecret')} />
|
||||
<span>{$t('sites.encryptSecret')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!secretKey.trim() || secretSubmitting}
|
||||
onclick={handleAddSecret}
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{secretSubmitting ? $t('common.saving') : $t('sites.saveSecret')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if secrets.length === 0}
|
||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noSecrets')}</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each secrets as secret (secret.id)}
|
||||
<div class="flex items-center justify-between rounded-lg border border-[var(--border-secondary)] px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if secret.encrypted}
|
||||
<IconLock size={14} class="text-[var(--text-tertiary)]" />
|
||||
{:else}
|
||||
<IconUnlock size={14} class="text-[var(--text-tertiary)]" />
|
||||
{/if}
|
||||
<span class="font-mono text-sm text-[var(--text-primary)]">{secret.key}</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{secret.value}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { confirmDeleteSecretId = secret.id; }}
|
||||
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Resource usage + logs for deployed sites. -->
|
||||
{#if site.container_id}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('resources.sectionTitle')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showLogs = !showLogs; }}
|
||||
class="rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
>
|
||||
{showLogs ? $t('resources.hideLogs') : $t('resources.showLogs')}
|
||||
</button>
|
||||
</div>
|
||||
<ContainerStats source={{ kind: 'site', siteId: site.id }} />
|
||||
</div>
|
||||
|
||||
{#if showLogs}
|
||||
<ContainerLogs
|
||||
source={{ kind: 'site', siteId: site.id }}
|
||||
onclose={() => { showLogs = false; }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Webhook (inbound: triggers a re-sync from the Git provider). -->
|
||||
<WebhookPanel
|
||||
title={$t('sites.webhookTitle')}
|
||||
description={$t('sites.webhookDesc')}
|
||||
fetchWebhook={() => api.getStaticSiteWebhook(siteId!)}
|
||||
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
||||
regenerateSigningSecret={() => api.regenerateStaticSiteSigningSecret(siteId!)}
|
||||
disableSigning={() => api.disableStaticSiteSigningSecret(siteId!)}
|
||||
setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)}
|
||||
/>
|
||||
|
||||
<!-- Recent inbound webhook activity (debug + audit). -->
|
||||
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listStaticSiteWebhookDeliveries(siteId!, signal)} />
|
||||
|
||||
<!-- Outgoing notification URL (per-site override; falls through to global). -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('sites.outgoingUrlDesc')}</p>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<FormField
|
||||
label=""
|
||||
name="siteNotificationUrl"
|
||||
bind:value={editNotificationUrl}
|
||||
placeholder="https://notify.example.com/webhook"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveNotificationUrl}
|
||||
disabled={savingNotificationUrl || editNotificationUrl === (site.notification_url ?? '')}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if savingNotificationUrl}<IconLoader size={16} />{/if}
|
||||
{$t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge posts site_sync_* events). -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('sites.outgoingWebhookTitle')}
|
||||
description={$t('sites.outgoingWebhookDesc')}
|
||||
hasUrl={!!site.notification_url}
|
||||
fallbackLabel={$t('sites.outgoingFallbackGlobal')}
|
||||
fetchSecret={() => api.getStaticSiteNotificationSecret(siteId!)}
|
||||
regenerateSecret={() => api.regenerateStaticSiteNotificationSecret(siteId!)}
|
||||
disableSigning={() => api.disableStaticSiteNotificationSigning(siteId!)}
|
||||
sendTest={() => api.testStaticSiteNotification(siteId!)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Storage -->
|
||||
{#if site.storage_enabled && site.mode === 'deno'}
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
||||
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.storage')}</h2>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageVolume')}</span>
|
||||
<span class="font-mono text-xs text-[var(--text-primary)]">tinyforge-site-{site.name}-data</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageMountPath')}</span>
|
||||
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageLimit')}</span>
|
||||
<span class="text-[var(--text-primary)]">{site.storage_limit_mb > 0 ? `${site.storage_limit_mb} MB` : $t('sites.unlimited')}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageUsed')}</span>
|
||||
<span class="text-[var(--text-primary)]">
|
||||
{#if storageUsage}
|
||||
{storageUsage.used_bytes < 1024 ? `${storageUsage.used_bytes} B` : storageUsage.used_bytes < 1048576 ? `${(storageUsage.used_bytes / 1024).toFixed(1)} KB` : `${(storageUsage.used_bytes / 1048576).toFixed(1)} MB`}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if storageUsage && site.storage_limit_mb > 0}
|
||||
{@const pct = Math.min(100, (storageUsage.used_bytes / (site.storage_limit_mb * 1048576)) * 100)}
|
||||
<div class="mt-4">
|
||||
<div class="h-2 rounded-full bg-[var(--surface-card-hover)] overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'}"
|
||||
style="width: {pct.toFixed(1)}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">{pct.toFixed(1)}% {$t('sites.storageOfLimit')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if confirmDelete}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title={$t('sites.confirmDelete')}
|
||||
message={`${$t('sites.confirmDeleteMsg')} "${site?.name}"?`}
|
||||
confirmLabel={$t('common.delete')}
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if confirmDeleteSecretId}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDeleteSecretId}
|
||||
title={$t('sites.confirmDeleteSecret')}
|
||||
message={`${$t('sites.confirmDeleteSecretMsg')} "${secrets.find(s => s.id === confirmDeleteSecretId)?.key}"?`}
|
||||
confirmLabel={$t('common.delete')}
|
||||
onconfirm={handleDeleteSecret}
|
||||
oncancel={() => { confirmDeleteSecretId = null; }}
|
||||
/>
|
||||
{/if}
|
||||
@@ -1,702 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { FolderEntry, GitProvider } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { IconCheck, IconLoader, IconChevronRight, IconSearch } from '$lib/components/icons';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import type { EntityPickerItem } from '$lib/types';
|
||||
|
||||
// Provider options.
|
||||
const providerOptions: { value: GitProvider; label: string }[] = [
|
||||
{ value: '', label: 'Auto-detect' },
|
||||
{ value: 'gitea', label: 'Gitea / Forgejo / Gogs' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'gitlab', label: 'GitLab' },
|
||||
];
|
||||
|
||||
// Wizard state.
|
||||
let step = $state(1);
|
||||
const totalSteps = 5;
|
||||
|
||||
// Step 1: Repo URL.
|
||||
let fullRepoUrl = $state('');
|
||||
let provider = $state<GitProvider>('');
|
||||
let detectedProvider = $state<GitProvider>('');
|
||||
let detecting = $state(false);
|
||||
let giteaUrl = $state('');
|
||||
let repoOwner = $state('');
|
||||
let repoName = $state('');
|
||||
let accessToken = $state('');
|
||||
let connectionTested = $state(false);
|
||||
let connectionError = $state('');
|
||||
let testing = $state(false);
|
||||
|
||||
// Repo picker.
|
||||
let showRepoPicker = $state(false);
|
||||
let repoPickerItems = $state<EntityPickerItem[]>([]);
|
||||
let repoPickerLoading = $state(false);
|
||||
|
||||
// The effective provider (explicit selection or autodetected).
|
||||
const effectiveProvider = $derived(provider || detectedProvider || 'gitea');
|
||||
|
||||
// Step 2: Branch picker.
|
||||
let branches = $state<string[]>([]);
|
||||
let selectedBranch = $state('');
|
||||
let branchesLoading = $state(false);
|
||||
let showBranchPicker = $state(false);
|
||||
|
||||
// Step 3: Folder picker.
|
||||
let tree = $state<FolderEntry[]>([]);
|
||||
let selectedFolder = $state('');
|
||||
let treeLoading = $state(false);
|
||||
let expandedDirs = $state<Set<string>>(new Set());
|
||||
|
||||
// Step 4: Configuration.
|
||||
let siteName = $state('');
|
||||
let domain = $state('');
|
||||
let mode = $state<'static' | 'deno'>('static');
|
||||
let renderMarkdown = $state(false);
|
||||
let syncTrigger = $state<'push' | 'tag' | 'manual'>('manual');
|
||||
let tagPattern = $state('');
|
||||
let storageEnabled = $state(false);
|
||||
let storageLimitStr = $state('0');
|
||||
|
||||
// Step 5: Review + submit.
|
||||
let submitting = $state(false);
|
||||
let submitError = $state('');
|
||||
|
||||
// Parse repo URL into components and autodetect provider.
|
||||
function parseRepoUrl(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length >= 2) {
|
||||
giteaUrl = `${parsed.protocol}//${parsed.host}`;
|
||||
repoOwner = pathParts[0];
|
||||
repoName = pathParts[1];
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL yet.
|
||||
}
|
||||
}
|
||||
|
||||
async function browseRepos() {
|
||||
if (!giteaUrl) return;
|
||||
showRepoPicker = true;
|
||||
if (repoPickerItems.length > 0) return;
|
||||
|
||||
repoPickerLoading = true;
|
||||
try {
|
||||
await autoDetectProvider();
|
||||
const repos = await api.listStaticSiteRepos({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
});
|
||||
repoPickerItems = repos.map(r => ({
|
||||
value: JSON.stringify({ owner: r.owner, name: r.name }),
|
||||
label: r.full_name,
|
||||
description: r.description || undefined,
|
||||
icon: r.private ? 'lock' : undefined,
|
||||
}));
|
||||
} catch {
|
||||
repoPickerItems = [];
|
||||
} finally {
|
||||
repoPickerLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectPickedRepo(value: string) {
|
||||
const parsed = JSON.parse(value) as { owner: string; name: string };
|
||||
repoOwner = parsed.owner;
|
||||
repoName = parsed.name;
|
||||
showRepoPicker = false;
|
||||
}
|
||||
|
||||
async function autoDetectProvider() {
|
||||
if (!giteaUrl || provider) return; // skip if manually selected
|
||||
detecting = true;
|
||||
try {
|
||||
const result = await api.detectStaticSiteProvider(giteaUrl);
|
||||
detectedProvider = result.provider;
|
||||
} catch {
|
||||
detectedProvider = 'gitea';
|
||||
} finally {
|
||||
detecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing = true;
|
||||
connectionError = '';
|
||||
connectionTested = false;
|
||||
try {
|
||||
// Autodetect provider if not manually set.
|
||||
await autoDetectProvider();
|
||||
|
||||
await api.testStaticSiteConnection({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName
|
||||
});
|
||||
connectionTested = true;
|
||||
} catch (e) {
|
||||
connectionError = e instanceof Error ? e.message : 'Connection failed';
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBranches() {
|
||||
branchesLoading = true;
|
||||
try {
|
||||
branches = await api.listStaticSiteBranches({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName
|
||||
});
|
||||
if (branches.length > 0 && !selectedBranch) {
|
||||
// Default to main/master if available.
|
||||
selectedBranch = branches.find(b => b === 'main') ?? branches.find(b => b === 'master') ?? branches[0];
|
||||
}
|
||||
} catch {
|
||||
branches = [];
|
||||
} finally {
|
||||
branchesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
treeLoading = true;
|
||||
try {
|
||||
tree = await api.listStaticSiteTree({
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
access_token: accessToken || undefined,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName,
|
||||
branch: selectedBranch
|
||||
});
|
||||
} catch {
|
||||
tree = [];
|
||||
} finally {
|
||||
treeLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToStep(s: number) {
|
||||
step = s;
|
||||
if (s === 2 && branches.length === 0) loadBranches();
|
||||
if (s === 3 && tree.length === 0) loadTree();
|
||||
if (s === 4) {
|
||||
if (!siteName) siteName = repoName;
|
||||
// Autodetect Deno mode: check if selected folder has an api/ subdirectory.
|
||||
const apiPrefix = selectedFolder ? selectedFolder + '/api' : 'api';
|
||||
const hasApi = tree.some(e => e.is_dir && (e.path === apiPrefix || e.path.startsWith(apiPrefix + '/')));
|
||||
if (hasApi) {
|
||||
mode = 'deno';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tree helpers.
|
||||
const folders = $derived(tree.filter(e => e.is_dir).sort((a, b) => a.path.localeCompare(b.path)));
|
||||
|
||||
function getTopLevelFolders(): FolderEntry[] {
|
||||
return folders.filter(f => !f.path.includes('/'));
|
||||
}
|
||||
|
||||
function getChildFolders(parentPath: string): FolderEntry[] {
|
||||
return folders.filter(f => {
|
||||
if (!f.path.startsWith(parentPath + '/')) return false;
|
||||
const rest = f.path.slice(parentPath.length + 1);
|
||||
return !rest.includes('/');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDir(path: string) {
|
||||
const next = new Set(expandedDirs);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
expandedDirs = next;
|
||||
}
|
||||
|
||||
function selectFolder(path: string) {
|
||||
selectedFolder = path;
|
||||
}
|
||||
|
||||
// Branch picker items.
|
||||
const branchPickerItems = $derived<EntityPickerItem[]>(
|
||||
branches.map(b => ({ value: b, label: b }))
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting = true;
|
||||
submitError = '';
|
||||
try {
|
||||
const site = await api.createStaticSite({
|
||||
name: siteName,
|
||||
provider: effectiveProvider,
|
||||
gitea_url: giteaUrl,
|
||||
repo_owner: repoOwner,
|
||||
repo_name: repoName,
|
||||
branch: selectedBranch,
|
||||
folder_path: selectedFolder,
|
||||
access_token: accessToken || undefined,
|
||||
domain: domain || undefined,
|
||||
mode,
|
||||
render_markdown: renderMarkdown,
|
||||
sync_trigger: syncTrigger,
|
||||
tag_pattern: syncTrigger === 'tag' ? tagPattern : undefined,
|
||||
storage_enabled: storageEnabled,
|
||||
storage_limit_mb: parseInt(storageLimitStr, 10) || 0
|
||||
});
|
||||
goto(`/sites/${site.id}`);
|
||||
} catch (e) {
|
||||
submitError = e instanceof Error ? e.message : 'Failed to create site';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('sites.newSite')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<ForgeHero
|
||||
backHref="/sites"
|
||||
eyebrowSuffix="NEW SITE"
|
||||
title={$t('sites.newSite')}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#each Array(totalSteps) as _, i}
|
||||
<div class="h-1.5 flex-1 rounded-full transition-colors {i < step ? 'bg-[var(--color-brand-600)]' : 'bg-[var(--border-primary)]'}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
|
||||
<!-- Step 1: Repository -->
|
||||
{#if step === 1}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step1Title')}</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Provider selector -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.provider')}</label>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#each providerOptions as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border px-3 py-2 text-sm font-medium transition-colors {provider === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { provider = opt.value; detectedProvider = ''; }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if provider === '' && detectedProvider}
|
||||
<p class="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{$t('sites.detectedProvider')}: {providerOptions.find(o => o.value === detectedProvider)?.label ?? detectedProvider}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Paste full URL for auto-fill -->
|
||||
<FormField
|
||||
label={$t('sites.fullRepoUrl')}
|
||||
name="fullRepoUrl"
|
||||
bind:value={fullRepoUrl}
|
||||
placeholder="https://git.example.com/owner/repo"
|
||||
helpText={$t('sites.fullRepoUrlHelp')}
|
||||
oninput={(e) => {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
if (val.includes('/') && val.startsWith('http')) {
|
||||
parseRepoUrl(val);
|
||||
autoDetectProvider();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Individual fields (auto-filled or manual) -->
|
||||
<FormField label={$t('sites.serverUrl')} name="serverUrl" bind:value={giteaUrl} placeholder="https://git.example.com" required />
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label={$t('sites.repoOwner')} name="repoOwner" bind:value={repoOwner} placeholder="username" required />
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<FormField label={$t('sites.repoName')} name="repoName" bind:value={repoName} placeholder="my-app" required />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={browseRepos}
|
||||
title={$t('sites.browseRepos')}
|
||||
disabled={!giteaUrl}
|
||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{#if repoPickerLoading}
|
||||
<IconLoader size={16} class="animate-spin" />
|
||||
{:else}
|
||||
<IconSearch size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EntityPicker
|
||||
bind:open={showRepoPicker}
|
||||
items={repoPickerItems}
|
||||
current={repoOwner && repoName ? JSON.stringify({ owner: repoOwner, name: repoName }) : ''}
|
||||
title={$t('sites.selectRepo')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={selectPickedRepo}
|
||||
onclose={() => { showRepoPicker = false; }}
|
||||
/>
|
||||
<FormField
|
||||
label={$t('sites.accessToken')}
|
||||
name="accessToken"
|
||||
type="password"
|
||||
bind:value={accessToken}
|
||||
placeholder={$t('sites.accessTokenPlaceholder')}
|
||||
helpText={$t('sites.accessTokenHelp')}
|
||||
/>
|
||||
|
||||
{#if connectionError}
|
||||
<div class="rounded-lg bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{connectionError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionTested}
|
||||
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 p-3 flex items-center gap-2">
|
||||
<IconCheck size={16} class="text-emerald-600" />
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-400">{$t('sites.connectionSuccess')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
disabled={!giteaUrl || !repoOwner || !repoName || testing}
|
||||
onclick={testConnection}
|
||||
>
|
||||
{#if testing}
|
||||
<IconLoader size={14} class="inline mr-1 animate-spin" />
|
||||
{/if}
|
||||
{$t('sites.testConnection')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={!connectionTested}
|
||||
onclick={() => goToStep(2)}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Branch -->
|
||||
{:else if step === 2}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step2Title')}</h2>
|
||||
|
||||
{#if branchesLoading}
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
|
||||
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingBranches')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectBranch')}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left rounded-lg border border-[var(--border-primary)] px-4 py-3 text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||
onclick={() => { showBranchPicker = true; }}
|
||||
>
|
||||
<span class="font-medium text-[var(--text-primary)]">{selectedBranch || $t('sites.chooseBranch')}</span>
|
||||
</button>
|
||||
<EntityPicker
|
||||
bind:open={showBranchPicker}
|
||||
items={branchPickerItems}
|
||||
current={selectedBranch}
|
||||
title={$t('sites.selectBranch')}
|
||||
placeholder={$t('entityPicker.search')}
|
||||
onselect={(val) => { selectedBranch = val; showBranchPicker = false; tree = []; }}
|
||||
onclose={() => { showBranchPicker = false; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 1; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={!selectedBranch}
|
||||
onclick={() => goToStep(3)}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Folder -->
|
||||
{:else if step === 3}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step3Title')}</h2>
|
||||
|
||||
{#if treeLoading}
|
||||
<div class="flex items-center gap-2 py-4">
|
||||
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
|
||||
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingTree')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectFolder')}</p>
|
||||
|
||||
<!-- Root option -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left rounded-lg px-4 py-2 text-sm transition-colors mb-1 {selectedFolder === '' ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
|
||||
onclick={() => selectFolder('')}
|
||||
>
|
||||
/ (root)
|
||||
</button>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto rounded-lg border border-[var(--border-primary)] p-2">
|
||||
{#each getTopLevelFolders() as folder (folder.path)}
|
||||
{@const isSelected = selectedFolder === folder.path}
|
||||
{@const isExpanded = expandedDirs.has(folder.path)}
|
||||
{@const children = getChildFolders(folder.path)}
|
||||
<div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if children.length > 0}
|
||||
<button type="button" class="p-0.5 text-[var(--text-tertiary)]" onclick={() => toggleDir(folder.path)}>
|
||||
<IconChevronRight size={14} class="transition-transform {isExpanded ? 'rotate-90' : ''}" />
|
||||
</button>
|
||||
{:else}
|
||||
<span class="w-5"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 text-left rounded px-2 py-1.5 text-sm transition-colors {isSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-primary)]'}"
|
||||
onclick={() => selectFolder(folder.path)}
|
||||
>
|
||||
{folder.path}
|
||||
</button>
|
||||
</div>
|
||||
{#if isExpanded}
|
||||
<div class="ml-5">
|
||||
{#each children as child (child.path)}
|
||||
{@const childSelected = selectedFolder === child.path}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left rounded px-2 py-1.5 text-sm transition-colors {childSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
|
||||
onclick={() => selectFolder(child.path)}
|
||||
>
|
||||
{child.path.split('/').pop()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedFolder}
|
||||
<p class="mt-2 text-xs text-[var(--text-tertiary)]">{$t('sites.selectedFolder')}: <strong>{selectedFolder || '/'}</strong></p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 2; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
|
||||
onclick={() => goToStep(4)}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Configuration -->
|
||||
{:else if step === 4}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step4Title')}</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label={$t('sites.siteName')} name="siteName" bind:value={siteName} placeholder="my-site" required />
|
||||
<FormField label={$t('sites.domain')} name="domain" bind:value={domain} placeholder="site.example.com" helpText={$t('sites.domainHelp')} />
|
||||
</div>
|
||||
|
||||
<!-- Mode -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.mode')}</label>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'static' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { mode = 'static'; }}
|
||||
>
|
||||
<div class="font-medium text-[var(--text-primary)]">Static</div>
|
||||
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeStaticDesc')}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'deno' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { mode = 'deno'; }}
|
||||
>
|
||||
<div class="font-medium text-[var(--text-primary)]">Deno</div>
|
||||
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeDenoDesc')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync trigger -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.syncTrigger')}</label>
|
||||
<div class="flex gap-3">
|
||||
{#each [
|
||||
{ value: 'manual', label: $t('sites.triggerManual') },
|
||||
{ value: 'push', label: $t('sites.triggerPush') },
|
||||
{ value: 'tag', label: $t('sites.triggerTag') }
|
||||
] as opt}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg border px-4 py-2.5 text-sm text-center font-medium transition-colors {syncTrigger === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
||||
onclick={() => { syncTrigger = opt.value as 'push' | 'tag' | 'manual'; }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if syncTrigger === 'tag'}
|
||||
<FormField label={$t('sites.tagPattern')} name="tagPattern" bind:value={tagPattern} placeholder="v*" helpText={$t('sites.tagPatternHelp')} />
|
||||
{/if}
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<ToggleSwitch bind:checked={renderMarkdown} label={$t('sites.renderMarkdown')} />
|
||||
<span>{$t('sites.renderMarkdown')}</span>
|
||||
</div>
|
||||
|
||||
<!-- Persistent Storage (Deno only) -->
|
||||
{#if mode === 'deno'}
|
||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<ToggleSwitch bind:checked={storageEnabled} label={$t('sites.enableStorage')} />
|
||||
<span>{$t('sites.enableStorage')}</span>
|
||||
</div>
|
||||
{#if storageEnabled}
|
||||
<div class="space-y-3 rounded-lg border border-[var(--border-secondary)] p-4">
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.storageHelp')}</p>
|
||||
<FormField
|
||||
label={$t('sites.storageLimitMB')}
|
||||
name="storageLimitMB"
|
||||
type="number"
|
||||
bind:value={storageLimitStr}
|
||||
placeholder="0"
|
||||
helpText={$t('sites.storageLimitHelp')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 3; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={!siteName.trim()}
|
||||
onclick={() => { step = 5; }}
|
||||
>
|
||||
{$t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Review -->
|
||||
{:else if step === 5}
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step5Title')}</h2>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg bg-[var(--surface-card-hover)] p-4">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.provider')}</span>
|
||||
<span class="text-[var(--text-primary)]">{providerOptions.find(o => o.value === effectiveProvider)?.label ?? effectiveProvider}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.repoUrl')}</span>
|
||||
<span class="text-[var(--text-primary)] font-mono text-xs">{giteaUrl}/{repoOwner}/{repoName}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.branch')}</span>
|
||||
<span class="text-[var(--text-primary)]">{selectedBranch}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
|
||||
<span class="text-[var(--text-primary)]">{selectedFolder || '/ (root)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.siteName')}</span>
|
||||
<span class="text-[var(--text-primary)]">{siteName}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
|
||||
<span class="text-[var(--text-primary)]">{domain || '-'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
|
||||
<span class="text-[var(--text-primary)]">{mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
|
||||
<span class="text-[var(--text-primary)]">{syncTrigger}{syncTrigger === 'tag' ? ` (${tagPattern})` : ''}</span>
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.renderMarkdown')}</span>
|
||||
<span class="text-[var(--text-primary)]">{renderMarkdown ? $t('common.yes') : $t('common.no')}</span>
|
||||
|
||||
{#if mode === 'deno'}
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storage')}</span>
|
||||
<span class="text-[var(--text-primary)]">{storageEnabled ? (parseInt(storageLimitStr, 10) > 0 ? `${storageLimitStr} MB` : $t('sites.unlimited')) : $t('common.no')}</span>
|
||||
{/if}
|
||||
|
||||
<span class="text-[var(--text-tertiary)]">{$t('sites.accessToken')}</span>
|
||||
<span class="text-[var(--text-primary)]">{accessToken ? '••••••••' : $t('sites.noToken')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if submitError}
|
||||
<div class="mt-4 rounded-lg bg-[var(--color-danger-light)] p-3">
|
||||
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 4; }}>
|
||||
{$t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
||||
disabled={submitting}
|
||||
onclick={handleSubmit}
|
||||
>
|
||||
{#if submitting}
|
||||
<IconLoader size={14} class="inline mr-1 animate-spin" />
|
||||
{/if}
|
||||
{$t('sites.createSite')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,535 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Stack } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let stacks = $state<Stack[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<Stack | null>(null);
|
||||
let deleteRemoveVolumes = $state(false);
|
||||
|
||||
async function loadStacks() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try { stacks = await api.listStacks(); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Failed to load stacks'; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
|
||||
async function handleStop(s: Stack) {
|
||||
try { await api.stopStack(s.id); setTimeout(loadStacks, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
|
||||
}
|
||||
async function handleStart(s: Stack) {
|
||||
try { await api.startStack(s.id); setTimeout(loadStacks, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
|
||||
}
|
||||
async function handleDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
const removeVolumes = deleteRemoveVolumes;
|
||||
confirmDelete = null; deleteRemoveVolumes = false;
|
||||
try { await api.deleteStack(id, removeVolumes); await loadStacks(); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
onMount(loadStacks);
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
{#snippet stacksToolbar()}
|
||||
<button class="forge-btn-icon" onclick={loadStacks} aria-label={$t('stacks.refresh')}>
|
||||
<IconRefresh size={16} />
|
||||
</button>
|
||||
<a href="/stacks/new" class="forge-btn">
|
||||
<IconPlus size={14} />
|
||||
<span>{$t('stacks.newStack')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
{#snippet stacksStats()}
|
||||
<div><dt>{$t('stacks.total').toUpperCase()}</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
|
||||
<div><dt>{$t('stacks.running').toUpperCase()}</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
|
||||
<div><dt>{$t('stacks.deploying').toUpperCase()}</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
|
||||
<div><dt>{$t('stacks.failed').toUpperCase()}</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
|
||||
{/snippet}
|
||||
{#snippet stacksLede()}{@html $t('stacks.lede')}{/snippet}
|
||||
<ForgeHero
|
||||
eyebrow={$t('stacks.eyebrow')}
|
||||
eyebrowSuffix={$t('stacks.title').toUpperCase()}
|
||||
title={$t('stacks.title')}
|
||||
size="lg"
|
||||
toolbar={stacksToolbar}
|
||||
lede_html={stacksLede}
|
||||
stats={stacksStats}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="grid">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="skeleton" style:--i={i}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stacks.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-mark">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<h2>{$t('stacks.empty.title')}</h2>
|
||||
<p>{$t('stacks.empty.desc')}</p>
|
||||
<a href="/stacks/new" class="btn-primary">
|
||||
<IconPlus size={16} /><span>{$t('stacks.newStack')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each stacks as s, i (s.id)}
|
||||
{@const sm = statusMeta(s.status)}
|
||||
<article class="card {sm.cls}">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="card-head">
|
||||
<span class="card-ref">[{String(i + 1).padStart(2, '0')} / {String(stacks.length).padStart(2, '0')}]</span>
|
||||
<span class="status-pill">
|
||||
<span class="pulse"></span>
|
||||
{sm.label}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<a href="/stacks/{s.id}" class="card-title">{s.name}</a>
|
||||
{#if s.description}
|
||||
<p class="card-desc">{s.description}</p>
|
||||
{:else}
|
||||
<p class="card-desc dim">{$t('stacks.card.noDescription')}</p>
|
||||
{/if}
|
||||
|
||||
{#if s.error}
|
||||
<div class="card-err" title={s.error}>{s.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="meta-k">{$t('stacks.card.updated')}</span>
|
||||
<span class="meta-v">{$fmt.dateTime(s.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
{#if s.status === 'running'}
|
||||
<button class="act" onclick={() => handleStop(s)} aria-label={$t('stacks.card.stop')}>
|
||||
<IconStop size={13} /><span>{$t('stacks.card.stop')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="act" onclick={() => handleStart(s)} aria-label={$t('stacks.card.start')}>
|
||||
<IconPlay size={13} /><span>{$t('stacks.card.start')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label={$t('stacks.card.delete')}>
|
||||
<IconTrash size={13} /><span>{$t('stacks.card.delete')}</span>
|
||||
</button>
|
||||
<a class="act-link" href="/stacks/{s.id}">{$t('stacks.card.open')} <span class="arrow">→</span></a>
|
||||
</footer>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title={$t('stacks.detail.delete.title')}
|
||||
message={confirmDelete ? $t('stacks.detail.delete.messageBase', { name: confirmDelete.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = null; deleteRemoveVolumes = false; }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* subtle workshop dot grid behind hero */
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 480px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.head-top {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 1.5rem; gap: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.title-accent {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 60ch;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.lede :global(em) {
|
||||
color: var(--accent);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Alert ─────────────────────────────────────── */
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Empty ─────────────────────────────────────── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.empty-mark {
|
||||
display: inline-flex; gap: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.empty-mark span {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.empty-mark span:nth-child(2) { background: var(--accent); animation: breathe 2.4s ease-in-out infinite; }
|
||||
.empty h2 {
|
||||
font-family: var(--serif); font-weight: 700;
|
||||
font-size: 1.5rem; margin: 0 0 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.empty p { color: var(--text-secondary); margin: 0 0 1.5rem; font-size: 0.95rem; }
|
||||
.empty :global(.btn-primary) { display: inline-flex; }
|
||||
|
||||
/* ── Grid & Cards ──────────────────────────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.skeleton {
|
||||
height: 230px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(110deg,
|
||||
var(--surface-card) 20%,
|
||||
var(--surface-card-hover) 50%,
|
||||
var(--surface-card) 80%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
animation-delay: calc(var(--i) * 120ms);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 1.25rem 1.25rem 1.1rem;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute; left: 0; top: 18px; bottom: 18px;
|
||||
width: 3px; border-radius: 0 3px 3px 0;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.card.st-running::before { background: var(--color-success); }
|
||||
.card.st-deploying::before{
|
||||
background: repeating-linear-gradient(0deg,
|
||||
var(--color-info) 0 6px,
|
||||
color-mix(in srgb, var(--color-info) 35%, transparent) 6px 12px);
|
||||
}
|
||||
.card.st-failed::before { background: var(--color-danger); }
|
||||
.card:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
box-shadow: 0 0 0 1px var(--color-brand-400), 0 14px 30px -18px var(--glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* registration corners (precision marks) */
|
||||
.reg {
|
||||
position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid; border-width: 0;
|
||||
opacity: 0; transition: opacity 180ms ease;
|
||||
}
|
||||
.card:hover .reg { opacity: 1; }
|
||||
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; }
|
||||
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; }
|
||||
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; }
|
||||
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; }
|
||||
|
||||
.card-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.card-ref {
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-pill .pulse {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.st-running .status-pill { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.st-running .status-pill .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
|
||||
.st-deploying .status-pill { background: var(--color-info-light); color: var(--color-info-dark); }
|
||||
.st-deploying .status-pill .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
|
||||
.st-failed .status-pill { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
.st-failed .status-pill .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
|
||||
:global([data-theme='dark']) .st-running .status-pill { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .st-deploying .status-pill { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
|
||||
:global([data-theme='dark']) .st-failed .status-pill { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
70%, 90% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.15rem; line-height: 1.3;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.01em;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.card-title:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.card-desc.dim { color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.card-err {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-danger-dark);
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.85rem;
|
||||
border-left: 2px solid var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
:global([data-theme='dark']) .card-err {
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex; gap: 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.55rem 0;
|
||||
margin-bottom: 0.9rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
border-bottom: 1px dashed var(--border-primary);
|
||||
}
|
||||
.card-meta .meta-k {
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.1em; text-transform: uppercase; font-size: 0.62rem;
|
||||
align-self: center;
|
||||
}
|
||||
.card-meta .meta-v { color: var(--text-secondary); }
|
||||
|
||||
.card-foot {
|
||||
display: flex; gap: 0.4rem; align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.act {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.38rem 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.act:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.act.danger { color: var(--color-danger); }
|
||||
.act.danger:hover {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .act.danger:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.act-link {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.act-link .arrow { display: inline-block; transition: transform 150ms ease; }
|
||||
.act-link:hover { color: var(--color-brand-700); }
|
||||
.act-link:hover .arrow { transform: translateX(3px); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.head-top { align-items: flex-start; }
|
||||
.display { font-size: 3rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,953 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Stack, StackRevision, StackService } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft, IconRefresh, IconPlay, IconStop, IconTrash } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
let stack = $state<Stack | null>(null);
|
||||
let revisions = $state<StackRevision[]>([]);
|
||||
let services = $state<StackService[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let editing = $state(false);
|
||||
let editYaml = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
let logsService = $state('');
|
||||
let logsText = $state('');
|
||||
let logsLoading = $state(false);
|
||||
|
||||
let confirmRollback = $state<StackRevision | null>(null);
|
||||
let confirmDelete = $state(false);
|
||||
let deleteRemoveVolumes = $state(false);
|
||||
|
||||
let tab = $state<'yaml' | 'revisions' | 'logs'>('yaml');
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function loadAll() {
|
||||
loading = true; error = '';
|
||||
try {
|
||||
const [s, revs, svcs] = await Promise.all([
|
||||
api.getStack(id),
|
||||
api.listStackRevisions(id),
|
||||
api.getStackServices(id).catch(() => [] as StackService[])
|
||||
]);
|
||||
stack = s; revisions = revs; services = svcs;
|
||||
if (!editing && revs.length > 0) editYaml = revs[0].yaml;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('stacks.detail.errors.load');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!stack) return;
|
||||
try { await api.stopStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.stop'); }
|
||||
}
|
||||
async function handleStart() {
|
||||
if (!stack) return;
|
||||
try { await api.startStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.start'); }
|
||||
}
|
||||
async function submitNewRevision() {
|
||||
if (!stack) return;
|
||||
submitting = true; error = '';
|
||||
try { await api.createStackRevision(stack.id, editYaml); editing = false; setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.update'); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
async function doRollback() {
|
||||
if (!stack || !confirmRollback) return;
|
||||
const revId = confirmRollback.id;
|
||||
confirmRollback = null;
|
||||
try { await api.rollbackStack(stack.id, revId); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.rollback'); }
|
||||
}
|
||||
async function doDelete() {
|
||||
if (!stack) return;
|
||||
const sid = stack.id;
|
||||
const rm = deleteRemoveVolumes;
|
||||
confirmDelete = false; deleteRemoveVolumes = false;
|
||||
try { await api.deleteStack(sid, rm); await goto('/stacks'); }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.delete'); }
|
||||
}
|
||||
async function loadLogs() {
|
||||
if (!stack) return;
|
||||
logsLoading = true;
|
||||
try { logsText = await api.getStackLogs(stack.id, logsService || undefined, 300); }
|
||||
catch (e) { logsText = e instanceof Error ? e.message : $t('stacks.detail.errors.fetchLogs'); }
|
||||
finally { logsLoading = false; }
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function serviceState(s: string): string {
|
||||
if (!s) return 'unknown';
|
||||
return s.toLowerCase();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadAll();
|
||||
refreshTimer = setInterval(() => { if (!editing) loadAll(); }, 5000);
|
||||
});
|
||||
onDestroy(() => { if (refreshTimer) clearInterval(refreshTimer); });
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
<div class="dot-grid" aria-hidden="true"></div>
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>{$t('stacks.title').toUpperCase()}</span>
|
||||
</a>
|
||||
|
||||
{#if loading && !stack}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
<span>{$t('stacks.detail.loading')}</span>
|
||||
</div>
|
||||
{:else if error && !stack}
|
||||
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
|
||||
{:else if stack}
|
||||
{@const sm = statusMeta(stack.status)}
|
||||
<header class="head">
|
||||
<div class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span class="sep">//</span>
|
||||
<span>STACK</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="mono-id">{stack.id.slice(0, 16)}</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="status-pill {sm.cls}">
|
||||
<span class="pulse"></span>{sm.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="head-row">
|
||||
<div class="head-left">
|
||||
<h1 class="display">{stack.name}</h1>
|
||||
{#if stack.description}
|
||||
<p class="lede">{stack.description}</p>
|
||||
{:else}
|
||||
<p class="lede dim">{$t('stacks.detail.noDescription')}</p>
|
||||
{/if}
|
||||
<span class="project-chip">
|
||||
<span class="chip-k">{$t('stacks.detail.composeProject')}</span>
|
||||
<code>{stack.compose_project_name}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn-ghost" onclick={loadAll} aria-label={$t('stacks.detail.refresh')}>
|
||||
<IconRefresh size={15} />
|
||||
</button>
|
||||
{#if stack.status === 'running'}
|
||||
<button onclick={handleStop} class="chip-btn">
|
||||
<IconStop size={13} /> <span>{$t('stacks.detail.stop')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={handleStart} class="chip-btn primary">
|
||||
<IconPlay size={13} /> <span>{$t('stacks.detail.start')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => (confirmDelete = true)} class="chip-btn danger">
|
||||
<IconTrash size={13} /> <span>{$t('stacks.detail.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stack.error}
|
||||
<div class="alert">
|
||||
<span class="alert-tag">{$t('stacks.detail.fault')}</span>
|
||||
<span>{stack.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- ── Stat tiles ─────────────────────────────── -->
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.services')}</span>
|
||||
<span class="stat-value">{String(services.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.servicesSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.running')}</span>
|
||||
<span class="stat-value accent">
|
||||
{String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.runningSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.revisions')}</span>
|
||||
<span class="stat-value">{String(revisions.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.revisionsSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">{$t('stacks.detail.stats.current')}</span>
|
||||
<span class="stat-value">
|
||||
R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.currentSub')}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Services ───────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">{$t('stacks.detail.services.title')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-count">{$t('stacks.detail.services.count', { n: String(services.length) })}</span>
|
||||
</header>
|
||||
{#if services.length === 0}
|
||||
<p class="panel-empty">{$t('stacks.detail.services.empty')}</p>
|
||||
{:else}
|
||||
<ul class="svc-list">
|
||||
{#each services as svc (svc.Name)}
|
||||
{@const st = serviceState(svc.State)}
|
||||
<li class="svc-row" data-state={st}>
|
||||
<span class="svc-dot"></span>
|
||||
<div class="svc-main">
|
||||
<div class="svc-name">{svc.Service}</div>
|
||||
<div class="svc-id">{svc.Name}</div>
|
||||
</div>
|
||||
<div class="svc-status">
|
||||
<span class="svc-state">{svc.State}</span>
|
||||
<span class="svc-detail">{svc.Status}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ── Tabs ───────────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" class="tab" class:active={tab==='yaml'} onclick={() => tab='yaml'}>
|
||||
<span class="tab-num">I</span><span>{$t('stacks.detail.tabs.blueprint')}</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='revisions'} onclick={() => tab='revisions'}>
|
||||
<span class="tab-num">II</span><span>{$t('stacks.detail.tabs.revisions')}</span>
|
||||
<span class="tab-badge">{revisions.length}</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='logs'} onclick={() => tab='logs'}>
|
||||
<span class="tab-num">III</span><span>{$t('stacks.detail.tabs.logs')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'yaml'}
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<span class="dim">{$t('stacks.detail.yaml.currentRevision')}</span>
|
||||
{#if !editing}
|
||||
<button class="chip" onclick={() => (editing = true)}>{$t('stacks.detail.yaml.edit')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editing}
|
||||
<textarea
|
||||
bind:value={editYaml}
|
||||
rows="20"
|
||||
class="yaml-edit"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="panel-foot">
|
||||
<button class="btn-ghost" onclick={() => (editing = false)}>{$t('stacks.detail.yaml.cancel')}</button>
|
||||
<button class="btn-primary" onclick={submitNewRevision} disabled={submitting}>
|
||||
<span>{submitting ? $t('stacks.detail.yaml.forging') : $t('stacks.detail.yaml.deployNew')}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if revisions[0]}
|
||||
<div class="yaml-frame">
|
||||
<div class="yaml-frame-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="yaml-title">docker-compose.yml</span>
|
||||
</div>
|
||||
<pre class="yaml-view">{revisions[0].yaml}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === 'revisions'}
|
||||
<div class="panel-body">
|
||||
<ol class="timeline">
|
||||
{#each revisions as rev (rev.id)}
|
||||
<li class="tl-entry" class:current={rev.id === stack.current_revision_id}>
|
||||
<div class="tl-dot"></div>
|
||||
<div class="tl-content">
|
||||
<div class="tl-head">
|
||||
<span class="tl-rev">R{rev.revision.toString().padStart(2, '0')}</span>
|
||||
{#if rev.id === stack.current_revision_id}
|
||||
<span class="tl-badge">{$t('stacks.detail.revisions.current')}</span>
|
||||
{/if}
|
||||
<span class="tl-status">{rev.status}</span>
|
||||
<span class="tl-time">{$fmt.dateTime(rev.created_at)}</span>
|
||||
</div>
|
||||
<div class="tl-meta">
|
||||
{$t('stacks.detail.revisions.by')} <strong>{rev.author || 'operator'}</strong>
|
||||
</div>
|
||||
{#if rev.id !== stack.current_revision_id}
|
||||
<button class="tl-action" onclick={() => (confirmRollback = rev)}>
|
||||
{$t('stacks.detail.revisions.rollback')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{:else if tab === 'logs'}
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<label class="log-select">
|
||||
<span class="dim">{$t('stacks.detail.logs.service')}</span>
|
||||
<select bind:value={logsService}>
|
||||
<option value="">{$t('stacks.detail.logs.allServices')}</option>
|
||||
{#each services as svc (svc.Service)}
|
||||
<option value={svc.Service}>{svc.Service}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button onclick={loadLogs} class="chip" disabled={logsLoading}>
|
||||
{logsLoading ? $t('stacks.detail.logs.fetching') : $t('stacks.detail.logs.fetch')}
|
||||
</button>
|
||||
</div>
|
||||
{#if logsText}
|
||||
<div class="terminal">
|
||||
<div class="terminal-head">
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-title">~/forge/{stack.name}{logsService ? '/' + logsService : ''}.log</span>
|
||||
</div>
|
||||
<pre class="terminal-body">{logsText}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="panel-empty">{$t('stacks.detail.logs.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRollback !== null}
|
||||
title={$t('stacks.detail.revisions.rollbackTitle')}
|
||||
message={confirmRollback ? $t('stacks.detail.revisions.rollbackMessage', { n: String(confirmRollback.revision) }) : ''}
|
||||
confirmLabel={$t('stacks.detail.revisions.rollbackConfirm')}
|
||||
confirmVariant="primary"
|
||||
onconfirm={doRollback}
|
||||
oncancel={() => (confirmRollback = null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title={$t('stacks.detail.delete.title')}
|
||||
message={stack ? $t('stacks.detail.delete.messageBase', { name: stack.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => { confirmDelete = false; deleteRemoveVolumes = false; }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 480px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back:hover { color: var(--accent); }
|
||||
|
||||
.loading {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem; color: var(--text-tertiary);
|
||||
}
|
||||
.spinner {
|
||||
width: 12px; height: 12px;
|
||||
border: 2px solid var(--text-tertiary);
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
70%, 90% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.eyebrow {
|
||||
display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.eyebrow .sep { opacity: 0.5; }
|
||||
.mono-id { color: var(--text-secondary); }
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-pill .pulse {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.status-pill.st-running { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.status-pill.st-running .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
|
||||
.status-pill.st-deploying { background: var(--color-info-light); color: var(--color-info-dark); }
|
||||
.status-pill.st-deploying .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
|
||||
.status-pill.st-failed { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
.status-pill.st-failed .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
|
||||
:global([data-theme='dark']) .status-pill.st-running { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .status-pill.st-deploying { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
|
||||
:global([data-theme='dark']) .status-pill.st-failed { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
.head-row {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
gap: 1.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.head-left { flex: 1; min-width: 280px; }
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(1.875rem, 4vw, 2.5rem);
|
||||
font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.45;
|
||||
max-width: 56ch;
|
||||
}
|
||||
.lede.dim { color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.project-chip {
|
||||
display: inline-flex; gap: 0.55rem; align-items: center;
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.chip-k {
|
||||
font-family: var(--mono); font-size: 0.6rem;
|
||||
letter-spacing: 0.15em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.project-chip code {
|
||||
font-family: var(--mono); font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 0.45rem; align-items: center; flex-wrap: wrap; }
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.chip-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip-btn:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.chip-btn.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border-color: var(--text-primary);
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.chip-btn.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 3px var(--glow);
|
||||
}
|
||||
.chip-btn.danger { color: var(--color-danger); }
|
||||
.chip-btn.danger:hover {
|
||||
background: var(--color-danger-light);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .chip-btn.danger:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Stats ─────────────────────────────────────── */
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.stat {
|
||||
padding: 1rem 1.15rem;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
}
|
||||
.stat:last-child { border-right: 0; }
|
||||
.stat-label {
|
||||
font-family: var(--mono); font-size: 0.62rem;
|
||||
letter-spacing: 0.2em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.stat-value {
|
||||
font-family: var(--serif); font-size: 2rem; line-height: 1.1;
|
||||
font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-value.accent { color: var(--accent); }
|
||||
.stat-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Panels ────────────────────────────────────── */
|
||||
.panel {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
padding: 1rem 1.35rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: var(--serif); font-size: 1.35rem;
|
||||
margin: 0; font-weight: 600; line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.title-accent { color: var(--accent); font-weight: 700; }
|
||||
.panel-count {
|
||||
font-family: var(--mono); font-size: 0.66rem;
|
||||
letter-spacing: 0.12em; color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-empty {
|
||||
padding: 1.75rem; margin: 0;
|
||||
font-family: var(--serif); font-style: italic; color: var(--text-tertiary);
|
||||
text-align: center; font-size: 1rem;
|
||||
}
|
||||
.panel-body { padding: 1.15rem 1.35rem 1.35rem; }
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem; flex-wrap: wrap;
|
||||
}
|
||||
.dim {
|
||||
font-family: var(--mono);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.7rem; letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip:hover:not(:disabled) {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.chip:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Services list ─────────────────────────────── */
|
||||
.svc-list { list-style: none; margin: 0; padding: 0; }
|
||||
.svc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto;
|
||||
gap: 1rem; align-items: center;
|
||||
padding: 0.85rem 1.35rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.svc-row:last-child { border-bottom: 0; }
|
||||
.svc-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.svc-row[data-state='running'] .svc-dot {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);
|
||||
}
|
||||
.svc-row[data-state='exited'] .svc-dot,
|
||||
.svc-row[data-state='dead'] .svc-dot { background: var(--color-danger); }
|
||||
.svc-row[data-state='restarting'] .svc-dot { background: var(--color-warning); animation: blink 0.6s infinite; }
|
||||
.svc-name {
|
||||
font-family: var(--serif); font-size: 1.2rem;
|
||||
color: var(--text-primary); line-height: 1.2;
|
||||
}
|
||||
.svc-id {
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-tertiary); margin-top: 0.1rem;
|
||||
}
|
||||
.svc-status { text-align: right; }
|
||||
.svc-state {
|
||||
display: inline-block;
|
||||
font-family: var(--mono); font-size: 0.66rem;
|
||||
font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.svc-detail {
|
||||
display: block; margin-top: 0.25rem;
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Tabs ──────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex; gap: 0;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.tab {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.95rem 1.25rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer; position: relative;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.tab:hover { color: var(--text-secondary); }
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute; left: 0; right: 0; bottom: -1px;
|
||||
height: 2px; background: var(--accent);
|
||||
}
|
||||
.tab-num {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.15rem;
|
||||
font-style: italic;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tab-badge {
|
||||
font-size: 0.58rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── YAML view / edit ──────────────────────────── */
|
||||
.yaml-frame {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.yaml-frame-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.yaml-frame-head .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.yaml-frame-head .dot:nth-child(2) { background: var(--color-warning); }
|
||||
.yaml-frame-head .dot:nth-child(3) { background: var(--color-success); }
|
||||
.yaml-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.yaml-view {
|
||||
max-height: 440px; overflow: auto;
|
||||
padding: 0.9rem 1rem; margin: 0;
|
||||
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
.yaml-edit {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.yaml-edit:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.panel-foot {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.arrow { transition: transform 150ms ease; }
|
||||
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
|
||||
|
||||
/* ── Timeline ──────────────────────────────────── */
|
||||
.timeline { list-style: none; margin: 0; padding: 0.25rem 0 0; position: relative; }
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute; top: 1rem; bottom: 1rem; left: 8px;
|
||||
width: 1px; background: var(--border-primary);
|
||||
}
|
||||
.tl-entry {
|
||||
position: relative;
|
||||
padding: 0.6rem 0 0.6rem 2rem;
|
||||
}
|
||||
.tl-dot {
|
||||
position: absolute; left: 3px; top: 1.05rem;
|
||||
width: 11px; height: 11px;
|
||||
background: var(--surface-card);
|
||||
border: 2px solid var(--text-tertiary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tl-entry.current .tl-dot {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||
}
|
||||
.tl-head {
|
||||
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tl-rev {
|
||||
font-family: var(--serif); font-size: 1.5rem;
|
||||
letter-spacing: 0; color: var(--text-primary); line-height: 1;
|
||||
}
|
||||
.tl-badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--accent); color: #fff;
|
||||
font-size: 0.58rem; font-weight: 600; letter-spacing: 0.16em;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.tl-status { color: var(--text-secondary); }
|
||||
.tl-time { color: var(--text-tertiary); }
|
||||
.tl-meta {
|
||||
font-size: 0.82rem; color: var(--text-tertiary);
|
||||
margin-top: 0.25rem; font-family: var(--serif);
|
||||
}
|
||||
.tl-meta strong { color: var(--text-secondary); font-weight: 500; }
|
||||
.tl-action {
|
||||
margin-top: 0.5rem;
|
||||
background: transparent; border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent); font-family: var(--mono);
|
||||
font-size: 0.68rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tl-action:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* ── Logs / Terminal ───────────────────────────── */
|
||||
.log-select { display: inline-flex; align-items: center; gap: 0.55rem; }
|
||||
.log-select select {
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.terminal {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #0b1020;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global([data-theme='dark']) .terminal { background: #05070f; }
|
||||
.terminal-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
background: #141a2e;
|
||||
border-bottom: 1px solid #0a0e1c;
|
||||
}
|
||||
:global([data-theme='dark']) .terminal-head { background: #0a0e1c; }
|
||||
.t-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
.t-dot:nth-child(1) { background: color-mix(in srgb, var(--color-danger) 70%, black); }
|
||||
.t-dot:nth-child(2) { background: color-mix(in srgb, var(--color-warning) 70%, black); }
|
||||
.t-dot:nth-child(3) { background: color-mix(in srgb, var(--color-success) 70%, black); }
|
||||
.t-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.7rem;
|
||||
color: rgba(255,255,255,0.45);
|
||||
}
|
||||
.terminal-body {
|
||||
max-height: 480px; overflow: auto;
|
||||
margin: 0; padding: 1rem 1.1rem;
|
||||
font-family: var(--mono); font-size: 0.76rem; line-height: 1.55;
|
||||
color: #c7d0e0;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.head-row { flex-direction: column; align-items: stretch; }
|
||||
.display { font-size: 2.5rem; }
|
||||
.svc-row { grid-template-columns: 14px 1fr; }
|
||||
.svc-status { grid-column: 2; text-align: left; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,595 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft } from '$lib/components/icons';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let yaml = $state('');
|
||||
let deployNow = $state(true);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let dragOver = $state(false);
|
||||
|
||||
const sample = `services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
cache:
|
||||
image: redis:7-alpine`;
|
||||
|
||||
async function handleFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
yaml = await file.text();
|
||||
}
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
yaml = await file.text();
|
||||
}
|
||||
function loadSample() { yaml = sample; }
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !yaml.trim()) {
|
||||
error = $t('stacks.new.errorRequired');
|
||||
return;
|
||||
}
|
||||
submitting = true; error = '';
|
||||
try {
|
||||
const { stack } = await api.createStack({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
yaml,
|
||||
deploy: deployNow
|
||||
});
|
||||
await goto(`/stacks/${stack.id}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $t('stacks.new.errorCreate');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineNumbers = $derived(
|
||||
yaml.split('\n').map((_, i) => String(i + 1).padStart(3, '0')).join('\n')
|
||||
);
|
||||
const lineCount = $derived(yaml ? yaml.split('\n').length : 0);
|
||||
const byteCount = $derived(new Blob([yaml]).size);
|
||||
|
||||
function syncScroll(e: Event) {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
const gutter = ta.parentElement?.querySelector('.gutter') as HTMLElement | null;
|
||||
if (gutter) gutter.scrollTop = ta.scrollTop;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
<div class="dot-grid" aria-hidden="true"></div>
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>{$t('stacks.new.back').toUpperCase()}</span>
|
||||
</a>
|
||||
|
||||
<header class="head">
|
||||
<span class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span class="sep">//</span>
|
||||
<span>NEW STACK</span>
|
||||
</span>
|
||||
<h1 class="display">{$t('stacks.new.title')}</h1>
|
||||
<p class="lede">{@html $t('stacks.new.lede')}</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={submit} class="form">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
<span class="lbl">{$t('stacks.new.name')}</span>
|
||||
<span class="req">{$t('stacks.new.required')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder={$t('stacks.new.namePlaceholder')}
|
||||
class="input"
|
||||
/>
|
||||
<p class="hint">{$t('stacks.new.nameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-desc" class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">{$t('stacks.new.description')}</span>
|
||||
<span class="opt">{$t('stacks.new.optional')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-desc"
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$t('stacks.new.descriptionPlaceholder')}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">{$t('stacks.new.composeYaml')}</span>
|
||||
<span class="req">{$t('stacks.new.required')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="chip" onclick={loadSample}>{$t('stacks.new.loadSample')}</button>
|
||||
<button type="button" class="chip" onclick={() => fileInput?.click()}>{$t('stacks.new.uploadFile')}</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".yml,.yaml"
|
||||
class="sr-only"
|
||||
onchange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !yaml}
|
||||
<button
|
||||
type="button"
|
||||
class="dropzone"
|
||||
class:drag-over={dragOver}
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<div class="dz-icon">⇣</div>
|
||||
<div class="dz-title">{$t('stacks.new.dropHere')}</div>
|
||||
<div class="dz-sub">{@html $t('stacks.new.dropSub')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="editor" class:hidden={!yaml}>
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">docker-compose.yml</span>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div class="gutter" aria-hidden="true"><pre>{lineNumbers}</pre></div>
|
||||
<textarea
|
||||
bind:value={yaml}
|
||||
onscroll={syncScroll}
|
||||
rows="20"
|
||||
spellcheck="false"
|
||||
placeholder={sample}
|
||||
class="yaml-area"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<span>{$t('stacks.new.lines', { n: String(lineCount) })}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{$t('stacks.new.bytes', { n: String(byteCount) })}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>YAML</span>
|
||||
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>{$t('stacks.new.clear')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="deploy-toggle">
|
||||
<ToggleSwitch bind:checked={deployNow} label={$t('stacks.new.deployImmediate')} />
|
||||
<span class="toggle-text">
|
||||
<strong>{$t('stacks.new.deployImmediate')}</strong>
|
||||
<span class="dim">{$t('stacks.new.deployHint')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/stacks" class="btn-ghost">{$t('stacks.new.cancel')}</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="btn-primary"
|
||||
>
|
||||
<span>{submitting ? $t('stacks.new.forging') : deployNow ? $t('stacks.new.forgeAndDeploy') : $t('stacks.new.saveBlueprint')}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 400px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back:hover { color: var(--accent); }
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.eyebrow .sep { opacity: 0.5; }
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(1.875rem, 4vw, 2.5rem);
|
||||
font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.display :global(em) {
|
||||
color: var(--accent);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 56ch;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.lede :global(code) {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Form ──────────────────────────────────────── */
|
||||
.form {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 1.75rem;
|
||||
}
|
||||
.reg {
|
||||
position: absolute; width: 10px; height: 10px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid; border-width: 0;
|
||||
}
|
||||
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; border-top-left-radius: var(--radius-2xl); }
|
||||
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; border-top-right-radius: var(--radius-2xl); }
|
||||
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; border-bottom-left-radius: var(--radius-2xl); }
|
||||
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; border-bottom-right-radius: var(--radius-2xl); }
|
||||
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
padding: 0.7rem 0.9rem; margin-bottom: 1.25rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Fields ────────────────────────────────────── */
|
||||
.field { margin-bottom: 1.5rem; }
|
||||
.field-label {
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
.field-label .num {
|
||||
display: inline-flex; width: 26px; height: 26px;
|
||||
justify-content: center; align-items: center;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
}
|
||||
.field-label .lbl {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.25rem; line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.field-label .req {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6rem; font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
}
|
||||
.field-label .opt {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6rem; font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
}
|
||||
.field-label .spacer { flex: 1; }
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem; color: var(--text-tertiary);
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
|
||||
/* ── Dropzone ──────────────────────────────────── */
|
||||
.dropzone {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%; min-height: 240px;
|
||||
background: var(--surface-card-hover);
|
||||
border: 2px dashed var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 2rem;
|
||||
transition: all 180ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dropzone:hover, .dropzone.drag-over {
|
||||
border-color: var(--color-brand-500);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dz-icon { font-size: 2.25rem; line-height: 1; color: var(--text-tertiary); transition: color 150ms ease; }
|
||||
.dropzone:hover .dz-icon, .dropzone.drag-over .dz-icon { color: var(--accent); }
|
||||
.dz-title {
|
||||
font-family: var(--serif); font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dz-title :global(em) { color: var(--accent); font-style: italic; }
|
||||
.dz-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.dz-sub :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
/* ── Editor ────────────────────────────────────── */
|
||||
.editor {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor.hidden { display: none; }
|
||||
.editor-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.editor-head .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.editor-head .dot:nth-child(2) { background: var(--color-warning); }
|
||||
.editor-head .dot:nth-child(3) { background: var(--color-success); }
|
||||
.editor-head .editor-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.gutter {
|
||||
flex-shrink: 0;
|
||||
width: 54px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-card-hover);
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
.gutter pre {
|
||||
margin: 0; padding: 0.85rem 0.6rem 0.85rem 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
.yaml-area {
|
||||
flex: 1; display: block;
|
||||
padding: 0.85rem 1rem;
|
||||
background: transparent;
|
||||
border: 0; outline: 0; resize: vertical;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
min-height: 300px;
|
||||
}
|
||||
.yaml-area::placeholder { color: var(--text-tertiary); }
|
||||
.editor-foot {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-foot .sep { opacity: 0.5; }
|
||||
.clear-btn {
|
||||
margin-left: auto;
|
||||
background: transparent; border: 0;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.clear-btn:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Deploy toggle ─────────────────────────────── */
|
||||
.deploy-toggle {
|
||||
display: flex; align-items: flex-start; gap: 0.8rem;
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 1.25rem;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.deploy-toggle:hover { border-color: var(--color-brand-300); }
|
||||
.deploy-toggle :global(.toggle-switch) { margin-top: 2px; }
|
||||
.toggle-text strong {
|
||||
display: block; font-family: var(--serif);
|
||||
font-size: 1.15rem; font-weight: 400; line-height: 1.2;
|
||||
color: var(--text-primary); margin-bottom: 0.15rem;
|
||||
}
|
||||
.toggle-text .dim { color: var(--text-tertiary); font-size: 0.82rem; }
|
||||
|
||||
/* ── Actions ───────────────────────────────────── */
|
||||
.actions {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
}
|
||||
.btn-ghost {
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.65rem 1.2rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.74rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.arrow { transition: transform 150ms ease; }
|
||||
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user