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;
}