192204a51c
Build / build (push) Failing after 4m51s
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).
5197 lines
163 KiB
Svelte
5197 lines
163 KiB
Svelte
<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} {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>
|