Files
tiny-forge/web/src/routes/apps/[id]/+page.svelte
T
alexei.dolgolyov 192204a51c
Build / build (push) Failing after 4m51s
feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
Sidebar tabs, Settings, and drill-in detail pages re-fetched on every
visit (loading=true + onMount), flashing an empty skeleton frame on each
navigation. Add an SWR cache layer so revisiting a view renders cached
data instantly while refreshing in the background.

- resourceCache.ts: single-value + keyed (per-id) SWR cache factories
- caches.ts: per-resource cache instances; resetAllCaches() on logout
- eventsSnapshot.ts: warm-seed snapshot for the SSE/paginated events page
- List/sidebar pages read $cache.value via $derived, refresh() on mount;
  mutations refresh the cache
- Settings forms seed once from settingsCache (edit-safe) and refetch
  after save (PUT /api/settings returns {status}, not the Settings object)
- Detail [id] pages warm-seed per id; apps/[id] seeds {workload,containers},
  resets non-seeded panels on warm nav, clears workload on 404, and
  invalidates its cache entry on delete

Deferred (still cold-fetch): triggers/[id] (webhook secret + multi-fetch
body gate), apps/new (create wizard).
2026-06-08 15:39:25 +03:00

5197 lines
163 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types';
import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
import * as api from '$lib/api';
import {
IconRefresh,
IconDeploy,
IconTrash,
IconEdit,
IconCopy,
IconCheck,
IconChevronDown,
IconServer,
IconExternalLink,
IconPlus,
IconX,
IconPlay,
IconStop,
IconGlobe,
IconHardDrive,
IconClock,
IconLoader
} from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WorkloadNotificationsPanel from '$lib/components/WorkloadNotificationsPanel.svelte';
import WorkloadSnapshotsPanel from '$lib/components/WorkloadSnapshotsPanel.svelte';
import TriggerKindForm, {
createTriggerKindFormState,
isTriggerFormValid,
buildTriggerInput
} from '$lib/components/TriggerKindForm.svelte';
import ImageSourceForm from '$lib/components/workload/ImageSourceForm.svelte';
import ComposeSourceForm from '$lib/components/workload/ComposeSourceForm.svelte';
import StaticSourceForm from '$lib/components/workload/StaticSourceForm.svelte';
import DockerfileSourceForm from '$lib/components/workload/DockerfileSourceForm.svelte';
import {
emptyImageState,
emptyComposeState,
emptyStaticState,
emptyDockerfileState,
seedImageState,
seedComposeState,
seedStaticState,
seedDockerfileState,
imageToConfig,
composeToConfig,
staticToConfig,
dockerfileToConfig,
stringifyConfig,
isDockerfileValid,
type ImageFormState,
type ComposeFormState,
type StaticFormState,
type DockerfileFormState
} from '$lib/workload/sourceForms';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
import { formatBytes } from '$lib/format/bytes';
import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse';
import { workloadDetailCache } from '$lib/stores/caches';
// Route params come back as `string | undefined`; the route file
// guarantees `id` exists, but the empty-string fallback satisfies
// the type checker — server validation rejects empty ids anyway.
const id = $derived($page.params.id ?? '');
// Warm-seed the workload from the per-id cache so the body — gated on
// `loading && !workload` — skips the full-page skeleton on revisit (the
// other sections populate when load() resolves). Seeding synchronously at
// init (not just in the load effect) avoids even a one-frame skeleton on a
// fresh mount. Edit-mode forms seed on-demand from `workload`, so there is
// no on-load form state to clobber.
const _seed = workloadDetailCache.peek(get(page).params.id ?? '').value;
let workload = $state<Workload | null>(_seed?.workload ?? null);
// Containers are seeded too (not just the workload) so the live-status badge
// + Stop/Start toolbar reflect last-known state on a warm revisit instead of
// momentarily reading the empty array as "not deployed".
let containers = $state<Container[]>(_seed?.containers ?? []);
let loading = $state(_seed == null);
let error = $state('');
let deploying = $state(false);
let deployRef = $state('');
let lastDeployMsg = $state('');
// Edit-mode state. Mirrors the workload's source config as pretty-
// printed JSON so the user edits the same shape /apps/new produced.
// Trigger configuration moved out into the standalone Triggers
// panel — workloads no longer carry an embedded trigger config.
let editing = $state(false);
let saving = $state(false);
let editName = $state('');
let editParentID = $state('');
let editSourceConfig = $state('');
let editPublicFaces = $state('');
let confirmDelete = $state(false);
let deleting = $state(false);
// ── Source-config edit form state ──────────────────────────────────
// One state object per source kind, modelled by the shared (pure,
// unit-tested) `sourceForms.ts` module — the SAME model the /apps/new
// wizard uses. The form bodies live in dedicated components under
// $lib/components/workload/ that bind these objects; serialization to
// the PUT body's `source_config` is done in saveEdit via the module's
// serializers so the shape stays byte-identical to the legacy inline
// helpers (key order + env/volumes/storage/unknown-key rules included).
// The kind is LOCKED on edit, so only the object matching the
// workload's source_kind is ever rendered/serialized.
let editAdvancedJson = $state(false);
let editImageState = $state<ImageFormState>(emptyImageState());
let editComposeState = $state<ComposeFormState>(emptyComposeState());
let editStaticState = $state<StaticFormState>(emptyStaticState());
let editDockerfileState = $state<DockerfileFormState>(emptyDockerfileState());
const useEditComposeForm = $derived(
(workload?.source_kind ?? '') === 'compose' && !editAdvancedJson
);
const useEditImageForm = $derived(
(workload?.source_kind ?? '') === 'image' && !editAdvancedJson
);
const useEditStaticForm = $derived(
(workload?.source_kind ?? '') === 'static' && !editAdvancedJson
);
const useEditDockerfileForm = $derived(
(workload?.source_kind ?? '') === 'dockerfile' && !editAdvancedJson
);
// Registry list — populated when the edit form opens, falls back to a
// plain text input if the request fails. Bound into ImageSourceForm.
let editRegistries = $state<{ name: string; url: string }[]>([]);
// A cleared <input type="number"> binds to null (not 0) in Svelte 5, and
// `null <= 0` is false — so a bare `port <= 0` check would pass validation
// on an empty field and POST `port: null`. The module's isDockerfileValid
// guards against null/NaN/non-positive identically to the legacy check.
const editDockerfilePortValid = $derived(isDockerfileValid(editDockerfileState));
// View-mode collapse state per config block. Trigger config is no
// longer carried on the workload, so the trigger panel is gone —
// only source + faces JSON viewers remain here.
let openSource = $state(true);
let openFaces = $state(true);
// ── Trigger bindings ──────────────────────────────────────
// Workloads no longer embed a trigger; instead they hold a list of
// bindings to standalone Trigger records. The bindings panel
// renders this list with per-row enable/unbind actions and an
// "Add trigger" modal that supports inline-create or pick-existing.
let bindings = $state<WorkloadTriggerBinding[]>([]);
let bindingsLoading = $state(false);
let bindingsError = $state('');
let confirmUnbindId = $state<string | null>(null);
// ── Per-binding override editor ──────────────────────────
// Each binding can override fields of its parent trigger's
// config via `binding_config` (top-level merge, binding wins).
// State is keyed by binding id. The base trigger config is
// lazy-fetched the first time a row's override panel is opened;
// the editable JSON text mirrors the binding's current
// `binding_config` and is round-tripped to the backend on save.
const BINDING_CONFIG_MAX_BYTES = 8 * 1024;
let openOverrideId = $state<string | null>(null);
let overrideTexts = $state<Record<string, string>>({});
let overrideBaseConfigs = $state<Record<string, string>>({});
let overrideBaseLoading = $state<Record<string, boolean>>({});
let overrideSaving = $state<Record<string, boolean>>({});
let overrideErrors = $state<Record<string, string>>({});
function formatJsonValue(v: unknown): string {
try {
return JSON.stringify(v ?? {}, null, 2);
} catch {
return '{}';
}
}
function parseOverrideObject(text: string): { ok: true; value: Record<string, unknown> } | { ok: false } {
const t = text.trim();
if (!t) return { ok: true, value: {} };
try {
const parsed = JSON.parse(t);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false };
}
return { ok: true, value: parsed as Record<string, unknown> };
} catch {
return { ok: false };
}
}
function overrideKeyCount(v: unknown): number {
if (!v || typeof v !== 'object' || Array.isArray(v)) return 0;
return Object.keys(v as Record<string, unknown>).length;
}
function byteLength(s: string): number {
try {
return new TextEncoder().encode(s).length;
} catch {
return s.length;
}
}
function overrideJsonValid(text: string): boolean {
const r = parseOverrideObject(text);
return r.ok;
}
function overrideKeyCountFromText(text: string): number {
const r = parseOverrideObject(text);
return r.ok ? Object.keys(r.value).length : 0;
}
function overrideTextSize(text: string): number {
const t = text.trim();
if (!t) return 0;
try {
const parsed = JSON.parse(t);
return byteLength(JSON.stringify(parsed));
} catch {
return byteLength(t);
}
}
function mergedPreview(baseText: string, overrideText: string): string {
let base: Record<string, unknown> = {};
try {
const parsed = JSON.parse(baseText || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
base = parsed as Record<string, unknown>;
}
} catch {
// fall through with empty base
}
const ov = parseOverrideObject(overrideText);
const merged = ov.ok ? { ...base, ...ov.value } : base;
return formatJsonValue(merged);
}
async function toggleOverridePanel(b: WorkloadTriggerBinding): Promise<void> {
if (openOverrideId === b.id) {
openOverrideId = null;
return;
}
openOverrideId = b.id;
// Seed editable text with current binding_config (or "{}").
if (!(b.id in overrideTexts)) {
overrideTexts = {
...overrideTexts,
[b.id]: formatJsonValue(b.binding_config ?? {})
};
}
// Lazy-fetch the trigger's base config the first time we open.
if (!(b.trigger_id in overrideBaseConfigs) && !overrideBaseLoading[b.trigger_id]) {
overrideBaseLoading = { ...overrideBaseLoading, [b.trigger_id]: true };
try {
const tr = await api.getTrigger(b.trigger_id);
overrideBaseConfigs = {
...overrideBaseConfigs,
[b.trigger_id]: formatJsonValue(tr.config ?? {})
};
} catch (e) {
overrideErrors = {
...overrideErrors,
[b.id]: e instanceof Error ? e.message : 'Failed to load trigger'
};
} finally {
overrideBaseLoading = { ...overrideBaseLoading, [b.trigger_id]: false };
}
}
}
async function saveOverride(b: WorkloadTriggerBinding): Promise<void> {
const text = overrideTexts[b.id] ?? '';
const parsed = parseOverrideObject(text);
if (!parsed.ok) {
overrideErrors = {
...overrideErrors,
[b.id]: $t('apps.detail.bindings.override.errInvalidJson')
};
return;
}
if (overrideTextSize(text) > BINDING_CONFIG_MAX_BYTES) {
overrideErrors = {
...overrideErrors,
[b.id]: $t('apps.detail.bindings.override.errTooLarge')
};
return;
}
overrideErrors = { ...overrideErrors, [b.id]: '' };
overrideSaving = { ...overrideSaving, [b.id]: true };
try {
const updated = await api.updateBinding(b.id, { binding_config: parsed.value });
bindings = bindings.map((x) =>
x.id === b.id ? { ...x, binding_config: updated.binding_config } : x
);
// Re-pretty so the editor matches the canonical normalized form.
overrideTexts = {
...overrideTexts,
[b.id]: formatJsonValue(updated.binding_config ?? {})
};
} catch (e) {
overrideErrors = {
...overrideErrors,
[b.id]: e instanceof Error ? e.message : 'Save failed'
};
} finally {
overrideSaving = { ...overrideSaving, [b.id]: false };
}
}
async function resetOverride(b: WorkloadTriggerBinding): Promise<void> {
overrideErrors = { ...overrideErrors, [b.id]: '' };
overrideSaving = { ...overrideSaving, [b.id]: true };
try {
const updated = await api.updateBinding(b.id, { binding_config: {} });
bindings = bindings.map((x) =>
x.id === b.id ? { ...x, binding_config: updated.binding_config } : x
);
overrideTexts = {
...overrideTexts,
[b.id]: formatJsonValue(updated.binding_config ?? {})
};
} catch (e) {
overrideErrors = {
...overrideErrors,
[b.id]: e instanceof Error ? e.message : 'Reset failed'
};
} finally {
overrideSaving = { ...overrideSaving, [b.id]: false };
}
}
// Add-binding modal state. The modal has two tabs (Inline / Pick)
// and lazy-loads the existing-trigger list the first time the
// "Pick" tab is opened.
let addModalOpen = $state(false);
let addModalTab = $state<'inline' | 'pick'>('inline');
let addModalSubmitting = $state(false);
let addModalError = $state('');
let inlineTriggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
let pickTriggerId = $state('');
let pickKindFilter = $state('');
let availableTriggers = $state<RedeployTrigger[]>([]);
let availableTriggersLoaded = $state(false);
// Filtered list for the picker, derived from the kind filter.
const availableTriggersFiltered = $derived(
pickKindFilter
? availableTriggers.filter((t) => t.kind === pickKindFilter)
: availableTriggers
);
// Tiny copy-button affordance — shows a check for 1.2s after success.
let copied = $state<Record<string, boolean>>({});
// ── Env vars ───────────────────────────────────────────────
let envRows = $state<api.WorkloadEnv[]>([]);
let envError = $state('');
let envSaving = $state(false);
let newEnvKey = $state('');
let newEnvValue = $state('');
let newEnvEncrypted = $state(true);
// Workload-side webhook UI was removed in the hard legacy cutover —
// inbound webhooks are now first-class Triggers. Use the bindings
// panel + the /triggers detail page to manage the webhook URL.
// ── Logs viewer ────────────────────────────────────────────
let logContainerRowID = $state<string | null>(null);
// ── Volumes ────────────────────────────────────────────────
let volumeRows = $state<api.WorkloadVolume[]>([]);
let volumeError = $state('');
let volumeSaving = $state(false);
let newVolSource = $state('');
let newVolTarget = $state('');
let newVolScope = $state('absolute');
// ── Chain (parent / self / children) ──────────────────────
let chain = $state<api.WorkloadChain | null>(null);
let chainError = $state('');
let promoting = $state<string | null>(null); // sourceID we're promoting FROM, for UI lock
// ── Branch preview environments ───────────────────────────
// Preview children are the chain children the backend flagged
// `is_preview` — per-branch deploys materialized from this workload
// (the template) when a bound git trigger has a branch_pattern. The
// chain DTO omits public_faces, so we lazy-fetch each preview child's
// full workload to derive its slug-prefixed FQDN (reusing the same
// deriveFaceFqdn helper the hero "open site" link uses).
interface PreviewMeta {
fqdn: string;
state: string; // primary container state, '' when none/unknown
loaded: boolean;
}
let previewMeta = $state<Record<string, PreviewMeta>>({});
let confirmTeardownId = $state<string | null>(null); // preview child id pending teardown
let tearingDown = $state(false);
const previewChildren = $derived(
chain ? chain.children.filter((c) => c.is_preview) : []
);
// Preview-armed detection: a workload is "armed" for previews when any
// of its enabled git-trigger bindings carries a non-empty branch_pattern.
// branch_pattern can live on the binding's override OR on the parent
// trigger's config — the binding override wins (top-level merge), mirroring
// how the backend resolves effective trigger config. triggersById is
// loaded fire-and-forget on page load; if it hasn't arrived yet we fall
// back to the binding override alone.
function readBranchPattern(v: unknown): string {
if (v && typeof v === 'object' && !Array.isArray(v)) {
const bp = (v as Record<string, unknown>).branch_pattern;
if (typeof bp === 'string') return bp.trim();
}
return '';
}
const armedBranchPattern = $derived.by(() => {
for (const b of bindings) {
if (!b.enabled || b.trigger_kind !== 'git') continue;
const fromOverride = readBranchPattern(b.binding_config);
if (fromOverride) return fromOverride;
const fromTrigger = readBranchPattern(triggersById[b.trigger_id]?.config);
if (fromTrigger) return fromTrigger;
}
return '';
});
const isPreviewArmed = $derived(armedBranchPattern !== '');
// Lazy-load each preview child's full record to derive its FQDN +
// container state. Sequenced per-load so a stale chain refresh can't
// clobber a newer one; failures degrade to a row with no link.
let previewMetaReqSeq = 0;
async function loadPreviewMeta(): Promise<void> {
const mine = ++previewMetaReqSeq;
const kids = previewChildren;
if (kids.length === 0) {
previewMeta = {};
return;
}
const results = await Promise.all(
kids.map(async (child): Promise<[string, PreviewMeta]> => {
try {
const [w, cs] = await Promise.all([
api.getWorkload(child.id),
api.listWorkloadContainers(child.id).catch(() => [] as Container[])
]);
return [child.id, { fqdn: deriveFaceFqdn(w.public_faces), state: cs[0]?.state ?? '', loaded: true }];
} catch {
return [child.id, { fqdn: '', state: '', loaded: true }];
}
})
);
if (mine !== previewMetaReqSeq) return;
const next: Record<string, PreviewMeta> = {};
for (const [cid, meta] of results) next[cid] = meta;
previewMeta = next;
}
// Fire-and-forget load of the most recent activity for this workload.
// Non-fatal on failure: the panel just shows its empty state.
async function loadActivity(): Promise<void> {
activityLoading = true;
try {
activityEvents = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE });
activityOffset = activityEvents.length;
activityHasMore = activityEvents.length === ACTIVITY_PAGE;
} catch {
// Non-fatal: panel shows empty state.
} finally {
activityLoading = false;
}
}
// Page in older events below the live-prepended head. The global events
// page can't filter by workload, so this is the only per-app history view.
async function loadMoreActivity(): Promise<void> {
if (activityLoadingMore || !activityHasMore) return;
activityLoadingMore = true;
try {
const more = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE, offset: activityOffset });
// Dedup by id: a live SSE prepend can shift the offset window by one,
// so a page boundary may re-return an already-shown row. {#each (entry.id)}
// REQUIRES unique keys, so drop duplicates.
const seen = new Set(activityEvents.map((e) => e.id));
const fresh = more.filter((e) => !seen.has(e.id));
activityEvents = [...activityEvents, ...fresh];
activityOffset += more.length;
activityHasMore = more.length === ACTIVITY_PAGE;
} catch {
// Non-fatal; leave the list as-is.
} finally {
activityLoadingMore = false;
}
}
async function doTeardownPreview(): Promise<void> {
if (!confirmTeardownId || tearingDown) return;
const cid = confirmTeardownId;
tearingDown = true;
chainError = '';
try {
// Reuse the existing workload-delete endpoint — deleting a preview
// child tears down its containers + proxy routes server-side. No
// dedicated preview-mutation endpoint exists or is needed.
await api.deletePluginWorkload(cid);
confirmTeardownId = null;
// Refresh the chain so the torn-down preview disappears.
chain = await api.getWorkloadChain(id);
await loadPreviewMeta();
} catch (e) {
chainError = e instanceof Error ? e.message : $t('apps.detail.previews.teardownFailed');
} finally {
tearingDown = false;
}
}
const teardownTarget = $derived(
confirmTeardownId ? previewChildren.find((c) => c.id === confirmTeardownId) ?? null : null
);
// ── Log scan rules (effective set for this workload) ─────
// The backend's EffectiveLogScanRules resolves globals minus
// per-workload overrides, plus workload-only additions, plus
// the substituted override rows themselves. We classify each
// row at render time so the UI can offer the right action
// (override / edit / delete) per row.
let logRules = $state<api.LogScanRule[]>([]);
let logRulesError = $state('');
let logRuleOverriding = $state<number | null>(null); // global id being overridden, for UI lock
// ── Runtime / storage panels (static workloads) ─────────
// Independent fetches off the main load() to keep their failures
// isolated — runtime-state and storage probes can return 5xx if
// the source container is mid-flight without blocking the rest of
// the detail page from rendering.
let runtimeState = $state<api.WorkloadRuntimeState | null>(null);
let runtimeError = $state('');
let storage = $state<api.WorkloadStorageUsage | null>(null);
let storageError = $state('');
let stopping = $state(false);
let starting = $state(false);
// ── Activity timeline (per-app deploy/event feed) ───────
// Read-only panel: most-recent events for this workload, kept live by
// the global SSE stream. activityNewIds drives the brief fade-in on
// freshly-arrived rows, mirroring the global events page.
const ACTIVITY_PAGE = 25;
let activityEvents = $state<EventLogEntry[]>([]);
let activityLoading = $state(true);
let activityNewIds = $state<Set<number>>(new Set());
let activityOffset = $state(0);
let activityHasMore = $state(false);
let activityLoadingMore = $state(false);
// Client-side severity filter over the already-loaded rows (no refetch).
let activitySeverity = $state<'all' | 'error'>('all');
const visibleActivity = $derived(
activitySeverity === 'all'
? activityEvents
: activityEvents.filter((e) => e.severity === activitySeverity)
);
// Sequence tokens + abort controllers so a slow in-flight probe
// cannot overwrite a faster newer one's result, and so an in-flight
// request is cancelled when the page unmounts (the user navigates
// away from /apps/[id] before the probe resolves).
let runtimeReqSeq = 0;
let storageReqSeq = 0;
let runtimeAbort: AbortController | null = null;
let storageAbort: AbortController | null = null;
// Live build-log tail (dockerfile workloads only). Capped to keep
// memory bounded: a `RUN apt-get` step can emit tens of thousands of
// lines and the DOM would melt without the cap. Latest lines win.
const BUILD_LOG_MAX_LINES = 300;
interface BuildLogLine {
id: number;
text: string;
}
let buildLogLines = $state<BuildLogLine[]>([]);
// Monotonic id so the {#each} can key on a STABLE value. Keying by array
// index re-keys nearly every row once the head-slice cap kicks in (index
// i maps to a different string each append), forcing Svelte to re-render
// all 300 nodes per line on the exact hot path the cap exists to protect.
let buildLogSeq = 0;
let buildLogTailEl: HTMLDivElement | null = $state(null);
let sseConn: SSEConnection | null = null;
function appendBuildLogLine(line: string): void {
// Mutating-via-spread keeps Svelte's reactivity happy. Slice from
// the head once the cap is exceeded so memory doesn't grow with
// long builds.
const entry: BuildLogLine = { id: buildLogSeq++, text: line };
const next = buildLogLines.length >= BUILD_LOG_MAX_LINES
? [...buildLogLines.slice(buildLogLines.length - BUILD_LOG_MAX_LINES + 1), entry]
: [...buildLogLines, entry];
buildLogLines = next;
// Auto-scroll to bottom on next paint. requestAnimationFrame
// rather than setTimeout(0) so the DOM has actually settled and
// scrollHeight reflects the newly appended row.
if (buildLogTailEl) {
const el = buildLogTailEl;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
}
function clearBuildLog(): void {
buildLogLines = [];
}
// Trigger metadata map keyed by trigger id. Bindings already carry
// kind + name, but `webhook_enabled` lives only on the parent
// RedeployTrigger. We pull the full list once per page load and
// look up flags from this map (cheap join over a small set).
let triggersById = $state<Record<string, api.RedeployTrigger>>({});
let triggersReqSeq = 0;
let triggersAbort: AbortController | null = null;
async function loadTriggerMeta() {
const mine = ++triggersReqSeq;
triggersAbort?.abort();
const controller = new AbortController();
triggersAbort = controller;
try {
const list = await api.listTriggers(undefined, controller.signal);
if (mine !== triggersReqSeq) return;
const next: Record<string, api.RedeployTrigger> = {};
for (const tr of list) next[tr.id] = tr;
triggersById = next;
} catch {
if (mine !== triggersReqSeq) return;
if (controller.signal.aborted) return;
// Soft-fail: the webhook summary panel will just render empty
// rather than blocking the whole detail page on a triggers fetch.
triggersById = {};
}
}
// Source kinds that publish a typed runtime-state into containers.extra_json.
// Both static and dockerfile use the same shape (status / last_commit_sha /
// last_sync_at / last_error) so the same panel renders them identically.
const runtimeStateKinds = new Set(['static', 'dockerfile']);
const hasRuntimeState = $derived(
!!workload && runtimeStateKinds.has(workload.source_kind)
);
async function loadRuntimeState() {
if (!workload || !runtimeStateKinds.has(workload.source_kind)) return;
const kind = workload.source_kind;
const mine = ++runtimeReqSeq;
runtimeAbort?.abort();
const controller = new AbortController();
runtimeAbort = controller;
runtimeError = '';
try {
const result = await api.getWorkloadRuntimeState(id, controller.signal);
// Re-check after the await: the active workload may have
// switched kinds (or the page may have unmounted) while the
// request was in flight. Both reqSeq and the kind snapshot
// must still agree for us to commit the result.
if (mine !== runtimeReqSeq || workload?.source_kind !== kind) return;
runtimeState = result;
} catch (e) {
if (mine !== runtimeReqSeq) return;
if (controller.signal.aborted) return;
runtimeState = null;
runtimeError = e instanceof Error ? e.message : $t('apps.detail.errors.runtimeStateFailed');
}
}
async function loadStorage() {
if (!workload || workload.source_kind !== 'static') return;
// Only ping the storage endpoint when the source config opted in;
// otherwise the backend returns enabled:false and we'd render an
// empty card anyway.
let enabled = false;
try {
const cfg = JSON.parse(workload.source_config || '{}') as Record<string, unknown>;
enabled = cfg?.storage_enabled === true;
} catch {
enabled = false;
}
if (!enabled) {
storage = null;
return;
}
const kind = workload.source_kind;
const mine = ++storageReqSeq;
storageAbort?.abort();
const controller = new AbortController();
storageAbort = controller;
storageError = '';
try {
const result = await api.getWorkloadStorage(id, controller.signal);
if (mine !== storageReqSeq || workload?.source_kind !== kind) return;
storage = result;
} catch (e) {
if (mine !== storageReqSeq) return;
if (controller.signal.aborted) return;
storage = null;
storageError = e instanceof Error ? e.message : $t('apps.detail.errors.storageFailed');
}
}
async function doStop() {
if (stopping) return;
stopping = true;
error = '';
try {
await api.stopWorkload(id);
} catch (e) {
// Stop returns 409 when there's no container to act on and
// 502 when every container failed. Narrow on ApiError so
// the variant message is keyed off the status code rather
// than a fragile regex over Error.message.
if (e instanceof api.ApiError && e.status === 409) {
error = $t('apps.detail.errors.stopNothing');
} else if (e instanceof api.ApiError && e.status === 502) {
error = $t('apps.detail.errors.stopAllFailed');
} else if (e instanceof Error) {
error = e.message;
} else {
error = $t('apps.detail.errors.stopFailed');
}
} finally {
// Always reload — Docker may have moved containers to an
// intermediate state we need to show even when the call
// failed. `load()` clears `error` on success, so snapshot
// the failure message and restore it after so the operator
// still sees what went wrong.
const failureMsg = error;
try {
await load();
await loadRuntimeState();
} catch {
/* keep snapshot */
}
if (failureMsg) error = failureMsg;
stopping = false;
}
}
async function doStart() {
if (starting) return;
starting = true;
error = '';
try {
await api.startWorkload(id);
} catch (e) {
if (e instanceof api.ApiError && e.status === 409) {
error = $t('apps.detail.errors.startNothing');
} else if (e instanceof api.ApiError && e.status === 502) {
error = $t('apps.detail.errors.startAllFailed');
} else if (e instanceof Error) {
error = e.message;
} else {
error = $t('apps.detail.errors.startFailed');
}
} finally {
// Same reload-on-finally + restore-error rule as doStop.
const failureMsg = error;
try {
await load();
await loadRuntimeState();
} catch {
/* keep snapshot */
}
if (failureMsg) error = failureMsg;
starting = false;
}
}
function runtimeStatusClass(s: string | undefined): string {
const norm = (s || '').toLowerCase();
if (norm === 'deployed') return 'rt-ok';
if (norm === 'syncing') return 'rt-busy';
if (norm === 'failed' || norm === 'error') return 'rt-bad';
if (norm === 'stopped') return 'rt-idle';
return 'rt-idle';
}
// Map a preview child's primary container state to the shared rt-badge
// tone class + a human label. Reuses the rt-ok/busy/bad/idle palette so
// the preview pills read consistently with the hero live-badge.
function liveBadgeToneClass(state: string): string {
const s = (state || '').toLowerCase();
if (s === 'running') return 'rt-ok';
if (s === 'restarting' || s === 'paused' || s === 'created' || s === 'starting')
return 'rt-busy';
if (s === 'exited' || s === 'dead' || s === 'stopped' || s === 'failed' || s === 'missing')
return 'rt-bad';
return 'rt-idle';
}
function previewStateLabel(state: string): string {
const s = (state || '').toLowerCase();
if (s === 'running') return $t('apps.detail.previews.stateRunning');
if (s === 'restarting' || s === 'paused' || s === 'created' || s === 'starting')
return $t('apps.detail.previews.statePending');
if (s === 'exited' || s === 'dead' || s === 'stopped' || s === 'failed' || s === 'missing')
return $t('apps.detail.previews.stateStopped');
return $t('apps.detail.previews.stateUnknown');
}
function usageBarClass(pct: number): string {
if (pct >= 0.9) return 'bar-red';
if (pct >= 0.7) return 'bar-amber';
return 'bar-green';
}
// Derived: visibility of toolbar controls.
// We treat a running container OR a "deployed" status (which is the
// post-build state for static workloads that don't carry a container)
// as "stoppable". Conversely a "stopped" status or an explicitly
// stopped container row makes the workload "startable". When the
// signals disagree (e.g. status=deployed but state=exited) we err on
// the side of showing both, letting the user choose.
const primaryState = $derived(containers[0]?.state ?? '');
const runtimeStatus = $derived(runtimeState?.status ?? '');
const canStop = $derived(
primaryState === 'running' ||
runtimeStatus === 'deployed' ||
runtimeStatus === 'syncing'
);
const canStart = $derived(
primaryState === 'stopped' ||
primaryState === 'exited' ||
runtimeStatus === 'stopped' ||
runtimeStatus === 'failed' ||
(containers.length > 0 && primaryState !== 'running' && primaryState !== '')
);
// Derive a workload's primary public-face FQDN from its public_faces JSON
// blob. Shared by the hero "open site ↗" affordance (via firstFaceFqdn)
// and the preview-environments panel, which derives the same FQDN from
// each preview child's own slug-prefixed public_faces. Returns '' when the
// blob is empty/malformed or the first face has neither subdomain nor
// domain.
function deriveFaceFqdn(publicFacesJson: string | undefined | null): string {
try {
const parsed = JSON.parse(publicFacesJson || '[]');
if (!Array.isArray(parsed) || parsed.length === 0) return '';
const face = parsed[0] as { domain?: string; subdomain?: string };
const dom = (face.domain || '').trim();
const sub = (face.subdomain || '').trim();
if (dom && sub) return `${sub}.${dom}`;
return dom || sub;
} catch {
return '';
}
}
const firstFaceFqdn = $derived(deriveFaceFqdn(workload?.public_faces));
const storageEnabledOnSource = $derived.by(() => {
if (!workload || workload.source_kind !== 'static') return false;
try {
const cfg = JSON.parse(workload.source_config || '{}') as Record<string, unknown>;
return cfg?.storage_enabled === true;
} catch {
return false;
}
});
const storageUsedPct = $derived.by(() => {
if (!storage || !storage.limit_mb || storage.limit_mb <= 0) return 0;
return Math.min(1, storage.used_bytes / (storage.limit_mb * 1024 * 1024));
});
// Universal "live state" pill rendered in the hero lede for every
// source kind. Reflects ONLY container runtime state — for static
// workloads the dedicated runtime panel below still owns the
// deployed/syncing/failed nuance. Kept derived (no fetch) so it
// updates immediately when the existing `containers` state refreshes.
type LiveBadgeTone = 'ok' | 'busy' | 'bad' | 'idle';
type LiveBadge = { tone: LiveBadgeTone; labelKey: string; count?: { running: number; total: number } };
const liveBadge = $derived.by<LiveBadge>(() => {
const total = containers.length;
if (total === 0) {
return { tone: 'idle', labelKey: 'apps.detail.liveBadge.notDeployed' };
}
let running = 0;
let transitioning = 0;
let stopped = 0;
for (const c of containers) {
const s = c.state;
if (s === 'running') running += 1;
else if (s === 'restarting' || s === 'paused' || s === 'created' || s === 'starting') transitioning += 1;
else if (s === 'exited' || s === 'dead' || s === 'stopped') stopped += 1;
}
if (running === total) return { tone: 'ok', labelKey: 'apps.detail.liveBadge.running' };
if (stopped === total) return { tone: 'bad', labelKey: 'apps.detail.liveBadge.stopped' };
if (transitioning > 0 && running === 0 && stopped === 0) {
return { tone: 'busy', labelKey: 'apps.detail.liveBadge.transitioning' };
}
return {
tone: 'busy',
labelKey: 'apps.detail.liveBadge.mixed',
count: { running, total }
};
});
// Webhook bindings — bindings whose underlying trigger has the
// inbound webhook channel enabled. We surface kind + name from the
// binding row directly and join `webhook_enabled` against the
// triggers-by-id map (populated by loadTriggerMeta on page load).
// Used by the read-only "Webhook bindings" summary panel.
const webhookBindings = $derived.by(() =>
bindings.filter((b) => triggersById[b.trigger_id]?.webhook_enabled === true)
);
async function load() {
const k = id;
// Reused-component nav (A→B): warm-seed B's header instantly if cached;
// a cold id keeps the skeleton until the fetch resolves.
const cached = workloadDetailCache.peek(k);
if (cached.value) {
workload = cached.value.workload;
containers = cached.value.containers;
// Reset the non-seeded dependent panels so a reused-component warm
// nav (A→B) never renders the previous id's chain / bindings / rules
// / env / volumes under the new id's header; they repopulate when
// this id's Promise.all below resolves.
chain = null;
bindings = [];
logRules = [];
envRows = [];
volumeRows = [];
chainError = '';
logRulesError = '';
bindingsError = '';
} else {
loading = true;
}
error = '';
try {
const [w, c, env, vols, ch, lr, bs] = await Promise.all([
api.getWorkload(id),
api.listWorkloadContainers(id),
api.listWorkloadEnv(id).catch(() => [] as api.WorkloadEnv[]),
api.listWorkloadVolumes(id).catch(() => [] as api.WorkloadVolume[]),
api.getWorkloadChain(id).catch((e) => {
chainError = e instanceof Error ? e.message : 'chain load failed';
return null;
}),
api.getEffectiveLogScanRules(id).catch((e) => {
logRulesError = e instanceof Error ? e.message : 'rules load failed';
return [] as api.LogScanRule[];
}),
api.listBindingsForWorkload(id).catch((e) => {
bindingsError = e instanceof Error ? e.message : 'bindings load failed';
return [] as WorkloadTriggerBinding[];
})
]);
// Bail if the route id changed mid-flight — a newer load() owns the
// page state and applying this stale result would clobber it.
if (id !== k) return;
workload = w;
containers = c;
workloadDetailCache.set(k, { workload: w, containers: c });
envRows = env;
volumeRows = vols;
chain = ch;
logRules = lr;
bindings = bs;
// Fire-and-forget trigger metadata load. The webhook-bindings
// panel joins `webhook_enabled` off this map; failure renders
// an empty webhook list, never blocks the page.
void loadTriggerMeta();
// Fire-and-forget per-preview FQDN + state probe. The chain DTO
// omits public_faces, so the preview-environments panel resolves
// each preview child's slug-prefixed URL from its full record.
void loadPreviewMeta();
// Fire-and-forget activity-timeline load. Failure is swallowed
// inside loadActivity; the panel falls back to its empty state.
void loadActivity();
// Fire-and-forget runtime / storage probes for static workloads.
// Failure is captured into their dedicated *_error fields and
// must not break the rest of the detail page render.
if (w.source_kind === 'static') {
void loadRuntimeState();
void loadStorage();
} else {
runtimeState = null;
storage = null;
}
// If we routed here from /apps/new with a deferred bind
// failure, surface it once and clear the flag.
try {
const key = `tinyforge.bindError.${id}`;
const stored = sessionStorage.getItem(key);
if (stored) {
bindingsError = stored;
sessionStorage.removeItem(key);
}
} catch {
// session storage may be disabled — ignore.
}
} catch (e) {
if (id !== k) return;
// Clear the (possibly warm-seeded) workload so a 404 / failed load
// resolves to the clean error page instead of a phantom, interactive
// detail UI for an entity that no longer exists.
workload = null;
error = e instanceof Error ? e.message : $t('apps.detail.loadError');
} finally {
// Only the current id's load may clear the skeleton — a stale load
// returning early must not flip a newer load's loading state.
if (id === k) loading = false;
}
}
// Classify each effective rule into one of three buckets so the
// UI can offer the right action. Mirrors the backend's
// EffectiveLogScanRules resolution but operates on the result
// shape — no extra round-trip required.
type RuleKind = 'global' | 'workload' | 'override';
function classifyRule(r: api.LogScanRule): RuleKind {
if (r.overrides_id !== 0) return 'override';
if (r.workload_id !== '') return 'workload';
return 'global';
}
// Create a per-workload override row that substitutes for the
// global in this workload's effective set. The override starts
// as a copy of the global so operators can tweak severity /
// streams / cooldown without rebuilding the regex from scratch.
async function overrideRule(global: api.LogScanRule) {
logRulesError = '';
logRuleOverriding = global.id;
try {
await api.createLogScanRule({
workload_id: id,
overrides_id: global.id,
name: global.name,
pattern: global.pattern,
severity: global.severity,
streams: global.streams,
cooldown_seconds: global.cooldown_seconds,
enabled: global.enabled
});
await reloadLogRules();
} catch (e) {
logRulesError = e instanceof Error ? e.message : 'Override failed';
} finally {
logRuleOverriding = null;
}
}
async function reloadLogRules() {
try {
logRules = await api.getEffectiveLogScanRules(id);
logRulesError = '';
} catch (e) {
logRulesError = e instanceof Error ? e.message : 'rules reload failed';
}
}
async function promoteFrom(sourceID: string) {
chainError = '';
promoting = sourceID;
try {
await api.promoteFromWorkload(id, sourceID, { deploy: true });
await load();
} catch (e) {
chainError = e instanceof Error ? e.message : 'Promote failed';
} finally {
promoting = null;
}
}
async function addVolume() {
volumeError = '';
if (!newVolTarget.trim().startsWith('/')) {
volumeError = $t('apps.detail.volumeTargetError');
return;
}
volumeSaving = true;
try {
await api.setWorkloadVolume(id, {
source: newVolSource.trim(),
target: newVolTarget.trim(),
scope: newVolScope
});
newVolSource = '';
newVolTarget = '';
volumeRows = await api.listWorkloadVolumes(id);
} catch (e) {
volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeSetFailed');
} finally {
volumeSaving = false;
}
}
async function removeVolume(volID: string) {
volumeError = '';
try {
await api.deleteWorkloadVolume(id, volID);
volumeRows = volumeRows.filter((v) => v.id !== volID);
} catch (e) {
volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeDeleteFailed');
}
}
async function addEnv() {
envError = '';
const key = newEnvKey.trim();
if (!key) {
envError = $t('apps.detail.envKeyRequired');
return;
}
envSaving = true;
try {
await api.setWorkloadEnv(id, {
key,
value: newEnvValue,
encrypted: newEnvEncrypted
});
newEnvKey = '';
newEnvValue = '';
envRows = await api.listWorkloadEnv(id);
} catch (e) {
envError = e instanceof Error ? e.message : $t('apps.detail.envSetFailed');
} finally {
envSaving = false;
}
}
async function removeEnv(envID: string) {
envError = '';
try {
await api.deleteWorkloadEnv(id, envID);
envRows = envRows.filter((e) => e.id !== envID);
} catch (e) {
envError = e instanceof Error ? e.message : $t('apps.detail.envDeleteFailed');
}
}
async function deploy() {
deploying = true;
lastDeployMsg = '';
error = '';
try {
const body = deployRef ? { reference: deployRef } : undefined;
const res = await api.deployPluginWorkload(id, body);
lastDeployMsg = $t('apps.detail.manualDeployDispatched', {
reference: res.reference || '(default)',
by: res.triggered_by
});
setTimeout(() => {
void load();
void loadRuntimeState();
}, 1500);
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.detail.deployError');
} finally {
deploying = false;
}
}
function prettyJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function jsonOk(s: string): boolean {
try {
JSON.parse(s);
return true;
} catch {
return false;
}
}
function startEdit() {
if (!workload) return;
editName = workload.name;
editParentID = workload.parent_workload_id || '';
editSourceConfig = prettyJson(workload.source_config);
editPublicFaces = prettyJson(workload.public_faces || '[]');
editAdvancedJson = false;
// Seed the matching kind's state object from the workload's
// source_config. Kind is locked on edit, so only this one is ever
// rendered/serialized — but reseeding via the module keeps the seed
// logic identical to /apps/new (and to the legacy inline seeders).
seedEditFormState(workload.source_kind, editSourceConfig);
// Fire-and-forget registry fetch — purely for the picker
// dropdown. Failure leaves the field as a plain text input.
void api
.listRegistries()
.then((rs) => {
editRegistries = rs.map((r) => ({ name: r.name, url: r.url }));
})
.catch(() => {
editRegistries = [];
});
editing = true;
}
// Seed the per-kind state object from a source_config JSON blob via the
// shared `sourceForms.ts` seeders. Thin wrappers — only the workload's
// (locked) kind is reseeded.
function seedEditFormState(kind: string, jsonText: string): void {
if (kind === 'compose') editComposeState = seedComposeState(jsonText);
else if (kind === 'image') editImageState = seedImageState(jsonText);
else if (kind === 'static') editStaticState = seedStaticState(jsonText);
else if (kind === 'dockerfile') editDockerfileState = seedDockerfileState(jsonText);
}
// Serialize the per-kind state object back to the canonical JSON string
// for the Advanced-JSON editor view. env/volumes (image), storage_*
// (static), and unknown keys (dockerfile) are preserved from the current
// editSourceConfig via the module serializers — byte-identical to the
// legacy edit*FormBody helpers.
function editFormStateToJSON(kind: string): string {
if (kind === 'compose') return stringifyConfig(composeToConfig(editComposeState));
if (kind === 'image') return stringifyConfig(imageToConfig(editImageState, editSourceConfig));
if (kind === 'static') return stringifyConfig(staticToConfig(editStaticState, editSourceConfig));
if (kind === 'dockerfile')
return stringifyConfig(dockerfileToConfig(editDockerfileState, editSourceConfig));
return editSourceConfig;
}
// Toggle between the kind-aware form and the raw JSON editor. Going to
// Advanced JSON commits the form fields into editSourceConfig first so
// the user sees the same data; going back re-seeds from the JSON so
// manual edits aren't lost. Mirrors /apps/new's toggleAdvancedJSON.
function toggleEditAdvancedJSON() {
const kind = workload?.source_kind ?? '';
if (!editAdvancedJson) {
editSourceConfig = editFormStateToJSON(kind);
editAdvancedJson = true;
} else {
seedEditFormState(kind, editSourceConfig);
editAdvancedJson = false;
}
}
function cancelEdit() {
editing = false;
error = '';
}
async function saveEdit() {
if (!workload) return;
saving = true;
error = '';
try {
let parsedSource: unknown;
let parsedFaces: unknown[];
// All four form paths route through the shared sourceForms.ts
// serializers so the PUT source_config is byte-identical to the
// legacy inline edit*FormBody helpers (key order + env/volumes
// (image), storage_* (static), and unknown-key preserve/scrub
// (dockerfile) rules included). env/volumes/storage_*/unknown keys
// are preserved from editSourceConfig.
if (useEditComposeForm) {
parsedSource = composeToConfig(editComposeState);
} else if (useEditImageForm) {
parsedSource = imageToConfig(editImageState, editSourceConfig);
} else if (useEditStaticForm) {
parsedSource = staticToConfig(editStaticState, editSourceConfig);
} else if (useEditDockerfileForm) {
parsedSource = dockerfileToConfig(editDockerfileState, editSourceConfig);
} else {
try {
parsedSource = JSON.parse(editSourceConfig);
} catch {
throw new Error('source_config is not valid JSON');
}
}
try {
const f = JSON.parse(editPublicFaces);
if (!Array.isArray(f)) throw new Error('public_faces must be an array');
parsedFaces = f as unknown[];
} catch (e) {
throw new Error(
`public_faces invalid: ${e instanceof Error ? e.message : 'parse error'}`
);
}
// Trigger fields are no longer carried on the workload row.
// We pass empty placeholders so the legacy backend path is
// effectively a no-op; bindings are managed via the
// dedicated Triggers panel below.
const body: PluginWorkloadInput = {
name: editName.trim(),
parent_workload_id: editParentID.trim(),
source_kind: workload.source_kind,
source_config: parsedSource,
trigger_kind: '',
trigger_config: {},
public_faces: parsedFaces as PluginWorkloadInput['public_faces']
};
await api.updatePluginWorkload(id, body);
editing = false;
await load();
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.detail.saveError');
} finally {
saving = false;
}
}
// ── Binding helpers ─────────────────────────────────────────
async function reloadBindings(): Promise<void> {
bindingsLoading = true;
try {
bindings = await api.listBindingsForWorkload(id);
bindingsError = '';
} catch (e) {
bindingsError = e instanceof Error ? e.message : $t('apps.detail.bindings.loadError');
} finally {
bindingsLoading = false;
}
}
async function toggleBinding(b: WorkloadTriggerBinding, next: boolean): Promise<void> {
try {
const updated = await api.updateBinding(b.id, { enabled: next });
bindings = bindings.map((x) =>
x.id === b.id ? { ...x, enabled: updated.enabled } : x
);
bindingsError = '';
} catch (e) {
bindingsError = e instanceof Error ? e.message : 'Update failed';
}
}
async function doUnbind(): Promise<void> {
if (!confirmUnbindId) return;
const bid = confirmUnbindId;
try {
await api.deleteBinding(bid);
bindings = bindings.filter((b) => b.id !== bid);
bindingsError = '';
} catch (e) {
bindingsError = e instanceof Error ? e.message : 'Unbind failed';
} finally {
confirmUnbindId = null;
}
}
function openAddTriggerModal(): void {
// Reset every interaction: fresh form, no pre-picked trigger,
// no stale error. Default to inline because that's the common
// case for one-off setups.
addModalOpen = true;
addModalTab = 'inline';
addModalError = '';
addModalSubmitting = false;
inlineTriggerForm = createTriggerKindFormState({ kind: 'registry' });
pickTriggerId = '';
pickKindFilter = '';
}
function closeAddTriggerModal(): void {
if (addModalSubmitting) return;
addModalOpen = false;
}
async function ensureAvailableTriggers(): Promise<void> {
if (availableTriggersLoaded) return;
try {
availableTriggers = await api.listTriggers();
availableTriggersLoaded = true;
} catch (e) {
addModalError = e instanceof Error ? e.message : 'Failed to load triggers';
}
}
async function switchAddTab(tab: 'inline' | 'pick'): Promise<void> {
addModalTab = tab;
addModalError = '';
if (tab === 'pick') {
await ensureAvailableTriggers();
}
}
const addModalCanSubmit = $derived.by(() => {
if (addModalSubmitting) return false;
if (addModalTab === 'inline') return isTriggerFormValid(inlineTriggerForm);
return !!pickTriggerId;
});
async function submitAddTrigger(): Promise<void> {
if (!addModalCanSubmit) return;
addModalSubmitting = true;
addModalError = '';
try {
if (addModalTab === 'inline') {
const inline = buildTriggerInput(inlineTriggerForm);
await api.bindTriggerToWorkload(id, { inline });
} else {
await api.bindTriggerToWorkload(id, { trigger_id: pickTriggerId });
}
addModalOpen = false;
await reloadBindings();
// Refresh trigger metadata too — a newly-created inline
// trigger is otherwise absent from triggersById, which would
// silently exclude it from the webhook bindings card.
await loadTriggerMeta();
} catch (e) {
addModalError = e instanceof Error ? e.message : $t('apps.detail.bindings.modal.error');
} finally {
addModalSubmitting = false;
}
}
const unbindTarget = $derived(
confirmUnbindId ? bindings.find((b) => b.id === confirmUnbindId) ?? null : null
);
function triggerKindLabel(k: string): string {
const key = `redeployTriggers.kind.${k}`;
const v = $t(key);
return v === key ? k : v;
}
async function doDelete() {
deleting = true;
error = '';
try {
await api.deletePluginWorkload(id);
// Drop the cached entry so navigating back to this (now-deleted) id
// doesn't warm-seed a phantom detail page.
workloadDetailCache.remove(id);
goto('/apps');
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.detail.deleteError');
deleting = false;
confirmDelete = false;
}
}
async function copyToClipboard(key: string, text: string) {
try {
await navigator.clipboard.writeText(text);
copied = { ...copied, [key]: true };
setTimeout(() => {
copied = { ...copied, [key]: false };
}, 1200);
} catch {
// Clipboard may be unavailable (insecure context). Fall back silently.
}
}
function sourceBadge(kind: string): string {
switch (kind) {
case 'image':
return 'src-image';
case 'compose':
return 'src-compose';
case 'static':
return 'src-static';
default:
return 'src-other';
}
}
function stateClass(s: string): string {
const norm = (s || '').toLowerCase();
if (norm === 'running') return 'st-running';
if (norm === 'failed' || norm === 'missing' || norm === 'error') return 'st-failed';
if (norm === 'deploying' || norm === 'starting' || norm === 'restarting')
return 'st-deploying';
return 'st-idle';
}
const sourceValid = $derived(jsonOk(editSourceConfig));
const facesValid = $derived(jsonOk(editPublicFaces));
// SvelteKit reuses the component instance when navigating between
// /apps/A → /apps/B, so onMount(load) only fires for the first
// mount. Use a route-id-dependent effect so the page reloads when
// the param changes; abort the prior request first so a slow
// fetch for the previous id cannot land on the new id's state.
$effect(() => {
const _ = id; // explicit dependency
runtimeAbort?.abort();
storageAbort?.abort();
triggersAbort?.abort();
// Reset the live tail when switching workloads — otherwise
// in-flight events from the previous workload's deploy could
// land in the new one's panel.
clearBuildLog();
load();
});
// Subscribe to /api/events keyed on the route id. Build logs are now
// streamed per-workload by the server (?workload_id=…) so a verbose
// build can't flood every dashboard connection — which means the
// connection MUST reconnect when the user navigates /apps/A → /apps/B
// (SvelteKit reuses the component instance). The $effect cleanup closes
// the prior connection on id change and on unmount.
$effect(() => {
const currentId = id;
if (!currentId) return;
const conn = connectGlobalEvents({
buildLogWorkloadId: currentId,
onEventLog: (payload) => {
// EventLog frames broadcast to EVERY connection (only high-volume
// build logs are workload-filtered server-side), so scope to this
// app client-side before prepending to the activity timeline.
if (payload.workload_id !== currentId) return;
const entry = toEventLogEntry(payload);
// Skip rows already shown (e.g. one that load-more just paged in)
// — {#each (entry.id)} requires unique keys.
if (activityEvents.some((e) => e.id === entry.id)) return;
// Bound generously so a live prepend can't truncate paged-in
// history (load-more grows the list intentionally).
activityEvents = [entry, ...activityEvents].slice(0, 500);
// Prune highlight ids to rows still present after the cap.
const present = new Set(activityEvents.map((e) => e.id));
activityNewIds = new Set([...activityNewIds, entry.id].filter((x) => present.has(x)));
setTimeout(() => {
activityNewIds = new Set([...activityNewIds].filter((x) => x !== entry.id));
}, 3000);
},
onBuildLog: (payload) => {
// Server already filters by workload_id; this is belt-and-braces.
if (payload.workload_id !== currentId) return;
appendBuildLogLine(payload.line);
},
onDeployStatus: (payload) => {
// A preview child deploying live: its state pill is derived
// from previewMeta, which otherwise only refreshes on a full
// load(). Re-pull preview meta so the pill tracks the deploy
// instead of going stale. (project_id is the workload id in
// the workload era — same field the parent check uses.)
if (previewChildren.some((c) => c.id === payload.project_id)) {
void loadPreviewMeta();
return;
}
// When a deploy lands as deployed/failed, refresh the
// runtime-state card so the user sees the new SHA /
// timestamp without a manual refresh.
if (payload.project_id !== currentId) return;
if (payload.status === 'deployed' || payload.status === 'failed') {
loadRuntimeState();
}
}
});
sseConn = conn;
return () => {
conn.close();
if (sseConn === conn) sseConn = null;
};
});
// Cancel any in-flight runtime / storage probe so a late resolve
// after the user navigates away can't mutate dead component state.
onDestroy(() => {
runtimeAbort?.abort();
storageAbort?.abort();
triggersAbort?.abort();
sseConn?.close();
sseConn = null;
});
</script>
<svelte:head>
<title>{workload?.name ?? $t('apps.detail.pageTitleFallback')} · Tinyforge</title>
</svelte:head>
<div class="forge">
{#if loading && !workload}
<div class="loading-line">
<span class="spinner" aria-hidden="true"></span>
<span>{$t('apps.detail.loading')}</span>
</div>
{:else if error && !workload}
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
{:else if workload}
{#snippet detailToolbar()}
<!-- Open / Refresh / Edit / Delete collapse into a <details>
overflow menu under 640px so the toolbar stays single-row
on phones. Stop/Start/Deploy remain inline at every width.
Both copies render; a CSS media query swaps which is shown
so keyboard nav and aria semantics stay native. -->
{#if !editing && firstFaceFqdn}
<a
class="forge-btn-ghost open-ext tb-wide"
href={`https://${firstFaceFqdn}`}
target="_blank"
rel="noopener noreferrer"
title={firstFaceFqdn}
>
<IconGlobe size={13} />
<span>{$t('apps.detail.toolbar.openSite')}</span>
<span class="open-ext-glyph" aria-hidden="true"></span>
</a>
{/if}
{#if !editing && canStop}
<button
class="forge-btn-ghost"
onclick={doStop}
disabled={stopping}
aria-label={$t('apps.detail.toolbar.stop')}
>
<IconStop size={13} />
<span>{stopping ? '…' : $t('apps.detail.toolbar.stop')}</span>
</button>
{/if}
{#if !editing && canStart}
<button
class="forge-btn-ghost"
onclick={doStart}
disabled={starting}
aria-label={$t('apps.detail.toolbar.start')}
>
<IconPlay size={13} />
<span>{starting ? '…' : $t('apps.detail.toolbar.start')}</span>
</button>
{/if}
<button class="forge-btn-icon tb-wide" onclick={load} aria-label={$t('apps.detail.refreshLabel')}>
<IconRefresh size={16} />
</button>
{#if !editing}
<button class="forge-btn-ghost tb-wide" onclick={startEdit}>
<IconEdit size={13} />
<span>{$t('apps.detail.editButton')}</span>
</button>
<button class="forge-btn-ghost danger tb-wide" onclick={() => (confirmDelete = true)}>
<IconTrash size={13} />
<span>{$t('apps.detail.deleteButton')}</span>
</button>
<!-- Overflow menu: only visible <640px via CSS. Native
<details> handles ESC/space/tab focus without JS. -->
<details class="tb-overflow">
<summary class="forge-btn-ghost tb-more-summary" aria-label={$t('apps.detail.toolbar.more')}>
<span>{$t('apps.detail.toolbar.more')}</span>
<span class="tb-more-glyph" aria-hidden="true"></span>
</summary>
<!-- Plain block with native focusable children — no
ARIA menu role, which would promise arrow-key
navigation we don't wire. Tab + ESC (via native
<details>) cover keyboard nav adequately. -->
<div class="tb-menu">
{#if firstFaceFqdn}
<a
class="tb-menu-item"
href={`https://${firstFaceFqdn}`}
target="_blank"
rel="noopener noreferrer"
title={firstFaceFqdn}
>
<IconGlobe size={13} />
<span>{$t('apps.detail.toolbar.openSite')}</span>
<span class="open-ext-glyph" aria-hidden="true"></span>
</a>
{/if}
<button class="tb-menu-item" onclick={load}>
<IconRefresh size={13} />
<span>{$t('apps.detail.refreshLabel')}</span>
</button>
<button class="tb-menu-item" onclick={startEdit}>
<IconEdit size={13} />
<span>{$t('apps.detail.editButton')}</span>
</button>
<button class="tb-menu-item danger" onclick={() => (confirmDelete = true)}>
<IconTrash size={13} />
<span>{$t('apps.detail.deleteButton')}</span>
</button>
</div>
</details>
{/if}
{/snippet}
{#snippet detailLede()}
<span class="lede-row">
<!-- role+aria-live so the auto-refresh on load() is
announced once. No aria-label: the visible text is
the label, and duplicating it suppresses the visible
reading on some screen readers. -->
<span
class="rt-badge live-badge rt-{liveBadge.tone}"
role="status"
aria-live="polite"
>
<span class="rt-dot" aria-hidden="true"></span>
{liveBadge.count
? $t(liveBadge.labelKey, {
running: String(liveBadge.count.running),
total: String(liveBadge.count.total)
})
: $t(liveBadge.labelKey)}
</span>
<span class="badge {sourceBadge(workload!.source_kind)}">
<span class="badge-dot" aria-hidden="true"></span>
{workload!.source_kind}
</span>
<span class="badge trigger">
{bindings.length === 0
? $t('apps.detail.chainTriggersZero')
: bindings.length === 1
? $t('apps.detail.chainTriggersOne')
: $t('apps.detail.chainTriggersMany', { count: String(bindings.length) })}
</span>
<span class="lede-sep">·</span>
<span class="lede-meta">
{$t('apps.detail.createdAt')} <code>{workload!.created_at}</code>
</span>
</span>
{/snippet}
<ForgeHero
backHref="/apps"
backLabel={$t('apps.detail.backLabel')}
eyebrowSuffix={$t('apps.detail.eyebrowSuffix')}
title={workload.name}
kicker={$t('apps.detail.kickerId', { id: workload.id })}
size="lg"
toolbar={detailToolbar}
lede_html={detailLede}
/>
{#if error}
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
{/if}
{#if editing}
<!-- ── Edit form ────────────────────────────── -->
<section class="panel">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title">{$t('apps.detail.editTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{$t('apps.detail.editSubPrefix')} <code>{workload.source_kind}</code> {$t('apps.detail.editSubSuffix')}
</span>
</header>
<div class="field">
<label for="edit-name" class="field-label">
<span class="num">01</span>
<span class="lbl">{$t('apps.detail.editFieldName')}</span>
</label>
<input
id="edit-name"
type="text"
class="input"
bind:value={editName}
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="field">
<label for="edit-parent" class="field-label">
<span class="num">02</span>
<span class="lbl">{$t('apps.detail.editFieldParent')}</span>
<span class="opt">{$t('apps.detail.editFieldOptional')}</span>
</label>
<input
id="edit-parent"
type="text"
class="input"
bind:value={editParentID}
placeholder={$t('apps.detail.editFieldParentPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="field">
<div class="field-label">
<span class="num">03</span>
<span class="lbl">{$t('apps.detail.editSourceConfig')}</span>
<span class="req">
{useEditComposeForm
? $t('apps.detail.editConfigYaml')
: useEditImageForm || useEditStaticForm || useEditDockerfileForm
? $t('apps.detail.editConfigForm')
: $t('apps.detail.editConfigJson')}
</span>
</div>
{#if useEditComposeForm}
<ComposeSourceForm bind:form={editComposeState} onAdvanced={toggleEditAdvancedJSON} />
{:else if useEditImageForm}
<!-- Image source form. Conflict detection is OFF on the
edit page (enableConflicts={false}) — the workload
would otherwise flag itself as conflicting with its
own image. The Inspect button stays (it only fills
empty port/healthcheck fields and is harmless). -->
<ImageSourceForm
bind:form={editImageState}
registries={editRegistries}
enableConflicts={false}
onAdvanced={toggleEditAdvancedJSON}
/>
{:else if useEditStaticForm}
<StaticSourceForm bind:form={editStaticState} onAdvanced={toggleEditAdvancedJSON} />
{:else if useEditDockerfileForm}
<DockerfileSourceForm bind:form={editDockerfileState} onAdvanced={toggleEditAdvancedJSON} />
{:else}
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">{$t('apps.detail.editSourceJsonHeader')}</span>
<span class="spacer"></span>
{#if (workload?.source_kind ?? '') === 'compose' || (workload?.source_kind ?? '') === 'image' || (workload?.source_kind ?? '') === 'static' || (workload?.source_kind ?? '') === 'dockerfile'}
<button
type="button"
class="editor-chip"
onclick={toggleEditAdvancedJSON}
title={$t('apps.detail.switchToFormTitle')}
>
{$t('apps.detail.backToForm')}
</button>
{/if}
</div>
<textarea
id="edit-source"
bind:value={editSourceConfig}
rows="12"
spellcheck="false"
class="code-area"
aria-label={$t('apps.detail.editSourceJsonAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!sourceValid}>
<span class="foot-dot" aria-hidden="true"></span>
{sourceValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
</span>
</div>
</div>
{/if}
</div>
<div class="field">
<div class="field-label">
<span class="num">04</span>
<span class="lbl">{$t('apps.detail.editPublicFaces')}</span>
<span class="opt">{$t('apps.detail.editPublicFacesTag')}</span>
</div>
<div class="editor">
<div class="editor-head">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
<span class="editor-title">{$t('apps.detail.editPublicFacesHeader')}</span>
</div>
<textarea
id="edit-faces"
bind:value={editPublicFaces}
rows="7"
spellcheck="false"
class="code-area"
aria-label={$t('apps.detail.editPublicFacesAria')}
></textarea>
<div class="editor-foot">
<span class="foot-status" class:bad={!facesValid}>
<span class="foot-dot" aria-hidden="true"></span>
{facesValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
</span>
</div>
</div>
</div>
<div class="actions">
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>{$t('apps.detail.editCancel')}</button>
<button
class="forge-btn"
onclick={saveEdit}
disabled={saving ||
!editName.trim() ||
(!useEditComposeForm && !useEditImageForm && !useEditStaticForm && !useEditDockerfileForm && !sourceValid) ||
(useEditImageForm && !editImageState.ref.trim()) ||
(useEditStaticForm && (!editStaticState.repoOwner.trim() || !editStaticState.repoName.trim())) ||
(useEditDockerfileForm && (!editDockerfileState.repoOwner.trim() || !editDockerfileState.repoName.trim() || !editDockerfilePortValid)) ||
!facesValid}
>
{saving ? $t('apps.detail.editSaving') : $t('apps.detail.editSave')}
</button>
</div>
</section>
{/if}
<!-- ── Runtime & storage (static workloads) ───────
Two narrow cards displayed in a 2-up grid so they read as
a single "operational status" row above Manual deploy. The
grid collapses to a single column on narrow viewports. -->
{#if !editing && hasRuntimeState && (runtimeState !== null || runtimeError || storageEnabledOnSource)}
<div class="rt-grid">
<section class="panel rt-card" aria-labelledby="rt-runtime-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title" id="rt-runtime-heading">
<IconClock size={14} />
<span>{$t('apps.detail.runtimeState.title')}</span><span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.runtimeState.sub')}</span>
</header>
{#if runtimeError}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{runtimeError}</span>
</div>
{:else if runtimeState && !runtimeState.has_state}
<p class="rt-empty">{$t('apps.detail.runtimeState.neverDeployed')}</p>
{:else if runtimeState}
<dl class="kv-grid">
<dt>{$t('apps.detail.runtimeState.status')}</dt>
<dd>
<span class="rt-badge {runtimeStatusClass(runtimeState.status)}">
<span class="rt-dot" aria-hidden="true"></span>
{runtimeState.status || runtimeState.state || '—'}
</span>
</dd>
<dt>{$t('apps.detail.runtimeState.lastCommit')}</dt>
<dd>
{#if runtimeState.last_commit_sha}
<code class="mono-sha">{runtimeState.last_commit_sha.slice(0, 8)}</code>
{:else}
<span class="muted"></span>
{/if}
</dd>
<dt>{$t('apps.detail.runtimeState.lastSync')}</dt>
<dd>
{#if runtimeState.last_sync_at}
<span class="mono-time">{$fmt.dateTime(runtimeState.last_sync_at)}</span>
{:else}
<span class="muted"></span>
{/if}
</dd>
{#if runtimeState.container_id}
<dt>{$t('apps.detail.runtimeState.container')}</dt>
<dd><code class="mono-sha">{runtimeState.container_id.slice(0, 12)}</code></dd>
{/if}
</dl>
{#if runtimeState.last_error}
<div class="alert inline-alert rt-error" role="alert">
<span class="alert-tag">ERR</span><span>{runtimeState.last_error}</span>
</div>
{/if}
{:else}
<p class="hint">{$t('apps.detail.runtimeState.loading')}</p>
{/if}
</section>
{#if storageEnabledOnSource}
<section class="panel rt-card" aria-labelledby="rt-storage-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title" id="rt-storage-heading">
<IconHardDrive size={14} />
<span>{$t('apps.detail.storage.title')}</span><span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.storage.sub')}</span>
</header>
{#if storageError}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{storageError}</span>
</div>
{:else if storage}
<dl class="kv-grid">
<dt>{$t('apps.detail.storage.used')}</dt>
<dd><span class="mono-time">{formatBytes(storage.used_bytes)}</span></dd>
<dt>{$t('apps.detail.storage.limit')}</dt>
<dd>
{#if storage.limit_mb && storage.limit_mb > 0}
<span class="mono-time">{storage.limit_mb} MB</span>
{:else}
<span class="muted">{$t('apps.detail.storage.unlimited')}</span>
{/if}
</dd>
</dl>
{#if storage.probe_error}
<p class="rt-probe-note">{$t('apps.detail.storage.unavailable')}</p>
{:else if storage.limit_mb && storage.limit_mb > 0}
<div class="usage-bar" role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(storageUsedPct * 100)}>
<div
class="usage-fill {usageBarClass(storageUsedPct)}"
style:width={`${Math.round(storageUsedPct * 100)}%`}
></div>
</div>
<p class="usage-caption">
{Math.round(storageUsedPct * 100)}%
</p>
{/if}
{:else}
<p class="hint">{$t('apps.detail.storage.loading')}</p>
{/if}
</section>
{/if}
</div>
{/if}
<!-- ── Build logs (dockerfile workloads only) ─────────
Live tail of the docker daemon's NDJSON build stream for
dockerfile-source workloads. Hidden entirely for static /
image / compose kinds — they don't run a build step on the
server. Hidden until the first line arrives so the panel
doesn't take up space on never-built workloads. -->
{#if !editing && workload.source_kind === 'dockerfile' && buildLogLines.length > 0}
<section class="panel build-log-panel" aria-labelledby="build-log-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head build-log-head">
<h2 class="panel-title" id="build-log-heading">
<IconLoader size={14} />
<span>{$t('apps.detail.buildLog.title')}</span><span class="title-accent">.</span>
</h2>
<button
class="forge-btn-ghost build-log-clear"
onclick={clearBuildLog}
aria-label={$t('apps.detail.buildLog.clear')}
>
{$t('apps.detail.buildLog.clear')}
</button>
</header>
<div
class="build-log-tail"
bind:this={buildLogTailEl}
role="log"
aria-live="polite"
aria-relevant="additions"
>
{#each buildLogLines as line (line.id)}
<div class="build-log-line">{line.text}</div>
{/each}
</div>
</section>
{/if}
<!-- ── Resource usage (per-container CPU/memory) ─────
Renders one ContainerStats block per row in the workload's
containers table. Hidden entirely when the workload hasn't
materialized any container rows yet so the page doesn't
show an empty card. -->
{#if !editing && containers.length > 0}
<section class="panel stats-panel" aria-labelledby="stats-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title" id="stats-heading">
{$t('apps.detail.stats.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">
{containers.length === 1
? $t('apps.detail.stats.sub')
: $t('apps.detail.stats.subMany', { count: String(containers.length) })}
</span>
</header>
<ul class="stats-list">
{#each containers as c (c.id)}
{@const collapseByDefault = containers.length > 2}
<li class="stats-row">
{#if collapseByDefault}
<!-- Compose stacks with N≥3 services would
otherwise stack a wall of charts. Collapse
each row into a native <details>; the
summary mirrors stats-row-head so the
visual rhythm matches the expanded variant. -->
<details class="stats-collapse">
<summary class="stats-row-head">
<span class="stats-collapse-glyph" aria-hidden="true"></span>
<span class="stats-role mono">{c.role || c.image_ref || c.id.slice(0, 8)}</span>
{#if c.container_id}
<span class="stats-cid mono">{c.container_id.slice(0, 12)}</span>
{/if}
</summary>
<ContainerStats
source={{ kind: 'workload', workloadId: id, containerRowId: c.id }}
/>
</details>
{:else}
<div class="stats-row-head">
<span class="stats-role mono">{c.role || c.image_ref || c.id.slice(0, 8)}</span>
{#if c.container_id}
<span class="stats-cid mono">{c.container_id.slice(0, 12)}</span>
{/if}
</div>
<ContainerStats
source={{ kind: 'workload', workloadId: id, containerRowId: c.id }}
/>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
<!-- ── Webhook bindings summary (read-only) ──────────
Lists every binding whose underlying trigger has the inbound
webhook channel enabled. Read-only on purpose: rotate /
regenerate / test actions live on /triggers/[id]. -->
<!-- Hide the entire section when there's nothing to show — the
bindings count chip in the lede already communicates the
empty case; an extra empty panel just adds scroll. -->
{#if !editing && webhookBindings.length > 0}
<section class="panel wh-panel" aria-labelledby="wh-heading">
<span class="reg reg-tl" aria-hidden="true"></span>
<span class="reg reg-tr" aria-hidden="true"></span>
<span class="reg reg-bl" aria-hidden="true"></span>
<span class="reg reg-br" aria-hidden="true"></span>
<header class="panel-head">
<h2 class="panel-title" id="wh-heading">
<IconGlobe size={14} />
<span>{$t('apps.detail.webhooks.title')}</span><span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.webhooks.sub')}</span>
</header>
<ul class="wh-list">
{#each webhookBindings as b (b.id)}
<li class="wh-row" class:disabled={!b.enabled}>
<span class="wh-icon" aria-hidden="true">
<IconGlobe size={13} />
</span>
<!-- Name is plain text (no link) — the "Open
trigger" button on the right is the single
click target, so screen readers don't
announce two equivalent links per row. -->
<span class="wh-name mono">
{b.trigger_name || b.trigger_id}
</span>
<span class="wh-kind mono">
{$t(`redeployTriggers.kindShort.${b.trigger_kind}`) ===
`redeployTriggers.kindShort.${b.trigger_kind}`
? b.trigger_kind.toUpperCase()
: $t(`redeployTriggers.kindShort.${b.trigger_kind}`)}
</span>
{#if !b.enabled}
<span class="wh-muted mono">{$t('apps.detail.webhooks.disabled')}</span>
{/if}
<a
class="forge-btn-ghost xs wh-open"
href={`/triggers/${b.trigger_id}`}
title={$t('apps.detail.webhooks.openTrigger')}
>
<IconExternalLink size={12} />
<span>{$t('apps.detail.webhooks.openTrigger')}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- ── Manual deploy ────────────────────────────── -->
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">{$t('apps.detail.manualDeployTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">{$t('apps.detail.manualDeploySub')}</span>
</header>
{#if lastDeployMsg}
<div class="success">
<span class="success-tag">{$t('apps.detail.manualDeployOk')}</span>
<span>{lastDeployMsg}</span>
</div>
{/if}
<div class="deploy-row">
<label class="deploy-input" for="deploy-ref">
<span class="sr-only">{$t('apps.detail.manualDeployRefAria')}</span>
<input
id="deploy-ref"
type="text"
bind:value={deployRef}
placeholder={$t('apps.detail.manualDeployRefPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<button class="forge-btn" onclick={deploy} disabled={deploying}>
<IconDeploy size={14} />
<span>{deploying ? $t('apps.detail.manualDeployDispatching') : $t('apps.detail.manualDeployButton')}</span>
</button>
</div>
<p class="hint">{$t('apps.detail.manualDeployHint')}</p>
</section>
<!-- ── Triggers (bindings) ─────────────────────────
Replaces the legacy single-trigger panel. Lists every
binding with a per-row enabled toggle, a "View trigger"
deep-link, and an unbind action gated by ConfirmDialog.
"Add trigger" opens a modal with two tabs: inline-create
a new trigger record, or pick an existing one. -->
{#if !editing}
<section id="bindings" class="panel" aria-labelledby="trig-bindings-heading">
<header class="panel-head split bindings-head">
<div class="bindings-head-left">
<h2 class="panel-title" id="trig-bindings-heading">
{$t('apps.detail.bindings.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">
{bindings.length === 0
? $t('apps.detail.bindings.subEmpty')
: bindings.length === 1
? $t('apps.detail.bindings.subCount', { count: '1' })
: $t('apps.detail.bindings.subCountMany', {
count: String(bindings.length)
})}
</span>
</div>
<button
type="button"
class="forge-btn"
onclick={openAddTriggerModal}
aria-label={$t('apps.detail.bindings.addButton')}
>
<IconPlus size={13} />
<span>{$t('apps.detail.bindings.addButton')}</span>
</button>
</header>
{#if bindingsError}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{bindingsError}</span>
</div>
{/if}
{#if bindingsLoading}
<p class="hint">{$t('apps.detail.bindings.loading')}</p>
{:else if bindings.length === 0}
<div class="empty-inline">
<span class="empty-mark" aria-hidden="true"></span>
<span>{$t('apps.detail.bindings.subEmpty')}</span>
</div>
{:else}
<ul class="bindings-list">
{#each bindings as b, i (b.id)}
{@const overrideOpen = openOverrideId === b.id}
{@const overrideCount = overrideKeyCount(b.binding_config)}
{@const editText = overrideTexts[b.id] ?? formatJsonValue(b.binding_config ?? {})}
{@const baseText = overrideBaseConfigs[b.trigger_id] ?? ''}
{@const baseLoading = overrideBaseLoading[b.trigger_id] === true}
{@const editValid = overrideJsonValid(editText)}
{@const editBytes = overrideTextSize(editText)}
{@const editTooLarge = editBytes > BINDING_CONFIG_MAX_BYTES}
{@const editKeys = overrideKeyCountFromText(editText)}
{@const rowError = overrideErrors[b.id] ?? ''}
{@const rowSaving = overrideSaving[b.id] === true}
<li class="binding" class:has-override={overrideCount > 0} class:open={overrideOpen}>
<div class="b-main">
<span class="b-ref mono">
{String(i + 1).padStart(2, '0')}
</span>
<a class="b-name" href={`/triggers/${b.trigger_id}`}>
{b.trigger_name || b.trigger_id}
</a>
<span class="b-kind mono">
{$t(`redeployTriggers.kindShort.${b.trigger_kind}`) ===
`redeployTriggers.kindShort.${b.trigger_kind}`
? b.trigger_kind.toUpperCase()
: $t(`redeployTriggers.kindShort.${b.trigger_kind}`)}
</span>
<span
class="b-state mono"
class:on={b.enabled}
class:off={!b.enabled}
>
{b.enabled
? $t('apps.detail.bindings.rowEnabled')
: $t('apps.detail.bindings.rowDisabled')}
</span>
{#if overrideCount > 0}
<span
class="b-override mono"
title={$t('apps.detail.bindings.override.badgeTitle')}
>
{overrideCount === 1
? $t('apps.detail.bindings.override.badgeOne')
: $t('apps.detail.bindings.override.badgeMany', {
count: String(overrideCount)
})}
</span>
{/if}
</div>
<div class="b-actions">
<ToggleSwitch
checked={b.enabled}
onchange={(next) => toggleBinding(b, next)}
label={$t('apps.detail.bindings.rowEnabled')}
/>
<button
type="button"
class="forge-btn-ghost xs override-btn"
class:active={overrideOpen}
class:has-override={overrideCount > 0}
onclick={() => toggleOverridePanel(b)}
aria-expanded={overrideOpen}
aria-controls={`override-panel-${b.id}`}
title={$t('apps.detail.bindings.override.toggle')}
>
<IconEdit size={12} />
<span>{$t('apps.detail.bindings.override.toggle')}</span>
<IconChevronDown size={12} class="chev" />
</button>
<a
class="forge-btn-icon"
href={`/triggers/${b.trigger_id}`}
aria-label={$t('apps.detail.bindings.openTrigger')}
title={$t('apps.detail.bindings.openTrigger')}
>
<IconExternalLink size={14} />
</a>
<button
type="button"
class="forge-btn-icon danger"
onclick={() => (confirmUnbindId = b.id)}
aria-label={$t('apps.detail.bindings.unbindAction')}
title={$t('apps.detail.bindings.unbindAction')}
>
<IconTrash size={14} />
</button>
</div>
{#if overrideOpen}
<div
class="override-panel"
id={`override-panel-${b.id}`}
role="region"
aria-label={$t('apps.detail.bindings.override.title')}
>
<header class="op-head">
<h3 class="op-title">
{$t('apps.detail.bindings.override.title')}
</h3>
<p class="op-sub">
{$t('apps.detail.bindings.override.subtitle')}
</p>
</header>
{#if rowError}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{rowError}</span>
</div>
{/if}
<div class="op-grid">
<section class="op-col">
<div class="op-col-head">
<span class="op-col-label">
{$t('apps.detail.bindings.override.baseLabel')}
</span>
<span class="op-col-tag mono">READ-ONLY</span>
</div>
{#if baseLoading}
<div class="op-readonly mono dim">
{$t('apps.detail.bindings.override.baseLoading')}
</div>
{:else if baseText}
<pre class="op-readonly mono">{baseText}</pre>
{:else}
<pre class="op-readonly mono dim">{'{}'}</pre>
{/if}
<p class="op-hint">
{$t('apps.detail.bindings.override.baseHint')}
</p>
</section>
<section class="op-col">
<div class="op-col-head">
<span class="op-col-label">
{$t('apps.detail.bindings.override.editLabel')}
</span>
<span
class="op-col-tag mono"
class:warn={editTooLarge}
class:bad={!editValid}
>
{editKeys}&nbsp;{editKeys === 1 ? $t('apps.detail.overrideKeyUnitSingular') : $t('apps.detail.overrideKeyUnitPlural')}
</span>
</div>
<textarea
class="op-editor mono"
class:bad={!editValid}
class:warn={editTooLarge}
bind:value={overrideTexts[b.id]}
rows="8"
spellcheck="false"
placeholder={'{}'}
aria-invalid={!editValid}
></textarea>
<div class="op-meta">
{#if !editValid}
<span class="op-meta-msg bad">
{$t('apps.detail.bindings.override.invalidJson')}
</span>
{:else if editTooLarge}
<span class="op-meta-msg bad">
{$t('apps.detail.bindings.override.tooLarge', {
size: String(editBytes),
limit: String(BINDING_CONFIG_MAX_BYTES)
})}
</span>
{:else}
<span class="op-meta-msg">
{$t('apps.detail.bindings.override.editHint')}
</span>
{/if}
<span class="op-meta-bytes mono" class:warn={editTooLarge}>
{editBytes}/{BINDING_CONFIG_MAX_BYTES}B
</span>
</div>
</section>
</div>
{#if editValid && !editTooLarge && baseText}
<section class="op-preview">
<div class="op-col-head">
<span class="op-col-label">
{$t('apps.detail.bindings.override.previewLabel')}
</span>
<span class="op-col-tag mono accent">EFFECTIVE</span>
</div>
<pre class="op-readonly mono">{mergedPreview(baseText, editText)}</pre>
<p class="op-hint">
{$t('apps.detail.bindings.override.previewHint')}
</p>
</section>
{/if}
<footer class="op-actions">
<button
type="button"
class="forge-btn-ghost xs"
onclick={() => resetOverride(b)}
disabled={rowSaving || overrideCount === 0}
>
{$t('apps.detail.bindings.override.resetButton')}
</button>
<div class="op-actions-right">
<button
type="button"
class="forge-btn-ghost"
onclick={() => (openOverrideId = null)}
disabled={rowSaving}
>
{$t('apps.detail.bindings.override.closeButton')}
</button>
<button
type="button"
class="forge-btn"
onclick={() => saveOverride(b)}
disabled={rowSaving || !editValid || editTooLarge}
aria-busy={rowSaving}
>
{rowSaving
? $t('apps.detail.bindings.override.saving')
: $t('apps.detail.bindings.override.saveButton')}
</button>
</div>
</footer>
</div>
{/if}
</li>
{/each}
</ul>
<p class="hint">{$t('apps.detail.bindings.rowEnableHint')}</p>
{/if}
</section>
{/if}
<!-- ── Containers ───────────────────────────────── -->
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">{$t('apps.detail.containersTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{containers.length === 0
? $t('apps.detail.containersEmpty')
: $t('apps.detail.containersCount', { count: String(containers.length) })}
</span>
</header>
{#if containers.length === 0}
<div class="empty-inline">
<span class="empty-mark" aria-hidden="true"></span>
<span>{$t('apps.detail.containersEmptyInline')}</span>
</div>
{:else}
{#if logContainerRowID}
<div class="logs-mount">
<ContainerLogs
source={{
kind: 'workload',
workloadId: id,
containerRowId: logContainerRowID
}}
onclose={() => (logContainerRowID = null)}
/>
</div>
{/if}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('apps.detail.containersColRole')}</th>
<th>{$t('apps.detail.containersColState')}</th>
<th>{$t('apps.detail.containersColImage')}</th>
<th>{$t('apps.detail.containersColSubdomain')}</th>
<th>{$t('apps.detail.containersColLastSeen')}</th>
<th class="t-right">{$t('apps.detail.containersColActions')}</th>
</tr>
</thead>
<tbody>
{#each containers as c (c.id)}
<tr>
<td>{c.role || '—'}</td>
<td>
<span class="state-pill {stateClass(c.state)}">
<span class="pulse" aria-hidden="true"></span>
{c.state}
</span>
</td>
<td class="mono">{c.image_ref || '—'}</td>
<td class="mono">{c.subdomain || '—'}</td>
<td class="muted mono">{c.last_seen_at || '—'}</td>
<td class="t-right">
{#if c.container_id}
<button
class="forge-btn-ghost"
onclick={() => (logContainerRowID = c.id)}
aria-label={`${$t('apps.detail.containersLogsAction')}: ${c.role || c.id}`}
>
<IconServer size={13} />
<span>{$t('apps.detail.containersLogsAction')}</span>
</button>
{:else}
<span class="muted mono">—</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<!-- ── Stages chain ─────────────────────────────── -->
{#if !editing && chain && (chain.parent || chain.children.length > 0)}
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">{$t('apps.detail.chainTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{chain.parent ? $t('apps.detail.chainSubFromParent') : $t('apps.detail.chainSubParentOf')}
{chain.children.length}
{chain.children.length === 1 ? $t('apps.detail.chainChildSingular') : $t('apps.detail.chainChildPlural')}
</span>
</header>
{#if chainError}
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{chainError}</span></div>
{/if}
{#if chain.parent}
<div class="chain-row">
<span class="chain-label">{$t('apps.detail.chainParentLabel')}</span>
<a class="chain-card" href={`/apps/${chain.parent.id}`}>
<span class="chain-name">{chain.parent.name}</span>
<span class="mono muted">{chain.parent.source_kind}</span>
</a>
{#if workload?.source_kind === 'image' && chain.parent.source_kind === 'image'}
<button
class="forge-btn-ghost"
disabled={promoting !== null}
onclick={() => promoteFrom(chain!.parent!.id)}
>
{promoting === chain.parent.id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
</button>
{/if}
</div>
{/if}
<div class="chain-row">
<span class="chain-label">{$t('apps.detail.chainSelfLabel')}</span>
<div class="chain-card chain-self">
<span class="chain-name">{workload?.name ?? '—'}</span>
<span class="mono muted">
{workload?.source_kind ?? ''} · {bindings.length === 0
? $t('apps.detail.chainTriggersZero')
: bindings.length === 1
? $t('apps.detail.chainTriggersOne')
: $t('apps.detail.chainTriggersMany', {
count: String(bindings.length)
})}
</span>
</div>
</div>
{#if chain.children.length > 0}
<div class="chain-row chain-children">
<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
<div class="chain-children-list">
{#each chain.children as child (child.id)}
<a class="chain-card" href={`/apps/${child.id}`}>
<span class="chain-name">
{child.name}
{#if child.is_preview}
<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
>{$t('apps.detail.previews.tag')}</span
>
{/if}
</span>
<span class="mono muted">{child.source_kind}</span>
</a>
{/each}
</div>
</div>
{/if}
<p class="hint">{@html $t('apps.detail.chainHint')}</p>
</section>
{/if}
<!-- ── Branch preview environments ──────────────────
Lists the per-branch previews materialized from this workload.
Shows when there's at least one live preview OR the workload is
"armed" (a git-trigger binding carries a branch_pattern) so the
operator gets a clear "push to deploy a preview" cue. -->
{#if !editing && (previewChildren.length > 0 || isPreviewArmed)}
<section class="panel" aria-labelledby="previews-heading">
<header class="panel-head">
<h2 class="panel-title" id="previews-heading">
{$t('apps.detail.previews.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">
{previewChildren.length === 0
? $t('apps.detail.previews.subEmpty')
: previewChildren.length === 1
? $t('apps.detail.previews.subCountOne')
: $t('apps.detail.previews.subCount', {
count: String(previewChildren.length)
})}
</span>
</header>
{#if previewChildren.length === 0}
<!-- Armed-but-empty state. -->
<div class="preview-empty">
<IconGlobe size={15} />
{#if armedBranchPattern}
<span
>{$t('apps.detail.previews.armedEmpty')}
<code>{armedBranchPattern}</code></span
>
{:else}
<span>{$t('apps.detail.previews.noneEmpty')}</span>
{/if}
</div>
{:else}
<div class="preview-list">
{#each previewChildren as child (child.id)}
{@const meta = previewMeta[child.id]}
{@const st = meta?.state ?? ''}
<div class="preview-row">
<span class="preview-state rt-badge {liveBadgeToneClass(st)}" title={st || $t('apps.detail.previews.stateUnknown')}>
<span class="rt-dot" aria-hidden="true"></span>
{previewStateLabel(st)}
</span>
<a class="preview-branch mono" href={`/apps/${child.id}`} title={child.name}>
{child.preview_branch || child.name}
</a>
<span class="preview-spacer"></span>
{#if meta && meta.fqdn}
<a
class="forge-btn-ghost open-ext"
href={`https://${meta.fqdn}`}
target="_blank"
rel="noopener noreferrer"
title={meta.fqdn}
>
<IconExternalLink size={13} />
<span>{$t('apps.detail.previews.open')}</span>
<span class="open-ext-glyph" aria-hidden="true">↗</span>
</a>
{:else if meta && meta.loaded}
<span class="preview-nourl">{$t('apps.detail.previews.noUrl')}</span>
{:else}
<span class="preview-nourl"><IconLoader size={13} /></span>
{/if}
<button
class="forge-btn-ghost danger"
onclick={() => (confirmTeardownId = child.id)}
aria-label={$t('apps.detail.previews.teardown')}
>
<IconTrash size={13} />
<span>{$t('apps.detail.previews.teardown')}</span>
</button>
</div>
{/each}
</div>
{/if}
{#if chainError}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{chainError}</span>
</div>
{/if}
<p class="hint">{@html $t('apps.detail.previews.hint')}</p>
</section>
{/if}
<!-- ── Activity timeline (recent deploys + events) ──
Read-only feed of the most recent workload-scoped events, kept
live by the global SSE stream's onEventLog callback. -->
{#if !editing}
<section class="panel" aria-labelledby="activity-heading">
<header class="panel-head">
<h2 class="panel-title" id="activity-heading">
{$t('apps.detail.activity.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.activity.subtitle')}</span>
{#if activityEvents.length > 0}
<div class="ml-auto inline-flex items-center rounded-lg bg-[var(--surface-card-hover)] p-0.5">
<button
type="button"
aria-pressed={activitySeverity === 'all'}
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
{activitySeverity === 'all'
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => { activitySeverity = 'all'; }}
>
{$t('apps.detail.activity.filterAll')}
</button>
<button
type="button"
aria-pressed={activitySeverity === 'error'}
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
{activitySeverity === 'error'
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => { activitySeverity = 'error'; }}
>
{$t('apps.detail.activity.filterErrors')}
</button>
</div>
{/if}
</header>
{#if activityLoading}
<div class="flex items-center justify-center py-16">
<IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
</div>
{:else if activityEvents.length === 0}
<p class="hint">{$t('apps.detail.activity.empty')}</p>
{:else if visibleActivity.length === 0}
<p class="hint">{$t('apps.detail.activity.noErrors')}</p>
{:else}
<div
class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] divide-y divide-[var(--border-secondary)]"
>
{#each visibleActivity as entry (entry.id)}
<EventLogEntryComponent {entry} isNew={activityNewIds.has(entry.id)} />
{/each}
</div>
{/if}
{#if !activityLoading && activityHasMore}
<div class="flex justify-center pt-2 pb-1">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
onclick={loadMoreActivity}
disabled={activityLoadingMore}
>
{#if activityLoadingMore}
<IconLoader size={16} class="animate-spin" />
{/if}
{$t('apps.detail.activity.loadMore')}
</button>
</div>
{/if}
</section>
{/if}
<!-- ── Per-workload notification routes ───────────── -->
{#if !editing}
<WorkloadNotificationsPanel workloadId={id} />
{/if}
<!-- ── Data-volume snapshots (capture) ────────────── -->
{#if !editing}
<WorkloadSnapshotsPanel workloadId={id} />
{/if}
<!-- ── Log scan rules (effective set) ─────────────── -->
{#if !editing}
<section class="panel" aria-labelledby="log-rules-heading">
<header class="panel-head">
<h2 class="panel-title" id="log-rules-heading">
{$t('logscan.panel.heading')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">
{logRules.length === 0
? $t('logscan.panel.subEmpty')
: logRules.length === 1
? $t('logscan.panel.subCountOne')
: $t('logscan.panel.subCount', { count: String(logRules.length) })}
· <a href="/log-scan-rules">{$t('observability.manage')}</a>
</span>
</header>
{#if logRulesError}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{logRulesError}</span>
</div>
{/if}
{#if logRules.length === 0}
<p class="hint">
{$t('logscan.panel.emptyHint')}
<a href="/log-scan-rules/new">{$t('logscan.panel.newRule')}</a>.
</p>
{:else}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('logscan.list.name')}</th>
<th>{$t('logscan.list.pattern')}</th>
<th>{$t('logscan.list.scope')}</th>
<th>{$t('logscan.list.severity')}</th>
<th>{$t('logscan.list.streams')}</th>
<th>{$t('logscan.list.status')}</th>
<th class="t-right">{$t('triggers.list.action')}</th>
</tr>
</thead>
<tbody>
{#each logRules as r (r.id)}
{@const kind = classifyRule(r)}
<tr>
<td>
<a class="row-link" href={`/log-scan-rules/${r.id}`}>
<span class="row-name">{r.name}</span>
</a>
</td>
<td class="muted mono small log-pattern">/{r.pattern}/</td>
<td>
<span class="badge scope-{kind}">{$t(`logscan.filter.${kind === 'override' ? 'overrides' : kind}`).toLowerCase()}</span>
</td>
<td>
<span class="severity sev-{r.severity}">{r.severity}</span>
</td>
<td class="mono small muted">{r.streams}</td>
<td>
<span class="status" class:on={r.enabled} class:off={!r.enabled}>
<span class="status-dot" aria-hidden="true"></span>
{r.enabled ? $t('logscan.status.on') : $t('logscan.status.off')}
</span>
</td>
<td class="actions-cell">
{#if kind === 'global'}
<button
type="button"
class="forge-btn-ghost xs"
onclick={() => overrideRule(r)}
disabled={logRuleOverriding !== null}
aria-busy={logRuleOverriding === r.id}
title={$t('logscan.panel.overrideTitle')}
>
{logRuleOverriding === r.id
? $t('logscan.panel.overriding')
: $t('logscan.panel.override')}
</button>
{:else}
<a class="row-action" href={`/log-scan-rules/${r.id}`}>
{$t('observability.edit')} <span class="arrow" aria-hidden="true">→</span>
</a>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<p class="hint">{$t('logscan.panel.footerHint')}</p>
{/if}
</section>
{/if}
<!-- ── Volumes ──────────────────────────────────── -->
{#if !editing}
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">{$t('apps.detail.volumesTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{volumeRows.length === 0
? $t('apps.detail.volumesEmpty')
: volumeRows.length === 1
? $t('apps.detail.volumesCountSingular', { count: '1' })
: $t('apps.detail.volumesCountPlural', { count: String(volumeRows.length) })}
</span>
</header>
{#if volumeError}
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{volumeError}</span></div>
{/if}
{#if volumeRows.length > 0}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('apps.detail.volumesColTarget')}</th>
<th>{$t('apps.detail.volumesColSource')}</th>
<th>{$t('apps.detail.volumesColScope')}</th>
<th class="t-right">{$t('apps.detail.volumesColUpdated')}</th>
<th class="t-right">{$t('apps.detail.volumesColActions')}</th>
</tr>
</thead>
<tbody>
{#each volumeRows as v (v.id)}
<tr>
<td class="mono">{v.target}</td>
<td class="mono">{v.source || '—'}</td>
<td>
<span class="state-pill st-encrypted">{v.scope}</span>
</td>
<td class="t-right muted mono">{v.updated_at}</td>
<td class="t-right">
<button
class="forge-btn-ghost danger"
onclick={() => removeVolume(v.id)}
aria-label={`${$t('apps.detail.volumesColActions')}: ${v.target}`}
>
<IconTrash size={13} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<form
class="env-add"
onsubmit={(ev) => {
ev.preventDefault();
addVolume();
}}
>
<label class="env-field">
<span>{$t('apps.detail.volumeSource')}</span>
<input
type="text"
bind:value={newVolSource}
placeholder={$t('apps.detail.volumeSourcePlaceholder')}
/>
</label>
<label class="env-field">
<span>{$t('apps.detail.volumeTarget')}</span>
<input
type="text"
bind:value={newVolTarget}
placeholder={$t('apps.detail.volumeTargetPlaceholder')}
required
/>
</label>
<label class="env-field">
<span>{$t('apps.detail.volumeScope')}</span>
<select bind:value={newVolScope}>
<option value="absolute">absolute</option>
<option value="named">named</option>
<option value="project_named">project_named</option>
<option value="ephemeral">ephemeral</option>
</select>
</label>
<button class="forge-btn" type="submit" disabled={volumeSaving || !newVolTarget.trim()}>
{volumeSaving ? $t('apps.detail.volumeSaving') : $t('apps.detail.volumeAddButton')}
</button>
</form>
<p class="hint">{$t('apps.detail.volumeHint')}</p>
</section>
{/if}
<!-- ── Env vars ─────────────────────────────────── -->
{#if !editing}
<section class="panel">
<header class="panel-head">
<h2 class="panel-title">{$t('apps.detail.envTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub">
{envRows.length === 0
? $t('apps.detail.envEmpty')
: envRows.length === 1
? $t('apps.detail.envCountSingular', { count: '1' })
: $t('apps.detail.envCountPlural', { count: String(envRows.length) })}
</span>
</header>
{#if envError}
<div class="alert inline-alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{envError}</span></div>
{/if}
{#if envRows.length > 0}
<div class="table-wrap">
<table class="forge-table">
<thead>
<tr>
<th>{$t('apps.detail.envColKey')}</th>
<th>{$t('apps.detail.envColValue')}</th>
<th class="t-right">{$t('apps.detail.envColUpdated')}</th>
<th class="t-right">{$t('apps.detail.envColActions')}</th>
</tr>
</thead>
<tbody>
{#each envRows as e (e.id)}
<tr>
<td class="mono">{e.key}</td>
<td>
{#if e.encrypted}
<span class="state-pill st-encrypted">
<span class="pulse" aria-hidden="true"></span>
{$t('apps.detail.envEncrypted')}
</span>
{:else}
<span class="mono">{e.value || '—'}</span>
{/if}
</td>
<td class="t-right muted mono">{e.updated_at}</td>
<td class="t-right">
<button
class="forge-btn-ghost danger"
onclick={() => removeEnv(e.id)}
aria-label={`${$t('apps.detail.envColActions')}: ${e.key}`}
>
<IconTrash size={13} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<form
class="env-add"
onsubmit={(ev) => {
ev.preventDefault();
addEnv();
}}
>
<label class="env-field">
<span>{$t('apps.detail.envKey')}</span>
<input
type="text"
bind:value={newEnvKey}
placeholder={$t('apps.detail.envKeyPlaceholder')}
pattern="[A-Za-z_][A-Za-z0-9_]*"
required
/>
</label>
<label class="env-field">
<span>{$t('apps.detail.envValue')}</span>
<input type="text" bind:value={newEnvValue} placeholder={$t('apps.detail.envValuePlaceholder')} />
</label>
<label class="env-toggle">
<ToggleSwitch bind:checked={newEnvEncrypted} label={$t('apps.detail.envEncryptLabel')} />
<span>{$t('apps.detail.envEncryptLabel')}</span>
</label>
<button class="forge-btn" type="submit" disabled={envSaving || !newEnvKey.trim()}>
{envSaving ? $t('apps.detail.envSaving') : $t('apps.detail.envAddButton')}
</button>
</form>
<p class="hint">{$t('apps.detail.envHint')}</p>
</section>
<!-- Webhook URL panel removed — inbound webhooks live on
the bound Triggers panel above. The trigger detail page
(/triggers/{id}) carries the URL + rotate action. -->
{/if}
<!-- ── Config viewers ───────────────────────────── -->
{#if !editing}
<section class="panel code-panel">
<header class="panel-head split">
<button
type="button"
class="head-toggle"
onclick={() => (openSource = !openSource)}
aria-expanded={openSource}
aria-controls="src-cfg"
>
<span class="chev" class:rot={!openSource} aria-hidden="true">
<IconChevronDown size={16} />
</span>
<h2 class="panel-title">{$t('apps.detail.sourceConfigTitle')}<span class="title-accent">.</span></h2>
<span class="panel-sub mono">{workload.source_kind}</span>
</button>
<button
type="button"
class="copy-btn"
onclick={() => copyToClipboard('source', prettyJson(workload!.source_config))}
aria-label={$t('apps.detail.sourceConfigCopyAria')}
>
{#if copied.source}
<IconCheck size={13} />
{:else}
<IconCopy size={13} />
{/if}
<span>{copied.source ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
</button>
</header>
{#if openSource}
<div id="src-cfg" class="code-body">
<pre class="code-view">{prettyJson(workload.source_config)}</pre>
</div>
{/if}
</section>
{#if workload.public_faces && workload.public_faces !== '[]'}
<section class="panel code-panel">
<header class="panel-head split">
<button
type="button"
class="head-toggle"
onclick={() => (openFaces = !openFaces)}
aria-expanded={openFaces}
aria-controls="faces-cfg"
>
<span class="chev" class:rot={!openFaces} aria-hidden="true">
<IconChevronDown size={16} />
</span>
<h2 class="panel-title">{$t('apps.detail.publicFacesTitle')}<span class="title-accent">.</span></h2>
</button>
<button
type="button"
class="copy-btn"
onclick={() => copyToClipboard('faces', prettyJson(workload!.public_faces))}
aria-label={$t('apps.detail.publicFacesCopyAria')}
>
{#if copied.faces}
<IconCheck size={13} />
{:else}
<IconCopy size={13} />
{/if}
<span>{copied.faces ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
</button>
</header>
{#if openFaces}
<div id="faces-cfg" class="code-body">
<pre class="code-view">{prettyJson(workload.public_faces)}</pre>
</div>
{/if}
</section>
{/if}
{/if}
{/if}
</div>
<ConfirmDialog
open={confirmDelete}
title={$t('apps.detail.deleteConfirmTitle')}
message={$t('apps.detail.deleteConfirmMessage', {
name: workload?.name ?? $t('apps.detail.deleteConfirmFallbackName')
})}
confirmLabel={deleting ? $t('apps.detail.deleteConfirmDeleting') : $t('apps.detail.deleteConfirmYes')}
confirmVariant="danger"
onconfirm={doDelete}
oncancel={() => {
if (!deleting) confirmDelete = false;
}}
/>
<ConfirmDialog
open={confirmUnbindId !== null}
title={$t('apps.detail.bindings.unbindTitle')}
message={$t('apps.detail.bindings.unbindMessage', {
name: unbindTarget?.trigger_name || unbindTarget?.trigger_id || ''
})}
confirmLabel={$t('apps.detail.bindings.unbindConfirm')}
confirmVariant="danger"
onconfirm={doUnbind}
oncancel={() => (confirmUnbindId = null)}
/>
<ConfirmDialog
open={confirmTeardownId !== null}
title={$t('apps.detail.previews.teardownTitle')}
message={$t('apps.detail.previews.teardownMessage', {
name: teardownTarget?.preview_branch || teardownTarget?.name || ''
})}
confirmLabel={tearingDown
? $t('apps.detail.previews.teardownPending')
: $t('apps.detail.previews.teardownConfirm')}
confirmVariant="danger"
onconfirm={doTeardownPreview}
oncancel={() => {
if (!tearingDown) confirmTeardownId = null;
}}
/>
<!-- ── Add-trigger modal ──────────────────────────────
Tabbed dialog: "Create new" mounts the shared TriggerKindForm
(kind picker + per-kind fields + webhook toggles); "Bind
existing" mounts a kind filter + select. Submit posts to
/api/workloads/{id}/triggers with either an inline trigger
spec or an existing trigger id, then refreshes bindings. -->
{#if addModalOpen}
<div
class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in"
role="presentation"
onclick={closeAddTriggerModal}
></div>
<div class="fixed inset-0 z-50 flex items-start justify-center p-4 overflow-y-auto modal-wrap">
<div class="add-modal animate-scale-in">
<header class="add-head">
<div class="add-head-text">
<h2 class="add-title">{$t('apps.detail.bindings.modal.title')}</h2>
<p class="add-sub">{$t('apps.detail.bindings.modal.subtitle')}</p>
</div>
<button
type="button"
class="forge-btn-icon"
onclick={closeAddTriggerModal}
aria-label={$t('apps.detail.bindings.modal.cancel')}
title={$t('apps.detail.bindings.modal.cancel')}
disabled={addModalSubmitting}
>
<IconX size={14} />
</button>
</header>
<div class="add-tabs" role="tablist" aria-label={$t('apps.detail.bindings.modal.title')}>
<button
type="button"
role="tab"
aria-selected={addModalTab === 'inline'}
class="add-tab"
class:active={addModalTab === 'inline'}
onclick={() => switchAddTab('inline')}
>
{$t('apps.detail.bindings.modal.tabInline')}
</button>
<button
type="button"
role="tab"
aria-selected={addModalTab === 'pick'}
class="add-tab"
class:active={addModalTab === 'pick'}
onclick={() => switchAddTab('pick')}
>
{$t('apps.detail.bindings.modal.tabPick')}
</button>
</div>
{#if addModalError}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{addModalError}</span>
</div>
{/if}
<div class="add-body">
{#if addModalTab === 'inline'}
<TriggerKindForm
bind:state={inlineTriggerForm}
idPrefix="add-inline"
showName={true}
showWebhook={true}
showKindPicker={true}
/>
{:else if availableTriggers.length === 0}
<div class="empty-inline">
<span class="empty-mark" aria-hidden="true"></span>
<span>{$t('apps.detail.bindings.modal.pickEmpty')}</span>
</div>
{:else}
<div class="pick-row">
<label class="sub" for="pick-kind">
<span class="sub-label">
{$t('apps.detail.bindings.modal.pickKind')}
</span>
<select id="pick-kind" class="input" bind:value={pickKindFilter}>
<option value="">
{$t('apps.detail.bindings.modal.pickKindAll')}
</option>
<option value="registry">
{triggerKindLabel('registry')}
</option>
<option value="git">{triggerKindLabel('git')}</option>
<option value="manual">{triggerKindLabel('manual')}</option>
</select>
</label>
<label class="sub" for="pick-trigger">
<span class="sub-label">
{$t('apps.detail.bindings.modal.pickLabel')}
</span>
<select id="pick-trigger" class="input" bind:value={pickTriggerId}>
<option value="">
{$t('apps.detail.bindings.modal.pickPlaceholder')}
</option>
{#each availableTriggersFiltered as tr (tr.id)}
<option value={tr.id}>
{tr.name} · {tr.kind}{tr.webhook_enabled
? ' · ' + $t('apps.new.triggers.pickWebhookOn')
: ''}
</option>
{/each}
</select>
</label>
</div>
{/if}
</div>
<footer class="add-actions">
<button
type="button"
class="forge-btn-ghost"
onclick={closeAddTriggerModal}
disabled={addModalSubmitting}
>
{$t('apps.detail.bindings.modal.cancel')}
</button>
<button
type="button"
class="forge-btn"
onclick={submitAddTrigger}
disabled={!addModalCanSubmit}
aria-busy={addModalSubmitting}
>
{addModalSubmitting
? $t('apps.detail.bindings.modal.submitting')
: addModalTab === 'inline'
? $t('apps.detail.bindings.modal.submitInline')
: $t('apps.detail.bindings.modal.submitPick')}
</button>
</footer>
</div>
</div>
{/if}
<style>
.forge {
--accent: var(--forge-accent);
--accent-soft: var(--forge-accent-soft);
--glow: var(--forge-glow);
--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);
display: flex;
flex-direction: column;
gap: 1.1rem;
max-width: 1080px;
margin: 0 auto;
}
:global([data-theme='dark']) .forge {
--badge-image-text: #93c5fd;
--badge-compose-text: #c4b5fd;
--badge-static-text: #6ee7b7;
}
.loading-line {
display: inline-flex;
align-items: center;
gap: 0.6rem;
color: var(--text-secondary);
font-family: var(--forge-mono);
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.spinner {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1.5px solid var(--border-primary);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── Hero lede row (badges + meta) ─────────────── */
.lede-row {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-family: var(--font-family-sans);
}
.lede-sep {
color: var(--text-tertiary);
opacity: 0.6;
}
.lede-meta {
font-size: 0.85rem;
color: var(--text-tertiary);
}
.lede-meta code {
font-family: var(--forge-mono);
font-size: 0.78rem;
color: var(--text-secondary);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.6rem;
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.src-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.src-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.src-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.trigger {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
/* ── Runtime / storage panels ────────────────────
Two narrow operational-status cards displayed in a 2-up grid
above the Manual deploy panel. Collapses to one column under
720px so the labels never get clipped by the registration
corners. */
.rt-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
@media (max-width: 720px) {
.rt-grid {
grid-template-columns: 1fr;
}
}
.rt-card .panel-title :global(svg) {
opacity: 0.7;
margin-right: 0.35rem;
vertical-align: -2px;
}
/* External-link affordance on the "Open" toolbar button — the
only ghost button that navigates away from the app, so it
gets the ↗ glyph to differentiate it from in-app actions. */
.open-ext .open-ext-glyph {
margin-left: 0.15rem;
font-size: 0.78em;
opacity: 0.65;
transition: transform 120ms ease, opacity 120ms ease;
}
.open-ext:hover .open-ext-glyph {
transform: translate(1px, -1px);
opacity: 1;
}
.kv-grid {
display: grid;
grid-template-columns: minmax(7rem, max-content) 1fr;
row-gap: 0.55rem;
column-gap: 1rem;
margin: 0.7rem 0 0;
font-size: 0.875rem;
}
.kv-grid dt {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
align-self: center;
}
.kv-grid dd {
margin: 0;
color: var(--text-secondary);
align-self: center;
}
.mono-sha,
.mono-time {
font-family: var(--forge-mono);
font-size: 0.78rem;
color: var(--text-secondary);
}
.muted {
color: var(--text-tertiary);
}
.rt-empty {
font-family: var(--forge-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
color: var(--text-tertiary);
margin: 0.7rem 0 0;
}
.rt-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.18rem 0.55rem;
border-radius: var(--radius-full);
font-family: var(--forge-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
border: 1px solid var(--border-primary);
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.rt-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.rt-badge.rt-ok {
/* Darken the foreground beyond -dark to clear 4.5:1 against the
12% tint on light backgrounds — color-mix the brand token with
black instead of relying on -dark alone. */
background: color-mix(in srgb, var(--color-success) 16%, transparent);
color: color-mix(in srgb, var(--color-success-dark) 80%, #000);
border-color: color-mix(in srgb, var(--color-success) 45%, transparent);
}
:global([data-theme='dark']) .rt-badge.rt-ok {
color: #6ee7b7;
}
.rt-badge.rt-busy {
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
color: var(--color-warning-dark);
border-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
}
.rt-badge.rt-busy .rt-dot {
animation: rt-pulse 1.4s ease-in-out infinite;
}
@keyframes rt-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
:global([data-theme='dark']) .rt-badge.rt-busy {
color: #fcd34d;
}
.rt-badge.rt-bad {
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger-dark);
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
}
:global([data-theme='dark']) .rt-badge.rt-bad {
color: #fca5a5;
}
.rt-badge.rt-idle {
color: var(--text-tertiary);
}
.rt-error {
margin-top: 0.8rem;
}
.rt-probe-note {
margin: 0.7rem 0 0;
font-size: 0.78rem;
color: var(--text-tertiary);
}
.usage-bar {
margin-top: 0.85rem;
height: 6px;
width: 100%;
background: var(--surface-card-hover);
border-radius: var(--radius-full);
overflow: hidden;
}
.usage-fill {
height: 100%;
transition: width 200ms ease;
border-radius: var(--radius-full);
}
.usage-fill.bar-green {
background: var(--color-success);
}
.usage-fill.bar-amber {
background: var(--color-warning);
}
.usage-fill.bar-red {
background: var(--color-danger);
}
.usage-caption {
margin: 0.35rem 0 0;
font-family: var(--forge-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
color: var(--text-tertiary);
text-align: right;
}
/* ── Alert / Success ───────────────────────────── */
.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;
}
.success {
display: flex;
gap: 0.7rem;
align-items: center;
padding: 0.55rem 0.8rem;
background: var(--color-success-light);
color: var(--color-success-dark);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
border-left-width: 4px;
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.success-tag {
font-family: var(--forge-mono);
font-weight: 700;
font-size: 0.62rem;
letter-spacing: 0.16em;
padding: 0.15rem 0.4rem;
background: var(--color-success);
color: #fff;
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .success {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: #86efac;
}
/* ── Panel ────────────────────────────────────── */
.panel {
position: relative;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
padding: 1.25rem 1.5rem 1.4rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.panel.code-panel {
padding: 0;
gap: 0;
overflow: hidden;
}
.reg {
position: absolute;
width: 10px;
height: 10px;
border-color: var(--color-brand-500);
border-style: solid;
border-width: 0;
pointer-events: none;
}
.reg-tl {
top: -1px;
left: -1px;
border-top-width: 2px;
border-left-width: 2px;
border-top-left-radius: var(--radius-2xl);
}
.reg-tr {
top: -1px;
right: -1px;
border-top-width: 2px;
border-right-width: 2px;
border-top-right-radius: var(--radius-2xl);
}
.reg-bl {
bottom: -1px;
left: -1px;
border-bottom-width: 2px;
border-left-width: 2px;
border-bottom-left-radius: var(--radius-2xl);
}
.reg-br {
bottom: -1px;
right: -1px;
border-bottom-width: 2px;
border-right-width: 2px;
border-bottom-right-radius: var(--radius-2xl);
}
.panel-head {
display: flex;
align-items: center;
gap: 0.7rem;
flex-wrap: wrap;
}
.panel-head.split {
gap: 0.5rem;
background: var(--surface-card-hover);
border-bottom: 1px solid var(--border-primary);
padding: 0.5rem 0.7rem 0.5rem 1.15rem;
}
.head-toggle {
flex: 1;
display: inline-flex;
align-items: center;
gap: 0.7rem;
min-width: 0;
background: transparent;
border: 0;
padding: 0.3rem 0;
margin: 0;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
transition: color 120ms ease;
}
.head-toggle:hover {
color: var(--accent);
}
.head-toggle:hover .panel-title {
color: var(--accent);
}
.head-toggle:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.panel-title {
font-family: var(--font-family-sans);
font-size: 0.98rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--text-primary);
margin: 0;
}
.title-accent {
color: var(--accent);
font-weight: 700;
}
.panel-sub {
font-size: 0.78rem;
color: var(--text-tertiary);
}
.panel-sub.mono {
font-family: var(--forge-mono);
font-size: 0.72rem;
padding: 0.12rem 0.45rem;
border-radius: var(--radius-sm);
background: var(--surface-card);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.panel-sub :global(code),
code {
font-family: var(--forge-mono);
font-size: 0.78rem;
}
.chev {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--text-tertiary);
transition: transform 180ms ease;
}
.chev.rot {
transform: rotate(-90deg);
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
}
.copy-btn:hover {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.copy-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.code-body {
padding: 0;
background: var(--surface-input);
}
.code-view {
margin: 0;
padding: 1rem 1.15rem;
font-family: var(--forge-mono);
font-size: 0.78rem;
line-height: 1.6;
color: var(--text-primary);
white-space: pre;
overflow: auto;
max-height: 360px;
tab-size: 2;
}
/* ── Deploy row ───────────────────────────────── */
.deploy-row {
display: flex;
gap: 0.5rem;
}
.deploy-input {
flex: 1;
display: flex;
}
.deploy-input input {
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border-input);
background: var(--surface-input);
border-radius: var(--radius-lg);
font-family: var(--forge-mono);
font-size: 0.85rem;
color: var(--text-primary);
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.deploy-input input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.5;
}
/* ── Containers table ─────────────────────────── */
.empty-inline {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.85rem 1rem;
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-tertiary);
font-size: 0.88rem;
}
.empty-mark {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-input);
}
.table-wrap {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.muted {
color: var(--text-tertiary);
}
.mono {
font-family: var(--forge-mono);
font-size: 0.78rem;
}
.state-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 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;
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.state-pill .pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-tertiary);
}
.state-pill.st-running {
background: var(--color-success-light);
color: var(--color-success-dark);
}
.state-pill.st-running .pulse {
background: var(--color-success);
animation: blink 1.8s infinite;
}
.state-pill.st-failed {
background: var(--color-danger-light);
color: var(--color-danger-dark);
}
.state-pill.st-failed .pulse {
background: var(--color-danger);
animation: blink 0.5s infinite;
}
.state-pill.st-deploying {
background: var(--color-info-light);
color: var(--color-info-dark);
}
.state-pill.st-deploying .pulse {
background: var(--color-info);
animation: blink 0.8s infinite;
}
:global([data-theme='dark']) .state-pill.st-running {
background: color-mix(in srgb, var(--color-success) 16%, transparent);
color: #86efac;
}
:global([data-theme='dark']) .state-pill.st-deploying {
background: color-mix(in srgb, var(--color-info) 16%, transparent);
color: #93c5fd;
}
:global([data-theme='dark']) .state-pill.st-failed {
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
color: #fca5a5;
}
@keyframes blink {
0%,
60%,
100% {
opacity: 1;
}
70%,
90% {
opacity: 0.3;
}
}
/* ── Edit form fields ─────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.field-label {
display: flex;
align-items: center;
gap: 0.55rem;
}
.num {
display: inline-flex;
width: 26px;
height: 26px;
justify-content: center;
align-items: center;
background: var(--text-primary);
color: var(--surface-card);
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.7rem;
font-weight: 700;
}
.lbl {
font-family: var(--font-family-sans);
font-weight: 600;
font-size: 1.05rem;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.req,
.opt {
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.req {
color: var(--color-danger);
}
.opt {
color: var(--text-tertiary);
}
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.6rem 0.8rem;
font-size: 0.92rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.editor {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface-input);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.editor:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.editor-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.8rem;
background: var(--surface-card-hover);
border-bottom: 1px solid var(--border-primary);
font-family: var(--forge-mono);
font-size: 0.7rem;
color: var(--text-tertiary);
}
.editor-head .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-input);
}
.editor-head .dot:nth-of-type(1) {
background: #ef4444aa;
}
.editor-head .dot:nth-of-type(2) {
background: #f59e0baa;
}
.editor-head .dot:nth-of-type(3) {
background: #10b981aa;
}
.editor-title {
margin-left: 0.4rem;
color: var(--text-secondary);
font-weight: 600;
}
.code-area {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 0.85rem 1rem;
font-family: var(--forge-mono);
font-size: 0.82rem;
line-height: 1.55;
color: var(--text-primary);
resize: vertical;
outline: none;
tab-size: 2;
}
.editor-foot {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.8rem;
border-top: 1px solid var(--border-primary);
background: var(--surface-card-hover);
font-family: var(--forge-mono);
font-size: 0.62rem;
color: var(--text-tertiary);
}
.foot-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-success-dark);
letter-spacing: 0.1em;
font-weight: 600;
}
.foot-status .foot-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-success);
}
.foot-status.bad {
color: var(--color-danger-dark);
}
.foot-status.bad .foot-dot {
background: var(--color-danger);
}
:global([data-theme='dark']) .foot-status {
color: #86efac;
}
:global([data-theme='dark']) .foot-status.bad {
color: #fca5a5;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding-top: 0.4rem;
}
/* Ghost-button danger variant — reuses .forge-btn-ghost from app.css
but tints the text + hover for destructive actions. */
:global(.forge-btn-ghost.danger) {
color: var(--color-danger);
}
:global(.forge-btn-ghost.danger:hover:not(:disabled)) {
border-color: var(--color-danger);
background: var(--color-danger-light);
color: var(--color-danger-dark);
}
:global([data-theme='dark']) :global(.forge-btn-ghost.danger:hover:not(:disabled)) {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: #fca5a5;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* ── Chain panel ───────────────────────────────────────────
The chain panel shows parent / self / children for promote-from
linkages. The original markup leaned on shared classes (`panel`,
`forge-btn-ghost`, `alert`) and was functional but unpolished
compared with sibling panels. These rules give each row a
fixed-width label column and a bordered card per workload,
highlighting the "self" card so the user always sees where they
are in the chain. */
.chain-row {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.4rem 0;
}
.chain-row.chain-children {
align-items: flex-start;
}
.chain-label {
flex: 0 0 70px;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
.chain-card {
display: inline-flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.55rem 0.8rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-primary);
text-decoration: none;
min-width: 180px;
transition: border-color 120ms ease, transform 120ms ease,
box-shadow 120ms ease;
}
a.chain-card:hover {
border-color: var(--color-brand-400);
transform: translateY(-1px);
box-shadow: 0 1px 0 0 var(--forge-glow);
}
.chain-card.chain-self {
background: color-mix(in srgb, var(--color-brand-500) 8%, var(--surface-card));
border-color: var(--color-brand-500);
cursor: default;
}
.chain-name {
font-weight: 600;
font-size: 0.92rem;
letter-spacing: -0.01em;
}
.chain-card .mono {
font-family: var(--forge-mono);
font-size: 0.68rem;
letter-spacing: 0.04em;
}
.chain-card .muted {
color: var(--text-tertiary);
}
.chain-children-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 600px) {
.chain-row {
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
}
.chain-label {
flex: 0 0 auto;
}
}
/* ── Branch preview environments ──────────────────────────
Each preview is a per-branch deploy of the template workload. The
panel lists them as rows: state pill (reusing the .rt-badge palette),
the branch name in mono, an open-↗ link to the slug-prefixed FQDN,
and a teardown button (gated behind ConfirmDialog). The "PREVIEW"
tag re-used in the chain panel marks the same rows there. */
.preview-tag {
display: inline-block;
margin-left: 0.4rem;
padding: 0.05rem 0.34rem;
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
vertical-align: middle;
color: var(--color-brand-700);
background: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 40%, transparent);
border-radius: var(--radius-sm);
}
:global([data-theme='dark']) .preview-tag {
color: var(--color-brand-300, #fcd34d);
}
.preview-empty {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 0.85rem;
font-size: 0.85rem;
color: var(--text-secondary);
background: var(--surface-subtle, var(--surface-card));
border: 1px dashed var(--border-primary);
border-radius: var(--radius-lg);
}
.preview-empty code {
font-family: var(--forge-mono);
font-size: 0.78rem;
color: var(--text-primary);
}
.preview-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preview-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.7rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
}
.preview-branch {
font-family: var(--forge-mono);
font-size: 0.84rem;
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
letter-spacing: -0.005em;
}
.preview-branch:hover {
color: var(--color-brand-600);
text-decoration: underline;
}
.preview-spacer {
flex: 1 1 auto;
}
.preview-state {
flex: 0 0 auto;
}
.preview-nourl {
display: inline-flex;
align-items: center;
font-family: var(--forge-mono);
font-size: 0.66rem;
letter-spacing: 0.06em;
color: var(--text-tertiary);
}
@media (max-width: 600px) {
.preview-row {
flex-wrap: wrap;
gap: 0.4rem;
}
.preview-spacer {
flex-basis: 100%;
height: 0;
}
}
/* ── Log rules table styling ──────────────────────────────
Mirrors the /log-scan-rules list-page badges so users see
consistent severity/scope shape across both surfaces. The
.scope-global/.scope-workload/.scope-override classes are
matched against the `kind` string produced by classifyRule. */
.log-pattern {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 720px) {
.log-pattern {
max-width: 160px;
}
}
.badge.scope-global {
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.15rem 0.45rem;
border-radius: var(--radius-sm);
}
.badge.scope-workload {
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
color: var(--color-brand-600);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.15rem 0.45rem;
border-radius: var(--radius-sm);
}
.badge.scope-override {
background: color-mix(in srgb, var(--forge-accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--forge-accent) 35%, transparent);
color: var(--forge-accent);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.15rem 0.45rem;
border-radius: var(--radius-sm);
}
.severity {
display: inline-flex;
align-items: center;
padding: 0.12rem 0.4rem;
border-radius: var(--radius-sm);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.sev-info {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.sev-warn {
background: color-mix(in srgb, #f59e0b 16%, transparent);
color: #b45309;
}
.sev-error {
background: var(--color-danger-light);
color: var(--color-danger-dark);
}
:global(.forge-btn-ghost.xs) {
padding: 0.2rem 0.55rem;
font-size: 0.62rem;
}
/* The source-config edit form's bespoke styling (.image-form,
.static-mode, .radio, .checkbox-row, .row.three, the discovery
buttons/pills, and the test-connection row) moved into the extracted
$lib/components/workload/* source-form components along with the markup
they styled. */
/* ── Bindings panel ─────────────────────────────────
Mirrors the bindings list on /triggers/[id] but
reads from the workload's perspective: each row
shows the bound trigger's name + kind, with the
enable toggle, deep-link, and unbind actions. */
.bindings-head {
justify-content: space-between;
gap: 0.7rem;
flex-wrap: wrap;
}
.bindings-head-left {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
.bindings-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.binding {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
flex-wrap: wrap;
}
.b-main {
display: inline-flex;
align-items: center;
gap: 0.7rem;
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.b-ref {
font-size: 0.66rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
flex: 0 0 auto;
}
.b-name {
font-weight: 600;
color: var(--text-primary);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
transition: color 120ms ease;
}
.b-name:hover {
color: var(--accent);
text-decoration: underline;
}
.b-kind {
display: inline-flex;
padding: 0.16rem 0.45rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
flex: 0 0 auto;
}
.b-state {
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
padding: 0.12rem 0.45rem;
border-radius: var(--radius-sm);
flex: 0 0 auto;
}
.b-state.on {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success-dark);
}
.b-state.off {
background: var(--surface-card);
color: var(--text-tertiary);
}
.b-actions {
display: inline-flex;
gap: 0.35rem;
align-items: center;
}
:global(.forge-btn-icon.danger) {
color: var(--color-danger);
}
:global(.forge-btn-icon.danger:hover:not(:disabled)) {
background: var(--color-danger-light);
}
:global([data-theme='dark']) :global(.forge-btn-icon.danger:hover:not(:disabled)) {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
}
/* xs modifier for ghost buttons — matches the sizing
used by /triggers/[id] and other pages. */
:global(.forge-btn-ghost.xs) {
padding: 0.32rem 0.6rem;
font-size: 0.6rem;
gap: 0.3rem;
border-radius: var(--radius-md);
}
/* ── Override editor (per-binding) ───────────────────
The expanded inline panel that lets operators set
a per-binding `binding_config` overlay. The host
binding row gains a left accent stripe when an
override is active so it's visually distinct from
pristine "inherit verbatim" rows. */
.binding.has-override {
border-left: 3px solid var(--forge-accent);
}
.binding.open {
border-color: var(--forge-accent);
box-shadow: 0 0 0 1px var(--forge-accent-soft);
}
.b-override {
display: inline-flex;
padding: 0.14rem 0.45rem;
background: var(--forge-accent-soft);
color: var(--forge-accent);
font-family: var(--forge-mono);
font-size: 0.56rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
border-radius: var(--radius-sm);
line-height: 1;
flex: 0 0 auto;
border: 1px solid color-mix(in srgb, var(--forge-accent) 35%, transparent);
}
.override-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-family: var(--forge-mono);
font-size: 0.6rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.override-btn :global(.chev) {
transition: transform 180ms ease;
}
.override-btn[aria-expanded='true'] :global(.chev) {
transform: rotate(180deg);
}
.override-btn.has-override {
color: var(--forge-accent);
}
.override-btn.active {
border-color: var(--forge-accent);
color: var(--forge-accent);
}
.override-panel {
flex: 1 0 100%;
display: flex;
flex-direction: column;
gap: 0.85rem;
margin-top: 0.6rem;
padding: 0.95rem 1rem 0.85rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
animation: op-fade 180ms ease;
}
@keyframes op-fade {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.op-head {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.op-title {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.op-sub {
margin: 0;
font-size: 0.76rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.op-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
@media (max-width: 720px) {
.op-grid {
grid-template-columns: 1fr;
}
}
.op-col,
.op-preview {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
.op-col-head {
display: inline-flex;
align-items: center;
gap: 0.5rem;
justify-content: space-between;
}
.op-col-label {
font-family: var(--forge-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
.op-col-tag {
display: inline-flex;
padding: 0.12rem 0.4rem;
background: var(--surface-card-hover);
color: var(--text-tertiary);
font-family: var(--forge-mono);
font-size: 0.54rem;
font-weight: 700;
letter-spacing: 0.16em;
border-radius: var(--radius-sm);
line-height: 1.3;
border: 1px solid var(--border-primary);
}
.op-col-tag.accent {
background: var(--forge-accent-soft);
color: var(--forge-accent);
border-color: color-mix(in srgb, var(--forge-accent) 30%, transparent);
}
.op-col-tag.warn {
background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent);
color: var(--color-warning-dark, #b45309);
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 35%, transparent);
}
.op-col-tag.bad {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: var(--color-danger-dark, var(--color-danger));
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
}
.op-readonly {
margin: 0;
padding: 0.65rem 0.75rem;
background: var(--surface-card-hover);
border: 1px dashed var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.78rem;
line-height: 1.5;
color: var(--text-secondary);
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.op-readonly.dim {
color: var(--text-tertiary);
font-style: italic;
}
.op-editor {
width: 100%;
min-height: 160px;
padding: 0.65rem 0.75rem;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
font-size: 0.82rem;
line-height: 1.5;
color: var(--text-primary);
font-family: var(--forge-mono);
resize: vertical;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.op-editor:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.op-editor.bad {
border-color: var(--color-danger);
}
.op-editor.bad:focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 22%, transparent);
}
.op-editor.warn {
border-color: var(--color-warning, #f59e0b);
}
.op-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.op-meta-msg {
font-size: 0.72rem;
color: var(--text-tertiary);
line-height: 1.4;
}
.op-meta-msg.bad {
color: var(--color-danger);
}
.op-meta-bytes {
font-size: 0.62rem;
letter-spacing: 0.1em;
color: var(--text-tertiary);
}
.op-meta-bytes.warn {
color: var(--color-warning-dark, #b45309);
font-weight: 700;
}
.op-hint {
margin: 0;
font-size: 0.72rem;
color: var(--text-tertiary);
line-height: 1.4;
}
.op-preview {
padding-top: 0.45rem;
border-top: 1px dashed var(--border-primary);
}
.op-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
flex-wrap: wrap;
}
.op-actions-right {
display: inline-flex;
gap: 0.45rem;
align-items: center;
margin-left: auto;
}
/* ── Add-trigger modal ──────────────────────────────
The dialog slides in over a backdrop. Two tabs at
the top swap between inline-create and pick-existing.
The body scrolls independently so very tall inline
forms don't push the actions off-screen. */
.modal-wrap {
padding-top: 4vh;
padding-bottom: 4vh;
}
.add-modal {
position: relative;
width: 100%;
max-width: 640px;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
max-height: 92vh;
}
.add-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.1rem 1.25rem 0.55rem;
border-bottom: 1px solid var(--border-primary);
}
.add-head-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.add-title {
margin: 0;
font-family: var(--font-family-sans);
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.add-sub {
margin: 0;
font-size: 0.82rem;
color: var(--text-tertiary);
line-height: 1.45;
}
.add-tabs {
display: inline-flex;
gap: 0;
padding: 0.55rem 1.25rem 0;
border-bottom: 1px solid var(--border-primary);
}
.add-tab {
padding: 0.55rem 0.95rem;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
font-family: var(--forge-mono);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
transition: color 120ms ease, border-color 120ms ease;
margin-bottom: -1px;
}
.add-tab:hover {
color: var(--text-secondary);
}
.add-tab.active {
color: var(--forge-accent);
border-bottom-color: var(--forge-accent);
}
.add-body {
padding: 1rem 1.25rem;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.add-actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding: 0.85rem 1.25rem;
border-top: 1px solid var(--border-primary);
background: var(--surface-card-hover);
border-bottom-left-radius: var(--radius-2xl);
border-bottom-right-radius: var(--radius-2xl);
}
.pick-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.85rem;
}
@media (max-width: 600px) {
.pick-row {
grid-template-columns: 1fr;
}
}
.pick-row .sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
.add-modal .alert.inline-alert {
margin: 0.55rem 1.25rem 0;
}
/* ── Live-state pill (hero lede) ──────────────────
Reuses the existing .rt-badge palette tokens (rt-ok / rt-busy /
rt-bad / rt-idle) so the live container-state chip matches the
static runtime-status badge stylistically. .live-badge is just
a sizing tweak so it sits comfortably next to .badge chips. */
.live-badge {
padding: 0.16rem 0.55rem;
/* Matches the bumped .rt-badge size (0.68rem) so the most
important status pill on the page is the most readable. */
font-size: 0.66rem;
letter-spacing: 0.14em;
}
/* ── Build log panel (dockerfile live tail) ──────── */
.build-log-panel {
margin-top: 1rem;
}
.build-log-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.build-log-clear {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 0.3rem 0.65rem;
}
.build-log-tail {
margin-top: 0.7rem;
max-height: 320px;
overflow-y: auto;
background: #0a0d12;
color: #d4dbe5;
border: 1px solid var(--border-primary);
border-radius: 4px;
padding: 0.65rem 0.85rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.75rem;
line-height: 1.45;
scrollbar-gutter: stable;
}
.build-log-line {
white-space: pre-wrap;
word-break: break-word;
}
/* ── Resource usage panel ─────────────────────── */
.stats-panel {
margin-top: 1rem;
}
.stats-list {
list-style: none;
margin: 0.7rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.stats-row {
padding: 0.7rem 0.85rem 0.85rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
}
.stats-row-head {
display: inline-flex;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
/* Breathing room between header and chart, matches .panel-head. */
margin-bottom: 0.5rem;
}
.stats-role {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
}
.stats-cid {
font-size: 0.68rem;
color: var(--text-tertiary);
}
/* Per-row collapse when the workload has ≥3 containers — a compose
stack with N services would otherwise stack a wall of charts. */
.stats-collapse > summary {
cursor: pointer;
list-style: none;
padding: 0.1rem 0;
}
.stats-collapse > summary::-webkit-details-marker {
display: none;
}
.stats-collapse-glyph {
display: inline-block;
margin-right: 0.15rem;
font-family: var(--forge-mono);
color: var(--text-tertiary);
transition: transform 120ms ease;
}
.stats-collapse[open] .stats-collapse-glyph {
transform: rotate(90deg);
}
/* ── Webhook bindings (read-only summary) ─────── */
.wh-panel {
margin-top: 1rem;
}
.wh-panel .panel-title :global(svg) {
opacity: 0.7;
margin-right: 0.35rem;
vertical-align: -2px;
}
.wh-list {
list-style: none;
margin: 0.7rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.wh-row {
display: inline-flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
}
.wh-row.disabled {
opacity: 0.68;
}
.wh-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-sm);
background: var(--surface-card);
color: var(--text-tertiary);
border: 1px solid var(--border-primary);
flex: 0 0 auto;
}
.wh-name {
font-weight: 600;
color: var(--text-primary);
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.wh-kind {
display: inline-flex;
padding: 0.16rem 0.45rem;
background: var(--text-primary);
color: var(--surface-card);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.18em;
border-radius: var(--radius-sm);
line-height: 1;
flex: 0 0 auto;
}
.wh-muted {
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wh-open {
margin-left: auto;
}
/* ── Responsive toolbar overflow ──────────────────
At ≥640px the overflow <details> stays hidden and every action
renders inline. Under 640px the .tb-wide buttons collapse into a
"More ⋯" menu. Both copies live in the DOM so keyboard/aria
semantics remain native — CSS only flips visibility. */
.tb-overflow {
position: relative;
display: none;
}
@media (max-width: 639px) {
.tb-wide {
display: none !important;
}
.tb-overflow {
display: inline-block;
}
}
.tb-more-summary {
list-style: none;
cursor: pointer;
user-select: none;
}
.tb-more-summary::-webkit-details-marker {
display: none;
}
.tb-more-glyph {
margin-left: 0.15rem;
font-size: 0.95em;
line-height: 1;
opacity: 0.7;
}
.tb-menu {
position: absolute;
right: 0;
top: calc(100% + 0.35rem);
min-width: 11rem;
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
/* Sticky nav in this app uses z-index ≥40; bump well past so
the overflow menu always sits above page chrome. */
z-index: 100;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
box-shadow: 0 12px 28px -10px rgba(15, 23, 42, 0.35);
}
:global([data-theme='dark']) .tb-menu {
box-shadow: 0 12px 28px -10px rgba(0, 0, 0, 0.55);
}
.tb-menu-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
background: transparent;
border: 0;
border-radius: var(--radius-md);
color: var(--text-secondary);
font: inherit;
font-size: 0.82rem;
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.tb-menu-item:hover,
.tb-menu-item:focus-visible {
background: var(--surface-card-hover);
color: var(--text-primary);
outline: none;
}
.tb-menu-item.danger {
color: var(--color-danger);
}
.tb-menu-item.danger:hover,
.tb-menu-item.danger:focus-visible {
background: var(--color-danger-light);
color: var(--color-danger-dark);
}
</style>