// 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; 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(base: string, path: string, init?: RequestInit): Promise { 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(path: string, init?: RequestInit): Promise { 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(path: string, init?: RequestInit): Promise { return requestFrom(alertingBase, path, init); } export function runQuery(query: string, language: Language): Promise { 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 { 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 { 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 { 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 { 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 { return request('/dashboards').then((d) => (d as Dashboard[]) ?? []); } export function getDashboard(id: string): Promise { return request(`/dashboards/${id}`); } export function createDashboard(input: { name: string; description?: string; default_earliest?: string; default_latest?: string; }): Promise { 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 { return request(`/dashboards/${id}`, { method: 'PUT', body: JSON.stringify(input) }); } export function deleteDashboard(id: string): Promise { return request(`/dashboards/${id}`, { method: 'DELETE' }); } export function addPanel(dashboardId: string, panel: Partial): Promise { return request(`/dashboards/${dashboardId}/panels`, { method: 'POST', body: JSON.stringify(panel) }); } export function updatePanel(dashboardId: string, panel: Partial): Promise { return request(`/dashboards/${dashboardId}/panels/${panel.id}`, { method: 'PUT', body: JSON.stringify(panel) }); } export function deletePanel(dashboardId: string, panelId: string): Promise { 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 { 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(path: string, init?: RequestInit): Promise { 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 { 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 { 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 { return request(`/dashboards/${id}/export`); } export function importDashboard(dashboard: Dashboard): Promise { 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, panel: Pick ): { 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 { return request('/auth/login', { method: 'POST', credentials: 'include', body: JSON.stringify({ username, password }) }); } export function logout(): Promise { 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 { 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 { return request('/auth/users', { credentials: 'include' }).then((u) => u ?? []); } export function createUser(username: string, password: string, role: string): Promise { return request('/auth/users', { method: 'POST', credentials: 'include', body: JSON.stringify({ username, password, role }) }); } export function deleteUser(id: string): Promise { 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 { 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 { return request(`/logs/retention/preview?older_than_hours=${olderThanHours}`, { credentials: 'include' }); } export function deleteLogsOlderThan(olderThanHours: number): Promise { 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 { return alertingRequest('/rules').then((r) => r ?? []); } export function getRule(id: string): Promise { return alertingRequest(`/rules/${id}`); } export function createRule(input: Partial): Promise { return alertingRequest('/rules', { method: 'POST', body: JSON.stringify(input) }); } export function deleteRule(id: string): Promise { return alertingRequest(`/rules/${id}`, { method: 'DELETE' }); } export function listDeliveries(ruleId: string): Promise { return alertingRequest(`/rules/${ruleId}/deliveries`).then((d) => d ?? []); } export function listNotificationTargets(): Promise { return alertingRequest('/targets').then((t) => t ?? []); } export function createNotificationTarget(input: { name: string; kind: NotificationKind; webhook_url: string; }): Promise { 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 { return request('/agents').then((a) => a ?? []); } export function getAgent(host: string): Promise { return request(`/agents/${encodeURIComponent(host)}`); } export function setAgentConfig(host: string, override: ConfigOverride): Promise { return request(`/agents/${encodeURIComponent(host)}/config`, { method: 'PUT', body: JSON.stringify(override) }); } export function clearAgentConfig(host: string): Promise { 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 { 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 { 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 { 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; 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') }; }