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