feat(ui): add dashboard sections with sidebar navigation and mock views
Add seven sidebar sections (Dashboard, Providers, Chat, Tools, Knowledge Base, Developer, Organization) with fully rendered mock views, nested sub-shells for Developer and Organization, and SearXNG container for future news feed integration. Replaces the previous OverviewPage with a news feed dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1088
assets/main.css
1088
assets/main.css
File diff suppressed because it is too large
Load Diff
@@ -28,4 +28,15 @@ services:
|
||||
- 27017:27017
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
MONGO_INITDB_ROOT_PASSWORD: example
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: certifai-searxng
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8080"
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:8888
|
||||
volumes:
|
||||
- ./searxng:/etc/searxng:rw
|
||||
32
src/app.rs
32
src/app.rs
@@ -4,8 +4,9 @@ use dioxus::prelude::*;
|
||||
/// Application routes.
|
||||
///
|
||||
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
|
||||
/// outside the `AppShell` layout. Authenticated pages like `OverviewPage`
|
||||
/// are wrapped in `AppShell` which renders the sidebar.
|
||||
/// outside the `AppShell` layout. Authenticated pages are wrapped in
|
||||
/// `AppShell` which renders the sidebar. `DeveloperShell` and `OrgShell`
|
||||
/// provide nested tab navigation within the app shell.
|
||||
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
@@ -17,8 +18,33 @@ pub enum Route {
|
||||
PrivacyPage {},
|
||||
#[layout(AppShell)]
|
||||
#[route("/dashboard")]
|
||||
OverviewPage {},
|
||||
DashboardPage {},
|
||||
#[route("/providers")]
|
||||
ProvidersPage {},
|
||||
#[route("/chat")]
|
||||
ChatPage {},
|
||||
#[route("/tools")]
|
||||
ToolsPage {},
|
||||
#[route("/knowledge")]
|
||||
KnowledgePage {},
|
||||
|
||||
#[layout(DeveloperShell)]
|
||||
#[route("/developer/agents")]
|
||||
AgentsPage {},
|
||||
#[route("/developer/flow")]
|
||||
FlowPage {},
|
||||
#[route("/developer/analytics")]
|
||||
AnalyticsPage {},
|
||||
#[end_layout]
|
||||
|
||||
#[layout(OrgShell)]
|
||||
#[route("/organization/pricing")]
|
||||
OrgPricingPage {},
|
||||
#[route("/organization/dashboard")]
|
||||
OrgDashboardPage {},
|
||||
#[end_layout]
|
||||
#[end_layout]
|
||||
|
||||
#[route("/login?:redirect_url")]
|
||||
Login { redirect_url: String },
|
||||
}
|
||||
|
||||
41
src/components/chat_bubble.rs
Normal file
41
src/components/chat_bubble.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/components/file_row.rs
Normal file
54
src/components/file_row.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
38
src/components/member_row.rs
Normal file
38
src/components/member_row.rs
Normal 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
129
src/components/news_card.rs
Normal file
129
src/components/news_card.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
23
src/components/page_header.rs
Normal file
23
src/components/page_header.rs
Normal 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} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/components/pricing_card.rs
Normal file
46
src/components/pricing_card.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ¤t_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
44
src/components/sub_nav.rs
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/components/tool_card.rs
Normal file
40
src/components/tool_card.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/models/chat.rs
Normal file
71
src/models/chat.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The role of a participant in a chat conversation.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ChatRole {
|
||||
/// Message sent by the human user
|
||||
User,
|
||||
/// Message generated by the AI assistant
|
||||
Assistant,
|
||||
/// System-level instruction (not displayed in UI)
|
||||
System,
|
||||
}
|
||||
|
||||
/// The type of file attached to a chat message.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AttachmentKind {
|
||||
/// Image file (png, jpg, webp, etc.)
|
||||
Image,
|
||||
/// Document file (pdf, docx, txt, etc.)
|
||||
Document,
|
||||
/// Source code file
|
||||
Code,
|
||||
}
|
||||
|
||||
/// A file attachment on a chat message.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `name` - Original filename
|
||||
/// * `kind` - Type of attachment for rendering
|
||||
/// * `size_bytes` - File size in bytes
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Attachment {
|
||||
pub name: String,
|
||||
pub kind: AttachmentKind,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
/// A single message in a chat conversation.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique message identifier
|
||||
/// * `role` - Who sent this message
|
||||
/// * `content` - The message text content
|
||||
/// * `attachments` - Optional file attachments
|
||||
/// * `timestamp` - ISO 8601 timestamp string
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub role: ChatRole,
|
||||
pub content: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// A chat session containing a conversation history.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique session identifier
|
||||
/// * `title` - Display title (usually derived from first message)
|
||||
/// * `messages` - Ordered list of messages in the session
|
||||
/// * `created_at` - ISO 8601 creation timestamp
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChatSession {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub created_at: String,
|
||||
}
|
||||
47
src/models/developer.rs
Normal file
47
src/models/developer.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An AI agent entry managed through the developer tools.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique agent identifier
|
||||
/// * `name` - Human-readable agent name
|
||||
/// * `description` - What this agent does
|
||||
/// * `status` - Current running status label
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AgentEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// A workflow/flow entry from the flow builder.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique flow identifier
|
||||
/// * `name` - Human-readable flow name
|
||||
/// * `node_count` - Number of nodes in the flow graph
|
||||
/// * `last_run` - ISO 8601 timestamp of the last execution
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FlowEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub node_count: u32,
|
||||
pub last_run: Option<String>,
|
||||
}
|
||||
|
||||
/// A single analytics metric for the developer dashboard.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `label` - Display name of the metric
|
||||
/// * `value` - Current value as a formatted string
|
||||
/// * `change_pct` - Percentage change from previous period (positive = increase)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnalyticsMetric {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub change_pct: f64,
|
||||
}
|
||||
60
src/models/knowledge.rs
Normal file
60
src/models/knowledge.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The type of file stored in the knowledge base.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FileKind {
|
||||
/// PDF document
|
||||
Pdf,
|
||||
/// Plain text or markdown file
|
||||
Text,
|
||||
/// Spreadsheet (csv, xlsx)
|
||||
Spreadsheet,
|
||||
/// Source code file
|
||||
Code,
|
||||
/// Image file
|
||||
Image,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
/// Returns the display label for a file kind.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pdf => "PDF",
|
||||
Self::Text => "Text",
|
||||
Self::Spreadsheet => "Spreadsheet",
|
||||
Self::Code => "Code",
|
||||
Self::Image => "Image",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an icon identifier for rendering.
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pdf => "file-pdf",
|
||||
Self::Text => "file-text",
|
||||
Self::Spreadsheet => "file-spreadsheet",
|
||||
Self::Code => "file-code",
|
||||
Self::Image => "file-image",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file stored in the knowledge base for RAG retrieval.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique file identifier
|
||||
/// * `name` - Original filename
|
||||
/// * `kind` - Type classification of the file
|
||||
/// * `size_bytes` - File size in bytes
|
||||
/// * `uploaded_at` - ISO 8601 upload timestamp
|
||||
/// * `chunk_count` - Number of vector chunks created from this file
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KnowledgeFile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: FileKind,
|
||||
pub size_bytes: u64,
|
||||
pub uploaded_at: String,
|
||||
pub chunk_count: u32,
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
mod chat;
|
||||
mod developer;
|
||||
mod knowledge;
|
||||
mod news;
|
||||
mod organization;
|
||||
mod provider;
|
||||
mod tool;
|
||||
mod user;
|
||||
|
||||
pub use chat::*;
|
||||
pub use developer::*;
|
||||
pub use knowledge::*;
|
||||
pub use news::*;
|
||||
pub use organization::*;
|
||||
pub use provider::*;
|
||||
pub use tool::*;
|
||||
pub use user::*;
|
||||
|
||||
62
src/models/news.rs
Normal file
62
src/models/news.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Categories for classifying AI news articles.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum NewsCategory {
|
||||
/// Large language model announcements and updates
|
||||
Llm,
|
||||
/// AI agent frameworks and tooling
|
||||
Agents,
|
||||
/// Data privacy and regulatory compliance
|
||||
Privacy,
|
||||
/// AI infrastructure and deployment
|
||||
Infrastructure,
|
||||
/// Open-source AI project releases
|
||||
OpenSource,
|
||||
}
|
||||
|
||||
impl NewsCategory {
|
||||
/// Returns the display label for a news category.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Llm => "LLM",
|
||||
Self::Agents => "Agents",
|
||||
Self::Privacy => "Privacy",
|
||||
Self::Infrastructure => "Infrastructure",
|
||||
Self::OpenSource => "Open Source",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the CSS class suffix for styling category badges.
|
||||
pub fn css_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Llm => "llm",
|
||||
Self::Agents => "agents",
|
||||
Self::Privacy => "privacy",
|
||||
Self::Infrastructure => "infrastructure",
|
||||
Self::OpenSource => "open-source",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single news feed card representing an AI-related article.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `title` - Headline of the article
|
||||
/// * `source` - Publishing outlet or author
|
||||
/// * `summary` - Brief summary text
|
||||
/// * `category` - Classification category
|
||||
/// * `url` - Link to the full article
|
||||
/// * `thumbnail_url` - Optional thumbnail image URL
|
||||
/// * `published_at` - ISO 8601 date string
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NewsCard {
|
||||
pub title: String,
|
||||
pub source: String,
|
||||
pub summary: String,
|
||||
pub category: NewsCategory,
|
||||
pub url: String,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub published_at: String,
|
||||
}
|
||||
84
src/models/organization.rs
Normal file
84
src/models/organization.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Role assigned to an organization member.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum MemberRole {
|
||||
/// Full administrative access
|
||||
Admin,
|
||||
/// Standard user access
|
||||
Member,
|
||||
/// Read-only access
|
||||
Viewer,
|
||||
}
|
||||
|
||||
impl MemberRole {
|
||||
/// Returns the display label for a member role.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Admin => "Admin",
|
||||
Self::Member => "Member",
|
||||
Self::Viewer => "Viewer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all available roles for populating dropdowns.
|
||||
pub fn all() -> &'static [Self] {
|
||||
&[Self::Admin, Self::Member, Self::Viewer]
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of the organization.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique member identifier
|
||||
/// * `name` - Display name
|
||||
/// * `email` - Email address
|
||||
/// * `role` - Assigned role within the organization
|
||||
/// * `joined_at` - ISO 8601 join date
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrgMember {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub role: MemberRole,
|
||||
pub joined_at: String,
|
||||
}
|
||||
|
||||
/// A pricing plan tier.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique plan identifier
|
||||
/// * `name` - Plan display name (e.g. "Starter", "Team", "Enterprise")
|
||||
/// * `price_eur` - Monthly price in euros
|
||||
/// * `features` - List of included features
|
||||
/// * `highlighted` - Whether this plan should be visually emphasized
|
||||
/// * `max_seats` - Maximum number of seats, None for unlimited
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PricingPlan {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub price_eur: u32,
|
||||
pub features: Vec<String>,
|
||||
pub highlighted: bool,
|
||||
pub max_seats: Option<u32>,
|
||||
}
|
||||
|
||||
/// Billing usage statistics for the current cycle.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `seats_used` - Number of active seats
|
||||
/// * `seats_total` - Total seats in the plan
|
||||
/// * `tokens_used` - Tokens consumed this billing cycle
|
||||
/// * `tokens_limit` - Token limit for the billing cycle
|
||||
/// * `billing_cycle_end` - ISO 8601 date when the current cycle ends
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BillingUsage {
|
||||
pub seats_used: u32,
|
||||
pub seats_total: u32,
|
||||
pub tokens_used: u64,
|
||||
pub tokens_limit: u64,
|
||||
pub billing_cycle_end: String,
|
||||
}
|
||||
74
src/models/provider.rs
Normal file
74
src/models/provider.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Supported LLM provider backends.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum LlmProvider {
|
||||
/// Self-hosted models via Ollama
|
||||
Ollama,
|
||||
/// Hugging Face Inference API
|
||||
HuggingFace,
|
||||
/// OpenAI-compatible endpoints
|
||||
OpenAi,
|
||||
/// Anthropic Claude API
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl LlmProvider {
|
||||
/// Returns the display name for a provider.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ollama => "Ollama",
|
||||
Self::HuggingFace => "Hugging Face",
|
||||
Self::OpenAi => "OpenAI",
|
||||
Self::Anthropic => "Anthropic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A model available from a provider.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique model identifier (e.g. "llama3.1:8b")
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `provider` - Which provider hosts this model
|
||||
/// * `context_window` - Maximum context length in tokens
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: LlmProvider,
|
||||
pub context_window: u32,
|
||||
}
|
||||
|
||||
/// An embedding model available from a provider.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique embedding model identifier
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `provider` - Which provider hosts this model
|
||||
/// * `dimensions` - Output embedding dimensions
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbeddingEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: LlmProvider,
|
||||
pub dimensions: u32,
|
||||
}
|
||||
|
||||
/// Active provider configuration state.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `provider` - Currently selected provider
|
||||
/// * `selected_model` - ID of the active chat model
|
||||
/// * `selected_embedding` - ID of the active embedding model
|
||||
/// * `api_key_set` - Whether an API key has been configured
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub provider: LlmProvider,
|
||||
pub selected_model: String,
|
||||
pub selected_embedding: String,
|
||||
pub api_key_set: bool,
|
||||
}
|
||||
73
src/models/tool.rs
Normal file
73
src/models/tool.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Category grouping for MCP tools.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ToolCategory {
|
||||
/// Web search and browsing tools
|
||||
Search,
|
||||
/// File and document processing tools
|
||||
FileSystem,
|
||||
/// Computation and math tools
|
||||
Compute,
|
||||
/// Code execution and analysis tools
|
||||
Code,
|
||||
/// Communication and notification tools
|
||||
Communication,
|
||||
}
|
||||
|
||||
impl ToolCategory {
|
||||
/// Returns the display label for a tool category.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Search => "Search",
|
||||
Self::FileSystem => "File System",
|
||||
Self::Compute => "Compute",
|
||||
Self::Code => "Code",
|
||||
Self::Communication => "Communication",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of an MCP tool instance.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ToolStatus {
|
||||
/// Tool is running and available
|
||||
Active,
|
||||
/// Tool is installed but not running
|
||||
Inactive,
|
||||
/// Tool encountered an error
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ToolStatus {
|
||||
/// Returns the CSS class suffix for status styling.
|
||||
pub fn css_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Active => "active",
|
||||
Self::Inactive => "inactive",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An MCP (Model Context Protocol) tool entry.
|
||||
///
|
||||
/// # Fields
|
||||
///
|
||||
/// * `id` - Unique tool identifier
|
||||
/// * `name` - Human-readable display name
|
||||
/// * `description` - Brief description of what the tool does
|
||||
/// * `category` - Classification category
|
||||
/// * `status` - Current running status
|
||||
/// * `enabled` - Whether the tool is toggled on by the user
|
||||
/// * `icon` - Icon identifier for rendering
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct McpTool {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub category: ToolCategory,
|
||||
pub status: ToolStatus,
|
||||
pub enabled: bool,
|
||||
pub icon: String,
|
||||
}
|
||||
147
src/pages/chat.rs
Normal file
147
src/pages/chat.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::ChatBubble;
|
||||
use crate::models::{ChatMessage, ChatRole, ChatSession};
|
||||
|
||||
/// ChatGPT-style chat interface with session list and message area.
|
||||
///
|
||||
/// Full-height layout: left panel shows session history,
|
||||
/// right panel shows messages and input bar.
|
||||
#[component]
|
||||
pub fn ChatPage() -> Element {
|
||||
let sessions = use_signal(mock_sessions);
|
||||
let mut active_session_id = use_signal(|| "session-1".to_string());
|
||||
let mut input_text = use_signal(String::new);
|
||||
|
||||
// Clone data out of signals before entering the rsx! block to avoid
|
||||
// holding a `Signal::read()` borrow across potential await points.
|
||||
let sessions_list = sessions.read().clone();
|
||||
let current_id = active_session_id.read().clone();
|
||||
let active_session = sessions_list.iter().find(|s| s.id == current_id).cloned();
|
||||
|
||||
rsx! {
|
||||
section { class: "chat-page",
|
||||
div { class: "chat-sidebar-panel",
|
||||
div { class: "chat-sidebar-header",
|
||||
h3 { "Conversations" }
|
||||
button { class: "btn-icon", "+" }
|
||||
}
|
||||
div { class: "chat-session-list",
|
||||
for session in &sessions_list {
|
||||
{
|
||||
let is_active = session.id == current_id;
|
||||
let class = if is_active {
|
||||
"chat-session-item chat-session-item--active"
|
||||
} else {
|
||||
"chat-session-item"
|
||||
};
|
||||
let id = session.id.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "{class}",
|
||||
onclick: move |_| active_session_id.set(id.clone()),
|
||||
div { class: "chat-session-title", "{session.title}" }
|
||||
div { class: "chat-session-date", "{session.created_at}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "chat-main-panel",
|
||||
if let Some(session) = &active_session {
|
||||
div { class: "chat-messages",
|
||||
for msg in &session.messages {
|
||||
ChatBubble { key: "{msg.id}", message: msg.clone() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
div { class: "chat-empty",
|
||||
p { "Select a conversation or start a new one." }
|
||||
}
|
||||
}
|
||||
div { class: "chat-input-bar",
|
||||
button { class: "btn-icon chat-attach-btn", "+" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "text",
|
||||
placeholder: "Type a message...",
|
||||
value: "{input_text}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
input_text.set(evt.value());
|
||||
},
|
||||
}
|
||||
button { class: "btn-primary chat-send-btn", "Send" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock chat sessions with sample messages.
|
||||
fn mock_sessions() -> Vec<ChatSession> {
|
||||
vec![
|
||||
ChatSession {
|
||||
id: "session-1".into(),
|
||||
title: "RAG Pipeline Setup".into(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
id: "msg-1".into(),
|
||||
role: ChatRole::User,
|
||||
content: "How do I set up a RAG pipeline with Ollama?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "10:30".into(),
|
||||
},
|
||||
ChatMessage {
|
||||
id: "msg-2".into(),
|
||||
role: ChatRole::Assistant,
|
||||
content: "To set up a RAG pipeline with Ollama, you'll need to: \
|
||||
1) Install Ollama and pull your preferred model, \
|
||||
2) Set up a vector database (e.g. ChromaDB), \
|
||||
3) Create an embedding pipeline for your documents, \
|
||||
4) Wire the retrieval step into your prompt chain."
|
||||
.into(),
|
||||
attachments: vec![],
|
||||
timestamp: "10:31".into(),
|
||||
},
|
||||
],
|
||||
created_at: "2026-02-18".into(),
|
||||
},
|
||||
ChatSession {
|
||||
id: "session-2".into(),
|
||||
title: "GDPR Compliance Check".into(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
id: "msg-3".into(),
|
||||
role: ChatRole::User,
|
||||
content: "What data does CERTifAI store about users?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "09:15".into(),
|
||||
},
|
||||
ChatMessage {
|
||||
id: "msg-4".into(),
|
||||
role: ChatRole::Assistant,
|
||||
content: "CERTifAI stores only the minimum data required: \
|
||||
email address, session tokens, and usage metrics. \
|
||||
All data stays on your infrastructure."
|
||||
.into(),
|
||||
attachments: vec![],
|
||||
timestamp: "09:16".into(),
|
||||
},
|
||||
],
|
||||
created_at: "2026-02-17".into(),
|
||||
},
|
||||
ChatSession {
|
||||
id: "session-3".into(),
|
||||
title: "MCP Server Configuration".into(),
|
||||
messages: vec![ChatMessage {
|
||||
id: "msg-5".into(),
|
||||
role: ChatRole::User,
|
||||
content: "How do I add a new MCP server?".into(),
|
||||
attachments: vec![],
|
||||
timestamp: "14:00".into(),
|
||||
}],
|
||||
created_at: "2026-02-16".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
71
src/pages/dashboard.rs
Normal file
71
src/pages/dashboard.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{NewsCardView, PageHeader};
|
||||
use crate::models::NewsCategory;
|
||||
|
||||
/// Dashboard page displaying an AI news feed grid with category filters.
|
||||
///
|
||||
/// Replaces the previous `OverviewPage`. Shows mock news items
|
||||
/// that will eventually be sourced from the SearXNG instance.
|
||||
#[component]
|
||||
pub fn DashboardPage() -> Element {
|
||||
let news = use_signal(crate::components::news_card::mock_news);
|
||||
let mut active_filter = use_signal(|| Option::<NewsCategory>::None);
|
||||
|
||||
// Collect filtered news items based on active category filter
|
||||
let filtered: Vec<_> = {
|
||||
let items = news.read();
|
||||
let filter = active_filter.read();
|
||||
match &*filter {
|
||||
Some(cat) => items
|
||||
.iter()
|
||||
.filter(|n| n.category == *cat)
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => items.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
// All available filter categories
|
||||
let categories = [
|
||||
("All", None),
|
||||
("LLM", Some(NewsCategory::Llm)),
|
||||
("Agents", Some(NewsCategory::Agents)),
|
||||
("Privacy", Some(NewsCategory::Privacy)),
|
||||
("Infrastructure", Some(NewsCategory::Infrastructure)),
|
||||
("Open Source", Some(NewsCategory::OpenSource)),
|
||||
];
|
||||
|
||||
rsx! {
|
||||
section { class: "dashboard-page",
|
||||
PageHeader {
|
||||
title: "Dashboard".to_string(),
|
||||
subtitle: "AI news and updates".to_string(),
|
||||
}
|
||||
div { class: "dashboard-filters",
|
||||
for (label, cat) in categories {
|
||||
{
|
||||
let is_active = *active_filter.read() == cat;
|
||||
let class = if is_active {
|
||||
"filter-tab filter-tab--active"
|
||||
} else {
|
||||
"filter-tab"
|
||||
};
|
||||
rsx! {
|
||||
button {
|
||||
class: "{class}",
|
||||
onclick: move |_| active_filter.set(cat.clone()),
|
||||
"{label}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "news-grid",
|
||||
for card in filtered {
|
||||
NewsCardView { key: "{card.title}", card }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/pages/developer/agents.rs
Normal file
24
src/pages/developer/agents.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Agents page placeholder for the LangGraph agent builder.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with the LangGraph framework.
|
||||
#[component]
|
||||
pub fn AgentsPage() -> Element {
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "A" }
|
||||
h2 { "Agent Builder" }
|
||||
p { class: "placeholder-desc",
|
||||
"Build and manage AI agents with LangGraph. \
|
||||
Create multi-step reasoning pipelines, tool-using agents, \
|
||||
and autonomous workflows."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch Agent Builder" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/pages/developer/analytics.rs
Normal file
70
src/pages/developer/analytics.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::models::AnalyticsMetric;
|
||||
|
||||
/// Analytics page placeholder for LangFuse integration.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button,
|
||||
/// plus a mock stats bar showing sample metrics.
|
||||
#[component]
|
||||
pub fn AnalyticsPage() -> Element {
|
||||
let metrics = mock_metrics();
|
||||
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "analytics-stats-bar",
|
||||
for metric in &metrics {
|
||||
div { class: "analytics-stat",
|
||||
span { class: "analytics-stat-value", "{metric.value}" }
|
||||
span { class: "analytics-stat-label", "{metric.label}" }
|
||||
span {
|
||||
class: if metric.change_pct >= 0.0 {
|
||||
"analytics-stat-change analytics-stat-change--up"
|
||||
} else {
|
||||
"analytics-stat-change analytics-stat-change--down"
|
||||
},
|
||||
"{metric.change_pct:+.1}%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "L" }
|
||||
h2 { "Analytics & Observability" }
|
||||
p { class: "placeholder-desc",
|
||||
"Monitor and analyze your AI pipelines with LangFuse. \
|
||||
Track token usage, latency, costs, and quality metrics \
|
||||
across all your deployments."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch LangFuse" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock analytics metrics for the stats bar.
|
||||
fn mock_metrics() -> Vec<AnalyticsMetric> {
|
||||
vec![
|
||||
AnalyticsMetric {
|
||||
label: "Total Requests".into(),
|
||||
value: "12,847".into(),
|
||||
change_pct: 14.2,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Avg Latency".into(),
|
||||
value: "245ms".into(),
|
||||
change_pct: -8.5,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Tokens Used".into(),
|
||||
value: "2.4M".into(),
|
||||
change_pct: 22.1,
|
||||
},
|
||||
AnalyticsMetric {
|
||||
label: "Error Rate".into(),
|
||||
value: "0.3%".into(),
|
||||
change_pct: -12.0,
|
||||
},
|
||||
]
|
||||
}
|
||||
24
src/pages/developer/flow.rs
Normal file
24
src/pages/developer/flow.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Flow page placeholder for the LangFlow visual workflow builder.
|
||||
///
|
||||
/// Shows a "Coming Soon" card with a disabled launch button.
|
||||
/// Will eventually integrate with LangFlow for visual flow design.
|
||||
#[component]
|
||||
pub fn FlowPage() -> Element {
|
||||
rsx! {
|
||||
section { class: "placeholder-page",
|
||||
div { class: "placeholder-card",
|
||||
div { class: "placeholder-icon", "F" }
|
||||
h2 { "Flow Builder" }
|
||||
p { class: "placeholder-desc",
|
||||
"Design visual AI workflows with LangFlow. \
|
||||
Drag-and-drop nodes to create data processing pipelines, \
|
||||
prompt chains, and integration flows."
|
||||
}
|
||||
button { class: "btn-primary", disabled: true, "Launch Flow Builder" }
|
||||
span { class: "placeholder-badge", "Coming Soon" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/pages/developer/mod.rs
Normal file
41
src/pages/developer/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
mod agents;
|
||||
mod analytics;
|
||||
mod flow;
|
||||
|
||||
pub use agents::*;
|
||||
pub use analytics::*;
|
||||
pub use flow::*;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||
|
||||
/// Shell layout for the Developer section.
|
||||
///
|
||||
/// Renders a horizontal tab bar (Agents, Flow, Analytics) above
|
||||
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||
#[component]
|
||||
pub fn DeveloperShell() -> Element {
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: "Agents",
|
||||
route: Route::AgentsPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Flow",
|
||||
route: Route::FlowPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Analytics",
|
||||
route: Route::AnalyticsPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "developer-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/pages/knowledge.rs
Normal file
124
src/pages/knowledge.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{FileRow, PageHeader};
|
||||
use crate::models::{FileKind, KnowledgeFile};
|
||||
|
||||
/// Knowledge Base page with file explorer table and upload controls.
|
||||
///
|
||||
/// Displays uploaded documents used for RAG retrieval with their
|
||||
/// metadata, chunk counts, and management actions.
|
||||
#[component]
|
||||
pub fn KnowledgePage() -> Element {
|
||||
let mut files = use_signal(mock_files);
|
||||
let mut search_query = use_signal(String::new);
|
||||
|
||||
// Filter files by search query (case-insensitive name match)
|
||||
let query = search_query.read().to_lowercase();
|
||||
let filtered: Vec<_> = files
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Remove a file by ID
|
||||
let on_delete = move |id: String| {
|
||||
files.write().retain(|f| f.id != id);
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "knowledge-page",
|
||||
PageHeader {
|
||||
title: "Knowledge Base".to_string(),
|
||||
subtitle: "Manage documents for RAG retrieval".to_string(),
|
||||
actions: rsx! {
|
||||
button { class: "btn-primary", "Upload File" }
|
||||
},
|
||||
}
|
||||
div { class: "knowledge-toolbar",
|
||||
input {
|
||||
class: "form-input knowledge-search",
|
||||
r#type: "text",
|
||||
placeholder: "Search files...",
|
||||
value: "{search_query}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
search_query.set(evt.value());
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "knowledge-table-wrapper",
|
||||
table { class: "knowledge-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Size" }
|
||||
th { "Chunks" }
|
||||
th { "Uploaded" }
|
||||
th { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for file in filtered {
|
||||
FileRow { key: "{file.id}", file, on_delete }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock knowledge base files.
|
||||
fn mock_files() -> Vec<KnowledgeFile> {
|
||||
vec![
|
||||
KnowledgeFile {
|
||||
id: "f1".into(),
|
||||
name: "company-handbook.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 2_450_000,
|
||||
uploaded_at: "2026-02-15".into(),
|
||||
chunk_count: 142,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f2".into(),
|
||||
name: "api-reference.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 89_000,
|
||||
uploaded_at: "2026-02-14".into(),
|
||||
chunk_count: 34,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f3".into(),
|
||||
name: "sales-data-q4.csv".into(),
|
||||
kind: FileKind::Spreadsheet,
|
||||
size_bytes: 1_200_000,
|
||||
uploaded_at: "2026-02-12".into(),
|
||||
chunk_count: 67,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f4".into(),
|
||||
name: "deployment-guide.pdf".into(),
|
||||
kind: FileKind::Pdf,
|
||||
size_bytes: 540_000,
|
||||
uploaded_at: "2026-02-10".into(),
|
||||
chunk_count: 28,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f5".into(),
|
||||
name: "onboarding-checklist.md".into(),
|
||||
kind: FileKind::Text,
|
||||
size_bytes: 12_000,
|
||||
uploaded_at: "2026-02-08".into(),
|
||||
chunk_count: 8,
|
||||
},
|
||||
KnowledgeFile {
|
||||
id: "f6".into(),
|
||||
name: "architecture-diagram.png".into(),
|
||||
kind: FileKind::Image,
|
||||
size_bytes: 3_800_000,
|
||||
uploaded_at: "2026-02-05".into(),
|
||||
chunk_count: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
mod chat;
|
||||
mod dashboard;
|
||||
pub mod developer;
|
||||
mod impressum;
|
||||
mod knowledge;
|
||||
mod landing;
|
||||
mod overview;
|
||||
pub mod organization;
|
||||
mod privacy;
|
||||
mod providers;
|
||||
mod tools;
|
||||
|
||||
pub use chat::*;
|
||||
pub use dashboard::*;
|
||||
pub use developer::*;
|
||||
pub use impressum::*;
|
||||
pub use knowledge::*;
|
||||
pub use landing::*;
|
||||
pub use overview::*;
|
||||
pub use organization::*;
|
||||
pub use privacy::*;
|
||||
pub use providers::*;
|
||||
pub use tools::*;
|
||||
|
||||
177
src/pages/organization/dashboard.rs
Normal file
177
src/pages/organization/dashboard.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{MemberRow, PageHeader};
|
||||
use crate::models::{BillingUsage, MemberRole, OrgMember};
|
||||
|
||||
/// Organization dashboard with billing stats, member table, and invite modal.
|
||||
///
|
||||
/// Shows current billing usage, a table of organization members
|
||||
/// with role management, and a button to invite new members.
|
||||
#[component]
|
||||
pub fn OrgDashboardPage() -> Element {
|
||||
let members = use_signal(mock_members);
|
||||
let usage = mock_usage();
|
||||
let mut show_invite = use_signal(|| false);
|
||||
let mut invite_email = use_signal(String::new);
|
||||
|
||||
let members_list = members.read().clone();
|
||||
|
||||
// Format token counts for display
|
||||
let tokens_display = format_tokens(usage.tokens_used);
|
||||
let tokens_limit_display = format_tokens(usage.tokens_limit);
|
||||
|
||||
rsx! {
|
||||
section { class: "org-dashboard-page",
|
||||
PageHeader {
|
||||
title: "Organization".to_string(),
|
||||
subtitle: "Manage members and billing".to_string(),
|
||||
actions: rsx! {
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| show_invite.set(true),
|
||||
"Invite Member"
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Stats bar
|
||||
div { class: "org-stats-bar",
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value",
|
||||
"{usage.seats_used}/{usage.seats_total}"
|
||||
}
|
||||
span { class: "org-stat-label", "Seats Used" }
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{tokens_display}" }
|
||||
span { class: "org-stat-label",
|
||||
"of {tokens_limit_display} tokens"
|
||||
}
|
||||
}
|
||||
div { class: "org-stat",
|
||||
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
|
||||
span { class: "org-stat-label", "Cycle Ends" }
|
||||
}
|
||||
}
|
||||
|
||||
// Members table
|
||||
div { class: "org-table-wrapper",
|
||||
table { class: "org-table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Email" }
|
||||
th { "Role" }
|
||||
th { "Joined" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for member in members_list {
|
||||
MemberRow {
|
||||
key: "{member.id}",
|
||||
member,
|
||||
on_role_change: move |_| {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invite modal
|
||||
if *show_invite.read() {
|
||||
div { class: "modal-overlay",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
div {
|
||||
class: "modal-content",
|
||||
// Prevent clicks inside modal from closing it
|
||||
onclick: move |evt: Event<MouseData>| evt.stop_propagation(),
|
||||
h3 { "Invite New Member" }
|
||||
div { class: "form-group",
|
||||
label { "Email Address" }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "email",
|
||||
placeholder: "colleague@company.com",
|
||||
value: "{invite_email}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
invite_email.set(evt.value());
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn-secondary",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| show_invite.set(false),
|
||||
"Send Invite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a token count into a human-readable string (e.g. "1.2M").
|
||||
fn format_tokens(count: u64) -> String {
|
||||
const M: u64 = 1_000_000;
|
||||
const K: u64 = 1_000;
|
||||
|
||||
if count >= M {
|
||||
format!("{:.1}M", count as f64 / M as f64)
|
||||
} else if count >= K {
|
||||
format!("{:.0}K", count as f64 / K as f64)
|
||||
} else {
|
||||
count.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock organization members.
|
||||
fn mock_members() -> Vec<OrgMember> {
|
||||
vec![
|
||||
OrgMember {
|
||||
id: "m1".into(),
|
||||
name: "Max Mustermann".into(),
|
||||
email: "max@example.com".into(),
|
||||
role: MemberRole::Admin,
|
||||
joined_at: "2026-01-10".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m2".into(),
|
||||
name: "Erika Musterfrau".into(),
|
||||
email: "erika@example.com".into(),
|
||||
role: MemberRole::Member,
|
||||
joined_at: "2026-01-15".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m3".into(),
|
||||
name: "Johann Schmidt".into(),
|
||||
email: "johann@example.com".into(),
|
||||
role: MemberRole::Member,
|
||||
joined_at: "2026-02-01".into(),
|
||||
},
|
||||
OrgMember {
|
||||
id: "m4".into(),
|
||||
name: "Anna Weber".into(),
|
||||
email: "anna@example.com".into(),
|
||||
role: MemberRole::Viewer,
|
||||
joined_at: "2026-02-10".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns mock billing usage data.
|
||||
fn mock_usage() -> BillingUsage {
|
||||
BillingUsage {
|
||||
seats_used: 4,
|
||||
seats_total: 25,
|
||||
tokens_used: 847_000,
|
||||
tokens_limit: 1_000_000,
|
||||
billing_cycle_end: "2026-03-01".into(),
|
||||
}
|
||||
}
|
||||
35
src/pages/organization/mod.rs
Normal file
35
src/pages/organization/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
mod dashboard;
|
||||
mod pricing;
|
||||
|
||||
pub use dashboard::*;
|
||||
pub use pricing::*;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::sub_nav::{SubNav, SubNavItem};
|
||||
|
||||
/// Shell layout for the Organization section.
|
||||
///
|
||||
/// Renders a horizontal tab bar (Pricing, Dashboard) above
|
||||
/// the child route outlet. Sits inside the main `AppShell` layout.
|
||||
#[component]
|
||||
pub fn OrgShell() -> Element {
|
||||
let tabs = vec![
|
||||
SubNavItem {
|
||||
label: "Pricing",
|
||||
route: Route::OrgPricingPage {},
|
||||
},
|
||||
SubNavItem {
|
||||
label: "Dashboard",
|
||||
route: Route::OrgDashboardPage {},
|
||||
},
|
||||
];
|
||||
|
||||
rsx! {
|
||||
div { class: "org-shell",
|
||||
SubNav { items: tabs }
|
||||
div { class: "shell-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/pages/organization/pricing.rs
Normal file
88
src/pages/organization/pricing.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::{PageHeader, PricingCard};
|
||||
use crate::models::PricingPlan;
|
||||
|
||||
/// Organization pricing page displaying three plan tiers.
|
||||
///
|
||||
/// Clicking "Get Started" on any plan navigates to the
|
||||
/// organization dashboard.
|
||||
#[component]
|
||||
pub fn OrgPricingPage() -> Element {
|
||||
let navigator = use_navigator();
|
||||
let plans = mock_plans();
|
||||
|
||||
rsx! {
|
||||
section { class: "pricing-page",
|
||||
PageHeader {
|
||||
title: "Pricing".to_string(),
|
||||
subtitle: "Choose the plan that fits your organization".to_string(),
|
||||
}
|
||||
div { class: "pricing-grid",
|
||||
for plan in plans {
|
||||
PricingCard {
|
||||
key: "{plan.id}",
|
||||
plan,
|
||||
on_select: move |_| {
|
||||
navigator.push(Route::OrgDashboardPage {});
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock pricing plans.
|
||||
fn mock_plans() -> Vec<PricingPlan> {
|
||||
vec![
|
||||
PricingPlan {
|
||||
id: "starter".into(),
|
||||
name: "Starter".into(),
|
||||
price_eur: 49,
|
||||
features: vec![
|
||||
"Up to 5 users".into(),
|
||||
"1 LLM provider".into(),
|
||||
"100K tokens/month".into(),
|
||||
"Community support".into(),
|
||||
"Basic analytics".into(),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: Some(5),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "team".into(),
|
||||
name: "Team".into(),
|
||||
price_eur: 199,
|
||||
features: vec![
|
||||
"Up to 25 users".into(),
|
||||
"All LLM providers".into(),
|
||||
"1M tokens/month".into(),
|
||||
"Priority support".into(),
|
||||
"Advanced analytics".into(),
|
||||
"Custom MCP tools".into(),
|
||||
"SSO integration".into(),
|
||||
],
|
||||
highlighted: true,
|
||||
max_seats: Some(25),
|
||||
},
|
||||
PricingPlan {
|
||||
id: "enterprise".into(),
|
||||
name: "Enterprise".into(),
|
||||
price_eur: 499,
|
||||
features: vec![
|
||||
"Unlimited users".into(),
|
||||
"All LLM providers".into(),
|
||||
"Unlimited tokens".into(),
|
||||
"Dedicated support".into(),
|
||||
"Full observability".into(),
|
||||
"Custom integrations".into(),
|
||||
"SLA guarantee".into(),
|
||||
"On-premise deployment".into(),
|
||||
],
|
||||
highlighted: false,
|
||||
max_seats: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::BsBook;
|
||||
use dioxus_free_icons::icons::fa_solid_icons::{FaChartLine, FaCubes, FaGears};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::components::DashboardCard;
|
||||
use crate::Route;
|
||||
|
||||
/// Overview dashboard page rendered inside the `AppShell` layout.
|
||||
///
|
||||
/// Displays a welcome heading and a grid of quick-access cards
|
||||
/// for the main GenAI platform tools.
|
||||
#[component]
|
||||
pub fn OverviewPage() -> Element {
|
||||
// Check authentication status on mount via a server function.
|
||||
let auth_check = use_resource(check_auth);
|
||||
let navigator = use_navigator();
|
||||
|
||||
// Once the server responds, redirect unauthenticated users to /auth.
|
||||
use_effect(move || {
|
||||
if let Some(Ok(false)) = auth_check() {
|
||||
navigator.push(NavigationTarget::<Route>::External(
|
||||
"/auth?redirect_url=/dashboard".into(),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
match auth_check() {
|
||||
// Still waiting for the server to respond.
|
||||
None => rsx! {},
|
||||
// Not authenticated -- render nothing while the redirect fires.
|
||||
Some(Ok(false)) => rsx! {},
|
||||
// Authenticated -- render the overview dashboard.
|
||||
Some(Ok(true)) => rsx! {
|
||||
section { class: "overview-page",
|
||||
h1 { class: "overview-heading", "GenAI Dashboard" }
|
||||
div { class: "dashboard-grid",
|
||||
DashboardCard {
|
||||
title: "Documentation".to_string(),
|
||||
description: "Guides & API Reference".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: BsBook, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
DashboardCard {
|
||||
title: "Langfuse".to_string(),
|
||||
description: "Observability & Analytics".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: FaChartLine, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
DashboardCard {
|
||||
title: "Langchain".to_string(),
|
||||
description: "Agent Framework".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: FaGears, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
DashboardCard {
|
||||
title: "Hugging Face".to_string(),
|
||||
description: "Browse Models".to_string(),
|
||||
href: "#".to_string(),
|
||||
icon: rsx! {
|
||||
Icon { icon: FaCubes, width: 28, height: 28 }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Server error -- surface it so it is not silently swallowed.
|
||||
Some(Err(err)) => rsx! {
|
||||
p { "Error: {err}" }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the current request has an active logged-in session.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if the session contains a logged-in user, `false` otherwise.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ServerFnError` if the session cannot be extracted from the request.
|
||||
#[server]
|
||||
async fn check_auth() -> Result<bool, ServerFnError> {
|
||||
use crate::infrastructure::{UserStateInner, LOGGED_IN_USER_SESS_KEY};
|
||||
use tower_sessions::Session;
|
||||
|
||||
// Extract the tower_sessions::Session from the Axum request.
|
||||
let session: Session = FullstackContext::extract().await?;
|
||||
let user: Option<UserStateInner> = session
|
||||
.get(LOGGED_IN_USER_SESS_KEY)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||
|
||||
Ok(user.is_some())
|
||||
}
|
||||
227
src/pages/providers.rs
Normal file
227
src/pages/providers.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::PageHeader;
|
||||
use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig};
|
||||
|
||||
/// Providers page for configuring LLM and embedding model backends.
|
||||
///
|
||||
/// Two-column layout: left side has a configuration form, right side
|
||||
/// shows the currently active provider status.
|
||||
#[component]
|
||||
pub fn ProvidersPage() -> Element {
|
||||
let mut selected_provider = use_signal(|| LlmProvider::Ollama);
|
||||
let mut selected_model = use_signal(|| "llama3.1:8b".to_string());
|
||||
let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string());
|
||||
let mut api_key = use_signal(String::new);
|
||||
let mut saved = use_signal(|| false);
|
||||
|
||||
let models = mock_models();
|
||||
let embeddings = mock_embeddings();
|
||||
|
||||
// Filter models/embeddings by selected provider
|
||||
let provider_val = selected_provider.read().clone();
|
||||
let available_models: Vec<_> = models
|
||||
.iter()
|
||||
.filter(|m| m.provider == provider_val)
|
||||
.collect();
|
||||
let available_embeddings: Vec<_> = embeddings
|
||||
.iter()
|
||||
.filter(|e| e.provider == provider_val)
|
||||
.collect();
|
||||
|
||||
let active_config = ProviderConfig {
|
||||
provider: provider_val.clone(),
|
||||
selected_model: selected_model.read().clone(),
|
||||
selected_embedding: selected_embedding.read().clone(),
|
||||
api_key_set: !api_key.read().is_empty(),
|
||||
};
|
||||
|
||||
rsx! {
|
||||
section { class: "providers-page",
|
||||
PageHeader {
|
||||
title: "Providers".to_string(),
|
||||
subtitle: "Configure your LLM and embedding backends".to_string(),
|
||||
}
|
||||
div { class: "providers-layout",
|
||||
div { class: "providers-form",
|
||||
div { class: "form-group",
|
||||
label { "Provider" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{provider_val.label()}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
let val = evt.value();
|
||||
let prov = match val.as_str() {
|
||||
"Hugging Face" => LlmProvider::HuggingFace,
|
||||
"OpenAI" => LlmProvider::OpenAi,
|
||||
"Anthropic" => LlmProvider::Anthropic,
|
||||
_ => LlmProvider::Ollama,
|
||||
};
|
||||
selected_provider.set(prov);
|
||||
saved.set(false);
|
||||
},
|
||||
option { value: "Ollama", "Ollama" }
|
||||
option { value: "Hugging Face", "Hugging Face" }
|
||||
option { value: "OpenAI", "OpenAI" }
|
||||
option { value: "Anthropic", "Anthropic" }
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Model" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{selected_model}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
selected_model.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
for m in &available_models {
|
||||
option { value: "{m.id}",
|
||||
"{m.name} ({m.context_window}k ctx)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Embedding Model" }
|
||||
select {
|
||||
class: "form-select",
|
||||
value: "{selected_embedding}",
|
||||
onchange: move |evt: Event<FormData>| {
|
||||
selected_embedding.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
for e in &available_embeddings {
|
||||
option { value: "{e.id}",
|
||||
"{e.name} ({e.dimensions}d)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "API Key" }
|
||||
input {
|
||||
class: "form-input",
|
||||
r#type: "password",
|
||||
placeholder: "Enter API key...",
|
||||
value: "{api_key}",
|
||||
oninput: move |evt: Event<FormData>| {
|
||||
api_key.set(evt.value());
|
||||
saved.set(false);
|
||||
},
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn-primary",
|
||||
onclick: move |_| saved.set(true),
|
||||
"Save Configuration"
|
||||
}
|
||||
if *saved.read() {
|
||||
p { class: "form-success", "Configuration saved." }
|
||||
}
|
||||
}
|
||||
div { class: "providers-status",
|
||||
h3 { "Active Configuration" }
|
||||
div { class: "status-card",
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Provider" }
|
||||
span { class: "status-value",
|
||||
"{active_config.provider.label()}"
|
||||
}
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Model" }
|
||||
span { class: "status-value",
|
||||
"{active_config.selected_model}"
|
||||
}
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "Embedding" }
|
||||
span { class: "status-value",
|
||||
"{active_config.selected_embedding}"
|
||||
}
|
||||
}
|
||||
div { class: "status-row",
|
||||
span { class: "status-label", "API Key" }
|
||||
span { class: "status-value",
|
||||
if active_config.api_key_set { "Set" } else { "Not set" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock model entries for all providers.
|
||||
fn mock_models() -> Vec<ModelEntry> {
|
||||
vec![
|
||||
ModelEntry {
|
||||
id: "llama3.1:8b".into(),
|
||||
name: "Llama 3.1 8B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "llama3.1:70b".into(),
|
||||
name: "Llama 3.1 70B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "mistral:7b".into(),
|
||||
name: "Mistral 7B".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
context_window: 32,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "meta-llama/Llama-3.1-8B".into(),
|
||||
name: "Llama 3.1 8B".into(),
|
||||
provider: LlmProvider::HuggingFace,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "gpt-4o".into(),
|
||||
name: "GPT-4o".into(),
|
||||
provider: LlmProvider::OpenAi,
|
||||
context_window: 128,
|
||||
},
|
||||
ModelEntry {
|
||||
id: "claude-sonnet-4-6".into(),
|
||||
name: "Claude Sonnet 4.6".into(),
|
||||
provider: LlmProvider::Anthropic,
|
||||
context_window: 200,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns mock embedding entries for all providers.
|
||||
fn mock_embeddings() -> Vec<EmbeddingEntry> {
|
||||
vec![
|
||||
EmbeddingEntry {
|
||||
id: "nomic-embed-text".into(),
|
||||
name: "Nomic Embed Text".into(),
|
||||
provider: LlmProvider::Ollama,
|
||||
dimensions: 768,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "sentence-transformers/all-MiniLM-L6-v2".into(),
|
||||
name: "MiniLM-L6-v2".into(),
|
||||
provider: LlmProvider::HuggingFace,
|
||||
dimensions: 384,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "text-embedding-3-small".into(),
|
||||
name: "Embedding 3 Small".into(),
|
||||
provider: LlmProvider::OpenAi,
|
||||
dimensions: 1536,
|
||||
},
|
||||
EmbeddingEntry {
|
||||
id: "voyage-3".into(),
|
||||
name: "Voyage 3".into(),
|
||||
provider: LlmProvider::Anthropic,
|
||||
dimensions: 1024,
|
||||
},
|
||||
]
|
||||
}
|
||||
120
src/pages/tools.rs
Normal file
120
src/pages/tools.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::{PageHeader, ToolCard};
|
||||
use crate::models::{McpTool, ToolCategory, ToolStatus};
|
||||
|
||||
/// Tools page displaying a grid of MCP tool cards with toggle switches.
|
||||
///
|
||||
/// Shows all available MCP tools with their status and allows
|
||||
/// enabling/disabling them via toggle buttons.
|
||||
#[component]
|
||||
pub fn ToolsPage() -> Element {
|
||||
let mut tools = use_signal(mock_tools);
|
||||
|
||||
// Toggle a tool's enabled state by its ID
|
||||
let on_toggle = move |id: String| {
|
||||
tools.write().iter_mut().for_each(|t| {
|
||||
if t.id == id {
|
||||
t.enabled = !t.enabled;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let tool_list = tools.read().clone();
|
||||
|
||||
rsx! {
|
||||
section { class: "tools-page",
|
||||
PageHeader {
|
||||
title: "Tools".to_string(),
|
||||
subtitle: "Manage MCP servers and tool integrations".to_string(),
|
||||
}
|
||||
div { class: "tools-grid",
|
||||
for tool in tool_list {
|
||||
ToolCard {
|
||||
key: "{tool.id}",
|
||||
tool,
|
||||
on_toggle,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns mock MCP tools for the tools grid.
|
||||
fn mock_tools() -> Vec<McpTool> {
|
||||
vec![
|
||||
McpTool {
|
||||
id: "calculator".into(),
|
||||
name: "Calculator".into(),
|
||||
description: "Mathematical computation and unit conversion".into(),
|
||||
category: ToolCategory::Compute,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "calculator".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "tavily".into(),
|
||||
name: "Tavily Search".into(),
|
||||
description: "AI-optimized web search API for real-time information".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "search".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "searxng".into(),
|
||||
name: "SearXNG".into(),
|
||||
description: "Privacy-respecting metasearch engine".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "globe".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "file-reader".into(),
|
||||
name: "File Reader".into(),
|
||||
description: "Read and parse local files in various formats".into(),
|
||||
category: ToolCategory::FileSystem,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "file".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "code-exec".into(),
|
||||
name: "Code Executor".into(),
|
||||
description: "Sandboxed code execution for Python and JavaScript".into(),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "terminal".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "web-scraper".into(),
|
||||
name: "Web Scraper".into(),
|
||||
description: "Extract structured data from web pages".into(),
|
||||
category: ToolCategory::Search,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "download".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "email".into(),
|
||||
name: "Email Sender".into(),
|
||||
description: "Send emails via configured SMTP server".into(),
|
||||
category: ToolCategory::Communication,
|
||||
status: ToolStatus::Inactive,
|
||||
enabled: false,
|
||||
icon: "mail".into(),
|
||||
},
|
||||
McpTool {
|
||||
id: "git".into(),
|
||||
name: "Git Operations".into(),
|
||||
description: "Interact with Git repositories for version control".into(),
|
||||
category: ToolCategory::Code,
|
||||
status: ToolStatus::Active,
|
||||
enabled: true,
|
||||
icon: "git".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user