feat(apps): stepped creation wizard, branch previews, and app-creation fixes

This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
+193 -26
View File
@@ -168,13 +168,14 @@ function patch<T>(path: string, body: unknown): Promise<T> {
});
}
// ── Deploys (inspect only; quick-deploy retired with /deploy page) ────
// `inspectImage` survives because the new-app wizard can use it to pre-fill
// image port/healthcheck. `quickDeploy` (POST /api/deploy/quick) is gone:
// it created a legacy Project + Stage in the now-dead path.
// ── Image inspect (new-app wizard pre-fill) ────────────────────────────
// `inspectImage` lets the new-app wizard pre-fill image port/healthcheck
// from a LOCAL image's metadata. It posts to the admin-gated discovery
// endpoint (the legacy POST /api/deploy/inspect route was dropped in the
// cutover). Local-only: it does not pull.
export function inspectImage(image: string, signal?: AbortSignal): Promise<InspectResult> {
return post<InspectResult>('/api/deploy/inspect', { image }, signal);
return post<InspectResult>('/api/discovery/image/inspect', { image }, signal);
}
// ── Discovery (/apps/new wizard helpers) ───────────────────────────
@@ -441,12 +442,76 @@ export function testNpmConnection(data: { npm_url?: string; npm_email?: string;
return post<{ status: string }>('/api/settings/npm/test', data);
}
export function listNpmCertificates(): Promise<NpmCertificate[]> {
return get<NpmCertificate[]>('/api/settings/npm-certificates');
// ── NPM friendly-name cache ─────────────────────────────────────────
// The settings/NPM page first renders "Certificate #<id>" / "Access List
// #<id>" then swaps to the friendly name once the list resolves — a visible
// flicker on every tab re-entry and after a reload. Back the two list calls
// with a short-lived sessionStorage cache so names resolve instantly within
// a session (survives reloads, scoped to the tab). Pass `force` to bypass it
// — the picker's "browse" action wants fresh data.
const NPM_CACHE_TTL_MS = 5 * 60 * 1000;
interface NpmCacheEntry<T> {
ts: number;
data: T;
}
export function listNpmAccessLists(): Promise<NpmAccessList[]> {
return get<NpmAccessList[]>('/api/settings/npm-access-lists');
function readNpmCache<T>(key: string): T | null {
if (typeof sessionStorage === 'undefined') return null;
try {
const raw = sessionStorage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw) as NpmCacheEntry<T>;
if (!entry || typeof entry.ts !== 'number') return null;
if (Date.now() - entry.ts > NPM_CACHE_TTL_MS) return null;
return entry.data;
} catch {
// Corrupt/unparseable cache — treat as a miss.
return null;
}
}
function writeNpmCache<T>(key: string, data: T): void {
if (typeof sessionStorage === 'undefined') return;
try {
const entry: NpmCacheEntry<T> = { ts: Date.now(), data };
sessionStorage.setItem(key, JSON.stringify(entry));
} catch {
// Quota/serialization failure is non-fatal — the call still returned
// fresh data; we just don't get the cache speedup next time.
}
}
const NPM_CERTS_CACHE_KEY = 'dw_npm_certs';
const NPM_ACCESS_LISTS_CACHE_KEY = 'dw_npm_access_lists';
/**
* List NPM SSL certificates. Cached in sessionStorage for ~5 minutes so the
* settings page resolves friendly names without a flicker on re-entry/reload.
* Pass `force` to skip the cache and refresh (e.g. opening the picker).
*/
export async function listNpmCertificates(force = false): Promise<NpmCertificate[]> {
if (!force) {
const cached = readNpmCache<NpmCertificate[]>(NPM_CERTS_CACHE_KEY);
if (cached) return cached;
}
const data = await get<NpmCertificate[]>('/api/settings/npm-certificates');
writeNpmCache(NPM_CERTS_CACHE_KEY, data);
return data;
}
/**
* List NPM access lists. Cached in sessionStorage for ~5 minutes (see
* {@link listNpmCertificates}). Pass `force` to skip the cache and refresh.
*/
export async function listNpmAccessLists(force = false): Promise<NpmAccessList[]> {
if (!force) {
const cached = readNpmCache<NpmAccessList[]>(NPM_ACCESS_LISTS_CACHE_KEY);
if (cached) return cached;
}
const data = await get<NpmAccessList[]>('/api/settings/npm-access-lists');
writeNpmCache(NPM_ACCESS_LISTS_CACHE_KEY, data);
return data;
}
// ── Volume scopes (metadata only) ───────────────────────────────────
@@ -495,7 +560,14 @@ export function deleteBackup(id: string): Promise<void> {
}
export function restoreBackup(id: string): Promise<{ status: string; message: string }> {
return post<{ status: string; message: string }>(`/api/backups/${id}/restore`);
// X-Confirm-Restore echoes the backup id. The backend rejects any
// POST whose header doesn't match the path param — this defeats
// blind CSRF (a cross-origin form/image-tag POST can't set custom
// headers without a preflight). Sent alongside the bearer JWT.
return request<{ status: string; message: string }>(`/api/backups/${id}/restore`, {
method: 'POST',
headers: { 'X-Confirm-Restore': id }
});
}
export function backupDownloadUrl(id: string): string {
@@ -518,49 +590,81 @@ export function getCurrentUser(): Promise<{ id: string; username: string; email:
return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me');
}
// Auth settings
export async function getAuthSettings(): Promise<any> {
return request<any>('/api/auth/settings');
// ── Auth settings & user management ─────────────────────────────────
// Previously typed as `any`, which silently disabled type checking on
// the entire user/auth surface — including the password-change call.
// All routes are admin-gated server-side.
export interface AuthSettings {
auth_mode: 'local' | 'oidc';
oidc_client_id: string;
oidc_client_secret: string;
oidc_issuer_url: string;
oidc_redirect_url: string;
}
export async function updateAuthSettings(settings: any): Promise<any> {
return request<any>('/api/auth/settings', {
export interface AuthUser {
id: string;
username: string;
email: string;
role: 'admin' | 'viewer' | string;
created_at: string;
}
export interface CreateUserInput {
username: string;
password: string;
email?: string;
role?: 'admin' | 'viewer' | string;
}
export interface UpdateUserInput {
email?: string;
role?: 'admin' | 'viewer' | string;
}
export async function getAuthSettings(): Promise<AuthSettings> {
return request<AuthSettings>('/api/auth/settings');
}
export async function updateAuthSettings(settings: AuthSettings): Promise<AuthSettings> {
return request<AuthSettings>('/api/auth/settings', {
method: 'PUT',
body: JSON.stringify(settings)
});
}
export async function listUsers(): Promise<any[]> {
return request<any[]>('/api/auth/users');
export async function listUsers(): Promise<AuthUser[]> {
return request<AuthUser[]>('/api/auth/users');
}
export async function createUser(data: { username: string; password: string; email?: string; role?: string }): Promise<any> {
return request<any>('/api/auth/users', {
export async function createUser(data: CreateUserInput): Promise<AuthUser> {
return request<AuthUser>('/api/auth/users', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateUser(uid: string, data: { email?: string; role?: string }): Promise<any> {
return request<any>(`/api/auth/users/${uid}`, {
export async function updateUser(uid: string, data: UpdateUserInput): Promise<AuthUser> {
return request<AuthUser>(`/api/auth/users/${uid}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function changeUserPassword(uid: string, password: string): Promise<any> {
return request<any>(`/api/auth/users/${uid}/password`, {
export async function changeUserPassword(uid: string, password: string): Promise<void> {
await request<void>(`/api/auth/users/${uid}/password`, {
method: 'PUT',
body: JSON.stringify({ password })
});
}
export async function deleteUser(uid: string): Promise<any> {
return request<any>(`/api/auth/users/${uid}`, { method: 'DELETE' });
export async function deleteUser(uid: string): Promise<void> {
await request<void>(`/api/auth/users/${uid}`, { method: 'DELETE' });
}
export async function logout(): Promise<void> {
await request<any>('/api/auth/logout', { method: 'POST' });
await request<void>('/api/auth/logout', { method: 'POST' });
}
// ── Config Export ────────────────────────────────────────────────────
@@ -804,6 +908,62 @@ export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted:
// workload to inbound deploys, create or bind a Trigger via the
// /triggers UI (which mints a /api/webhook/triggers/{secret} URL).
// Per-workload outbound notification routes (Slack/Discord/etc).
// Multi-destination fan-out — the dispatcher sends to every enabled
// row whose event_types allow-list matches the event. An empty
// event_types means "match every event". Secret round-trips as
// write-only: the API returns secret_set, never the ciphertext.
export interface WorkloadNotification {
id: string;
workload_id: string;
name: string;
url: string;
secret_set: boolean;
event_types: string;
enabled: boolean;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface WorkloadNotificationInput {
name: string;
url: string;
secret?: string;
event_types?: string;
enabled?: boolean;
sort_order?: number;
}
export function listWorkloadNotifications(
id: string,
signal?: AbortSignal
): Promise<WorkloadNotification[]> {
return get<WorkloadNotification[]>(`/api/workloads/${id}/notifications`, signal);
}
export function createWorkloadNotification(
id: string,
body: WorkloadNotificationInput
): Promise<WorkloadNotification> {
return post<WorkloadNotification>(`/api/workloads/${id}/notifications`, body);
}
export function updateWorkloadNotification(
id: string,
nid: string,
body: WorkloadNotificationInput
): Promise<WorkloadNotification> {
return put<WorkloadNotification>(`/api/workloads/${id}/notifications/${nid}`, body);
}
export function deleteWorkloadNotification(
id: string,
nid: string
): Promise<{ success: boolean }> {
return del<{ success: boolean }>(`/api/workloads/${id}/notifications/${nid}`);
}
export function fetchWorkloadContainerLogs(
workloadId: string,
containerRowId: string,
@@ -993,6 +1153,13 @@ export interface WorkloadChainNode {
name: string;
source_kind: string;
trigger_kind: string;
/** True when this child was materialized as a branch preview of the chain's
* `self` workload (vs. an operator-created stage child). Always false for
* the parent and self nodes. */
is_preview?: boolean;
/** The git branch a preview child was deployed for. Empty for non-preview
* nodes (omitted by the server). */
preview_branch?: string;
created_at: string;
updated_at: string;
}
+33 -3
View File
@@ -1,5 +1,7 @@
<!--
Confirm dialog with fade/scale-in animation.
Confirm dialog with fade/scale-in animation. Adds Escape-to-cancel,
autofocus on the confirm button, and aria-modal so assistive tech
treats the rest of the document as inert while the dialog is open.
-->
<script lang="ts">
import { IconAlert } from '$lib/components/icons';
@@ -36,14 +38,41 @@
? 'text-[var(--color-danger)]'
: 'text-[var(--color-brand-600)]'
);
let confirmButton: HTMLButtonElement | null = $state(null);
// Focus the confirm button when the dialog opens so keyboard
// users can hit Enter immediately. Scoped to the open transition;
// repeated opens re-focus.
$effect(() => {
if (open && confirmButton) {
// queueMicrotask so the DOM is mounted before focus().
queueMicrotask(() => confirmButton?.focus());
}
});
function handleKey(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
oncancel();
}
}
</script>
<svelte:window onkeydown={handleKey} />
{#if open}
<!-- Backdrop -->
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={oncancel}></div>
<!-- Dialog -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
>
<div class="dlg w-full max-w-md animate-scale-in">
<span class="dlg-reg dlg-reg-tl"></span>
<span class="dlg-reg dlg-reg-tr"></span>
@@ -55,7 +84,7 @@
<IconAlert size={20} class={iconColorClass} />
</div>
<div class="flex-1">
<h3 class="dlg-title">{title}</h3>
<h3 id="confirm-dialog-title" class="dlg-title">{title}</h3>
<p class="dlg-msg">{message}</p>
</div>
</div>
@@ -68,6 +97,7 @@
type="button"
class="dlg-confirm {confirmVariant}"
onclick={onconfirm}
bind:this={confirmButton}
>
{confirmLabel}
</button>
+6
View File
@@ -5,6 +5,12 @@
actionLabel?: string;
actionHref?: string;
onaction?: () => void;
/**
* The icon prop was historically accepted but never rendered —
* the markup uses the breathing-dot "empty-mark" element instead.
* Kept for source compatibility (call sites still pass it) and
* to avoid a noisy svelte-check sweep, but the value is ignored.
*/
icon?: 'projects' | 'instances' | 'deploys' | 'registries' | 'volumes' | 'users';
}
+22 -4
View File
@@ -91,12 +91,16 @@
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
// Guard against an empty filtered list — `% 0` is NaN, which
// would poison highlightIndex for subsequent keystrokes.
if (flatFiltered.length === 0) return;
highlightIndex = (highlightIndex + 1) % flatFiltered.length;
scrollHighlightedIntoView();
break;
}
case 'ArrowUp': {
event.preventDefault();
if (flatFiltered.length === 0) return;
highlightIndex = (highlightIndex - 1 + flatFiltered.length) % flatFiltered.length;
scrollHighlightedIntoView();
break;
@@ -156,7 +160,7 @@
type="button"
class="entity-picker-close"
onclick={onclose}
aria-label="Close"
aria-label={$t('common.close')}
>
<IconX size={18} />
</button>
@@ -180,7 +184,7 @@
type="button"
class="entity-picker-close-inline"
onclick={onclose}
aria-label="Close"
aria-label={$t('common.close')}
>
<IconX size={16} />
</button>
@@ -213,8 +217,22 @@
onmouseenter={() => { highlightIndex = flatIdx; }}
disabled={item.disabled}
>
{#if item.icon}
<span class="entity-picker-item-icon">{@html item.icon}</span>
{#if item.icon === 'lock'}
<span class="entity-picker-item-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
{:else if item.icon === 'box'}
<span class="entity-picker-item-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
</span>
{:else if item.icon === 'folder'}
<span class="entity-picker-item-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
</span>
{:else if item.icon === 'branch'}
<span class="entity-picker-item-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
</span>
{/if}
<span class="entity-picker-item-content">
<span class="entity-picker-item-label">{item.label}</span>
@@ -0,0 +1,154 @@
<!--
RegistryImagePicker — optional "browse images" affordance for the
registry trigger's image field.
The registry trigger config carries only a fully-qualified image `ref`
(no registry id). On open, this loads every configured registry's
images and lists them in a single command palette, GROUPED by registry,
so the operator picks a fully-qualified ref without typing it. Selecting
a row calls `onpick(full_ref, registry_name)`; the parent writes the ref
into its config and may use the registry name to auto-select the source
registry. Manual text entry in the parent input is always preserved — the
picker is purely additive convenience.
Lives in its own component (rather than inline in TriggerKindForm)
because that form binds a prop named `state`, which shadows Svelte 5's
`$state` rune. Isolating the reactive picker state here sidesteps the
collision, and keeps the only in-flow element a single button so it
drops cleanly into the field's input row.
-->
<script lang="ts">
import EntityPicker from '$lib/components/EntityPicker.svelte';
import { IconBox } from '$lib/components/icons';
import * as api from '$lib/api';
import type { EntityPickerItem } from '$lib/types';
import { t } from '$lib/i18n';
interface Props {
/** Current image ref — highlights the matching row in the picker. */
current?: string;
/**
* Called with the chosen image's full_ref and the name of the registry
* it came from. Callers that only care about the ref (e.g. the registry
* trigger form) can ignore the second argument.
*/
onpick: (fullRef: string, registryName: string) => void;
}
let { current = '', onpick }: Props = $props();
let open = $state(false);
let loading = $state(false);
let loaded = $state(false);
let items = $state<EntityPickerItem[]>([]);
// Lazily load every registry's images the first time the operator
// opens the picker, flattened into one grouped list. Per-registry
// failures are tolerated (other registries still populate). A total
// failure leaves the list empty — the picker then shows its built-in
// "no results" state — and never blocks manual entry in the field.
async function ensureLoaded(): Promise<void> {
if (loaded || loading) return;
loading = true;
try {
const registries = await api.listRegistries();
const collected: EntityPickerItem[] = [];
await Promise.all(
registries.map(async (reg) => {
try {
const images = await api.listRegistryImages(reg.id);
for (const img of images) {
collected.push({
value: img.full_ref,
label: img.full_ref,
description: img.owner ? `${img.owner}/${img.name}` : img.name,
group: reg.name,
icon: 'box'
});
}
} catch {
// Skip a registry we can't reach; others still load.
}
})
);
items = collected;
loaded = true;
} catch {
// Total failure (e.g. /api/registries unreachable): leave items
// empty so the picker shows its empty state. Do NOT mark loaded
// so a later open retries.
items = [];
} finally {
loading = false;
}
}
async function openPicker(): Promise<void> {
await ensureLoaded();
open = true;
}
function handleSelect(value: string): void {
// Each picker row carries its source registry name in `group` (set when
// the list was built). Surface it so callers can auto-select the
// registry the image came from. Falls back to '' (public) if not found.
const picked = items.find((i) => i.value === value);
onpick(value, picked?.group ?? '');
open = false;
}
</script>
<button
type="button"
class="browse-btn"
onclick={openPicker}
disabled={loading}
title={$t('redeployTriggers.form.browseImagesHint')}
>
<IconBox size={13} />
<span>{$t('redeployTriggers.form.browseImages')}</span>
</button>
<EntityPicker
bind:open
{items}
{current}
title={$t('redeployTriggers.form.browseImagesTitle')}
placeholder={$t('redeployTriggers.form.browseImagesSearch')}
onselect={handleSelect}
onclose={() => (open = false)}
/>
<style>
.browse-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0 0.7rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-family: var(--forge-mono);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
white-space: nowrap;
}
.browse-btn:hover:not(:disabled) {
border-color: var(--forge-accent);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.browse-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.browse-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
</style>
@@ -210,7 +210,7 @@
</div>
{:else if !proxyConnected}
<div class="panel-error">
<code>{proxy.error ?? `${proxyProvider.toUpperCase()} is not reachable.`}</code>
<code>{proxy.error ?? $t('daemons.notReachable', { provider: proxyProvider.toUpperCase() })}</code>
<p>{$t('daemons.proxyHint')}</p>
{#if proxy.url}
<p class="url"><span class="kdim">URL</span> <code>{proxy.url}</code></p>
@@ -48,7 +48,6 @@
{#if !loading}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<!-- Containers -->
<a href="/containers" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
+2 -1
View File
@@ -4,6 +4,7 @@
<script lang="ts">
import { toasts, type ToastType } from '$lib/stores/toast';
import { IconCheck, IconX, IconAlert, IconInfo } from '$lib/components/icons';
import { t } from '$lib/i18n';
const bgMap: Record<ToastType, string> = {
success: 'bg-[var(--color-success)]',
@@ -34,7 +35,7 @@
<button
class="ml-2 rounded-md p-0.5 text-white/70 hover:text-white transition-colors"
onclick={() => toasts.remove(toast.id)}
aria-label="Dismiss notification"
aria-label={$t('common.dismissNotification')}
>
<IconX size={16} />
</button>
+31 -2
View File
@@ -1,10 +1,18 @@
<!--
Task 6: Toggle switch to replace checkboxes.
Toggle switch replacing raw checkboxes. Accepts an `ariaLabel` /
`ariaLabelledby` so the switch announces its purpose to assistive
tech regardless of surrounding markup. `label` remains a thin alias
that defaults aria-label without forcing a visible <label>.
-->
<script lang="ts">
interface Props {
checked?: boolean;
/** Accessible name. Falls back to ariaLabel for clarity. */
label?: string;
/** Explicit aria-label override. Takes precedence over label. */
ariaLabel?: string;
/** id of a visible label element. Takes precedence over ariaLabel. */
ariaLabelledby?: string;
disabled?: boolean;
onchange?: (checked: boolean) => void;
}
@@ -12,6 +20,8 @@
let {
checked = $bindable(false),
label = '',
ariaLabel,
ariaLabelledby,
disabled = false,
onchange
}: Props = $props();
@@ -21,15 +31,34 @@
checked = !checked;
onchange?.(checked);
}
function handleKey(e: KeyboardEvent) {
if (disabled) return;
// Native <button> already handles Space/Enter, but role="switch"
// best-practice docs (WAI-ARIA APG) call out Space explicitly —
// pin the behaviour so we don't depend on browser defaults.
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
toggle();
}
}
const computedAriaLabel = $derived(ariaLabel ?? (label || undefined));
</script>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={computedAriaLabel}
aria-labelledby={ariaLabelledby}
aria-disabled={disabled ? 'true' : undefined}
class="toggle-switch {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
onclick={toggle}
onkeydown={handleKey}
{disabled}
>
<span class="sr-only">{label}</span>
{#if label}
<span class="sr-only">{label}</span>
{/if}
</button>
+68 -18
View File
@@ -48,6 +48,7 @@
gitRepo: string;
gitMode: 'push' | 'tag';
gitBranch: string;
gitBranchPattern: string;
gitTagPattern: string;
// schedule
schInterval: string;
@@ -70,6 +71,7 @@
gitRepo: init.gitRepo ?? '',
gitMode: init.gitMode ?? 'push',
gitBranch: init.gitBranch ?? 'main',
gitBranchPattern: init.gitBranchPattern ?? '',
gitTagPattern: init.gitTagPattern ?? 'v*',
schInterval: init.schInterval ?? '24h',
schReference: init.schReference ?? '',
@@ -191,6 +193,7 @@
// understands the silent rewrite.
s.gitMode = cfg.mode === 'tag' ? 'tag' : 'push';
s.gitBranch = typeof cfg.branch === 'string' ? cfg.branch : 'main';
s.gitBranchPattern = typeof cfg.branch_pattern === 'string' ? cfg.branch_pattern : '';
s.gitTagPattern = typeof cfg.tag_pattern === 'string' ? cfg.tag_pattern : 'v*';
break;
case 'manual':
@@ -213,14 +216,22 @@
tag_pattern: s.regTagPattern.trim() || '*'
};
} else if (s.kind === 'git') {
config =
s.gitMode === 'push'
? { repo: s.gitRepo.trim(), mode: 'push', branch: s.gitBranch.trim() || 'main' }
: {
repo: s.gitRepo.trim(),
mode: 'tag',
tag_pattern: s.gitTagPattern.trim() || '*'
};
if (s.gitMode === 'push') {
const branchPattern = s.gitBranchPattern.trim();
const pushCfg: Record<string, unknown> = {
repo: s.gitRepo.trim(),
mode: 'push',
branch: s.gitBranch.trim() || 'main'
};
if (branchPattern) pushCfg.branch_pattern = branchPattern;
config = pushCfg;
} else {
config = {
repo: s.gitRepo.trim(),
mode: 'tag',
tag_pattern: s.gitTagPattern.trim() || '*'
};
}
} else if (s.kind === 'manual') {
config = {};
} else if (s.kind === 'schedule') {
@@ -243,6 +254,7 @@
<script lang="ts">
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
import { t } from '$lib/i18n';
interface Props {
@@ -296,6 +308,14 @@
}
}
}
// Registry-image picker writes the chosen full_ref back into the
// registry trigger's image field. The picker's own reactive state
// lives in RegistryImagePicker (a prop named `state` here shadows the
// $state rune, so the picker is isolated in its own component).
function onRegistryImagePicked(fullRef: string): void {
state.regImage = fullRef;
}
</script>
<div class="tk-form">
@@ -392,16 +412,19 @@
{:else if state.kind === 'registry'}
<label class="sub" for="{idPrefix}-image">
<span class="sub-label">{$t('redeployTriggers.form.image')}</span>
<input
id="{idPrefix}-image"
type="text"
class="input mono"
bind:value={state.regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<div class="input-with-button">
<input
id="{idPrefix}-image"
type="text"
class="input mono"
bind:value={state.regImage}
placeholder={$t('redeployTriggers.form.imagePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<RegistryImagePicker current={state.regImage} onpick={onRegistryImagePicked} />
</div>
<span class="hint">{$t('redeployTriggers.form.imageHint')}</span>
</label>
<label class="sub" for="{idPrefix}-tag">
@@ -475,6 +498,19 @@
/>
<span class="hint">{$t('redeployTriggers.form.branchHint')}</span>
</label>
<label class="sub" for="{idPrefix}-branch-pattern">
<span class="sub-label">{$t('redeployTriggers.form.branchPattern')}</span>
<input
id="{idPrefix}-branch-pattern"
type="text"
class="input mono"
bind:value={state.gitBranchPattern}
placeholder={$t('redeployTriggers.form.branchPatternPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('redeployTriggers.form.branchPatternHint')}</span>
</label>
{:else}
<label class="sub" for="{idPrefix}-gtag">
<span class="sub-label">{$t('redeployTriggers.form.tagPattern')}</span>
@@ -711,6 +747,20 @@
color: var(--color-danger);
}
/* ── Registry image picker affordance ─────────────
The image field becomes an input + "browse" button row (the button
is rendered by RegistryImagePicker). Manual text entry stays fully
functional — the picker is purely additive. */
.input-with-button {
display: flex;
align-items: stretch;
gap: 0.4rem;
}
.input-with-button > .input {
flex: 1;
min-width: 0;
}
.kind-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -0,0 +1,384 @@
<script lang="ts">
/**
* WorkloadNotificationsPanel
*
* Per-workload outbound notification routes. Multi-destination
* fan-out: each row is one Slack channel / Discord webhook /
* generic receiver, optionally filtered to a comma-separated
* allow-list of event types. When zero rows exist the dispatcher
* falls back to the legacy single notification URL on the workload.
*
* Secret is write-only: the API returns secret_set so the UI shows
* "secret configured" / "no secret" without ever round-tripping the
* ciphertext. To rotate, submit a new plaintext value.
*/
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import ToggleSwitch from './ToggleSwitch.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconPlus, IconTrash, IconEdit } from './icons';
interface Props {
workloadId: string;
}
let { workloadId }: Props = $props();
let rows = $state<api.WorkloadNotification[]>([]);
let loading = $state(true);
let error = $state('');
let saving = $state(false);
let confirmDeleteId = $state<string | null>(null);
// Edit form. editingId === '' means we're creating, otherwise it's
// the row being edited. Form fields are kept flat (string) so the
// API payload assembly stays trivial.
let editingId = $state<string | null>(null);
let formName = $state('');
let formURL = $state('');
let formSecret = $state('');
let formEventTypes = $state('');
let formEnabled = $state(true);
const formValid = $derived(formURL.trim().length > 0);
async function load(): Promise<void> {
loading = true;
error = '';
try {
rows = await api.listWorkloadNotifications(workloadId);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
// Reload whenever the workloadId prop changes — the parent (/apps/[id])
// reuses this component instance across /apps/A → /apps/B navigation, so
// onMount(load) alone would keep showing the previous workload's rows.
$effect(() => {
const _ = workloadId; // explicit dependency
load();
});
function startAdd(): void {
editingId = '';
formName = '';
formURL = '';
formSecret = '';
formEventTypes = '';
formEnabled = true;
}
function startEdit(row: api.WorkloadNotification): void {
editingId = row.id;
formName = row.name;
formURL = row.url;
formSecret = ''; // write-only; empty means "leave unchanged"
formEventTypes = row.event_types;
formEnabled = row.enabled;
}
function cancelEdit(): void {
editingId = null;
}
async function save(): Promise<void> {
if (!formValid || saving) return;
saving = true;
try {
const body: api.WorkloadNotificationInput = {
name: formName.trim(),
url: formURL.trim(),
event_types: formEventTypes.trim(),
enabled: formEnabled
};
if (formSecret.trim()) body.secret = formSecret.trim();
if (editingId === '') {
await api.createWorkloadNotification(workloadId, body);
} else if (editingId) {
await api.updateWorkloadNotification(workloadId, editingId, body);
}
editingId = null;
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
saving = false;
}
}
async function doDelete(id: string): Promise<void> {
saving = true;
try {
await api.deleteWorkloadNotification(workloadId, id);
confirmDeleteId = null;
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
saving = false;
}
}
</script>
<section class="panel notif-panel" aria-labelledby="notif-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="notif-heading">
{$t('apps.detail.notifications.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.notifications.sub')}</span>
</header>
{#if error}
<div class="alert inline-alert" role="alert">
<span class="alert-tag">ERR</span><span>{error}</span>
</div>
{/if}
{#if loading}
<p class="hint">{$t('apps.detail.notifications.loading')}</p>
{:else if rows.length === 0 && editingId === null}
<p class="hint">{$t('apps.detail.notifications.empty')}</p>
<button class="forge-btn" onclick={startAdd}>
<IconPlus size={13} />
<span>{$t('apps.detail.notifications.addFirst')}</span>
</button>
{:else}
{#if rows.length > 0}
<ul class="notif-list">
{#each rows as row (row.id)}
<li class="notif-row" class:disabled={!row.enabled}>
<div class="notif-main">
<span class="notif-name">{row.name || '(unnamed)'}</span>
<span class="notif-url mono">{row.url}</span>
<span class="notif-meta">
{#if row.event_types}
<span class="notif-tag">{row.event_types}</span>
{:else}
<span class="notif-tag muted">{$t('apps.detail.notifications.allEvents')}</span>
{/if}
{#if row.secret_set}
<span class="notif-tag secret">{$t('apps.detail.notifications.signed')}</span>
{/if}
{#if !row.enabled}
<span class="notif-tag muted">{$t('apps.detail.notifications.disabled')}</span>
{/if}
</span>
</div>
<div class="notif-actions">
<button
class="forge-btn-ghost"
onclick={() => startEdit(row)}
aria-label={$t('apps.detail.notifications.edit')}
>
<IconEdit size={13} />
</button>
<button
class="forge-btn-ghost danger"
onclick={() => (confirmDeleteId = row.id)}
aria-label={$t('apps.detail.notifications.delete')}
>
<IconTrash size={13} />
</button>
</div>
</li>
{/each}
</ul>
{/if}
{#if editingId === null}
<button class="forge-btn-ghost notif-add" onclick={startAdd}>
<IconPlus size={13} />
<span>{$t('apps.detail.notifications.add')}</span>
</button>
{/if}
{/if}
{#if editingId !== null}
<div class="notif-form">
<label class="sub">
<span class="sub-label">{$t('apps.detail.notifications.name')}</span>
<input
type="text"
class="input"
bind:value={formName}
placeholder={$t('apps.detail.notifications.namePlaceholder')}
autocomplete="off"
/>
</label>
<label class="sub">
<span class="sub-label">{$t('apps.detail.notifications.url')}</span>
<input
type="url"
class="input mono"
bind:value={formURL}
placeholder="https://hooks.slack.com/services/..."
autocomplete="off"
/>
</label>
<label class="sub">
<span class="sub-label">{$t('apps.detail.notifications.secret')}</span>
<input
type="password"
class="input mono"
bind:value={formSecret}
placeholder={editingId
? $t('apps.detail.notifications.secretEditPlaceholder')
: $t('apps.detail.notifications.secretPlaceholder')}
autocomplete="new-password"
/>
<span class="hint">{$t('apps.detail.notifications.secretHint')}</span>
</label>
<label class="sub">
<span class="sub-label">{$t('apps.detail.notifications.eventTypes')}</span>
<input
type="text"
class="input mono"
bind:value={formEventTypes}
placeholder={$t('apps.detail.notifications.eventTypesPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<span class="hint">{$t('apps.detail.notifications.eventTypesHint')}</span>
</label>
<div class="sub toggle-row">
<span class="sub-label">{$t('apps.detail.notifications.enabled')}</span>
<ToggleSwitch bind:checked={formEnabled} ariaLabel={$t('apps.detail.notifications.enabled')} />
</div>
<div class="notif-form-actions">
<button class="forge-btn-ghost" onclick={cancelEdit} disabled={saving}>
{$t('apps.detail.notifications.cancel')}
</button>
<button class="forge-btn" onclick={save} disabled={!formValid || saving}>
{saving ? $t('apps.detail.notifications.saving') : $t('apps.detail.notifications.save')}
</button>
</div>
</div>
{/if}
</section>
{#if confirmDeleteId}
<ConfirmDialog
open={true}
title={$t('apps.detail.notifications.confirmDeleteTitle')}
message={$t('apps.detail.notifications.confirmDeleteMessage')}
confirmLabel={$t('apps.detail.notifications.delete')}
confirmVariant="danger"
onconfirm={() => confirmDeleteId && doDelete(confirmDeleteId)}
oncancel={() => (confirmDeleteId = null)}
/>
{/if}
<style>
.notif-panel {
margin-top: 1rem;
}
.notif-list {
list-style: none;
margin: 0.7rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.notif-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
padding: 0.65rem 0.85rem;
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: 4px;
}
.notif-row.disabled {
opacity: 0.55;
}
.notif-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
.notif-name {
font-weight: 600;
font-size: 0.85rem;
}
.notif-url {
font-size: 0.75rem;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notif-meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.15rem;
}
.notif-tag {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.68rem;
padding: 0.1rem 0.45rem;
border: 1px solid var(--border-primary);
border-radius: 3px;
letter-spacing: 0.04em;
}
.notif-tag.secret {
border-color: var(--accent-warm, #c08458);
}
.notif-tag.muted {
color: var(--text-tertiary);
}
.notif-actions {
display: flex;
gap: 0.35rem;
}
.notif-add {
margin-top: 0.85rem;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.notif-form {
margin-top: 1rem;
padding-top: 0.95rem;
border-top: 1px dashed var(--border-primary);
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.notif-form .sub {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.notif-form .sub-label {
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
}
.notif-form .toggle-row {
flex-direction: row;
align-items: center;
gap: 0.65rem;
}
.notif-form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.hint {
font-size: 0.72rem;
color: var(--text-tertiary);
}
</style>
@@ -0,0 +1,210 @@
<script lang="ts">
// Vertical step rail for multi-step wizards. Renders numbered steps with
// a "molten" ember fill connecting completed steps — the forge control-
// panel motif. Steps up to `maxReached` are clickable to jump back;
// upcoming steps are inert. Collapses to a horizontal bar on narrow
// viewports via CSS.
interface WizardStep {
label: string;
}
interface Props {
steps: WizardStep[];
/** 1-based index of the active step. */
current: number;
/** 1-based index of the furthest step the user may navigate to. */
maxReached: number;
onselect: (step: number) => void;
}
let { steps, current, maxReached, onselect }: Props = $props();
function stepState(index1: number): 'done' | 'active' | 'upcoming' {
if (index1 < current) return 'done';
if (index1 === current) return 'active';
return 'upcoming';
}
function pad(n: number): string {
return String(n).padStart(2, '0');
}
// Up/Down arrows move focus among the reachable (enabled) step buttons
// so the rail reads as one grouped control to keyboard users.
function handleKeydown(e: KeyboardEvent): void {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const list = (e.currentTarget as HTMLElement).closest('.rail-list');
if (!list) return;
const btns = Array.from(list.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
const idx = btns.indexOf(e.currentTarget as HTMLButtonElement);
if (idx === -1) return;
e.preventDefault();
const target =
e.key === 'ArrowDown' ? Math.min(idx + 1, btns.length - 1) : Math.max(idx - 1, 0);
btns[target]?.focus();
}
</script>
<nav class="rail" aria-label="Progress">
<ol class="rail-list">
{#each steps as step, i (i)}
{@const num = i + 1}
{@const st = stepState(num)}
{@const reachable = num <= maxReached && num !== current}
<li class="rail-item rail-{st}">
<button
type="button"
class="rail-btn"
disabled={!reachable}
aria-current={st === 'active' ? 'step' : undefined}
onclick={() => reachable && onselect(num)}
onkeydown={handleKeydown}
>
<span class="rail-marker" aria-hidden="true">
{#if st === 'done'}
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.5 3.5L13 4" /></svg>
{:else}
{pad(num)}
{/if}
</span>
<span class="rail-label">{step.label}</span>
</button>
</li>
{/each}
</ol>
</nav>
<style>
.rail {
flex-shrink: 0;
}
.rail-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.rail-item {
position: relative;
}
/* Connector line between markers. Drawn from this marker's centre
upward to the previous one; ember-filled once the step is reached. */
.rail-item:not(:first-child)::before {
content: '';
position: absolute;
left: calc(0.875rem - 1px);
top: calc(-0.5rem);
height: 0.5rem;
width: 2px;
background: var(--border-primary);
transition: background var(--transition-slow);
}
.rail-done:not(:first-child)::before,
.rail-active:not(:first-child)::before {
background: var(--forge-ember);
}
.rail-btn {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-2);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
color: var(--text-tertiary);
transition: color var(--transition-fast), background var(--transition-fast);
}
.rail-btn:not(:disabled):hover {
background: var(--surface-card-hover);
color: var(--text-secondary);
}
.rail-btn:disabled {
cursor: default;
}
.rail-marker {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-full);
border: 2px solid var(--border-primary);
background: var(--surface-card);
font-family: var(--font-family-mono);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--text-tertiary);
transition: all var(--transition-fast);
}
.rail-done .rail-marker {
border-color: var(--forge-ember);
background: var(--forge-ember);
color: #fff;
}
.rail-active .rail-marker {
border-color: var(--forge-ember);
color: var(--forge-ember);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--forge-ember) 18%, transparent);
}
.rail-label {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
line-height: var(--leading-tight);
}
.rail-active .rail-label {
color: var(--text-primary);
font-weight: var(--weight-semibold);
}
.rail-done .rail-label {
color: var(--text-secondary);
}
/* Horizontal rail on narrow screens: markers in a row, labels hidden. */
@media (max-width: 820px) {
.rail-list {
flex-direction: row;
gap: 0;
justify-content: space-between;
}
.rail-item {
flex: 1;
display: flex;
justify-content: center;
}
.rail-item:not(:first-child)::before {
left: auto;
right: 50%;
top: calc(0.875rem - 1px);
height: 2px;
width: 100%;
}
.rail-btn {
flex-direction: column;
gap: var(--space-1);
width: auto;
z-index: 1;
}
.rail-label {
display: none;
}
}
</style>
@@ -0,0 +1,181 @@
<!--
App manifest — a forged "spec-sheet" summarizing the whole workload on the
wizard's Review step so the operator can confirm everything before Create.
Pure presentation: the page computes the row set + source-kind via `$derived`
and passes them in. Values are rendered in a definition grid (mono uppercase
labels in the left column, values in the right). Machine-readable values
(image refs, repo paths, branches, ports, FQDNs) are set `mono` by the caller
so they read as the literals they are. The source kind is shown as an ember
badge in the manifest header.
-->
<script lang="ts">
import { t } from '$lib/i18n';
export interface ManifestRow {
label: string;
value: string;
mono?: boolean;
}
interface Props {
/** Definition rows: Name / Source / Trigger / Public face. */
rows: ManifestRow[];
/** Source-kind string (image / compose / static / dockerfile) — badge. */
sourceKind: string;
}
let { rows, sourceKind }: Props = $props();
</script>
<section class="manifest" aria-label={$t('apps.new.manifest.title')}>
<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="manifest-head">
<span class="forge-eyebrow">
<span class="forge-ember"></span>
<span class="eb-word">{$t('apps.new.manifest.title')}</span>
</span>
{#if sourceKind}
<span class="kind-badge mono">{sourceKind}</span>
{/if}
</header>
<dl class="manifest-grid">
{#each rows as row (row.label)}
<div class="manifest-row">
<dt class="manifest-label">{row.label}</dt>
<dd class="manifest-value" class:mono={row.mono}>{row.value}</dd>
</div>
{/each}
</dl>
</section>
<style>
/* Forged spec-sheet: subtle bordered panel with registration corners,
ember eyebrow header, and a label/value definition grid. Reuses the
forge token system end-to-end — no ad-hoc colours. */
.manifest {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5) var(--space-5) var(--space-6);
background: var(--surface-card-hover);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
}
.reg {
position: absolute;
width: 9px;
height: 9px;
border-color: var(--forge-accent);
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-xl);
}
.reg-tr {
top: -1px;
right: -1px;
border-top-width: 2px;
border-right-width: 2px;
border-top-right-radius: var(--radius-xl);
}
.reg-bl {
bottom: -1px;
left: -1px;
border-bottom-width: 2px;
border-left-width: 2px;
border-bottom-left-radius: var(--radius-xl);
}
.reg-br {
bottom: -1px;
right: -1px;
border-bottom-width: 2px;
border-right-width: 2px;
border-bottom-right-radius: var(--radius-xl);
}
.manifest-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--border-primary);
}
.eb-word {
font-weight: 700;
}
.kind-badge {
display: inline-flex;
align-self: flex-start;
padding: 0.2rem 0.55rem;
background: var(--forge-accent);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
border-radius: var(--radius-sm);
line-height: 1;
}
/* Definition grid — mono uppercase labels, values aligned in a second
column. Collapses to stacked rows on narrow viewports. */
.manifest-grid {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
}
.manifest-row {
display: grid;
grid-template-columns: minmax(7rem, 0.32fr) 1fr;
gap: var(--space-4);
align-items: baseline;
padding: var(--space-2) 0;
border-bottom: 1px dashed var(--border-secondary);
}
.manifest-row:last-child {
border-bottom: 0;
}
.manifest-label {
margin: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-tertiary);
}
.manifest-value {
margin: 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-primary);
word-break: break-word;
}
.manifest-value.mono {
font-family: var(--forge-mono);
font-size: 0.82rem;
}
@media (max-width: 560px) {
.manifest-row {
grid-template-columns: 1fr;
gap: var(--space-1);
}
}
</style>
@@ -0,0 +1,222 @@
<!--
Compose source form. Surfaces the YAML stack + optional project name as
proper controls instead of forcing the operator to hand-escape YAML inside
a JSON string. The parent owns the `ComposeFormState` (from
`$lib/workload/sourceForms`) and binds it here; serialization to the
`source_config` object is done by the parent via `composeToConfig` so the
shape stays byte-identical to the legacy inline path.
The "Advanced JSON" chip is rendered by the parent (it owns the raw-editor
toggle); this component is purely the form-field body.
-->
<script lang="ts">
import type { ComposeFormState } from '$lib/workload/sourceForms';
import { t } from '$lib/i18n';
interface Props {
form: ComposeFormState;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
}
let { form = $bindable(), onAdvanced }: Props = $props();
</script>
<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.new.composeHeader')}</span>
<span class="spacer"></span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<textarea
id="app-compose-yaml"
bind:value={form.yaml}
rows="12"
spellcheck="false"
class="code-area"
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>
{$t('apps.new.fieldConfigYaml')}
</span>
<span class="sep">·</span>
<span>{form.yaml.split('\n').length} {$t('apps.new.linesUnit')}</span>
</div>
</div>
<label class="sub" for="app-compose-project">
<span class="sub-label">{$t('apps.new.composeProjectLabel')}</span>
<input
id="app-compose-project"
type="text"
class="input"
bind:value={form.projectName}
placeholder={$t('apps.new.composeProjectPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<style>
.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);
}
.input:focus-visible {
outline: none;
}
.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);
}
/* ── Code editor frame ─────────────────────────── */
.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;
letter-spacing: 0.02em;
}
.spacer {
flex: 1;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
Quiet secondary text-link rather than a prominent chip, so it doesn't
compete with the form title. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.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;
}
.code-area::placeholder {
color: var(--text-tertiary);
}
.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);
}
:global([data-theme='dark']) .foot-status {
color: #86efac;
}
.sep {
opacity: 0.5;
}
</style>
@@ -0,0 +1,286 @@
<!--
Dockerfile source form. Shares the provider + repo + branch + token
git-discovery wiring with the static source (StaticDiscoveryWizard in its
compact `dockerfile` variant — same handlers, no folder tree). The
build-step controls (context path, dockerfile path, port) are the only
dockerfile-specific UI.
The parent owns the `DockerfileFormState` (from `$lib/workload/sourceForms`)
and binds it here; serialization to `source_config` is done by the parent
via `dockerfileToConfig` so the shape (incl. preserved unknown keys + the
scrubbed static-only keys) stays byte-identical. `DockerfileFormState
extends GitSourceState`, so the same object is bound into the wizard.
-->
<script lang="ts">
import type { DockerfileFormState } from '$lib/workload/sourceForms';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import { IconX } from '$lib/components/icons';
import { t } from '$lib/i18n';
interface Props {
form: DockerfileFormState;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
/**
* Transient discovery status — OPTIONAL pass-through to
* StaticDiscoveryWizard (dockerfile variant: detect + test only, no
* folder tree / mode). Each defaults to this component's own internal
* `$state`, so a parent that doesn't bind them keeps the original
* "resets on remount" behaviour (the detail/edit page `apps/[id]`
* binds none of these). The create wizard `apps/new` binds them up to
* the PAGE so the detect/test pills survive the form unmounting under
* the Advanced-JSON / source-kind toggles.
*/
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
detectError?: string;
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
testError?: string;
}
let {
form = $bindable(),
onAdvanced,
detectStatus = $bindable('idle'),
detectError = $bindable(''),
testStatus = $bindable('idle'),
testError = $bindable('')
}: Props = $props();
// `touched` flips true on first blur — used by the pill to avoid shouting
// "required" the instant the user lands on the form.
let portTouched = $state(false);
// 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. Guard against null/NaN/non-positive here.
const portValid = $derived(
typeof form.port === 'number' && Number.isFinite(form.port) && form.port > 0
);
</script>
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">{$t('apps.new.dockerfileHeader')}</span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<StaticDiscoveryWizard
bind:git={form}
variant="dockerfile"
bind:detectStatus
bind:detectError
bind:testStatus
bind:testError
idPrefix="app-df"
/>
<!-- Build-step controls — the only dockerfile-only UI. The form is a
two-phase form (locate the code, describe how to build it). A
forge-eyebrow divider phrases the conceptual break. -->
<div class="df-section-break" aria-hidden="false">
<span class="forge-eyebrow">
<span class="forge-ember"></span>
<span class="eb-word">{$t('apps.new.dockerfileBuildEyebrow')}</span>
</span>
</div>
<div class="row">
<label class="sub" for="app-df-context">
<span class="sub-label">{$t('apps.new.dockerfileContextPath')}</span>
<input
id="app-df-context"
type="text"
class="input mono"
bind:value={form.contextPath}
placeholder={$t('apps.new.dockerfileContextPathPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="sub" for="app-df-dockerfile">
<span class="sub-label">{$t('apps.new.dockerfilePath')}</span>
<input
id="app-df-dockerfile"
type="text"
class="input mono"
bind:value={form.dockerfilePath}
placeholder="Dockerfile"
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<div class="row">
<label class="sub" for="app-df-port">
<span class="sub-label"
>{$t('apps.new.dockerfilePort')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
>*</span
></span
>
<input
id="app-df-port"
type="number"
min="1"
max="65535"
class="input mono"
bind:value={form.port}
onblur={() => (portTouched = true)}
placeholder="8080"
required
/>
</label>
</div>
{#if portTouched && !portValid}
<div class="discover-pill discover-pill-bad">
<IconX size={12} />
<span>{$t('apps.new.dockerfilePortRequired')}</span>
</div>
{/if}
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
</div>
<style>
.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);
}
.input:focus-visible {
outline: none;
}
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.45;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 600px) {
.row {
grid-template-columns: 1fr;
}
}
.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);
}
/* Required-field marker — same danger hue as the page-level `.req`
badge, rendered as a compact asterisk. */
.req-star {
margin-left: 0.2rem;
color: var(--color-danger);
font-weight: 700;
}
.editor-title {
margin-left: 0.4rem;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.02em;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
Quiet secondary text-link rather than a prominent chip, so it doesn't
compete with the form title. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Image source form shell (shared visual vocabulary) ── */
.image-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
padding: 0.85rem 1rem;
}
.image-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border-primary);
margin-bottom: 0.2rem;
}
.image-form-foot {
margin-top: 0.2rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
/* Conceptual section divider — separates git-discovery from build-step.
Same dashed border vocabulary as image-form-foot so it reads as a
sibling of the foot hint, not a new pattern. */
.df-section-break {
margin-top: 0.45rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
.discover-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.55rem;
border-radius: var(--radius-md);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1;
align-self: flex-start;
}
.discover-pill-bad {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: var(--color-danger-dark);
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
}
:global([data-theme='dark']) .discover-pill-bad {
color: #fca5a5;
}
</style>
@@ -0,0 +1,741 @@
<!--
Image source form. Surfaces the most-used image-source fields as proper
controls (ref, port, healthcheck, default tag, registry, resource limits).
Env + volumes stay on the detail page where they have dedicated panels.
The parent owns the `ImageFormState` (from `$lib/workload/sourceForms`) and
binds it here; serialization to `source_config` is done by the parent via
`imageToConfig` so the shape stays byte-identical.
Two pieces of async UX live in this component but write their results back
through bindable props so the parent's submit gate can read them:
• Inspect — pulls port + healthcheck from the image metadata. Guarded by
an AbortController + ref re-check so a late response can't relabel a
newer ref. Touch sentinels stop Inspect overwriting fields the operator
already edited.
• Conflict lookup — debounced /api/discovery/image/conflicts call,
guarded by a sequence token so a slow earlier response can't clobber a
faster later one. The `conflicts` / `conflictAcknowledged` /
`conflictBlocked` triplet is bound to the parent which runs the
two-click "submit anyway" gate.
-->
<script lang="ts">
import { onDestroy } from 'svelte';
import type { ImageFormState } from '$lib/workload/sourceForms';
import * as api from '$lib/api';
import { IconSearch, IconLoader } from '$lib/components/icons';
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
import { t } from '$lib/i18n';
interface Props {
form: ImageFormState;
/** Registry list for the picker; empty falls back to a text input. */
registries?: { name: string; url: string }[];
/** Bound submitting flag — gates the debounced conflict lookup. */
submitting?: boolean;
/** Bound conflict triplet — the parent's submit gate reads these. */
conflicts?: api.ImageConflict[];
conflictAcknowledged?: boolean;
conflictBlocked?: boolean;
/**
* Gate the debounced /api/discovery/image/conflicts lookup + the conflict
* warning panel. The create wizard wants it (default `true`) so an
* operator about to deploy a duplicate image is warned. The detail-page
* EDIT form must turn it OFF — there the workload would flag itself as a
* conflict with its own image. When off, the conflict triplet / submitting
* / registries props are unused and may be omitted by the parent.
*/
enableConflicts?: boolean;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
}
let {
form = $bindable(),
registries = [],
submitting = $bindable(false),
conflicts = $bindable([]),
conflictAcknowledged = $bindable(false),
conflictBlocked = $bindable(false),
enableConflicts = true,
onAdvanced
}: Props = $props();
// ── Inspect state ─────────────────────────────────────────────────
type InspectStatus = 'idle' | 'pending' | 'ok' | 'error';
let inspectStatus = $state<InspectStatus>('idle');
// AbortController + sequence guard so a late inspect response cannot
// mislabel the *current* image ref after the user typed a new one.
let inspectAbort: AbortController | null = null;
// Touch sentinels for fields with a "0 == empty" sentinel value (port,
// healthcheck). Once the user interacts, Inspect leaves them alone — even
// when still `0` / "" (some images really do listen on port 0 / have no
// healthcheck).
let portTouched = $state(false);
let healthcheckTouched = $state(false);
// The legacy inline seedImageFromJSON reset the touched sentinels on every
// reseed (mount, kind switch, Advanced↔form toggle). The parent reseeds by
// REASSIGNING the form object (a fresh seed* result), so the object's
// identity changes on reseed but not on in-place field edits. Track that
// identity to reset the sentinels exactly when a reseed happens — keeping
// Inspect's "only fill untouched fields" behaviour identical to before.
let lastSeenForm: ImageFormState | null = null;
$effect(() => {
if (form !== lastSeenForm) {
lastSeenForm = form;
portTouched = false;
healthcheckTouched = false;
}
});
// ── Conflict-lookup state ─────────────────────────────────────────
let conflictLoading = $state(false);
let conflictDebounce: ReturnType<typeof setTimeout> | null = null;
// Race token so a slow earlier response cannot overwrite a faster later one.
let conflictReqSeq = 0;
// Query the backend for workloads already using this image. Failures are
// silent (the existing list stays) — a transient network blip should never
// clear a real warning. The caller guards against empty / too-short refs.
async function fetchImageConflicts(ref: string): Promise<void> {
const mine = ++conflictReqSeq;
conflictLoading = true;
try {
const result = await api.listImageConflicts(ref);
if (mine === conflictReqSeq) {
conflicts = result;
}
} catch (e) {
if (mine === conflictReqSeq) {
// no-op; intentionally preserve prior conflicts
void e;
}
} finally {
if (mine === conflictReqSeq) {
conflictLoading = false;
}
}
}
function scheduleImageConflictLookup(ref: string) {
if (conflictDebounce) {
clearTimeout(conflictDebounce);
conflictDebounce = null;
}
// Conflict detection disabled (edit form) — never probe the backend.
if (!enableConflicts) return;
const trimmed = ref.trim();
if (trimmed.length < 3 || submitting) return;
conflictDebounce = setTimeout(() => {
conflictDebounce = null;
void fetchImageConflicts(trimmed);
}, 300);
}
function onImageRefInput() {
// Typing invalidates any prior acknowledgement and clears the stale
// list so the panel doesn't lie about the current ref; the debounced
// lookup will repopulate it.
conflictAcknowledged = false;
conflictBlocked = false;
conflicts = [];
// Also reset the inspect pill — its OK/error status belongs to the
// *previous* ref and would mislead the user otherwise.
inspectStatus = 'idle';
inspectAbort?.abort();
scheduleImageConflictLookup(form.ref);
}
function onImageRefBlur() {
if (!enableConflicts) return;
const trimmed = form.ref.trim();
if (trimmed.length < 3 || submitting) return;
if (conflictDebounce) {
clearTimeout(conflictDebounce);
conflictDebounce = null;
}
void fetchImageConflicts(trimmed);
}
// Tear down the pending debounce timer + cancel any in-flight inspect
// request if the user navigates away mid-window — otherwise the late
// resolve mutates dead state.
onDestroy(() => {
if (conflictDebounce) {
clearTimeout(conflictDebounce);
conflictDebounce = null;
}
inspectAbort?.abort();
});
// Pull port + healthcheck from the image's exposed metadata. Only
// overwrites untouched fields. A new call aborts any in-flight one, and we
// re-check the ref after the await so a late response can't relabel the
// *new* image ref the user just typed.
async function inspectImageRef() {
const ref = form.ref.trim();
if (!ref) return;
if (inspectStatus === 'pending') return;
inspectAbort?.abort();
const controller = new AbortController();
inspectAbort = controller;
inspectStatus = 'pending';
try {
const result = await api.inspectImage(ref, controller.signal);
// Late-arrival guard: if the user edited the ref during the flight,
// our success belongs to a stale value — discard.
if (form.ref.trim() !== ref) return;
// Only fill fields the operator hasn't touched. The sentinel is the
// touched flag, not the value — a user who deliberately types `0`
// or clears the healthcheck still owns the field.
if (!portTouched && typeof result.port === 'number') form.port = result.port;
if (!healthcheckTouched && typeof result.healthcheck === 'string') {
form.healthcheck = result.healthcheck;
}
inspectStatus = 'ok';
} catch (e) {
if (controller.signal.aborted) return;
if (form.ref.trim() !== ref) return;
// Show a friendly, localized message — never the raw backend
// string (the discovery handlers were just hardened to drop
// leaky daemon errors, so there is nothing useful to surface).
void e;
inspectStatus = 'error';
}
}
</script>
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">{$t('apps.new.imageHeader')}</span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<label class="sub" for="app-image-ref">
<span class="sub-label"
>{$t('apps.new.imageRefLabel')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}>*</span></span
>
<div class="input-with-button">
<input
id="app-image-ref"
type="text"
class="input mono"
bind:value={form.ref}
oninput={onImageRefInput}
onblur={onImageRefBlur}
placeholder={$t('apps.new.imageRefPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<button
type="button"
class="discover-btn"
onclick={inspectImageRef}
disabled={!form.ref.trim() || inspectStatus === 'pending'}
title={$t('apps.new.imageInspectHint')}
>
{#if inspectStatus === 'pending'}
<IconLoader size={14} />
{:else}
<IconSearch size={14} />
{/if}
<span>{$t('apps.new.imageInspect')}</span>
</button>
<RegistryImagePicker
current={form.ref}
onpick={(ref, registryName) => {
form.ref = ref;
// Auto-select the registry the image came from so private
// images pull with the right credentials without a second
// manual step. Only adopt it when the picker surfaced a
// non-empty name (public images carry '') so we never wipe a
// registry the operator already chose.
if (registryName) form.registryName = registryName;
onImageRefInput();
}}
/>
</div>
<p class="hint">{$t('apps.new.imageRefHint')}</p>
{#if inspectStatus === 'ok'}
<span class="discover-pill discover-pill-ok inline">{$t('apps.new.imageInspectOk')}</span>
{:else if inspectStatus === 'error'}
<span class="discover-pill discover-pill-bad inline">{$t('apps.new.errors.inspectFailed')}</span>
{/if}
<!--
Conflict-checking indicator. Reserves no layout when idle and is a
quiet inline hint (not the full panel) while a lookup is in flight,
so a no-conflict blur no longer flashes the warning panel in then
out. The panel itself renders only for REAL conflicts below.
-->
{#if enableConflicts && conflictLoading}
<span class="conflict-checking" role="status" aria-live="polite">
<IconLoader size={12} />
<span>{$t('apps.new.imageConflictChecking')}</span>
</span>
{/if}
</label>
{#if enableConflicts && conflicts.length > 0}
<div class="conflict-panel" role="status" aria-live="polite">
<div class="conflict-panel-head">
<span class="conflict-tag">{$t('apps.new.imageConflictTag')}</span>
</div>
<p class="conflict-heading">
{$t('apps.new.imageConflictHeading', { count: String(conflicts.length) })}
<code class="conflict-ref mono">{form.ref.trim()}</code>
</p>
<ul class="conflict-list">
{#each conflicts as conflict (conflict.id)}
<li class="conflict-row">
<div class="conflict-row-text">
<span class="conflict-name">{conflict.name}</span>
<span class="conflict-image mono">{conflict.image}</span>
</div>
<a
href={`/apps/${conflict.id}`}
class="editor-chip conflict-open"
title={$t('apps.new.imageConflictOpenBtn')}
>
{$t('apps.new.imageConflictOpenBtn')}
</a>
</li>
{/each}
</ul>
<p class="conflict-foot">{$t('apps.new.imageConflictAcknowledgeNote')}</p>
</div>
{/if}
<div class="row three">
<label class="sub" for="app-image-port">
<span class="sub-label">{$t('apps.new.imagePort')}</span>
<input
id="app-image-port"
type="number"
min="0"
max="65535"
class="input"
bind:value={form.port}
oninput={() => (portTouched = true)}
/>
</label>
<label class="sub" for="app-image-healthcheck">
<span class="sub-label">{$t('apps.new.imageHealthcheck')}</span>
<input
id="app-image-healthcheck"
type="text"
class="input mono"
bind:value={form.healthcheck}
oninput={() => (healthcheckTouched = true)}
placeholder="/healthz"
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="sub" for="app-image-default-tag">
<span class="sub-label">{$t('apps.new.imageDefaultTag')}</span>
<input
id="app-image-default-tag"
type="text"
class="input mono"
bind:value={form.defaultTag}
placeholder="latest"
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<label class="sub" for="app-image-registry">
<span class="sub-label">{$t('apps.new.imageRegistryLabel')}</span>
{#if registries.length > 0}
<select id="app-image-registry" class="input" bind:value={form.registryName}>
<option value="">{$t('apps.new.imageRegistryPublic')}</option>
{#each registries as r}
<option value={r.name}>{r.name} {r.url}</option>
{/each}
</select>
{:else}
<input
id="app-image-registry"
type="text"
class="input"
bind:value={form.registryName}
placeholder={$t('apps.new.imageRegistryPublic')}
autocomplete="off"
/>
{/if}
<p class="hint">{$t('apps.new.imageRegistryHint')}</p>
</label>
<div class="row three">
<label class="sub" for="app-image-cpu">
<span class="sub-label">{$t('apps.new.imageCpu')}</span>
<input id="app-image-cpu" type="number" min="0" step="0.1" class="input" bind:value={form.cpuLimit} />
<p class="hint">{$t('apps.new.imageCpuHint')}</p>
</label>
<label class="sub" for="app-image-memory">
<span class="sub-label">{$t('apps.new.imageMemory')}</span>
<input id="app-image-memory" type="number" min="0" class="input" bind:value={form.memoryLimit} />
<p class="hint">{$t('apps.new.imageMemoryHint')}</p>
</label>
<label class="sub" for="app-image-max">
<span class="sub-label">{$t('apps.new.imageMax')}</span>
<input id="app-image-max" type="number" min="1" class="input" bind:value={form.maxInstances} />
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
</label>
</div>
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
</div>
<style>
.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);
}
.input:focus-visible {
outline: none;
}
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.45;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
.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);
}
/* Required-field marker — same danger hue as the page-level `.req`
badge, rendered as a compact asterisk so it doesn't bloat the mono
sub-label. The aria-label carries the meaning for assistive tech. */
.req-star {
margin-left: 0.2rem;
color: var(--color-danger);
font-weight: 700;
}
.editor-title {
margin-left: 0.4rem;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.02em;
}
.editor-chip {
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 0.22rem 0.55rem;
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;
}
.editor-chip:hover {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.editor-chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
A quiet secondary text-link, not a prominent chip — it must not
compete with the form title. Mono, muted, underline-on-hover so it
reads as the rarely-used power-user door it is. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Image source form ──────────────────────────────────
Same overall shell as the editor box (border + radius) but the
contents are a stack of labelled form rows. */
.image-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
padding: 0.85rem 1rem;
}
.image-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border-primary);
margin-bottom: 0.2rem;
}
.image-form-foot {
margin-top: 0.2rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
.row.three {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 720px) {
.row.three {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.row.three {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.row {
grid-template-columns: 1fr;
}
}
/* ── Discovery-style input+button row + status pills ─── */
.input-with-button {
display: flex;
align-items: stretch;
gap: 0.4rem;
}
.input-with-button > .input {
flex: 1;
min-width: 0;
}
.discover-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.7rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
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;
white-space: nowrap;
}
.discover-btn:hover:not(:disabled) {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.discover-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.discover-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.discover-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.55rem;
border-radius: var(--radius-md);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1;
align-self: flex-start;
}
.discover-pill.inline {
align-self: center;
}
.discover-pill-ok {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success-dark);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
}
.discover-pill-bad {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: var(--color-danger-dark);
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
}
:global([data-theme='dark']) .discover-pill-ok {
color: #86efac;
}
:global([data-theme='dark']) .discover-pill-bad {
color: #fca5a5;
}
/* ── Image-source conflict panel ──────────────────
Sibling of .image-form-foot. Reuses the dashed border + soft card
surface treatment lifted into a loud amber-leaning warning tag. */
.conflict-panel {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin-top: 0.2rem;
padding: 0.75rem 0.9rem;
background: var(--surface-card-hover);
border: 1px dashed var(--color-warning, var(--forge-accent));
border-radius: var(--radius-lg);
}
.conflict-panel-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.conflict-tag {
display: inline-flex;
padding: 0.18rem 0.5rem;
background: var(--color-warning, var(--forge-accent));
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;
}
/* Quiet inline "checking…" hint shown near the image-ref input while a
conflict lookup is in flight. Deliberately NOT the full panel, so a
no-conflict blur doesn't flash a panel in and out. Self-aligned so it
sits with the inspect status pills without shifting form layout. */
.conflict-checking {
display: inline-flex;
align-items: center;
gap: 0.35rem;
align-self: flex-start;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--text-tertiary);
}
.conflict-checking :global(svg) {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.conflict-heading {
margin: 0;
font-size: 0.84rem;
color: var(--text-secondary);
line-height: 1.5;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.4rem;
}
.conflict-ref {
padding: 0.1rem 0.35rem;
font-size: 0.78rem;
background: var(--surface-input);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
color: var(--text-primary);
}
.conflict-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.conflict-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.65rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
}
.conflict-row-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.conflict-name {
font-weight: 600;
font-size: 0.88rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.conflict-image {
font-size: 0.74rem;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.conflict-open {
flex: 0 0 auto;
text-decoration: none;
}
.conflict-foot {
margin: 0;
font-size: 0.74rem;
color: var(--text-tertiary);
line-height: 1.45;
}
</style>
@@ -0,0 +1,122 @@
<script lang="ts">
// Card-grid selector for the workload Source kind, replacing a bare
// <select>. Mirrors the trigger-mode card pattern already used in the
// wizard (role=radio buttons, mono tag + name + optional hint) so the
// two pickers read as one design language. Kinds come from the backend
// plugin registry; descriptions are optional (passed in when i18n keys
// exist) so this component never hardcodes copy.
interface Props {
kinds: string[];
value: string;
onchange: () => void;
descriptions?: Record<string, string>;
ariaLabel?: string;
}
let { kinds, value = $bindable(), onchange, descriptions = {}, ariaLabel }: Props = $props();
function select(kind: string): void {
if (kind === value) return;
value = kind;
onchange();
}
// Radiogroup keyboard semantics: arrows move selection (and focus) to
// the adjacent card, wrapping at the ends.
function onKeydown(e: KeyboardEvent, index: number): void {
const k = e.key;
if (k !== 'ArrowRight' && k !== 'ArrowDown' && k !== 'ArrowLeft' && k !== 'ArrowUp') return;
e.preventDefault();
const dir = k === 'ArrowRight' || k === 'ArrowDown' ? 1 : -1;
const next = (index + dir + kinds.length) % kinds.length;
select(kinds[next]);
const grid = (e.currentTarget as HTMLElement).closest('.kind-grid');
grid?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
}
</script>
<div class="kind-grid" role="radiogroup" aria-label={ariaLabel}>
{#each kinds as kind, i (kind)}
<button
type="button"
role="radio"
aria-checked={value === kind}
tabindex={value === kind ? 0 : -1}
class="kind-card"
class:active={value === kind}
onclick={() => select(kind)}
onkeydown={(e) => onKeydown(e, i)}
>
<span class="kind-tag mono">{kind.toUpperCase()}</span>
<span class="kind-name">{kind}</span>
{#if descriptions[kind]}
<span class="kind-hint">{descriptions[kind]}</span>
{/if}
</button>
{/each}
</div>
<style>
.kind-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: var(--space-3);
}
.kind-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-4);
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
transition:
border-color var(--transition-fast),
background var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.kind-card:hover {
border-color: var(--border-input);
transform: translateY(-1px);
}
.kind-card.active {
border-color: var(--forge-ember);
background: color-mix(in srgb, var(--forge-ember) 6%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--forge-ember) 16%, transparent);
}
.kind-tag {
font-size: 0.625rem;
letter-spacing: 0.08em;
font-weight: var(--weight-semibold);
color: var(--text-tertiary);
padding: 2px var(--space-2);
border: 1px solid var(--border-primary);
border-radius: var(--radius-sm);
}
.kind-card.active .kind-tag {
color: var(--forge-ember-deep);
border-color: color-mix(in srgb, var(--forge-ember) 40%, transparent);
}
.kind-name {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
color: var(--text-primary);
text-transform: capitalize;
}
.kind-hint {
font-size: var(--text-xs);
color: var(--text-tertiary);
line-height: var(--leading-normal);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
<!--
Static source form — Gitea Pages-alike with optional Deno runtime mode.
Delegates the git-discovery block (provider/repo/branch/token + folder
tree) to StaticDiscoveryWizard, then adds the static-only mode radio and
the render-markdown toggle.
The parent owns the `StaticFormState` (from `$lib/workload/sourceForms`)
and binds it here; serialization to `source_config` is done by the parent
via `staticToConfig` so the shape (incl. preserved storage_* keys) stays
byte-identical. `StaticFormState extends GitSourceState`, so the same
object is bound straight into the wizard's `git` slice.
-->
<script lang="ts">
import type { StaticFormState } from '$lib/workload/sourceForms';
import type { FolderEntry } from '$lib/api';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import { t } from '$lib/i18n';
interface Props {
form: StaticFormState;
/** Flip to the raw-JSON editor (owned by the parent). */
onAdvanced: () => void;
/**
* Transient discovery status — OPTIONAL pass-through to
* StaticDiscoveryWizard. Each defaults to this component's own
* internal `$state`, so a parent that doesn't bind them gets the
* original "resets on remount" behaviour (this is what the detail/
* edit page `apps/[id]` relies on — it binds none of these). The
* create wizard `apps/new` binds them up to the PAGE so the loaded
* tree + detect/test pills + mode override survive the form
* unmounting under the Advanced-JSON / source-kind toggles.
*/
modeUserOverride?: boolean;
treeLoaded?: boolean;
tree?: FolderEntry[];
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
detectError?: string;
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
testError?: string;
}
let {
form = $bindable(),
onAdvanced,
// Sentinel: once the user manually toggles the static/deno radio,
// auto-detection stops overwriting their choice on subsequent tree loads.
modeUserOverride = $bindable(false),
// Reflects whether the discovery wizard has loaded a folder tree — gates
// the "auto-detected Deno" hint exactly like the legacy
// `staticTree.length > 0` guard did.
treeLoaded = $bindable(false),
tree = $bindable([]),
detectStatus = $bindable('idle'),
detectError = $bindable(''),
testStatus = $bindable('idle'),
testError = $bindable('')
}: Props = $props();
function onModeChange() {
modeUserOverride = true;
}
</script>
<div class="image-form">
<div class="image-form-head">
<span class="editor-title">{$t('apps.new.staticHeader')}</span>
<button type="button" class="json-escape" onclick={onAdvanced} title={$t('apps.new.switchToJsonTitle')}>
{$t('apps.new.advancedJson')}
</button>
</div>
<StaticDiscoveryWizard
bind:git={form}
variant="static"
showFolderTree={true}
bind:folderPath={form.folderPath}
bind:mode={form.mode}
bind:modeUserOverride
bind:treeLoaded
bind:tree
bind:detectStatus
bind:detectError
bind:testStatus
bind:testError
idPrefix="app-static"
/>
<fieldset class="static-mode">
<legend class="sub-label">{$t('apps.new.staticMode')}</legend>
<label class="radio">
<input type="radio" name="static-mode" value="static" bind:group={form.mode} onchange={onModeChange} />
<span>
<strong>static</strong> {$t('apps.new.staticModeStaticDesc')}
</span>
</label>
<label class="radio">
<input type="radio" name="static-mode" value="deno" bind:group={form.mode} onchange={onModeChange} />
<span>
<strong>deno</strong> {$t('apps.new.staticModeDenoDesc')}
</span>
</label>
{#if !modeUserOverride && form.mode === 'deno' && treeLoaded}
<p class="hint static-deno-auto">{$t('apps.new.staticDenoAutoDetected')}</p>
{/if}
</fieldset>
<label class="toggle-row">
<ToggleSwitch bind:checked={form.renderMarkdown} label={$t('apps.new.staticRenderMarkdown')} />
<span>
<strong>{$t('apps.new.staticRenderMarkdown')}</strong> {@html $t('apps.new.staticRenderMarkdownDesc')}
</span>
</label>
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
</div>
<style>
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.45;
}
.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);
}
.editor-title {
margin-left: 0.4rem;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.02em;
}
/* ── "Edit as JSON" escape hatch ─────────────────────────
Quiet secondary text-link rather than a prominent chip, so it doesn't
compete with the form title. */
.json-escape {
background: none;
border: 0;
padding: 0;
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
cursor: pointer;
text-decoration: none;
text-underline-offset: 3px;
transition: color 120ms ease;
}
.json-escape:hover {
color: var(--forge-accent);
text-decoration: underline;
}
.json-escape:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* ── Image source form shell (shared visual vocabulary) ── */
.image-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-input);
padding: 0.85rem 1rem;
}
.image-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border-primary);
margin-bottom: 0.2rem;
}
.image-form-foot {
margin-top: 0.2rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--border-primary);
}
/* ── Static source extras ────────────────────────────── */
.static-mode {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin: 0;
padding: 0.7rem 0.9rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--surface-card);
}
.static-mode legend {
padding: 0 0.3rem;
}
.radio {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.35rem 0;
font-size: 0.88rem;
color: var(--text-secondary);
cursor: pointer;
}
.radio input {
margin-top: 0.18rem;
accent-color: var(--color-brand-500);
}
.radio strong,
.toggle-row strong {
color: var(--text-primary);
}
.toggle-row {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.35rem 0;
font-size: 0.88rem;
color: var(--text-secondary);
cursor: pointer;
}
.toggle-row :global(.toggle-switch) {
margin-top: 0.1rem;
}
.static-deno-auto {
margin-top: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed var(--border-primary);
color: var(--color-success-dark);
}
:global([data-theme='dark']) .static-deno-auto {
color: #86efac;
}
</style>
+143 -15
View File
@@ -15,7 +15,7 @@
"nav": {
"dashboard": "Dashboard",
"apps": "Apps",
"eventTriggers": "Triggers",
"eventTriggers": "Event Triggers",
"logScanRules": "Log Rules",
"triggers": "Triggers",
"proxies": "Proxies",
@@ -23,7 +23,13 @@
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records",
"containers": "Containers"
"containers": "Containers",
"sectionObserve": "Observe",
"sectionSystem": "System",
"closeSidebar": "Close sidebar",
"openSidebar": "Open sidebar",
"quickNavTitle": "Press 'g' then a letter to jump between sections",
"quickNavLabel": "quick-nav"
},
"dashboard": {
"title": "Dashboard",
@@ -42,7 +48,11 @@
"systemHealth": "System health",
"daemons": "Daemons",
"systemResources": "System resources",
"systemResourcesSubtitle": "CPU, memory, disk, and top consumers"
"systemResourcesSubtitle": "CPU, memory, disk, and top consumers",
"statSubWorkloads": "workloads →",
"statSubRunning": "running",
"statSubNeedAttention": "need attention",
"statSubStale": "stale →"
},
"resources": {
"cpuCores": "CPU Cores",
@@ -237,6 +247,7 @@
"deleteFailed": "Failed to delete registry",
"testFailed": "Connection test failed",
"loadFailed": "Failed to load registries",
"deleteTitle": "Delete registry?",
"deleteConfirm": "Delete registry \"{name}\"? This cannot be undone.",
"healthChecking": "Checking...",
"healthConnected": "Connected",
@@ -354,6 +365,7 @@
"createFailed": "Failed to create user",
"deleteFailed": "Failed to delete user",
"deleteConfirm": "Are you sure you want to delete this user?",
"deleteConfirmMessage": "Delete user \"{username}\"? This cannot be undone.",
"usernameRequired": "Username and password are required",
"networkError": "Network error",
"password": "Password"
@@ -400,6 +412,9 @@
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"toggle": "Toggle",
"dismissNotification": "Dismiss notification",
"delete": "Delete",
"edit": "Edit",
"change": "Change",
@@ -429,6 +444,7 @@
"missing": "Missing"
},
"containers": {
"eyebrowSuffix": "GLOBAL",
"errLoad": "Failed to load containers",
"searchPlaceholder": "Search workload, role, image, subdomain…",
"kindFilterLabel": "Workload kind",
@@ -476,6 +492,7 @@
},
"stale": {
"title": "Stale Containers",
"eyebrowSuffix": "STALE",
"noStale": "No stale containers",
"noStaleDesc": "All containers are healthy and running.",
"cleanup": "Clean up",
@@ -541,13 +558,13 @@
"unavailable": "Stats unavailable"
},
"systemHealth": {
"title": "System Health",
"containers": "Containers",
"proxies": "Proxies",
"recentErrors": "Recent Errors"
},
"daemons": {
"title": "Daemons",
"notReachable": "{provider} is not reachable.",
"refresh": "Refresh",
"refreshing": "Refreshing",
"docker": "Docker Engine",
@@ -1110,6 +1127,10 @@
"image": "Image reference",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Full image reference without the tag — Tinyforge matches new tags pushed under it.",
"browseImages": "Browse",
"browseImagesHint": "Pick an image from a configured registry instead of typing the reference.",
"browseImagesTitle": "Select an image",
"browseImagesSearch": "Search images…",
"tagPattern": "Tag pattern",
"tagPatternPlaceholder": "*",
"tagPatternHint": "path.Match glob (e.g. v*, release-*). Use * to match every tag.",
@@ -1122,6 +1143,9 @@
"branch": "Branch",
"branchPlaceholder": "main",
"branchHint": "Only push events advancing this branch fire the trigger.",
"branchPattern": "Branch pattern (preview deploys)",
"branchPatternPlaceholder": "feat/* or * for any branch",
"branchPatternHint": "When set, any push to a matching branch spawns a per-branch preview deploy. Leave empty to disable previews.",
"manualNote": "Manual triggers carry no config. They fire only via the workload's Deploy button or POST /workloads/{id}/deploy.",
"scheduleNote": "Fires on a fixed interval driven by Tinyforge's internal scheduler. No external webhook is required — enable the webhook ingress below only if a CI also needs to fire it on demand.",
"intervalPresets": "Quick presets",
@@ -1186,6 +1210,14 @@
},
"new": {
"pageTitle": "New App · Tinyforge",
"wizard": {
"stepBasics": "Basics",
"stepConfigure": "Configure",
"stepTrigger": "Trigger",
"stepReview": "Review",
"next": "Next",
"back": "Back"
},
"backLabel": "Back to apps",
"eyebrowSuffix": "NEW APP",
"title": "Forge a new app",
@@ -1198,6 +1230,7 @@
"alertTag": "ERR",
"fieldName": "Name",
"fieldNameRequired": "REQUIRED",
"fieldRequired": "Required",
"fieldNamePlaceholder": "my-app",
"fieldNameHint": "Lowercase, no spaces. Becomes part of container names and subdomains.",
"fieldSourcePlugin": "Source plugin",
@@ -1207,7 +1240,7 @@
"fieldConfigYaml": "YAML",
"fieldConfigForm": "FORM",
"fieldConfigJson": "JSON",
"advancedJson": "Advanced JSON",
"advancedJson": "Edit as JSON",
"backToForm": "Back to form",
"resetSample": "Reset sample",
"switchToJsonTitle": "Switch to the raw JSON editor",
@@ -1230,11 +1263,21 @@
"imageRegistryLabel": "Registry (for private pulls)",
"imageRegistryPublic": "(public — no auth)",
"imageRegistryHint": "Match the name from the Registries settings page. Leave empty for public images.",
"imageCpu": "CPU limit (cores, 0 = ∞)",
"imageMemory": "Memory limit (MB, 0 = ∞)",
"imageCpu": "CPU limit",
"imageCpuHint": "Cores, 0 = ∞",
"imageMemory": "Memory limit",
"imageMemoryHint": "MB, 0 = ∞",
"imageMax": "Max instances",
"imageMaxHint": "1 = strict blue-green.",
"imageFoot": "Env vars and volume mounts live in their own panels on the workload detail page after creation.",
"dockerfileHeader": "dockerfile source · build from a git repo",
"dockerfileBuildEyebrow": "build · dockerfile",
"dockerfileContextPath": "Build context",
"dockerfileContextPathPlaceholder": "(empty = repo root)",
"dockerfilePath": "Dockerfile path",
"dockerfilePort": "Container port",
"dockerfilePortRequired": "Container port is required — pick the port your app listens on (165535).",
"dockerfileFoot": "Tinyforge clones the repo, builds the image from the Dockerfile, and runs one container. Env vars and volumes live in the detail page after creation.",
"staticHeader": "static source · pages from a repo",
"staticProvider": "Provider",
"staticBaseUrl": "Base URL",
@@ -1262,7 +1305,7 @@
"staticTestConnection": "Test connection",
"staticConnectionOk": "Connected",
"staticConnectionFailed": "Connection failed: {error}",
"staticBrowseRepos": "Browse repositories",
"staticBrowseRepos": "Browse",
"staticBrowseBranches": "Browse branches",
"staticBrowseFolders": "Browse folders",
"staticPickerRepoTitle": "Select repository",
@@ -1275,6 +1318,7 @@
"staticTreeEmpty": "No folders found in this branch.",
"staticDenoAutoDetected": "Auto-detected an <code>api/</code> folder — switched to Deno mode.",
"imageConflictTag": "IMAGE IN USE",
"imageConflictChecking": "Checking for conflicts…",
"imageConflictHeading": "{count} workload(s) already use this image:",
"imageConflictOpenBtn": "Open",
"imageConflictAcknowledgeNote": "If this is intentional (for example a separate stage), continue to create a new workload.",
@@ -1300,21 +1344,23 @@
"submit": "Forge app",
"submitting": "Forging…",
"submitAnyway": "Forge anyway",
"unsavedChanges": "You have unsaved changes to this app. Leave without creating it?",
"unsavedChangesTitle": "Unsaved changes",
"unsavedChangesConfirm": "Leave",
"errors": {
"detectionFailed": "Provider detection failed.",
"connectionFailed": "Connection failed.",
"reposFailed": "Failed to load repositories.",
"branchesFailed": "Failed to load branches.",
"treeFailed": "Failed to load folder tree.",
"detectionFailed": "Couldn't detect a Git provider at that URL. Check the base URL is correct and reachable.",
"connectionFailed": "Couldn't reach the repository. Check the provider URL, owner/repo, and access token (for private repos).",
"reposFailed": "Couldn't list repositories. Check the base URL and access token.",
"branchesFailed": "Couldn't list branches. Check the repository and access token.",
"treeFailed": "Couldn't load the folder tree. Check the repository, branch, and access token.",
"sourceConfigInvalid": "Source config is not valid JSON.",
"triggerBindUnknown": "unknown error",
"createFailed": "Workload create failed.",
"inspectFailed": "Image inspect failed."
"inspectFailed": "Couldn't inspect that image — make sure it's pulled locally and the reference is correct."
},
"imageInspect": "Inspect",
"imageInspectHint": "Pulls port + healthcheck from the image so you don't have to type them.",
"imageInspectOk": "Inspected — port + healthcheck filled.",
"imageInspectError": "Inspect failed: {error}",
"triggers": {
"section": "Trigger",
"sectionSub": "Optional. Pick how this app gets a redeploy signal — registry watcher, git event, or manual button.",
@@ -1334,6 +1380,18 @@
"pickWebhookOn": "WEBHOOK ON",
"skippedNote": "No trigger will be bound. You can add one from the app's Triggers panel after it's created.",
"bindError": "App created, but the trigger binding failed: {error}. Open the app's Triggers panel to retry."
},
"manifest": {
"title": "Manifest",
"name": "Name",
"source": "Source",
"trigger": "Trigger",
"publicFace": "Public face",
"unnamed": "(unnamed)",
"registryPublic": "public registry",
"folderRoot": "root",
"triggerManual": "Manual only",
"internalOnly": "Internal only"
}
},
"detail": {
@@ -1365,6 +1423,40 @@
"unavailable": "Usage probe unavailable (container may be stopped).",
"loading": "Computing usage…"
},
"buildLog": {
"title": "Build log",
"sub": "Live tail of the Docker daemon's build output.",
"clear": "Clear"
},
"notifications": {
"title": "Notification routes",
"sub": "Multi-destination fan-out for deploy/build events. Falls back to the workload's legacy URL when empty.",
"loading": "Loading routes…",
"empty": "No per-workload notification routes configured. Add one to get a per-channel destination.",
"addFirst": "Add first route",
"add": "Add route",
"edit": "Edit route",
"delete": "Delete route",
"name": "Name",
"namePlaceholder": "Slack #alerts",
"url": "Webhook URL",
"secret": "Signing secret",
"secretPlaceholder": "Optional — receiver verifies HMAC if set",
"secretEditPlaceholder": "Leave empty to keep the existing secret",
"secretHint": "HMAC-SHA256 over the request body, sent as X-Hub-Signature-256.",
"eventTypes": "Event types",
"eventTypesPlaceholder": "deploy_failure,build_failure (empty = all)",
"eventTypesHint": "Comma-separated allow-list. Empty means every event fires this route.",
"enabled": "Enabled",
"save": "Save route",
"saving": "Saving…",
"cancel": "Cancel",
"allEvents": "all events",
"signed": "signed",
"disabled": "disabled",
"confirmDeleteTitle": "Delete notification route?",
"confirmDeleteMessage": "This route will stop firing immediately. The workload's legacy notification URL (if set) will resume catching events when no routes match."
},
"toolbar": {
"stop": "Stop",
"start": "Start",
@@ -1455,6 +1547,19 @@
"editStaticModeDenoDesc": "— Deno runtime with dynamic routing.",
"editStaticRenderMarkdown": "Render markdown",
"editStaticRenderMarkdownDesc": "— auto-render <code>.md</code> as HTML.",
"editDockerfileHeader": "dockerfile source · build from a git repo",
"editDockerfileBuildEyebrow": "build · dockerfile",
"editDockerfileContextPath": "Build context",
"editDockerfileContextPathPlaceholder": "(empty = repo root)",
"editDockerfilePath": "Dockerfile path",
"editDockerfilePort": "Container port",
"editTestConnection": "Test connection",
"editTestConnectionOk": "Connection OK",
"editTestConnectionFailed": "Connection failed: {error}",
"editTestConnectionUnknownError": "Unknown error",
"overrideKeyUnitSingular": "KEY",
"overrideKeyUnitPlural": "KEYS",
"editTestConnectionIncomplete": "Fill provider, base URL, owner, and name first.",
"editSourceJsonHeader": "source_config.json",
"editSourceJsonAria": "Source plugin configuration (JSON)",
"editPublicFaces": "Public faces",
@@ -1494,6 +1599,29 @@
"chainPromoteButton": "Promote from parent",
"chainPromoting": "Promoting…",
"chainHint": "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.",
"previews": {
"title": "Preview environments",
"subEmpty": "no active previews",
"subCountOne": "1 active preview",
"subCount": "{count} active previews",
"tag": "Preview",
"tagTitle": "Per-branch preview deploy of this workload",
"armedEmpty": "No active previews — push to a branch matching",
"noneEmpty": "No active previews yet.",
"open": "Open",
"noUrl": "no public URL",
"teardown": "Tear down",
"teardownTitle": "Tear down preview?",
"teardownMessage": "This deletes the preview for branch \"{name}\" and removes its containers and proxy routes. Pushing to the branch again will recreate it.",
"teardownConfirm": "Tear down",
"teardownPending": "Tearing down…",
"teardownFailed": "Teardown failed",
"stateRunning": "Running",
"statePending": "Pending",
"stateStopped": "Stopped",
"stateUnknown": "Unknown",
"hint": "Previews are created automatically when a push lands on a branch matching a git trigger's <code>branch_pattern</code>, and torn down when the branch is deleted. Each gets its own slug-prefixed subdomain."
},
"volumesTitle": "Volumes",
"volumesEmpty": "No mounts",
"volumesCountSingular": "{count} mount",
+1 -1
View File
@@ -53,7 +53,7 @@ function getNestedValue(obj: Record<string, unknown>, path: string): string {
/**
* Derived store that returns a translation function.
* Usage: $t('dashboard.title') or $t('projectDetail.deleteConfirmMessage', { name: 'my-app' })
* Usage: $t('dashboard.title') or $t('settingsAuth.deleteConfirmMessage', { username: 'alice' })
*/
export const t = derived(locale, ($locale) => {
const dict = translations[$locale] ?? translations.en;
+143 -15
View File
@@ -15,7 +15,7 @@
"nav": {
"dashboard": "Панель",
"apps": "Приложения",
"eventTriggers": "Триггеры",
"eventTriggers": "Триггеры событий",
"logScanRules": "Лог-правила",
"triggers": "Триггеры",
"proxies": "Прокси",
@@ -23,7 +23,13 @@
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи",
"containers": "Контейнеры"
"containers": "Контейнеры",
"sectionObserve": "Наблюдение",
"sectionSystem": "Система",
"closeSidebar": "Закрыть боковую панель",
"openSidebar": "Открыть боковую панель",
"quickNavTitle": "Нажмите «g», затем букву для перехода между разделами",
"quickNavLabel": "быстрая навигация"
},
"dashboard": {
"title": "Панель управления",
@@ -42,7 +48,11 @@
"systemHealth": "Состояние системы",
"daemons": "Демоны",
"systemResources": "Системные ресурсы",
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей"
"systemResourcesSubtitle": "CPU, память, диск и топ потребителей",
"statSubWorkloads": "нагрузки →",
"statSubRunning": "запущено",
"statSubNeedAttention": "требует внимания",
"statSubStale": "устаревшие →"
},
"resources": {
"cpuCores": "Ядра CPU",
@@ -237,6 +247,7 @@
"deleteFailed": "Не удалось удалить реестр",
"testFailed": "Тест подключения не удался",
"loadFailed": "Не удалось загрузить реестры",
"deleteTitle": "Удалить реестр?",
"deleteConfirm": "Удалить реестр «{name}»? Это действие необратимо.",
"healthChecking": "Проверка...",
"healthConnected": "Подключено",
@@ -354,6 +365,7 @@
"createFailed": "Не удалось создать пользователя",
"deleteFailed": "Не удалось удалить пользователя",
"deleteConfirm": "Вы уверены, что хотите удалить этого пользователя?",
"deleteConfirmMessage": "Удалить пользователя «{username}»? Это действие необратимо.",
"usernameRequired": "Имя пользователя и пароль обязательны",
"networkError": "Ошибка сети",
"password": "Пароль"
@@ -400,6 +412,9 @@
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"close": "Закрыть",
"toggle": "Переключить",
"dismissNotification": "Закрыть уведомление",
"delete": "Удалить",
"edit": "Изменить",
"change": "Изменить",
@@ -429,6 +444,7 @@
"missing": "Отсутствует"
},
"containers": {
"eyebrowSuffix": "ГЛОБАЛЬНО",
"errLoad": "Не удалось загрузить контейнеры",
"searchPlaceholder": "Поиск по нагрузке, роли, образу, поддомену…",
"kindFilterLabel": "Тип нагрузки",
@@ -476,6 +492,7 @@
},
"stale": {
"title": "Устаревшие контейнеры",
"eyebrowSuffix": "УСТАРЕВШИЕ",
"noStale": "Нет устаревших контейнеров",
"noStaleDesc": "Все контейнеры исправны и работают.",
"cleanup": "Очистить",
@@ -541,13 +558,13 @@
"unavailable": "Статистика недоступна"
},
"systemHealth": {
"title": "Состояние системы",
"containers": "Контейнеры",
"proxies": "Прокси",
"recentErrors": "Недавние ошибки"
},
"daemons": {
"title": "Демоны",
"notReachable": "{provider} недоступен.",
"refresh": "Обновить",
"refreshing": "Обновление",
"docker": "Docker Engine",
@@ -1110,6 +1127,10 @@
"image": "Ссылка на образ",
"imagePlaceholder": "registry.example.com/owner/app",
"imageHint": "Полная ссылка на образ без тега — Tinyforge ловит новые теги, выкладываемые под этой ссылкой.",
"browseImages": "Выбрать",
"browseImagesHint": "Выберите образ из настроенного реестра вместо ручного ввода ссылки.",
"browseImagesTitle": "Выбор образа",
"browseImagesSearch": "Поиск образов…",
"tagPattern": "Шаблон тега",
"tagPatternPlaceholder": "*",
"tagPatternHint": "Glob path.Match (например, v*, release-*). * совпадает с любым тегом.",
@@ -1122,6 +1143,9 @@
"branch": "Ветка",
"branchPlaceholder": "main",
"branchHint": "Только push'и, продвигающие эту ветку, дёргают триггер.",
"branchPattern": "Шаблон ветки (preview-деплои)",
"branchPatternPlaceholder": "feat/* или * для любой ветки",
"branchPatternHint": "Если задан, любой push в подходящую ветку создаёт отдельный preview-деплой. Оставьте пустым, чтобы выключить.",
"manualNote": "У ручных триггеров нет конфига. Они срабатывают только через кнопку Deploy на странице нагрузки или POST /workloads/{id}/deploy.",
"scheduleNote": "Срабатывает по фиксированному интервалу, который ведёт внутренний планировщик Tinyforge. Внешний webhook не нужен — включите его ниже только если CI тоже должен запускать триггер вручную.",
"intervalPresets": "Быстрые пресеты",
@@ -1186,6 +1210,14 @@
},
"new": {
"pageTitle": "Новое приложение · Tinyforge",
"wizard": {
"stepBasics": "Основное",
"stepConfigure": "Настройка",
"stepTrigger": "Триггер",
"stepReview": "Обзор",
"next": "Далее",
"back": "Назад"
},
"backLabel": "К приложениям",
"eyebrowSuffix": "НОВОЕ ПРИЛОЖЕНИЕ",
"title": "Создать приложение",
@@ -1198,6 +1230,7 @@
"alertTag": "ОШ",
"fieldName": "Имя",
"fieldNameRequired": "ОБЯЗАТЕЛЬНО",
"fieldRequired": "Обязательно",
"fieldNamePlaceholder": "my-app",
"fieldNameHint": "В нижнем регистре, без пробелов. Используется в именах контейнеров и поддоменах.",
"fieldSourcePlugin": "Source-плагин",
@@ -1207,7 +1240,7 @@
"fieldConfigYaml": "YAML",
"fieldConfigForm": "ФОРМА",
"fieldConfigJson": "JSON",
"advancedJson": "Расширенный JSON",
"advancedJson": "Редактировать JSON",
"backToForm": "К форме",
"resetSample": "Сбросить к примеру",
"switchToJsonTitle": "Переключиться на сырой JSON-редактор",
@@ -1230,11 +1263,21 @@
"imageRegistryLabel": "Реестр (для приватных pull-ов)",
"imageRegistryPublic": "(публичный — без авторизации)",
"imageRegistryHint": "Имя должно совпадать с записью на странице «Реестры». Оставьте пустым для публичных образов.",
"imageCpu": "Лимит CPU (ядра, 0 = ∞)",
"imageMemory": "Лимит памяти (МБ, 0 = ∞)",
"imageCpu": "Лимит CPU",
"imageCpuHint": "Ядра, 0 = ∞",
"imageMemory": "Лимит памяти",
"imageMemoryHint": "МБ, 0 = ∞",
"imageMax": "Макс. инстансов",
"imageMaxHint": "1 = строгий blue-green.",
"imageFoot": "Переменные окружения и тома задаются в отдельных панелях на странице нагрузки после создания.",
"dockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
"dockerfileBuildEyebrow": "сборка · dockerfile",
"dockerfileContextPath": "Контекст сборки",
"dockerfileContextPathPlaceholder": "(пусто = корень репо)",
"dockerfilePath": "Путь к Dockerfile",
"dockerfilePort": "Порт контейнера",
"dockerfilePortRequired": "Укажите порт, который слушает приложение (1–65535).",
"dockerfileFoot": "Tinyforge склонирует репо, соберёт образ из Dockerfile и запустит контейнер. Переменные окружения и тома — на странице нагрузки после создания.",
"staticHeader": "static-источник · страницы из репозитория",
"staticProvider": "Провайдер",
"staticBaseUrl": "Base URL",
@@ -1262,7 +1305,7 @@
"staticTestConnection": "Проверить соединение",
"staticConnectionOk": "Соединение установлено",
"staticConnectionFailed": "Ошибка соединения: {error}",
"staticBrowseRepos": "Выбрать репозиторий",
"staticBrowseRepos": "Обзор",
"staticBrowseBranches": "Выбрать ветку",
"staticBrowseFolders": "Выбрать папку",
"staticPickerRepoTitle": "Выбор репозитория",
@@ -1275,6 +1318,7 @@
"staticTreeEmpty": "В этой ветке нет папок.",
"staticDenoAutoDetected": "Обнаружена папка <code>api/</code> — режим автоматически переключён на Deno.",
"imageConflictTag": "ОБРАЗ УЖЕ ИСПОЛЬЗУЕТСЯ",
"imageConflictChecking": "Проверка конфликтов…",
"imageConflictHeading": "Этот образ уже используется в {count} нагрузке(ах):",
"imageConflictOpenBtn": "Открыть",
"imageConflictAcknowledgeNote": "Если это намеренно (например, отдельный этап), нажмите «Создать» ещё раз для продолжения.",
@@ -1300,21 +1344,23 @@
"submit": "Создать приложение",
"submitting": "Создание…",
"submitAnyway": "Всё равно создать",
"unsavedChanges": "В этом приложении есть несохранённые изменения. Покинуть страницу, не создавая его?",
"unsavedChangesTitle": "Несохранённые изменения",
"unsavedChangesConfirm": "Покинуть",
"errors": {
"detectionFailed": "Не удалось определить провайдера.",
"connectionFailed": "Ошибка соединения.",
"reposFailed": "Не удалось загрузить репозитории.",
"branchesFailed": "Не удалось загрузить ветки.",
"treeFailed": "Не удалось загрузить дерево папок.",
"detectionFailed": "Не удалось определить Git-провайдера по этому URL. Проверьте, что базовый URL верен и доступен.",
"connectionFailed": "Не удалось подключиться к репозиторию. Проверьте URL провайдера, владельца/репозиторий и токен доступа (для приватных репозиториев).",
"reposFailed": "Не удалось получить список репозиториев. Проверьте базовый URL и токен доступа.",
"branchesFailed": "Не удалось получить список веток. Проверьте репозиторий и токен доступа.",
"treeFailed": "Не удалось загрузить дерево папок. Проверьте репозиторий, ветку и токен доступа.",
"sourceConfigInvalid": "source_config не является корректным JSON.",
"triggerBindUnknown": "неизвестная ошибка",
"createFailed": "Не удалось создать нагрузку.",
"inspectFailed": "Не удалось проинспектировать образ."
"inspectFailed": "Не удалось проинспектировать образ — убедитесь, что он скачан локально и ссылка указана верно."
},
"imageInspect": "Инспектировать",
"imageInspectHint": "Подставляет порт и healthcheck из образа, чтобы не вводить вручную.",
"imageInspectOk": "Готово — порт и healthcheck подставлены.",
"imageInspectError": "Ошибка инспекции: {error}",
"triggers": {
"section": "Триггер",
"sectionSub": "Необязательно. Выберите, откуда придёт сигнал передеплоя — слежение за реестром, git-событие или ручная кнопка.",
@@ -1334,6 +1380,18 @@
"pickWebhookOn": "ВЕБХУК ВКЛ",
"skippedNote": "Триггер не будет привязан. Добавьте его из панели «Триггеры» в карточке приложения после создания.",
"bindError": "Приложение создано, но привязка триггера не удалась: {error}. Откройте панель «Триггеры» в карточке, чтобы повторить."
},
"manifest": {
"title": "Манифест",
"name": "Имя",
"source": "Источник",
"trigger": "Триггер",
"publicFace": "Публичный фронт",
"unnamed": "(без имени)",
"registryPublic": "публичный реестр",
"folderRoot": "корень",
"triggerManual": "Только вручную",
"internalOnly": "Только внутренний"
}
},
"detail": {
@@ -1365,6 +1423,40 @@
"unavailable": "Не удалось получить размер (контейнер мог быть остановлен).",
"loading": "Вычисление размера…"
},
"buildLog": {
"title": "Журнал сборки",
"sub": "Живой поток вывода сборки Docker.",
"clear": "Очистить"
},
"notifications": {
"title": "Маршруты уведомлений",
"sub": "Множественные точки доставки для событий деплоя/сборки. При пустом списке используется устаревший единственный URL.",
"loading": "Загрузка маршрутов…",
"empty": "Нет настроенных маршрутов уведомлений. Добавьте, чтобы получать события в отдельный канал.",
"addFirst": "Добавить первый маршрут",
"add": "Добавить маршрут",
"edit": "Изменить",
"delete": "Удалить",
"name": "Имя",
"namePlaceholder": "Slack #alerts",
"url": "URL вебхука",
"secret": "Секрет подписи",
"secretPlaceholder": "Опционально — приёмник проверяет HMAC",
"secretEditPlaceholder": "Оставьте пустым, чтобы сохранить текущий секрет",
"secretHint": "HMAC-SHA256 от тела запроса, заголовок X-Hub-Signature-256.",
"eventTypes": "Типы событий",
"eventTypesPlaceholder": "deploy_failure,build_failure (пусто = все)",
"eventTypesHint": "Список через запятую. Пусто — маршрут срабатывает на любое событие.",
"enabled": "Включён",
"save": "Сохранить",
"saving": "Сохранение…",
"cancel": "Отмена",
"allEvents": "все события",
"signed": "подписан",
"disabled": "выключен",
"confirmDeleteTitle": "Удалить маршрут уведомлений?",
"confirmDeleteMessage": "Маршрут перестанет срабатывать. Устаревший URL уведомлений на workload (если задан) снова возьмёт события на себя."
},
"toolbar": {
"stop": "Стоп",
"start": "Старт",
@@ -1455,6 +1547,19 @@
"editStaticModeDenoDesc": "— Deno-рантайм с динамической маршрутизацией.",
"editStaticRenderMarkdown": "Рендерить markdown",
"editStaticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> как HTML.",
"editDockerfileHeader": "dockerfile-источник · сборка из git-репозитория",
"editDockerfileBuildEyebrow": "сборка · dockerfile",
"editDockerfileContextPath": "Контекст сборки",
"editDockerfileContextPathPlaceholder": "(пусто = корень репо)",
"editDockerfilePath": "Путь к Dockerfile",
"editDockerfilePort": "Порт контейнера",
"editTestConnection": "Проверить соединение",
"editTestConnectionOk": "Соединение установлено",
"editTestConnectionFailed": "Ошибка соединения: {error}",
"editTestConnectionUnknownError": "Неизвестная ошибка",
"overrideKeyUnitSingular": "КЛЮЧ",
"overrideKeyUnitPlural": "КЛЮЧИ",
"editTestConnectionIncomplete": "Заполните провайдера, base URL, owner и name.",
"editSourceJsonHeader": "source_config.json",
"editSourceJsonAria": "Конфигурация source-плагина (JSON)",
"editPublicFaces": "Публичные фронты",
@@ -1494,6 +1599,29 @@
"chainPromoteButton": "Продвинуть от родителя",
"chainPromoting": "Продвижение…",
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
"previews": {
"title": "Превью-окружения",
"subEmpty": "нет активных превью",
"subCountOne": "1 активное превью",
"subCount": "активных превью: {count}",
"tag": "Превью",
"tagTitle": "Превью-развёртывание этой нагрузки для отдельной ветки",
"armedEmpty": "Нет активных превью — отправьте пуш в ветку, соответствующую шаблону",
"noneEmpty": "Пока нет активных превью.",
"open": "Открыть",
"noUrl": "нет публичного URL",
"teardown": "Удалить",
"teardownTitle": "Удалить превью?",
"teardownMessage": "Это удалит превью для ветки «{name}» вместе с его контейнерами и маршрутами прокси. Новый пуш в эту ветку создаст его заново.",
"teardownConfirm": "Удалить",
"teardownPending": "Удаление…",
"teardownFailed": "Не удалось удалить",
"stateRunning": "Работает",
"statePending": "Запускается",
"stateStopped": "Остановлено",
"stateUnknown": "Неизвестно",
"hint": "Превью создаются автоматически, когда пуш приходит в ветку, соответствующую <code>branch_pattern</code> git-триггера, и удаляются при удалении ветки. Каждое получает собственный поддомен с префиксом-слагом."
},
"volumesTitle": "Тома",
"volumesEmpty": "Нет монтирований",
"volumesCountSingular": "{count} монтирование",
+37 -3
View File
@@ -9,7 +9,7 @@ import { getAuthToken } from './auth';
// ── Types ──────────────────────────────────────────────────────────
export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log';
export type SSEEventType = 'deploy_log' | 'instance_status' | 'deploy_status' | 'event_log' | 'build_log';
export interface SSEEvent<T = unknown> {
type: SSEEventType;
@@ -47,7 +47,18 @@ export interface EventLogSSEPayload {
created_at: string;
}
type SSEPayload = DeployLogPayload | InstanceStatusPayload | DeployStatusPayload | EventLogSSEPayload;
export interface BuildLogPayload {
workload_id: string;
line: string;
stream?: string;
}
type SSEPayload =
| DeployLogPayload
| InstanceStatusPayload
| DeployStatusPayload
| EventLogSSEPayload
| BuildLogPayload;
export interface SSEOptions {
/** Called for each SSE event received. */
@@ -123,6 +134,16 @@ export function connectSSE(url: string, options: SSEOptions): SSEConnection {
if (closed) return;
// Defensive clear: onerror can fire multiple times in quick
// succession during a network flap, each call would otherwise
// queue an additional reconnect and abandon the prior
// EventSource without closing it. Cancel any pending retry
// before scheduling a fresh one.
if (retryTimeout !== null) {
clearTimeout(retryTimeout);
retryTimeout = null;
}
retryCount++;
onError?.(retryCount);
@@ -168,10 +189,21 @@ export function connectGlobalEvents(callbacks: {
onInstanceStatus?: (payload: InstanceStatusPayload) => void;
onDeployStatus?: (payload: DeployStatusPayload) => void;
onEventLog?: (payload: EventLogSSEPayload) => void;
onBuildLog?: (payload: BuildLogPayload) => void;
onOpen?: () => void;
onError?: (attempt: number) => void;
/**
* Opt in to build-log frames for a single workload. Build logs are
* high-volume; the server only streams them to connections that pass
* this, so a verbose build can't flood every dashboard connection.
* Omit it on connections that don't render build output.
*/
buildLogWorkloadId?: string;
}): SSEConnection {
return connectSSE('/api/events', {
const url = callbacks.buildLogWorkloadId
? `/api/events?workload_id=${encodeURIComponent(callbacks.buildLogWorkloadId)}`
: '/api/events';
return connectSSE(url, {
onEvent(event) {
if (event.type === 'instance_status') {
callbacks.onInstanceStatus?.(event.payload as InstanceStatusPayload);
@@ -179,6 +211,8 @@ export function connectGlobalEvents(callbacks: {
callbacks.onDeployStatus?.(event.payload as DeployStatusPayload);
} else if (event.type === 'event_log') {
callbacks.onEventLog?.(event.payload as EventLogSSEPayload);
} else if (event.type === 'build_log') {
callbacks.onBuildLog?.(event.payload as BuildLogPayload);
}
},
onOpen: callbacks.onOpen,
+48 -18
View File
@@ -5,17 +5,32 @@
───────────────────────────────────────────────────────────────────── */
:root {
/* ── Brand Colors ───────────────────────────────────── */
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-200: #c7d2fe;
--color-brand-300: #a5b4fc;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
--color-brand-800: #3730a3;
--color-brand-900: #312e81;
/* ── Brand Colors ─────────────────────────────────────
"Forge" identity: amber / ember hues matching the
industrial control-room aesthetic. Indigo was a poor
fit for a product called Tinyforge — these tokens are
amber 50-900 with a slight orange shift on the 500-700
range so the active accent reads as molten metal rather
than playful pastel. */
--color-brand-50: #fff8eb;
--color-brand-100: #fdebcb;
--color-brand-200: #fbd591;
--color-brand-300: #f8b955;
--color-brand-400: #f59e0b;
--color-brand-500: #d97706;
--color-brand-600: #b45309;
--color-brand-700: #92400e;
--color-brand-800: #78350f;
--color-brand-900: #451a03;
/* Forge ember accent — used directly by forge-ember CSS
class and the highlight ring on hover states. Distinct
token so a future rebrand can shift the accent without
re-touching every consumer of --color-brand-*. */
--forge-ember: #ea580c;
--forge-ember-deep: #c2410c;
--forge-anvil: #1c1917;
--forge-spark: #fed7aa;
/* ── Semantic Colors ────────────────────────────────── */
--color-success: #16a34a;
@@ -45,10 +60,15 @@
--border-focus: var(--color-brand-500);
--border-input: #cbd5e1;
/* ── Text Colors ────────────────────────────────────── */
/* ── Text Colors ──────────────────────────────────────
Tertiary darkened from #94a3b8 (3.4:1 on #f8fafc — fails
WCAG AA) to #64748b (4.6:1 — AA-compliant). The old hue
is kept as --text-tertiary-soft for non-text decorations
(rule dots, separators) where contrast is not a concern. */
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--text-tertiary: #64748b;
--text-tertiary-soft: #94a3b8;
--text-inverse: #ffffff;
--text-link: var(--color-brand-600);
--text-link-hover: var(--color-brand-700);
@@ -66,9 +86,15 @@
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* ── Typography Scale ───────────────────────────────── */
--font-family-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-family-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
/* ── Typography Scale ─────────────────────────────────
System UI stack as the default: matches the OS, costs
zero bytes, and reads as a tool rather than a marketing
site. Inter remains as a fallback hint for installs that
have it (downloaded for an earlier theme) but is no
longer first-class. Monospace stays JetBrains Mono for
the code feel — operators read a lot of SHAs. */
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Inter', 'Helvetica Neue', Arial, sans-serif;
--font-family-mono: ui-monospace, 'JetBrains Mono', SFMono-Regular, 'Cascadia Code', Menlo, Consolas, monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
@@ -128,8 +154,12 @@
--border-input: #475569;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
/* Dark mode: secondary darkened to #94a3b8 (4.7:1 on #1e293b),
tertiary held at #94a3b8 too — same hue but used on darker
surfaces. The legacy #64748b on #1e293b was 3.2:1, failing AA. */
--text-secondary: #cbd5e1;
--text-tertiary: #94a3b8;
--text-tertiary-soft: #64748b;
--text-inverse: #0f172a;
--text-link: var(--color-brand-400);
--text-link-hover: var(--color-brand-300);
+14 -4
View File
@@ -111,19 +111,29 @@ export interface ApiEnvelope<T> {
error?: string;
}
/** Response shape for POST /api/deploy/inspect */
/** Response shape for POST /api/discovery/image/inspect */
export interface InspectResult {
image: string;
port: number;
healthcheck: string;
}
/** Item for the EntityPicker command-palette component. */
/**
* Item for the EntityPicker command-palette component.
*
* `icon` was historically typed as `string` and rendered via `{@html}` —
* which made it a potential stored-XSS sink the moment a caller built the
* value from any non-literal data. Narrowed to a controlled token union
* so every supported glyph is rendered through a known SVG path. Adding a
* new glyph requires a code change here AND a render-branch in
* EntityPicker.svelte — keep them in sync.
*/
export type EntityPickerIcon = 'lock' | 'box' | 'folder' | 'branch';
export interface EntityPickerItem {
value: string;
label: string;
description?: string;
icon?: string;
icon?: EntityPickerIcon;
group?: string;
disabled?: boolean;
disabledHint?: string;
+276
View File
@@ -0,0 +1,276 @@
import { describe, it, expect } from 'vitest';
import {
emptyImageState,
emptyComposeState,
emptyStaticState,
emptyDockerfileState,
seedImageState,
seedComposeState,
seedStaticState,
seedDockerfileState,
imageToConfig,
composeToConfig,
staticToConfig,
dockerfileToConfig,
stringifyConfig,
isImageValid,
isComposeValid,
isStaticValid,
isDockerfileValid
} from './sourceForms';
describe('image source', () => {
it('seeds defaults from empty/malformed JSON', () => {
expect(seedImageState('{}')).toEqual(emptyImageState());
expect(seedImageState('not json')).toEqual(emptyImageState());
expect(seedImageState('[]')).toEqual(emptyImageState());
expect(seedImageState('42')).toEqual(emptyImageState());
});
it('seeds populated fields', () => {
const json = JSON.stringify({
image: 'nginx',
port: 8080,
healthcheck: '/healthz',
default_tag: 'stable',
registry_name: 'docker.io',
cpu_limit: 2,
memory_limit: 512,
max_instances: 3
});
expect(seedImageState(json)).toEqual({
ref: 'nginx',
port: 8080,
healthcheck: '/healthz',
defaultTag: 'stable',
registryName: 'docker.io',
cpuLimit: 2,
memoryLimit: 512,
maxInstances: 3
});
});
it('serializes to the exact source_config shape and key order', () => {
const config = imageToConfig(emptyImageState(), '{}');
expect(Object.keys(config)).toEqual([
'image',
'registry_name',
'port',
'healthcheck',
'env',
'volumes',
'cpu_limit',
'memory_limit',
'default_tag',
'max_instances'
]);
expect(config).toEqual({
image: '',
registry_name: '',
port: 0,
healthcheck: '',
env: {},
volumes: [],
cpu_limit: 0,
memory_limit: 0,
default_tag: 'latest',
max_instances: 1
});
});
it('preserves env and volumes from the existing config', () => {
const existing = JSON.stringify({
image: 'old',
env: { FOO: 'bar' },
volumes: [{ source: 'data', scope: 'named' }]
});
const config = imageToConfig({ ...emptyImageState(), ref: 'new' }, existing);
expect(config.env).toEqual({ FOO: 'bar' });
expect(config.volumes).toEqual([{ source: 'data', scope: 'named' }]);
expect(config.image).toBe('new');
});
it('round-trips state -> config -> state', () => {
const state = seedImageState(
JSON.stringify({ image: 'app', port: 3000, default_tag: 'v1', max_instances: 2 })
);
expect(seedImageState(stringifyConfig(imageToConfig(state, '{}')))).toEqual(state);
});
it('validity requires a non-empty image ref', () => {
expect(isImageValid(emptyImageState())).toBe(false);
expect(isImageValid({ ...emptyImageState(), ref: ' ' })).toBe(false);
expect(isImageValid({ ...emptyImageState(), ref: 'nginx' })).toBe(true);
});
});
describe('compose source', () => {
it('seeds defaults and populated fields', () => {
expect(seedComposeState('{}')).toEqual(emptyComposeState());
expect(
seedComposeState(JSON.stringify({ compose_yaml: 'services: {}', compose_project_name: 'app' }))
).toEqual({ yaml: 'services: {}', projectName: 'app' });
});
it('serializes to the exact shape', () => {
const config = composeToConfig({ yaml: 'x', projectName: 'p' });
expect(Object.keys(config)).toEqual(['compose_yaml', 'compose_project_name']);
expect(config).toEqual({ compose_yaml: 'x', compose_project_name: 'p' });
});
it('validity requires non-empty yaml', () => {
expect(isComposeValid(emptyComposeState())).toBe(false);
expect(isComposeValid({ yaml: 'services: {}', projectName: '' })).toBe(true);
});
});
describe('static source', () => {
it('seeds defaults, normalizing provider and branch', () => {
expect(seedStaticState('{}')).toEqual(emptyStaticState());
// unknown provider -> gitea; empty branch -> main
expect(seedStaticState(JSON.stringify({ provider: 'bogus', branch: '' }))).toEqual(
emptyStaticState()
);
expect(seedStaticState(JSON.stringify({ provider: 'github' })).provider).toBe('github');
expect(seedStaticState(JSON.stringify({ mode: 'deno' })).mode).toBe('deno');
});
it('serializes to the exact shape and key order', () => {
const config = staticToConfig(emptyStaticState(), '{}');
expect(Object.keys(config)).toEqual([
'provider',
'base_url',
'repo_owner',
'repo_name',
'branch',
'folder_path',
'access_token',
'mode',
'render_markdown'
]);
expect(config.branch).toBe('main');
});
it('preserves storage_* keys only when present', () => {
const withStorage = staticToConfig(
emptyStaticState(),
JSON.stringify({ storage_enabled: true, storage_limit_mb: 100 })
);
expect(withStorage.storage_enabled).toBe(true);
expect(withStorage.storage_limit_mb).toBe(100);
const without = staticToConfig(emptyStaticState(), '{}');
expect('storage_enabled' in without).toBe(false);
expect('storage_limit_mb' in without).toBe(false);
});
it('round-trips a populated state', () => {
const state = seedStaticState(
JSON.stringify({
provider: 'gitlab',
base_url: 'https://gl.example',
repo_owner: 'me',
repo_name: 'site',
branch: 'dev',
folder_path: 'public',
access_token: 'secret',
mode: 'deno',
render_markdown: true
})
);
expect(seedStaticState(stringifyConfig(staticToConfig(state, '{}')))).toEqual(state);
});
it('validity requires base_url + owner + repo', () => {
expect(isStaticValid(emptyStaticState())).toBe(false);
expect(
isStaticValid({
...emptyStaticState(),
baseURL: 'https://x',
repoOwner: 'o',
repoName: 'r'
})
).toBe(true);
});
});
describe('dockerfile source', () => {
it('seeds defaults, defaulting dockerfile_path to Dockerfile', () => {
expect(seedDockerfileState('{}')).toEqual(emptyDockerfileState());
expect(seedDockerfileState(JSON.stringify({ dockerfile_path: '' })).dockerfilePath).toBe(
'Dockerfile'
);
expect(seedDockerfileState(JSON.stringify({ dockerfile_path: 'docker/Dockerfile' })).dockerfilePath).toBe(
'docker/Dockerfile'
);
});
it('serializes to the exact shape and key order', () => {
const config = dockerfileToConfig(emptyDockerfileState(), '{}');
expect(Object.keys(config)).toEqual([
'provider',
'base_url',
'repo_owner',
'repo_name',
'branch',
'access_token',
'context_path',
'dockerfile_path',
'port'
]);
expect(config.dockerfile_path).toBe('Dockerfile');
expect(config.branch).toBe('main');
expect(config.port).toBe(0);
});
it('preserves unknown keys but scrubs static-only keys', () => {
const existing = JSON.stringify({
// unknown key the operator added via raw JSON -> preserved
healthcheck: '/up',
cpu_limit: 1,
// static-only leftovers from a static->dockerfile switch -> scrubbed
folder_path: 'public',
mode: 'deno',
render_markdown: true,
storage_enabled: true,
storage_limit_mb: 50
});
const config = dockerfileToConfig(emptyDockerfileState(), existing);
expect(config.healthcheck).toBe('/up');
expect(config.cpu_limit).toBe(1);
expect('folder_path' in config).toBe(false);
expect('mode' in config).toBe(false);
expect('render_markdown' in config).toBe(false);
expect('storage_enabled' in config).toBe(false);
expect('storage_limit_mb' in config).toBe(false);
});
it('round-trips a populated state', () => {
const state = seedDockerfileState(
JSON.stringify({
provider: 'github',
base_url: 'https://gh.example',
repo_owner: 'me',
repo_name: 'svc',
branch: 'main',
access_token: 't',
context_path: 'backend',
dockerfile_path: 'backend/Dockerfile',
port: 8000
})
);
expect(seedDockerfileState(stringifyConfig(dockerfileToConfig(state, '{}')))).toEqual(state);
});
it('validity requires git fields + a positive port', () => {
const base = {
...emptyDockerfileState(),
baseURL: 'https://x',
repoOwner: 'o',
repoName: 'r'
};
expect(isDockerfileValid(base)).toBe(false); // port 0
expect(isDockerfileValid({ ...base, port: 8080 })).toBe(true);
expect(isDockerfileValid({ ...base, port: -1 })).toBe(false);
});
});
+353
View File
@@ -0,0 +1,353 @@
/**
* Shared source-config form model for the four workload Source kinds
* (image / compose / static / dockerfile).
*
* Before this module the seed (JSON -> form fields) and serialize
* (form fields -> source_config JSON) logic lived inline and DUPLICATED
* verbatim in both `routes/apps/new/+page.svelte` and
* `routes/apps/[id]/+page.svelte`. A drift between the two silently
* changes the `source_config` shape the backend stores, which breaks
* deploys. This module is the single source of truth so the create
* wizard and the detail-page edit form serialize identically.
*
* The functions are pure (no Svelte runes, no DOM) so they unit-test in
* a plain node environment. Components hold the state objects as `$state`
* and call these to seed / serialize.
*
* Fidelity contract: output key order, defaults, and the preserve/scrub
* behaviour below MUST match the legacy inline helpers exactly. Tests in
* `sourceForms.test.ts` lock the shapes.
*/
export type GitProvider = 'gitea' | 'github' | 'gitlab';
/** Image source: deploy a pre-built image from a registry. */
export interface ImageFormState {
ref: string;
port: number;
healthcheck: string;
defaultTag: string;
registryName: string;
cpuLimit: number;
memoryLimit: number;
maxInstances: number;
}
/** Compose source: a docker-compose stack. */
export interface ComposeFormState {
yaml: string;
projectName: string;
}
/**
* Git-discovery fields shared by the static and dockerfile sources —
* both clone a repo via the same provider/owner/repo/branch/token path.
* Extracted so a single discovery component can bind this slice of
* either form.
*/
export interface GitSourceState {
provider: GitProvider;
baseURL: string;
repoOwner: string;
repoName: string;
branch: string;
accessToken: string;
}
/** Static source: serve files (optionally Deno) from a repo folder. */
export interface StaticFormState extends GitSourceState {
folderPath: string;
mode: 'static' | 'deno';
renderMarkdown: boolean;
}
/** Dockerfile source: build an image from a Dockerfile in a repo. */
export interface DockerfileFormState extends GitSourceState {
contextPath: string;
dockerfilePath: string;
port: number;
}
// ── Defaults ────────────────────────────────────────────────────────
export function emptyImageState(): ImageFormState {
return {
ref: '',
port: 0,
healthcheck: '',
defaultTag: 'latest',
registryName: '',
cpuLimit: 0,
memoryLimit: 0,
maxInstances: 1
};
}
export function emptyComposeState(): ComposeFormState {
return { yaml: '', projectName: '' };
}
function emptyGitSourceState(): GitSourceState {
return {
provider: 'gitea',
baseURL: '',
repoOwner: '',
repoName: '',
branch: 'main',
accessToken: ''
};
}
export function emptyStaticState(): StaticFormState {
return { ...emptyGitSourceState(), folderPath: '', mode: 'static', renderMarkdown: false };
}
export function emptyDockerfileState(): DockerfileFormState {
return { ...emptyGitSourceState(), contextPath: '', dockerfilePath: 'Dockerfile', port: 0 };
}
// ── Parse helpers ───────────────────────────────────────────────────
/** Parse to an object for seeding; malformed / non-object JSON -> {}. */
function parseObject(jsonText: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(jsonText);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// fall through
}
return {};
}
/** Parse for preserve helpers; malformed JSON -> null (caller guards). */
function tryParse(jsonText: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(jsonText);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
// fall through
}
return null;
}
function strOr(value: unknown, fallback: string): string {
return typeof value === 'string' ? value : fallback;
}
/** Non-empty string or fallback (matches legacy `typeof x === 'string' && x ? x : d`). */
function strOrTruthy(value: unknown, fallback: string): string {
return typeof value === 'string' && value ? value : fallback;
}
function numOr(value: unknown, fallback: number): number {
return typeof value === 'number' ? value : fallback;
}
function normProvider(value: unknown): GitProvider {
return value === 'github' || value === 'gitlab' ? value : 'gitea';
}
// ── Seed: source_config JSON -> form state ──────────────────────────
export function seedImageState(jsonText: string): ImageFormState {
const o = parseObject(jsonText);
return {
ref: strOr(o.image, ''),
port: numOr(o.port, 0),
healthcheck: strOr(o.healthcheck, ''),
defaultTag: strOr(o.default_tag, 'latest'),
registryName: strOr(o.registry_name, ''),
cpuLimit: numOr(o.cpu_limit, 0),
memoryLimit: numOr(o.memory_limit, 0),
maxInstances: numOr(o.max_instances, 1)
};
}
export function seedComposeState(jsonText: string): ComposeFormState {
const o = parseObject(jsonText);
return {
yaml: strOr(o.compose_yaml, ''),
projectName: strOr(o.compose_project_name, '')
};
}
export function seedStaticState(jsonText: string): StaticFormState {
const o = parseObject(jsonText);
return {
provider: normProvider(o.provider),
baseURL: strOr(o.base_url, ''),
repoOwner: strOr(o.repo_owner, ''),
repoName: strOr(o.repo_name, ''),
branch: strOrTruthy(o.branch, 'main'),
accessToken: strOr(o.access_token, ''),
folderPath: strOr(o.folder_path, ''),
mode: o.mode === 'deno' ? 'deno' : 'static',
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false
};
}
export function seedDockerfileState(jsonText: string): DockerfileFormState {
const o = parseObject(jsonText);
return {
provider: normProvider(o.provider),
baseURL: strOr(o.base_url, ''),
repoOwner: strOr(o.repo_owner, ''),
repoName: strOr(o.repo_name, ''),
branch: strOrTruthy(o.branch, 'main'),
accessToken: strOr(o.access_token, ''),
contextPath: strOr(o.context_path, ''),
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
port: numOr(o.port, 0)
};
}
// ── Serialize: form state -> source_config object ───────────────────
/**
* Preserve `env` (object) and `volumes` (array) from an existing config
* — they're edited in dedicated detail-page panels, not the source form,
* and must survive a form round-trip.
*/
function preserveEnvVolumes(existingJson: string): {
env: Record<string, string>;
volumes: unknown[];
} {
const existing = tryParse(existingJson);
let env: Record<string, string> = {};
let volumes: unknown[] = [];
if (existing) {
if (existing.env && typeof existing.env === 'object') {
env = existing.env as Record<string, string>;
}
if (Array.isArray(existing.volumes)) {
volumes = existing.volumes;
}
}
return { env, volumes };
}
export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
const { env, volumes } = preserveEnvVolumes(existingJson);
return {
image: s.ref,
registry_name: s.registryName,
port: s.port,
healthcheck: s.healthcheck,
env,
volumes,
cpu_limit: s.cpuLimit,
memory_limit: s.memoryLimit,
default_tag: s.defaultTag,
max_instances: s.maxInstances
};
}
export function composeToConfig(s: ComposeFormState): Record<string, unknown> {
return { compose_yaml: s.yaml, compose_project_name: s.projectName };
}
export function staticToConfig(s: StaticFormState, existingJson: string): Record<string, unknown> {
const out: Record<string, unknown> = {
provider: s.provider,
base_url: s.baseURL,
repo_owner: s.repoOwner,
repo_name: s.repoName,
branch: s.branch || 'main',
folder_path: s.folderPath,
access_token: s.accessToken,
mode: s.mode,
render_markdown: s.renderMarkdown
};
// Preserve storage_* keys set via the raw JSON editor (not yet surfaced
// as form controls) so a form round-trip doesn't silently drop them.
const existing = tryParse(existingJson);
if (existing) {
if (typeof existing.storage_enabled === 'boolean') out.storage_enabled = existing.storage_enabled;
if (typeof existing.storage_limit_mb === 'number') out.storage_limit_mb = existing.storage_limit_mb;
}
return out;
}
/**
* Keys the dockerfile form owns. Everything else in an existing config is
* preserved on round-trip EXCEPT the static-only keys (folder_path / mode
* / render_markdown / storage_*) which are deliberately scrubbed: after a
* static -> dockerfile switch they'd otherwise linger as dead keys and
* make the backend log "unknown field" noise on every save.
*/
const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
'provider',
'base_url',
'repo_owner',
'repo_name',
'branch',
'access_token',
'context_path',
'dockerfile_path',
'port',
'folder_path',
'mode',
'render_markdown',
'storage_enabled',
'storage_limit_mb'
]);
export function dockerfileToConfig(
s: DockerfileFormState,
existingJson: string
): Record<string, unknown> {
const preserved: Record<string, unknown> = {};
const existing = tryParse(existingJson);
if (existing) {
for (const [k, v] of Object.entries(existing)) {
if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
}
}
return {
provider: s.provider,
base_url: s.baseURL,
repo_owner: s.repoOwner,
repo_name: s.repoName,
branch: s.branch || 'main',
access_token: s.accessToken,
context_path: s.contextPath,
dockerfile_path: s.dockerfilePath || 'Dockerfile',
port: s.port || 0,
...preserved
};
}
/** Pretty-print a config object for the Advanced-JSON editor view. */
export function stringifyConfig(config: Record<string, unknown>): string {
return JSON.stringify(config, null, 2);
}
// ── Per-kind validity ───────────────────────────────────────────────
// Encodes the required fields per source kind. These back the wizard's
// step gating (replacing the prior opaque ~250-char boolean). Optional
// fields (folder_path, context_path, healthcheck, resource limits, ...)
// are intentionally not required here.
export function isImageValid(s: ImageFormState): boolean {
return s.ref.trim() !== '';
}
export function isComposeValid(s: ComposeFormState): boolean {
return s.yaml.trim() !== '';
}
function isGitSourceValid(s: GitSourceState): boolean {
return s.baseURL.trim() !== '' && s.repoOwner.trim() !== '' && s.repoName.trim() !== '';
}
export function isStaticValid(s: StaticFormState): boolean {
return isGitSourceValid(s);
}
export function isDockerfileValid(s: DockerfileFormState): boolean {
return isGitSourceValid(s) && typeof s.port === 'number' && Number.isFinite(s.port) && s.port > 0;
}
+54 -17
View File
@@ -25,27 +25,51 @@
type NavCountKey = 'apps' | 'workloads' | 'proxies' | 'containers' | 'eventsErrors';
// Navigation entries are now grouped into named sections. The
// renderer treats a `section:` marker entry as a visual divider with
// an uppercase eyebrow label, but otherwise renders items as before.
// Grouping the flat list (Events / Event Triggers / Log Rules sat
// next to Apps / Containers without any visual separation) was the
// biggest readability complaint from the earlier UI review.
type NavSection = 'build' | 'observe' | 'system';
const navItems: ReadonlyArray<{
href: string;
labelKey: string;
icon: string;
section: NavSection;
countKey?: NavCountKey;
/** When true the badge uses a danger style (red). */
alert?: boolean;
/** Static label override when the i18n catalogue does not yet carry the key. */
labelOverride?: string;
}> = [
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/apps', labelKey: 'nav.apps', icon: 'box', countKey: 'apps' },
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', labelOverride: 'Event Triggers' },
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', labelOverride: 'Log Rules' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard', section: 'build' },
{ href: '/apps', labelKey: 'nav.apps', icon: 'box', section: 'build', countKey: 'apps' },
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', section: 'build', countKey: 'containers' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', section: 'build', countKey: 'proxies' },
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy', section: 'build' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', section: 'observe', countKey: 'eventsErrors', alert: true },
{ href: '/event-triggers', labelKey: 'nav.eventTriggers', icon: 'events', section: 'observe' },
{ href: '/log-scan-rules', labelKey: 'nav.logScanRules', icon: 'events', section: 'observe' },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings', section: 'system' }
];
// sectionLabels: eyebrow text rendered above the first item of each
// section. `build` is left unlabelled — it's the default and adding
// an eyebrow above Dashboard would feel redundant.
// Localized via $t — $derived so a language switch re-renders the
// eyebrows. `build` stays unlabelled (see above).
const sectionLabels: Record<NavSection, string> = $derived({
build: '',
observe: $t('nav.sectionObserve'),
system: $t('nav.sectionSystem')
});
function sectionStart(idx: number): NavSection | null {
const cur = navItems[idx].section;
if (idx === 0) return cur;
const prev = navItems[idx - 1].section;
return cur !== prev ? cur : null;
}
function isActive(href: string, pathname: string): boolean {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
@@ -194,7 +218,7 @@
<button
class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
onclick={() => { sidebarOpen = false; }}
aria-label="Close sidebar"
aria-label={$t('nav.closeSidebar')}
>
<IconX size={20} />
</button>
@@ -269,8 +293,12 @@
<!-- Navigation -->
<nav class="flex-1 space-y-0.5 px-3 py-3">
{#each navItems as item}
{#each navItems as item, idx}
{@const active = isActive(item.href, $page.url.pathname)}
{@const newSection = sectionStart(idx)}
{#if newSection && sectionLabels[newSection]}
<div class="nav-section-eyebrow" aria-hidden="true">{sectionLabels[newSection]}</div>
{/if}
<a
href={item.href}
class="nav-item group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
@@ -291,7 +319,7 @@
{:else if item.icon === 'settings'}
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{/if}
<span class="nav-label">{item.labelOverride ?? $t(item.labelKey)}</span>
<span class="nav-label">{$t(item.labelKey)}</span>
{#if item.countKey}
{@const count = $navCounts[item.countKey]}
@@ -338,9 +366,9 @@
<span class="clock-suffix">{clockOffset}</span>
</span>
</div>
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
<p class="forge-nav-hint" title={$t('nav.quickNavTitle')}>
<kbd>g</kbd><span class="arr"></span><kbd>d</kbd><kbd>a</kbd><kbd>n</kbd><kbd>t</kbd>
<span class="hint-label">quick-nav</span>
<span class="hint-label">{$t('nav.quickNavLabel')}</span>
</p>
</div>
</aside>
@@ -352,7 +380,7 @@
<button
class="rounded-md p-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
onclick={() => { sidebarOpen = true; }}
aria-label="Open sidebar"
aria-label={$t('nav.openSidebar')}
>
<IconMenu size={22} />
</button>
@@ -550,6 +578,15 @@
flex: 1;
min-width: 0;
}
.nav-section-eyebrow {
margin: 0.85rem 0.25rem 0.25rem;
padding: 0 0.5rem;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
color: var(--text-tertiary);
}
.nav-active {
background: var(--surface-card-hover);
color: var(--text-primary) !important;
+12 -7
View File
@@ -70,7 +70,12 @@
return () => { loadController?.abort(); };
});
const totalWorkloads = $derived(workloads.length);
// Plugin-native workloads only. Legacy pre-cutover rows (kind project/
// stack/site) carry an empty source_kind and have no UI home post-cutover,
// so they must not inflate the headline count or the recent strip — this
// matches the /apps list, which shows the same set.
const pluginWorkloads = $derived(workloads.filter((w) => w.source_kind !== ''));
const totalWorkloads = $derived(pluginWorkloads.length);
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
const totalStale = $derived(staleContainers.length);
@@ -78,7 +83,7 @@
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
// recent-activity strip without paging the entire list.
const recentWorkloads = $derived(
[...workloads]
[...pluginWorkloads]
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
.slice(0, 6)
);
@@ -113,7 +118,7 @@
{/snippet}
<ForgeHero
eyebrow="THE FORGE"
eyebrowSuffix="DASHBOARD"
eyebrowSuffix={$t('nav.dashboard').toUpperCase()}
title={$t('dashboard.title')}
accent="."
size="lg"
@@ -125,22 +130,22 @@
<a href="/apps" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
<span class="forge-stat-sub">workloads</span>
<span class="forge-stat-sub">{$t('dashboard.statSubWorkloads')}</span>
</a>
<a href="/containers" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
<span class="forge-stat-sub">running</span>
<span class="forge-stat-sub">{$t('dashboard.statSubRunning')}</span>
</a>
<a href="/containers?state=failed" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
<span class="forge-stat-sub">need attention</span>
<span class="forge-stat-sub">{$t('dashboard.statSubNeedAttention')}</span>
</a>
<a href="/containers/stale" class="forge-stat stat-link">
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
<span class="forge-stat-sub">stale →</span>
<span class="forge-stat-sub">{$t('dashboard.statSubStale')}</span>
</a>
</div>
+6 -6
View File
@@ -11,12 +11,12 @@
let error = $state('');
let filter = $state<'all' | string>('all');
// Plugin-native rows are the ones with both source_kind and trigger_kind
// populated. Legacy project/stack/site rows still appear in
// /api/workloads — those are surfaced under their own sections.
const pluginRows = $derived(
workloads.filter((w) => w.source_kind !== '' && w.trigger_kind !== '')
);
// Plugin-native rows are those with a source_kind. trigger_kind is no
// longer on the workload row (triggers are first-class bindings now), so a
// manual/binding-trigger app legitimately has an empty trigger_kind and
// must NOT be filtered out. Legacy pre-cutover project/stack/site rows
// carry an empty source_kind and are excluded — they have no UI home.
const pluginRows = $derived(workloads.filter((w) => w.source_kind !== ''));
const filtered = $derived(
filter === 'all' ? pluginRows : pluginRows.filter((w) => w.source_kind === filter)
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -152,7 +152,7 @@
{/snippet}
<ForgeHero
backHref="/"
eyebrowSuffix="GLOBAL"
eyebrowSuffix={$t('containers.eyebrowSuffix')}
title={$t('nav.containers')}
size="lg"
toolbar={heroToolbar}
+1 -1
View File
@@ -104,7 +104,7 @@
{/snippet}
<ForgeHero
backHref="/"
eyebrowSuffix="STALE"
eyebrowSuffix={$t('stale.eyebrowSuffix')}
title={$t('stale.title')}
size="lg"
toolbar={heroToolbar}
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as api from '$lib/api';
@@ -145,7 +144,12 @@
testResult !== null && testResult.status_code >= 200 && testResult.status_code < 300
);
onMount(load);
// Reload when route id changes — see the apps/[id] page for the
// same rationale (SvelteKit reuses the component instance).
$effect(() => {
const _ = id;
load();
});
</script>
<svelte:head>
+1 -1
View File
@@ -227,7 +227,7 @@
{/if}
{/snippet}
<ForgeHero
eyebrowSuffix="EVENTS"
eyebrowSuffix={$t('nav.events').toUpperCase()}
title={$t('events.title')}
size="lg"
toolbar={heroToolbar}
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as api from '$lib/api';
@@ -152,7 +151,13 @@
return $t('logscan.scope.global');
}
onMount(load);
// Reload when route id changes — SvelteKit reuses the component
// instance across [id] transitions, so onMount alone would leave
// stale data when navigating between sibling pages.
$effect(() => {
const _ = id;
load();
});
</script>
<svelte:head>
+1 -1
View File
@@ -87,7 +87,7 @@
<div class="space-y-6">
<ForgeHero
eyebrowSuffix="PROXIES"
eyebrowSuffix={$t('nav.proxies').toUpperCase()}
title={$t('proxies.title')}
lede={$t('proxies.description')}
size="lg"
+1 -1
View File
@@ -77,7 +77,7 @@
<div class="mx-auto max-w-5xl">
<ForgeHero
eyebrowSuffix="SETTINGS"
eyebrowSuffix={$t('nav.settings').toUpperCase()}
title={$t('settings.title')}
size="lg"
/>
+31 -19
View File
@@ -5,30 +5,19 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import {
getAuthSettings,
updateAuthSettings,
listUsers as apiListUsers,
createUser,
deleteUser as apiDeleteUser,
ApiError
ApiError,
type AuthSettings,
type AuthUser
} from '$lib/api';
interface AuthSettings {
auth_mode: string;
oidc_client_id: string;
oidc_client_secret: string;
oidc_issuer_url: string;
oidc_redirect_url: string;
}
interface User {
id: string;
username: string;
email: string;
role: string;
created_at: string;
}
type User = AuthUser;
let loading = $state(true);
let settings = $state<AuthSettings>({
@@ -92,8 +81,21 @@
}
}
async function handleDeleteUser(id: string) {
if (!confirm($t('settingsAuth.deleteConfirm'))) return;
// Replace native window.confirm() with the project's ConfirmDialog so
// destructive user-delete matches the rest of the app's modal pattern
// (CLAUDE.md `feedback_secret_actions_use_dialog`).
let confirmDeleteUserId = $state<string | null>(null);
let confirmDeleteUsername = $state('');
function askDeleteUser(id: string, username: string) {
confirmDeleteUserId = id;
confirmDeleteUsername = username;
}
async function handleDeleteUser() {
const id = confirmDeleteUserId;
confirmDeleteUserId = null;
if (!id) return;
try {
await apiDeleteUser(id);
await loadUsers();
@@ -198,7 +200,7 @@
</td>
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
<td class="px-4 py-2.5 text-right">
<button onclick={() => handleDeleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}>
<button onclick={() => askDeleteUser(user.id, user.username)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}>
<IconTrash size={16} />
</button>
</td>
@@ -235,3 +237,13 @@
</div>
{/if}
</div>
<ConfirmDialog
open={confirmDeleteUserId !== null}
title={$t('settingsAuth.deleteConfirm')}
message={confirmDeleteUsername ? $t('settingsAuth.deleteConfirmMessage', { username: confirmDeleteUsername }) : ''}
confirmLabel={$t('common.delete')}
confirmVariant="danger"
onconfirm={handleDeleteUser}
oncancel={() => { confirmDeleteUserId = null; }}
/>
+5 -2
View File
@@ -101,7 +101,9 @@
async function openCertPicker() {
loadingCerts = true;
try {
const certs = await listNpmCertificates();
// Browse = force-refresh so the list reflects any certs added in NPM
// since the cached snapshot.
const certs = await listNpmCertificates(true);
if (certs.length === 0) { toasts.info($t('settingsGeneral.noCertificatesFound')); return; }
certPickerItems = certs.map((cert): EntityPickerItem => ({
value: String(cert.id),
@@ -132,7 +134,8 @@
async function openAccessListPicker() {
loadingAccessLists = true;
try {
const lists = await listNpmAccessLists();
// Browse = force-refresh (see openCertPicker).
const lists = await listNpmAccessLists(true);
if (lists.length === 0) { toasts.info($t('settingsNpm.noAccessLists')); return; }
accessListPickerItems = lists.map((al): EntityPickerItem => ({
value: String(al.id),
+8 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import * as api from '$lib/api';
@@ -257,7 +257,13 @@
return v === key ? k : v;
}
onMount(load);
// SvelteKit reuses this component instance across /triggers/A → /triggers/B,
// so onMount(load) would only fire once. The id-keyed effect reloads on
// param change.
$effect(() => {
const _ = id;
load();
});
</script>
<svelte:head>