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:
parent
4aa7e9d816
commit
04dd74d15f
14 changed files with 1359 additions and 3 deletions
901
Cargo.lock
generated
901
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,12 @@
|
|||
[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"
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
|
|
@ -18,3 +18,4 @@ pub mod proxy;
|
|||
pub mod pty;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod store;
|
||||
|
|
|
|||
84
crates/bascule-core/src/store.rs
Normal file
84
crates/bascule-core/src/store.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
12
crates/bascule-dashboard-web/Cargo.toml
Normal file
12
crates/bascule-dashboard-web/Cargo.toml
Normal 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"
|
||||
139
crates/bascule-dashboard-web/assets/style.css
Normal file
139
crates/bascule-dashboard-web/assets/style.css
Normal 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; }
|
||||
63
crates/bascule-dashboard-web/src/main.rs
Normal file
63
crates/bascule-dashboard-web/src/main.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/bascule-dashboard/Cargo.toml
Normal file
12
crates/bascule-dashboard/Cargo.toml
Normal 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 }
|
||||
3
crates/bascule-dashboard/src/components/mod.rs
Normal file
3
crates/bascule-dashboard/src/components/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod session_table;
|
||||
pub mod stats_cards;
|
||||
pub mod status_bar;
|
||||
48
crates/bascule-dashboard/src/components/session_table.rs
Normal file
48
crates/bascule-dashboard/src/components/session_table.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
crates/bascule-dashboard/src/components/stats_cards.rs
Normal file
32
crates/bascule-dashboard/src/components/stats_cards.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
17
crates/bascule-dashboard/src/components/status_bar.rs
Normal file
17
crates/bascule-dashboard/src/components/status_bar.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
crates/bascule-dashboard/src/lib.rs
Normal file
6
crates/bascule-dashboard/src/lib.rs
Normal 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;
|
||||
35
crates/bascule-dashboard/src/types.rs
Normal file
35
crates/bascule-dashboard/src/types.rs
Normal 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,
|
||||
}
|
||||
Loading…
Reference in a new issue