apt lists phased updates as upgradable while an upgrade defers them until Ubuntu selects the host, so a freshly patched server kept reporting pending updates. The agent now flags them; the server stores the flag and leaves them out of patch run counts, and the server page shows them in their own section.
1613 lines
47 KiB
TypeScript
1613 lines
47 KiB
TypeScript
export type ServerStatus = "pending" | "active" | "offline";
|
|
export type KeySource = "uploaded" | "generated";
|
|
|
|
export interface PackageUpdate {
|
|
name: string;
|
|
current_version?: string;
|
|
new_version: string;
|
|
/** Ubuntu phased update this host is not yet selected for: apt defers it. */
|
|
phased?: boolean;
|
|
}
|
|
|
|
/** Updates an upgrade would install now. Phased updates are pending but deferred by apt. */
|
|
export function installableUpdates(updates: PackageUpdate[] | undefined): PackageUpdate[] {
|
|
return (updates ?? []).filter((u) => !u.phased);
|
|
}
|
|
|
|
export interface Inventory {
|
|
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
|
|
memory: { total_bytes: number; used_bytes: number };
|
|
swap_total_bytes: number;
|
|
swap_used_bytes: number;
|
|
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
|
|
kernel?: string;
|
|
reboot_required?: boolean;
|
|
metrics_at?: string;
|
|
static_at?: string;
|
|
}
|
|
|
|
export interface Server {
|
|
id: string;
|
|
server_id: string;
|
|
hostname: string;
|
|
ip_address: string;
|
|
os_info: string;
|
|
status: ServerStatus;
|
|
agent_version?: string;
|
|
last_seen: string;
|
|
created_at: string;
|
|
available_updates?: PackageUpdate[];
|
|
updates_checked_at?: string;
|
|
console_protocols?: string[];
|
|
inventory?: Inventory;
|
|
tags?: Record<string, string>;
|
|
}
|
|
|
|
export type MonitorType = "http" | "tcp" | "icmp" | "tls";
|
|
export type MonitorStatus = "up" | "down" | "pending";
|
|
|
|
export interface MonitorTarget {
|
|
url?: string;
|
|
host?: string;
|
|
port?: number;
|
|
method?: string;
|
|
expected_status?: number;
|
|
keyword?: string;
|
|
tls_warn_days?: number;
|
|
insecure?: boolean;
|
|
}
|
|
|
|
export interface MonitorState {
|
|
status: MonitorStatus;
|
|
last_check_at?: string;
|
|
latency_ms: number;
|
|
message?: string;
|
|
cert_expiry_at?: string;
|
|
fails: number;
|
|
}
|
|
|
|
export interface Monitor {
|
|
monitor_id: string;
|
|
name: string;
|
|
/** Display-only heading on the monitors page. Empty means ungrouped. */
|
|
group?: string;
|
|
type: MonitorType;
|
|
target: MonitorTarget;
|
|
interval_sec: number;
|
|
runner: string; // "server" or a server_id
|
|
retries: number;
|
|
enabled: boolean;
|
|
channel_ids?: string[];
|
|
state: MonitorState;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface MonitorInput {
|
|
name: string;
|
|
group?: string;
|
|
type: MonitorType;
|
|
target: MonitorTarget;
|
|
interval_sec: number;
|
|
runner: string;
|
|
retries: number;
|
|
enabled: boolean;
|
|
channel_ids?: string[];
|
|
}
|
|
|
|
export interface Incident {
|
|
incident_id: string;
|
|
monitor_id: string;
|
|
started_at: string;
|
|
resolved_at?: string;
|
|
cause?: string;
|
|
}
|
|
|
|
/** One check result. Kept for 48 hours, which is what the sub-hour views read. */
|
|
export interface MonitorSample {
|
|
monitor_id: string;
|
|
at: string;
|
|
up: boolean;
|
|
latency_ms: number;
|
|
}
|
|
|
|
export interface Rollup {
|
|
monitor_id: string;
|
|
period_start: string;
|
|
checks: number;
|
|
up_count: number;
|
|
sum_latency: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status page authoring types. These mirror server/internal/models/statuspage.go
|
|
// field for field -- that Go file is the contract. The public shapes
|
|
// (PublicDay, PublicComponent, PublicSection, PublicIncident, StatusSnapshot)
|
|
// live further down this file, added by the public status page task; this
|
|
// block must not redeclare them.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type StatusPageKind = "incident" | "maintenance";
|
|
export type StatusImpact = "none" | "minor" | "major" | "critical";
|
|
|
|
export interface StatusPageEntry {
|
|
monitor_id: string;
|
|
display_name?: string;
|
|
}
|
|
|
|
export interface StatusPageSection {
|
|
name: string;
|
|
entries: StatusPageEntry[];
|
|
}
|
|
|
|
export interface StatusPageBanner {
|
|
enabled: boolean;
|
|
level?: string;
|
|
text?: string;
|
|
}
|
|
|
|
export interface StatusPage {
|
|
page_id: string;
|
|
title: string;
|
|
description?: string;
|
|
logo_url?: string;
|
|
published: boolean;
|
|
banner: StatusPageBanner;
|
|
sections: StatusPageSection[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface StatusIncidentUpdate {
|
|
at: string;
|
|
status: string;
|
|
body: string;
|
|
author?: string;
|
|
}
|
|
|
|
export interface StatusIncident {
|
|
incident_id: string;
|
|
page_ids: string[];
|
|
kind: StatusPageKind;
|
|
title: string;
|
|
impact: StatusImpact;
|
|
affected_monitors?: string[];
|
|
status: string;
|
|
scheduled_start?: string;
|
|
scheduled_end?: string;
|
|
updates: StatusIncidentUpdate[];
|
|
started_at: string;
|
|
resolved_at?: string;
|
|
}
|
|
|
|
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
|
|
|
|
/**
|
|
* What a channel's secret config values read as over the API. Writing it back
|
|
* unchanged preserves the stored credential; anything else, including "", is
|
|
* written verbatim.
|
|
*
|
|
* Mirrors `models.RedactedSecret` and `models.channelSecretKeys` in
|
|
* `server/internal/models/channel.go` - change both in the same commit, the
|
|
* same hazard as the mirrored token blocks.
|
|
*/
|
|
export const REDACTED_SECRET = "••••••••";
|
|
|
|
export const CHANNEL_SECRET_FIELDS: Record<ChannelType, string[]> = {
|
|
webhook: ["url"],
|
|
slack: ["url"],
|
|
discord: ["url"],
|
|
telegram: ["token"],
|
|
smtp: ["password"],
|
|
};
|
|
|
|
export interface NotificationChannel {
|
|
channel_id: string;
|
|
name: string;
|
|
type: ChannelType;
|
|
config: Record<string, string>;
|
|
enabled: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ChannelInput {
|
|
name: string;
|
|
type: ChannelType;
|
|
config: Record<string, string>;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface ConsoleConnectRequest {
|
|
server_id: string;
|
|
protocol: string;
|
|
key_id?: string;
|
|
rdp_username?: string;
|
|
rdp_password?: string;
|
|
ssh_username?: string;
|
|
}
|
|
|
|
export interface ConsoleConnectResponse {
|
|
session_id: string;
|
|
token: string;
|
|
ws_path: string;
|
|
}
|
|
|
|
export interface Key {
|
|
id: string;
|
|
key_id: string;
|
|
label: string;
|
|
public_key: string;
|
|
fingerprint: string;
|
|
source: KeySource;
|
|
generated_by_server_id?: string;
|
|
has_private_key: boolean;
|
|
has_passphrase?: boolean;
|
|
created_at: string;
|
|
assigned_count?: number;
|
|
}
|
|
|
|
export interface Assignment {
|
|
id: string;
|
|
key_id: string;
|
|
server_id: string;
|
|
assigned_at: string;
|
|
revoked_at: string | null;
|
|
}
|
|
|
|
export interface AuditEvent {
|
|
id: string;
|
|
event_type: string;
|
|
actor: string;
|
|
server_id?: string;
|
|
key_id?: string;
|
|
details: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface AuditQuery {
|
|
q?: string;
|
|
category?: string;
|
|
limit?: number;
|
|
skip?: number;
|
|
}
|
|
|
|
/*
|
|
* The audit log pages on the server, unlike the fleet endpoints that answer
|
|
* with everything and slice in the browser. It is kept for months and read to
|
|
* answer questions about the past, so a search that only saw the most recent
|
|
* page would report "no results" for events that exist.
|
|
*/
|
|
export interface AuditPage {
|
|
events: AuditEvent[];
|
|
total: number;
|
|
}
|
|
|
|
export interface AlertSettings {
|
|
offline_threshold_minutes: number;
|
|
offline_channel_ids: string[] | null;
|
|
}
|
|
|
|
export interface SecretsSettings {
|
|
read_token_set: boolean;
|
|
rotated_at?: string;
|
|
}
|
|
|
|
export interface Settings {
|
|
alerts: AlertSettings;
|
|
secrets: SecretsSettings;
|
|
workflow_log_retention_days?: number | null;
|
|
local_login_enabled?: boolean;
|
|
api_token_max_days?: number | null;
|
|
}
|
|
|
|
export type ApiToken = {
|
|
token_id: string;
|
|
name: string;
|
|
hint: string;
|
|
role: Role;
|
|
scopes: string[];
|
|
expires_at?: string | null;
|
|
created_at: string;
|
|
last_used_at?: string | null;
|
|
user_id: string;
|
|
user_email?: string;
|
|
/** Restricts the token to servers carrying every pair. Absent or empty is
|
|
* the whole fleet - the asymmetry is deliberate, see services.MatchesSelector. */
|
|
tag_selector?: Record<string, string> | null;
|
|
};
|
|
|
|
export interface SecretGroupSummary {
|
|
group: string;
|
|
key_count: number;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface Secret {
|
|
group: string;
|
|
key: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface NewServerResponse {
|
|
server_id: string;
|
|
pre_reg_token: string;
|
|
install_command: string;
|
|
install_command_ps: string;
|
|
}
|
|
|
|
export interface GenerateKeyOptions {
|
|
label: string;
|
|
key_type: "ed25519" | "rsa" | "ecdsa";
|
|
key_size?: number;
|
|
passphrase?: string;
|
|
comment?: string;
|
|
}
|
|
|
|
export interface KeyWithAssignments extends Key {
|
|
assignments: (Assignment & { server: Server })[];
|
|
}
|
|
|
|
export interface ServerWithKeys extends Server {
|
|
keys: (Assignment & { key: Key })[];
|
|
}
|
|
|
|
export interface InputParam {
|
|
name: string;
|
|
default: string;
|
|
description: string;
|
|
}
|
|
|
|
export interface WorkflowStep {
|
|
step_id: string;
|
|
name: string;
|
|
description: string;
|
|
interpreter: "bash" | "powershell";
|
|
script: string;
|
|
declared_outputs: string[];
|
|
declared_inputs: InputParam[];
|
|
secret_refs: string[];
|
|
source?: "user" | "default";
|
|
slug?: string;
|
|
}
|
|
|
|
export interface WorkflowStepRef {
|
|
step_id?: string;
|
|
inline?: WorkflowStep;
|
|
order: number;
|
|
on_failure: "stop" | "continue" | "retry";
|
|
max_retries: number;
|
|
overrides?: { script?: string; secret_refs?: string[] };
|
|
inputs?: Record<string, string>;
|
|
}
|
|
|
|
export interface Workflow {
|
|
workflow_id: string;
|
|
name: string;
|
|
target_server_ids: string[];
|
|
target_tags?: Record<string, string>;
|
|
steps: WorkflowStepRef[];
|
|
schedule?: Schedule;
|
|
next_run_at?: string;
|
|
last_run_at?: string;
|
|
last_skipped?: Skip;
|
|
}
|
|
|
|
export interface StepRun {
|
|
order: number;
|
|
name: string;
|
|
status: string;
|
|
attempts: number;
|
|
exit_code: number;
|
|
log_offset: number;
|
|
output_env: Record<string, string>;
|
|
started_at?: string;
|
|
finished_at?: string;
|
|
}
|
|
|
|
export interface ServerRun {
|
|
server_id: string;
|
|
hostname: string;
|
|
status: string;
|
|
run_env: Record<string, string>;
|
|
steps: StepRun[];
|
|
started_at?: string;
|
|
finished_at?: string;
|
|
}
|
|
|
|
export interface WorkflowRun {
|
|
run_id: string;
|
|
workflow_id: string;
|
|
name: string;
|
|
status: string;
|
|
triggered_by: string;
|
|
started_at: string;
|
|
finished_at?: string;
|
|
server_runs: ServerRun[];
|
|
}
|
|
|
|
export type PatchScope = "all" | "security";
|
|
export type PatchReboot = "never" | "if_required";
|
|
export type PatchRunStatus = "running" | "succeeded" | "partial" | "failed" | "cancelled";
|
|
export type PatchServerStatus =
|
|
| "queued"
|
|
| "waiting_offline"
|
|
| "patching"
|
|
| "rebooting"
|
|
| "succeeded"
|
|
| "failed"
|
|
| "unsupported"
|
|
| "agent_too_old"
|
|
| "missed_offline"
|
|
| "window_closed"
|
|
| "cancelled";
|
|
|
|
export interface MaintenanceWindow {
|
|
window_id: string;
|
|
name: string;
|
|
cron: string;
|
|
tz: string;
|
|
duration_minutes: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface MaintenanceWindowInput {
|
|
name: string;
|
|
cron: string;
|
|
tz: string;
|
|
duration_minutes: number;
|
|
}
|
|
|
|
export interface WindowSpan {
|
|
start: string;
|
|
end: string;
|
|
}
|
|
|
|
export interface PatchPolicy {
|
|
policy_id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
window_id: string;
|
|
target_server_ids: string[];
|
|
target_tags?: Record<string, string>;
|
|
scope: PatchScope;
|
|
reboot: PatchReboot;
|
|
max_concurrent: number;
|
|
notify_channel_ids?: string[];
|
|
next_run_at?: string;
|
|
last_run_at?: string;
|
|
last_skipped?: Skip;
|
|
disabled_reason?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export type PatchPolicyInput = Pick<
|
|
PatchPolicy,
|
|
"name" | "enabled" | "window_id" | "target_server_ids" | "target_tags" | "scope" | "reboot" | "max_concurrent" | "notify_channel_ids"
|
|
>;
|
|
|
|
export interface PatchServerRun {
|
|
server_id: string;
|
|
hostname: string;
|
|
status: PatchServerStatus;
|
|
pending_before: number;
|
|
pending_after?: number;
|
|
rebooted_at?: string;
|
|
verified_at?: string;
|
|
output?: string;
|
|
error?: string;
|
|
started_at?: string;
|
|
finished_at?: string;
|
|
}
|
|
|
|
export interface PatchRun {
|
|
run_id: string;
|
|
policy_id?: string;
|
|
policy_name?: string;
|
|
triggered_by: string;
|
|
source: "schedule" | "run_now" | "server" | "vulnerabilities" | "mcp";
|
|
scope: PatchScope;
|
|
reboot: PatchReboot;
|
|
max_concurrent: number;
|
|
window_end?: string;
|
|
status: PatchRunStatus;
|
|
cancelled_at?: string;
|
|
started_at: string;
|
|
finished_at?: string;
|
|
servers: PatchServerRun[];
|
|
}
|
|
|
|
export type Role = "owner" | "admin" | "member";
|
|
|
|
/** The session as returned by GET /auth/me mirrors auth.Session on the server. */
|
|
export interface SessionUser {
|
|
user_id: string;
|
|
instance_id: string;
|
|
role: Role;
|
|
email: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface Instance {
|
|
instance_id: string;
|
|
name: string;
|
|
slug: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface MeResponse {
|
|
user: SessionUser;
|
|
instance: Instance | null;
|
|
}
|
|
|
|
export interface BootstrapStatus {
|
|
needs_setup: boolean;
|
|
instance_name?: string;
|
|
/** Vantage HQ has locked this instance; the login page shows a message instead of the form. */
|
|
locked?: boolean;
|
|
}
|
|
|
|
export interface BootstrapResponse {
|
|
instance: Instance;
|
|
slug: string;
|
|
instance_id: string;
|
|
}
|
|
|
|
export interface InstanceUser {
|
|
user_id: string;
|
|
instance_id: string;
|
|
email: string;
|
|
role: Role;
|
|
// "hq" means the row was projected from a Vantage HQ account. Its role,
|
|
// password and existence belong to HQ; this instance refuses to change them.
|
|
auth_source: "local" | "oidc" | "hq";
|
|
hq_user_id?: string;
|
|
created_at: string;
|
|
last_login?: string;
|
|
}
|
|
|
|
export interface OrgUserInput {
|
|
email: string;
|
|
password: string;
|
|
role: Role;
|
|
}
|
|
|
|
export interface PublicProvider {
|
|
id: string;
|
|
name: string;
|
|
preset: string;
|
|
}
|
|
|
|
export interface ProvidersResponse {
|
|
local_enabled: boolean;
|
|
providers: PublicProvider[];
|
|
locked?: boolean;
|
|
}
|
|
|
|
export interface AuthProvider {
|
|
provider_id: string;
|
|
instance_id: string;
|
|
name: string;
|
|
kind: "oidc" | "oauth2";
|
|
preset: string;
|
|
issuer: string;
|
|
client_id: string;
|
|
scopes: string[];
|
|
enabled: boolean;
|
|
callback_notice: boolean;
|
|
order: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
client_secret_set: boolean;
|
|
callback_url: string;
|
|
}
|
|
|
|
export interface AuthPreset {
|
|
id: string;
|
|
label: string;
|
|
kind: "oidc" | "oauth2";
|
|
input_label: string;
|
|
input_hint: string;
|
|
}
|
|
|
|
export interface AuthProviderInput {
|
|
name: string;
|
|
preset: string;
|
|
issuer_input?: string;
|
|
client_id: string;
|
|
client_secret: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface AuthProviderUpdate {
|
|
name?: string;
|
|
issuer_input?: string;
|
|
client_id?: string;
|
|
client_secret?: string;
|
|
enabled?: boolean;
|
|
order?: number;
|
|
}
|
|
|
|
class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
}
|
|
}
|
|
|
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const res = await fetch(`/api${path}`, {
|
|
credentials: "include",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...options?.headers,
|
|
},
|
|
...options,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
|
|
let message = text || res.statusText || `HTTP ${res.status}`;
|
|
try {
|
|
const body = JSON.parse(text);
|
|
if (body?.error) message = body.error;
|
|
} catch {}
|
|
throw new ApiError(res.status, message);
|
|
}
|
|
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
async function authRequest<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const res = await fetch(path, {
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json", ...options?.headers },
|
|
...options,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
let message = `HTTP ${res.status}`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.error) message = body.error;
|
|
} catch {}
|
|
throw new ApiError(res.status, message);
|
|
}
|
|
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
export const auth = {
|
|
bootstrapStatus(): Promise<BootstrapStatus> {
|
|
return authRequest<BootstrapStatus>("/auth/bootstrap-status");
|
|
},
|
|
|
|
bootstrap(input: { instance_name: string; email: string; password: string }): Promise<BootstrapResponse> {
|
|
return authRequest<BootstrapResponse>("/auth/bootstrap", {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
},
|
|
|
|
login(email: string, password: string): Promise<{ ok: boolean }> {
|
|
return authRequest<{ ok: boolean }>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
},
|
|
|
|
logout(): Promise<void> {
|
|
return authRequest<void>("/auth/logout", { method: "POST" });
|
|
},
|
|
|
|
me(): Promise<MeResponse> {
|
|
return authRequest<MeResponse>("/auth/me");
|
|
},
|
|
|
|
/** Unauthenticated: what the login page draws itself from. */
|
|
providers(): Promise<ProvidersResponse> {
|
|
return authRequest<ProvidersResponse>("/auth/providers");
|
|
},
|
|
|
|
/** Where a provider button sends the browser. */
|
|
ssoStartUrl(providerId: string): string {
|
|
return `/auth/oidc/${providerId}/start`;
|
|
},
|
|
};
|
|
|
|
export const api = {
|
|
listInstanceUsers(): Promise<InstanceUser[]> {
|
|
return request<InstanceUser[]>("/instance/users");
|
|
},
|
|
|
|
createInstanceUser(input: OrgUserInput): Promise<InstanceUser> {
|
|
return request<InstanceUser>("/instance/users", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updateInstanceUserRole(userId: string, role: Role): Promise<{ ok: boolean }> {
|
|
return request<{ ok: boolean }>(`/instance/users/${userId}/role`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ role }),
|
|
});
|
|
},
|
|
|
|
deleteInstanceUser(userId: string): Promise<void> {
|
|
return request<void>(`/instance/users/${userId}`, { method: "DELETE" });
|
|
},
|
|
|
|
listAuthPresets(): Promise<AuthPreset[]> {
|
|
return request<AuthPreset[]>("/auth/presets");
|
|
},
|
|
|
|
listAuthProviders(): Promise<AuthProvider[]> {
|
|
return request<AuthProvider[]>("/auth/providers");
|
|
},
|
|
|
|
createAuthProvider(input: AuthProviderInput): Promise<AuthProvider> {
|
|
return request<AuthProvider>("/auth/providers", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updateAuthProvider(id: string, input: AuthProviderUpdate): Promise<{ saved: boolean }> {
|
|
return request<{ saved: boolean }>(`/auth/providers/${id}`, { method: "PUT", body: JSON.stringify(input) });
|
|
},
|
|
|
|
deleteAuthProvider(id: string): Promise<{ deleted: boolean }> {
|
|
return request<{ deleted: boolean }>(`/auth/providers/${id}`, { method: "DELETE" });
|
|
},
|
|
|
|
testAuthProvider(id: string): Promise<{ ok: boolean; message: string }> {
|
|
return request<{ ok: boolean; message: string }>(`/auth/providers/${id}/test`, { method: "POST" });
|
|
},
|
|
|
|
ackAuthProviderNotice(id: string): Promise<{ acknowledged: boolean }> {
|
|
return request<{ acknowledged: boolean }>(`/auth/providers/${id}/ack-notice`, { method: "POST" });
|
|
},
|
|
|
|
listServers(tags?: Record<string, string>): Promise<Server[]> {
|
|
const params = Object.entries(tags ?? {}).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`);
|
|
return request<Server[]>(`/servers${params.length ? `?${params.join("&")}` : ""}`);
|
|
},
|
|
|
|
listKnownTags(): Promise<Record<string, string[]>> {
|
|
return request<Record<string, string[]>>("/servers/tags");
|
|
},
|
|
|
|
setServerTags(serverId: string, tags: Record<string, string>): Promise<{ tags: Record<string, string> }> {
|
|
return request<{ tags: Record<string, string> }>(`/servers/${serverId}/tags`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ tags }),
|
|
});
|
|
},
|
|
|
|
getServer(serverId: string): Promise<ServerWithKeys> {
|
|
return request<ServerWithKeys>(`/servers/${serverId}`);
|
|
},
|
|
|
|
createServer(): Promise<NewServerResponse> {
|
|
return request<NewServerResponse>("/servers/new", { method: "POST" });
|
|
},
|
|
|
|
deleteServer(serverId: string): Promise<void> {
|
|
return request<void>(`/servers/${serverId}`, { method: "DELETE" });
|
|
},
|
|
|
|
generateKeyForServer(serverId: string, opts: GenerateKeyOptions): Promise<{ command_id: string }> {
|
|
return request<{ command_id: string }>(`/servers/${serverId}/generate-key`, {
|
|
method: "POST",
|
|
body: JSON.stringify(opts),
|
|
});
|
|
},
|
|
|
|
getUpdateCommand(osInfo?: string): string {
|
|
if (osInfo && osInfo.toLowerCase().includes("windows")) {
|
|
return `irm "${window.location.origin}/update.ps1" | iex`;
|
|
}
|
|
return `curl -fsSL "${window.location.origin}/update" | bash`;
|
|
},
|
|
|
|
listMonitors(): Promise<Monitor[]> {
|
|
return request<Monitor[]>("/monitors");
|
|
},
|
|
|
|
getMonitor(monitorId: string): Promise<Monitor> {
|
|
return request<Monitor>(`/monitors/${monitorId}`);
|
|
},
|
|
|
|
createMonitor(input: MonitorInput): Promise<Monitor> {
|
|
return request<Monitor>("/monitors", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updateMonitor(monitorId: string, input: Partial<MonitorInput>): Promise<void> {
|
|
return request<void>(`/monitors/${monitorId}`, { method: "PUT", body: JSON.stringify(input) });
|
|
},
|
|
|
|
deleteMonitor(monitorId: string): Promise<void> {
|
|
return request<void>(`/monitors/${monitorId}`, { method: "DELETE" });
|
|
},
|
|
|
|
getMonitorIncidents(monitorId: string): Promise<Incident[]> {
|
|
return request<Incident[]>(`/monitors/${monitorId}/incidents`);
|
|
},
|
|
|
|
getMonitorUptime(monitorId: string): Promise<Rollup[]> {
|
|
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
|
|
},
|
|
|
|
getMonitorSamples(monitorId: string, minutes: number): Promise<MonitorSample[]> {
|
|
return request<MonitorSample[]>(`/monitors/${monitorId}/samples?minutes=${minutes}`);
|
|
},
|
|
|
|
listStatusPages(): Promise<StatusPage[]> {
|
|
return request<StatusPage[]>("/status-pages");
|
|
},
|
|
|
|
getStatusPage(pageId: string): Promise<StatusPage> {
|
|
return request<StatusPage>(`/status-pages/${pageId}`);
|
|
},
|
|
|
|
createStatusPage(input: Partial<StatusPage>): Promise<StatusPage> {
|
|
return request<StatusPage>("/status-pages", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updateStatusPage(pageId: string, input: Partial<StatusPage>): Promise<StatusPage> {
|
|
return request<StatusPage>(`/status-pages/${pageId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(input),
|
|
});
|
|
},
|
|
|
|
deleteStatusPage(pageId: string): Promise<void> {
|
|
return request<void>(`/status-pages/${pageId}`, { method: "DELETE" });
|
|
},
|
|
|
|
listStatusIncidents(pageId: string): Promise<StatusIncident[]> {
|
|
return request<StatusIncident[]>(`/status-pages/${pageId}/incidents`);
|
|
},
|
|
|
|
createStatusIncident(pageId: string, input: Partial<StatusIncident>): Promise<StatusIncident> {
|
|
return request<StatusIncident>(`/status-pages/${pageId}/incidents`, {
|
|
method: "POST",
|
|
body: JSON.stringify(input),
|
|
});
|
|
},
|
|
|
|
updateStatusIncident(
|
|
pageId: string,
|
|
incidentId: string,
|
|
input: Partial<StatusIncident>,
|
|
): Promise<StatusIncident> {
|
|
return request<StatusIncident>(`/status-pages/${pageId}/incidents/${incidentId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(input),
|
|
});
|
|
},
|
|
|
|
deleteStatusIncident(pageId: string, incidentId: string): Promise<void> {
|
|
return request<void>(`/status-pages/${pageId}/incidents/${incidentId}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
|
|
postStatusIncidentUpdate(
|
|
pageId: string,
|
|
incidentId: string,
|
|
status: string,
|
|
body: string,
|
|
): Promise<StatusIncident> {
|
|
return request<StatusIncident>(`/status-pages/${pageId}/incidents/${incidentId}/updates`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ status, body }),
|
|
});
|
|
},
|
|
|
|
listChannels(): Promise<NotificationChannel[]> {
|
|
return request<NotificationChannel[]>("/channels");
|
|
},
|
|
|
|
createChannel(input: ChannelInput): Promise<NotificationChannel> {
|
|
return request<NotificationChannel>("/channels", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updateChannel(channelId: string, input: Partial<ChannelInput>): Promise<void> {
|
|
return request<void>(`/channels/${channelId}`, { method: "PUT", body: JSON.stringify(input) });
|
|
},
|
|
|
|
deleteChannel(channelId: string): Promise<void> {
|
|
return request<void>(`/channels/${channelId}`, { method: "DELETE" });
|
|
},
|
|
|
|
testChannel(channelId: string): Promise<{ status: string }> {
|
|
return request<{ status: string }>(`/channels/${channelId}/test`, { method: "POST" });
|
|
},
|
|
|
|
getLatestAgentVersion(): Promise<{ version: string }> {
|
|
return request<{ version: string }>("/agent/latest-version");
|
|
},
|
|
|
|
updateAgent(serverId: string): Promise<{ message: string; version: string }> {
|
|
return request<{ message: string; version: string }>(`/servers/${serverId}/update-agent`, {
|
|
method: "POST",
|
|
});
|
|
},
|
|
|
|
applyUpdates(serverId: string, source?: "vulnerabilities"): Promise<{ message: string; run_id?: string }> {
|
|
const qs = source ? `?source=${source}` : "";
|
|
return request<{ message: string; run_id?: string }>(`/servers/${serverId}/apply-updates${qs}`, {
|
|
method: "POST",
|
|
});
|
|
},
|
|
|
|
listMaintenanceWindows(): Promise<MaintenanceWindow[]> {
|
|
return request<MaintenanceWindow[]>("/maintenance-windows");
|
|
},
|
|
|
|
createMaintenanceWindow(input: MaintenanceWindowInput): Promise<MaintenanceWindow> {
|
|
return request<MaintenanceWindow>("/maintenance-windows", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updateMaintenanceWindow(windowId: string, input: MaintenanceWindowInput): Promise<MaintenanceWindow> {
|
|
return request<MaintenanceWindow>(`/maintenance-windows/${windowId}`, { method: "PUT", body: JSON.stringify(input) });
|
|
},
|
|
|
|
deleteMaintenanceWindow(windowId: string): Promise<void> {
|
|
return request<void>(`/maintenance-windows/${windowId}`, { method: "DELETE" });
|
|
},
|
|
|
|
previewMaintenanceWindow(input: Omit<MaintenanceWindowInput, "name">): Promise<WindowSpan[]> {
|
|
return request<WindowSpan[]>("/maintenance-windows/preview", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
listPatchPolicies(): Promise<PatchPolicy[]> {
|
|
return request<PatchPolicy[]>("/patch-policies");
|
|
},
|
|
|
|
createPatchPolicy(input: PatchPolicyInput): Promise<PatchPolicy> {
|
|
return request<PatchPolicy>("/patch-policies", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updatePatchPolicy(policyId: string, input: PatchPolicyInput): Promise<PatchPolicy> {
|
|
return request<PatchPolicy>(`/patch-policies/${policyId}`, { method: "PUT", body: JSON.stringify(input) });
|
|
},
|
|
|
|
deletePatchPolicy(policyId: string): Promise<void> {
|
|
return request<void>(`/patch-policies/${policyId}`, { method: "DELETE" });
|
|
},
|
|
|
|
runPatchPolicyNow(policyId: string): Promise<PatchRun> {
|
|
return request<PatchRun>(`/patch-policies/${policyId}/run-now`, { method: "POST" });
|
|
},
|
|
|
|
listPatchRuns(params: { policy_id?: string; server_id?: string; limit?: number } = {}): Promise<PatchRun[]> {
|
|
const qs = new URLSearchParams();
|
|
if (params.policy_id) qs.set("policy_id", params.policy_id);
|
|
if (params.server_id) qs.set("server_id", params.server_id);
|
|
if (params.limit) qs.set("limit", String(params.limit));
|
|
const suffix = qs.toString();
|
|
return request<PatchRun[]>(`/patch-runs${suffix ? `?${suffix}` : ""}`);
|
|
},
|
|
|
|
getPatchRun(runId: string): Promise<PatchRun> {
|
|
return request<PatchRun>(`/patch-runs/${runId}`);
|
|
},
|
|
|
|
cancelPatchRun(runId: string): Promise<void> {
|
|
return request<void>(`/patch-runs/${runId}/cancel`, { method: "POST" });
|
|
},
|
|
|
|
listAuditEvents(params: AuditQuery = {}): Promise<AuditPage> {
|
|
const qs = new URLSearchParams();
|
|
if (params.q) qs.set("q", params.q);
|
|
if (params.category) qs.set("category", params.category);
|
|
if (params.limit) qs.set("limit", String(params.limit));
|
|
if (params.skip) qs.set("skip", String(params.skip));
|
|
const suffix = qs.toString();
|
|
return request<AuditPage>(`/audit${suffix ? `?${suffix}` : ""}`);
|
|
},
|
|
|
|
getSettings(): Promise<Settings> {
|
|
return request<Settings>("/settings");
|
|
},
|
|
|
|
saveSettings(settings: {
|
|
alerts: AlertSettings;
|
|
workflow_log_retention_days?: number | null;
|
|
local_login_enabled?: boolean;
|
|
api_token_max_days?: number | null;
|
|
}): Promise<{ saved: boolean }> {
|
|
return request<{ saved: boolean }>("/settings", {
|
|
method: "PUT",
|
|
body: JSON.stringify(settings),
|
|
});
|
|
},
|
|
|
|
rotateSecretsToken(): Promise<{ token: string }> {
|
|
return request<{ token: string }>("/settings/secrets-token", { method: "POST" });
|
|
},
|
|
|
|
listApiTokens(all = false): Promise<{ tokens: ApiToken[]; all: boolean }> {
|
|
return request<{ tokens: ApiToken[]; all: boolean }>(`/tokens${all ? "?all=true" : ""}`);
|
|
},
|
|
|
|
listTokenScopes(): Promise<{ scopes: string[] }> {
|
|
return request<{ scopes: string[] }>("/tokens/scopes");
|
|
},
|
|
|
|
createApiToken(body: {
|
|
name: string;
|
|
role: Role;
|
|
scopes: string[];
|
|
tag_selector?: Record<string, string>;
|
|
expires_in_days?: number | null;
|
|
}): Promise<{ token: string; record: ApiToken }> {
|
|
return request<{ token: string; record: ApiToken }>("/tokens", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
},
|
|
|
|
revokeApiToken(tokenId: string): Promise<{ revoked: boolean }> {
|
|
return request<{ revoked: boolean }>(`/tokens/${tokenId}`, { method: "DELETE" });
|
|
},
|
|
|
|
listSecretGroups(): Promise<SecretGroupSummary[]> {
|
|
return request<SecretGroupSummary[]>("/secrets");
|
|
},
|
|
|
|
createSecretGroup(group: string, values: Record<string, string>): Promise<{ group: string }> {
|
|
return request<{ group: string }>("/secrets", {
|
|
method: "POST",
|
|
body: JSON.stringify({ group, values }),
|
|
});
|
|
},
|
|
|
|
getSecretGroup(group: string): Promise<{ group: string; secrets: Secret[] }> {
|
|
return request<{ group: string; secrets: Secret[] }>(`/secrets/${encodeURIComponent(group)}`);
|
|
},
|
|
|
|
putSecrets(group: string, values: Record<string, string>): Promise<{ saved: boolean }> {
|
|
return request<{ saved: boolean }>(`/secrets/${encodeURIComponent(group)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(values),
|
|
});
|
|
},
|
|
|
|
revealSecret(group: string, key: string): Promise<{ value: string }> {
|
|
return request<{ value: string }>(`/secrets/${encodeURIComponent(group)}/reveal`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ key }),
|
|
});
|
|
},
|
|
|
|
deleteSecret(group: string, key: string): Promise<void> {
|
|
return request<void>(`/secrets/${encodeURIComponent(group)}/${encodeURIComponent(key)}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
|
|
deleteSecretGroup(group: string): Promise<void> {
|
|
return request<void>(`/secrets/${encodeURIComponent(group)}`, { method: "DELETE" });
|
|
},
|
|
|
|
listKeys(): Promise<Key[]> {
|
|
return request<Key[]>("/keys");
|
|
},
|
|
|
|
getKey(keyId: string): Promise<KeyWithAssignments> {
|
|
return request<KeyWithAssignments>(`/keys/${keyId}`);
|
|
},
|
|
|
|
uploadKey(label: string, public_key: string, private_key?: string, passphrase?: string): Promise<Key> {
|
|
return request<Key>("/keys", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
label,
|
|
public_key,
|
|
private_key: private_key || undefined,
|
|
passphrase: passphrase || undefined,
|
|
}),
|
|
});
|
|
},
|
|
|
|
getPrivateKey(keyId: string): Promise<{ private_key: string }> {
|
|
return request<{ private_key: string }>(`/keys/${keyId}/private-key`);
|
|
},
|
|
|
|
deleteKey(keyId: string): Promise<void> {
|
|
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
|
|
},
|
|
|
|
assignKey(keyId: string, serverId: string): Promise<Assignment> {
|
|
return request<Assignment>(`/keys/${keyId}/assign`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ server_id: serverId }),
|
|
});
|
|
},
|
|
|
|
revokeKey(keyId: string, serverId: string): Promise<void> {
|
|
return request<void>(`/keys/${keyId}/assign/${serverId}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
|
|
connectConsole(body: ConsoleConnectRequest): Promise<ConsoleConnectResponse> {
|
|
return request<ConsoleConnectResponse>("/console/connect", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
},
|
|
|
|
listSteps(): Promise<WorkflowStep[]> {
|
|
return request<WorkflowStep[]>("/steps");
|
|
},
|
|
|
|
createStep(s: Partial<WorkflowStep>): Promise<WorkflowStep> {
|
|
return request<WorkflowStep>("/steps", {
|
|
method: "POST",
|
|
body: JSON.stringify(s),
|
|
});
|
|
},
|
|
|
|
updateStep(stepId: string, s: Partial<WorkflowStep>): Promise<WorkflowStep> {
|
|
return request<WorkflowStep>(`/steps/${stepId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(s),
|
|
});
|
|
},
|
|
|
|
deleteStep(stepId: string): Promise<void> {
|
|
return request<void>(`/steps/${stepId}`, { method: "DELETE" });
|
|
},
|
|
|
|
exportStepUrl(stepId: string): string {
|
|
return `/api/steps/${stepId}/export`;
|
|
},
|
|
|
|
importStep(doc: unknown): Promise<WorkflowStep> {
|
|
return request<WorkflowStep>("/steps/import", {
|
|
method: "POST",
|
|
body: JSON.stringify(doc),
|
|
});
|
|
},
|
|
|
|
parseStep(doc: unknown): Promise<WorkflowStep> {
|
|
return request<WorkflowStep>("/steps/parse", {
|
|
method: "POST",
|
|
body: JSON.stringify(doc),
|
|
});
|
|
},
|
|
|
|
seedDefaults(): Promise<{ created: number; updated: number }> {
|
|
return request<{ created: number; updated: number }>("/steps/seed-defaults", {
|
|
method: "POST",
|
|
});
|
|
},
|
|
|
|
stepUsage(): Promise<Record<string, number>> {
|
|
return request<Record<string, number>>("/steps/usage");
|
|
},
|
|
|
|
listWorkflows(): Promise<Workflow[]> {
|
|
return request<Workflow[]>("/workflows");
|
|
},
|
|
|
|
getWorkflow(workflowId: string): Promise<Workflow> {
|
|
return request<Workflow>(`/workflows/${workflowId}`);
|
|
},
|
|
|
|
createWorkflow(w: Partial<Workflow>): Promise<Workflow> {
|
|
return request<Workflow>("/workflows", {
|
|
method: "POST",
|
|
body: JSON.stringify(w),
|
|
});
|
|
},
|
|
|
|
updateWorkflow(workflowId: string, w: Partial<Workflow>): Promise<Workflow> {
|
|
return request<Workflow>(`/workflows/${workflowId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(w),
|
|
});
|
|
},
|
|
|
|
deleteWorkflow(workflowId: string): Promise<void> {
|
|
return request<void>(`/workflows/${workflowId}`, { method: "DELETE" });
|
|
},
|
|
|
|
runWorkflow(workflowId: string): Promise<{ run_id: string }> {
|
|
return request<{ run_id: string }>(`/workflows/${workflowId}/run`, {
|
|
method: "POST",
|
|
});
|
|
},
|
|
|
|
listRuns(workflowId: string): Promise<WorkflowRun[]> {
|
|
return request<WorkflowRun[]>(`/workflows/${workflowId}/runs`);
|
|
},
|
|
|
|
getRun(runId: string): Promise<WorkflowRun> {
|
|
return request<WorkflowRun>(`/runs/${runId}`);
|
|
},
|
|
|
|
cancelRun(runId: string): Promise<void> {
|
|
return request<void>(`/runs/${runId}/cancel`, { method: "POST" });
|
|
},
|
|
|
|
async getServerRunLog(runId: string, serverId: string): Promise<string> {
|
|
const res = await fetch(`/api/runs/${runId}/servers/${serverId}/logs`, {
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) throw new Error("no logs");
|
|
return res.text();
|
|
},
|
|
|
|
serverRunLogStreamUrl(runId: string, serverId: string): string {
|
|
return `/api/runs/${runId}/servers/${serverId}/logs/stream`;
|
|
},
|
|
|
|
setWorkflowSchedule(workflowId: string, schedule: Schedule): Promise<{ schedule: Schedule; next_run_at: string | null }> {
|
|
return request(`/workflows/${workflowId}/schedule`, { method: "PUT", body: JSON.stringify(schedule) });
|
|
},
|
|
|
|
previewSchedule(workflowId: string, cron: string, tz: string): Promise<{ occurrences: string[] }> {
|
|
return request(`/workflows/${workflowId}/schedule/preview?cron=${encodeURIComponent(cron)}&tz=${encodeURIComponent(tz)}`);
|
|
},
|
|
};
|
|
|
|
export type LicenseState = "valid" | "expired" | "invalid";
|
|
|
|
export interface LicenseInfo {
|
|
instance_id: string;
|
|
state: LicenseState;
|
|
reason?: string;
|
|
tier?: string;
|
|
support_level?: string;
|
|
expires_at?: string;
|
|
days_remaining?: number;
|
|
limits: {
|
|
max_servers: number;
|
|
max_monitors: number;
|
|
max_secret_groups: number;
|
|
max_channels: number;
|
|
audit_retention_days: number;
|
|
};
|
|
features: Record<string, boolean>;
|
|
usage: {
|
|
servers: number;
|
|
monitors: number;
|
|
secret_groups: number;
|
|
channels: number;
|
|
};
|
|
source: string;
|
|
/** "cloud" | "self_hosted". A cloud instance's licence is managed in HQ. */
|
|
deployment: string;
|
|
}
|
|
|
|
export type Severity = "critical" | "high" | "medium" | "low" | "unknown";
|
|
export type FindingState = "open" | "fixed" | "accepted";
|
|
|
|
export interface Acceptance {
|
|
by: string;
|
|
reason: string;
|
|
until: string;
|
|
at: string;
|
|
}
|
|
|
|
export interface VulnFinding {
|
|
id: string;
|
|
server_id: string;
|
|
cve_id: string;
|
|
package_name: string;
|
|
installed_version: string;
|
|
/** Absent means no vendor fix is published - a real state, not missing data. */
|
|
fixed_in?: string;
|
|
severity: Severity;
|
|
cvss_score?: number;
|
|
title?: string;
|
|
references?: string[];
|
|
state: FindingState;
|
|
first_seen: string;
|
|
last_seen: string;
|
|
fixed_at?: string;
|
|
accepted?: Acceptance;
|
|
}
|
|
|
|
/** One CVE across every server it affects. The board groups by CVE because the
|
|
* same CVE on forty servers is one decision, not forty rows. */
|
|
export interface VulnGroup {
|
|
cve_id: string;
|
|
severity: Severity;
|
|
title?: string;
|
|
server_count: number;
|
|
findings: VulnFinding[];
|
|
}
|
|
|
|
export interface VulnSummary {
|
|
counts: Partial<Record<Severity, number>>;
|
|
db_version?: number;
|
|
pulled_at?: string;
|
|
last_full_scan_at?: string;
|
|
last_error?: string;
|
|
}
|
|
|
|
export interface InstalledPackage {
|
|
name: string;
|
|
version: string;
|
|
epoch?: number;
|
|
arch: string;
|
|
source_name?: string;
|
|
}
|
|
|
|
export interface ServerPackages {
|
|
server_id: string;
|
|
os: { family: string; version_id: string; arch: string };
|
|
hash: string;
|
|
packages: InstalledPackage[];
|
|
collected_at: string;
|
|
scan_pending: boolean;
|
|
scanned_at?: string;
|
|
/** "ok" | "unsupported". Unsupported must never read as "clean". */
|
|
status: string;
|
|
db_version: number;
|
|
}
|
|
|
|
export interface PackageHit {
|
|
server_id: string;
|
|
name: string;
|
|
version: string;
|
|
}
|
|
|
|
export interface VulnAlertRule {
|
|
id: string;
|
|
name: string;
|
|
enabled: boolean;
|
|
min_severity: Severity;
|
|
tags?: Record<string, string>;
|
|
channel_ids: string[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface VulnAlertRuleInput {
|
|
name: string;
|
|
enabled: boolean;
|
|
min_severity: Severity;
|
|
tags?: Record<string, string>;
|
|
channel_ids: string[];
|
|
}
|
|
|
|
export const vulnerabilities = {
|
|
list(params?: { severity?: string; state?: string; server?: string; hasFix?: boolean; tags?: Record<string, string> }): Promise<VulnGroup[]> {
|
|
const q = new URLSearchParams();
|
|
if (params?.severity) q.set("severity", params.severity);
|
|
if (params?.state) q.set("state", params.state);
|
|
if (params?.server) q.set("server", params.server);
|
|
// Explicitly undefined-checked: `false` is a real selection here (the
|
|
// unfixable set), so a truthiness test would silently drop it.
|
|
if (params?.hasFix !== undefined) q.set("has_fix", String(params.hasFix));
|
|
for (const [k, v] of Object.entries(params?.tags ?? {})) q.append("tag", `${k}:${v}`);
|
|
const qs = q.toString();
|
|
return request<VulnGroup[]>(`/vulnerabilities${qs ? `?${qs}` : ""}`);
|
|
},
|
|
|
|
summary(): Promise<VulnSummary> {
|
|
return request<VulnSummary>("/vulnerabilities/summary");
|
|
},
|
|
|
|
rescan(): Promise<{ queued: number }> {
|
|
return request<{ queued: number }>("/vulnerabilities/rescan", { method: "POST" });
|
|
},
|
|
|
|
accept(id: string, reason: string, until: string): Promise<VulnFinding> {
|
|
return request<VulnFinding>(`/vulnerabilities/${id}/accept`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ reason, until }),
|
|
});
|
|
},
|
|
|
|
unaccept(id: string): Promise<VulnFinding> {
|
|
return request<VulnFinding>(`/vulnerabilities/${id}/accept`, { method: "DELETE" });
|
|
},
|
|
|
|
forServer(serverId: string): Promise<VulnFinding[]> {
|
|
return request<VulnFinding[]>(`/servers/${serverId}/vulnerabilities`);
|
|
},
|
|
|
|
packagesForServer(serverId: string): Promise<ServerPackages | { reported: false }> {
|
|
return request<ServerPackages | { reported: false }>(`/servers/${serverId}/packages`);
|
|
},
|
|
|
|
searchPackages(name: string): Promise<PackageHit[]> {
|
|
return request<PackageHit[]>(`/packages/search?name=${encodeURIComponent(name)}`);
|
|
},
|
|
|
|
listRules(): Promise<VulnAlertRule[]> {
|
|
return request<VulnAlertRule[]>("/vuln-rules");
|
|
},
|
|
|
|
createRule(input: VulnAlertRuleInput): Promise<VulnAlertRule> {
|
|
return request<VulnAlertRule>("/vuln-rules", { method: "POST", body: JSON.stringify(input) });
|
|
},
|
|
|
|
updateRule(id: string, input: VulnAlertRuleInput): Promise<{ status: string }> {
|
|
return request<{ status: string }>(`/vuln-rules/${id}`, { method: "PUT", body: JSON.stringify(input) });
|
|
},
|
|
|
|
deleteRule(id: string): Promise<{ status: string }> {
|
|
return request<{ status: string }>(`/vuln-rules/${id}`, { method: "DELETE" });
|
|
},
|
|
};
|
|
|
|
export type WorkloadKind = "container" | "unit";
|
|
export type WorkloadAction = "start" | "stop" | "restart";
|
|
|
|
/** One Docker container, one systemd unit, or one Windows service.
|
|
*
|
|
* `state` is deliberately not a shared vocabulary across the two kinds:
|
|
* containers report running/exited/paused/restarting/created, units report
|
|
* active/inactive/failed/activating. A failed unit and an exited container
|
|
* mean different things. */
|
|
export interface Workload {
|
|
kind: WorkloadKind;
|
|
id: string;
|
|
name: string;
|
|
state: string;
|
|
health?: string;
|
|
image?: string;
|
|
stack?: string;
|
|
ports?: string[];
|
|
restarts?: number;
|
|
started_at?: string;
|
|
protected: boolean;
|
|
}
|
|
|
|
export interface ServerWorkloads {
|
|
server_id: string;
|
|
hash?: string;
|
|
workloads: Workload[];
|
|
collected_at?: string;
|
|
/** false with no error means "Docker not in use here", which is not a
|
|
* fault. With an error it means installed but not responding. */
|
|
docker_ok: boolean;
|
|
docker_error?: string;
|
|
systemd_ok: boolean;
|
|
systemd_error?: string;
|
|
}
|
|
|
|
export interface WorkloadHit {
|
|
server_id: string;
|
|
workload: Workload;
|
|
}
|
|
|
|
export const workloads = {
|
|
forServer(serverId: string): Promise<ServerWorkloads> {
|
|
return request<ServerWorkloads>(`/servers/${serverId}/workloads`);
|
|
},
|
|
|
|
refresh(serverId: string): Promise<{ message: string }> {
|
|
return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" });
|
|
},
|
|
|
|
control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> {
|
|
return request<{ message: string }>(
|
|
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`,
|
|
{ method: "POST", body: JSON.stringify({ kind, action }) },
|
|
);
|
|
},
|
|
|
|
logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> {
|
|
return request<{ text: string; truncated: boolean }>(
|
|
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`,
|
|
);
|
|
},
|
|
|
|
search(params?: { image?: string; stack?: string; state?: string }): Promise<WorkloadHit[]> {
|
|
const q = new URLSearchParams();
|
|
if (params?.image) q.set("image", params.image);
|
|
if (params?.stack) q.set("stack", params.stack);
|
|
if (params?.state) q.set("state", params.state);
|
|
const qs = q.toString();
|
|
return request<WorkloadHit[]>(`/workloads${qs ? `?${qs}` : ""}`);
|
|
},
|
|
};
|
|
|
|
// `request` already prefixes /api, so these paths do not repeat it.
|
|
export const licence = {
|
|
get(): Promise<LicenseInfo> {
|
|
return request<LicenseInfo>("/license");
|
|
},
|
|
put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> {
|
|
return request("/license", { method: "POST", body: JSON.stringify({ blob }) });
|
|
},
|
|
};
|
|
|
|
export interface Schedule {
|
|
enabled: boolean;
|
|
cron: string;
|
|
tz: string;
|
|
}
|
|
|
|
export interface Skip {
|
|
reason: string;
|
|
due: string;
|
|
at: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public status page types
|
|
//
|
|
// These mirror services.StatusSnapshot and friends in
|
|
// server/internal/services/statussnapshot.go field for field - that Go file
|
|
// is the contract. They back the anonymous /status/[pageId] page, which is
|
|
// deliberately outside the (app) route group and never calls `request()`
|
|
// (no session, no auth). Task 10 adds the authoring types and api client
|
|
// methods alongside these; it must not redeclare this block.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PublicDay {
|
|
date: string;
|
|
state: "up" | "down" | "maintenance" | "no_data";
|
|
uptime: number;
|
|
}
|
|
|
|
export interface PublicComponent {
|
|
name: string;
|
|
status: "up" | "down" | "maintenance" | "pending" | "no_data";
|
|
uptime_90d: number;
|
|
days: PublicDay[];
|
|
}
|
|
|
|
export interface PublicSection {
|
|
name: string;
|
|
components: PublicComponent[];
|
|
}
|
|
|
|
export interface PublicIncidentUpdate {
|
|
at: string;
|
|
status: string;
|
|
body: string;
|
|
}
|
|
|
|
export interface PublicIncident {
|
|
id: string;
|
|
kind: string;
|
|
title: string;
|
|
impact?: string;
|
|
status: string;
|
|
affected?: string[];
|
|
started_at: string;
|
|
resolved_at?: string;
|
|
scheduled_start?: string;
|
|
scheduled_end?: string;
|
|
updates?: PublicIncidentUpdate[];
|
|
}
|
|
|
|
export interface PublicBanner {
|
|
level: string;
|
|
text: string;
|
|
}
|
|
|
|
export interface StatusSnapshot {
|
|
available: boolean;
|
|
reason?: string;
|
|
title: string;
|
|
description?: string;
|
|
logo_url?: string;
|
|
banner?: PublicBanner;
|
|
overall: string;
|
|
sections: PublicSection[];
|
|
active_incidents: PublicIncident[];
|
|
upcoming_maintenance: PublicIncident[];
|
|
history: PublicIncident[];
|
|
generated_at: string;
|
|
}
|