93b6911b34
Every deploy across all four source kinds now writes a workload-scoped
event via a shared plugin.EmitDeployEvent helper (replacing the inline
emit duplicated in static/dockerfile, standardizing static's metadata
key site_id->workload_id, and adding emission to image+compose which
were silent). New indexed event_log.workload_id column, EventLogFilter
.WorkloadID, and GET /api/workloads/{id}/events (id pinned from path).
Frontend: a forge "Activity" panel on /apps/[id] reusing EventLogEntry,
live SSE prepend filtered by workload_id, load-more pagination, an
All/Errors severity filter, and a shared toEventLogEntry mapper. en/ru
i18n parity.
Security: compose's failure status emits a generic reason instead of raw
`docker compose up` output, which can echo app secrets and egresses to
operator webhooks (NotificationURL + event-trigger actions); full detail
stays only in the returned error. Rune-safe 256-rune status cap.
Reviewed: go + typescript APPROVE; security HIGH fixed.
1378 lines
45 KiB
TypeScript
1378 lines
45 KiB
TypeScript
import type {
|
|
ApiEnvelope,
|
|
App,
|
|
Container,
|
|
ContainerStats,
|
|
ContainerStatsSample,
|
|
ContainerView,
|
|
SystemStats,
|
|
SystemStatsSample,
|
|
TopContainerSample,
|
|
DockerHealth,
|
|
ProxyHealth,
|
|
EventLogEntry,
|
|
EventLogStats,
|
|
InspectResult,
|
|
LocalImage,
|
|
NpmCertificate,
|
|
NpmAccessList,
|
|
ProxyRoute,
|
|
Registry,
|
|
RegistryImage,
|
|
Settings,
|
|
StaleContainer,
|
|
VolumeScopeInfo,
|
|
BrowseResult,
|
|
DnsZone,
|
|
DnsRecordView,
|
|
BackupInfo,
|
|
Workload,
|
|
WorkloadKind
|
|
} from './types';
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly status: number
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
import { getAuthToken, clearAuth } from './auth';
|
|
|
|
// ── Concurrency limiter ────────────────────────────────────────────
|
|
// Chrome allows only 6 concurrent HTTP/1.1 connections per host.
|
|
// Reserve slots for the persistent SSE stream and health checks by
|
|
// capping regular API requests to 4 concurrent.
|
|
const MAX_CONCURRENT = 4;
|
|
let inflight = 0;
|
|
const queue: Array<() => void> = [];
|
|
|
|
function acquireSlot(signal?: AbortSignal | null): Promise<void> {
|
|
if (inflight < MAX_CONCURRENT) {
|
|
inflight++;
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise<void>((resolve, reject) => {
|
|
// A queued waiter inherits the releasing request's slot, so it
|
|
// must not increment `inflight` again — `releaseSlot` skips the
|
|
// decrement when it hands the slot off, keeping the count stable.
|
|
const entry = () => { resolve(); };
|
|
queue.push(entry);
|
|
|
|
signal?.addEventListener('abort', () => {
|
|
const idx = queue.indexOf(entry);
|
|
if (idx !== -1) {
|
|
queue.splice(idx, 1);
|
|
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
|
}
|
|
}, { once: true });
|
|
});
|
|
}
|
|
|
|
function releaseSlot(): void {
|
|
const next = queue.shift();
|
|
if (next) {
|
|
next();
|
|
} else {
|
|
inflight--;
|
|
}
|
|
}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
// Write operations (user-initiated) bypass the concurrency limiter
|
|
// so they are never blocked behind background polling.
|
|
const method = init?.method?.toUpperCase() ?? 'GET';
|
|
if (method !== 'GET') {
|
|
return requestInner<T>(path, init);
|
|
}
|
|
await acquireSlot(init?.signal);
|
|
try {
|
|
return await requestInner<T>(path, init);
|
|
} finally {
|
|
releaseSlot();
|
|
}
|
|
}
|
|
|
|
async function requestInner<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const token = getAuthToken();
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(init?.headers as Record<string, string>)
|
|
};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const res = await fetch(path, {
|
|
...init,
|
|
headers
|
|
});
|
|
|
|
// Redirect to login on 401 (expired/missing token).
|
|
if (res.status === 401 && typeof window !== 'undefined' && !path.includes('/auth/')) {
|
|
clearAuth();
|
|
window.location.href = '/login';
|
|
throw new ApiError('Authentication required', 401);
|
|
}
|
|
|
|
let envelope: ApiEnvelope<T>;
|
|
try {
|
|
envelope = await res.json();
|
|
} catch {
|
|
throw new ApiError(
|
|
`Server returned non-JSON response (HTTP ${res.status})`,
|
|
res.status
|
|
);
|
|
}
|
|
|
|
if (!envelope.success) {
|
|
throw new ApiError(envelope.error ?? 'Unknown API error', res.status);
|
|
}
|
|
|
|
return envelope.data as T;
|
|
}
|
|
|
|
function get<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
return request<T>(path, signal ? { signal } : undefined);
|
|
}
|
|
|
|
function post<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
|
const init: RequestInit = {
|
|
method: 'POST',
|
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
};
|
|
if (signal) init.signal = signal;
|
|
return request<T>(path, init);
|
|
}
|
|
|
|
function put<T>(path: string, body: unknown): Promise<T> {
|
|
return request<T>(path, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body)
|
|
});
|
|
}
|
|
|
|
function del<T>(path: string): Promise<T> {
|
|
return request<T>(path, { method: 'DELETE' });
|
|
}
|
|
|
|
function patch<T>(path: string, body: unknown): Promise<T> {
|
|
return request<T>(path, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body)
|
|
});
|
|
}
|
|
|
|
// ── 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/discovery/image/inspect', { image }, signal);
|
|
}
|
|
|
|
// ── Discovery (/apps/new wizard helpers) ───────────────────────────
|
|
// These endpoints back the auto-discovery + connection-test flow that
|
|
// the static-site creation wizard used in the legacy /sites/new page.
|
|
// They are admin-gated; the token is plaintext over HTTPS and is not
|
|
// persisted server-side.
|
|
|
|
// GitProviderKind is the union the *frontend* sends. The empty string
|
|
// means "auto-detect server-side" (DetectProviderWithProbe runs).
|
|
export type GitProviderKind = '' | 'gitea' | 'github' | 'gitlab';
|
|
|
|
// DetectedGitProvider is the narrower union the backend's detect
|
|
// endpoint actually returns — `staticsite.DetectProviderWithProbe`
|
|
// always resolves to one of the three concrete kinds (it falls back to
|
|
// `gitea` for unknown hosts). Kept distinct from GitProviderKind so a
|
|
// successful detection cannot ever set the dropdown back to "".
|
|
export type DetectedGitProvider = 'gitea' | 'github' | 'gitlab';
|
|
|
|
export interface RepoInfo {
|
|
owner: string;
|
|
name: string;
|
|
full_name: string;
|
|
description: string;
|
|
private: boolean;
|
|
html_url: string;
|
|
}
|
|
|
|
export interface FolderEntry {
|
|
path: string;
|
|
is_dir: boolean;
|
|
}
|
|
|
|
export interface DiscoveryGitRequest {
|
|
provider?: GitProviderKind;
|
|
base_url: string;
|
|
access_token?: string;
|
|
repo_owner?: string;
|
|
repo_name?: string;
|
|
branch?: string;
|
|
query?: string;
|
|
}
|
|
|
|
export interface ImageConflict {
|
|
id: string;
|
|
name: string;
|
|
image: string;
|
|
app_id?: string;
|
|
}
|
|
|
|
export function detectGitProvider(
|
|
baseURL: string,
|
|
signal?: AbortSignal
|
|
): Promise<{ provider: DetectedGitProvider }> {
|
|
return post<{ provider: DetectedGitProvider }>(
|
|
'/api/discovery/git/detect-provider',
|
|
{ base_url: baseURL },
|
|
signal
|
|
);
|
|
}
|
|
|
|
export function testGitConnection(
|
|
req: DiscoveryGitRequest,
|
|
signal?: AbortSignal
|
|
): Promise<{ status: string }> {
|
|
return post<{ status: string }>('/api/discovery/git/test-connection', req, signal);
|
|
}
|
|
|
|
export function listGitRepos(req: DiscoveryGitRequest, signal?: AbortSignal): Promise<RepoInfo[]> {
|
|
return post<RepoInfo[]>('/api/discovery/git/repos', req, signal);
|
|
}
|
|
|
|
export function listGitBranches(
|
|
req: DiscoveryGitRequest,
|
|
signal?: AbortSignal
|
|
): Promise<string[]> {
|
|
return post<string[]>('/api/discovery/git/branches', req, signal);
|
|
}
|
|
|
|
export function listGitTree(req: DiscoveryGitRequest, signal?: AbortSignal): Promise<FolderEntry[]> {
|
|
return post<FolderEntry[]>('/api/discovery/git/tree', req, signal);
|
|
}
|
|
|
|
export function listImageConflicts(image: string, signal?: AbortSignal): Promise<ImageConflict[]> {
|
|
return get<ImageConflict[]>(
|
|
`/api/discovery/image/conflicts?image=${encodeURIComponent(image)}`,
|
|
signal
|
|
);
|
|
}
|
|
|
|
// ── Workload runtime view (runtime-state, storage, stop, start) ────
|
|
// Backed by internal/api/workload_runtime.go. The shapes mirror the
|
|
// Go side exactly so the UI can render without further normalization.
|
|
|
|
export interface WorkloadRuntimeState {
|
|
source_kind: string;
|
|
has_state: boolean;
|
|
container_id?: string;
|
|
state?: string;
|
|
status?: string;
|
|
last_commit_sha?: string;
|
|
last_sync_at?: string;
|
|
last_error?: string;
|
|
}
|
|
|
|
export interface WorkloadStorageUsage {
|
|
source_kind: string;
|
|
enabled: boolean;
|
|
used_bytes: number;
|
|
limit_mb?: number;
|
|
probe_error?: string;
|
|
}
|
|
|
|
export interface StopStartResult {
|
|
touched: number;
|
|
failed: number;
|
|
}
|
|
|
|
export function getWorkloadRuntimeState(
|
|
id: string,
|
|
signal?: AbortSignal
|
|
): Promise<WorkloadRuntimeState> {
|
|
return get<WorkloadRuntimeState>(`/api/workloads/${id}/runtime-state`, signal);
|
|
}
|
|
|
|
export function getWorkloadStorage(
|
|
id: string,
|
|
signal?: AbortSignal
|
|
): Promise<WorkloadStorageUsage> {
|
|
return get<WorkloadStorageUsage>(`/api/workloads/${id}/storage`, signal);
|
|
}
|
|
|
|
export function stopWorkload(id: string): Promise<StopStartResult> {
|
|
return post<StopStartResult>(`/api/workloads/${id}/stop`);
|
|
}
|
|
|
|
export function startWorkload(id: string): Promise<StopStartResult> {
|
|
return post<StopStartResult>(`/api/workloads/${id}/start`);
|
|
}
|
|
|
|
// ── Registries ──────────────────────────────────────────────────────
|
|
|
|
export function listRegistries(): Promise<Registry[]> {
|
|
return get<Registry[]>('/api/registries');
|
|
}
|
|
|
|
export function createRegistry(data: Partial<Registry>): Promise<Registry> {
|
|
return post<Registry>('/api/registries', data);
|
|
}
|
|
|
|
export function updateRegistry(id: string, data: Partial<Registry>): Promise<Registry> {
|
|
return put<Registry>(`/api/registries/${id}`, data);
|
|
}
|
|
|
|
export function deleteRegistry(id: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/registries/${id}`);
|
|
}
|
|
|
|
export function testRegistry(id: string): Promise<{ status: string }> {
|
|
return post<{ status: string }>(`/api/registries/${id}/test`);
|
|
}
|
|
|
|
export function listRegistryTags(registryId: string, image: string): Promise<string[]> {
|
|
// Image contains slashes (e.g. "owner/name") — don't encode them;
|
|
// the backend route uses a wildcard (/tags/*) that expects path segments.
|
|
return get<string[]>(`/api/registries/${registryId}/tags/${image}`);
|
|
}
|
|
|
|
export function listRegistryImages(registryId: string): Promise<RegistryImage[]> {
|
|
return get<RegistryImage[]>(`/api/registries/${registryId}/images`);
|
|
}
|
|
|
|
// ── Settings ────────────────────────────────────────────────────────
|
|
|
|
export function getSettings(signal?: AbortSignal): Promise<Settings> {
|
|
return get<Settings>('/api/settings', signal);
|
|
}
|
|
|
|
export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
|
return put<Settings>('/api/settings', data);
|
|
}
|
|
|
|
// ── Webhook envelopes ──────────────────────────────────────────────
|
|
// These shapes are reused by the workload + trigger webhook flows.
|
|
|
|
export interface WebhookUrlResponse {
|
|
webhook_url: string;
|
|
webhook_secret: string;
|
|
has_signing_secret?: boolean;
|
|
webhook_require_signature?: boolean;
|
|
}
|
|
|
|
export interface SigningSecretResponse {
|
|
signing_secret: string;
|
|
}
|
|
|
|
export interface WebhookDelivery {
|
|
id: number;
|
|
target_type: 'project' | 'site' | 'workload' | 'trigger';
|
|
target_id: string;
|
|
target_name: string;
|
|
received_at: string;
|
|
source_ip: string;
|
|
signature_state: 'valid' | 'invalid' | 'missing' | 'unconfigured';
|
|
status_code: number;
|
|
outcome: string;
|
|
detail: string;
|
|
body_size: number;
|
|
}
|
|
|
|
// ── Outgoing-webhook signing & test (settings tier only) ───────────
|
|
// Per-project, per-stage, per-site tiers were dropped with the legacy
|
|
// endpoints. Per-workload signing is exposed via the workload webhook
|
|
// flow below.
|
|
|
|
export interface NotificationSecretResponse {
|
|
secret: string;
|
|
has_secret: boolean;
|
|
}
|
|
|
|
export interface NotificationTestResult {
|
|
url: string;
|
|
tier: 'settings' | 'project' | 'stage' | 'site' | 'workload' | 'trigger';
|
|
status_code: number;
|
|
latency_ms: number;
|
|
signature_sent: boolean;
|
|
delivery_id: string;
|
|
response_snippet: string;
|
|
error?: string;
|
|
}
|
|
|
|
export function getSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
|
return get<NotificationSecretResponse>('/api/settings/notification-secret');
|
|
}
|
|
export function regenerateSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
|
return post<NotificationSecretResponse>('/api/settings/notification-secret/regenerate');
|
|
}
|
|
export function disableSettingsNotificationSigning(): Promise<NotificationSecretResponse> {
|
|
return post<NotificationSecretResponse>('/api/settings/notification-secret/disable');
|
|
}
|
|
export function testSettingsNotification(): Promise<NotificationTestResult> {
|
|
return post<NotificationTestResult>('/api/settings/notification-test');
|
|
}
|
|
|
|
// ── Proxy Routes ───────────────────────────────────────────────────
|
|
|
|
export function listProxyRoutes(signal?: AbortSignal): Promise<ProxyRoute[]> {
|
|
return get<ProxyRoute[]>('/api/proxies', signal);
|
|
}
|
|
|
|
// ── Docker Management ──────────────────────────────────────────────
|
|
|
|
export function getUnusedImageStats(signal?: AbortSignal): Promise<{
|
|
total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean;
|
|
}> {
|
|
return get<{ total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean }>('/api/docker/unused-images', signal);
|
|
}
|
|
|
|
export function pruneImages(): Promise<{ images_removed: number; space_reclaimed_mb: number }> {
|
|
return post<{ images_removed: number; space_reclaimed_mb: number }>('/api/docker/prune-images');
|
|
}
|
|
|
|
export function testNpmConnection(data: { npm_url?: string; npm_email?: string; npm_password?: string }): Promise<{ status: string }> {
|
|
return post<{ status: string }>('/api/settings/npm/test', data);
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
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) ───────────────────────────────────
|
|
// Per-project volume CRUD endpoints died with the legacy routes; the
|
|
// workload volume endpoints cover the new path. Scope metadata stays
|
|
// because the volume editor for workloads still needs it.
|
|
|
|
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
|
|
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
|
|
}
|
|
|
|
// ── DNS ────────────────────────────────────────────────────────────
|
|
|
|
export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> {
|
|
return post<{ success: boolean; error?: string }>('/api/settings/dns/test', { provider, token, zone_id: zoneId });
|
|
}
|
|
|
|
export function listDnsZones(token?: string): Promise<DnsZone[]> {
|
|
return post<DnsZone[]>('/api/settings/dns/zones', { token: token ?? '' });
|
|
}
|
|
|
|
export function getDnsRecords(): Promise<DnsRecordView[]> {
|
|
return get<DnsRecordView[]>('/api/dns/records');
|
|
}
|
|
|
|
export function syncDnsRecords(): Promise<{ created: number; deleted: number; already_synced: number }> {
|
|
return post<{ created: number; deleted: number; already_synced: number }>('/api/dns/sync');
|
|
}
|
|
|
|
export function deleteDnsRecord(fqdn: string): Promise<void> {
|
|
return del<void>(`/api/dns/records/${encodeURIComponent(fqdn)}`);
|
|
}
|
|
|
|
// ── Backups ────────────────────────────────────────────────────────
|
|
|
|
export function listBackups(): Promise<BackupInfo[]> {
|
|
return get<BackupInfo[]>('/api/backups');
|
|
}
|
|
|
|
export function triggerBackup(): Promise<BackupInfo> {
|
|
return post<BackupInfo>('/api/backups');
|
|
}
|
|
|
|
export function deleteBackup(id: string): Promise<void> {
|
|
return del<void>(`/api/backups/${id}`);
|
|
}
|
|
|
|
export function restoreBackup(id: string): Promise<{ status: string; message: string }> {
|
|
// 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 {
|
|
return `/api/backups/${id}/download`;
|
|
}
|
|
|
|
// ── Health ──────────────────────────────────────────────────────────
|
|
|
|
export function getHealth(): Promise<{ docker: DockerHealth; proxy?: ProxyHealth }> {
|
|
return get<{ docker: DockerHealth; proxy?: ProxyHealth }>('/api/health');
|
|
}
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────────────
|
|
|
|
export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> {
|
|
return post<{ token: string; expires_at: string }>('/api/auth/login', { username, password });
|
|
}
|
|
|
|
export function getCurrentUser(): Promise<{ id: string; username: string; email: string; role: string }> {
|
|
return get<{ id: string; username: string; email: string; role: string }>('/api/auth/me');
|
|
}
|
|
|
|
// ── 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 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<AuthUser[]> {
|
|
return request<AuthUser[]>('/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: 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<void> {
|
|
await request<void>(`/api/auth/users/${uid}/password`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ password })
|
|
});
|
|
}
|
|
|
|
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<void>('/api/auth/logout', { method: 'POST' });
|
|
}
|
|
|
|
// ── Config Export ────────────────────────────────────────────────────
|
|
|
|
export function exportConfigUrl(): string {
|
|
return '/api/config/export';
|
|
}
|
|
|
|
// ── Workload volume browse / download / upload ─────────────────────
|
|
// The browse/download/upload helpers now target /api/workloads/{id}
|
|
// instead of the deleted project-scoped path. Source/path/scope params
|
|
// retain the same query keys for compatibility with the volume editor.
|
|
|
|
export function browseVolume(
|
|
workloadId: string,
|
|
volId: string,
|
|
params?: { path?: string; reference?: string }
|
|
): Promise<BrowseResult> {
|
|
const query = new URLSearchParams();
|
|
if (params?.path) query.set('path', params.path);
|
|
if (params?.reference) query.set('reference', params.reference);
|
|
const qs = query.toString();
|
|
return get<BrowseResult>(
|
|
`/api/workloads/${workloadId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`
|
|
);
|
|
}
|
|
|
|
export function volumeDownloadUrl(
|
|
workloadId: string,
|
|
volId: string,
|
|
params?: { path?: string; reference?: string }
|
|
): string {
|
|
const query = new URLSearchParams();
|
|
if (params?.path) query.set('path', params.path);
|
|
if (params?.reference) query.set('reference', params.reference);
|
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
|
if (token) query.set('token', token);
|
|
const qs = query.toString();
|
|
return `/api/workloads/${workloadId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`;
|
|
}
|
|
|
|
export async function uploadToVolume(
|
|
workloadId: string,
|
|
volId: string,
|
|
files: FileList,
|
|
params?: { path?: string; reference?: string }
|
|
): Promise<{ uploaded: string[]; count: number }> {
|
|
const query = new URLSearchParams();
|
|
if (params?.path) query.set('path', params.path);
|
|
if (params?.reference) query.set('reference', params.reference);
|
|
const qs = query.toString();
|
|
|
|
const formData = new FormData();
|
|
for (let i = 0; i < files.length; i++) {
|
|
formData.append('files', files[i]);
|
|
}
|
|
|
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
|
|
const res = await fetch(
|
|
`/api/workloads/${workloadId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`,
|
|
{
|
|
method: 'POST',
|
|
headers,
|
|
body: formData
|
|
}
|
|
);
|
|
|
|
const envelope = await res.json();
|
|
if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed');
|
|
return envelope.data;
|
|
}
|
|
|
|
// ── Event Log ───────────────────────────────────────────────────────
|
|
|
|
export function fetchEventLog(params?: {
|
|
severity?: string;
|
|
source?: string;
|
|
since?: string;
|
|
until?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<EventLogEntry[]> {
|
|
const query = new URLSearchParams();
|
|
if (params?.severity) query.set('severity', params.severity);
|
|
if (params?.source) query.set('source', params.source);
|
|
if (params?.since) query.set('since', params.since);
|
|
if (params?.until) query.set('until', params.until);
|
|
if (params?.limit) query.set('limit', String(params.limit));
|
|
if (params?.offset) query.set('offset', String(params.offset));
|
|
const qs = query.toString();
|
|
return get<EventLogEntry[]>(`/api/events/log${qs ? `?${qs}` : ''}`);
|
|
}
|
|
|
|
export function fetchEventLogStats(signal?: AbortSignal): Promise<EventLogStats> {
|
|
return get<EventLogStats>('/api/events/log/stats', signal);
|
|
}
|
|
|
|
export function fetchWorkloadEvents(
|
|
id: string,
|
|
params?: { severity?: string; limit?: number; offset?: number },
|
|
signal?: AbortSignal
|
|
): Promise<EventLogEntry[]> {
|
|
const query = new URLSearchParams();
|
|
if (params?.severity) query.set('severity', params.severity);
|
|
if (params?.limit) query.set('limit', String(params.limit));
|
|
if (params?.offset) query.set('offset', String(params.offset));
|
|
const qs = query.toString();
|
|
return get<EventLogEntry[]>(`/api/workloads/${id}/events${qs ? `?${qs}` : ''}`, signal);
|
|
}
|
|
|
|
export function deleteEvent(id: number): Promise<{ status: string }> {
|
|
return del<{ status: string }>(`/api/events/log/${id}`);
|
|
}
|
|
|
|
export function clearAllEvents(): Promise<{ status: string; count: number }> {
|
|
return del<{ status: string; count: number }>('/api/events/log');
|
|
}
|
|
|
|
// ── Stale Containers ────────────────────────────────────────────────
|
|
|
|
export function fetchStaleContainers(signal?: AbortSignal): Promise<StaleContainer[]> {
|
|
return get<StaleContainer[]>('/api/containers/stale', signal);
|
|
}
|
|
|
|
export function cleanupStaleContainer(id: string): Promise<{ deleted: string }> {
|
|
return post<{ deleted: string }>(`/api/containers/stale/${id}/cleanup`);
|
|
}
|
|
|
|
export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> {
|
|
return post<{ deleted: number }>('/api/containers/stale/cleanup');
|
|
}
|
|
|
|
// ── System Stats ───────────────────────────────────────────────────
|
|
|
|
export function fetchSystemStats(signal?: AbortSignal): Promise<SystemStats> {
|
|
return get<SystemStats>('/api/system/stats', signal);
|
|
}
|
|
|
|
export function fetchSystemStatsHistory(
|
|
window = '2h',
|
|
signal?: AbortSignal
|
|
): Promise<SystemStatsSample[]> {
|
|
return get<SystemStatsSample[]>(`/api/system/stats/history?window=${encodeURIComponent(window)}`, signal);
|
|
}
|
|
|
|
export function fetchTopContainers(
|
|
by: 'cpu' | 'memory' = 'cpu',
|
|
limit = 5,
|
|
signal?: AbortSignal
|
|
): Promise<TopContainerSample[]> {
|
|
return get<TopContainerSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
|
|
}
|
|
|
|
// ── Per-container stats (workload-scoped) ──────────────────────────
|
|
// Project / stage / instance + static-site stats endpoints died with
|
|
// the legacy routes. Use the workload-scoped endpoint for any per-
|
|
// container CPU/memory drill-down.
|
|
|
|
export function fetchWorkloadContainerStats(
|
|
workloadId: string,
|
|
containerRowId: string,
|
|
signal?: AbortSignal
|
|
): Promise<ContainerStats> {
|
|
return get<ContainerStats>(
|
|
`/api/workloads/${workloadId}/containers/${containerRowId}/stats`,
|
|
signal
|
|
);
|
|
}
|
|
|
|
export function fetchWorkloadContainerStatsHistory(
|
|
workloadId: string,
|
|
containerRowId: string,
|
|
window = '2h',
|
|
signal?: AbortSignal
|
|
): Promise<ContainerStatsSample[]> {
|
|
return get<ContainerStatsSample[]>(
|
|
`/api/workloads/${workloadId}/containers/${containerRowId}/stats/history?window=${encodeURIComponent(window)}`,
|
|
signal
|
|
);
|
|
}
|
|
|
|
// ── Workloads ───────────────────────────────────────────────────────
|
|
|
|
export function listWorkloads(kind?: WorkloadKind, signal?: AbortSignal): Promise<Workload[]> {
|
|
const path = kind ? `/api/workloads?kind=${encodeURIComponent(kind)}` : '/api/workloads';
|
|
return get<Workload[]>(path, signal);
|
|
}
|
|
|
|
export function getWorkload(id: string, signal?: AbortSignal): Promise<Workload> {
|
|
return get<Workload>(`/api/workloads/${id}`, signal);
|
|
}
|
|
|
|
export function listWorkloadContainers(id: string, signal?: AbortSignal): Promise<Container[]> {
|
|
return get<Container[]>(`/api/workloads/${id}/containers`, signal);
|
|
}
|
|
|
|
export function setWorkloadAppID(id: string, appID: string): Promise<Workload> {
|
|
return patch<Workload>(`/api/workloads/${id}/app`, { app_id: appID });
|
|
}
|
|
|
|
export function createPluginWorkload(body: import('./types').PluginWorkloadInput): Promise<Workload> {
|
|
return post<Workload>('/api/workloads', body);
|
|
}
|
|
|
|
export function updatePluginWorkload(id: string, body: import('./types').PluginWorkloadInput): Promise<Workload> {
|
|
return put<Workload>(`/api/workloads/${id}/plugin`, body);
|
|
}
|
|
|
|
export function deployPluginWorkload(
|
|
id: string,
|
|
body?: { reference?: string; note?: string }
|
|
): Promise<{ workload_id: string; reference: string; triggered_by: string }> {
|
|
return post(`/api/workloads/${id}/deploy`, body ?? {});
|
|
}
|
|
|
|
export function listHookKinds(signal?: AbortSignal): Promise<import('./types').HookKinds> {
|
|
return get<import('./types').HookKinds>('/api/hooks/kinds', signal);
|
|
}
|
|
|
|
export function deletePluginWorkload(id: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/workloads/${id}`);
|
|
}
|
|
|
|
export interface WorkloadEnv {
|
|
id: string;
|
|
workload_id: string;
|
|
key: string;
|
|
value: string;
|
|
encrypted: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export function listWorkloadEnv(id: string, signal?: AbortSignal): Promise<WorkloadEnv[]> {
|
|
return get<WorkloadEnv[]>(`/api/workloads/${id}/env`, signal);
|
|
}
|
|
|
|
export function setWorkloadEnv(
|
|
id: string,
|
|
body: { key: string; value: string; encrypted: boolean }
|
|
): Promise<WorkloadEnv> {
|
|
return put<WorkloadEnv>(`/api/workloads/${id}/env`, body);
|
|
}
|
|
|
|
export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`);
|
|
}
|
|
|
|
// Workload-level webhook URL accessors were removed in the hard legacy
|
|
// cutover: inbound webhooks are now first-class Triggers. To wire a
|
|
// 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,
|
|
tail: number
|
|
): Promise<string[]> {
|
|
return get<string[]>(
|
|
`/api/workloads/${workloadId}/containers/${containerRowId}/logs?tail=${tail}`
|
|
);
|
|
}
|
|
|
|
export interface WorkloadVolume {
|
|
id: string;
|
|
workload_id: string;
|
|
source: string;
|
|
target: string;
|
|
scope: string;
|
|
name: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export function listWorkloadVolumes(id: string, signal?: AbortSignal): Promise<WorkloadVolume[]> {
|
|
return get<WorkloadVolume[]>(`/api/workloads/${id}/volumes`, signal);
|
|
}
|
|
|
|
export function setWorkloadVolume(
|
|
id: string,
|
|
body: { source: string; target: string; scope: string; name?: string }
|
|
): Promise<WorkloadVolume> {
|
|
return put<WorkloadVolume>(`/api/workloads/${id}/volumes`, body);
|
|
}
|
|
|
|
export function deleteWorkloadVolume(id: string, volID: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/workloads/${id}/volumes/${volID}`);
|
|
}
|
|
|
|
export interface HookKindSchema {
|
|
kind: string;
|
|
sample: unknown;
|
|
}
|
|
|
|
export function getHookKindSchema(kind: string, signal?: AbortSignal): Promise<HookKindSchema> {
|
|
return get<HookKindSchema>(`/api/hooks/kinds/${kind}/schema`, signal);
|
|
}
|
|
|
|
// ── Triggers (first-class redeploy signal sources) ──────────────────
|
|
|
|
export interface RedeployTrigger {
|
|
id: string;
|
|
kind: string;
|
|
name: string;
|
|
config: unknown;
|
|
webhook_enabled: boolean;
|
|
webhook_require_signature: boolean;
|
|
binding_count: number;
|
|
/** RFC3339 timestamp the scheduler last dispatched this trigger. Empty for
|
|
* never-fired or non-scheduler-driven triggers. */
|
|
last_fired_at: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface TriggerWebhook {
|
|
url: string;
|
|
secret: string;
|
|
webhook_require_signature: boolean;
|
|
}
|
|
|
|
export interface TriggerBinding {
|
|
id: string;
|
|
workload_id: string;
|
|
workload_name: string;
|
|
trigger_id: string;
|
|
binding_config: unknown;
|
|
enabled: boolean;
|
|
sort_order: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface WorkloadTriggerBinding extends TriggerBinding {
|
|
trigger_kind: string;
|
|
trigger_name: string;
|
|
}
|
|
|
|
export interface TriggerInput {
|
|
kind: string;
|
|
name: string;
|
|
config: unknown;
|
|
webhook_enabled: boolean;
|
|
webhook_require_signature: boolean;
|
|
}
|
|
|
|
export interface BindingInput {
|
|
workload_id: string;
|
|
binding_config?: unknown;
|
|
enabled?: boolean;
|
|
sort_order?: number;
|
|
}
|
|
|
|
export interface WorkloadBindInput {
|
|
trigger_id?: string;
|
|
binding_config?: unknown;
|
|
enabled?: boolean;
|
|
sort_order?: number;
|
|
inline?: TriggerInput;
|
|
}
|
|
|
|
export function listTriggers(kind?: string, signal?: AbortSignal): Promise<RedeployTrigger[]> {
|
|
const path = kind ? `/api/triggers?kind=${encodeURIComponent(kind)}` : '/api/triggers';
|
|
return get<RedeployTrigger[]>(path, signal);
|
|
}
|
|
|
|
export function getTrigger(id: string, signal?: AbortSignal): Promise<RedeployTrigger> {
|
|
return get<RedeployTrigger>(`/api/triggers/${id}`, signal);
|
|
}
|
|
|
|
export function createTrigger(body: TriggerInput): Promise<RedeployTrigger> {
|
|
return post<RedeployTrigger>('/api/triggers', body);
|
|
}
|
|
|
|
export function updateTrigger(id: string, body: TriggerInput): Promise<RedeployTrigger> {
|
|
return put<RedeployTrigger>(`/api/triggers/${id}`, body);
|
|
}
|
|
|
|
export function deleteTrigger(id: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/triggers/${id}`);
|
|
}
|
|
|
|
export function getTriggerWebhook(id: string, signal?: AbortSignal): Promise<TriggerWebhook> {
|
|
return get<TriggerWebhook>(`/api/triggers/${id}/webhook`, signal);
|
|
}
|
|
|
|
export function regenerateTriggerWebhook(id: string): Promise<{ secret: string; url: string }> {
|
|
return post<{ secret: string; url: string }>(`/api/triggers/${id}/webhook/regenerate`);
|
|
}
|
|
|
|
export interface FireNowResponse {
|
|
trigger: string;
|
|
fired_at: string;
|
|
bindings: number;
|
|
deployed: number;
|
|
errored: number;
|
|
}
|
|
|
|
/** Fire a schedule trigger immediately without waiting for the next
|
|
* natural fire window. Backend rejects with 400 for non-schedule kinds. */
|
|
export function fireTriggerNow(id: string): Promise<FireNowResponse> {
|
|
return post<FireNowResponse>(`/api/triggers/${id}/fire`);
|
|
}
|
|
|
|
export function listBindingsForTrigger(id: string, signal?: AbortSignal): Promise<TriggerBinding[]> {
|
|
return get<TriggerBinding[]>(`/api/triggers/${id}/bindings`, signal);
|
|
}
|
|
|
|
export function bindWorkloadToTrigger(triggerId: string, body: BindingInput): Promise<TriggerBinding> {
|
|
return post<TriggerBinding>(`/api/triggers/${triggerId}/bindings`, body);
|
|
}
|
|
|
|
export function listBindingsForWorkload(
|
|
workloadId: string,
|
|
signal?: AbortSignal
|
|
): Promise<WorkloadTriggerBinding[]> {
|
|
return get<WorkloadTriggerBinding[]>(`/api/workloads/${workloadId}/triggers`, signal);
|
|
}
|
|
|
|
export function bindTriggerToWorkload(
|
|
workloadId: string,
|
|
body: WorkloadBindInput
|
|
): Promise<TriggerBinding> {
|
|
return post<TriggerBinding>(`/api/workloads/${workloadId}/triggers`, body);
|
|
}
|
|
|
|
export function updateBinding(
|
|
id: string,
|
|
body: { binding_config?: unknown; enabled?: boolean; sort_order?: number }
|
|
): Promise<TriggerBinding> {
|
|
return put<TriggerBinding>(`/api/bindings/${id}`, body);
|
|
}
|
|
|
|
export function deleteBinding(id: string): Promise<{ deleted: string }> {
|
|
return del<{ deleted: string }>(`/api/bindings/${id}`);
|
|
}
|
|
|
|
export interface WorkloadChainNode {
|
|
id: string;
|
|
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;
|
|
}
|
|
|
|
export interface WorkloadChain {
|
|
parent: WorkloadChainNode | null;
|
|
self: WorkloadChainNode;
|
|
children: WorkloadChainNode[];
|
|
}
|
|
|
|
export function getWorkloadChain(id: string, signal?: AbortSignal): Promise<WorkloadChain> {
|
|
return get<WorkloadChain>(`/api/workloads/${id}/chain`, signal);
|
|
}
|
|
|
|
export function promoteFromWorkload(
|
|
targetID: string,
|
|
sourceID: string,
|
|
body?: { image_tag?: string; deploy?: boolean }
|
|
): Promise<{ workload_id: string; source_id: string; promoted_tag: string; deploy_queued: boolean }> {
|
|
return post(`/api/workloads/${targetID}/promote-from/${sourceID}`, body ?? {});
|
|
}
|
|
|
|
// ── Containers (global index) ───────────────────────────────────────
|
|
|
|
export interface ListContainersFilter {
|
|
workload_id?: string;
|
|
kind?: WorkloadKind;
|
|
state?: string;
|
|
app_id?: string;
|
|
}
|
|
|
|
export function listContainers(filter: ListContainersFilter = {}, signal?: AbortSignal): Promise<ContainerView[]> {
|
|
const params = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(filter)) {
|
|
// Skip unset / empty filters; explicitly check undefined and '' instead
|
|
// of truthy so future filter shapes (numbers, booleans) aren't dropped.
|
|
if (v !== undefined && v !== '') params.set(k, String(v));
|
|
}
|
|
const qs = params.toString();
|
|
const path = qs ? `/api/containers?${qs}` : '/api/containers';
|
|
return get<ContainerView[]>(path, signal);
|
|
}
|
|
|
|
// ── Apps ────────────────────────────────────────────────────────────
|
|
|
|
export function listApps(signal?: AbortSignal): Promise<App[]> {
|
|
return get<App[]>('/api/apps', signal);
|
|
}
|
|
|
|
export function getApp(id: string, signal?: AbortSignal): Promise<App> {
|
|
return get<App>(`/api/apps/${id}`, signal);
|
|
}
|
|
|
|
export function createApp(data: { name: string; description?: string }): Promise<App> {
|
|
return post<App>('/api/apps', data);
|
|
}
|
|
|
|
export function updateApp(id: string, data: { name: string; description?: string }): Promise<App> {
|
|
return put<App>(`/api/apps/${id}`, data);
|
|
}
|
|
|
|
export function deleteApp(id: string): Promise<void> {
|
|
return del<void>(`/api/apps/${id}`);
|
|
}
|
|
|
|
// ── Event Triggers ──────────────────────────────────────────────────
|
|
// Backend: internal/api/event_triggers.go. AND-composed filter shape;
|
|
// empty filter fields mean "match any value." The dispatcher fans
|
|
// matching event_log entries out to action_target via signed webhook.
|
|
|
|
export interface EventTrigger {
|
|
id: number;
|
|
name: string;
|
|
filter_severity: string; // CSV; "" = any
|
|
filter_source: string; // CSV; "" = any
|
|
filter_message_regex: string; // "" = any
|
|
action_type: string; // 'webhook' today
|
|
action_target: string; // URL
|
|
action_secret: string; // optional HMAC secret
|
|
enabled: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface EventTriggerInput {
|
|
name: string;
|
|
filter_severity?: string;
|
|
filter_source?: string;
|
|
filter_message_regex?: string;
|
|
action_type?: string;
|
|
action_target: string;
|
|
action_secret?: string;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export function listEventTriggers(signal?: AbortSignal): Promise<EventTrigger[]> {
|
|
return get<EventTrigger[]>('/api/event-triggers', signal);
|
|
}
|
|
|
|
export function getEventTrigger(id: number, signal?: AbortSignal): Promise<EventTrigger> {
|
|
return get<EventTrigger>(`/api/event-triggers/${id}`, signal);
|
|
}
|
|
|
|
export function createEventTrigger(data: EventTriggerInput): Promise<EventTrigger> {
|
|
return post<EventTrigger>('/api/event-triggers', data);
|
|
}
|
|
|
|
export function updateEventTrigger(id: number, data: EventTriggerInput): Promise<EventTrigger> {
|
|
return patch<EventTrigger>(`/api/event-triggers/${id}`, data);
|
|
}
|
|
|
|
export function deleteEventTrigger(id: number): Promise<void> {
|
|
return del<void>(`/api/event-triggers/${id}`);
|
|
}
|
|
|
|
export function testEventTrigger(id: number): Promise<NotificationTestResult> {
|
|
return post<NotificationTestResult>(`/api/event-triggers/${id}/test`);
|
|
}
|
|
|
|
// ── Log scan rules ──────────────────────────────────────────────────
|
|
// Backend: internal/api/log_scan_rules.go. Rules are regex patterns
|
|
// the scanner manager evaluates against container log lines. Scope
|
|
// model: workload_id="" + overrides_id=0 → global; workload_id set →
|
|
// workload-only (or per-workload override of a global via
|
|
// overrides_id).
|
|
|
|
export interface LogScanRule {
|
|
id: number;
|
|
workload_id: string; // "" = global
|
|
overrides_id: number; // 0 = not an override
|
|
name: string;
|
|
pattern: string;
|
|
severity: 'info' | 'warn' | 'error';
|
|
streams: 'all' | 'stdout' | 'stderr';
|
|
cooldown_seconds: number;
|
|
enabled: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface LogScanRuleInput {
|
|
workload_id?: string;
|
|
overrides_id?: number;
|
|
name: string;
|
|
pattern: string;
|
|
severity?: 'info' | 'warn' | 'error';
|
|
streams?: 'all' | 'stdout' | 'stderr';
|
|
cooldown_seconds?: number;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export interface LogScanTestResult {
|
|
matched: boolean;
|
|
captures?: Record<string, string>;
|
|
error?: string;
|
|
}
|
|
|
|
export function listLogScanRules(opts?: {
|
|
workloadID?: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<LogScanRule[]> {
|
|
const params = opts?.workloadID ? `?workload_id=${encodeURIComponent(opts.workloadID)}` : '';
|
|
return get<LogScanRule[]>(`/api/log-scan-rules${params}`, opts?.signal);
|
|
}
|
|
|
|
export function getLogScanRule(id: number, signal?: AbortSignal): Promise<LogScanRule> {
|
|
return get<LogScanRule>(`/api/log-scan-rules/${id}`, signal);
|
|
}
|
|
|
|
export function createLogScanRule(data: LogScanRuleInput): Promise<LogScanRule> {
|
|
return post<LogScanRule>('/api/log-scan-rules', data);
|
|
}
|
|
|
|
export function updateLogScanRule(id: number, data: LogScanRuleInput): Promise<LogScanRule> {
|
|
return patch<LogScanRule>(`/api/log-scan-rules/${id}`, data);
|
|
}
|
|
|
|
export function deleteLogScanRule(id: number): Promise<void> {
|
|
return del<void>(`/api/log-scan-rules/${id}`);
|
|
}
|
|
|
|
export function testLogScanRule(id: number, sampleLine: string): Promise<LogScanTestResult> {
|
|
return post<LogScanTestResult>(`/api/log-scan-rules/${id}/test`, { sample_line: sampleLine });
|
|
}
|
|
|
|
export function getEffectiveLogScanRules(workloadID: string, signal?: AbortSignal): Promise<LogScanRule[]> {
|
|
return get<LogScanRule[]>(`/api/workloads/${workloadID}/effective-rules`, signal);
|
|
}
|
|
|
|
export interface LogScanStats {
|
|
engine: {
|
|
dropped_by_bucket: number;
|
|
dropped_by_cooldown: number;
|
|
};
|
|
active_tails: number;
|
|
last_compile_errors: string[];
|
|
}
|
|
|
|
export function getLogScanStats(signal?: AbortSignal): Promise<LogScanStats> {
|
|
return get<LogScanStats>('/api/log-scan-rules/stats', signal);
|
|
}
|
|
|