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
+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>