Skip to main content

larust_db/dashboard/
mod.rs

1//! `/{DB_DASHBOARD_PATH}/*` (default `/xr-db`) - Larust's own database
2//! admin dashboard: a small, auth-gated, server-rendered tool for
3//! browsing and editing the app's *actual* SQL database (whatever
4//! `DB_CONNECTION` it's configured for - SQLite/MySQL/Postgres), plus a
5//! secondary view onto the embedded KV store this crate also provides.
6//! The SQL side is the reason this exists - `users`, `posts`, every real
7//! model an app has lives there, not in the KV store, which is a
8//! deliberately separate, additive facade for app-local data that never
9//! needed relations (see this crate's own top-level doc comment for that
10//! design). This module is the framework's answer to "I want to poke at
11//! my database during development without leaving Larust or installing a
12//! separate SQL client" - the same job phpMyAdmin/Adminer do for PHP.
13//!
14//! **Carries the Larust brand, not the host app's.** This is a tool the
15//! *framework* ships (the same posture Laravel's own Telescope/Horizon
16//! dashboards take - their own fixed identity, regardless of whatever the
17//! host app looks like), so its colors and mark (`>_` + "larust", [`STYLE`]
18//! below) are the same ones `demo/resources/views/layouts/app.blade.xr` and
19//! `demo/public/styles/style.css` use for the framework's own reference
20//! app. `--brand`'s hex values are duplicated here rather than shared,
21//! since this crate has no dependency on `demo` or any CSS build step of
22//! its own - if that palette ever changes there, it should change here too.
23//!
24//! **Three sections, one login.** `/` (Database - table list/browse/edit),
25//! `/sql` (raw SQL), `/kv` (the embedded KV store) all share the same
26//! session, password, and mount-path gating - see [`sql_views`]/
27//! [`kv_views`] for each section's own handlers, and this module's own
28//! `STYLE`/`page_shell`/`nav_html` for the chrome all three render inside.
29//!
30//! **Mount path.** Configurable via `DB_DASHBOARD_PATH` (default `xr-db`) -
31//! deliberately *not* under the `/__larust_*` internal-route convention
32//! `wire`/`push`/`spa`/`reverb` use, since those are machine-only asset/
33//! API endpoints nobody types into a browser, while this is a real page a
34//! developer navigates to. Changing the path is obscurity, not security -
35//! it's not a substitute for the two real gates below, only an extra one
36//! on top for a team that wants a less-guessable URL.
37//!
38//! **`DB_DASHBOARD_PASSWORD`** (never the app's own `APP_KEY` or user
39//! auth - this has nothing to do with app users) - the dashboard refuses
40//! to serve at all if it's unset, fail closed rather than a default
41//! password. Hashed once via the *existing* `larust_auth::
42//! {hash_password, verify_password}` (argon2, no new crypto). Login sets
43//! a dedicated session flag, mirroring `larust_auth::guard::login`/
44//! `check`'s exact shape one-for-one but with its own key - not a second
45//! auth mechanism, the same primitive reused. Separately, whether this
46//! crate's `DbPlugin` is even registered at all is the generated app's
47//! own choice (an `APP_DEBUG` gate baked into `routes/web.rs` at `xr new`
48//! time - see `docs/ARCHITECTURE.md`) - this module doesn't assume that
49//! and enforces its own password gate regardless.
50//!
51//! **Two different security postures for two different features.**
52//! Structured browse/edit/insert/delete (`sql_views`) only ever
53//! interpolates a table/column name into SQL after validating it against
54//! that table's own freshly-introspected schema - every *value* is always
55//! a bound parameter (`sql::codec::bind_any`), never interpolated as
56//! text. The raw "Run SQL" page is deliberately the opposite: unrestricted
57//! by design, the same way phpMyAdmin's own SQL tab is - its safety is the
58//! password/`APP_DEBUG` double-gate above, not query validation, since
59//! restricting it would defeat the entire point of a "run SQL" feature.
60//!
61//! CSRF is not handled here: every state-changing form embeds the
62//! session's token (`larust_http::csrf::token`/`csrf::FIELD_NAME`), and
63//! verification happens the same way it does for every other POST route in
64//! this framework - the app's own top-level `.middleware(csrf::verify)`,
65//! which (since the `Router::plugin` CSRF fix) already covers every
66//! plugin-contributed route, this one included.
67
68mod kv_views;
69mod sql_views;
70
71use axum::extract::Request;
72use axum::http::StatusCode;
73use axum::middleware::Next;
74use axum::response::{Html, IntoResponse, Redirect, Response};
75use larust_core::AppError;
76use larust_http::session::Session;
77use serde::Deserialize;
78use std::sync::OnceLock;
79
80const SESSION_KEY: &str = "_larust_db_dashboard_authed";
81const DEFAULT_DASHBOARD_PATH: &str = "xr-db";
82
83static DASHBOARD_PATH: OnceLock<String> = OnceLock::new();
84
85/// The dashboard's mount path segment (no leading/trailing slash),
86/// computed once on first access from `DB_DASHBOARD_PATH` - falls back to
87/// [`DEFAULT_DASHBOARD_PATH`] when unset, empty, or all slashes. Leading/
88/// trailing slashes in the configured value are trimmed so
89/// `DB_DASHBOARD_PATH=xr-db`, `=/xr-db`, and `=/xr-db/` all behave
90/// identically.
91pub(crate) fn dashboard_path() -> &'static str {
92    DASHBOARD_PATH.get_or_init(|| {
93        std::env::var("DB_DASHBOARD_PATH")
94            .ok()
95            .map(|raw| raw.trim_matches('/').to_string())
96            .filter(|path| !path.is_empty())
97            .unwrap_or_else(|| DEFAULT_DASHBOARD_PATH.to_string())
98    })
99}
100
101/// The routes this crate needs, for [`larust_http::Router::plugin`]. Only
102/// `/login` is reachable without a valid dashboard session - everything
103/// else sits behind [`require_db_login`] via a nested `.group("", ...)`,
104/// the same shape `ROUTES_WEB_HEADER_WITH_AUTH`'s own `require_auth` group
105/// already uses. axum matches `/{base}` and `/{base}/` (and `/login` vs
106/// `/login/`) as distinct routes - both registered so a typed or
107/// bookmarked trailing slash doesn't 404.
108pub struct DbPlugin;
109
110impl larust_http::Plugin for DbPlugin {
111    fn routes(&self) -> larust_http::Router {
112        let base = dashboard_path();
113        larust_http::Router::new()
114            .get(&format!("/{base}/login"), login_form)
115            .get(&format!("/{base}/login/"), login_form)
116            .post(&format!("/{base}/login"), login)
117            .group("", |r| {
118                r.middleware(axum::middleware::from_fn(require_db_login))
119                    .get(&format!("/{base}"), sql_views::table_list)
120                    .get(&format!("/{base}/"), sql_views::table_list)
121                    .get(&format!("/{base}/sql"), sql_views::sql_form)
122                    .post(&format!("/{base}/sql"), sql_views::sql_run)
123                    .get(&format!("/{base}/import"), sql_views::import_form)
124                    .post(&format!("/{base}/import"), sql_views::import_run)
125                    .post(&format!("/{base}/migrate/fresh"), sql_views::migrate_fresh)
126                    .get(&format!("/{base}/t/{{table}}"), sql_views::browse)
127                    .get(
128                        &format!("/{base}/t/{{table}}/structure"),
129                        sql_views::structure,
130                    )
131                    .get(&format!("/{base}/t/{{table}}/new"), sql_views::new_form)
132                    .post(&format!("/{base}/t/{{table}}/insert"), sql_views::insert)
133                    .get(&format!("/{base}/t/{{table}}/edit"), sql_views::edit_form)
134                    .post(&format!("/{base}/t/{{table}}/update"), sql_views::update)
135                    .post(&format!("/{base}/t/{{table}}/delete"), sql_views::delete)
136                    .get(&format!("/{base}/kv"), kv_views::index)
137                    .post(&format!("/{base}/kv/set"), kv_views::set)
138                    .post(&format!("/{base}/kv/{{key}}/delete"), kv_views::delete)
139                    .post(&format!("/{base}/logout"), logout)
140            })
141    }
142}
143
144static PASSWORD_HASH: OnceLock<Option<String>> = OnceLock::new();
145
146/// The configured dashboard password's hash, computed once on first access
147/// from `DB_DASHBOARD_PASSWORD` - `None` if that env var is unset or
148/// empty, which is what makes every handler below fail closed.
149fn configured_password_hash() -> Option<&'static str> {
150    PASSWORD_HASH
151        .get_or_init(|| {
152            std::env::var("DB_DASHBOARD_PASSWORD")
153                .ok()
154                .filter(|password| !password.is_empty())
155                .map(|password| {
156                    larust_auth::hash_password(&password)
157                        .expect("hashing the configured DB_DASHBOARD_PASSWORD")
158                })
159        })
160        .as_deref()
161}
162
163fn dashboard_disabled() -> Response {
164    (
165        StatusCode::SERVICE_UNAVAILABLE,
166        "The embedded DB dashboard is disabled - set DB_DASHBOARD_PASSWORD in .env to enable it.",
167    )
168        .into_response()
169}
170
171async fn is_authed(session: &Session) -> Result<bool, AppError> {
172    Ok(session
173        .get::<bool>(SESSION_KEY)
174        .await
175        .map_err(internal)?
176        .unwrap_or(false))
177}
178
179/// Gates every route except `/login` behind a valid dashboard session -
180/// mirrors `larust_auth::middleware::require_auth`'s exact shape (fail
181/// closed on a session-store error rather than letting the request
182/// through).
183pub async fn require_db_login(session: Session, request: Request, next: Next) -> Response {
184    if configured_password_hash().is_none() {
185        return dashboard_disabled();
186    }
187    match is_authed(&session).await {
188        Ok(true) => next.run(request).await,
189        Ok(false) => Redirect::to(&format!("/{}/login", dashboard_path())).into_response(),
190        Err(error) => {
191            tracing::warn!(%error, "db dashboard: failed to read session; denying access");
192            Redirect::to(&format!("/{}/login", dashboard_path())).into_response()
193        }
194    }
195}
196
197pub async fn login_form(session: Session) -> Response {
198    if configured_password_hash().is_none() {
199        return dashboard_disabled();
200    }
201    let csrf = larust_http::csrf::token(&session).await;
202    Html(render_login_page(&csrf, None)).into_response()
203}
204
205#[derive(Deserialize)]
206pub struct LoginForm {
207    password: String,
208}
209
210pub async fn login(session: Session, axum::Form(form): axum::Form<LoginForm>) -> Response {
211    let Some(hash) = configured_password_hash() else {
212        return dashboard_disabled();
213    };
214    let ok = larust_auth::verify_password(hash, &form.password).unwrap_or(false);
215    if !ok {
216        let csrf = larust_http::csrf::token(&session).await;
217        return Html(render_login_page(&csrf, Some("Incorrect password."))).into_response();
218    }
219    if let Err(error) = session.insert(SESSION_KEY, true).await {
220        tracing::warn!(%error, "db dashboard: failed to persist login session flag");
221    }
222    Redirect::to(&format!("/{}", dashboard_path())).into_response()
223}
224
225pub async fn logout(session: Session) -> Result<Redirect, AppError> {
226    session
227        .remove::<bool>(SESSION_KEY)
228        .await
229        .map_err(internal)?;
230    Ok(Redirect::to(&format!("/{}/login", dashboard_path())))
231}
232
233/// `>_` + "larust" - the same mark and wordmark
234/// `demo/resources/views/layouts/app.blade.xr` uses in its own header (see
235/// this module's own doc comment for why this crate carries that identity
236/// rather than a generic one).
237fn brand_html(size_class: &str) -> String {
238    format!(
239        r#"<div class="brand {size_class}">
240    <span class="brand-mark">&gt;_</span>
241    <span class="brand-name">larust <strong>db</strong></span>
242</div>"#
243    )
244}
245
246/// Which of the three sections a page belongs to, for [`sidebar_html`]'s
247/// active-tab highlighting.
248#[derive(Clone, Copy, PartialEq, Eq)]
249pub(crate) enum Section {
250    Database,
251    Sql,
252    Kv,
253}
254
255/// Percent-encodes a single URL *path* segment (RFC 3986 unreserved
256/// characters pass through, everything else becomes `%XX`) - deliberately
257/// not `form_urlencoded` (that crate targets
258/// `application/x-www-form-urlencoded` query-string/form-body encoding,
259/// where a space becomes `+`; a path segment has different escaping rules
260/// entirely, and axum's own `Path` extractor percent-decodes, not
261/// plus-decodes, so using the wrong encoder here would round-trip a table
262/// name with a space incorrectly). Shared by `sql_views` (route-building)
263/// and this module's own [`sidebar_html`] (the table nav list).
264pub(crate) fn path_segment(s: &str) -> String {
265    let mut out = String::with_capacity(s.len());
266    for byte in s.bytes() {
267        match byte {
268            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
269                out.push(byte as char);
270            }
271            _ => out.push_str(&format!("%{byte:02X}")),
272        }
273    }
274    out
275}
276
277/// The same mark `demo/public/images/favicon.svg` ships, inlined as a data
278/// URI rather than served from a path - this crate has no dependency on
279/// `demo`'s static files (or any given host app's), and a generated app
280/// isn't guaranteed to keep that exact file in place, so embedding it here
281/// is the only way every `/{base}` page reliably gets the Larust favicon
282/// regardless of the host app's own asset layout.
283const FAVICON_DATA_URI: &str = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCI+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5jbHMtMSB7CiAgICAgICAgZmlsbDogI2ZmZjsKICAgICAgfQoKICAgICAgLmNscy0yIHsKICAgICAgICBmaWxsOiAjZmY3MzVmOwogICAgICB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KICA8cGF0aCBpZD0iUmVjdGFuZ2xlXzEiIGRhdGEtbmFtZT0iUmVjdGFuZ2xlIDEiIGNsYXNzPSJjbHMtMiIgZD0iTTEyLDBoMjRjNi42MywwLDEyLDUuMzcsMTIsMTJ2MjRjMCw2LjYzLTUuMzcsMTItMTIsMTJIMFYxMkMwLDUuMzcsNS4zNywwLDEyLDBaIi8+CiAgPGcgaWQ9IlBhdGhfMSIgZGF0YS1uYW1lPSJQYXRoIDEiPgogICAgPHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTMuMjUsMzAuNTljLS4yMywwLS40Ni0uMDgtLjY0LS4yMy0uNDItLjM2LS40OC0uOTktLjEyLTEuNDFsNC43LTUuNTktNC42OS01LjQyYy0uMzYtLjQyLS4zMi0xLjA1LjEtMS40MS40Mi0uMzYsMS4wNS0uMzIsMS40MS4xbDUuMjUsNi4wN2MuMzIuMzcuMzMuOTIsMCwxLjNsLTUuMjUsNi4yNGMtLjIuMjQtLjQ4LjM2LS43Ny4zNloiLz4KICA8L2c+CiAgPGcgaWQ9IkxpbmVfMSIgZGF0YS1uYW1lPSJMaW5lIDEiPgogICAgPHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMzIuNzUsMzQuNzNoLTEyYy0uNTUsMC0xLS40NS0xLTFzLjQ1LTEsMS0xaDEyYy41NSwwLDEsLjQ1LDEsMXMtLjQ1LDEtMSwxWiIvPgogIDwvZz4KPC9zdmc+";
284
285/// Wraps `body_html` in the shared page shell (doctype/head/[`STYLE`]) -
286/// every full page in this module renders through this, so there's one
287/// place that owns the `<html>`/`<head>` boilerplate.
288pub(crate) fn page_shell(body_html: &str) -> String {
289    format!(
290        r#"<!doctype html>
291<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>larust db</title><link rel="icon" href="{FAVICON_DATA_URI}" type="image/svg+xml">{STYLE}</head>
292<body>
293{body_html}
294</body></html>
295"#
296    )
297}
298
299/// Just the logout button now - the brand mark moved into [`sidebar_html`]
300/// once the top nav tabs became a left sidebar.
301fn topbar_html(csrf: &str) -> String {
302    let base = dashboard_path();
303    format!(
304        r#"<div class="topbar">
305    <form method="post" action="/{base}/logout">
306        <input type="hidden" name="{field}" value="{csrf}">
307        <button type="submit" class="link-button">Log out</button>
308    </form>
309</div>"#,
310        field = larust_http::csrf::FIELD_NAME,
311        csrf = html_escape(csrf),
312    )
313}
314
315/// The persistent left sidebar: brand mark, the 3-section switcher
316/// (Database/SQL/Key-Value), and - only for the Database section - the
317/// live table list (the currently-browsed table highlighted via
318/// `active_table`), an "Import .sql" link, and the destructive "Fresh
319/// migrate" action, visually separated as `.sidebar-danger`.
320///
321/// `tables` is fetched fresh by every Database-section caller (one extra
322/// query per page load beyond what that page already needed) - an
323/// accepted tradeoff for a dev-only tool, not worth caching machinery.
324fn sidebar_html(
325    base: &str,
326    csrf: &str,
327    active: Section,
328    tables: &[String],
329    active_table: Option<&str>,
330) -> String {
331    let tab = |section: Section, href: String, label: &str| {
332        let class = if section == active {
333            "nav-tab-v is-active"
334        } else {
335            "nav-tab-v"
336        };
337        format!(r#"<a class="{class}" href="{href}">{label}</a>"#)
338    };
339    let section_nav = format!(
340        r#"<nav class="nav-tabs-vertical">
341    {database}
342    {sql}
343    {kv}
344</nav>"#,
345        database = tab(Section::Database, format!("/{base}"), "Database"),
346        sql = tab(Section::Sql, format!("/{base}/sql"), "SQL"),
347        kv = tab(Section::Kv, format!("/{base}/kv"), "Key-Value"),
348    );
349
350    let database_extra = if active == Section::Database {
351        let table_items: String = tables
352            .iter()
353            .map(|table| {
354                let class = if Some(table.as_str()) == active_table {
355                    "table-nav-item is-active"
356                } else {
357                    "table-nav-item"
358                };
359                format!(
360                    r#"<a class="{class}" href="/{base}/t/{path}">{name}</a>"#,
361                    path = path_segment(table),
362                    name = html_escape(table),
363                )
364            })
365            .collect();
366        format!(
367            r#"<div class="sidebar-section">
368    <div class="sidebar-label">Tables</div>
369    <nav class="table-nav-list">{table_items}</nav>
370</div>
371<div class="sidebar-section">
372    <a class="sidebar-link" href="/{base}/import">Import .sql</a>
373</div>
374<div class="sidebar-danger">
375    <form method="post" action="/{base}/migrate/fresh" onsubmit="return confirm('This drops every table and reapplies migrations from scratch. Continue?')">
376        <input type="hidden" name="{field}" value="{csrf}">
377        <button type="submit" class="button-ghost">Fresh migrate</button>
378    </form>
379</div>"#,
380            field = larust_http::csrf::FIELD_NAME,
381            csrf = html_escape(csrf),
382        )
383    } else {
384        String::new()
385    };
386
387    format!(
388        r#"{brand}
389{section_nav}
390{database_extra}"#,
391        brand = brand_html("brand-compact"),
392    )
393}
394
395/// Wraps `main_html` in the topbar + left-sidebar shell every logged-in
396/// page under `/{base}` shares - the one place that owns the
397/// `.dashboard-layout` structure, so every `sql_views`/`kv_views` handler
398/// just supplies its own inner content.
399pub(crate) fn page_frame(
400    csrf: &str,
401    active: Section,
402    tables: &[String],
403    active_table: Option<&str>,
404    main_html: &str,
405) -> String {
406    format!(
407        r#"<div class="wrap">
408    {topbar}
409    <div class="dashboard-layout">
410        <aside class="sidebar">
411            {sidebar}
412        </aside>
413        <main class="main-content">
414            {main_html}
415        </main>
416    </div>
417</div>"#,
418        topbar = topbar_html(csrf),
419        sidebar = sidebar_html(dashboard_path(), csrf, active, tables, active_table),
420    )
421}
422
423fn render_login_page(csrf: &str, error: Option<&str>) -> String {
424    let base = dashboard_path();
425    let error_html = error
426        .map(|message| format!(r#"<p class="error">{}</p>"#, html_escape(message)))
427        .unwrap_or_default();
428    page_shell(&format!(
429        r#"<div class="wrap wrap-narrow">
430    <div class="card card-centered">
431        {brand}
432        <p class="subtitle">Sign in to browse and edit</p>
433        {error_html}
434        <form method="post" action="/{base}/login">
435            <input type="password" name="password" placeholder="Dashboard password" autofocus class="login-input">
436            <input type="hidden" name="{field}" value="{csrf}">
437            <button type="submit" class="button button-block">Log in</button>
438        </form>
439    </div>
440</div>"#,
441        brand = brand_html("brand-centered"),
442        field = larust_http::csrf::FIELD_NAME,
443        csrf = html_escape(csrf),
444    ))
445}
446
447// Larust's own brand - the same colors and `>_`/"larust" mark
448// `demo/public/styles/style.css` and its layout template use, kept in
449// sync by hand (see this module's own doc comment for why this crate
450// can't just depend on that stylesheet). Light values match `:root` there,
451// dark values match its `[data-theme="dark"]` block - demo's own dark
452// mode is a JS-driven toggle, this page has no JS at all, so it switches
453// on `prefers-color-scheme` instead; the two mechanisms differ, the colors
454// don't.
455const STYLE: &str = r#"<style>
456:root {
457    --ink: #202124;
458    --muted: #6b6d73;
459    --paper: #fffdf9;
460    --canvas: #f4f0e8;
461    --line: #e4ddd2;
462    --brand: #f4513d;
463    --brand-dark: #cf3628;
464    --brand-shadow: color-mix(in srgb, var(--brand) 19%, transparent);
465    --danger: #8c3028;
466    --danger-bg: #ffebe7;
467    --danger-line: #f0c4bc;
468    color-scheme: light dark;
469}
470@media (prefers-color-scheme: dark) {
471    :root {
472        --ink: #f6f1e8;
473        --muted: #b8b1a8;
474        --paper: #272522;
475        --canvas: #181716;
476        --line: #45413b;
477        --brand: #ff735f;
478        --brand-dark: #ff8a79;
479        --danger: #ffb4a8;
480        --danger-bg: #3a2320;
481        --danger-line: #5c332c;
482    }
483}
484* { box-sizing: border-box; }
485body {
486    margin: 0;
487    min-height: 100vh;
488    color: var(--ink);
489    background: var(--canvas);
490    font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
491    line-height: 1.5;
492}
493.wrap { width: min(1200px, calc(100% - 40px)); margin: 48px auto; }
494.wrap-narrow { width: min(420px, calc(100% - 40px)); margin: 96px auto; }
495.dashboard-layout { display: flex; align-items: flex-start; gap: 28px; }
496.sidebar { width: 220px; flex-shrink: 0; }
497.main-content { flex: 1; min-width: 0; }
498.nav-tabs-vertical { display: flex; flex-direction: column; gap: 2px; margin: 20px 0; }
499.nav-tab-v {
500    padding: 8px 10px;
501    border-radius: 8px;
502    color: var(--muted);
503    font-weight: 650;
504    font-size: .9rem;
505}
506.nav-tab-v:hover { background: var(--canvas); color: var(--ink); }
507.nav-tab-v.is-active { color: var(--brand-dark); background: var(--brand-shadow); }
508.sidebar-section { margin-bottom: 16px; }
509.sidebar-label {
510    padding: 0 10px;
511    margin-bottom: 6px;
512    color: var(--muted);
513    font-size: .72rem;
514    font-weight: 750;
515    text-transform: uppercase;
516    letter-spacing: .05em;
517}
518.table-nav-list { display: flex; flex-direction: column; gap: 1px; max-height: 50vh; overflow-y: auto; }
519.table-nav-item {
520    padding: 7px 10px;
521    border-radius: 8px;
522    color: var(--ink);
523    font-size: .85rem;
524    font-family: ui-monospace, "SF Mono", Consolas, monospace;
525    overflow: hidden;
526    text-overflow: ellipsis;
527    white-space: nowrap;
528}
529.table-nav-item:hover { background: var(--canvas); }
530.table-nav-item.is-active { background: var(--brand-shadow); color: var(--brand-dark); font-weight: 650; }
531.sidebar-link {
532    display: block;
533    padding: 7px 10px;
534    color: var(--muted);
535    font-size: .85rem;
536    font-weight: 650;
537    border-radius: 8px;
538}
539.sidebar-link:hover { background: var(--canvas); color: var(--ink); }
540.sidebar-danger { padding-top: 16px; border-top: 1px solid var(--line); }
541.sidebar-danger .button-ghost { width: 100%; color: var(--danger); box-shadow: inset 0 0 0 1px var(--danger-line); }
542.sidebar-danger .button-ghost:hover { background: var(--danger-bg); }
543.card {
544    background: var(--paper);
545    border: 1px solid var(--line);
546    border-radius: 18px;
547    padding: 30px 32px;
548}
549.card-centered { text-align: center; padding: 40px 36px; }
550.card-centered form { text-align: left; }
551.brand { display: inline-flex; gap: 12px; align-items: center; }
552.brand-centered { display: flex; justify-content: center; gap: 16px; margin-bottom: 22px; }
553.brand-mark {
554    display: grid;
555    place-items: center;
556    flex-shrink: 0;
557    width: 34px;
558    height: 34px;
559    color: #fff;
560    background: var(--brand);
561    border-radius: 10px 10px 10px 3px;
562    font-family: ui-monospace, monospace;
563    font-size: 1rem;
564    font-weight: 700;
565}
566.brand-centered .brand-mark {
567    width: 60px;
568    height: 60px;
569    border-radius: 18px 18px 18px 6px;
570    font-size: 1.6rem;
571}
572.brand-name { font-size: 1.15rem; font-weight: 800; letter-spacing: -.03em; }
573.brand-name strong { color: var(--brand); font-weight: 800; }
574.brand-centered .brand-name { font-size: 1.6rem; }
575h1 { margin: 0 0 4px; font-size: 1.3rem; letter-spacing: -.01em; }
576h2 { margin: 0 0 16px; font-size: 1.05rem; letter-spacing: -.01em; }
577.subtitle { margin: 6px 0 0; color: var(--muted); font-size: .88rem; }
578.card-centered .subtitle { margin: 0 0 28px; font-size: .95rem; }
579.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 16px; }
580.nav-tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--line); }
581.nav-tab {
582    padding: 9px 16px;
583    color: var(--muted);
584    font-weight: 650;
585    font-size: .9rem;
586    border-bottom: 2px solid transparent;
587    margin-bottom: -1px;
588}
589.nav-tab:hover { color: var(--ink); }
590.nav-tab.is-active { color: var(--brand-dark); border-bottom-color: var(--brand); }
591.breadcrumb { margin: 0 0 16px; color: var(--muted); font-size: .85rem; }
592.breadcrumb a { color: var(--brand-dark); font-weight: 650; }
593.breadcrumb a:hover { text-decoration: underline; }
594input[type="text"], input[type="password"], textarea {
595    width: 100%;
596    padding: 11px 14px;
597    border: 1px solid var(--line);
598    border-radius: 10px;
599    background: var(--paper);
600    color: var(--ink);
601    font: inherit;
602    outline: none;
603}
604textarea { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: .88rem; min-height: 120px; resize: vertical; }
605input[type="text"]:focus, input[type="password"]:focus, textarea:focus {
606    border-color: var(--brand);
607    box-shadow: 0 0 0 4px var(--brand-shadow);
608}
609input[readonly] { background: var(--canvas); color: var(--muted); }
610.login-input { margin-bottom: 16px; }
611.button {
612    display: inline-flex;
613    align-items: center;
614    justify-content: center;
615    border: 0;
616    border-radius: 10px;
617    padding: 11px 18px;
618    background: var(--brand);
619    color: #fff;
620    font: inherit;
621    font-weight: 750;
622    cursor: pointer;
623    white-space: nowrap;
624    box-shadow: 0 6px 14px var(--brand-shadow);
625    transition: .15s;
626}
627.button:hover { background: var(--brand-dark); transform: translateY(-1px); }
628.button-block { width: 100%; }
629.button-secondary {
630    background: var(--paper);
631    color: var(--ink);
632    box-shadow: inset 0 0 0 1px var(--line);
633}
634.button-secondary:hover { background: var(--canvas); transform: none; }
635.button-ghost {
636    border: 0;
637    background: transparent;
638    color: var(--muted);
639    box-shadow: inset 0 0 0 1px var(--line);
640    border-radius: 8px;
641    padding: 6px 12px;
642    font: inherit;
643    font-size: .85rem;
644    font-weight: 650;
645    cursor: pointer;
646    transition: .15s;
647}
648.button-ghost:hover { background: var(--danger-bg); color: var(--danger); box-shadow: inset 0 0 0 1px var(--danger-line); }
649.link-button {
650    border: 0;
651    background: transparent;
652    color: var(--muted);
653    font: inherit;
654    font-size: .85rem;
655    font-weight: 650;
656    cursor: pointer;
657    padding: 2px;
658}
659.link-button:hover { color: var(--brand-dark); text-decoration: underline; }
660.set-row { display: flex; gap: 8px; margin-bottom: 24px; }
661.set-row input[type="text"]:first-child { flex: 0 0 200px; }
662.set-row input[type="text"]:nth-child(2) { flex: 1; }
663table { width: 100%; border-collapse: collapse; }
664th {
665    text-align: left;
666    padding: 8px 10px;
667    border-bottom: 1px solid var(--line);
668    color: var(--muted);
669    font-size: .72rem;
670    font-weight: 750;
671    text-transform: uppercase;
672    letter-spacing: .05em;
673    white-space: nowrap;
674}
675td { padding: 12px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
676tr:last-child td { border-bottom: none; }
677tr.is-clickable:hover { background: var(--canvas); }
678.key-cell { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-weight: 650; white-space: nowrap; }
679.actions-cell { text-align: right; width: 1%; white-space: nowrap; }
680.count-cell { color: var(--muted); text-align: right; width: 1%; white-space: nowrap; }
681pre { margin: 0; font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: .82rem; white-space: pre-wrap; word-break: break-word; color: var(--muted); }
682.null-value { color: var(--muted); font-style: italic; }
683.empty-state { padding: 32px 8px; text-align: center; color: var(--muted); }
684.empty-state p { margin: 0 0 6px; }
685.empty-hint { font-size: .85rem; }
686.empty-hint code { background: var(--canvas); border-radius: 4px; padding: 2px 6px; font-family: ui-monospace, "SF Mono", Consolas, monospace; }
687.error {
688    margin: 0 0 16px;
689    padding: 10px 14px;
690    border-radius: 10px;
691    color: var(--danger);
692    background: var(--danger-bg);
693    font-size: .88rem;
694    font-weight: 650;
695}
696.form-field { display: grid; gap: 6px; margin-bottom: 16px; }
697.form-field label { font-size: .82rem; font-weight: 700; color: var(--muted); }
698.form-actions { display: flex; gap: 10px; margin-top: 20px; }
699.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; font-size: .85rem; color: var(--muted); }
700.pagination a { color: var(--brand-dark); font-weight: 650; }
701.pagination a:hover { text-decoration: underline; }
702.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; }
703</style>"#;
704
705/// Every value embedded above is either app-controlled data (a KV/SQL
706/// value, a table/column name) or another route's own generated token -
707/// never assume it's safe to write into HTML verbatim.
708pub(crate) fn html_escape(input: &str) -> String {
709    input
710        .replace('&', "&amp;")
711        .replace('<', "&lt;")
712        .replace('>', "&gt;")
713        .replace('"', "&quot;")
714        .replace('\'', "&#39;")
715}
716
717pub(crate) fn internal<E: std::error::Error + Send + Sync + 'static>(error: E) -> AppError {
718    AppError::Internal(Box::new(error))
719}