feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s

The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.

Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
  static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
  stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
  workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
  rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
  dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
  internal/stack/manager.go gone (the rest of those packages stay as
  helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
  gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
  regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
  SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
  minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
  staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
  SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
  table (projects, stages, stage_env, volumes, deploys, deploy_logs,
  poll_states, stacks, stack_revisions, stack_deploys, static_sites,
  static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
  Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
  StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
  GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
  so api + store paths share one secret-generation impl (no
  panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
  + static-site label paths; only canonical tinyforge.workload.id
  dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
  path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
  private (no external callers)

Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
  helper + types (Project, Stage, Stack, StaticSite, Deploy,
  Instance, Volume, etc.); kept Workload, Container, App, Settings,
  Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
  api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
  /deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
  listWorkloads + listContainers only; 4-card stat grid
  (workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
  ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
  proxies/+page.svelte, containers/+page.svelte all rewired to the
  workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
  SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
  volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
  instance.*, confirm.* namespaces; en/ru parity preserved (1042
  keys each)

Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):

- Sec H1: dead-end workload webhook URL handlers (would mint URLs
  that 404 the new trigger-only ingress) deleted across backend +
  frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
  store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
  field names, workloadIDRow rationale, webhook_deliveries.target_type
  enum, WebhookDeliveryLog component header

Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
  items are now shipped. Next focus is Priority 3 polish (apps.* i18n
  + codemap entries) and Priority 4 tests.

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