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:
@@ -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')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user