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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user