New api/logretention package: GET /logs/retention/preview and DELETE /logs/retention, both gated to RoleAdmin (Owner satisfies it too), issue purpose-built parameterized statements against ClickHouse's logs table (a count and a synchronous ALTER TABLE ... DELETE mutation) rather than routing through querylang/executor's SELECT-only SQLRunner. Settings gets a new "Log retention" section, visible only to an owner or admin, that previews how many records a chosen age cutoff would remove before showing an explicit confirm/cancel panel -- no delete happens without that second step. Scoped to core's single-tenant ClickHouse table; enterprise/'s per-tenant routing and Tantivy's lack of a bulk-delete primitive are disclosed gaps in api/logretention/store.go's doc comment, not silently assumed to already work.
736 lines
27 KiB
TypeScript
736 lines
27 KiB
TypeScript
// First real API-client module -- previously each route did its own
|
|
// inline fetch(). Introduced in Phase 3 because the surface triples
|
|
// (query + dashboards + panels + export/import); still zero-dependency,
|
|
// a thin fetch wrapper, not a generated client.
|
|
|
|
export const apiBase = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080';
|
|
export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http://localhost:8081';
|
|
// Optional third backend (Phase 4; enterprise/ is AGPLv3 same as core
|
|
// as of Phase 6, but stays a separate optional service architecturally)
|
|
// -- undefined in a deployment that hasn't built/deployed enterprise-auth,
|
|
// same "runtime capability check" shape /docs/phase-4-rbac-design.md's
|
|
// Web UI boundary section describes. getAuthFeatures below treats a
|
|
// missing base URL the same as a failed fetch: everything reports
|
|
// disabled, no broken links.
|
|
export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL as string | undefined;
|
|
|
|
// Local login (see api/localauth's package doc comment). Baked in at
|
|
// build time same as the base URLs above -- requestFrom/alertingRequest
|
|
// below only send `credentials: 'include'` when this is true, since
|
|
// api's/alerting's own CORS stays the permissive wildcard-friendly
|
|
// WithCORS (no Access-Control-Allow-Credentials) unless the deployment
|
|
// set LOCAL_AUTH_ENABLED server-side too -- browsers categorically
|
|
// refuse to combine a credentialed fetch with a wildcard
|
|
// Access-Control-Allow-Origin, so sending credentials unconditionally
|
|
// would break every plain `docker compose up` local-dev deployment,
|
|
// which never sets either of these.
|
|
export const localAuthEnabled = import.meta.env.VITE_LOCAL_AUTH_ENABLED === 'true';
|
|
|
|
export type Language = '' | 'sql' | 'spl';
|
|
|
|
// warnings (Phase 7) is populated by the shared costguard package's
|
|
// assessment of every query, hand-written or AI-suggested alike --
|
|
// informational only, never a reason a query didn't run. Optional/absent
|
|
// (not an empty array) when there's nothing to say, matching the
|
|
// backend's `omitempty` -- see api/queryapi/handler.go's doc comment on
|
|
// why this is additive, not new enforcement.
|
|
export type QueryResult = { columns: string[]; rows: unknown[][]; warnings?: string[] };
|
|
|
|
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n' | 'heatmap';
|
|
|
|
export type Panel = {
|
|
id: string;
|
|
dashboard_id: string;
|
|
title: string;
|
|
query: string;
|
|
query_language: Language;
|
|
viz_type: VizType;
|
|
viz_config: Record<string, string>;
|
|
position_x: number;
|
|
position_y: number;
|
|
width: number;
|
|
height: number;
|
|
earliest_override: string | null;
|
|
latest_override: string | null;
|
|
sort_order: number;
|
|
};
|
|
|
|
export type Dashboard = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
default_earliest: string;
|
|
default_latest: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
panels: Panel[] | null;
|
|
};
|
|
|
|
class ApiError extends Error {}
|
|
|
|
async function requestFrom<T>(base: string, path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${base}${path}`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...(localAuthEnabled ? { credentials: 'include' as RequestCredentials } : {}),
|
|
...init
|
|
});
|
|
if (!res.ok) {
|
|
let message = `request failed with status ${res.status}`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.error) message = body.error;
|
|
} catch {
|
|
// non-JSON error body -- keep the generic message
|
|
}
|
|
throw new ApiError(message);
|
|
}
|
|
if (res.status === 204) return undefined as T;
|
|
return res.json();
|
|
}
|
|
|
|
function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
return requestFrom(apiBase, path, init);
|
|
}
|
|
|
|
// alerting is a separate service (its own base URL) -- see
|
|
// /docs/phase-3-alerting-design.md's component boundary.
|
|
function alertingRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
|
return requestFrom(alertingBase, path, init);
|
|
}
|
|
|
|
export function runQuery(query: string, language: Language): Promise<QueryResult> {
|
|
return request('/query', { method: 'POST', body: JSON.stringify({ query, language }) });
|
|
}
|
|
|
|
// ---- AI-assisted query authoring (Phase 7 Track A) ----
|
|
// Every function here returns text/suggestions only -- running anything
|
|
// still goes through runQuery above, unchanged, per the phase's
|
|
// non-negotiable "no parallel execution path" design principle. A
|
|
// deployment with no OLLAMA_BASE_URL configured has these routes
|
|
// entirely unregistered server-side (api/cmd/api's main.go), so a 404
|
|
// here is a normal, expected "AI isn't enabled" response, not a bug --
|
|
// callers (QueryBar.svelte) treat any error from these functions as
|
|
// "AI unavailable right now," never a user-facing failure.
|
|
|
|
export type Confidence = 'high' | 'medium' | 'low';
|
|
|
|
export function aiComplete(queryPrefix: string, language: string): Promise<{ suggestion: string }> {
|
|
return request('/ai/complete', { method: 'POST', body: JSON.stringify({ queryPrefix, language }) });
|
|
}
|
|
|
|
export function aiExplain(
|
|
query: string,
|
|
language: string,
|
|
originalIntent?: string
|
|
): Promise<{ explanation: string }> {
|
|
return request('/ai/explain', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ query, language, originalIntent: originalIntent ?? '' })
|
|
});
|
|
}
|
|
|
|
export type FixResponse = {
|
|
suggestedQuery: string;
|
|
explanation: string;
|
|
confidence: Confidence | '';
|
|
blocked: boolean;
|
|
costWarnings?: string[];
|
|
};
|
|
|
|
export function aiFix(
|
|
query: string,
|
|
language: string,
|
|
opts: { parseError?: string; executionError?: string }
|
|
): Promise<FixResponse> {
|
|
return request('/ai/fix', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
query,
|
|
language,
|
|
parseError: opts.parseError ?? '',
|
|
executionError: opts.executionError ?? ''
|
|
})
|
|
});
|
|
}
|
|
|
|
export type OptimizeResponse = {
|
|
findings: string[];
|
|
phrased: string;
|
|
suggestedQuery?: string;
|
|
};
|
|
|
|
export function aiOptimize(query: string, language: string): Promise<OptimizeResponse> {
|
|
return request('/ai/optimize', { method: 'POST', body: JSON.stringify({ query, language }) });
|
|
}
|
|
|
|
// ---- Natural language translation (Phase 7 Track B) ----
|
|
// Same non-negotiable split as every other AI operation: this returns a
|
|
// query for review, never executes it. Running the result is the exact
|
|
// same runQuery() above, reused unchanged -- task 9's explicit
|
|
// requirement.
|
|
export type TranslateResponse = {
|
|
query: string;
|
|
confidence: Confidence | '';
|
|
lowConfidenceReason?: string;
|
|
compiles: boolean;
|
|
compileError?: string;
|
|
blocked: boolean;
|
|
costWarnings?: string[];
|
|
};
|
|
|
|
export function aiTranslate(nlQuery: string): Promise<TranslateResponse> {
|
|
return request('/ai/translate', { method: 'POST', body: JSON.stringify({ nlQuery }) });
|
|
}
|
|
|
|
// ---- Interaction audit logging (task 12) ----
|
|
// Fire-and-forget: a failure here (including AI being unconfigured
|
|
// server-side, same 404-is-normal posture as every other /ai/* call)
|
|
// must never block or surface an error for the accept/dismiss action
|
|
// that triggered it, so callers should not await rejection handling
|
|
// beyond a swallowed .catch(() => {}).
|
|
export type InteractionOperation = 'translate' | 'fix' | 'optimize';
|
|
|
|
export function logInteraction(entry: {
|
|
operation: InteractionOperation;
|
|
input: string;
|
|
output: string;
|
|
confidence?: Confidence | '';
|
|
accepted: boolean;
|
|
edited: boolean;
|
|
finalQuery?: string;
|
|
}): Promise<void> {
|
|
return request('/ai/log-interaction', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
operation: entry.operation,
|
|
input: entry.input,
|
|
output: entry.output,
|
|
confidence: entry.confidence ?? '',
|
|
accepted: entry.accepted,
|
|
edited: entry.edited,
|
|
finalQuery: entry.finalQuery ?? ''
|
|
})
|
|
});
|
|
}
|
|
|
|
export function listDashboards(): Promise<Dashboard[]> {
|
|
return request('/dashboards').then((d) => (d as Dashboard[]) ?? []);
|
|
}
|
|
|
|
export function getDashboard(id: string): Promise<Dashboard> {
|
|
return request(`/dashboards/${id}`);
|
|
}
|
|
|
|
export function createDashboard(input: {
|
|
name: string;
|
|
description?: string;
|
|
default_earliest?: string;
|
|
default_latest?: string;
|
|
}): Promise<Dashboard> {
|
|
return request('/dashboards', { method: 'POST', body: JSON.stringify(input) });
|
|
}
|
|
|
|
export function updateDashboard(
|
|
id: string,
|
|
input: { name: string; description?: string; default_earliest?: string; default_latest?: string }
|
|
): Promise<Dashboard> {
|
|
return request(`/dashboards/${id}`, { method: 'PUT', body: JSON.stringify(input) });
|
|
}
|
|
|
|
export function deleteDashboard(id: string): Promise<void> {
|
|
return request(`/dashboards/${id}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export function addPanel(dashboardId: string, panel: Partial<Panel>): Promise<Panel> {
|
|
return request(`/dashboards/${dashboardId}/panels`, { method: 'POST', body: JSON.stringify(panel) });
|
|
}
|
|
|
|
export function updatePanel(dashboardId: string, panel: Partial<Panel>): Promise<Panel> {
|
|
return request(`/dashboards/${dashboardId}/panels/${panel.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(panel)
|
|
});
|
|
}
|
|
|
|
export function deletePanel(dashboardId: string, panelId: string): Promise<void> {
|
|
return request(`/dashboards/${dashboardId}/panels/${panelId}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export type AuthFeatures = { sso_configured: boolean; oidc_enabled: boolean; saml_enabled: boolean };
|
|
|
|
// getAuthFeatures never throws -- an absent/unreachable enterprise-auth
|
|
// is a normal, expected deployment shape (Phase 0-3-style single-tenant,
|
|
// no enterprise/ deployed), not an error condition the settings page
|
|
// should surface. Deliberately no `credentials: 'include'` here: this
|
|
// endpoint needs no session, and CORS_ALLOWED_ORIGIN's dev default of
|
|
// "*" can't be combined with credentialed fetches anyway (the browser
|
|
// rejects that combination) -- the same reason api.ts's other requests
|
|
// don't send credentials yet. Wiring session cookies through is part of
|
|
// the deferred login-flow work, alongside tightening CORS_ALLOWED_ORIGIN
|
|
// to a real origin, done together as one change so neither breaks the
|
|
// other.
|
|
export async function getAuthFeatures(): Promise<AuthFeatures> {
|
|
const disabled = { sso_configured: false, oidc_enabled: false, saml_enabled: false };
|
|
if (!enterpriseAuthBase) return disabled;
|
|
try {
|
|
const res = await fetch(`${enterpriseAuthBase}/auth/features`);
|
|
if (!res.ok) return disabled;
|
|
return await res.json();
|
|
} catch {
|
|
return disabled;
|
|
}
|
|
}
|
|
|
|
// --- tenant picker (Phase 4) -------------------------------------------
|
|
//
|
|
// The two calls below are the reason getAuthFeatures above doesn't send
|
|
// credentials but these do: they carry the short-lived
|
|
// sentry_pending_login cookie enterprise-auth's finishLogin sets when an
|
|
// identity resolves to more than one tenant_memberships row (see
|
|
// enterprise/internal/loginhandler's package doc comment), and
|
|
// selectTenant's response sets the real session cookie. Both require
|
|
// `credentials: 'include'`, which is exactly why enterprise-auth's CORS
|
|
// (httpserver.WithCredentialedCORS) can't use getAuthFeatures'/api.ts's
|
|
// other requests' wildcard-friendly posture -- browsers refuse to honor
|
|
// Access-Control-Allow-Origin: "*" on a credentialed request at all, so
|
|
// CORS_ALLOWED_ORIGIN has to name this page's real origin.
|
|
|
|
export type Membership = { tenant_id: string; tenant_display_name: string; role: string };
|
|
|
|
class TenantPickerError extends Error {}
|
|
|
|
// enterprise-auth's loginhandler responds to an error with plain
|
|
// http.Error text (e.g. "no membership in the requested tenant"), not a
|
|
// JSON {"error": "..."} body the way /api's queryapi/dashboards handlers
|
|
// do -- requestFrom's JSON-body error parsing wouldn't surface that
|
|
// message, so this reads the body as plain text instead.
|
|
async function enterpriseAuthRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
|
if (!enterpriseAuthBase) {
|
|
throw new TenantPickerError('enterprise-auth is not configured (VITE_ENTERPRISE_AUTH_BASE_URL unset)');
|
|
}
|
|
const res = await fetch(`${enterpriseAuthBase}${path}`, {
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...init
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
throw new TenantPickerError(body || `request failed with status ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// listMemberships backs the tenant-picker page's initial load -- see
|
|
// web/src/routes/select-tenant. A 400/401 (missing or expired pending
|
|
// login) surfaces as a thrown TenantPickerError; the page's own error
|
|
// state is what tells the user to start over at login.
|
|
export function listMemberships(): Promise<Membership[]> {
|
|
return enterpriseAuthRequest('/auth/memberships');
|
|
}
|
|
|
|
export function selectTenant(tenantId: string): Promise<{ redirect_url: string }> {
|
|
return enterpriseAuthRequest('/auth/select-tenant', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ tenant_id: tenantId })
|
|
});
|
|
}
|
|
|
|
export type CurrentSession = { tenant_id: string; user_id: string; role: string };
|
|
|
|
// getCurrentSession backs the sidebar's tenant indicator (Phase 5).
|
|
// POST /internal/authorize already exists (api/authz.HTTPAuthorizer and
|
|
// alerting's queryclient call it the same way) and is already reachable
|
|
// from the browser -- enterprise-auth's whole mux is wrapped in
|
|
// WithCredentialedCORS, not just the tenant-picker routes -- so this is
|
|
// a client for an existing endpoint, not a new one. Returns null for
|
|
// every "no tenant to show" case alike (no enterprise-auth configured,
|
|
// not logged in, or a plain single-tenant deployment) rather than
|
|
// throwing -- same "absence is a normal deployment shape" posture
|
|
// getAuthFeatures already uses, so the sidebar can just render nothing
|
|
// instead of branching on error types.
|
|
export async function getCurrentSession(): Promise<CurrentSession | null> {
|
|
if (!enterpriseAuthBase) return null;
|
|
try {
|
|
const res = await fetch(`${enterpriseAuthBase}/internal/authorize`, {
|
|
method: 'POST',
|
|
credentials: 'include'
|
|
});
|
|
if (!res.ok) return null;
|
|
const session = (await res.json()) as CurrentSession;
|
|
return session.tenant_id ? session : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function exportDashboard(id: string): Promise<Dashboard> {
|
|
return request(`/dashboards/${id}/export`);
|
|
}
|
|
|
|
export function importDashboard(dashboard: Dashboard): Promise<Dashboard> {
|
|
return request('/dashboards/import', { method: 'POST', body: JSON.stringify(dashboard) });
|
|
}
|
|
|
|
// resolveTimeRange applies the override-or-default rule from
|
|
// /docs/phase-3-dashboard-design.md's "Time-range mechanics": a panel's
|
|
// own earliest/latest override wins if set, otherwise the dashboard's
|
|
// default applies.
|
|
export function resolveTimeRange(
|
|
dashboard: Pick<Dashboard, 'default_earliest' | 'default_latest'>,
|
|
panel: Pick<Panel, 'earliest_override' | 'latest_override'>
|
|
): { earliest: string; latest: string } {
|
|
return {
|
|
earliest: panel.earliest_override ?? dashboard.default_earliest,
|
|
latest: panel.latest_override ?? dashboard.default_latest
|
|
};
|
|
}
|
|
|
|
// injectTimeRange prepends earliest=/latest= as leading base_search
|
|
// terms -- works because they're ordinary implicit-AND terms in Phase
|
|
// 2's grammar, order-independent. Never used for raw-SQL panels (the
|
|
// dashboards API rejects query_language: "sql" on panels entirely, so
|
|
// this never has to handle that case).
|
|
//
|
|
// "now" is a UI-only sentinel (the default_latest value shown in the
|
|
// time-range picker), not a token the query language understands --
|
|
// time_expr only accepts a quoted absolute timestamp or a "-N unit"
|
|
// relative offset (see /docs/query-language-design.md). Emitting a
|
|
// literal `latest=now` produces a real compile error ("expected a
|
|
// quoted absolute timestamp or a relative offset"), caught by actually
|
|
// running this against the live stack. Omitting the latest= clause
|
|
// entirely is the query language's own way of saying "no upper bound",
|
|
// which is exactly what "now" means here.
|
|
export function injectTimeRange(query: string, earliest: string, latest: string): string {
|
|
const clauses = [`earliest=${earliest}`];
|
|
if (latest && latest !== 'now') clauses.push(`latest=${latest}`);
|
|
return `${clauses.join(' ')} ${query}`;
|
|
}
|
|
|
|
// --- local login (single-tenant mode, see api/localauth) --------------
|
|
|
|
export type LocalSession = { user_id: string; tenant_id: string; username: string; role: string };
|
|
|
|
export function login(username: string, password: string): Promise<LocalSession & { token: string }> {
|
|
return request('/auth/login', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
}
|
|
|
|
export function logout(): Promise<void> {
|
|
return request('/auth/logout', { method: 'POST', credentials: 'include' });
|
|
}
|
|
|
|
// getLocalSession is three-state, not a boolean -- +layout.ts's route
|
|
// guard needs to tell "not logged in" (null, redirect to /login) apart
|
|
// from "this deployment doesn't have local auth turned on at all"
|
|
// ('disabled', let the request through) -- GET /auth/session is only
|
|
// ever registered server-side when LOCAL_AUTH_ENABLED is set (see
|
|
// api/localauth.Handler.RegisterRoutes' doc comment), so a 404 here
|
|
// means the latter, same "absence is a normal deployment shape" posture
|
|
// getAuthFeatures/getCurrentSession above already use for enterprise
|
|
// auth. Always sends credentials regardless of the module-level
|
|
// localAuthEnabled flag -- this is the one call the route guard makes
|
|
// unconditionally to *discover* whether local auth is on, so it can't
|
|
// rely on that flag being true first.
|
|
export async function getLocalSession(): Promise<LocalSession | 'disabled' | null> {
|
|
try {
|
|
const res = await fetch(`${apiBase}/auth/session`, { credentials: 'include' });
|
|
if (res.status === 404) return 'disabled';
|
|
if (!res.ok) return null;
|
|
return await res.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type LocalUser = { id: string; username: string; role: string; created_at: string };
|
|
|
|
export function listUsers(): Promise<LocalUser[]> {
|
|
return request<LocalUser[]>('/auth/users', { credentials: 'include' }).then((u) => u ?? []);
|
|
}
|
|
|
|
export function createUser(username: string, password: string, role: string): Promise<LocalUser> {
|
|
return request('/auth/users', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: JSON.stringify({ username, password, role })
|
|
});
|
|
}
|
|
|
|
export function deleteUser(id: string): Promise<void> {
|
|
return request(`/auth/users/${id}`, { method: 'DELETE', credentials: 'include' });
|
|
}
|
|
|
|
// resetPassword's response only carries `password` when the caller
|
|
// didn't supply one -- see api/localauth/handler.go's
|
|
// resetPasswordResponse doc comment.
|
|
export function resetPassword(id: string, newPassword?: string): Promise<{ password?: string }> {
|
|
return request(`/auth/users/${id}/reset-password`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: JSON.stringify(newPassword ? { password: newPassword } : {})
|
|
});
|
|
}
|
|
|
|
export function setUserRole(id: string, role: string): Promise<LocalUser> {
|
|
return request(`/auth/users/${id}/role`, {
|
|
method: 'PUT',
|
|
credentials: 'include',
|
|
body: JSON.stringify({ role })
|
|
});
|
|
}
|
|
|
|
// --- log retention (owner/admin only, see api/logretention) -----------
|
|
|
|
export type LogRetentionPreview = { count: number; cutoff: string };
|
|
export type LogRetentionDeleteResult = { deleted_count: number; cutoff: string };
|
|
|
|
export function previewLogDeletion(olderThanHours: number): Promise<LogRetentionPreview> {
|
|
return request(`/logs/retention/preview?older_than_hours=${olderThanHours}`, { credentials: 'include' });
|
|
}
|
|
|
|
export function deleteLogsOlderThan(olderThanHours: number): Promise<LogRetentionDeleteResult> {
|
|
return request(`/logs/retention?older_than_hours=${olderThanHours}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include'
|
|
});
|
|
}
|
|
|
|
// --- alerting ---------------------------------------------------------
|
|
|
|
export type ConditionType = 'threshold' | 'absence';
|
|
export type Comparator = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'ne';
|
|
export type NotificationKind = 'webhook' | 'slack' | 'pagerduty';
|
|
export type AlertRuleState = 'ok' | 'pending' | 'firing';
|
|
|
|
export type NotificationTarget = {
|
|
id: string;
|
|
name: string;
|
|
kind: NotificationKind;
|
|
webhook_url: string;
|
|
};
|
|
|
|
export type AlertRule = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
query: string;
|
|
query_language: Language;
|
|
condition_type: ConditionType;
|
|
comparator?: Comparator;
|
|
threshold_value?: number;
|
|
eval_interval_seconds: number;
|
|
for_minutes: number;
|
|
renotify_interval_minutes?: number;
|
|
notification_target_id: string;
|
|
enabled: boolean;
|
|
state: {
|
|
state: AlertRuleState;
|
|
last_evaluated_at?: string;
|
|
last_eval_status: 'ok' | 'error';
|
|
last_error?: string;
|
|
last_value?: number;
|
|
consecutive_errors: number;
|
|
};
|
|
};
|
|
|
|
export type DeliveryLogEntry = {
|
|
id: number;
|
|
event_type: 'firing' | 'resolved';
|
|
status: 'pending' | 'sent' | 'failed' | 'retrying';
|
|
attempt_count: number;
|
|
last_error?: string;
|
|
response_status?: number;
|
|
created_at: string;
|
|
};
|
|
|
|
export function listRules(): Promise<AlertRule[]> {
|
|
return alertingRequest<AlertRule[]>('/rules').then((r) => r ?? []);
|
|
}
|
|
|
|
export function getRule(id: string): Promise<AlertRule> {
|
|
return alertingRequest(`/rules/${id}`);
|
|
}
|
|
|
|
export function createRule(input: Partial<AlertRule>): Promise<AlertRule> {
|
|
return alertingRequest('/rules', { method: 'POST', body: JSON.stringify(input) });
|
|
}
|
|
|
|
export function deleteRule(id: string): Promise<void> {
|
|
return alertingRequest(`/rules/${id}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export function listDeliveries(ruleId: string): Promise<DeliveryLogEntry[]> {
|
|
return alertingRequest<DeliveryLogEntry[]>(`/rules/${ruleId}/deliveries`).then((d) => d ?? []);
|
|
}
|
|
|
|
export function listNotificationTargets(): Promise<NotificationTarget[]> {
|
|
return alertingRequest<NotificationTarget[]>('/targets').then((t) => t ?? []);
|
|
}
|
|
|
|
export function createNotificationTarget(input: {
|
|
name: string;
|
|
kind: NotificationKind;
|
|
webhook_url: string;
|
|
}): Promise<NotificationTarget> {
|
|
return alertingRequest('/targets', { method: 'POST', body: JSON.stringify(input) });
|
|
}
|
|
|
|
// ---- Agent inventory + remote config ----
|
|
// See /docs/agent-management-design.md. An agent only appears here
|
|
// after it's checked in at least once (GET /agents/{host} 404s until
|
|
// then) -- there's no "pre-register a host" step, inventory is purely
|
|
// observed from real check-ins.
|
|
export type ConfigOverride = {
|
|
batch_max_size?: number;
|
|
batch_flush_interval_ms?: number;
|
|
heartbeat_enabled?: boolean;
|
|
heartbeat_interval_ms?: number;
|
|
journald_unit?: string;
|
|
extra_file_paths?: string[];
|
|
};
|
|
|
|
export type Agent = {
|
|
id: string;
|
|
tenant_id: string;
|
|
host: string;
|
|
service: string;
|
|
agent_version: string;
|
|
source_kind: string;
|
|
source_detail: string;
|
|
batch_max_size: number;
|
|
batch_flush_interval_ms: number;
|
|
heartbeat_enabled: boolean;
|
|
heartbeat_interval_ms: number;
|
|
first_seen_at: string;
|
|
last_seen_at: string;
|
|
desired_override?: ConfigOverride;
|
|
desired_override_version?: string;
|
|
applied_override_version: string;
|
|
pending: boolean;
|
|
updated_by?: string;
|
|
// pending_command/command_issued_at/by (real, restart only for now
|
|
// -- see /docs/agent-management-design.md's punch list) have no
|
|
// "delivered" signal to show the way config's `pending` does:
|
|
// ingest clears pending_command the instant it hands the command to
|
|
// the agent, not once the agent confirms it ran, so
|
|
// command_issued_at is shown as a last-issued record instead.
|
|
pending_command?: string;
|
|
command_issued_at?: string;
|
|
command_issued_by?: string;
|
|
};
|
|
|
|
export function listAgents(): Promise<Agent[]> {
|
|
return request<Agent[]>('/agents').then((a) => a ?? []);
|
|
}
|
|
|
|
export function getAgent(host: string): Promise<Agent> {
|
|
return request(`/agents/${encodeURIComponent(host)}`);
|
|
}
|
|
|
|
export function setAgentConfig(host: string, override: ConfigOverride): Promise<Agent> {
|
|
return request(`/agents/${encodeURIComponent(host)}/config`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(override)
|
|
});
|
|
}
|
|
|
|
export function clearAgentConfig(host: string): Promise<void> {
|
|
return request(`/agents/${encodeURIComponent(host)}/config`, { method: 'DELETE' });
|
|
}
|
|
|
|
// "restart" is the only supported value today -- server-validated
|
|
// (400 on anything else), RoleAdmin-gated (403 for a Viewer/Editor
|
|
// session), and audit-logged. See /docs/agent-management-design.md's
|
|
// punch list for why stop/uninstall aren't here yet.
|
|
export function issueAgentCommand(host: string, command: 'restart'): Promise<Agent> {
|
|
return request(`/agents/${encodeURIComponent(host)}/command`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ command })
|
|
});
|
|
}
|
|
|
|
// ---- Host CPU/memory/disk metrics ----
|
|
// No new REST endpoints -- a metrics sample is an ordinary log record
|
|
// (see agent/README.md's "Host CPU/memory/disk metrics" section and
|
|
// agent/sentry-agent/src/main.rs's send_metrics), tagged
|
|
// `sentry.metrics=true`, fetched through the same POST /query every
|
|
// other page already uses via runQuery(). Only ever set on one agent
|
|
// process per physical host, so `stats count by host` over this tag
|
|
// naturally lists real hosts, not every fragmented per-source agent
|
|
// identity `/agents` shows (see the deployment notes on why several
|
|
// agent processes can share one physical host under different
|
|
// `[agent] host` values).
|
|
|
|
export type HostSummary = { host: string; sampleCount: number };
|
|
|
|
export async function listMetricsHosts(): Promise<HostSummary[]> {
|
|
const result = await runQuery('sentry.metrics=true | stats count by host', 'spl');
|
|
const hostIdx = result.columns.indexOf('host');
|
|
const countIdx = result.columns.indexOf('count');
|
|
return result.rows.map((r) => ({ host: String(r[hostIdx]), sampleCount: Number(r[countIdx]) }));
|
|
}
|
|
|
|
export type HostMetrics = {
|
|
host: string;
|
|
timestamp: string;
|
|
cpuPercent: number;
|
|
memUsedBytes: number;
|
|
memTotalBytes: number;
|
|
diskUsedBytes: number;
|
|
diskTotalBytes: number;
|
|
// Static-or-slow-changing context (see agent/src/metrics.rs's
|
|
// Metrics doc comment) -- sent on the same record specifically so a
|
|
// viewer never has to correlate two different samples to make sense
|
|
// of the utilization numbers above (is 21% CPU busy or idle depends
|
|
// on core count; is this usage normal depends on uptime).
|
|
cpuCores: number;
|
|
osName: string;
|
|
kernelVersion: string;
|
|
arch: string;
|
|
uptimeSeconds: number;
|
|
ipv4Addresses: string[];
|
|
ipv6Addresses: string[];
|
|
};
|
|
|
|
// Reads straight out of the record's `attributes` object (already
|
|
// returned in full on every query result row) rather than trying to
|
|
// project attribute-derived fields as top-level query-language columns
|
|
// -- simpler, and doesn't depend on `fields` supporting synthetic
|
|
// attribute columns the same way filtering does.
|
|
export async function getHostMetrics(host: string): Promise<HostMetrics | null> {
|
|
const result = await runQuery(
|
|
`host="${host}" sentry.metrics=true | sort -timestamp | head 1`,
|
|
'spl'
|
|
);
|
|
if (result.rows.length === 0) return null;
|
|
const row = result.rows[0];
|
|
const timestampIdx = result.columns.indexOf('timestamp');
|
|
const attributesIdx = result.columns.indexOf('attributes');
|
|
const attrs = (row[attributesIdx] ?? {}) as Record<string, string>;
|
|
const num = (key: string) => Number(attrs[key] ?? 0);
|
|
// Comma-joined by the agent (see agent/src/main.rs's send_metrics) --
|
|
// split back into a list here, filtering out the empty string a
|
|
// host with no addresses of a given family produces (''.split(',')
|
|
// is [''], not [], so the filter is load-bearing, not defensive).
|
|
const addrList = (key: string) => (attrs[key] ?? '').split(',').filter((a) => a !== '');
|
|
return {
|
|
host,
|
|
timestamp: String(row[timestampIdx]),
|
|
cpuPercent: num('cpu_percent'),
|
|
memUsedBytes: num('mem_used_bytes'),
|
|
memTotalBytes: num('mem_total_bytes'),
|
|
diskUsedBytes: num('disk_used_bytes'),
|
|
diskTotalBytes: num('disk_total_bytes'),
|
|
cpuCores: num('cpu_cores'),
|
|
osName: attrs['os_name'] ?? 'unknown',
|
|
kernelVersion: attrs['kernel_version'] ?? 'unknown',
|
|
arch: attrs['arch'] ?? 'unknown',
|
|
uptimeSeconds: num('uptime_seconds'),
|
|
ipv4Addresses: addrList('ipv4_addresses'),
|
|
ipv6Addresses: addrList('ipv6_addresses')
|
|
};
|
|
}
|