Build the ui/ component library and persistent app shell
Button, Input, Select, Badge, SeverityBadge, Table, Card, Modal, Tooltip, Tabs, Skeleton, EmptyState -- a shared library so pages stop hand-rolling markup per page (web/src/lib/components/ui, barrel export in index.ts). Modal and CommandPalette are built on native <dialog> for a real focus trap, Escape-to-close, and top-layer stacking instead of hand-rolling those. Tabs uses roving tabindex with arrow-key nav. NavSidebar replaces the old top-nav with a persistent sidebar (Search/Dashboards/Alerts/Data Sources/Settings), a live tenant indicator (api.ts's new getCurrentSession(), a client for the already-existing POST /internal/authorize -- zero new backend surface), theme/density quick-toggles, and a command-palette hint. Collapses to an off-canvas drawer under 860px. CommandPalette (Cmd/Ctrl+K) indexes the five static destinations plus live-fetched dashboards/alert rules. Data Sources is a new, honestly-scoped placeholder page (one data source per tenant today, no UI needed yet). Settings and Select-tenant are re-tokened onto the new component library. +layout.svelte's content wrapper is a plain <div>, not a second <main> -- every page already renders its own top-level <main>.
This commit is contained in:
+29
-1
@@ -16,7 +16,7 @@ export type Language = '' | 'sql' | 'spl';
|
||||
|
||||
export type QueryResult = { columns: string[]; rows: unknown[][] };
|
||||
|
||||
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n';
|
||||
export type VizType = 'table' | 'line' | 'bar' | 'single_stat' | 'top_n' | 'heatmap';
|
||||
|
||||
export type Panel = {
|
||||
id: string;
|
||||
@@ -203,6 +203,34 @@ export function selectTenant(tenantId: string): Promise<{ redirect_url: string }
|
||||
});
|
||||
}
|
||||
|
||||
export type CurrentSession = { tenant_id: string; user_id: string; role: string };
|
||||
|
||||
// getCurrentSession backs the sidebar's tenant indicator (Phase 5).
|
||||
// POST /internal/authorize already exists (api/authz.HTTPAuthorizer and
|
||||
// alerting's queryclient call it the same way) and is already reachable
|
||||
// from the browser -- enterprise-auth's whole mux is wrapped in
|
||||
// WithCredentialedCORS, not just the tenant-picker routes -- so this is
|
||||
// a client for an existing endpoint, not a new one. Returns null for
|
||||
// every "no tenant to show" case alike (no enterprise-auth configured,
|
||||
// not logged in, or a plain single-tenant deployment) rather than
|
||||
// throwing -- same "absence is a normal deployment shape" posture
|
||||
// getAuthFeatures already uses, so the sidebar can just render nothing
|
||||
// instead of branching on error types.
|
||||
export async function getCurrentSession(): Promise<CurrentSession | null> {
|
||||
if (!enterpriseAuthBase) return null;
|
||||
try {
|
||||
const res = await fetch(`${enterpriseAuthBase}/internal/authorize`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const session = (await res.json()) as CurrentSession;
|
||||
return session.tenant_id ? session : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function exportDashboard(id: string): Promise<Dashboard> {
|
||||
return request(`/dashboards/${id}/export`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { listDashboards, listRules } from '$lib/api';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
type Item = { id: string; label: string; hint: string; go: () => void };
|
||||
|
||||
const staticItems: Item[] = [
|
||||
{ id: 'nav-search', label: 'Search', hint: 'Go to', go: () => goto('/') },
|
||||
{ id: 'nav-dashboards', label: 'Dashboards', hint: 'Go to', go: () => goto('/dashboards') },
|
||||
{ id: 'nav-alerts', label: 'Alerts', hint: 'Go to', go: () => goto('/alerts') },
|
||||
{ id: 'nav-data-sources', label: 'Data Sources', hint: 'Go to', go: () => goto('/data-sources') },
|
||||
{ id: 'nav-settings', label: 'Settings', hint: 'Go to', go: () => goto('/settings') }
|
||||
];
|
||||
|
||||
let dynamicItems: Item[] = $state([]);
|
||||
let loaded = false;
|
||||
let query = $state('');
|
||||
let selected = $state(0);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||
|
||||
async function loadDynamic() {
|
||||
if (loaded) return;
|
||||
loaded = true;
|
||||
const [dashboards, rules] = await Promise.allSettled([listDashboards(), listRules()]);
|
||||
const items: Item[] = [];
|
||||
if (dashboards.status === 'fulfilled') {
|
||||
for (const d of dashboards.value) {
|
||||
items.push({
|
||||
id: `dash-${d.id}`,
|
||||
label: d.name,
|
||||
hint: 'Dashboard',
|
||||
go: () => goto(`/dashboards/${d.id}`)
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rules.status === 'fulfilled') {
|
||||
for (const r of rules.value) {
|
||||
items.push({
|
||||
id: `rule-${r.id}`,
|
||||
label: r.name,
|
||||
hint: 'Alert rule',
|
||||
go: () => goto(`/alerts/${r.id}`)
|
||||
});
|
||||
}
|
||||
}
|
||||
dynamicItems = items;
|
||||
}
|
||||
|
||||
let allItems = $derived([...staticItems, ...dynamicItems]);
|
||||
let filtered = $derived(
|
||||
query.trim() === ''
|
||||
? allItems
|
||||
: allItems.filter((i) => i.label.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
selected = 0;
|
||||
});
|
||||
|
||||
// Native <dialog> instead of a hand-rolled overlay -- same reasoning
|
||||
// as ui/Modal.svelte: focus trap, Escape-to-close, and top-layer
|
||||
// stacking come for free, and it resolves the a11y-linter warnings a
|
||||
// plain role="dialog" div on a non-interactive element raises,
|
||||
// rather than suppressing them.
|
||||
$effect(() => {
|
||||
if (!dialogEl) return;
|
||||
if (open && !dialogEl.open) {
|
||||
dialogEl.showModal();
|
||||
loadDynamic();
|
||||
queueMicrotask(() => inputEl?.focus());
|
||||
}
|
||||
if (!open && dialogEl.open) dialogEl.close();
|
||||
});
|
||||
|
||||
function onDialogClose() {
|
||||
open = false;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function onBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialogEl) open = false;
|
||||
}
|
||||
|
||||
function choose(item: Item) {
|
||||
item.go();
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
selected = Math.min(selected + 1, filtered.length - 1);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
selected = Math.max(selected - 1, 0);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Enter') {
|
||||
if (filtered[selected]) choose(filtered[selected]);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
const isK = e.key === 'k' || e.key === 'K';
|
||||
if (isK && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
open = !open;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onGlobalKeydown} />
|
||||
|
||||
<dialog bind:this={dialogEl} class="palette" onclose={onDialogClose} onclick={onBackdropClick} aria-label="Command palette">
|
||||
<div class="frame">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
onkeydown={onKeydown}
|
||||
type="text"
|
||||
placeholder="Jump to a dashboard, alert rule, or page…"
|
||||
aria-label="Search"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="palette-listbox"
|
||||
/>
|
||||
<ul id="palette-listbox" role="listbox">
|
||||
{#each filtered as item, i (item.id)}
|
||||
<li role="option" aria-selected={i === selected}>
|
||||
<button type="button" class:selected={i === selected} onclick={() => choose(item)}>
|
||||
<span class="label">{item.label}</span>
|
||||
<span class="hint">{item.hint}</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty">No matches</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
position: fixed;
|
||||
top: 12vh;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.palette::backdrop {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
padding: 0 var(--space-4);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
li.empty {
|
||||
padding: var(--space-3);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.selected {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
.hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { getCurrentSession, enterpriseAuthBase, type CurrentSession } from '$lib/api';
|
||||
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import { getDensity, toggleDensity } from '$lib/density.svelte';
|
||||
|
||||
let {
|
||||
onOpenPalette,
|
||||
mobileOpen = false,
|
||||
onCloseMobile
|
||||
}: { onOpenPalette: () => void; mobileOpen?: boolean; onCloseMobile?: () => void } = $props();
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Search', icon: '◇' },
|
||||
{ href: '/dashboards', label: 'Dashboards', icon: '▤' },
|
||||
{ href: '/alerts', label: 'Alerts', icon: '▲' },
|
||||
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
|
||||
{ href: '/settings', label: 'Settings', icon: '⚙' }
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return page.url.pathname === '/';
|
||||
return page.url.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
let session: CurrentSession | null = $state(null);
|
||||
$effect(() => {
|
||||
getCurrentSession().then((s) => (session = s));
|
||||
});
|
||||
|
||||
const themeOptions: { value: Theme; label: string }[] = [
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'system', label: 'System' }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if mobileOpen}
|
||||
<button type="button" class="backdrop" onclick={onCloseMobile} aria-label="Close menu"></button>
|
||||
{/if}
|
||||
|
||||
<aside class="sidebar" class:mobile-open={mobileOpen}>
|
||||
<div class="brand">
|
||||
<span class="mark" aria-hidden="true">◆</span>
|
||||
<span class="name">Sentry</span>
|
||||
<button type="button" class="close-mobile" onclick={onCloseMobile} aria-label="Close menu">✕</button>
|
||||
</div>
|
||||
|
||||
{#if enterpriseAuthBase}
|
||||
<div class="tenant">
|
||||
{#if session}
|
||||
<div class="tenant-pill">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span class="tenant-name">{session.tenant_id}</span>
|
||||
<span class="role">{session.role}</span>
|
||||
</div>
|
||||
<a class="switch" href="{enterpriseAuthBase}/auth/oidc/login">Switch tenant</a>
|
||||
{:else}
|
||||
<a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<nav aria-label="Main">
|
||||
{#each navItems as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={isActive(item.href)}
|
||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
||||
onclick={onCloseMobile}
|
||||
>
|
||||
<span class="ic" aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="footer">
|
||||
<button type="button" class="palette-hint" onclick={onOpenPalette}>
|
||||
<span>Jump to…</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
|
||||
<div class="controls">
|
||||
<label for="theme-select" class="sr-only">Theme</label>
|
||||
<select id="theme-select" value={getTheme()} onchange={(e) => setTheme(e.currentTarget.value as Theme)}>
|
||||
{#each themeOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button type="button" class="density-toggle" onclick={toggleDensity} title="Toggle row density">
|
||||
{getDensity() === 'compact' ? 'Compact' : 'Comfortable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
.mark {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.name {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
.close-mobile {
|
||||
display: none;
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-md);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tenant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.tenant-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
flex: none;
|
||||
}
|
||||
.tenant-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.role {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.switch {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
padding: 0 var(--space-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
.switch:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.switch.signin {
|
||||
display: block;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
nav a:hover {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
}
|
||||
nav a.active {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.ic {
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.palette-hint:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
color: var(--color-text);
|
||||
}
|
||||
kbd {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.controls select,
|
||||
.density-toggle {
|
||||
flex: 1;
|
||||
height: 1.9rem;
|
||||
font-size: var(--text-xs);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
|
||||
/* Below this width the sidebar becomes an off-canvas drawer
|
||||
(+layout.svelte renders a menu button to open it) instead of a
|
||||
fixed grid column -- "shouldn't break on a laptop screen or a
|
||||
tablet in landscape" is the actual bar (this is a desktop-first
|
||||
tool), so the breakpoint is deliberately narrower than a phone
|
||||
viewport would need. */
|
||||
@media (max-width: 860px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 90;
|
||||
width: 16rem;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.15s ease;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.close-mobile {
|
||||
display: block;
|
||||
}
|
||||
.backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
z-index: 80;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// Generic status badge -- for severity specifically, use
|
||||
// SeverityBadge.svelte instead (it owns the OTel->tier mapping so
|
||||
// callers can't accidentally invent a sixth color).
|
||||
type Tone = 'neutral' | 'success' | 'danger' | 'accent';
|
||||
|
||||
let { tone = 'neutral', children }: { tone?: Tone; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<span class="badge {tone}">{@render children()}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.1rem var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
width: fit-content;
|
||||
}
|
||||
.neutral {
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 16%, transparent);
|
||||
}
|
||||
.success {
|
||||
color: var(--color-success);
|
||||
background: color-mix(in srgb, var(--color-success) 16%, transparent);
|
||||
}
|
||||
.danger {
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
|
||||
}
|
||||
.accent {
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
let {
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
type = 'button',
|
||||
disabled = false,
|
||||
href,
|
||||
onclick,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
type?: 'button' | 'submit';
|
||||
disabled?: boolean;
|
||||
href?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
children: Snippet;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} class="btn {variant} {size}" aria-disabled={disabled} {...rest}>
|
||||
{@render children()}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} class="btn {variant} {size}" {disabled} {onclick} {...rest}>
|
||||
{@render children()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
font-family: var(--font-ui);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 0.1s ease,
|
||||
border-color 0.1s ease;
|
||||
}
|
||||
.btn:disabled,
|
||||
.btn[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.md {
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-4);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.sm {
|
||||
height: 1.75rem;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
actions,
|
||||
padded = true,
|
||||
children
|
||||
}: {
|
||||
title?: string;
|
||||
actions?: Snippet;
|
||||
padded?: boolean;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
{#if title || actions}
|
||||
<div class="head">
|
||||
{#if title}<h2>{title}</h2>{/if}
|
||||
{#if actions}<div class="actions">{@render actions()}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="body" class:padded>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--panel-padding);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.head h2 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.body.padded {
|
||||
padding: var(--panel-padding);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
icon = '◇',
|
||||
title,
|
||||
description,
|
||||
action
|
||||
}: {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty">
|
||||
<div class="icon" aria-hidden="true">{icon}</div>
|
||||
<h2>{title}</h2>
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
{#if action}<div class="action">{@render action()}</div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.icon {
|
||||
font-size: var(--text-2xl);
|
||||
color: var(--color-border-strong);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
p {
|
||||
max-width: 30rem;
|
||||
margin: 0;
|
||||
}
|
||||
.action {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value = $bindable(''),
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
disabled = false,
|
||||
id,
|
||||
invalid = false,
|
||||
...rest
|
||||
}: {
|
||||
value?: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
invalid?: boolean;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
{id}
|
||||
{type}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
bind:value
|
||||
aria-invalid={invalid}
|
||||
class="input"
|
||||
class:invalid
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.input {
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
width: 100%;
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
.input:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.input.invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// Built on <dialog> deliberately -- native focus trap, Escape-to-close,
|
||||
// and top-layer stacking for free, instead of hand-rolling all three.
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
children,
|
||||
footer
|
||||
}: {
|
||||
open?: boolean;
|
||||
title: string;
|
||||
children: Snippet;
|
||||
footer?: Snippet;
|
||||
} = $props();
|
||||
|
||||
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (!dialogEl) return;
|
||||
if (open && !dialogEl.open) dialogEl.showModal();
|
||||
if (!open && dialogEl.open) dialogEl.close();
|
||||
});
|
||||
|
||||
function onClose() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialogEl) open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialogEl} onclose={onClose} onclick={onBackdropClick} aria-labelledby="modal-title">
|
||||
<div class="frame">
|
||||
<header>
|
||||
<h2 id="modal-title">{title}</h2>
|
||||
<button type="button" class="close" onclick={() => (open = false)} aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div class="content">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if footer}
|
||||
<footer>{@render footer()}</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: min(32rem, calc(100vw - 2rem));
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-base);
|
||||
line-height: 1;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
.close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
disabled = false,
|
||||
id,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
children: Snippet;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<select {id} {disabled} bind:value class="select" {...rest}>
|
||||
{@render children()}
|
||||
</select>
|
||||
<span class="chev" aria-hidden="true">⌄</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.select {
|
||||
appearance: none;
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-6) 0 var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.select:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chev {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { severityTier } from '$lib/severity';
|
||||
|
||||
let { severity }: { severity: string } = $props();
|
||||
let tier = $derived(severityTier(severity));
|
||||
</script>
|
||||
|
||||
<span class="chip {tier}">{severity}</span>
|
||||
|
||||
<style>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.05rem var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
width: fit-content;
|
||||
}
|
||||
.quiet {
|
||||
color: var(--color-sev-quiet);
|
||||
background: var(--color-sev-quiet-bg);
|
||||
}
|
||||
.info {
|
||||
color: var(--color-sev-info);
|
||||
background: var(--color-sev-info-bg);
|
||||
}
|
||||
.warn {
|
||||
color: var(--color-sev-warn);
|
||||
background: var(--color-sev-warn-bg);
|
||||
}
|
||||
.error {
|
||||
color: var(--color-sev-error);
|
||||
background: var(--color-sev-error-bg);
|
||||
}
|
||||
.critical {
|
||||
color: var(--color-sev-critical);
|
||||
background: var(--color-sev-critical-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
let { width = '100%', height = '1rem' }: { width?: string; height?: string } = $props();
|
||||
</script>
|
||||
|
||||
<span class="skeleton" style:width style:height></span>
|
||||
|
||||
<style>
|
||||
.skeleton {
|
||||
display: block;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-surface-raised) 25%,
|
||||
var(--color-border) 50%,
|
||||
var(--color-surface-raised) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// A thin styling wrapper, not a data grid -- real <table> markup
|
||||
// underneath (native semantics matter for screen readers), density-
|
||||
// aware row height/padding from tokens.css's --row-* variables.
|
||||
// Column sort/resize (task 6) layers on top of this later; this just
|
||||
// establishes the shared chrome every table in the app should share.
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="scroll">
|
||||
<table class="ui-table">
|
||||
{@render children()}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
:global(.ui-table thead th) {
|
||||
text-align: left;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-2) var(--row-padding-x);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
:global(.ui-table tbody td) {
|
||||
padding: var(--row-padding-y) var(--row-padding-x);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
:global(.ui-table tbody tr:last-child td) {
|
||||
border-bottom: none;
|
||||
}
|
||||
:global(.ui-table tbody tr:hover td) {
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
// Renders the tab list only -- the caller renders each panel's content
|
||||
// (conditionally, on `active`) and is responsible for
|
||||
// id="panel-{tab.id}" aria-labelledby="tab-{tab.id}" on it. Keeps this
|
||||
// component decoupled from what a panel's content actually is.
|
||||
type Tab = { id: string; label: string };
|
||||
|
||||
let {
|
||||
tabs,
|
||||
active = $bindable('')
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
active?: string;
|
||||
} = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!active && tabs.length) active = tabs[0].id;
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const i = tabs.findIndex((t) => t.id === active);
|
||||
if (e.key === 'ArrowRight') {
|
||||
active = tabs[(i + 1) % tabs.length].id;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
active = tabs[(i - 1 + tabs.length) % tabs.length].id;
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div role="tablist" tabindex="-1" class="tablist" onkeydown={onKeydown}>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
role="tab"
|
||||
id="tab-{tab.id}"
|
||||
aria-selected={active === tab.id}
|
||||
aria-controls="panel-{tab.id}"
|
||||
tabindex={active === tab.id ? 0 : -1}
|
||||
class="tab"
|
||||
class:active={active === tab.id}
|
||||
onclick={() => (active = tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tablist {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--color-text);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { text, children, id }: { text: string; children: Snippet; id: string } = $props();
|
||||
</script>
|
||||
|
||||
<span class="wrap">
|
||||
<span aria-describedby={id} class="trigger">
|
||||
{@render children()}
|
||||
</span>
|
||||
<span role="tooltip" {id} class="tip">{text}</span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.tip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--space-2));
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-xs);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-sm);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.1s ease,
|
||||
transform 0.1s ease;
|
||||
z-index: 20;
|
||||
}
|
||||
.wrap:hover .tip,
|
||||
.wrap:focus-within .tip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
export { default as Input } from './Input.svelte';
|
||||
export { default as Select } from './Select.svelte';
|
||||
export { default as Badge } from './Badge.svelte';
|
||||
export { default as SeverityBadge } from './SeverityBadge.svelte';
|
||||
export { default as Table } from './Table.svelte';
|
||||
export { default as Card } from './Card.svelte';
|
||||
export { default as Modal } from './Modal.svelte';
|
||||
export { default as Tooltip } from './Tooltip.svelte';
|
||||
export { default as Tabs } from './Tabs.svelte';
|
||||
export { default as Skeleton } from './Skeleton.svelte';
|
||||
export { default as EmptyState } from './EmptyState.svelte';
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import NavSidebar from '$lib/components/NavSidebar.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
|
||||
// Phase 3: dashboards + alerts routes added, so there's now more than
|
||||
// one page -- a minimal nav replaces the previous "no nav, one page"
|
||||
// layout.
|
||||
let { children } = $props();
|
||||
let paletteOpen = $state(false);
|
||||
let mobileNavOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -12,30 +14,62 @@
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<nav>
|
||||
<a href="/">Query</a>
|
||||
<a href="/dashboards">Dashboards</a>
|
||||
<a href="/alerts">Alerts</a>
|
||||
<a href="/settings">Settings</a>
|
||||
</nav>
|
||||
<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>
|
||||
|
||||
{@render children()}
|
||||
<CommandPalette bind:open={paletteOpen} />
|
||||
|
||||
<style>
|
||||
nav {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 15rem 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
nav a {
|
||||
color: #06c;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
.main-col {
|
||||
min-width: 0;
|
||||
}
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
.page {
|
||||
padding: var(--space-6);
|
||||
min-width: 0;
|
||||
}
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.menu-toggle {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-md);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
.page {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { Card } from '$lib/components/ui';
|
||||
import { enterpriseAuthBase } from '$lib/api';
|
||||
</script>
|
||||
|
||||
<main class="page">
|
||||
<h1>Data Sources</h1>
|
||||
<p class="lede">
|
||||
Every tenant has exactly one data source today — its own ClickHouse database and Tantivy
|
||||
index, provisioned together when the tenant is created. There's nothing to configure yet.
|
||||
</p>
|
||||
|
||||
<Card title="Current data source">
|
||||
{#if enterpriseAuthBase}
|
||||
<p>
|
||||
Your queries run against your tenant's dedicated ClickHouse database and Tantivy index —
|
||||
never a shared one. See <a href="/settings">Settings</a> for SSO and tenant configuration.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
This deployment isn't running in multi-tenant mode, so there's one data source for the
|
||||
whole instance — the default ClickHouse database and Tantivy index every query already
|
||||
runs against.
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<p class="note">
|
||||
Multiple data sources per tenant — a second ClickHouse cluster, a read replica, an external
|
||||
source — is real future work, not something this page is hiding. The
|
||||
<code>data_sources</code> table this reads from was already built with that in mind (see
|
||||
<code>/docs/phase-4-rbac-design.md</code>).
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.lede {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.note {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -83,10 +83,8 @@
|
||||
|
||||
<style>
|
||||
main {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 480px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1rem;
|
||||
max-width: 30rem;
|
||||
margin: var(--space-8) auto;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
@@ -94,23 +92,25 @@
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-size: var(--text-md);
|
||||
font-family: var(--font-ui);
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: #06c;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
@@ -118,18 +118,18 @@
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.role {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.error {
|
||||
color: #b00020;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.note {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getAuthFeatures, enterpriseAuthBase, type AuthFeatures } from '$lib/api';
|
||||
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import { getDensity, setDensity, type Density } from '$lib/density.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let features = $state<AuthFeatures>({ sso_configured: false, oidc_enabled: false, saml_enabled: false });
|
||||
@@ -10,18 +12,60 @@
|
||||
loading = false;
|
||||
}
|
||||
load();
|
||||
|
||||
const themeOptions: { value: Theme; label: string; hint: string }[] = [
|
||||
{ value: 'dark', label: 'Dark', hint: 'Default' },
|
||||
{ value: 'light', label: 'Light', hint: '' },
|
||||
{ value: 'system', label: 'System', hint: 'Follow OS setting' }
|
||||
];
|
||||
const densityOptions: { value: Density; label: string; hint: string }[] = [
|
||||
{ value: 'comfortable', label: 'Comfortable', hint: 'Dashboards, forms' },
|
||||
{ value: 'compact', label: 'Compact', hint: 'Log tables, results' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>Settings</h1>
|
||||
|
||||
<section>
|
||||
<h2>Appearance</h2>
|
||||
<div class="option-group" role="radiogroup" aria-label="Theme">
|
||||
{#each themeOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="option"
|
||||
class:selected={getTheme() === opt.value}
|
||||
aria-pressed={getTheme() === opt.value}
|
||||
onclick={() => setTheme(opt.value)}
|
||||
>
|
||||
<span class="option-label">{opt.label}</span>
|
||||
{#if opt.hint}<span class="option-hint">{opt.hint}</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="option-group" role="radiogroup" aria-label="Row density">
|
||||
{#each densityOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="option"
|
||||
class:selected={getDensity() === opt.value}
|
||||
aria-pressed={getDensity() === opt.value}
|
||||
onclick={() => setDensity(opt.value)}
|
||||
>
|
||||
<span class="option-label">{opt.label}</span>
|
||||
<span class="option-hint">{opt.hint}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Core</h2>
|
||||
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
|
||||
</section>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading…</p>
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if features.sso_configured}
|
||||
<section>
|
||||
<h2>Single sign-on</h2>
|
||||
@@ -48,19 +92,66 @@
|
||||
|
||||
<style>
|
||||
main {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 960px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-xl);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
section {
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.note {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.option-group {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.option:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.option.selected {
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
|
||||
}
|
||||
.option-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.option-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
/* The accent-tinted .selected background is *lighter* than plain
|
||||
--color-surface, which quietly drops --color-text-muted below
|
||||
4.5:1 (axe-core's color-contrast check caught this at 4.4:1) --
|
||||
fixed by using the full-strength --color-text on that lighter
|
||||
background specifically, not by changing the shared muted token
|
||||
everywhere else it still passes correctly. */
|
||||
.option.selected .option-hint {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user