chore(workload): close the workload-first arc — apps i18n + codemap + tests
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Closes the workload-first refactor by landing the Priority 3 polish items and the Priority 4 test gap. Net: ~2,400 lines added, ~350 lines modified across 13 files. Priority 3 — polish - apps.* i18n namespace: 276 new keys across apps.list.* (27), apps.new.* (91, sibling of existing apps.new.triggers.*), and apps.detail.* (158, sibling of existing apps.detail.bindings.*). EN+RU at 1314 keys each, perfectly in sync. /apps, /apps/new, /apps/[id] now render entirely from i18n. - New codemap docs/CODEMAPS/workload-plugin.md (238 lines): Source × Trigger contract, dispatch seam, webhook fan-out path, recipes for adding a new Source or Trigger kind. Plus docs/CODEMAPS/INDEX.md gateway. Priority 4 — tests - internal/api/workloads_test.go (new, ~30 subtests): /api/workloads CRUD + deploy + delete + env + volumes + chain + promote-from + triggers list/inline-bind + auth gating + standalone /api/triggers CRUD (create / dup-409 / kind filter / delete). Uses real POST handlers via httptest.NewServer + a fake plugin source registered under "testfakesource". - internal/deployer/dispatch_test.go (new, 11 tests): DispatchPlugin / DispatchTeardown / DispatchReconcile happy + unknown-kind + propagated-error each; PluginDeps wiring; a real 2s-bounded RWMutex deadlock probe on PluginDeps vs SetDNSProvider. - internal/workload/plugin/source/compose/compose_test.go (new, ~26 subtests): composeProjectName sanitization, writeYAML / writeYAMLIfChanged hash short-circuit, Validate happy + bad inputs, Kind / SchemaSample. Coverage delta on the workload-plugin path: - internal/api: 1.1% → 16.0% - internal/deployer: 0% → 54.1% - internal/workload/plugin/source/compose: 0% → 38.5% - Trigger plugins already at 87-95% from the trigger-split work. Production fix surfaced by the tests - store.CreateWorkload now self-references RefID = ID when caller leaves RefID empty (the typical plugin-native path). The api layer's broken backfill loop (called UpdateWorkload, which deliberately omits ref_id) is gone. Multiple sibling plugin workloads can now coexist under the UNIQUE(kind, ref_id) constraint. Review fixes addressed before commit - CRITICAL: deadlock-detect test gained a real 2s time.After (was selecting on context.Background().Done() which never fires). - HIGH: happy-path test now hard-asserts RefID = ID (was a t.Logf that would silently pass after a production fix). - HIGH: standalone /api/triggers CRUD coverage added (was bypassed by the workload-side bind flow). - HIGH: seedWorkload bypass deleted; tests now go through the real POST /api/workloads handler. - MEDIUM: withTempDir restore is a no-op (t.Setenv auto-restores); dead `old := os.Getenv(...)` capture removed. - MEDIUM: list-workloads test now asserts ID membership, not just count. Doc - WORKLOAD_REFACTOR_TODO: all three Priority 1 items, Priority 3 polish, and Priority 4 tests marked DONE. The workload-first arc is closed.
This commit is contained in:
@@ -401,7 +401,7 @@
|
||||
// session storage may be disabled — ignore.
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load app';
|
||||
error = e instanceof Error ? e.message : $t('apps.detail.loadError');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -469,7 +469,7 @@
|
||||
async function addVolume() {
|
||||
volumeError = '';
|
||||
if (!newVolTarget.trim().startsWith('/')) {
|
||||
volumeError = 'Target must be an absolute container path (e.g. /data)';
|
||||
volumeError = $t('apps.detail.volumeTargetError');
|
||||
return;
|
||||
}
|
||||
volumeSaving = true;
|
||||
@@ -483,7 +483,7 @@
|
||||
newVolTarget = '';
|
||||
volumeRows = await api.listWorkloadVolumes(id);
|
||||
} catch (e) {
|
||||
volumeError = e instanceof Error ? e.message : 'Failed to set volume';
|
||||
volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeSetFailed');
|
||||
} finally {
|
||||
volumeSaving = false;
|
||||
}
|
||||
@@ -495,7 +495,7 @@
|
||||
await api.deleteWorkloadVolume(id, volID);
|
||||
volumeRows = volumeRows.filter((v) => v.id !== volID);
|
||||
} catch (e) {
|
||||
volumeError = e instanceof Error ? e.message : 'Failed to delete volume';
|
||||
volumeError = e instanceof Error ? e.message : $t('apps.detail.volumeDeleteFailed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
envError = '';
|
||||
const key = newEnvKey.trim();
|
||||
if (!key) {
|
||||
envError = 'Key is required';
|
||||
envError = $t('apps.detail.envKeyRequired');
|
||||
return;
|
||||
}
|
||||
envSaving = true;
|
||||
@@ -517,7 +517,7 @@
|
||||
newEnvValue = '';
|
||||
envRows = await api.listWorkloadEnv(id);
|
||||
} catch (e) {
|
||||
envError = e instanceof Error ? e.message : 'Failed to set env';
|
||||
envError = e instanceof Error ? e.message : $t('apps.detail.envSetFailed');
|
||||
} finally {
|
||||
envSaving = false;
|
||||
}
|
||||
@@ -529,7 +529,7 @@
|
||||
await api.deleteWorkloadEnv(id, envID);
|
||||
envRows = envRows.filter((e) => e.id !== envID);
|
||||
} catch (e) {
|
||||
envError = e instanceof Error ? e.message : 'Failed to delete env';
|
||||
envError = e instanceof Error ? e.message : $t('apps.detail.envDeleteFailed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,10 +540,13 @@
|
||||
try {
|
||||
const body = deployRef ? { reference: deployRef } : undefined;
|
||||
const res = await api.deployPluginWorkload(id, body);
|
||||
lastDeployMsg = `Dispatched ${res.reference || '(default)'} as ${res.triggered_by}`;
|
||||
lastDeployMsg = $t('apps.detail.manualDeployDispatched', {
|
||||
reference: res.reference || '(default)',
|
||||
by: res.triggered_by
|
||||
});
|
||||
setTimeout(load, 1500);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Deploy failed';
|
||||
error = e instanceof Error ? e.message : $t('apps.detail.deployError');
|
||||
} finally {
|
||||
deploying = false;
|
||||
}
|
||||
@@ -789,7 +792,7 @@
|
||||
editing = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Save failed';
|
||||
error = e instanceof Error ? e.message : $t('apps.detail.saveError');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
@@ -913,7 +916,7 @@
|
||||
await api.deletePluginWorkload(id);
|
||||
goto('/apps');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
error = e instanceof Error ? e.message : $t('apps.detail.deleteError');
|
||||
deleting = false;
|
||||
confirmDelete = false;
|
||||
}
|
||||
@@ -960,30 +963,30 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{workload?.name ?? 'App'} · Tinyforge</title>
|
||||
<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>Loading workload…</span>
|
||||
<span>{$t('apps.detail.loading')}</span>
|
||||
</div>
|
||||
{:else if error && !workload}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
|
||||
{:else if workload}
|
||||
{#snippet detailToolbar()}
|
||||
<button class="forge-btn-icon" onclick={load} aria-label="Refresh">
|
||||
<button class="forge-btn-icon" onclick={load} aria-label={$t('apps.detail.refreshLabel')}>
|
||||
<IconRefresh size={16} />
|
||||
</button>
|
||||
{#if !editing}
|
||||
<button class="forge-btn-ghost" onclick={startEdit}>
|
||||
<IconEdit size={13} />
|
||||
<span>Edit</span>
|
||||
<span>{$t('apps.detail.editButton')}</span>
|
||||
</button>
|
||||
<button class="forge-btn-ghost danger" onclick={() => (confirmDelete = true)}>
|
||||
<IconTrash size={13} />
|
||||
<span>Delete</span>
|
||||
<span>{$t('apps.detail.deleteButton')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -1003,24 +1006,24 @@
|
||||
</span>
|
||||
<span class="lede-sep">·</span>
|
||||
<span class="lede-meta">
|
||||
created <code>{workload!.created_at}</code>
|
||||
{$t('apps.detail.createdAt')} <code>{workload!.created_at}</code>
|
||||
</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
backHref="/apps"
|
||||
backLabel="Back to apps"
|
||||
eyebrowSuffix="APP"
|
||||
backLabel={$t('apps.detail.backLabel')}
|
||||
eyebrowSuffix={$t('apps.detail.eyebrowSuffix')}
|
||||
title={workload.name}
|
||||
kicker={`id: ${workload.id}`}
|
||||
kicker={$t('apps.detail.kickerId', { id: workload.id })}
|
||||
size="lg"
|
||||
toolbar={detailToolbar}
|
||||
lede_html={detailLede}
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('apps.detail.alertTag')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
@@ -1032,17 +1035,16 @@
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Edit configuration<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.editTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
Source <code>{workload.source_kind}</code> · triggers managed in the
|
||||
Triggers panel below
|
||||
{$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">Name</span>
|
||||
<span class="lbl">{$t('apps.detail.editFieldName')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
@@ -1057,15 +1059,15 @@
|
||||
<div class="field">
|
||||
<label for="edit-parent" class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Parent workload</span>
|
||||
<span class="opt">OPTIONAL</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="workload UUID (blank for root)"
|
||||
placeholder={$t('apps.detail.editFieldParentPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -1074,28 +1076,28 @@
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">Source config</span>
|
||||
<span class="lbl">{$t('apps.detail.editSourceConfig')}</span>
|
||||
<span class="req">
|
||||
{useEditComposeForm
|
||||
? 'YAML'
|
||||
? $t('apps.detail.editConfigYaml')
|
||||
: useEditImageForm || useEditStaticForm
|
||||
? 'FORM'
|
||||
: 'JSON'}
|
||||
? $t('apps.detail.editConfigForm')
|
||||
: $t('apps.detail.editConfigJson')}
|
||||
</span>
|
||||
</div>
|
||||
{#if useEditComposeForm}
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">compose.yaml</span>
|
||||
<span class="editor-title">{$t('apps.detail.editComposeHeader')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.detail.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.detail.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -1104,24 +1106,24 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
placeholder={'services:\n web:\n image: nginx:alpine'}
|
||||
aria-label="Compose YAML"
|
||||
placeholder={$t('apps.detail.editComposePlaceholder')}
|
||||
aria-label={$t('apps.detail.editComposeAria')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status">
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
YAML
|
||||
{$t('apps.detail.editConfigYaml')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="edit-compose-project">
|
||||
<span class="sub-label">Compose project name (optional)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editComposeProject')}</span>
|
||||
<input
|
||||
id="edit-compose-project"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={editComposeProjectName}
|
||||
placeholder="(defaults to sanitized workload name)"
|
||||
placeholder={$t('apps.detail.editComposeProjectPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -1129,24 +1131,24 @@
|
||||
{:else if useEditImageForm}
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">image source · runtime knobs</span>
|
||||
<span class="editor-title">{$t('apps.detail.editImageHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.detail.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.detail.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="sub" for="edit-image-ref">
|
||||
<span class="sub-label">Image (registry path)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageRef')}</span>
|
||||
<input
|
||||
id="edit-image-ref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={editImageRef}
|
||||
placeholder="registry.example.com/owner/app"
|
||||
placeholder={$t('apps.detail.editImageRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
@@ -1154,7 +1156,7 @@
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="edit-image-port">
|
||||
<span class="sub-label">Port</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImagePort')}</span>
|
||||
<input
|
||||
id="edit-image-port"
|
||||
type="number"
|
||||
@@ -1165,7 +1167,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-healthcheck">
|
||||
<span class="sub-label">Healthcheck path</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageHealthcheck')}</span>
|
||||
<input
|
||||
id="edit-image-healthcheck"
|
||||
type="text"
|
||||
@@ -1177,7 +1179,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-default-tag">
|
||||
<span class="sub-label">Default tag</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageDefaultTag')}</span>
|
||||
<input
|
||||
id="edit-image-default-tag"
|
||||
type="text"
|
||||
@@ -1190,14 +1192,14 @@
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="edit-image-registry">
|
||||
<span class="sub-label">Registry (for private pulls)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageRegistry')}</span>
|
||||
{#if editRegistries.length > 0}
|
||||
<select
|
||||
id="edit-image-registry"
|
||||
class="input"
|
||||
bind:value={editImageRegistryName}
|
||||
>
|
||||
<option value="">(public — no auth)</option>
|
||||
<option value="">{$t('apps.detail.editImageRegistryPublic')}</option>
|
||||
{#each editRegistries as r}
|
||||
<option value={r.name}>{r.name} — {r.url}</option>
|
||||
{/each}
|
||||
@@ -1208,14 +1210,14 @@
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={editImageRegistryName}
|
||||
placeholder="(public — no auth)"
|
||||
placeholder={$t('apps.detail.editImageRegistryPublic')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="edit-image-cpu">
|
||||
<span class="sub-label">CPU limit (cores, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageCpu')}</span>
|
||||
<input
|
||||
id="edit-image-cpu"
|
||||
type="number"
|
||||
@@ -1226,7 +1228,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-memory">
|
||||
<span class="sub-label">Memory limit (MB, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageMemory')}</span>
|
||||
<input
|
||||
id="edit-image-memory"
|
||||
type="number"
|
||||
@@ -1236,7 +1238,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-image-max">
|
||||
<span class="sub-label">Max instances</span>
|
||||
<span class="sub-label">{$t('apps.detail.editImageMax')}</span>
|
||||
<input
|
||||
id="edit-image-max"
|
||||
type="number"
|
||||
@@ -1246,27 +1248,24 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint image-form-foot">
|
||||
Env vars and volume mounts use their own panels below — saving here
|
||||
preserves them.
|
||||
</p>
|
||||
<p class="hint image-form-foot">{$t('apps.detail.editImageFoot')}</p>
|
||||
</div>
|
||||
{:else if useEditStaticForm}
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">static source · pages from a repo</span>
|
||||
<span class="editor-title">{$t('apps.detail.editStaticHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.detail.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.detail.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="edit-static-provider">
|
||||
<span class="sub-label">Provider</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticProvider')}</span>
|
||||
<select
|
||||
id="edit-static-provider"
|
||||
class="input"
|
||||
@@ -1278,13 +1277,13 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="edit-static-base-url">
|
||||
<span class="sub-label">Base URL</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticBaseUrl')}</span>
|
||||
<input
|
||||
id="edit-static-base-url"
|
||||
type="url"
|
||||
class="input mono"
|
||||
bind:value={editStaticBaseURL}
|
||||
placeholder="https://git.example.com"
|
||||
placeholder={$t('apps.detail.editStaticBaseUrlPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -1292,7 +1291,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="edit-static-owner">
|
||||
<span class="sub-label">Repo owner</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticRepoOwner')}</span>
|
||||
<input
|
||||
id="edit-static-owner"
|
||||
type="text"
|
||||
@@ -1304,7 +1303,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-static-name">
|
||||
<span class="sub-label">Repo name</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticRepoName')}</span>
|
||||
<input
|
||||
id="edit-static-name"
|
||||
type="text"
|
||||
@@ -1318,7 +1317,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="edit-static-branch">
|
||||
<span class="sub-label">Branch</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticBranch')}</span>
|
||||
<input
|
||||
id="edit-static-branch"
|
||||
type="text"
|
||||
@@ -1329,31 +1328,31 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="edit-static-folder">
|
||||
<span class="sub-label">Folder path (optional)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticFolder')}</span>
|
||||
<input
|
||||
id="edit-static-folder"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={editStaticFolderPath}
|
||||
placeholder="(repo root)"
|
||||
placeholder={$t('apps.detail.editStaticFolderPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="edit-static-token">
|
||||
<span class="sub-label">Access token (private repos)</span>
|
||||
<span class="sub-label">{$t('apps.detail.editStaticToken')}</span>
|
||||
<input
|
||||
id="edit-static-token"
|
||||
type="password"
|
||||
class="input"
|
||||
bind:value={editStaticAccessToken}
|
||||
placeholder="(leave blank for public repos)"
|
||||
placeholder={$t('apps.detail.editStaticTokenPlaceholder')}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<fieldset class="static-mode">
|
||||
<legend class="sub-label">Mode</legend>
|
||||
<legend class="sub-label">{$t('apps.detail.editStaticMode')}</legend>
|
||||
<label class="radio">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -1361,7 +1360,7 @@
|
||||
value="static"
|
||||
bind:group={editStaticMode}
|
||||
/>
|
||||
<span><strong>static</strong> — serve files via nginx.</span>
|
||||
<span><strong>static</strong> {$t('apps.detail.editStaticModeStaticDesc')}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input
|
||||
@@ -1370,16 +1369,16 @@
|
||||
value="deno"
|
||||
bind:group={editStaticMode}
|
||||
/>
|
||||
<span><strong>deno</strong> — Deno runtime with dynamic routing.</span>
|
||||
<span><strong>deno</strong> {$t('apps.detail.editStaticModeDenoDesc')}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="checkbox-row">
|
||||
<ToggleSwitch
|
||||
bind:checked={editStaticRenderMarkdown}
|
||||
label="Render markdown"
|
||||
label={$t('apps.detail.editStaticRenderMarkdown')}
|
||||
/>
|
||||
<span>
|
||||
<strong>Render markdown</strong> — auto-render <code>.md</code> as HTML.
|
||||
<strong>{$t('apps.detail.editStaticRenderMarkdown')}</strong> {@html $t('apps.detail.editStaticRenderMarkdownDesc')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1387,16 +1386,16 @@
|
||||
<div class="editor">
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">source_config.json</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'}
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleEditAdvancedJSON}
|
||||
title="Switch back to the form"
|
||||
title={$t('apps.detail.switchToFormTitle')}
|
||||
>
|
||||
Back to form
|
||||
{$t('apps.detail.backToForm')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1406,12 +1405,12 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Source plugin configuration (JSON)"
|
||||
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 ? 'JSON OK' : 'JSON INVALID'}
|
||||
{sourceValid ? $t('apps.detail.jsonOk') : $t('apps.detail.jsonInvalid')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1421,13 +1420,13 @@
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">04</span>
|
||||
<span class="lbl">Public faces</span>
|
||||
<span class="opt">JSON ARRAY</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">public_faces.json</span>
|
||||
<span class="editor-title">{$t('apps.detail.editPublicFacesHeader')}</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="edit-faces"
|
||||
@@ -1435,19 +1434,19 @@
|
||||
rows="7"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Public faces configuration (JSON array)"
|
||||
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 ? 'JSON OK' : 'JSON INVALID'}
|
||||
{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}>Cancel</button>
|
||||
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>{$t('apps.detail.editCancel')}</button>
|
||||
<button
|
||||
class="forge-btn"
|
||||
onclick={saveEdit}
|
||||
@@ -1458,7 +1457,7 @@
|
||||
(useEditStaticForm && (!editStaticRepoOwner.trim() || !editStaticRepoName.trim())) ||
|
||||
!facesValid}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
{saving ? $t('apps.detail.editSaving') : $t('apps.detail.editSave')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1467,38 +1466,35 @@
|
||||
<!-- ── Manual deploy ────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Manual deploy<span class="title-accent">.</span></h2>
|
||||
<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">OK</span>
|
||||
<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">Deploy reference</span>
|
||||
<span class="sr-only">{$t('apps.detail.manualDeployRefAria')}</span>
|
||||
<input
|
||||
id="deploy-ref"
|
||||
type="text"
|
||||
bind:value={deployRef}
|
||||
placeholder="reference (image tag, git sha, blank for default)"
|
||||
placeholder={$t('apps.detail.manualDeployRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<button class="forge-btn" onclick={deploy} disabled={deploying}>
|
||||
<IconDeploy size={14} />
|
||||
<span>{deploying ? 'Dispatching…' : 'Deploy'}</span>
|
||||
<span>{deploying ? $t('apps.detail.manualDeployDispatching') : $t('apps.detail.manualDeployButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Use a specific image tag, git sha, or branch to force a deploy. Leave blank to use the
|
||||
default reference resolved by the source plugin.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.detail.manualDeployHint')}</p>
|
||||
</section>
|
||||
|
||||
<!-- ── Triggers (bindings) ─────────────────────────
|
||||
@@ -1787,15 +1783,17 @@
|
||||
<!-- ── Containers ───────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Containers<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.containersTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{containers.length === 0 ? 'No containers yet' : `${containers.length} reconciled`}
|
||||
{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>No containers yet — deploy to spin one up.</span>
|
||||
<span>{$t('apps.detail.containersEmptyInline')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#if logContainerRowID}
|
||||
@@ -1814,12 +1812,12 @@
|
||||
<table class="forge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>State</th>
|
||||
<th>Image</th>
|
||||
<th>Subdomain</th>
|
||||
<th>Last seen</th>
|
||||
<th class="t-right">Actions</th>
|
||||
<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>
|
||||
@@ -1840,10 +1838,10 @@
|
||||
<button
|
||||
class="forge-btn-ghost"
|
||||
onclick={() => (logContainerRowID = c.id)}
|
||||
aria-label={`View logs for ${c.role || c.id}`}
|
||||
aria-label={`${$t('apps.detail.containersLogsAction')}: ${c.role || c.id}`}
|
||||
>
|
||||
<IconServer size={13} />
|
||||
<span>Logs</span>
|
||||
<span>{$t('apps.detail.containersLogsAction')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="muted mono">—</span>
|
||||
@@ -1861,20 +1859,20 @@
|
||||
{#if !editing && chain && (chain.parent || chain.children.length > 0)}
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Chain<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.chainTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{chain.parent ? 'promotes from a parent' : 'parent of'}
|
||||
{chain.parent ? $t('apps.detail.chainSubFromParent') : $t('apps.detail.chainSubParentOf')}
|
||||
{chain.children.length}
|
||||
{chain.children.length === 1 ? 'child' : 'children'}
|
||||
{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">ERR</span><span>{chainError}</span></div>
|
||||
<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">Parent</span>
|
||||
<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>
|
||||
@@ -1885,14 +1883,14 @@
|
||||
disabled={promoting !== null}
|
||||
onclick={() => promoteFrom(chain!.parent!.id)}
|
||||
>
|
||||
{promoting === chain.parent.id ? 'Promoting…' : 'Promote from parent'}
|
||||
{promoting === chain.parent.id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="chain-row">
|
||||
<span class="chain-label">This</span>
|
||||
<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">
|
||||
@@ -1909,7 +1907,7 @@
|
||||
|
||||
{#if chain.children.length > 0}
|
||||
<div class="chain-row chain-children">
|
||||
<span class="chain-label">Children</span>
|
||||
<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}`}>
|
||||
@@ -1920,10 +1918,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="hint">
|
||||
Set <code>parent_workload_id</code> on a workload to build a chain. Image-source children
|
||||
can promote the parent's currently-running tag with one click.
|
||||
</p>
|
||||
<p class="hint">{@html $t('apps.detail.chainHint')}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -2024,26 +2019,28 @@
|
||||
{#if !editing}
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Volumes<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.volumesTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{volumeRows.length === 0
|
||||
? 'No mounts'
|
||||
: `${volumeRows.length} mount${volumeRows.length === 1 ? '' : 's'}`}
|
||||
? $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">ERR</span><span>{volumeError}</span></div>
|
||||
<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>Target</th>
|
||||
<th>Source</th>
|
||||
<th>Scope</th>
|
||||
<th class="t-right">Updated</th>
|
||||
<th class="t-right">Actions</th>
|
||||
<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>
|
||||
@@ -2059,7 +2056,7 @@
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={() => removeVolume(v.id)}
|
||||
aria-label={`Delete mount ${v.target}`}
|
||||
aria-label={`${$t('apps.detail.volumesColActions')}: ${v.target}`}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</button>
|
||||
@@ -2078,24 +2075,24 @@
|
||||
}}
|
||||
>
|
||||
<label class="env-field">
|
||||
<span>Source (host)</span>
|
||||
<span>{$t('apps.detail.volumeSource')}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newVolSource}
|
||||
placeholder="/srv/data/myapp"
|
||||
placeholder={$t('apps.detail.volumeSourcePlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label class="env-field">
|
||||
<span>Target (container)</span>
|
||||
<span>{$t('apps.detail.volumeTarget')}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newVolTarget}
|
||||
placeholder="/data"
|
||||
placeholder={$t('apps.detail.volumeTargetPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="env-field">
|
||||
<span>Scope</span>
|
||||
<span>{$t('apps.detail.volumeScope')}</span>
|
||||
<select bind:value={newVolScope}>
|
||||
<option value="absolute">absolute</option>
|
||||
<option value="named">named</option>
|
||||
@@ -2104,13 +2101,10 @@
|
||||
</select>
|
||||
</label>
|
||||
<button class="forge-btn" type="submit" disabled={volumeSaving || !newVolTarget.trim()}>
|
||||
{volumeSaving ? 'Saving…' : 'Add / Replace'}
|
||||
{volumeSaving ? $t('apps.detail.volumeSaving') : $t('apps.detail.volumeAddButton')}
|
||||
</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Absolute mounts bind a host path into the container. Non-absolute scopes are accepted for
|
||||
future use; only absolute is honoured at deploy time today.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.detail.volumeHint')}</p>
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
@@ -2119,25 +2113,27 @@
|
||||
{#if !editing}
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Env<span class="title-accent">.</span></h2>
|
||||
<h2 class="panel-title">{$t('apps.detail.envTitle')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-sub">
|
||||
{envRows.length === 0
|
||||
? 'No overrides'
|
||||
: `${envRows.length} override${envRows.length === 1 ? '' : 's'}`}
|
||||
? $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">ERR</span><span>{envError}</span></div>
|
||||
<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>Key</th>
|
||||
<th>Value</th>
|
||||
<th class="t-right">Updated</th>
|
||||
<th class="t-right">Actions</th>
|
||||
<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>
|
||||
@@ -2148,7 +2144,7 @@
|
||||
{#if e.encrypted}
|
||||
<span class="state-pill st-encrypted">
|
||||
<span class="pulse" aria-hidden="true"></span>
|
||||
ENCRYPTED
|
||||
{$t('apps.detail.envEncrypted')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="mono">{e.value || '—'}</span>
|
||||
@@ -2159,7 +2155,7 @@
|
||||
<button
|
||||
class="forge-btn-ghost danger"
|
||||
onclick={() => removeEnv(e.id)}
|
||||
aria-label={`Delete ${e.key}`}
|
||||
aria-label={`${$t('apps.detail.envColActions')}: ${e.key}`}
|
||||
>
|
||||
<IconTrash size={13} />
|
||||
</button>
|
||||
@@ -2178,31 +2174,28 @@
|
||||
}}
|
||||
>
|
||||
<label class="env-field">
|
||||
<span>Key</span>
|
||||
<span>{$t('apps.detail.envKey')}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newEnvKey}
|
||||
placeholder="DATABASE_URL"
|
||||
placeholder={$t('apps.detail.envKeyPlaceholder')}
|
||||
pattern="[A-Za-z_][A-Za-z0-9_]*"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="env-field">
|
||||
<span>Value</span>
|
||||
<input type="text" bind:value={newEnvValue} placeholder="(empty to unset)" />
|
||||
<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="Encrypt at rest" />
|
||||
<span>Encrypt at rest</span>
|
||||
<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 ? 'Saving…' : 'Add / Replace'}
|
||||
{envSaving ? $t('apps.detail.envSaving') : $t('apps.detail.envAddButton')}
|
||||
</button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Encrypted values are write-only after store — the API redacts them on read. Rotate by
|
||||
setting a new value.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.detail.envHint')}</p>
|
||||
</section>
|
||||
|
||||
<!-- Webhook URL panel removed — inbound webhooks live on
|
||||
@@ -2224,21 +2217,21 @@
|
||||
<span class="chev" class:rot={!openSource} aria-hidden="true">
|
||||
<IconChevronDown size={16} />
|
||||
</span>
|
||||
<h2 class="panel-title">Source config<span class="title-accent">.</span></h2>
|
||||
<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="Copy source config"
|
||||
aria-label={$t('apps.detail.sourceConfigCopyAria')}
|
||||
>
|
||||
{#if copied.source}
|
||||
<IconCheck size={13} />
|
||||
{:else}
|
||||
<IconCopy size={13} />
|
||||
{/if}
|
||||
<span>{copied.source ? 'Copied' : 'Copy'}</span>
|
||||
<span>{copied.source ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
|
||||
</button>
|
||||
</header>
|
||||
{#if openSource}
|
||||
@@ -2261,20 +2254,20 @@
|
||||
<span class="chev" class:rot={!openFaces} aria-hidden="true">
|
||||
<IconChevronDown size={16} />
|
||||
</span>
|
||||
<h2 class="panel-title">Public faces<span class="title-accent">.</span></h2>
|
||||
<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="Copy public faces"
|
||||
aria-label={$t('apps.detail.publicFacesCopyAria')}
|
||||
>
|
||||
{#if copied.faces}
|
||||
<IconCheck size={13} />
|
||||
{:else}
|
||||
<IconCopy size={13} />
|
||||
{/if}
|
||||
<span>{copied.faces ? 'Copied' : 'Copy'}</span>
|
||||
<span>{copied.faces ? $t('apps.detail.sourceConfigCopied') : $t('apps.detail.sourceConfigCopy')}</span>
|
||||
</button>
|
||||
</header>
|
||||
{#if openFaces}
|
||||
@@ -2290,9 +2283,11 @@
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete this app?"
|
||||
message={`Tears down all containers and proxy routes owned by "${workload?.name ?? 'this workload'}", then removes the row. This cannot be undone.`}
|
||||
confirmLabel={deleting ? 'Deleting…' : 'Yes, delete'}
|
||||
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={() => {
|
||||
|
||||
Reference in New Issue
Block a user