Răsfoiți Sursa

Add a dashboard to observe a Kuatia ledger over HTTP

The ledger had no way to inspect its state without writing Rust against the
Store trait. This adds kuatia-dashboard, a read-only observer that serves a
server-rendered HTML UI (Tera templates with htmx for live refresh) and a JSON
REST API under /api built from the same data layer, so the same state can drive
both the built-in pages and any richer client someone wants to build.

It connects to any supported backend (in-memory or file SQLite, or PostgreSQL)
and binds a configurable host and port through CLI flags that fall back to
environment variables. Seeding demo data is opt-in via --seed and is a no-op
against a database that already holds accounts, so pointing the dashboard at a
real, persistent ledger just visualizes it.
Cesar Rodas 3 zile în urmă
părinte
comite
2e53654b6c

Fișier diff suprimat deoarece este prea mare
+ 760 - 0
Cargo.lock


+ 1 - 1
Cargo.toml

@@ -1,6 +1,6 @@
 [workspace]
 resolver = "2"
-members = ["crates/kuatia-money", "crates/kuatia-types", "crates/kuatia-core", "crates/kuatia-storage", "crates/kuatia-storage-sql", "crates/kuatia"]
+members = ["crates/kuatia-money", "crates/kuatia-types", "crates/kuatia-core", "crates/kuatia-storage", "crates/kuatia-storage-sql", "crates/kuatia", "crates/kuatia-dashboard"]
 
 [workspace.package]
 version = "0.1.0"

+ 30 - 0
crates/kuatia-dashboard/Cargo.toml

@@ -0,0 +1,30 @@
+[package]
+name = "kuatia-dashboard"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.85"
+license = "Apache-2.0"
+publish = false
+description = "Server-rendered dashboard and REST API for exploring a Kuatia ledger"
+
+[[bin]]
+name = "kuatia-dashboard"
+path = "src/main.rs"
+
+[dependencies]
+kuatia = { path = "../kuatia", version = "0.1.0" }
+kuatia-core = { workspace = true }
+kuatia-storage = { workspace = true }
+kuatia-storage-sql = { workspace = true, features = ["postgres"] }
+
+tokio = { workspace = true, features = ["full"] }
+serde = { workspace = true }
+serde_json = { workspace = true }
+sqlx = { workspace = true, features = ["sqlite", "postgres"] }
+
+axum = "0.8"
+clap = { version = "4", features = ["derive", "env"] }
+tower-http = { version = "0.6", features = ["cors", "trace"] }
+tera = "1"
+tracing = { workspace = true }
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }

+ 68 - 0
crates/kuatia-dashboard/README.md

@@ -0,0 +1,68 @@
+# kuatia-dashboard
+
+A read-only visualizer for a Kuatia ledger. It connects to a ledger database
+(SQLite or PostgreSQL) and serves two views of it:
+
+- A server-rendered HTML dashboard (Tera templates, htmx for live refresh).
+- A JSON REST API under `/api` for anyone who wants to build a richer client.
+
+The dashboard only observes the ledger. It never mutates it. Templates, CSS,
+and htmx are embedded in the binary, so nothing extra is needed on disk at
+runtime.
+
+## Run it
+
+```sh
+# In-memory demo (seeds accounts/transfers on start):
+cargo run -p kuatia-dashboard -- --seed
+# open http://127.0.0.1:3000
+
+# Persist to a SQLite file, seed once, then reopen without reseeding:
+cargo run -p kuatia-dashboard -- --db sqlite://kuatia.db --seed
+cargo run -p kuatia-dashboard -- --db sqlite://kuatia.db
+
+# Point at a PostgreSQL ledger on a custom port:
+cargo run -p kuatia-dashboard -- --db postgres://user:pass@host/db --port 8080
+```
+
+`--seed` populates demo data only when the ledger is empty, so it is safe to
+leave on; against an already-populated database it is a no-op.
+
+## Pages
+
+| Path             | Shows                                                      |
+| ---------------- | --------------------------------------------------------- |
+| `/`              | Overview: account count, transfer count, issued per asset |
+| `/accounts`      | All accounts with policy, flags, and balances             |
+| `/accounts/:id`  | One account: balances, postings, and its transfers        |
+| `/transfers`     | Recent transfers with their created postings              |
+| `/events`        | The append-only ledger event log                          |
+
+Each page wraps its dynamic section in an htmx `hx-get`/`hx-trigger="every 3s"`
+element that polls a matching `/ui/*` route and swaps in the fresh fragment.
+
+## REST API
+
+All amounts are minor-unit strings (the ledger's native `Cent` form). Clients
+format them using the decimals from `/api/assets`.
+
+| Method & path           | Returns                                               |
+| ----------------------- | ----------------------------------------------------- |
+| `GET /api/assets`       | Asset registry: id, code, symbol, decimals            |
+| `GET /api/overview`     | Account count, transfer count, total issued per asset |
+| `GET /api/accounts`     | All accounts with policy, flags, and balances         |
+| `GET /api/accounts/:id` | One account plus its postings and transfers           |
+| `GET /api/transfers`    | Recent transfers with their created postings (legs)   |
+| `GET /api/events`       | The append-only ledger event log                      |
+
+## Configuration
+
+Each option is a CLI flag with an environment-variable fallback and a default.
+
+| Flag       | Env var                 | Default          | Purpose                                  |
+| ---------- | ----------------------- | ---------------- | ---------------------------------------- |
+| `--db`     | `KUATIA_DASHBOARD_DB`   | `sqlite::memory:`| Ledger database URL (SQLite or Postgres) |
+| `--host`   | `KUATIA_DASHBOARD_HOST` | `127.0.0.1`      | Interface to bind                        |
+| `--port`   | `KUATIA_DASHBOARD_PORT` | `3000`           | Listen port                              |
+| `--seed`   | `KUATIA_DASHBOARD_SEED` | `false`          | Seed demo data if the ledger is empty    |
+| (n/a)      | `RUST_LOG`              | `kuatia_dashboard=info,tower_http=info` | Log filter                |

+ 85 - 0
crates/kuatia-dashboard/src/api.rs

@@ -0,0 +1,85 @@
+//! REST API over a `Ledger`. Everything is read-only: the dashboard observes
+//! the ledger, it does not mutate it. All monetary values are emitted as
+//! minor-unit strings (the native `Cent` serialization); clients format them
+//! using the asset registry from `/api/assets`.
+//!
+//! These handlers are thin wrappers over the shared builders in [`crate::data`];
+//! the server-rendered HTML views in [`crate::ui`] read from the same builders.
+
+use axum::{
+    Json, Router,
+    extract::{Path, Query, State},
+    routing::get,
+};
+use kuatia_core::AccountId;
+use serde::Deserialize;
+
+use crate::assets::AssetMeta;
+use crate::data::{
+    AccountDetailDto, AccountDto, ApiError, AppState, EventDto, OverviewDto, TransferDto,
+};
+
+/// Build the `/api` router.
+pub fn router(state: AppState) -> Router {
+    Router::new()
+        .route("/assets", get(assets))
+        .route("/overview", get(overview))
+        .route("/accounts", get(accounts))
+        .route("/accounts/{id}", get(account_detail))
+        .route("/transfers", get(transfers))
+        .route("/events", get(events))
+        .with_state(state)
+}
+
+async fn assets(State(state): State<AppState>) -> Json<Vec<AssetMeta>> {
+    Json((*state.assets).clone())
+}
+
+async fn overview(State(state): State<AppState>) -> Result<Json<OverviewDto>, ApiError> {
+    Ok(Json(crate::data::overview(&state).await?))
+}
+
+async fn accounts(State(state): State<AppState>) -> Result<Json<Vec<AccountDto>>, ApiError> {
+    Ok(Json(crate::data::accounts(&state).await?))
+}
+
+async fn account_detail(
+    State(state): State<AppState>,
+    Path(id): Path<i64>,
+) -> Result<Json<AccountDetailDto>, ApiError> {
+    Ok(Json(
+        crate::data::account_detail(&state, AccountId::new(id)).await?,
+    ))
+}
+
+#[derive(Deserialize)]
+struct TransfersParams {
+    limit: Option<u32>,
+}
+
+async fn transfers(
+    State(state): State<AppState>,
+    Query(params): Query<TransfersParams>,
+) -> Result<Json<Vec<TransferDto>>, ApiError> {
+    Ok(Json(crate::data::transfers(&state, params.limit).await?))
+}
+
+#[derive(Deserialize)]
+struct EventsParams {
+    after: Option<u64>,
+    limit: Option<u32>,
+}
+
+async fn events(
+    State(state): State<AppState>,
+    Query(params): Query<EventsParams>,
+) -> Result<Json<Vec<EventDto>>, ApiError> {
+    Ok(Json(
+        crate::data::events(
+            &state,
+            params.after.unwrap_or(0),
+            params.limit.unwrap_or(200),
+        )
+        .await?,
+    ))
+}

+ 43 - 0
crates/kuatia-dashboard/src/assets.rs

@@ -0,0 +1,43 @@
+//! Static asset registry for the demo. Kuatia tracks assets only by opaque
+//! [`AssetId`]; symbols and decimal precision live in the application, so the
+//! dashboard defines them here and exposes them over the API for formatting.
+
+use kuatia_core::AssetId;
+use serde::Serialize;
+
+pub const USD: AssetId = AssetId::new(1);
+pub const EUR: AssetId = AssetId::new(2);
+pub const BTC: AssetId = AssetId::new(3);
+
+/// Presentation metadata for one asset.
+#[derive(Debug, Clone, Serialize)]
+pub struct AssetMeta {
+    pub id: AssetId,
+    pub code: &'static str,
+    pub symbol: &'static str,
+    pub decimals: u8,
+}
+
+/// All assets known to the demo, in display order.
+pub fn registry() -> Vec<AssetMeta> {
+    vec![
+        AssetMeta {
+            id: USD,
+            code: "USD",
+            symbol: "$",
+            decimals: 2,
+        },
+        AssetMeta {
+            id: EUR,
+            code: "EUR",
+            symbol: "\u{20ac}",
+            decimals: 2,
+        },
+        AssetMeta {
+            id: BTC,
+            code: "BTC",
+            symbol: "\u{20bf}",
+            decimals: 8,
+        },
+    ]
+}

+ 371 - 0
crates/kuatia-dashboard/src/data.rs

@@ -0,0 +1,371 @@
+//! Shared data layer. Reads the ledger and builds the DTOs consumed by both the
+//! JSON API ([`crate::api`]) and the server-rendered HTML views ([`crate::ui`]).
+//! Everything here is read-only. Monetary values stay as raw [`Cent`] (minor
+//! units); presentation formats them.
+
+use std::sync::Arc;
+
+use axum::{
+    Json,
+    http::StatusCode,
+    response::{IntoResponse, Response},
+};
+use kuatia::ledger::Ledger;
+use kuatia_core::{Account, AccountId, AccountPolicy, AssetId, Cent, PostingId};
+use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
+use kuatia_storage::store::{EnvelopeRecord, TransferQuery};
+use serde::Serialize;
+use tera::Tera;
+
+use crate::assets::AssetMeta;
+use crate::seed::account_label;
+
+/// Shared handler state.
+#[derive(Clone)]
+pub struct AppState {
+    pub ledger: Arc<Ledger>,
+    pub assets: Arc<Vec<AssetMeta>>,
+    pub tera: Arc<Tera>,
+}
+
+// ---------------------------------------------------------------------------
+// DTOs
+// ---------------------------------------------------------------------------
+
+#[derive(Serialize)]
+pub struct BalanceDto {
+    pub asset: AssetId,
+    pub value: Cent,
+}
+
+#[derive(Serialize)]
+pub struct AccountDto {
+    pub id: AccountId,
+    pub label: Option<&'static str>,
+    pub version: u64,
+    pub policy: PolicyDto,
+    pub frozen: bool,
+    pub closed: bool,
+    pub balances: Vec<BalanceDto>,
+}
+
+#[derive(Serialize)]
+pub struct PolicyDto {
+    pub kind: &'static str,
+    pub floor: Option<Cent>,
+}
+
+#[derive(Serialize)]
+pub struct PostingDto {
+    pub id: String,
+    pub owner: AccountId,
+    pub asset: AssetId,
+    pub value: Cent,
+    pub status: String,
+}
+
+#[derive(Serialize)]
+pub struct TransferLegDto {
+    pub owner: AccountId,
+    pub label: Option<&'static str>,
+    pub asset: AssetId,
+    pub value: Cent,
+    pub payer: Option<AccountId>,
+    pub payer_label: Option<&'static str>,
+}
+
+#[derive(Serialize)]
+pub struct TransferDto {
+    pub id: String,
+    pub created_at: i64,
+    pub consumes: usize,
+    pub legs: Vec<TransferLegDto>,
+}
+
+#[derive(Serialize)]
+pub struct EventDto {
+    pub seq: u64,
+    pub timestamp: i64,
+    pub kind: &'static str,
+    pub account: Option<AccountId>,
+    pub transfer: Option<String>,
+}
+
+#[derive(Serialize)]
+pub struct IssuedDto {
+    pub asset: AssetId,
+    pub issued: Cent,
+}
+
+#[derive(Serialize)]
+pub struct OverviewDto {
+    pub accounts: usize,
+    pub transfers: u64,
+    pub assets: usize,
+    pub issued: Vec<IssuedDto>,
+}
+
+#[derive(Serialize)]
+pub struct AccountDetailDto {
+    pub account: AccountDto,
+    pub postings: Vec<PostingDto>,
+    pub transfers: Vec<TransferDto>,
+}
+
+// ---------------------------------------------------------------------------
+// Conversions
+// ---------------------------------------------------------------------------
+
+fn hex32(bytes: &[u8; 32]) -> String {
+    bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn posting_id(id: &PostingId) -> String {
+    format!("{}:{}", hex32(&id.transfer.0), id.index)
+}
+
+fn policy_dto(policy: &AccountPolicy) -> PolicyDto {
+    match policy {
+        AccountPolicy::NoOverdraft => PolicyDto {
+            kind: "NoOverdraft",
+            floor: None,
+        },
+        AccountPolicy::CappedOverdraft { floor } => PolicyDto {
+            kind: "CappedOverdraft",
+            floor: Some(*floor),
+        },
+        AccountPolicy::UncappedOverdraft => PolicyDto {
+            kind: "UncappedOverdraft",
+            floor: None,
+        },
+        AccountPolicy::SystemAccount => PolicyDto {
+            kind: "SystemAccount",
+            floor: None,
+        },
+        AccountPolicy::ExternalAccount => PolicyDto {
+            kind: "ExternalAccount",
+            floor: None,
+        },
+    }
+}
+
+async fn account_dto(state: &AppState, account: &Account) -> Result<AccountDto, ApiError> {
+    let mut balances = Vec::new();
+    for asset in state.assets.iter() {
+        let value = state.ledger.balance(&account.id, &asset.id).await?;
+        // Emit only non-zero balances; a zero renders as the string "0".
+        if value.to_string() != "0" {
+            balances.push(BalanceDto {
+                asset: asset.id,
+                value,
+            });
+        }
+    }
+    Ok(AccountDto {
+        id: account.id,
+        label: account_label(account.id),
+        version: account.version,
+        policy: policy_dto(&account.policy),
+        frozen: account.is_frozen(),
+        closed: account.is_closed(),
+        balances,
+    })
+}
+
+fn transfer_dto(record: &EnvelopeRecord) -> TransferDto {
+    let legs = record
+        .envelope
+        .creates
+        .iter()
+        .map(|p| TransferLegDto {
+            owner: p.owner,
+            label: account_label(p.owner),
+            asset: p.asset,
+            value: p.value,
+            payer: p.payer,
+            payer_label: p.payer.and_then(account_label),
+        })
+        .collect();
+    TransferDto {
+        id: hex32(&record.receipt.transfer_id.0),
+        created_at: record.created_at,
+        consumes: record.envelope.consumes.len(),
+        legs,
+    }
+}
+
+fn event_dto(event: &LedgerEvent) -> EventDto {
+    let (kind, account, transfer) = match &event.kind {
+        LedgerEventKind::TransferCommitted { transfer_id } => {
+            ("TransferCommitted", None, Some(hex32(&transfer_id.0)))
+        }
+        LedgerEventKind::AccountCreated { account_id } => {
+            ("AccountCreated", Some(*account_id), None)
+        }
+        LedgerEventKind::AccountFrozen { account_id } => ("AccountFrozen", Some(*account_id), None),
+        LedgerEventKind::AccountUnfrozen { account_id } => {
+            ("AccountUnfrozen", Some(*account_id), None)
+        }
+        LedgerEventKind::AccountClosed { account_id } => ("AccountClosed", Some(*account_id), None),
+    };
+    EventDto {
+        seq: event.seq,
+        timestamp: event.timestamp,
+        kind,
+        account,
+        transfer,
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Builders — read the ledger and assemble DTOs.
+// ---------------------------------------------------------------------------
+
+/// Ledger-wide summary: counts and total issued per asset.
+pub async fn overview(state: &AppState) -> Result<OverviewDto, ApiError> {
+    let accounts = state.ledger.list_accounts().await?;
+    let page = state
+        .ledger
+        .query_transfers(&TransferQuery::default())
+        .await?;
+
+    // Total issued per asset = the negative of the external account's balance;
+    // deposits push the offset (negative) side onto External, so its balance
+    // mirrors everything in circulation.
+    let mut issued = Vec::new();
+    for asset in state.assets.iter() {
+        let external = state
+            .ledger
+            .balance(&crate::seed::EXTERNAL, &asset.id)
+            .await?;
+        let issued_value = external
+            .checked_neg()
+            .map_err(|_| ApiError::internal("overflow"))?;
+        if issued_value.to_string() != "0" {
+            issued.push(IssuedDto {
+                asset: asset.id,
+                issued: issued_value,
+            });
+        }
+    }
+
+    Ok(OverviewDto {
+        accounts: accounts.len(),
+        transfers: page.total,
+        assets: state.assets.len(),
+        issued,
+    })
+}
+
+/// Every account (sorted by id) with its balances.
+pub async fn accounts(state: &AppState) -> Result<Vec<AccountDto>, ApiError> {
+    let mut accounts = state.ledger.list_accounts().await?;
+    accounts.sort_by_key(|a| a.id.0);
+    let mut out = Vec::with_capacity(accounts.len());
+    for account in &accounts {
+        out.push(account_dto(state, account).await?);
+    }
+    Ok(out)
+}
+
+/// One account with its postings (largest first) and the transfers it took part
+/// in.
+pub async fn account_detail(state: &AppState, id: AccountId) -> Result<AccountDetailDto, ApiError> {
+    let account = state.ledger.get_account(&id).await?;
+
+    let mut postings: Vec<PostingDto> = state
+        .ledger
+        .postings(&id)
+        .await?
+        .iter()
+        .map(|p| PostingDto {
+            id: posting_id(&p.id),
+            owner: p.owner,
+            asset: p.asset,
+            value: p.value,
+            status: format!("{:?}", p.status),
+        })
+        .collect();
+    postings.sort_by_key(|p| std::cmp::Reverse(p.value));
+
+    let transfers = state
+        .ledger
+        .history(&id)
+        .await?
+        .iter()
+        .map(transfer_dto)
+        .collect();
+
+    Ok(AccountDetailDto {
+        account: account_dto(state, &account).await?,
+        postings,
+        transfers,
+    })
+}
+
+/// Recent transfers, newest first.
+pub async fn transfers(state: &AppState, limit: Option<u32>) -> Result<Vec<TransferDto>, ApiError> {
+    let query = TransferQuery {
+        limit: limit.or(Some(100)),
+        ..Default::default()
+    };
+    let page = state.ledger.query_transfers(&query).await?;
+    let mut out: Vec<TransferDto> = page.items.iter().map(transfer_dto).collect();
+    out.sort_by_key(|t| std::cmp::Reverse(t.created_at));
+    Ok(out)
+}
+
+/// Ledger events after `after`, oldest first (as stored).
+pub async fn events(state: &AppState, after: u64, limit: u32) -> Result<Vec<EventDto>, ApiError> {
+    let events = state.ledger.get_events_since(after, limit).await?;
+    Ok(events.iter().map(event_dto).collect())
+}
+
+// ---------------------------------------------------------------------------
+// Error handling
+// ---------------------------------------------------------------------------
+
+/// Any handler failure. Rendered as a JSON error body with a 500 (or 404 for a
+/// missing account). Shared by the JSON and HTML handlers.
+pub struct ApiError {
+    status: StatusCode,
+    message: String,
+}
+
+impl ApiError {
+    fn internal(message: impl Into<String>) -> Self {
+        Self {
+            status: StatusCode::INTERNAL_SERVER_ERROR,
+            message: message.into(),
+        }
+    }
+
+    /// Build a 500 from any displayable error (used by the HTML render path).
+    pub fn from_display(err: impl std::fmt::Display) -> Self {
+        Self::internal(err.to_string())
+    }
+}
+
+impl From<kuatia::error::LedgerError> for ApiError {
+    fn from(err: kuatia::error::LedgerError) -> Self {
+        use kuatia::error::LedgerError;
+        let status = match err {
+            LedgerError::AccountNotFound(_) => StatusCode::NOT_FOUND,
+            _ => StatusCode::INTERNAL_SERVER_ERROR,
+        };
+        Self {
+            status,
+            message: err.to_string(),
+        }
+    }
+}
+
+impl IntoResponse for ApiError {
+    fn into_response(self) -> Response {
+        (
+            self.status,
+            Json(serde_json::json!({ "error": self.message })),
+        )
+            .into_response()
+    }
+}

+ 93 - 0
crates/kuatia-dashboard/src/main.rs

@@ -0,0 +1,93 @@
+//! Kuatia dashboard server.
+//!
+//! Connects to a ledger database, then serves two views of it: a server-rendered
+//! HTML dashboard (Tera templates, htmx live refresh) and a read-only JSON REST
+//! API under `/api` for anyone who wants to build a richer client.
+//!
+//! ```sh
+//! cargo run -p kuatia-dashboard -- --seed          # in-memory demo
+//! cargo run -p kuatia-dashboard -- --db sqlite://kuatia.db --port 8080
+//! # then open http://127.0.0.1:<port>
+//! ```
+
+mod api;
+mod assets;
+mod data;
+mod seed;
+mod ui;
+
+use std::sync::Arc;
+
+use axum::Router;
+use clap::Parser;
+use tower_http::cors::CorsLayer;
+use tower_http::trace::TraceLayer;
+
+use data::AppState;
+
+/// Command-line options. Each flag falls back to an environment variable, then
+/// a default.
+#[derive(Debug, Parser)]
+#[command(
+    name = "kuatia-dashboard",
+    about = "Server-rendered dashboard and REST API for a Kuatia ledger"
+)]
+struct Cli {
+    /// Ledger database URL. The scheme selects the backend: `sqlite::memory:`,
+    /// `sqlite://path.db`, or `postgres://user:pass@host/db`.
+    #[arg(long, env = "KUATIA_DASHBOARD_DB", default_value = "sqlite::memory:")]
+    db: String,
+
+    /// Host/interface to bind.
+    #[arg(long, env = "KUATIA_DASHBOARD_HOST", default_value = "127.0.0.1")]
+    host: String,
+
+    /// TCP port to listen on.
+    #[arg(long, env = "KUATIA_DASHBOARD_PORT", default_value_t = 3000)]
+    port: u16,
+
+    /// Seed demo accounts and transfers if the ledger is empty. A no-op against
+    /// an already-populated database.
+    #[arg(long, env = "KUATIA_DASHBOARD_SEED", default_value_t = false)]
+    seed: bool,
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            tracing_subscriber::EnvFilter::try_from_default_env()
+                .unwrap_or_else(|_| "kuatia_dashboard=info,tower_http=info".into()),
+        )
+        .init();
+
+    let cli = Cli::parse();
+
+    let ledger = seed::connect(&cli.db).await?;
+    tracing::info!("connected to ledger at {}", cli.db);
+    if cli.seed {
+        if seed::seed_if_empty(&ledger).await? {
+            tracing::info!("seeded demo ledger");
+        } else {
+            tracing::info!("ledger already populated, skipping seed");
+        }
+    }
+
+    let state = AppState {
+        ledger,
+        assets: Arc::new(assets::registry()),
+        tera: Arc::new(ui::build_tera()?),
+    };
+
+    let app = Router::new()
+        .merge(ui::router(state.clone()))
+        .nest("/api", api::router(state))
+        .layer(TraceLayer::new_for_http())
+        .layer(CorsLayer::permissive());
+
+    let addr = format!("{}:{}", cli.host, cli.port);
+    let listener = tokio::net::TcpListener::bind(&addr).await?;
+    tracing::info!("dashboard listening on http://{addr}");
+    axum::serve(listener, app).await?;
+    Ok(())
+}

+ 156 - 0
crates/kuatia-dashboard/src/seed.rs

@@ -0,0 +1,156 @@
+//! Demo data. Builds an in-memory ledger with a handful of accounts, funds
+//! them from an external boundary account, then runs payments and a
+//! multi-asset trade so the dashboard has something to visualize.
+
+use std::sync::Arc;
+
+use kuatia::ledger::Ledger;
+use kuatia_core::{Account, AccountId, AccountPolicy, Amount, AssetId, Cent, TransferBuilder};
+use kuatia_storage_sql::SqlStore;
+
+use crate::assets::{BTC, EUR, USD};
+
+/// Well-known account ids used by the demo.
+pub const TREASURY: AccountId = AccountId::new(1);
+pub const EXTERNAL: AccountId = AccountId::new(99);
+pub const ALICE: AccountId = AccountId::new(100);
+pub const BOB: AccountId = AccountId::new(101);
+pub const CAROL: AccountId = AccountId::new(102);
+pub const MERCHANT: AccountId = AccountId::new(103);
+
+/// Human-readable labels for the seeded accounts, surfaced by the API so the
+/// frontend can show names instead of raw ids.
+pub fn account_label(id: AccountId) -> Option<&'static str> {
+    Some(match id {
+        TREASURY => "Treasury",
+        EXTERNAL => "External",
+        ALICE => "Alice",
+        BOB => "Bob",
+        CAROL => "Carol",
+        MERCHANT => "Merchant",
+        _ => return None,
+    })
+}
+
+/// Connect to the ledger database at `db_url`, create the schema, and run
+/// recovery. The URL scheme selects the backend (e.g. `sqlite::memory:`,
+/// `sqlite://kuatia.db`, `postgres://user:pass@host/db`).
+///
+/// The pool is capped at a single connection: `sqlite::memory:` gives each
+/// connection its own separate database, so more than one would split the
+/// ledger; one connection is also fine for a low-traffic dashboard on a file or
+/// Postgres backend.
+pub async fn connect(db_url: &str) -> Result<Arc<Ledger>, Box<dyn std::error::Error>> {
+    sqlx::any::install_default_drivers();
+    let pool = sqlx::any::AnyPoolOptions::new()
+        .max_connections(1)
+        .connect(&sqlite_creatable(db_url))
+        .await?;
+    let store = SqlStore::new(pool);
+    store.migrate().await?;
+    let ledger = Arc::new(Ledger::new(store));
+    ledger.recover().await?;
+    Ok(ledger)
+}
+
+/// A SQLite backend will not create a missing file unless the URL asks for it,
+/// so add `mode=rwc` to a file-backed `sqlite:` URL that does not already set a
+/// mode. In-memory and non-SQLite URLs pass through unchanged.
+fn sqlite_creatable(db_url: &str) -> String {
+    if !db_url.starts_with("sqlite:") || db_url.contains(":memory:") || db_url.contains("mode=") {
+        return db_url.to_string();
+    }
+    let sep = if db_url.contains('?') { '&' } else { '?' };
+    format!("{db_url}{sep}mode=rwc")
+}
+
+/// Seed the demo data only if the ledger has no accounts yet. Returns `true` if
+/// it seeded, `false` if the ledger was already populated (so re-running with
+/// `--seed` against a persistent database is a safe no-op rather than a
+/// duplicate-id error).
+pub async fn seed_if_empty(ledger: &Arc<Ledger>) -> Result<bool, Box<dyn std::error::Error>> {
+    if !ledger.list_accounts().await?.is_empty() {
+        return Ok(false);
+    }
+    populate(ledger).await?;
+    Ok(true)
+}
+
+/// Populate the ledger with demo accounts and a spread of transfers.
+pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn std::error::Error>> {
+    // Two-decimal assets (USD, EUR) and an 8-decimal asset (BTC).
+    let fiat = Amount::new(2);
+    let btc = Amount::new(8);
+
+    create(ledger, TREASURY, AccountPolicy::SystemAccount).await?;
+    create(ledger, EXTERNAL, AccountPolicy::ExternalAccount).await?;
+    create(ledger, ALICE, AccountPolicy::NoOverdraft).await?;
+    create(ledger, BOB, AccountPolicy::NoOverdraft).await?;
+    // Carol may overdraw down to -$500.00.
+    create(
+        ledger,
+        CAROL,
+        AccountPolicy::CappedOverdraft {
+            floor: fiat.parse("-500.00")?,
+        },
+    )
+    .await?;
+    create(ledger, MERCHANT, AccountPolicy::NoOverdraft).await?;
+
+    // Fund accounts from the external boundary.
+    deposit(ledger, ALICE, USD, fiat.parse("1000.00")?).await?;
+    deposit(ledger, BOB, EUR, fiat.parse("500.00")?).await?;
+    deposit(ledger, ALICE, BTC, btc.parse("0.50000000")?).await?;
+    deposit(ledger, CAROL, USD, fiat.parse("200.00")?).await?;
+
+    // Ordinary payments between held balances.
+    pay(ledger, ALICE, BOB, USD, fiat.parse("150.00")?).await?;
+    pay(ledger, BOB, MERCHANT, EUR, fiat.parse("80.00")?).await?;
+    pay(ledger, ALICE, MERCHANT, BTC, btc.parse("0.10000000")?).await?;
+
+    // Carol spends past her balance, into the capped overdraft.
+    pay(ledger, CAROL, MERCHANT, USD, fiat.parse("250.00")?).await?;
+
+    // Atomic multi-asset trade: Alice buys EUR from Bob with USD.
+    let trade = TransferBuilder::new()
+        .pay(ALICE, BOB, USD, fiat.parse("100.00")?)
+        .pay(BOB, ALICE, EUR, fiat.parse("90.00")?)
+        .build();
+    ledger.commit(trade).await?;
+
+    Ok(())
+}
+
+async fn create(
+    ledger: &Arc<Ledger>,
+    id: AccountId,
+    policy: AccountPolicy,
+) -> Result<(), Box<dyn std::error::Error>> {
+    ledger.create_account(Account::new(id, policy)).await?;
+    Ok(())
+}
+
+async fn deposit(
+    ledger: &Arc<Ledger>,
+    to: AccountId,
+    asset: AssetId,
+    amount: Cent,
+) -> Result<(), Box<dyn std::error::Error>> {
+    let transfer = TransferBuilder::new()
+        .deposit(to, asset, amount, EXTERNAL)?
+        .build();
+    ledger.commit(transfer).await?;
+    Ok(())
+}
+
+async fn pay(
+    ledger: &Arc<Ledger>,
+    from: AccountId,
+    to: AccountId,
+    asset: AssetId,
+    amount: Cent,
+) -> Result<(), Box<dyn std::error::Error>> {
+    let transfer = TransferBuilder::new().pay(from, to, asset, amount).build();
+    ledger.commit(transfer).await?;
+    Ok(())
+}

+ 512 - 0
crates/kuatia-dashboard/src/ui.rs

@@ -0,0 +1,512 @@
+//! Server-rendered HTML views (Tera templates) with htmx-driven live refresh.
+//!
+//! Full-page routes render the whole document; `/ui/*` routes render just the
+//! dynamic fragment that htmx polls and swaps in place. Both read from the same
+//! [`crate::data`] builders as the JSON API, then format money and timestamps
+//! for display. Templates and static assets are embedded in the binary, so the
+//! server needs no files on disk at runtime.
+
+use axum::{
+    Router,
+    extract::{Path, State},
+    http::header,
+    response::{Html, IntoResponse, Response},
+    routing::get,
+};
+use kuatia_core::{Amount, AssetId, Cent};
+use serde::Serialize;
+use tera::{Context, Tera};
+
+use crate::assets::AssetMeta;
+use crate::data::{
+    self, AccountDto, ApiError, AppState, EventDto, OverviewDto, PostingDto, TransferDto,
+};
+
+// ---------------------------------------------------------------------------
+// Tera setup — templates embedded via include_str!.
+// ---------------------------------------------------------------------------
+
+/// Build the Tera instance with all templates registered by name.
+pub fn build_tera() -> Result<Tera, tera::Error> {
+    let mut tera = Tera::default();
+    tera.add_raw_templates(vec![
+        ("base.html", include_str!("../templates/base.html")),
+        (
+            "pages/overview.html",
+            include_str!("../templates/pages/overview.html"),
+        ),
+        (
+            "pages/accounts.html",
+            include_str!("../templates/pages/accounts.html"),
+        ),
+        (
+            "pages/account.html",
+            include_str!("../templates/pages/account.html"),
+        ),
+        (
+            "pages/transfers.html",
+            include_str!("../templates/pages/transfers.html"),
+        ),
+        (
+            "pages/events.html",
+            include_str!("../templates/pages/events.html"),
+        ),
+        (
+            "partials/overview.html",
+            include_str!("../templates/partials/overview.html"),
+        ),
+        (
+            "partials/accounts.html",
+            include_str!("../templates/partials/accounts.html"),
+        ),
+        (
+            "partials/account.html",
+            include_str!("../templates/partials/account.html"),
+        ),
+        (
+            "partials/transfers.html",
+            include_str!("../templates/partials/transfers.html"),
+        ),
+        (
+            "partials/events.html",
+            include_str!("../templates/partials/events.html"),
+        ),
+    ])?;
+    Ok(tera)
+}
+
+/// Build the UI router: full pages, `/ui/*` htmx partials, and `/static/*`.
+pub fn router(state: AppState) -> Router {
+    Router::new()
+        .route("/", get(overview_page))
+        .route("/accounts", get(accounts_page))
+        .route("/accounts/{id}", get(account_page))
+        .route("/transfers", get(transfers_page))
+        .route("/events", get(events_page))
+        .route("/ui/overview", get(overview_partial))
+        .route("/ui/accounts", get(accounts_partial))
+        .route("/ui/accounts/{id}", get(account_partial))
+        .route("/ui/transfers", get(transfers_partial))
+        .route("/ui/events", get(events_partial))
+        .route("/static/dashboard.css", get(css))
+        .route("/static/htmx.min.js", get(htmx_js))
+        .with_state(state)
+}
+
+// ---------------------------------------------------------------------------
+// View models — DTOs with money and timestamps pre-formatted for display.
+// ---------------------------------------------------------------------------
+
+#[derive(Serialize)]
+struct MoneyView {
+    text: String,
+    negative: bool,
+}
+
+#[derive(Serialize)]
+struct BalanceView {
+    code: String,
+    money: MoneyView,
+}
+
+#[derive(Serialize)]
+struct AccountView {
+    id: i64,
+    name: String,
+    version: u64,
+    policy_kind: &'static str,
+    floor: Option<MoneyView>,
+    frozen: bool,
+    closed: bool,
+    balances: Vec<BalanceView>,
+}
+
+#[derive(Serialize)]
+struct PostingView {
+    short_id: String,
+    status: String,
+    money: MoneyView,
+}
+
+#[derive(Serialize)]
+struct LegView {
+    to_name: String,
+    from_name: Option<String>,
+    is_change: bool,
+    money: MoneyView,
+}
+
+#[derive(Serialize)]
+struct TransferView {
+    short_id: String,
+    full_id: String,
+    time: String,
+    consumes: usize,
+    legs: Vec<LegView>,
+}
+
+#[derive(Serialize)]
+struct EventView {
+    seq: u64,
+    kind: &'static str,
+    account: Option<i64>,
+    transfer_short: Option<String>,
+    time: String,
+}
+
+#[derive(Serialize)]
+struct IssuedView {
+    code: String,
+    money: MoneyView,
+}
+
+// ---------------------------------------------------------------------------
+// Formatting helpers
+// ---------------------------------------------------------------------------
+
+/// Look up asset metadata by id.
+fn asset_of(assets: &[AssetMeta], id: AssetId) -> Option<&AssetMeta> {
+    assets.iter().find(|a| a.id == id)
+}
+
+/// Format a signed [`Cent`] with the given decimals and symbol, grouping the
+/// integer part with thousands separators.
+fn fmt(value: Cent, decimals: u8, symbol: &str) -> MoneyView {
+    let raw = Amount::new(decimals).format(value); // e.g. "-1234567.89" or "1000"
+    let negative = raw.starts_with('-');
+    let unsigned = raw.trim_start_matches('-');
+    let (whole, frac) = match unsigned.split_once('.') {
+        Some((w, f)) => (w, Some(f)),
+        None => (unsigned, None),
+    };
+    let grouped = group_thousands(whole);
+    let body = match frac {
+        Some(f) => format!("{grouped}.{f}"),
+        None => grouped,
+    };
+    let sign = if negative { "-" } else { "" };
+    MoneyView {
+        text: format!("{sign}{symbol}{body}"),
+        negative,
+    }
+}
+
+/// Format using an asset's decimals/symbol, or fall back to the raw value.
+fn fmt_asset(value: Cent, asset: Option<&AssetMeta>) -> MoneyView {
+    match asset {
+        Some(a) => fmt(value, a.decimals, a.symbol),
+        None => MoneyView {
+            text: value.to_string(),
+            negative: value.to_string().starts_with('-'),
+        },
+    }
+}
+
+/// Insert commas every three digits from the right.
+fn group_thousands(digits: &str) -> String {
+    let bytes = digits.as_bytes();
+    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
+    let len = bytes.len();
+    for (i, b) in bytes.iter().enumerate() {
+        if i > 0 && (len - i) % 3 == 0 {
+            out.push(',');
+        }
+        out.push(*b as char);
+    }
+    out
+}
+
+/// A short hex form of an id for compact display.
+fn short_hex(hex: &str) -> String {
+    if hex.len() <= 14 {
+        return hex.to_string();
+    }
+    format!("{}…{}", &hex[..8], &hex[hex.len() - 6..])
+}
+
+/// Format unix milliseconds as `YYYY-MM-DD HH:MM:SS UTC` without a date crate.
+fn fmt_millis(ms: i64) -> String {
+    let secs = ms.div_euclid(1000);
+    let days = secs.div_euclid(86_400);
+    let tod = secs.rem_euclid(86_400);
+    let (h, m, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
+    let (y, mo, d) = civil_from_days(days);
+    format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}:{s:02} UTC")
+}
+
+/// Days since the Unix epoch to a civil (year, month, day). Hinnant's algorithm.
+fn civil_from_days(z: i64) -> (i64, u32, u32) {
+    let z = z + 719_468;
+    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+    let doe = z - era * 146_097; // [0, 146096]
+    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
+    let y = yoe + era * 400;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
+    let mp = (5 * doy + 2) / 153; // [0, 11]
+    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
+    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
+    (if m <= 2 { y + 1 } else { y }, m, d)
+}
+
+/// The symbol/decimals to render an overdraft floor, which carries no asset.
+/// Use the first two-decimal asset (fiat) if present.
+fn floor_style(assets: &[AssetMeta]) -> (u8, &str) {
+    assets
+        .iter()
+        .find(|a| a.decimals == 2)
+        .map(|a| (a.decimals, a.symbol))
+        .unwrap_or((2, ""))
+}
+
+// ---------------------------------------------------------------------------
+// View builders — DTO -> view model.
+// ---------------------------------------------------------------------------
+
+fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
+    let (floor_dec, floor_sym) = floor_style(assets);
+    AccountView {
+        id: dto.id.0,
+        name: dto
+            .label
+            .map(String::from)
+            .unwrap_or_else(|| format!("#{}", dto.id.0)),
+        version: dto.version,
+        policy_kind: dto.policy.kind,
+        floor: dto.policy.floor.map(|f| fmt(f, floor_dec, floor_sym)),
+        frozen: dto.frozen,
+        closed: dto.closed,
+        balances: dto
+            .balances
+            .iter()
+            .map(|b| BalanceView {
+                code: asset_of(assets, b.asset)
+                    .map(|a| a.code.to_string())
+                    .unwrap_or_default(),
+                money: fmt_asset(b.value, asset_of(assets, b.asset)),
+            })
+            .collect(),
+    }
+}
+
+fn transfer_view(dto: &TransferDto, assets: &[AssetMeta]) -> TransferView {
+    TransferView {
+        short_id: short_hex(&dto.id),
+        full_id: dto.id.clone(),
+        time: fmt_millis(dto.created_at),
+        consumes: dto.consumes,
+        legs: dto
+            .legs
+            .iter()
+            .map(|leg| LegView {
+                to_name: leg
+                    .label
+                    .map(String::from)
+                    .unwrap_or_else(|| format!("#{}", leg.owner.0)),
+                from_name: leg.payer.map(|p| {
+                    leg.payer_label
+                        .map(String::from)
+                        .unwrap_or_else(|| format!("#{}", p.0))
+                }),
+                is_change: leg.payer.is_none(),
+                money: fmt_asset(leg.value, asset_of(assets, leg.asset)),
+            })
+            .collect(),
+    }
+}
+
+fn posting_view(dto: &PostingDto, assets: &[AssetMeta]) -> PostingView {
+    PostingView {
+        short_id: short_hex(&dto.id),
+        status: dto.status.clone(),
+        money: fmt_asset(dto.value, asset_of(assets, dto.asset)),
+    }
+}
+
+fn event_view(dto: &EventDto) -> EventView {
+    EventView {
+        seq: dto.seq,
+        kind: dto.kind,
+        account: dto.account.map(|a| a.0),
+        transfer_short: dto.transfer.as_deref().map(short_hex),
+        time: fmt_millis(dto.timestamp),
+    }
+}
+
+fn issued_view(dto: &OverviewDto, assets: &[AssetMeta]) -> Vec<IssuedView> {
+    dto.issued
+        .iter()
+        .map(|i| IssuedView {
+            code: asset_of(assets, i.asset)
+                .map(|a| a.code.to_string())
+                .unwrap_or_default(),
+            money: fmt_asset(i.issued, asset_of(assets, i.asset)),
+        })
+        .collect()
+}
+
+// ---------------------------------------------------------------------------
+// Context builders
+// ---------------------------------------------------------------------------
+
+async fn overview_ctx(state: &AppState) -> Result<Context, ApiError> {
+    let dto = data::overview(state).await?;
+    let mut ctx = Context::new();
+    ctx.insert("nav", "overview");
+    ctx.insert("accounts_count", &dto.accounts);
+    ctx.insert("transfers_count", &dto.transfers);
+    ctx.insert("assets_count", &dto.assets);
+    ctx.insert("issued", &issued_view(&dto, &state.assets));
+    Ok(ctx)
+}
+
+async fn accounts_ctx(state: &AppState) -> Result<Context, ApiError> {
+    let dtos = data::accounts(state).await?;
+    let views: Vec<AccountView> = dtos
+        .iter()
+        .map(|a| account_view(a, &state.assets))
+        .collect();
+    let mut ctx = Context::new();
+    ctx.insert("nav", "accounts");
+    ctx.insert("accounts", &views);
+    Ok(ctx)
+}
+
+async fn account_ctx(state: &AppState, id: i64) -> Result<Context, ApiError> {
+    let dto = data::account_detail(state, kuatia_core::AccountId::new(id)).await?;
+    let mut ctx = Context::new();
+    ctx.insert("nav", "accounts");
+    ctx.insert("account", &account_view(&dto.account, &state.assets));
+    ctx.insert(
+        "postings",
+        &dto.postings
+            .iter()
+            .map(|p| posting_view(p, &state.assets))
+            .collect::<Vec<_>>(),
+    );
+    ctx.insert(
+        "transfers",
+        &dto.transfers
+            .iter()
+            .map(|t| transfer_view(t, &state.assets))
+            .collect::<Vec<_>>(),
+    );
+    Ok(ctx)
+}
+
+async fn transfers_ctx(state: &AppState) -> Result<Context, ApiError> {
+    let dtos = data::transfers(state, None).await?;
+    let views: Vec<TransferView> = dtos
+        .iter()
+        .map(|t| transfer_view(t, &state.assets))
+        .collect();
+    let mut ctx = Context::new();
+    ctx.insert("nav", "transfers");
+    ctx.insert("transfers", &views);
+    Ok(ctx)
+}
+
+async fn events_ctx(state: &AppState) -> Result<Context, ApiError> {
+    let dtos = data::events(state, 0, 200).await?;
+    // Newest first for display.
+    let views: Vec<EventView> = dtos.iter().rev().map(event_view).collect();
+    let mut ctx = Context::new();
+    ctx.insert("nav", "events");
+    ctx.insert("events", &views);
+    Ok(ctx)
+}
+
+// ---------------------------------------------------------------------------
+// Rendering + handlers
+// ---------------------------------------------------------------------------
+
+fn render(state: &AppState, template: &str, ctx: &Context) -> Result<Html<String>, ApiError> {
+    state
+        .tera
+        .render(template, ctx)
+        .map(Html)
+        .map_err(ApiError::from_display)
+}
+
+async fn overview_page(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(&state, "pages/overview.html", &overview_ctx(&state).await?)
+}
+async fn overview_partial(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(
+        &state,
+        "partials/overview.html",
+        &overview_ctx(&state).await?,
+    )
+}
+
+async fn accounts_page(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(&state, "pages/accounts.html", &accounts_ctx(&state).await?)
+}
+async fn accounts_partial(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(
+        &state,
+        "partials/accounts.html",
+        &accounts_ctx(&state).await?,
+    )
+}
+
+async fn account_page(
+    State(state): State<AppState>,
+    Path(id): Path<i64>,
+) -> Result<Html<String>, ApiError> {
+    render(
+        &state,
+        "pages/account.html",
+        &account_ctx(&state, id).await?,
+    )
+}
+async fn account_partial(
+    State(state): State<AppState>,
+    Path(id): Path<i64>,
+) -> Result<Html<String>, ApiError> {
+    render(
+        &state,
+        "partials/account.html",
+        &account_ctx(&state, id).await?,
+    )
+}
+
+async fn transfers_page(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(
+        &state,
+        "pages/transfers.html",
+        &transfers_ctx(&state).await?,
+    )
+}
+async fn transfers_partial(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(
+        &state,
+        "partials/transfers.html",
+        &transfers_ctx(&state).await?,
+    )
+}
+
+async fn events_page(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(&state, "pages/events.html", &events_ctx(&state).await?)
+}
+async fn events_partial(State(state): State<AppState>) -> Result<Html<String>, ApiError> {
+    render(&state, "partials/events.html", &events_ctx(&state).await?)
+}
+
+async fn css() -> Response {
+    (
+        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
+        include_str!("../static/dashboard.css"),
+    )
+        .into_response()
+}
+
+async fn htmx_js() -> Response {
+    (
+        [(
+            header::CONTENT_TYPE,
+            "application/javascript; charset=utf-8",
+        )],
+        include_str!("../static/htmx.min.js"),
+    )
+        .into_response()
+}

+ 362 - 0
crates/kuatia-dashboard/static/dashboard.css

@@ -0,0 +1,362 @@
+:root {
+  --bg: #0f1720;
+  --panel: #17212b;
+  --panel-2: #1e2a37;
+  --line: #2a3a49;
+  --text: #e6edf3;
+  --muted: #8ba0b3;
+  --accent: #4c9be8;
+  --pos: #57c98a;
+  --neg: #e8736b;
+  --chip: #24333f;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--text);
+  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.app {
+  max-width: 1100px;
+  margin: 0 auto;
+  padding: 1.5rem 1.25rem 4rem;
+}
+
+.header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 1rem;
+}
+
+.header h1 {
+  font-size: 1.4rem;
+  margin: 0;
+  letter-spacing: 0.02em;
+}
+
+.live {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.4rem;
+  color: var(--muted);
+  font-size: 0.85rem;
+}
+
+.live .dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: var(--pos);
+  animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+  0% { box-shadow: 0 0 0 0 rgba(87, 201, 138, 0.5); }
+  70% { box-shadow: 0 0 0 8px rgba(87, 201, 138, 0); }
+  100% { box-shadow: 0 0 0 0 rgba(87, 201, 138, 0); }
+}
+
+/* Nav */
+.nav {
+  display: flex;
+  gap: 0.4rem;
+  margin-bottom: 1.5rem;
+  border-bottom: 1px solid var(--line);
+}
+
+.nav a {
+  padding: 0.5rem 0.9rem;
+  color: var(--muted);
+  border-bottom: 2px solid transparent;
+  margin-bottom: -1px;
+}
+
+.nav a:hover {
+  color: var(--text);
+}
+
+.nav a.active {
+  color: var(--text);
+  border-bottom-color: var(--accent);
+}
+
+.back {
+  color: var(--muted);
+  font-size: 0.85rem;
+}
+.back:hover {
+  color: var(--text);
+}
+
+/* Overview cards */
+.overview {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+  gap: 0.75rem;
+}
+
+.card {
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  padding: 0.9rem 1rem;
+}
+
+.card-label {
+  color: var(--muted);
+  font-size: 0.78rem;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+}
+
+.card-value {
+  font-size: 1.8rem;
+  font-weight: 600;
+  margin-top: 0.2rem;
+}
+
+.issued-list {
+  margin-top: 0.4rem;
+  display: flex;
+  flex-direction: column;
+  gap: 0.15rem;
+  font-size: 1rem;
+  font-weight: 600;
+}
+
+/* Panels */
+.panel {
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  padding: 1rem 1.1rem;
+}
+
+.panel h2 {
+  margin: 0 0 0.75rem;
+  font-size: 1.05rem;
+}
+
+.panel h2 .acct-id {
+  font-weight: 400;
+}
+
+/* Tables */
+table.grid {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+table.grid th {
+  text-align: left;
+  color: var(--muted);
+  font-weight: 500;
+  font-size: 0.75rem;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  padding: 0.35rem 0.5rem;
+  border-bottom: 1px solid var(--line);
+}
+
+table.grid td {
+  padding: 0.55rem 0.5rem;
+  border-bottom: 1px solid var(--line);
+  vertical-align: top;
+}
+
+table.grid.small td,
+table.grid.small th {
+  padding: 0.35rem 0.4rem;
+  font-size: 0.85rem;
+}
+
+.right {
+  text-align: right;
+}
+
+table.grid tbody tr {
+  cursor: pointer;
+}
+
+table.grid tbody tr:hover {
+  background: var(--panel-2);
+}
+
+.acct-name {
+  font-weight: 600;
+}
+
+.acct-id {
+  color: var(--muted);
+  font-size: 0.78rem;
+  display: flex;
+  gap: 0.35rem;
+  align-items: center;
+}
+
+/* Chips */
+.policy,
+.flag,
+.status,
+.event-kind {
+  display: inline-block;
+  font-size: 0.72rem;
+  padding: 0.1rem 0.45rem;
+  border-radius: 999px;
+  background: var(--chip);
+  color: var(--text);
+  white-space: nowrap;
+}
+
+.policy.NoOverdraft { color: #9fb4c6; }
+.policy.CappedOverdraft { color: #e0b25a; }
+.policy.UncappedOverdraft { color: #e8736b; }
+.policy.SystemAccount { color: #4c9be8; }
+.policy.ExternalAccount { color: #b98be0; }
+
+.flag.frozen { background: #2b3f57; color: #7fb3ff; }
+.flag.closed { background: #4a2a2a; color: #e8736b; }
+
+.status.Active { color: var(--pos); }
+.status.PendingInactive { color: #e0b25a; }
+.status.Inactive { color: var(--muted); }
+
+/* Money */
+.money {
+  font-variant-numeric: tabular-nums;
+  font-weight: 600;
+}
+.money.pos { color: var(--pos); }
+.money.neg { color: var(--neg); }
+
+/* Feeds */
+.feed {
+  display: flex;
+  flex-direction: column;
+  gap: 0.6rem;
+}
+
+.feed-item {
+  background: var(--panel-2);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  padding: 0.6rem 0.7rem;
+}
+
+.feed-item.compact {
+  padding: 0.45rem 0.55rem;
+}
+
+.feed-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 0.4rem;
+  font-size: 0.8rem;
+}
+
+.legs {
+  display: flex;
+  flex-direction: column;
+  gap: 0.2rem;
+}
+
+.leg {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 1rem;
+}
+
+.leg.change {
+  opacity: 0.6;
+  font-style: italic;
+}
+
+.leg-dir b {
+  font-weight: 600;
+}
+
+.feed-foot {
+  margin-top: 0.4rem;
+  font-size: 0.75rem;
+}
+
+.hash {
+  font-family: ui-monospace, "SF Mono", Menlo, monospace;
+  font-size: 0.8rem;
+  color: var(--accent);
+}
+
+/* Event rows */
+.event-row {
+  display: grid;
+  grid-template-columns: 150px 1fr auto;
+  gap: 0.6rem;
+  align-items: center;
+  padding: 0.35rem 0.2rem;
+  border-bottom: 1px solid var(--line);
+  font-size: 0.85rem;
+}
+
+.event-kind.TransferCommitted { color: var(--pos); }
+.event-kind.AccountCreated { color: var(--accent); }
+.event-kind.AccountFrozen,
+.event-kind.AccountClosed { color: var(--neg); }
+
+.event-time {
+  font-size: 0.75rem;
+}
+
+/* Account detail */
+.detail-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  align-items: center;
+}
+
+.drawer h3,
+.panel h3 {
+  margin: 1.4rem 0 0.5rem;
+  font-size: 0.95rem;
+  color: var(--muted);
+  border-bottom: 1px solid var(--line);
+  padding-bottom: 0.25rem;
+}
+
+.balance-list {
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+.balance-list li {
+  display: flex;
+  justify-content: space-between;
+  padding: 0.3rem 0;
+  border-bottom: 1px solid var(--line);
+}
+
+.asset-code {
+  color: var(--muted);
+}
+
+/* Utilities */
+.muted {
+  color: var(--muted);
+}

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
crates/kuatia-dashboard/static/htmx.min.js


+ 25 - 0
crates/kuatia-dashboard/templates/base.html

@@ -0,0 +1,25 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Kuatia Ledger</title>
+    <link rel="stylesheet" href="/static/dashboard.css" />
+    <script src="/static/htmx.min.js"></script>
+  </head>
+  <body>
+    <div class="app">
+      <header class="header">
+        <h1><a href="/">Kuatia Ledger</a></h1>
+        <span class="live"><span class="dot"></span> live</span>
+      </header>
+      <nav class="nav">
+        <a href="/" class="{% if nav == 'overview' %}active{% endif %}">Overview</a>
+        <a href="/accounts" class="{% if nav == 'accounts' %}active{% endif %}">Accounts</a>
+        <a href="/transfers" class="{% if nav == 'transfers' %}active{% endif %}">Transfers</a>
+        <a href="/events" class="{% if nav == 'events' %}active{% endif %}">Events</a>
+      </nav>
+      <main>{% block content %}{% endblock content %}</main>
+    </div>
+  </body>
+</html>

+ 10 - 0
crates/kuatia-dashboard/templates/pages/account.html

@@ -0,0 +1,10 @@
+{% extends "base.html" %}
+{% block content %}
+<p><a class="back" href="/accounts">&larr; Accounts</a></p>
+<div class="panel">
+  <h2>{{ account.name }} <span class="acct-id">#{{ account.id }}</span></h2>
+  <div id="live" hx-get="/ui/accounts/{{ account.id }}" hx-trigger="every 3s" hx-swap="innerHTML">
+    {% include "partials/account.html" %}
+  </div>
+</div>
+{% endblock content %}

+ 9 - 0
crates/kuatia-dashboard/templates/pages/accounts.html

@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% block content %}
+<div class="panel">
+  <h2>Accounts</h2>
+  <div id="live" hx-get="/ui/accounts" hx-trigger="every 3s" hx-swap="innerHTML">
+    {% include "partials/accounts.html" %}
+  </div>
+</div>
+{% endblock content %}

+ 9 - 0
crates/kuatia-dashboard/templates/pages/events.html

@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% block content %}
+<div class="panel">
+  <h2>Event log</h2>
+  <div id="live" hx-get="/ui/events" hx-trigger="every 3s" hx-swap="innerHTML">
+    {% include "partials/events.html" %}
+  </div>
+</div>
+{% endblock content %}

+ 6 - 0
crates/kuatia-dashboard/templates/pages/overview.html

@@ -0,0 +1,6 @@
+{% extends "base.html" %}
+{% block content %}
+<div id="live" hx-get="/ui/overview" hx-trigger="every 3s" hx-swap="innerHTML">
+  {% include "partials/overview.html" %}
+</div>
+{% endblock content %}

+ 9 - 0
crates/kuatia-dashboard/templates/pages/transfers.html

@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% block content %}
+<div class="panel">
+  <h2>Transfers</h2>
+  <div id="live" hx-get="/ui/transfers" hx-trigger="every 3s" hx-swap="innerHTML">
+    {% include "partials/transfers.html" %}
+  </div>
+</div>
+{% endblock content %}

+ 65 - 0
crates/kuatia-dashboard/templates/partials/account.html

@@ -0,0 +1,65 @@
+<div class="detail-meta">
+  <span class="policy {{ account.policy_kind }}">{{ account.policy_kind }}</span>
+  {% if account.floor %}
+    <span class="muted">floor <span class="money neg">{{ account.floor.text }}</span></span>
+  {% endif %}
+  <span class="muted">version {{ account.version }}</span>
+  {% if account.frozen %}<span class="flag frozen">frozen</span>{% endif %}
+  {% if account.closed %}<span class="flag closed">closed</span>{% endif %}
+</div>
+
+<h3>Balances</h3>
+{% if account.balances %}
+<ul class="balance-list">
+  {% for b in account.balances %}
+  <li>
+    <span class="asset-code">{{ b.code }}</span>
+    <span class="money {% if b.money.negative %}neg{% else %}pos{% endif %}">{{ b.money.text }}</span>
+  </li>
+  {% endfor %}
+</ul>
+{% else %}
+<p class="muted">No balances.</p>
+{% endif %}
+
+<h3>Postings ({{ postings | length }})</h3>
+<table class="grid small">
+  <thead>
+    <tr>
+      <th>Posting</th>
+      <th>Status</th>
+      <th class="right">Value</th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for p in postings %}
+    <tr>
+      <td><code class="hash" title="{{ p.short_id }}">{{ p.short_id }}</code></td>
+      <td><span class="status {{ p.status }}">{{ p.status }}</span></td>
+      <td class="right">
+        <span class="money {% if p.money.negative %}neg{% else %}pos{% endif %}">{{ p.money.text }}</span>
+      </td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+
+<h3>Transfers ({{ transfers | length }})</h3>
+<div class="feed">
+  {% for t in transfers %}
+  <div class="feed-item compact">
+    <div class="feed-head">
+      <code class="hash" title="{{ t.full_id }}">{{ t.short_id }}</code>
+      <span class="muted">{{ t.time }}</span>
+    </div>
+    <div class="legs">
+      {% for leg in t.legs %}
+      <div class="leg {% if leg.is_change %}change{% endif %}">
+        <span class="leg-dir">{{ leg.to_name }}</span>
+        <span class="money {% if leg.money.negative %}neg{% else %}pos{% endif %}">{{ leg.money.text }}</span>
+      </div>
+      {% endfor %}
+    </div>
+  </div>
+  {% endfor %}
+</div>

+ 35 - 0
crates/kuatia-dashboard/templates/partials/accounts.html

@@ -0,0 +1,35 @@
+<table class="grid">
+  <thead>
+    <tr>
+      <th>Account</th>
+      <th>Policy</th>
+      <th class="right">Balances</th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for a in accounts %}
+    <tr onclick="window.location='/accounts/{{ a.id }}'">
+      <td>
+        <div class="acct-name">{{ a.name }}</div>
+        <div class="acct-id">
+          #{{ a.id }}
+          {% if a.frozen %}<span class="flag frozen">frozen</span>{% endif %}
+          {% if a.closed %}<span class="flag closed">closed</span>{% endif %}
+        </div>
+      </td>
+      <td><span class="policy {{ a.policy_kind }}">{{ a.policy_kind }}</span></td>
+      <td class="right">
+        {% if a.balances %}
+          {% for b in a.balances %}
+          <div>
+            <span class="money {% if b.money.negative %}neg{% else %}pos{% endif %}">{{ b.money.text }}</span>
+          </div>
+          {% endfor %}
+        {% else %}
+          <span class="muted">&mdash;</span>
+        {% endif %}
+      </td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>

+ 12 - 0
crates/kuatia-dashboard/templates/partials/events.html

@@ -0,0 +1,12 @@
+<div class="feed">
+  {% for e in events %}
+  <div class="event-row">
+    <span class="event-kind {{ e.kind }}">{{ e.kind }}</span>
+    <span class="event-target">
+      {% if e.account %}account #{{ e.account }}{% endif %}
+      {% if e.transfer_short %}<code class="hash">{{ e.transfer_short }}</code>{% endif %}
+    </span>
+    <span class="muted event-time">{{ e.time }}</span>
+  </div>
+  {% endfor %}
+</div>

+ 28 - 0
crates/kuatia-dashboard/templates/partials/overview.html

@@ -0,0 +1,28 @@
+<div class="overview">
+  <div class="card">
+    <div class="card-label">Accounts</div>
+    <div class="card-value">{{ accounts_count }}</div>
+  </div>
+  <div class="card">
+    <div class="card-label">Transfers</div>
+    <div class="card-value">{{ transfers_count }}</div>
+  </div>
+  <div class="card">
+    <div class="card-label">Assets</div>
+    <div class="card-value">{{ assets_count }}</div>
+  </div>
+  <div class="card">
+    <div class="card-label">Total issued</div>
+    <div class="issued-list">
+      {% if issued %}
+        {% for i in issued %}
+          <div class="issued-row">
+            <span class="money {% if i.money.negative %}neg{% else %}pos{% endif %}">{{ i.money.text }}</span>
+          </div>
+        {% endfor %}
+      {% else %}
+        <span class="muted">nothing issued</span>
+      {% endif %}
+    </div>
+  </div>
+</div>

+ 25 - 0
crates/kuatia-dashboard/templates/partials/transfers.html

@@ -0,0 +1,25 @@
+<div class="feed">
+  {% for t in transfers %}
+  <div class="feed-item">
+    <div class="feed-head">
+      <code class="hash" title="{{ t.full_id }}">{{ t.short_id }}</code>
+      <span class="muted">{{ t.time }}</span>
+    </div>
+    <div class="legs">
+      {% for leg in t.legs %}
+      <div class="leg {% if leg.is_change %}change{% endif %}">
+        <span class="leg-dir">
+          {% if leg.is_change %}
+            change &rarr; <b>{{ leg.to_name }}</b>
+          {% else %}
+            <b>{{ leg.from_name }}</b> &rarr; <b>{{ leg.to_name }}</b>
+          {% endif %}
+        </span>
+        <span class="money {% if leg.money.negative %}neg{% else %}pos{% endif %}">{{ leg.money.text }}</span>
+      </div>
+      {% endfor %}
+    </div>
+    <div class="feed-foot muted">consumes {{ t.consumes }} posting(s)</div>
+  </div>
+  {% endfor %}
+</div>

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff