feat: docker-compose stacks with Forge-themed UI
Build / build (push) Successful in 10m42s

Adds a new Stacks feature: upload/edit docker-compose YAML,
deploy as atomic units, browse revisions, roll back, and
stream logs. Backend in internal/stack + internal/api/stacks.go,
persistent storage in internal/store/stacks.go.

Stacks pages (list, new, detail) use a modern Forge aesthetic —
Instrument Serif display type, JetBrains Mono for meta/code,
indigo ember accents, dot-grid hero, registration marks on
hover, terminal panel for logs. Palette is sourced from the
app's existing design tokens so the feature remains consistent
with the rest of Tinyforge.

Fonts self-hosted via @fontsource/instrument-serif and
@fontsource/jetbrains-mono to satisfy the strict CSP.
This commit is contained in:
2026-04-16 03:48:37 +03:00
parent b622384774
commit 75424a5f25
23 changed files with 3603 additions and 18 deletions
+76
View File
@@ -771,4 +771,80 @@ export function getStaticSiteStorage(
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 { ApiError };
+2 -1
View File
@@ -18,7 +18,8 @@
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records",
"sites": "Sites"
"sites": "Sites",
"stacks": "Stacks"
},
"dashboard": {
"title": "Dashboard",
+2 -1
View File
@@ -18,7 +18,8 @@
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи",
"sites": "Сайты"
"sites": "Сайты",
"stacks": "Стеки"
},
"dashboard": {
"title": "Панель управления",
+34
View File
@@ -372,6 +372,40 @@ export interface StaticSiteStorageUsage {
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. */