feat(dash): improved frontend dashboard (#6)
All checks were successful
CI / Format (push) Successful in 6m30s
CI / Clippy (push) Successful in 2m25s
CI / Security Audit (push) Successful in 1m53s
CI / Tests (push) Successful in 2m50s
CI / Deploy (push) Successful in 4s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-02-19 11:52:41 +00:00
parent f699976f4d
commit a588be306a
46 changed files with 4960 additions and 261 deletions

View File

@@ -0,0 +1,41 @@
use crate::models::{ChatMessage, ChatRole};
use dioxus::prelude::*;
/// Renders a single chat message bubble with role-based styling.
///
/// User messages are right-aligned; assistant messages are left-aligned.
///
/// # Arguments
///
/// * `message` - The chat message to render
#[component]
pub fn ChatBubble(message: ChatMessage) -> Element {
let bubble_class = match message.role {
ChatRole::User => "chat-bubble chat-bubble--user",
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
ChatRole::System => "chat-bubble chat-bubble--system",
};
let role_label = match message.role {
ChatRole::User => "You",
ChatRole::Assistant => "Assistant",
ChatRole::System => "System",
};
rsx! {
div { class: "{bubble_class}",
div { class: "chat-bubble-header",
span { class: "chat-bubble-role", "{role_label}" }
span { class: "chat-bubble-time", "{message.timestamp}" }
}
div { class: "chat-bubble-content", "{message.content}" }
if !message.attachments.is_empty() {
div { class: "chat-bubble-attachments",
for att in &message.attachments {
span { class: "chat-attachment", "{att.name}" }
}
}
}
}
}
}

View File

@@ -0,0 +1,54 @@
use crate::models::KnowledgeFile;
use dioxus::prelude::*;
/// Renders a table row for a knowledge base file.
///
/// # Arguments
///
/// * `file` - The knowledge file data to render
/// * `on_delete` - Callback fired when the delete button is clicked
#[component]
pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler<String>) -> Element {
// Format file size for human readability (Python devs: similar to humanize.naturalsize)
let size_display = format_size(file.size_bytes);
rsx! {
tr { class: "file-row",
td { class: "file-row-name",
span { class: "file-row-icon", "{file.kind.icon()}" }
"{file.name}"
}
td { "{file.kind.label()}" }
td { "{size_display}" }
td { "{file.chunk_count} chunks" }
td { "{file.uploaded_at}" }
td {
button {
class: "btn-icon btn-danger",
onclick: {
let id = file.id.clone();
move |_| on_delete.call(id.clone())
},
"Delete"
}
}
}
}
}
/// Formats a byte count into a human-readable string (e.g. "1.2 MB").
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}

View File

@@ -1,11 +1,26 @@
use crate::Route;
use dioxus::prelude::*;
/// Login redirect component.
///
/// Redirects the user to the external OAuth authentication endpoint.
/// If no `redirect_url` is provided, defaults to `/dashboard`.
///
/// # Arguments
///
/// * `redirect_url` - URL to redirect to after successful authentication
#[component]
pub fn Login(redirect_url: String) -> Element {
let navigator = use_navigator();
use_effect(move || {
let target = format!("/auth?redirect_url={}", redirect_url);
// Default to /dashboard when redirect_url is empty.
let destination = if redirect_url.is_empty() {
"/dashboard".to_string()
} else {
redirect_url.clone()
};
let target = format!("/auth?redirect_url={destination}");
navigator.push(NavigationTarget::<Route>::External(target));
});

View File

@@ -0,0 +1,38 @@
use crate::models::{MemberRole, OrgMember};
use dioxus::prelude::*;
/// Renders a table row for an organization member with a role dropdown.
///
/// # Arguments
///
/// * `member` - The organization member data to render
/// * `on_role_change` - Callback fired with (member_id, new_role) when role changes
#[component]
pub fn MemberRow(member: OrgMember, on_role_change: EventHandler<(String, String)>) -> Element {
rsx! {
tr { class: "member-row",
td { class: "member-row-name", "{member.name}" }
td { "{member.email}" }
td {
select {
class: "member-role-select",
value: "{member.role.label()}",
onchange: {
let id = member.id.clone();
move |evt: Event<FormData>| {
on_role_change.call((id.clone(), evt.value()));
}
},
for role in MemberRole::all() {
option {
value: "{role.label()}",
selected: *role == member.role,
"{role.label()}"
}
}
}
}
td { "{member.joined_at}" }
}
}
}

View File

@@ -1,8 +1,24 @@
mod app_shell;
mod card;
mod chat_bubble;
mod file_row;
mod login;
mod member_row;
pub mod news_card;
mod page_header;
mod pricing_card;
pub mod sidebar;
pub mod sub_nav;
mod tool_card;
pub use app_shell::*;
pub use card::*;
pub use chat_bubble::*;
pub use file_row::*;
pub use login::*;
pub use member_row::*;
pub use news_card::*;
pub use page_header::*;
pub use pricing_card::*;
pub use sub_nav::*;
pub use tool_card::*;

138
src/components/news_card.rs Normal file
View File

@@ -0,0 +1,138 @@
use crate::models::{NewsCard as NewsCardModel, NewsCategory};
use dioxus::prelude::*;
/// Renders a news feed card with title, source, category badge, and summary.
///
/// # Arguments
///
/// * `card` - The news card model data to render
#[component]
pub fn NewsCardView(card: NewsCardModel) -> Element {
let badge_class = format!("news-badge news-badge--{}", card.category.css_class());
rsx! {
article { class: "news-card",
if let Some(ref thumb) = card.thumbnail_url {
div { class: "news-card-thumb",
img {
src: "{thumb}",
alt: "{card.title}",
loading: "lazy",
}
}
}
div { class: "news-card-body",
div { class: "news-card-meta",
span { class: "{badge_class}", "{card.category.label()}" }
span { class: "news-card-source", "{card.source}" }
span { class: "news-card-date", "{card.published_at}" }
}
h3 { class: "news-card-title",
a {
href: "{card.url}",
target: "_blank",
rel: "noopener",
"{card.title}"
}
}
p { class: "news-card-summary", "{card.summary}" }
}
}
}
}
/// Returns mock news data for the dashboard.
pub fn mock_news() -> Vec<NewsCardModel> {
vec![
NewsCardModel {
title: "Llama 4 Released with 1M Context Window".into(),
source: "Meta AI Blog".into(),
summary: "Meta releases Llama 4 with a 1 million token context window.".into(),
category: NewsCategory::Llm,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-18".into(),
},
NewsCardModel {
title: "EU AI Act Enforcement Begins".into(),
source: "TechCrunch".into(),
summary: "The EU AI Act enters its enforcement phase across member states.".into(),
category: NewsCategory::Privacy,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-17".into(),
},
NewsCardModel {
title: "LangChain v0.4 Introduces Native MCP Support".into(),
source: "LangChain Blog".into(),
summary: "New version adds first-class MCP server integration.".into(),
category: NewsCategory::Agents,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-16".into(),
},
NewsCardModel {
title: "Ollama Adds Multi-GPU Scheduling".into(),
source: "Ollama".into(),
summary: "Run large models across multiple GPUs with automatic sharding.".into(),
category: NewsCategory::Infrastructure,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-15".into(),
},
NewsCardModel {
title: "Mistral Open Sources Codestral 2".into(),
source: "Mistral AI".into(),
summary: "Codestral 2 achieves state-of-the-art on HumanEval benchmarks.".into(),
category: NewsCategory::OpenSource,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-14".into(),
},
NewsCardModel {
title: "NVIDIA Releases NeMo 3.0 Framework".into(),
source: "NVIDIA Developer".into(),
summary: "Updated framework simplifies enterprise LLM fine-tuning.".into(),
category: NewsCategory::Infrastructure,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-13".into(),
},
NewsCardModel {
title: "Anthropic Claude 4 Sets New Reasoning Records".into(),
source: "Anthropic".into(),
summary: "Claude 4 achieves top scores across major reasoning benchmarks.".into(),
category: NewsCategory::Llm,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-12".into(),
},
NewsCardModel {
title: "CrewAI Raises $52M for Agent Orchestration".into(),
source: "VentureBeat".into(),
summary: "Series B funding to expand multi-agent orchestration platform.".into(),
category: NewsCategory::Agents,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-11".into(),
},
NewsCardModel {
title: "DeepSeek V4 Released Under Apache 2.0".into(),
source: "DeepSeek".into(),
summary: "Latest open-weight model competes with proprietary offerings.".into(),
category: NewsCategory::OpenSource,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-10".into(),
},
NewsCardModel {
title: "GDPR Fines for AI Training Data Reach Record High".into(),
source: "Reuters".into(),
summary: "European regulators issue largest penalties yet for AI data misuse.".into(),
category: NewsCategory::Privacy,
url: "#".into(),
thumbnail_url: None,
published_at: "2026-02-09".into(),
},
]
}

View File

@@ -0,0 +1,23 @@
use dioxus::prelude::*;
/// Reusable page header with title, subtitle, and an optional action slot.
///
/// # Arguments
///
/// * `title` - Main heading text for the page
/// * `subtitle` - Secondary descriptive text below the title
/// * `actions` - Optional element rendered on the right side (e.g. buttons)
#[component]
pub fn PageHeader(title: String, subtitle: String, actions: Option<Element>) -> Element {
rsx! {
div { class: "page-header",
div { class: "page-header-text",
h1 { class: "page-title", "{title}" }
p { class: "page-subtitle", "{subtitle}" }
}
if let Some(actions) = actions {
div { class: "page-header-actions", {actions} }
}
}
}
}

View File

@@ -0,0 +1,46 @@
use crate::models::PricingPlan;
use dioxus::prelude::*;
/// Renders a pricing plan card with features list and call-to-action button.
///
/// # Arguments
///
/// * `plan` - The pricing plan data to render
/// * `on_select` - Callback fired when the CTA button is clicked
#[component]
pub fn PricingCard(plan: PricingPlan, on_select: EventHandler<String>) -> Element {
let card_class = if plan.highlighted {
"pricing-card pricing-card--highlighted"
} else {
"pricing-card"
};
let seats_label = match plan.max_seats {
Some(n) => format!("Up to {n} seats"),
None => "Unlimited seats".to_string(),
};
rsx! {
div { class: "{card_class}",
h3 { class: "pricing-card-name", "{plan.name}" }
div { class: "pricing-card-price",
span { class: "pricing-card-amount", "{plan.price_eur}" }
span { class: "pricing-card-period", " EUR / month" }
}
p { class: "pricing-card-seats", "{seats_label}" }
ul { class: "pricing-card-features",
for feature in &plan.features {
li { "{feature}" }
}
}
button {
class: "pricing-card-cta",
onclick: {
let id = plan.id.clone();
move |_| on_select.call(id.clone())
},
"Get Started"
}
}
}
}

View File

@@ -1,8 +1,8 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsFileEarmarkText, BsGear, BsGithub, BsGrid, BsHouseDoor, BsRobot,
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
BsGrid, BsHouseDoor, BsPuzzle,
};
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
use dioxus_free_icons::Icon;
use crate::Route;
@@ -25,29 +25,39 @@ struct NavItem {
pub fn Sidebar(email: String, avatar_url: String) -> Element {
let nav_items: Vec<NavItem> = vec![
NavItem {
label: "Overview",
route: Route::OverviewPage {},
label: "Dashboard",
route: Route::DashboardPage {},
icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } },
},
NavItem {
label: "Documentation",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsFileEarmarkText, width: 18, height: 18 } },
label: "Providers",
route: Route::ProvidersPage {},
icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } },
},
NavItem {
label: "Agents",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsRobot, width: 18, height: 18 } },
label: "Chat",
route: Route::ChatPage {},
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
},
NavItem {
label: "Models",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: FaCubes, width: 18, height: 18 } },
label: "Tools",
route: Route::ToolsPage {},
icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } },
},
NavItem {
label: "Settings",
route: Route::OverviewPage {},
icon: rsx! { Icon { icon: BsGear, width: 18, height: 18 } },
label: "Knowledge Base",
route: Route::KnowledgePage {},
icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } },
},
NavItem {
label: "Developer",
route: Route::AgentsPage {},
icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } },
},
NavItem {
label: "Organization",
route: Route::OrgPricingPage {},
icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } },
},
];
@@ -56,15 +66,22 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
rsx! {
aside { class: "sidebar",
// -- Header: avatar circle + email --
SidebarHeader { email: email.clone(), avatar_url }
// -- Navigation links --
nav { class: "sidebar-nav",
for item in nav_items {
{
// Simple active check: highlight Overview only when on `/`.
let is_active = item.route == current_route;
// Active detection for nested routes: highlight the parent nav
// item when any child route within the nested shell is active.
let is_active = match &current_route {
Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => {
item.label == "Developer"
}
Route::OrgPricingPage {} | Route::OrgDashboardPage {} => {
item.label == "Organization"
}
_ => item.route == current_route,
};
let cls = if is_active { "sidebar-link active" } else { "sidebar-link" };
rsx! {
Link { to: item.route, class: cls,
@@ -76,7 +93,6 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
}
}
// -- Logout button --
div { class: "sidebar-logout",
Link {
to: NavigationTarget::<Route>::External("/auth/logout".into()),
@@ -86,7 +102,6 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
}
}
// -- Footer: version + social links --
SidebarFooter {}
}
}

44
src/components/sub_nav.rs Normal file
View File

@@ -0,0 +1,44 @@
use crate::app::Route;
use dioxus::prelude::*;
/// A single tab item for the sub-navigation bar.
///
/// # Fields
///
/// * `label` - Display text for the tab
/// * `route` - Route to navigate to when clicked
#[derive(Clone, PartialEq)]
pub struct SubNavItem {
pub label: &'static str,
pub route: Route,
}
/// Horizontal tab navigation bar used inside nested shell layouts.
///
/// Highlights the active tab based on the current route.
///
/// # Arguments
///
/// * `items` - List of tab items to render
#[component]
pub fn SubNav(items: Vec<SubNavItem>) -> Element {
let current_route = use_route::<Route>();
rsx! {
nav { class: "sub-nav",
for item in &items {
{
let is_active = item.route == current_route;
let class = if is_active {
"sub-nav-item sub-nav-item--active"
} else {
"sub-nav-item"
};
rsx! {
Link { class: "{class}", to: item.route.clone(), "{item.label}" }
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
use crate::models::McpTool;
use dioxus::prelude::*;
/// Renders an MCP tool card with name, description, status indicator, and toggle.
///
/// # Arguments
///
/// * `tool` - The MCP tool data to render
/// * `on_toggle` - Callback fired when the enable/disable toggle is clicked
#[component]
pub fn ToolCard(tool: McpTool, on_toggle: EventHandler<String>) -> Element {
let status_class = format!("tool-status tool-status--{}", tool.status.css_class());
let toggle_class = if tool.enabled {
"tool-toggle tool-toggle--on"
} else {
"tool-toggle tool-toggle--off"
};
rsx! {
div { class: "tool-card",
div { class: "tool-card-header",
div { class: "tool-card-icon", "\u{2699}" }
span { class: "{status_class}", "" }
}
h3 { class: "tool-card-name", "{tool.name}" }
p { class: "tool-card-desc", "{tool.description}" }
div { class: "tool-card-footer",
span { class: "tool-card-category", "{tool.category.label()}" }
button {
class: "{toggle_class}",
onclick: {
let id = tool.id.clone();
move |_| on_toggle.call(id.clone())
},
if tool.enabled {
"ON"
} else {
"OFF"
}
}
}
}
}
}