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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
+648
-1766
File diff suppressed because it is too large
Load Diff
@@ -152,7 +152,7 @@
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
backHref="/"
|
||||
eyebrowSuffix="GLOBAL"
|
||||
eyebrowSuffix={$t('containers.eyebrowSuffix')}
|
||||
title={$t('nav.containers')}
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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; }}
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user