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;