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:
@@ -325,7 +325,7 @@
|
||||
existingTriggers = [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load plugin kinds';
|
||||
error = e instanceof Error ? e.message : $t('apps.new.loadingKinds');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -533,20 +533,20 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New App · Tinyforge</title>
|
||||
<title>{$t('apps.new.pageTitle')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="forge">
|
||||
{#snippet newLede()}
|
||||
Create a plugin-native workload. <em>Source</em> = how it deploys (image, compose, static).
|
||||
Pick or create a <em>trigger</em> below — when one fires, the source plugin redeploys.
|
||||
{$t('apps.new.ledePrefix')} <em>{$t('apps.new.ledeSourceLabel')}</em>
|
||||
{$t('apps.new.ledeSourceMid')} <em>{$t('apps.new.ledeTriggerLabel')}</em> {$t('apps.new.ledeSuffix')}
|
||||
{/snippet}
|
||||
|
||||
<ForgeHero
|
||||
backHref="/apps"
|
||||
backLabel="Back to apps"
|
||||
eyebrowSuffix="NEW APP"
|
||||
title="Forge a new app"
|
||||
backLabel={$t('apps.new.backLabel')}
|
||||
eyebrowSuffix={$t('apps.new.eyebrowSuffix')}
|
||||
title={$t('apps.new.title')}
|
||||
size="lg"
|
||||
lede_html={newLede}
|
||||
/>
|
||||
@@ -554,7 +554,7 @@
|
||||
{#if loading}
|
||||
<div class="loading-line">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Loading available plugin kinds…</span>
|
||||
<span>{$t('apps.new.loadingKinds')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={submit} class="form" novalidate>
|
||||
@@ -564,36 +564,36 @@
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('apps.new.alertTag')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="app-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
<span class="lbl">Name</span>
|
||||
<span class="req">REQUIRED</span>
|
||||
<span class="lbl">{$t('apps.new.fieldName')}</span>
|
||||
<span class="req">{$t('apps.new.fieldNameRequired')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="app-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="my-app"
|
||||
placeholder={$t('apps.new.fieldNamePlaceholder')}
|
||||
class="input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<p class="hint">Lowercase, no spaces. Becomes part of container names and subdomains.</p>
|
||||
<p class="hint">{$t('apps.new.fieldNameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Source plugin</span>
|
||||
<span class="opt">REQUIRED</span>
|
||||
<span class="lbl">{$t('apps.new.fieldSourcePlugin')}</span>
|
||||
<span class="opt">{$t('apps.new.fieldNameRequired')}</span>
|
||||
</div>
|
||||
<label class="sub" for="app-source">
|
||||
<span class="sub-label">Source</span>
|
||||
<span class="sub-label">{$t('apps.new.fieldSourceLabel')}</span>
|
||||
<select
|
||||
id="app-source"
|
||||
class="input"
|
||||
@@ -605,37 +605,34 @@
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Populated from the running daemon — only plugins compiled in show up. Triggers
|
||||
(registry / git / manual) are configured below as standalone records.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.fieldSourceHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">Source config</span>
|
||||
<span class="lbl">{$t('apps.new.fieldSourceConfig')}</span>
|
||||
<span class="req">
|
||||
{useComposeForm
|
||||
? 'YAML'
|
||||
? $t('apps.new.fieldConfigYaml')
|
||||
: useImageForm || useStaticForm
|
||||
? 'FORM'
|
||||
: 'JSON'}
|
||||
? $t('apps.new.fieldConfigForm')
|
||||
: $t('apps.new.fieldConfigJson')}
|
||||
</span>
|
||||
</div>
|
||||
{#if useComposeForm}
|
||||
<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 · compose</span>
|
||||
<span class="editor-title">{$t('apps.new.composeHeader')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.new.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -644,26 +641,26 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
placeholder={'services:\n web:\n image: nginx:alpine\n ports:\n - "80"'}
|
||||
aria-label="Compose YAML"
|
||||
placeholder={$t('apps.new.composePlaceholder')}
|
||||
aria-label={$t('apps.new.composeAriaLabel')}
|
||||
></textarea>
|
||||
<div class="editor-foot">
|
||||
<span class="foot-status">
|
||||
<span class="foot-dot" aria-hidden="true"></span>
|
||||
YAML
|
||||
{$t('apps.new.fieldConfigYaml')}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{composeYaml.split('\n').length} lines</span>
|
||||
<span>{composeYaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="sub" for="app-compose-project">
|
||||
<span class="sub-label">Compose project name (optional)</span>
|
||||
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
|
||||
<input
|
||||
id="app-compose-project"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={composeProjectName}
|
||||
placeholder="(defaults to sanitized workload name)"
|
||||
placeholder={$t('apps.new.composeProjectPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -675,36 +672,33 @@
|
||||
to be set at create time. -->
|
||||
<div class="image-form">
|
||||
<div class="image-form-head">
|
||||
<span class="editor-title">image source · runtime knobs</span>
|
||||
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.new.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<label class="sub" for="app-image-ref">
|
||||
<span class="sub-label">Image (registry path)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageRefLabel')}</span>
|
||||
<input
|
||||
id="app-image-ref"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={imageRef}
|
||||
placeholder="registry.example.com/owner/app"
|
||||
placeholder={$t('apps.new.imageRefPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<p class="hint">
|
||||
Fully-qualified reference; the tag is set per-deploy via the trigger or
|
||||
the Default tag field below.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.imageRefHint')}</p>
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-port">
|
||||
<span class="sub-label">Port</span>
|
||||
<span class="sub-label">{$t('apps.new.imagePort')}</span>
|
||||
<input
|
||||
id="app-image-port"
|
||||
type="number"
|
||||
@@ -715,7 +709,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-healthcheck">
|
||||
<span class="sub-label">Healthcheck path</span>
|
||||
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
|
||||
<input
|
||||
id="app-image-healthcheck"
|
||||
type="text"
|
||||
@@ -727,7 +721,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-default-tag">
|
||||
<span class="sub-label">Default tag</span>
|
||||
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
|
||||
<input
|
||||
id="app-image-default-tag"
|
||||
type="text"
|
||||
@@ -740,14 +734,14 @@
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="app-image-registry">
|
||||
<span class="sub-label">Registry (for private pulls)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
|
||||
{#if registries.length > 0}
|
||||
<select
|
||||
id="app-image-registry"
|
||||
class="input"
|
||||
bind:value={imageRegistryName}
|
||||
>
|
||||
<option value="">(public — no auth)</option>
|
||||
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
|
||||
{#each registries as r}
|
||||
<option value={r.name}>{r.name} — {r.url}</option>
|
||||
{/each}
|
||||
@@ -758,18 +752,15 @@
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={imageRegistryName}
|
||||
placeholder="(public — no auth)"
|
||||
placeholder={$t('apps.new.imageRegistryPublic')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{/if}
|
||||
<p class="hint">
|
||||
Match the name from the Registries settings page. Leave empty for
|
||||
public images.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
|
||||
</label>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-image-cpu">
|
||||
<span class="sub-label">CPU limit (cores, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
|
||||
<input
|
||||
id="app-image-cpu"
|
||||
type="number"
|
||||
@@ -780,7 +771,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-memory">
|
||||
<span class="sub-label">Memory limit (MB, 0 = ∞)</span>
|
||||
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
|
||||
<input
|
||||
id="app-image-memory"
|
||||
type="number"
|
||||
@@ -790,7 +781,7 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-image-max">
|
||||
<span class="sub-label">Max instances</span>
|
||||
<span class="sub-label">{$t('apps.new.imageMax')}</span>
|
||||
<input
|
||||
id="app-image-max"
|
||||
type="number"
|
||||
@@ -798,13 +789,10 @@
|
||||
class="input"
|
||||
bind:value={imageMaxInstances}
|
||||
/>
|
||||
<p class="hint">1 = strict blue-green.</p>
|
||||
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint image-form-foot">
|
||||
Env vars and volume mounts live in their own panels on the workload
|
||||
detail page after creation.
|
||||
</p>
|
||||
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
|
||||
</div>
|
||||
{:else if useStaticForm}
|
||||
<!-- Static source form. Provider + repo + mode in
|
||||
@@ -812,19 +800,19 @@
|
||||
password input. -->
|
||||
<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.new.staticHeader')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch to the raw JSON editor"
|
||||
title={$t('apps.new.switchToJsonTitle')}
|
||||
>
|
||||
Advanced JSON
|
||||
{$t('apps.new.advancedJson')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-static-provider">
|
||||
<span class="sub-label">Provider</span>
|
||||
<span class="sub-label">{$t('apps.new.staticProvider')}</span>
|
||||
<select
|
||||
id="app-static-provider"
|
||||
class="input"
|
||||
@@ -836,13 +824,13 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sub" for="app-static-base-url">
|
||||
<span class="sub-label">Base URL</span>
|
||||
<span class="sub-label">{$t('apps.new.staticBaseUrl')}</span>
|
||||
<input
|
||||
id="app-static-base-url"
|
||||
type="url"
|
||||
class="input mono"
|
||||
bind:value={staticBaseURL}
|
||||
placeholder="https://git.example.com"
|
||||
placeholder={$t('apps.new.staticBaseUrlPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -850,26 +838,26 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-static-owner">
|
||||
<span class="sub-label">Repo owner</span>
|
||||
<span class="sub-label">{$t('apps.new.staticRepoOwner')}</span>
|
||||
<input
|
||||
id="app-static-owner"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticRepoOwner}
|
||||
placeholder="owner"
|
||||
placeholder={$t('apps.new.staticRepoOwnerPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-static-name">
|
||||
<span class="sub-label">Repo name</span>
|
||||
<span class="sub-label">{$t('apps.new.staticRepoName')}</span>
|
||||
<input
|
||||
id="app-static-name"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticRepoName}
|
||||
placeholder="pages"
|
||||
placeholder={$t('apps.new.staticRepoNamePlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
@@ -878,46 +866,44 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="sub" for="app-static-branch">
|
||||
<span class="sub-label">Branch</span>
|
||||
<span class="sub-label">{$t('apps.new.staticBranch')}</span>
|
||||
<input
|
||||
id="app-static-branch"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticBranch}
|
||||
placeholder="main"
|
||||
placeholder={$t('apps.new.staticBranchPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-static-folder">
|
||||
<span class="sub-label">Folder path (optional)</span>
|
||||
<span class="sub-label">{$t('apps.new.staticFolder')}</span>
|
||||
<input
|
||||
id="app-static-folder"
|
||||
type="text"
|
||||
class="input mono"
|
||||
bind:value={staticFolderPath}
|
||||
placeholder="(repo root)"
|
||||
placeholder={$t('apps.new.staticFolderPlaceholder')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="sub" for="app-static-token">
|
||||
<span class="sub-label">Access token (private repos)</span>
|
||||
<span class="sub-label">{$t('apps.new.staticToken')}</span>
|
||||
<input
|
||||
id="app-static-token"
|
||||
type="password"
|
||||
class="input"
|
||||
bind:value={staticAccessToken}
|
||||
placeholder="(leave blank for public repos)"
|
||||
placeholder={$t('apps.new.staticTokenPlaceholder')}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="hint">
|
||||
Encrypted at rest. Required only when the repo is private.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.staticTokenHint')}</p>
|
||||
</label>
|
||||
<fieldset class="static-mode">
|
||||
<legend class="sub-label">Mode</legend>
|
||||
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
|
||||
<label class="radio">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -926,8 +912,7 @@
|
||||
bind:group={staticMode}
|
||||
/>
|
||||
<span>
|
||||
<strong>static</strong> — serve files via nginx; zero runtime
|
||||
overhead.
|
||||
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
@@ -938,40 +923,35 @@
|
||||
bind:group={staticMode}
|
||||
/>
|
||||
<span>
|
||||
<strong>deno</strong> — Deno runtime container with optional
|
||||
dynamic routing.
|
||||
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<label class="toggle-row">
|
||||
<ToggleSwitch
|
||||
bind:checked={staticRenderMarkdown}
|
||||
label="Render markdown"
|
||||
label={$t('apps.new.staticRenderMarkdown')}
|
||||
/>
|
||||
<span>
|
||||
<strong>Render markdown</strong> — auto-render <code>.md</code>
|
||||
files as HTML pages.
|
||||
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint image-form-foot">
|
||||
The webhook secret for git push triggers lives on the workload's
|
||||
Webhook panel after creation.
|
||||
</p>
|
||||
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<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 · {sourceKind}</span>
|
||||
<span class="editor-title">{$t('apps.new.sourceConfigJsonTitle', { kind: sourceKind })}</span>
|
||||
<span class="spacer"></span>
|
||||
{#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static'}
|
||||
<button
|
||||
type="button"
|
||||
class="editor-chip"
|
||||
onclick={toggleAdvancedJSON}
|
||||
title="Switch back to the form"
|
||||
title={$t('apps.new.switchToFormTitle')}
|
||||
>
|
||||
Back to form
|
||||
{$t('apps.new.backToForm')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
@@ -979,7 +959,7 @@
|
||||
class="editor-chip"
|
||||
onclick={() => (sourceConfig = sourceConfigSample(sourceKind))}
|
||||
>
|
||||
Reset sample
|
||||
{$t('apps.new.resetSample')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -988,15 +968,15 @@
|
||||
rows="12"
|
||||
spellcheck="false"
|
||||
class="code-area"
|
||||
aria-label="Source plugin configuration (JSON)"
|
||||
aria-label={$t('apps.new.sourceConfigJsonAria')}
|
||||
></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.new.jsonOk') : $t('apps.new.jsonInvalid')}
|
||||
</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{sourceLines} lines</span>
|
||||
<span>{sourceLines} {$t('apps.new.linesUnit')}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{sourceBytes} B</span>
|
||||
</div>
|
||||
@@ -1008,7 +988,7 @@
|
||||
<legend class="field-label as-legend">
|
||||
<span class="num">04</span>
|
||||
<span class="lbl">{$t('apps.new.triggers.section')}</span>
|
||||
<span class="opt">OPTIONAL</span>
|
||||
<span class="opt">{$t('apps.new.triggerNumOptional')}</span>
|
||||
</legend>
|
||||
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
|
||||
|
||||
@@ -1029,7 +1009,7 @@
|
||||
class:active={triggerMode === 'inline'}
|
||||
onclick={() => (triggerMode = 'inline')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">NEW</span>
|
||||
<span class="trig-mode-tag mono">{$t('apps.new.triggerNewTag')}</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
|
||||
</button>
|
||||
@@ -1041,7 +1021,7 @@
|
||||
class:active={triggerMode === 'pick'}
|
||||
onclick={() => (triggerMode = 'pick')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">PICK</span>
|
||||
<span class="trig-mode-tag mono">{$t('apps.new.triggerPickTag')}</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
|
||||
</button>
|
||||
@@ -1053,7 +1033,7 @@
|
||||
class:active={triggerMode === 'skip'}
|
||||
onclick={() => (triggerMode = 'skip')}
|
||||
>
|
||||
<span class="trig-mode-tag mono">SKIP</span>
|
||||
<span class="trig-mode-tag mono">{$t('apps.new.triggerSkipTag')}</span>
|
||||
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
|
||||
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
|
||||
</button>
|
||||
@@ -1073,7 +1053,7 @@
|
||||
<div class="trig-sub">
|
||||
{#if existingTriggers.length === 0}
|
||||
<div class="note muted-note">
|
||||
<span class="note-tag">∅</span>
|
||||
<span class="note-tag">{$t('apps.new.noteEmptyTag')}</span>
|
||||
<p>{$t('apps.new.triggers.pickEmpty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1100,7 +1080,7 @@
|
||||
{:else}
|
||||
<div class="trig-sub">
|
||||
<div class="note muted-note">
|
||||
<span class="note-tag">SKIP</span>
|
||||
<span class="note-tag">{$t('apps.new.noteSkipTag')}</span>
|
||||
<p>{$t('apps.new.triggers.skippedNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1110,34 +1090,34 @@
|
||||
<fieldset class="field group">
|
||||
<legend class="field-label as-legend">
|
||||
<span class="num">05</span>
|
||||
<span class="lbl">Public face</span>
|
||||
<span class="opt">OPTIONAL</span>
|
||||
<span class="lbl">{$t('apps.new.faceLabel')}</span>
|
||||
<span class="opt">{$t('apps.new.faceOptional')}</span>
|
||||
</legend>
|
||||
<div class="row three">
|
||||
<label class="sub" for="app-public-subdomain">
|
||||
<span class="sub-label">Subdomain</span>
|
||||
<span class="sub-label">{$t('apps.new.faceSubdomain')}</span>
|
||||
<input
|
||||
id="app-public-subdomain"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={publicSubdomain}
|
||||
placeholder="myapp"
|
||||
placeholder={$t('apps.new.faceSubdomainPlaceholder')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-public-domain">
|
||||
<span class="sub-label">Domain</span>
|
||||
<span class="sub-label">{$t('apps.new.faceDomain')}</span>
|
||||
<input
|
||||
id="app-public-domain"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={publicDomain}
|
||||
placeholder="(inherit from settings)"
|
||||
placeholder={$t('apps.new.faceDomainPlaceholder')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class="sub" for="app-public-port">
|
||||
<span class="sub-label">Target port</span>
|
||||
<span class="sub-label">{$t('apps.new.facePort')}</span>
|
||||
<input
|
||||
id="app-public-port"
|
||||
type="number"
|
||||
@@ -1148,20 +1128,17 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Leave blank to skip provisioning a proxy route. Filling any field creates a single
|
||||
face row attached to this workload.
|
||||
</p>
|
||||
<p class="hint">{$t('apps.new.faceHint')}</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/apps" class="forge-btn-ghost">Cancel</a>
|
||||
<a href="/apps" class="forge-btn-ghost">{$t('apps.new.cancel')}</a>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="submit"
|
||||
disabled={submitting || !name.trim() || (!useComposeForm && !useImageForm && !useStaticForm && !sourceValid) || (useImageForm && !imageRef.trim()) || (useStaticForm && (!staticRepoOwner.trim() || !staticRepoName.trim())) || !triggerStepValid}
|
||||
>
|
||||
<span>{submitting ? 'Forging…' : 'Forge app'}</span>
|
||||
<span>{submitting ? $t('apps.new.submitting') : $t('apps.new.submit')}</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user