refactor(workload): plugin architecture wave + apps UI + volume scopes

Completes the workload-first refactor's plugin layer:

- internal/workload/plugin/ — Source/Trigger plugin contract,
  registry, types (Workload, DeploymentIntent, InboundEvent,
  PublicFace). Self-registering init() pattern + blank-import
  in cmd/server/main.go.
- Source plugins: image (blue-green with multi-face proxy routing),
  compose, static. Trigger plugins: registry, git, manual.
- internal/deployer/dispatch.go — DispatchPlugin/Teardown/Reconcile
  seam routing the legacy deployer through plugins.
- internal/api/workload_*.go — REST surface: workloads, env,
  volumes, chain (parent/children), promote-from. hooks.go
  serves /api/hooks/kinds/{kind}/schema for the wizard.
- internal/store: workload_env (encrypt-at-rest secrets) and
  workload_volumes tables, keyed on workload_id.
- cmd/server/static_backend.go — phantom-row adapter delegating
  the static source plugin to the legacy staticsite.Manager
  (deleted at hard cutover once the static inline port lands).
- web/src/routes/apps/ — /apps list + /apps/new wizard +
  /apps/[id] detail with kind-aware compose / image / static
  forms (Advanced JSON toggle), env panel, volumes panel,
  webhook panel, chain panel, manual deploy.

Volume scope generalization (v2 resolver):

- internal/volume.ResolveWorkloadPath (workload-keyed, sits
  next to legacy ResolvePath). Honors all VolumeScope values:
  absolute, ephemeral, instance, stage, project, project_named,
  named. internal/workload/plugin/source/image/image.go
  computeMounts wires settings + imageTag through. Coverage in
  internal/volume/resolver_test.go (portable Linux/Windows via
  t.TempDir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:17:41 +03:00
parent f42b21a2b9
commit 8d6a527a2b
41 changed files with 9482 additions and 18 deletions
+13 -2
View File
@@ -6,14 +6,19 @@
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import { fetchContainerLogs, fetchStaticSiteLogs } from '$lib/api';
import {
fetchContainerLogs,
fetchStaticSiteLogs,
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: 'site'; siteId: string }
| { kind: 'workload'; workloadId: string; containerRowId: string };
interface Props {
source: LogSource;
@@ -58,6 +63,9 @@
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}`;
}
@@ -65,6 +73,9 @@
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);
}
+36 -3
View File
@@ -536,11 +536,11 @@ export interface SystemStatsSample {
// ── Workload / Container / App ────────────────────────────────────
export type WorkloadKind = 'project' | 'stack' | 'site';
export type WorkloadKind = 'project' | 'stack' | 'site' | 'plugin' | (string & {});
/**
* Workload is the unifying primitive over Project / Stack / StaticSite.
* Read-only at this layer — mutations go through the kind-specific endpoints.
* Workload is the unifying primitive over Project / Stack / StaticSite,
* plus plugin-native rows whose source/trigger kinds are populated.
*/
export interface Workload {
id: string;
@@ -548,12 +548,45 @@ export interface Workload {
ref_id: string;
name: string;
app_id: string;
source_kind: string;
source_config: string;
trigger_kind: string;
trigger_config: string;
public_faces: string;
parent_workload_id: string;
notification_url: string;
webhook_require_signature: boolean;
created_at: string;
updated_at: string;
}
export interface PublicFace {
subdomain: string;
domain: string;
target_service: string;
target_port: number;
access_list_id: number;
enable_ssl: boolean;
}
export interface PluginWorkloadInput {
name: string;
group_id?: string;
parent_workload_id?: string;
source_kind: string;
source_config: unknown;
trigger_kind: string;
trigger_config: unknown;
public_faces?: PublicFace[];
notification_url?: string;
webhook_require_signature?: boolean;
}
export interface HookKinds {
sources: string[];
triggers: string[];
}
/**
* Canonical container states. The trailing `(string & {})` is the
* "literal-friendly string" trick — it lets the union accept arbitrary
+530
View File
@@ -0,0 +1,530 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Workload } from '$lib/types';
import * as api from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let workloads = $state<Workload[]>([]);
let loading = $state(true);
let error = $state('');
let filter = $state<'all' | string>('all');
// Plugin-native rows are the ones with both source_kind and trigger_kind
// populated. Legacy project/stack/site rows still appear in
// /api/workloads — those are surfaced under their own sections.
const pluginRows = $derived(
workloads.filter((w) => w.source_kind !== '' && w.trigger_kind !== '')
);
const filtered = $derived(
filter === 'all' ? pluginRows : pluginRows.filter((w) => w.source_kind === filter)
);
const sourceKinds = $derived(
Array.from(new Set(pluginRows.map((w) => w.source_kind))).sort()
);
const countBy = $derived((kind: string) =>
pluginRows.filter((w) => w.source_kind === kind).length
);
async function load() {
loading = true;
error = '';
try {
workloads = await api.listWorkloads();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load apps';
} finally {
loading = false;
}
}
function sourceBadge(kind: string): string {
switch (kind) {
case 'image':
return 'badge-image';
case 'compose':
return 'badge-compose';
case 'static':
return 'badge-static';
default:
return 'badge-other';
}
}
onMount(load);
</script>
<svelte:head>
<title>Apps · Tinyforge</title>
</svelte:head>
<div class="forge">
{#snippet appsToolbar()}
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
<IconRefresh size={16} />
</button>
<a href="/apps/new" class="forge-btn">
<IconPlus size={14} />
<span>New App</span>
</a>
{/snippet}
{#snippet appsStats()}
<div>
<dt>TOTAL</dt>
<dd>{loading ? '—' : String(pluginRows.length).padStart(2, '0')}</dd>
</div>
<div>
<dt>IMAGE</dt>
<dd>{loading ? '—' : String(countBy('image')).padStart(2, '0')}</dd>
</div>
<div>
<dt>COMPOSE</dt>
<dd>{loading ? '—' : String(countBy('compose')).padStart(2, '0')}</dd>
</div>
<div>
<dt>STATIC</dt>
<dd class="accent">{loading ? '—' : String(countBy('static')).padStart(2, '0')}</dd>
</div>
{/snippet}
{#snippet appsLede()}
Plugin-native deployables &mdash; <em>image</em>, <em>compose</em>, or <em>static</em>, with
pluggable redeploy triggers. Legacy projects, stacks, and sites continue to live under their
own sections during the cutover.
{/snippet}
<ForgeHero
eyebrowSuffix="APPS"
title="Apps"
size="lg"
toolbar={appsToolbar}
lede_html={appsLede}
stats={appsStats}
/>
{#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
{/if}
{#if !loading && pluginRows.length > 0}
<div class="filter-row" role="tablist" aria-label="Filter by source plugin">
<button
class="chip"
class:active={filter === 'all'}
role="tab"
aria-selected={filter === 'all'}
onclick={() => (filter = 'all')}
>
<span class="chip-label">ALL</span>
<span class="chip-count">{String(pluginRows.length).padStart(2, '0')}</span>
</button>
{#each sourceKinds as kind}
{@const count = countBy(kind)}
<button
class="chip src-{kind}"
class:active={filter === kind}
role="tab"
aria-selected={filter === kind}
onclick={() => (filter = kind)}
>
<span class="chip-dot" aria-hidden="true"></span>
<span class="chip-label">{kind.toUpperCase()}</span>
<span class="chip-count">{String(count).padStart(2, '0')}</span>
</button>
{/each}
</div>
{/if}
{#if loading}
<div class="skeleton-rows" aria-busy="true">
{#each Array(4) as _, i}
<div class="skeleton-row" style:--i={i}></div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="empty">
<div class="empty-mark"><span></span><span></span><span></span></div>
<h2>No apps yet</h2>
<p>
Apps unify image, compose, and static deployables behind a single plugin-driven
surface. Forge your first one to see it light up here.
</p>
<a href="/apps/new" class="btn-primary">
<IconPlus size={14} /><span>Forge the first app</span>
</a>
</div>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>Name</th>
<th>Source</th>
<th>Trigger</th>
<th>Created</th>
<th class="t-right">Actions</th>
</tr>
</thead>
<tbody>
{#each filtered as w, i (w.id)}
<tr>
<td>
<a class="row-link" href={`/apps/${w.id}`}>
<span class="row-ref">{String(i + 1).padStart(2, '0')}</span>
<span class="row-name">{w.name}</span>
</a>
</td>
<td>
<span class="badge {sourceBadge(w.source_kind)}">
<span class="badge-dot" aria-hidden="true"></span>{w.source_kind}
</span>
</td>
<td>
<span class="badge badge-trigger">{w.trigger_kind}</span>
</td>
<td class="muted mono">{w.created_at}</td>
<td class="actions-cell">
<a class="row-action" href={`/apps/${w.id}`}>
Open <span class="arrow" aria-hidden="true"></span>
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.forge {
--accent: var(--forge-accent);
--accent-soft: var(--forge-accent-soft);
--glow: var(--forge-glow);
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 1240px;
margin: 0 auto;
}
/* ── Alert ─────────────────────────────────────── */
.alert {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.7rem 0.9rem;
background: var(--color-danger-light);
color: var(--color-danger-dark);
border: 1px solid var(--color-danger);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.alert-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-danger);
color: #fff;
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .alert {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
/* ── Filter chips ──────────────────────────────── */
.filter-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--border-primary);
background: var(--surface-card);
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease,
transform 150ms ease;
}
.chip:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.chip.active {
background: var(--text-primary);
color: var(--surface-card);
border-color: var(--text-primary);
}
.chip.active .chip-count,
.chip.active .chip-dot {
color: var(--surface-card);
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
}
.chip.src-image .chip-dot {
color: var(--badge-image-color);
}
.chip.src-compose .chip-dot {
color: var(--badge-compose-color);
}
.chip.src-static .chip-dot {
color: var(--badge-static-color);
}
.chip-count {
font-variant-numeric: tabular-nums;
font-size: 0.62rem;
opacity: 0.7;
}
/* ── Skeleton ──────────────────────────────────── */
.skeleton-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.skeleton-row {
height: 52px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(
110deg,
var(--surface-card) 20%,
var(--surface-card-hover) 50%,
var(--surface-card) 80%
);
background-size: 200% 100%;
animation: shimmer 1.6s linear infinite;
animation-delay: calc(var(--i) * 120ms);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Empty ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 4rem 2rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-2xl);
background: var(--surface-card);
}
.empty-mark {
display: inline-flex;
gap: 4px;
margin-bottom: 1.5rem;
}
.empty-mark span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-input);
}
.empty-mark span:nth-child(2) {
background: var(--accent);
animation: ember 2.4s ease-in-out infinite;
}
@keyframes ember {
0%,
100% {
box-shadow: 0 0 0 3px var(--accent-soft);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
}
}
.empty h2 {
font-family: var(--font-family-sans);
font-weight: 700;
font-size: 1.5rem;
margin: 0 0 0.5rem;
letter-spacing: -0.01em;
}
.empty p {
color: var(--text-secondary);
margin: 0 auto 1.5rem;
font-size: 0.95rem;
max-width: 48ch;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: var(--text-primary);
color: var(--surface-card);
border: 0;
border-radius: var(--radius-lg);
font-family: var(--forge-mono);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
box-shadow: 0 0 0 0 var(--glow);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 0 0 4px var(--glow);
}
/* ── Table ─────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
background: var(--surface-card);
overflow: hidden;
}
.t-right {
text-align: right;
}
.row-link {
display: inline-flex;
align-items: baseline;
gap: 0.6rem;
color: var(--text-primary);
text-decoration: none;
transition: color 120ms ease;
}
.row-link:hover {
color: var(--accent);
}
.row-link:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-ref {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.row-name {
font-weight: 600;
}
.row-action {
font-family: var(--forge-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
text-decoration: none;
}
.row-action:hover {
color: var(--color-brand-700);
}
.row-action:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.row-action .arrow {
display: inline-block;
transition: transform 150ms ease;
}
.row-action:hover .arrow {
transform: translateX(3px);
}
/* ── Source badges (themed via tokens) ─────────── */
/* Light-theme defaults; dark overrides below pick up automatically. */
.forge {
--badge-image-color: var(--color-info);
--badge-image-text: var(--color-info-dark);
--badge-compose-color: var(--color-brand-500);
--badge-compose-text: var(--color-brand-700);
--badge-static-color: var(--color-success);
--badge-static-text: var(--color-success-dark);
}
:global([data-theme='dark']) .forge {
--badge-image-text: #93c5fd;
--badge-compose-text: #c4b5fd;
--badge-static-text: #6ee7b7;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.55rem;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
border: 1px solid var(--border-primary);
}
.badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge-image {
background: color-mix(in srgb, var(--badge-image-color) 10%, transparent);
color: var(--badge-image-text);
border-color: color-mix(in srgb, var(--badge-image-color) 35%, transparent);
}
.badge-compose {
background: color-mix(in srgb, var(--badge-compose-color) 10%, transparent);
color: var(--badge-compose-text);
border-color: color-mix(in srgb, var(--badge-compose-color) 35%, transparent);
}
.badge-static {
background: color-mix(in srgb, var(--badge-static-color) 10%, transparent);
color: var(--badge-static-text);
border-color: color-mix(in srgb, var(--badge-static-color) 35%, transparent);
}
.badge-other {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.badge-trigger {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
font-size: 0.78rem;
}
.actions-cell {
text-align: right;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff