feat: Dioxus dashboard — session analytics + WASM web target

New crates:
  bascule-dashboard — shared Dioxus component library
    SessionTable: live active sessions with auth/backend/TPM status
    StatsCards: active count, 24h total, TPM attested %, failed auth
    StatusBar: connection health indicator
    types.rs: DashboardSession, DashboardStats, HealthResponse

  bascule-dashboard-web — WASM web target (Dioxus 0.6 + web features)
    Compiles to wasm32-unknown-unknown
    Dark-first CSS (light mode via prefers-color-scheme)
    Monospace data display, clean stat cards

  bascule-core/store.rs — in-memory session store
    SessionStore with active sessions + aggregate stats
    Updated via SessionHandler hooks

Both dashboard library and web WASM target compile clean.
Server and shell builds unaffected. Zero substrate deps.

Signed-off-by: Tyler King <tking@guildhouse.dev>
This commit is contained in:
Tyler King 2026-04-05 14:10:01 -04:00
parent 4aa7e9d816
commit 04dd74d15f
14 changed files with 1359 additions and 3 deletions

901
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,12 @@
[workspace] [workspace]
members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id", "crates/bascule-shell"] members = [
"crates/bascule-core",
"crates/bascule-server",
"crates/bascule-auth-agent-id",
"crates/bascule-shell",
"crates/bascule-dashboard",
"crates/bascule-dashboard-web",
]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]

View file

@ -18,3 +18,4 @@ pub mod proxy;
pub mod pty; pub mod pty;
pub mod server; pub mod server;
pub mod session; pub mod session;
pub mod store;

View file

@ -0,0 +1,84 @@
//! Session store — tracks active and historical sessions for the dashboard.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// A session as the dashboard sees it.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DashboardSession {
pub id: String,
pub principal: String,
pub auth_method: String,
pub source_ip: String,
pub backend: String,
pub container_image: Option<String>,
pub connected_at: String,
pub tpm_attested: bool,
pub attestation_hash: Option<String>,
pub commands_executed: u64,
}
/// Aggregate stats for the dashboard.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct DashboardStats {
pub active_sessions: usize,
pub total_sessions_24h: u64,
pub auth_breakdown: HashMap<String, u64>,
pub backend_breakdown: HashMap<String, u64>,
pub attested_percentage: f64,
pub failed_auth_24h: u64,
}
/// In-memory session store updated by SessionHandler.
#[derive(Clone)]
pub struct SessionStore {
pub active: Arc<RwLock<HashMap<String, DashboardSession>>>,
pub stats: Arc<RwLock<DashboardStats>>,
}
impl SessionStore {
pub fn new() -> Self {
Self {
active: Arc::new(RwLock::new(HashMap::new())),
stats: Arc::new(RwLock::new(DashboardStats::default())),
}
}
pub async fn add_session(&self, session: DashboardSession) {
let method = session.auth_method.clone();
let backend = session.backend.clone();
let attested = session.tpm_attested;
self.active.write().await.insert(session.id.clone(), session);
let mut stats = self.stats.write().await;
stats.active_sessions = self.active.read().await.len();
stats.total_sessions_24h += 1;
*stats.auth_breakdown.entry(method).or_insert(0) += 1;
*stats.backend_breakdown.entry(backend).or_insert(0) += 1;
// Recalculate attested percentage
let total = stats.total_sessions_24h as f64;
if attested {
stats.attested_percentage = ((stats.attested_percentage * (total - 1.0) + 100.0) / total).min(100.0);
} else if total > 0.0 {
stats.attested_percentage = (stats.attested_percentage * (total - 1.0)) / total;
}
}
pub async fn remove_session(&self, id: &str) {
self.active.write().await.remove(id);
self.stats.write().await.active_sessions = self.active.read().await.len();
}
pub async fn record_auth_failure(&self) {
self.stats.write().await.failed_auth_24h += 1;
}
}
impl Default for SessionStore {
fn default() -> Self {
Self::new()
}
}

View file

@ -0,0 +1,12 @@
[package]
name = "bascule-dashboard-web"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Bascule dashboard — web (WASM) target"
[dependencies]
bascule-dashboard = { path = "../bascule-dashboard" }
dioxus = { version = "0.6", features = ["web"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,139 @@
:root {
--bg-primary: #0f1117;
--bg-secondary: #1a1d27;
--bg-card: #222633;
--text-primary: #e4e7ef;
--text-secondary: #8b8fa3;
--accent-primary: #6c8cff;
--accent-success: #4caf82;
--accent-warning: #e8a838;
--accent-danger: #e85454;
--border: #2a2e3d;
--radius: 8px;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-sans: 'Inter', -apple-system, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #f8f9fc;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a1d27;
--text-secondary: #6b7280;
--border: #e5e7eb;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
}
.dashboard { min-height: 100vh; }
.dashboard-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.dashboard-header h1 { font-size: 1.2em; }
.dashboard-main {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
}
.stat-value {
font-size: 2em;
font-weight: 700;
font-family: var(--font-mono);
}
.stat-label {
font-size: 0.8em;
color: var(--text-secondary);
margin-top: 4px;
}
.stat-primary .stat-value { color: var(--accent-primary); }
.stat-secondary .stat-value { color: var(--text-secondary); }
.stat-success .stat-value { color: var(--accent-success); }
.stat-warning .stat-value { color: var(--accent-warning); }
.stat-danger .stat-value { color: var(--accent-danger); }
.session-table { margin-top: 24px; }
.session-table h2 { margin-bottom: 12px; }
table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 0.85em;
}
th {
text-align: left;
padding: 8px 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
font-weight: 500;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.8em;
background: var(--bg-secondary);
color: var(--text-secondary);
}
.image-tag { font-size: 0.8em; color: var(--text-secondary); }
.empty { color: var(--text-secondary); padding: 24px; text-align: center; }
.status-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85em;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-connected { background: var(--accent-success); }
.status-disconnected { background: var(--accent-danger); }
.status-text { color: var(--text-secondary); }
.refresh-time { color: var(--text-secondary); font-size: 0.8em; }

View file

@ -0,0 +1,63 @@
//! Bascule Dashboard — Web (WASM) entry point.
use dioxus::prelude::*;
use bascule_dashboard::components::session_table::SessionTable;
use bascule_dashboard::components::stats_cards::StatsCards;
use bascule_dashboard::components::status_bar::StatusBar;
use bascule_dashboard::types::{DashboardSession, DashboardStats};
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
// For now, use placeholder data. In production, fetch from /api/sessions.
let sessions = vec![
DashboardSession {
id: "sess-001".into(),
principal: "tking@guildhouse.dev".into(),
auth_method: "ssh-key".into(),
source_ip: "192.168.1.10".into(),
backend: "container".into(),
container_image: Some("k8s-ops".into()),
connected_at: "2026-04-05T10:00:00Z".into(),
tpm_attested: true,
attestation_hash: Some("e9b95f002f54222d".into()),
commands_executed: 47,
},
DashboardSession {
id: "sess-002".into(),
principal: "agent:claude-code".into(),
auth_method: "agent-id".into(),
source_ip: "10.0.1.50".into(),
backend: "pty".into(),
container_image: None,
connected_at: "2026-04-05T10:15:00Z".into(),
tpm_attested: false,
attestation_hash: None,
commands_executed: 12,
},
];
let stats = DashboardStats {
active_sessions: 2,
total_sessions_24h: 47,
attested_percentage: 78.0,
failed_auth_24h: 3,
..Default::default()
};
rsx! {
div { class: "dashboard",
header { class: "dashboard-header",
h1 { "Bascule" }
StatusBar { connected: true, last_refresh: Some("just now".into()) }
}
main { class: "dashboard-main",
StatsCards { stats: stats }
SessionTable { sessions: sessions }
}
}
}
}

View file

@ -0,0 +1,12 @@
[package]
name = "bascule-dashboard"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Dashboard components for Bascule SSH proxy"
[dependencies]
dioxus = "0.6"
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }

View file

@ -0,0 +1,3 @@
pub mod session_table;
pub mod stats_cards;
pub mod status_bar;

View file

@ -0,0 +1,48 @@
use dioxus::prelude::*;
use crate::types::DashboardSession;
#[component]
pub fn SessionTable(sessions: Vec<DashboardSession>) -> Element {
rsx! {
div { class: "session-table",
h2 { "Active Sessions ({sessions.len()})" }
if sessions.is_empty() {
p { class: "empty", "No active sessions" }
} else {
table {
thead {
tr {
th { "Principal" }
th { "Method" }
th { "Backend" }
th { "Source IP" }
th { "TPM" }
th { "Commands" }
}
}
tbody {
for session in sessions.iter() {
tr { class: "session-row",
td { "{session.principal}" }
td {
span { class: "badge", "{session.auth_method}" }
}
td {
span { class: "badge", "{session.backend}" }
if let Some(ref img) = session.container_image {
span { class: "image-tag", " ({img})" }
}
}
td { "{session.source_ip}" }
td {
if session.tpm_attested { "" } else { "" }
}
td { "{session.commands_executed}" }
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,32 @@
use dioxus::prelude::*;
use crate::types::DashboardStats;
#[component]
pub fn StatsCards(stats: DashboardStats) -> Element {
rsx! {
div { class: "stat-grid",
StatCard { label: "Active Sessions".to_string(), value: format!("{}", stats.active_sessions), accent: "primary".to_string() }
StatCard { label: "24h Total".to_string(), value: format!("{}", stats.total_sessions_24h), accent: "secondary".to_string() }
StatCard {
label: "TPM Attested".to_string(),
value: format!("{:.0}%", stats.attested_percentage),
accent: (if stats.attested_percentage > 90.0 { "success" } else { "warning" }).to_string(),
}
StatCard {
label: "Failed Auth (24h)".to_string(),
value: format!("{}", stats.failed_auth_24h),
accent: (if stats.failed_auth_24h > 10 { "danger" } else { "success" }).to_string(),
}
}
}
}
#[component]
fn StatCard(label: String, value: String, accent: String) -> Element {
rsx! {
div { class: "stat-card stat-{accent}",
div { class: "stat-value", "{value}" }
div { class: "stat-label", "{label}" }
}
}
}

View file

@ -0,0 +1,17 @@
use dioxus::prelude::*;
#[component]
pub fn StatusBar(connected: bool, last_refresh: Option<String>) -> Element {
let status_class = if connected { "connected" } else { "disconnected" };
let status_text = if connected { "Connected" } else { "Disconnected" };
rsx! {
div { class: "status-bar",
span { class: "status-dot status-{status_class}" }
span { class: "status-text", "{status_text}" }
if let Some(ref ts) = last_refresh {
span { class: "refresh-time", "Last: {ts}" }
}
}
}
}

View file

@ -0,0 +1,6 @@
//! Bascule Dashboard — shared component library.
//!
//! Dioxus components consumed by both the web (WASM) and TUI targets.
pub mod components;
pub mod types;

View file

@ -0,0 +1,35 @@
//! Shared types between dashboard and server.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DashboardSession {
pub id: String,
pub principal: String,
pub auth_method: String,
pub source_ip: String,
pub backend: String,
pub container_image: Option<String>,
pub connected_at: String,
pub tpm_attested: bool,
pub attestation_hash: Option<String>,
pub commands_executed: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DashboardStats {
pub active_sessions: usize,
pub total_sessions_24h: u64,
pub auth_breakdown: HashMap<String, u64>,
pub backend_breakdown: HashMap<String, u64>,
pub attested_percentage: f64,
pub failed_auth_24h: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
pub active_sessions: usize,
}