Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings

This is a large squashed commit covering two batches of prior uncommitted
work plus a full security-audit remediation pass, kept together because
go.mod/go.sum and several shared files (main.go, handler.go) were touched
by both and splitting risked non-building intermediate commits.

Features (built earlier, previously uncommitted):
- Local username/password login for single-tenant deployments with no
  SSO configured (api/localauth, alerting/internal/sessioncheck,
  sentryctl users, web/src/routes/login, metadata migrations 0040/0041).
- Remotely-editable additional log file paths for agents, on top of
  their existing primary source (api/agents, agent/sentry-agent
  extra-file-path diffing, web agent config UI).
- IPv4/IPv6 addresses reported alongside other host system metrics.

Security audit remediation (this pass, all live-verified in production):
- Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...)
  in the raw-SQL query escape hatch.
- High: deny sensitive paths and require Admin to add agent
  extra_file_paths (Editor could previously point an agent at /etc/shadow
  or an SSH key); alerting webhook targets now validate against
  internal/metadata/loopback addresses, both at creation and send time;
  alerting's session middleware now enforces an Editor+ floor on
  mutating requests instead of "any authenticated session"; bumped
  goxmldsig to close a SAML signature-verification bypass (GO-2026-4753).
- Medium: per-IP login rate limiting; security response headers
  (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy)
  on web/nginx.conf; a DevCredentialWarnings check in every Go service's
  config loader, logging loudly at startup if a deployment is still on
  docker-compose.yml's literal dev-only credentials; dependency bumps
  (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected
  Go module and both Rust crates, including a previously-uncovered x/net
  vulnerability in deploy/operator; a new security-scan.yml CI workflow
  running cargo-deny/govulncheck/npm-audit, mirroring the existing
  license-compliance.yml matrix shape.
- Low: removed sentryctl's plaintext --password flag (shell
  history/`ps` exposure) in favor of stdin and a --password-stdin flag
  for reset-password's optional specific-password path; a dummy bcrypt
  comparison closes a login response-time username-enumeration
  side-channel.
This commit is contained in:
2026-08-18 23:53:20 -07:00
parent d2bb9de245
commit 4b5dae5879
87 changed files with 5095 additions and 164 deletions
+164
View File
@@ -14,6 +14,18 @@ export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http:
// 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
@@ -59,6 +71,7 @@ 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) {
@@ -393,6 +406,74 @@ export function injectTimeRange(query: string, earliest: string, latest: string)
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 } : {})
});
}
// --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence';
@@ -484,6 +565,7 @@ export type ConfigOverride = {
heartbeat_enabled?: boolean;
heartbeat_interval_ms?: number;
journald_unit?: string;
extra_file_paths?: string[];
};
export type Agent = {
@@ -545,3 +627,85 @@ export function issueAgentCommand(host: string, command: 'restart'): Promise<Age
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')
};
}
+49 -1
View File
@@ -1,6 +1,14 @@
<script lang="ts">
import { page } from '$app/state';
import { getCurrentSession, enterpriseAuthBase, type CurrentSession } from '$lib/api';
import {
getCurrentSession,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
logout,
type CurrentSession,
type LocalSession
} from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, toggleDensity } from '$lib/density.svelte';
@@ -16,6 +24,7 @@
{ href: '/alerts', label: 'Alerts', icon: '▲' },
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
{ href: '/agents', label: 'Agents', icon: '●' },
{ href: '/hosts', label: 'Hosts', icon: '▣' },
{ href: '/settings', label: 'Settings', icon: '⚙' }
];
@@ -29,6 +38,22 @@
getCurrentSession().then((s) => (session = s));
});
let localSession: LocalSession | null = $state(null);
$effect(() => {
if (!localAuthEnabled) return;
getLocalSession().then((s) => (localSession = s === 'disabled' ? null : s));
});
let loggingOut = $state(false);
async function handleLogout() {
loggingOut = true;
try {
await logout();
} finally {
window.location.href = '/login';
}
}
const themeOptions: { value: Theme; label: string }[] = [
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' },
@@ -60,6 +85,17 @@
<a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a>
{/if}
</div>
{:else if localAuthEnabled && localSession}
<div class="tenant">
<div class="tenant-pill">
<span class="dot" aria-hidden="true"></span>
<span class="tenant-name">{localSession.username}</span>
<span class="role">{localSession.role}</span>
</div>
<button type="button" class="switch logout-btn" onclick={handleLogout} disabled={loggingOut}>
{loggingOut ? 'Signing out…' : 'Log out'}
</button>
</div>
{/if}
<nav aria-label="Main">
@@ -177,6 +213,18 @@
.switch:hover {
color: var(--color-accent);
}
.logout-btn {
background: none;
border: none;
font-family: var(--font-ui);
width: 100%;
text-align: left;
cursor: pointer;
}
.logout-btn:disabled {
cursor: default;
opacity: 0.6;
}
.switch.signin {
display: block;
padding: var(--space-2) var(--space-3);
+81 -14
View File
@@ -3,10 +3,60 @@
import favicon from '$lib/assets/favicon.svg';
import NavSidebar from '$lib/components/NavSidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import { page } from '$app/state';
import { getLocalSession } from '$lib/api';
let { children } = $props();
let paletteOpen = $state(false);
let mobileNavOpen = $state(false);
const isLoginPage = $derived(page.url.pathname === '/login');
// Gates rendering of {@render children()} entirely until the first
// auth check resolves -- set once, on the very first check, never
// reset by later ones (see the $effect below). Without this, the
// shell (and every child page's own onMount/effect data-fetching)
// mounted immediately on every navigation, so a protected page's real
// content -- and a first request for it, before the redirect below
// even fired -- was visible for one frame on every load. Login-page
// visits and "local auth isn't configured on this deployment" both
// count as immediately checked -- neither has anything to gate.
let initialCheckDone = $state(false);
let authorized = $state(false);
// Route guard for local login (see api/localauth's package doc
// comment) -- client-only, same "no +page.ts/hooks.server.ts load,
// everything client-fetched" posture every other data-dependent page
// in this app already uses (this is a prerendered static SPA;
// checking during SvelteKit's build-time prerender pass would mean
// fetching a live endpoint at build time, which nothing else here
// does). getLocalSession() returning 'disabled' means this
// deployment has no local auth configured at all -- same as a
// deployment with neither enterprise-auth nor local auth turned on,
// let every route through unchanged. Re-runs on every navigation
// ($effect re-fires when isLoginPage's dependency, page.url, changes),
// same "poll on every navigation" posture GET /auth/session's own
// doc comment (api/localauth/handler.go) describes -- but only ever
// sets initialCheckDone, never clears it, so a session expiring
// mid-use redirects without re-blanking an already-rendered page (a
// full navigation to /login is already underway by the time that'd
// matter anyway).
$effect(() => {
if (isLoginPage) {
authorized = true;
initialCheckDone = true;
return;
}
getLocalSession().then((session) => {
if (session === null) {
const next = encodeURIComponent(page.url.pathname + page.url.search);
window.location.href = `/login?next=${next}`;
} else {
authorized = true;
}
initialCheckDone = true;
});
});
</script>
<svelte:head>
@@ -14,25 +64,42 @@
<link rel="icon" href={favicon} />
</svelte:head>
<div class="shell">
<NavSidebar
onOpenPalette={() => (paletteOpen = true)}
mobileOpen={mobileNavOpen}
onCloseMobile={() => (mobileNavOpen = false)}
/>
<div class="main-col">
<button type="button" class="menu-toggle" onclick={() => (mobileNavOpen = true)} aria-label="Open menu">
</button>
<div class="page">
{@render children()}
{#if isLoginPage}
{@render children()}
{:else if initialCheckDone && authorized}
<div class="shell">
<NavSidebar
onOpenPalette={() => (paletteOpen = true)}
mobileOpen={mobileNavOpen}
onCloseMobile={() => (mobileNavOpen = false)}
/>
<div class="main-col">
<button type="button" class="menu-toggle" onclick={() => (mobileNavOpen = true)} aria-label="Open menu">
</button>
<div class="page">
{@render children()}
</div>
</div>
</div>
</div>
<CommandPalette bind:open={paletteOpen} />
<CommandPalette bind:open={paletteOpen} />
{:else}
<!-- initialCheckDone is false: the auth check is still in flight (or
we're already navigating away to /login) -- nothing protected has
mounted yet, this is the whole point. A brief blank screen with no
feedback could look frozen on a slow connection, so show the same
plain "Loading…" text every other data-dependent page in this app
already uses (see e.g. settings/+page.svelte) rather than nothing
at all. -->
<p class="auth-loading">Loading…</p>
{/if}
<style>
.auth-loading {
color: var(--color-text-muted);
padding: var(--space-6);
}
.shell {
display: grid;
grid-template-columns: 15rem 1fr;
+51 -1
View File
@@ -26,6 +26,13 @@
let heartbeatEnabled = $state(true);
let heartbeatIntervalMs = $state('0');
let journaldUnit = $state('');
// Extra file paths to tail in addition to the agent's primary
// source -- unlike every field above, there's no "effective value"
// to fall back to when no override exists yet (this is purely a
// remote-override concept, agent.toml has no equivalent field), so
// an agent with no override starts with an empty list, not
// something derived from `agent`.
let extraFilePaths = $state<string[]>([]);
function resetForm(a: Agent) {
const o = a.desired_override;
@@ -34,6 +41,15 @@
heartbeatEnabled = o?.heartbeat_enabled ?? a.heartbeat_enabled;
heartbeatIntervalMs = String(o?.heartbeat_interval_ms ?? a.heartbeat_interval_ms);
journaldUnit = o?.journald_unit ?? '';
extraFilePaths = o?.extra_file_paths ? [...o.extra_file_paths] : [];
}
function addExtraFilePath() {
extraFilePaths = [...extraFilePaths, ''];
}
function removeExtraFilePath(index: number) {
extraFilePaths = extraFilePaths.filter((_, i) => i !== index);
}
async function load() {
@@ -59,7 +75,8 @@
batch_flush_interval_ms: Number(batchFlushIntervalMs),
heartbeat_enabled: heartbeatEnabled,
heartbeat_interval_ms: Number(heartbeatIntervalMs),
...(agent?.source_kind === 'journald' ? { journald_unit: journaldUnit } : {})
...(agent?.source_kind === 'journald' ? { journald_unit: journaldUnit } : {}),
extra_file_paths: extraFilePaths.map((p) => p.trim()).filter((p) => p !== '')
});
} catch (e) {
saveError = e instanceof Error ? e.message : String(e);
@@ -179,6 +196,20 @@
<Input id="journald-unit" placeholder="(empty = whole journal)" bind:value={journaldUnit} />
</div>
{/if}
<div class="field extra-paths">
<span class="field-label">Additional log paths</span>
<p class="hint">
Extra files this agent should tail alongside its primary source above -- never a replacement for it. Applied
the same way as every other field here, on the agent's next check-in.
</p>
{#each extraFilePaths as _, i}
<div class="path-row">
<Input placeholder="/var/log/example.log" bind:value={extraFilePaths[i]} />
<Button variant="secondary" onclick={() => removeExtraFilePath(i)}>Remove</Button>
</div>
{/each}
<Button variant="secondary" onclick={addExtraFilePath}>Add path</Button>
</div>
{#if saveError}<p class="error">Error: {saveError}</p>{/if}
@@ -275,6 +306,9 @@
margin-bottom: var(--space-3);
max-width: 20rem;
}
.field.extra-paths {
max-width: none;
}
.field label {
font-size: var(--text-sm);
color: var(--color-text-muted);
@@ -285,6 +319,22 @@
gap: var(--space-2);
color: var(--color-text);
}
.field-label {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.field .hint {
margin-bottom: var(--space-2);
}
.path-row {
display: flex;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.path-row :global(input) {
flex: 1;
}
.actions {
display: flex;
gap: var(--space-3);
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import { listMetricsHosts, type HostSummary } from '$lib/api';
import { EmptyState, Skeleton, Table } from '$lib/components/ui';
let hosts = $state<HostSummary[]>([]);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
hosts = await listMetricsHosts();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
</script>
<main>
<h1>Hosts</h1>
<p class="subtitle">
CPU, memory, and disk usage for every host reporting metrics. Only one agent process per
physical host reports these -- see agent/README.md's "Host CPU/memory/disk metrics" section.
</p>
{#if error}<p class="error">Error: {error}</p>{/if}
{#if loading}
<div class="skeleton-list">
{#each Array(3) as _, i (i)}
<Skeleton height="2.25rem" />
{/each}
</div>
{:else if hosts.length === 0}
<EmptyState
icon="▣"
title="No hosts reporting metrics yet"
description="A host appears here once an agent with [metrics] enabled = true has sent its first sample -- see agent/README.md."
/>
{:else}
<Table>
<thead>
<tr>
<th>Host</th>
</tr>
</thead>
<tbody>
{#each hosts as h (h.host)}
<tr>
<td><a href={`/hosts/${encodeURIComponent(h.host)}`}>{h.host}</a></td>
</tr>
{/each}
</tbody>
</Table>
{/if}
</main>
<style>
main {
max-width: 56rem;
}
h1 {
font-size: var(--text-xl);
margin-bottom: var(--space-2);
}
.subtitle {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
}
.error {
color: var(--color-danger);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
a {
color: var(--color-text);
font-weight: var(--font-weight-medium);
text-decoration: none;
}
a:hover {
color: var(--color-accent);
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// No route params, data comes from a client-side fetch -- same shape as
// the agents list page's +page.ts.
export const prerender = true;
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import { page } from '$app/state';
import { getHostMetrics, type HostMetrics } from '$lib/api';
import { Card, Skeleton } from '$lib/components/ui';
const host = page.params.host!;
let metrics = $state<HostMetrics | null>(null);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
metrics = await getHostMetrics(host);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
}
function percent(used: number, total: number): number {
if (total <= 0) return 0;
return Math.min(100, Math.max(0, (used / total) * 100));
}
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
return `${Math.round(ms / 86_400_000)}d ago`;
}
function formatUptime(seconds: number): string {
if (seconds <= 0) return '—';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
</script>
<main>
<a class="back" href="/hosts">← Hosts</a>
<h1>{host}</h1>
{#if loading}
<Skeleton height="12rem" />
{:else if error}
<p class="error">Error: {error}</p>
{:else if !metrics}
<p class="hint">No metrics samples for this host yet.</p>
{:else}
<p class="hint">Last sample {relativeTime(metrics.timestamp)}.</p>
<section class="system">
<dl>
<dt>OS</dt>
<dd>{metrics.osName}</dd>
<dt>Kernel</dt>
<dd>{metrics.kernelVersion}</dd>
<dt>Architecture</dt>
<dd>{metrics.arch}</dd>
<dt>Uptime</dt>
<dd>{formatUptime(metrics.uptimeSeconds)}</dd>
<dt>IPv4</dt>
<dd>{metrics.ipv4Addresses.length > 0 ? metrics.ipv4Addresses.join(', ') : '—'}</dd>
<dt>IPv6</dt>
<dd>{metrics.ipv6Addresses.length > 0 ? metrics.ipv6Addresses.join(', ') : '—'}</dd>
</dl>
</section>
<div class="stats">
<Card title="CPU">
<div class="big-number">{metrics.cpuPercent.toFixed(1)}%</div>
<div class="bar">
<div class="bar-fill" style="width: {metrics.cpuPercent.toFixed(1)}%"></div>
</div>
<div class="detail">{metrics.cpuCores} core{metrics.cpuCores === 1 ? '' : 's'}</div>
</Card>
<Card title="Memory">
<div class="big-number">{percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%</div>
<div class="bar">
<div
class="bar-fill"
style="width: {percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%"
></div>
</div>
<div class="detail">{formatBytes(metrics.memUsedBytes)} / {formatBytes(metrics.memTotalBytes)}</div>
</Card>
<Card title="Disk (/)">
<div class="big-number">{percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%</div>
<div class="bar">
<div
class="bar-fill"
style="width: {percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%"
></div>
</div>
<div class="detail">{formatBytes(metrics.diskUsedBytes)} / {formatBytes(metrics.diskTotalBytes)}</div>
</Card>
</div>
{/if}
</main>
<style>
main {
max-width: 48rem;
}
.back {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-decoration: none;
}
.back:hover {
color: var(--color-accent);
}
h1 {
font-size: var(--text-xl);
margin: var(--space-2) 0 var(--space-2);
font-family: var(--font-mono);
}
.hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
}
.error {
color: var(--color-danger);
}
.system {
margin-bottom: var(--space-5);
}
.system dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-1) var(--space-4);
font-size: var(--text-sm);
}
.system dt {
color: var(--color-text-muted);
}
.system dd {
margin: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: var(--space-4);
}
.big-number {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-3);
}
.bar {
height: 0.5rem;
border-radius: var(--radius-sm);
background: var(--color-bg);
border: 1px solid var(--color-border);
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--color-accent);
}
.detail {
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// The host param doesn't exist at build time -- same reasoning as
// agents/[host]/+page.ts.
export const prerender = false;
export const ssr = false;
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import { page } from '$app/state';
import { login } from '$lib/api';
let username = $state('');
let password = $state('');
let error = $state('');
let submitting = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
error = '';
try {
await login(username, password);
// Full navigation, not SvelteKit's router -- same reasoning
// select-tenant/+page.svelte's choose() gives for the tenant
// picker: reloading picks up the session cookie login() just
// set, which client-side routing wouldn't need to know about
// but a fresh page load makes unambiguous.
const next = page.url.searchParams.get('next') || '/';
window.location.href = next;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
submitting = false;
}
}
</script>
<main>
<h1>Sign in</h1>
<form onsubmit={submit}>
{#if error}
<p class="error">{error}</p>
{/if}
<label>
<span>Username</span>
<input type="text" autocomplete="username" bind:value={username} disabled={submitting} required />
</label>
<label>
<span>Password</span>
<input
type="password"
autocomplete="current-password"
bind:value={password}
disabled={submitting}
required
/>
</label>
<button type="submit" disabled={submitting}>{submitting ? 'Signing in…' : 'Sign in'}</button>
</form>
</main>
<style>
main {
max-width: 22rem;
margin: var(--space-8) auto;
}
h1 {
font-size: var(--text-lg);
margin-bottom: var(--space-5);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
input {
font-family: var(--font-ui);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
input:focus {
outline: none;
border-color: var(--color-accent);
}
button {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.6;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// Client-only, same reasoning as select-tenant/+page.ts -- nothing to
// prerender server-side, everything here is a fetch against api's
// /auth/login.
export const prerender = true;
+222 -1
View File
@@ -1,5 +1,17 @@
<script lang="ts">
import { getAuthFeatures, enterpriseAuthBase, type AuthFeatures } from '$lib/api';
import {
getAuthFeatures,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
listUsers,
createUser,
deleteUser,
resetPassword,
type AuthFeatures,
type LocalSession,
type LocalUser
} from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
@@ -13,6 +25,75 @@
}
load();
// --- local user management (owner-role only, see api/localauth) ---
let localSession = $state<LocalSession | 'disabled' | null>(null);
let users = $state<LocalUser[]>([]);
let usersLoading = $state(false);
let usersError = $state('');
let newUsername = $state('');
let newPassword = $state('');
let newRole = $state('editor');
let creating = $state(false);
// lastReset holds a just-generated password so it can be shown once
// (never stored, never recoverable after -- same posture
// -seed-admin's initial password takes, see cmd/api/main.go).
let lastReset = $state<{ userId: string; password: string } | null>(null);
async function loadUsers() {
if (!localAuthEnabled) return;
localSession = await getLocalSession();
if (localSession === 'disabled' || localSession === null || localSession.role !== 'owner') return;
usersLoading = true;
usersError = '';
try {
users = await listUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
usersLoading = false;
}
}
loadUsers();
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (creating) return;
creating = true;
usersError = '';
try {
await createUser(newUsername, newPassword, newRole);
newUsername = '';
newPassword = '';
newRole = 'editor';
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
async function handleDelete(id: string) {
usersError = '';
try {
await deleteUser(id);
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
async function handleReset(id: string) {
usersError = '';
lastReset = null;
try {
const { password } = await resetPassword(id);
if (password) lastReset = { userId: id, password };
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
const themeOptions: { value: Theme; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Default' },
{ value: 'light', label: 'Light', hint: '' },
@@ -64,6 +145,69 @@
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
</section>
{#if localAuthEnabled && localSession && localSession !== 'disabled'}
<section>
<h2>Users</h2>
{#if localSession.role !== 'owner'}
<p class="note">Only an owner can manage users. Signed in as {localSession.username} ({localSession.role}).</p>
{:else}
{#if usersError}<p class="error">{usersError}</p>{/if}
{#if usersLoading}
<p class="muted">Loading…</p>
{:else}
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr>
<td>{u.username}</td>
<td class="role-cell">{u.role}</td>
<td class="actions">
<button type="button" onclick={() => handleReset(u.id)}>Reset password</button>
<button type="button" class="danger" onclick={() => handleDelete(u.id)}>Delete</button>
</td>
</tr>
{#if lastReset?.userId === u.id}
<tr>
<td colspan="3">
<p class="note">
New password (shown once): <code>{lastReset.password}</code>
</p>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
<form onsubmit={handleCreate} class="create-user">
<input type="text" placeholder="Username" bind:value={newUsername} disabled={creating} required />
<input
type="password"
placeholder="Password (min. 8 characters)"
bind:value={newPassword}
disabled={creating}
required
/>
<select bind:value={newRole} disabled={creating}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
<button type="submit" disabled={creating}>{creating ? 'Adding…' : 'Add user'}</button>
</form>
{/if}
</section>
{/if}
{#if loading}
<p class="muted">Loading…</p>
{:else if features.sso_configured}
@@ -154,4 +298,81 @@
.option.selected .option-hint {
color: var(--color-text);
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
font-size: var(--text-sm);
}
th,
td {
text-align: left;
padding: var(--space-2) var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.role-cell {
color: var(--color-text-muted);
text-transform: capitalize;
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.actions button {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
}
.actions button.danger {
color: var(--color-danger);
border-color: var(--color-danger);
}
.create-user {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
}
.create-user input,
.create-user select {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.create-user button {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.create-user button:disabled {
cursor: default;
opacity: 0.6;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
code {
font-family: var(--font-mono);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.1rem 0.4rem;
}
</style>