feat(dash): improved frontend dashboard #6

Merged
sharang merged 8 commits from feat/CAI-4-next into main 2026-02-19 11:52:41 +00:00
46 changed files with 4960 additions and 261 deletions

View File

@@ -96,76 +96,19 @@ jobs:
run: cargo test --features web --no-default-features
# ---------------------------------------------------------------------------
# Stage 3: Build Docker image and push to registry
# Only on main and release/* branches
# Stage 3: Deploy (only after tests pass, only on main)
# ---------------------------------------------------------------------------
build-and-push:
name: Build & Push Image
deploy:
name: Deploy
runs-on: docker
needs: [test]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Determine image tag
id: tag
run: |
BRANCH="${GITHUB_REF#refs/heads/}"
# Replace / with - for valid Docker tags (e.g. release/1.0 -> release-1.0)
BRANCH_SAFE=$(echo "$BRANCH" | tr '/' '-')
SHA=$(echo "$GITHUB_SHA" | head -c 8)
echo "tag=${BRANCH_SAFE}-${SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to container registry
run: >-
echo "${{ secrets.REGISTRY_PASSWORD }}"
| docker login https://registry.meghsakha.com
-u "${{ secrets.REGISTRY_USERNAME }}"
--password-stdin
- name: Build Docker image
run: >-
docker build
-t registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
-t registry.meghsakha.com/certifai/dashboard:latest
.
- name: Push Docker image
run: |
docker push registry.meghsakha.com/certifai/dashboard:${{ steps.tag.outputs.tag }}
docker push registry.meghsakha.com/certifai/dashboard:latest
# ---------------------------------------------------------------------------
# Stage 3b: Generate changelog from conventional commits
# Only on main and release/* branches
# ---------------------------------------------------------------------------
changelog:
name: Changelog
runs-on: docker
needs: [test]
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/'))
if: github.ref == 'refs/heads/main'
container:
image: rust:1.89-bookworm
image: alpine:latest
steps:
- name: Checkout (full history)
- name: Trigger Coolify deploy
run: |
git clone "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
git checkout "${GITHUB_SHA}"
- name: Install git-cliff
run: cargo install git-cliff --locked
- name: Generate changelog
run: git cliff --output CHANGELOG.md
- name: Upload changelog artifact
uses: actions/upload-artifact@v4
with:
name: changelog
path: CHANGELOG.md
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ keycloak/*
# Node modules
node_modules/
searxng/

View File

@@ -237,10 +237,6 @@ The SaaS application dashboard is the landing page for the company admin to view
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
## Features management
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
## Code structure
The following folder structure is maintained for separation of concerns:
- src/components/*.rs : All components that are required to be rendered are placed here. These are frontend only, reusable components that are specific for the application.

View File

@@ -1,5 +1,11 @@
# CERTifAI
[![CI](https://gitea.meghsakha.com/sharang/certifai/actions/workflows/ci.yml/badge.svg?branch=main)](https://gitea.meghsakha.com/sharang/certifai/actions?workflow=ci.yml)
[![Rust](https://img.shields.io/badge/Rust-1.89-orange?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Dioxus](https://img.shields.io/badge/Dioxus-0.7-blue?logo=webassembly&logoColor=white)](https://dioxuslabs.com/)
[![License](https://img.shields.io/badge/License-Proprietary-red)](LICENSE)
[![GDPR](https://img.shields.io/badge/GDPR-Compliant-green)](https://gdpr.eu/)
This project is a SaaS application dashboard for administation of self-hosted private GenAI (generative AI) toolbox for companies and individuals. The purpose of the dashboard is to manage LLMs, Agents, MCP Servers and other GenAI related features.
The purpose of `CERTifAI`is to provide self-hosted or GDPR-Conform GenAI infrastructure to companies who do not wish to subscribe to non-EU cloud providers to protect their intellectual property from being used as training data.
@@ -19,9 +25,6 @@ The SaaS application dashboard is the landing page for the company admin to view
This project is written in dioxus with fullstack and router features. MongoDB is used as a database for maintaining user state. Keycloak is used as identity provider for user management.
## Features management
All features are detailed and described under the features folder in clear markdown instructions which are valid for both human and AI code developers.
## Code structure
The following folder structure is maintained for separation of concerns:

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
@@ -162,55 +162,137 @@
}
}
@layer utilities {
.diff {
.btn {
:where(&) {
@layer daisyui.l1.l2.l3 {
width: unset;
}
}
.prose :where(a&:not(.btn-link)):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
text-decoration-line: none;
}
@layer daisyui.l1.l2.l3 {
position: relative;
display: grid;
width: 100%;
overflow: hidden;
display: inline-flex;
flex-shrink: 0;
cursor: pointer;
flex-wrap: nowrap;
align-items: center;
justify-content: center;
gap: calc(0.25rem * 1.5);
text-align: center;
vertical-align: middle;
outline-offset: 2px;
webkit-user-select: none;
user-select: none;
grid-template-rows: 1fr 1.8rem 1fr;
direction: ltr;
container-type: inline-size;
grid-template-columns: auto 1fr;
&:focus-visible, &:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
padding-inline: var(--btn-p);
color: var(--btn-fg);
--tw-prose-links: var(--btn-fg);
height: var(--size);
font-size: var(--fontsize, 0.875rem);
font-weight: 600;
outline-color: var(--btn-color, var(--color-base-content));
transition-property: color, background-color, border-color, box-shadow;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
transition-duration: 0.2s;
border-start-start-radius: var(--join-ss, var(--radius-field));
border-start-end-radius: var(--join-se, var(--radius-field));
border-end-start-radius: var(--join-es, var(--radius-field));
border-end-end-radius: var(--join-ee, var(--radius-field));
background-color: var(--btn-bg);
background-size: auto, calc(var(--noise) * 100%);
background-image: none, var(--btn-noise);
border-width: var(--border);
border-style: solid;
border-color: var(--btn-border);
text-shadow: 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 0.15));
touch-action: manipulation;
box-shadow: 0 0.5px 0 0.5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);
--size: calc(var(--size-field, 0.25rem) * 10);
--btn-bg: var(--btn-color, var(--color-base-200));
--btn-fg: var(--color-base-content);
--btn-p: 1rem;
--btn-border: var(--btn-bg);
@supports (color: color-mix(in lab, red, red)) {
--btn-border: color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));
}
&:focus-visible {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
outline-color: var(--color-base-content);
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
--btn-shadow: 0 3px 2px -2px var(--btn-bg),
0 4px 3px -2px var(--btn-bg);
@supports (color: color-mix(in lab, red, red)) {
--btn-shadow: 0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000),
0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);
}
&:has(.diff-item-1:focus-visible) {
outline-style: var(--tw-outline-style);
outline-width: 2px;
outline-offset: 1px;
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
}
}
@supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
&:focus {
.diff-resizer {
min-width: 5cqi;
max-width: 5cqi;
--btn-noise: var(--fx-noise);
@media (hover: hover) {
&:hover {
--btn-bg: var(--btn-color, var(--color-base-200));
@supports (color: color-mix(in lab, red, red)) {
--btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
}
}
&:has(.diff-item-1:focus) {
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
&:focus-visible, &:has(:focus-visible) {
outline-width: 2px;
outline-style: solid;
isolation: isolate;
}
&:active:not(.btn-active) {
translate: 0 0.5px;
--btn-bg: var(--btn-color, var(--color-base-200));
@supports (color: color-mix(in lab, red, red)) {
--btn-bg: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 5%);
}
--btn-border: var(--btn-color, var(--color-base-200));
@supports (color: color-mix(in lab, red, red)) {
--btn-border: color-mix(in oklab, var(--btn-color, var(--color-base-200)), #000 7%);
}
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);
}
&:is(input[type="checkbox"], input[type="radio"]) {
appearance: none;
&[aria-label]::after {
--tw-content: attr(aria-label);
content: var(--tw-content);
}
}
&:where(input:checked:not(.filter .btn)) {
--btn-color: var(--color-primary);
--btn-fg: var(--color-primary-content);
isolation: isolate;
}
}
&:disabled {
@layer daisyui.l1.l2 {
&:not(.btn-link, .btn-ghost) {
background-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
}
box-shadow: none;
}
pointer-events: none;
--btn-border: #0000;
--btn-noise: none;
--btn-fg: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
}
}
}
&[disabled] {
@layer daisyui.l1.l2 {
&:not(.btn-link, .btn-ghost) {
background-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
}
box-shadow: none;
}
pointer-events: none;
--btn-border: #0000;
--btn-noise: none;
--btn-fg: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--btn-fg: color-mix(in oklch, var(--color-base-content) 20%, #0000);
}
}
}
@@ -490,6 +572,23 @@
}
}
}
.indicator {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
width: max-content;
:where(.indicator-item) {
z-index: 1;
position: absolute;
white-space: nowrap;
top: var(--indicator-t, 0);
bottom: var(--indicator-b, auto);
left: var(--indicator-s, auto);
right: var(--indicator-e, 0);
translate: var(--indicator-x, 50%) var(--indicator-y, -50%);
}
}
}
.steps {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -600,6 +699,175 @@
}
}
}
.select {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid #0000;
position: relative;
display: inline-flex;
flex-shrink: 1;
appearance: none;
align-items: center;
gap: calc(0.25rem * 1.5);
background-color: var(--color-base-100);
padding-inline-start: calc(0.25rem * 3);
padding-inline-end: calc(0.25rem * 7);
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
height: var(--size);
font-size: 0.875rem;
touch-action: manipulation;
border-start-start-radius: var(--join-ss, var(--radius-field));
border-start-end-radius: var(--join-se, var(--radius-field));
border-end-start-radius: var(--join-es, var(--radius-field));
border-end-end-radius: var(--join-ee, var(--radius-field));
background-image: linear-gradient(45deg, #0000 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, #0000 50%);
background-position: calc(100% - 20px) calc(1px + 50%), calc(100% - 16.1px) calc(1px + 50%);
background-size: 4px 4px, 4px 4px;
background-repeat: no-repeat;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
}
border-color: var(--input-color);
--input-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
--input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
}
--size: calc(var(--size-field, 0.25rem) * 10);
[dir="rtl"] & {
background-position: calc(0% + 12px) calc(1px + 50%), calc(0% + 16px) calc(1px + 50%);
&::picker(select), select::picker(select) {
translate: 0.5rem 0;
}
}
&[multiple] {
height: auto;
overflow: auto;
padding-block: calc(0.25rem * 3);
padding-inline-end: calc(0.25rem * 3);
background-image: none;
}
select {
margin-inline-start: calc(0.25rem * -3);
margin-inline-end: calc(0.25rem * -7);
width: calc(100% + 2.75rem);
appearance: none;
padding-inline-start: calc(0.25rem * 3);
padding-inline-end: calc(0.25rem * 7);
height: calc(100% - calc(var(--border) * 2));
align-items: center;
background: inherit;
border-radius: inherit;
border-style: none;
&:focus, &:focus-within {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:not(:last-child) {
margin-inline-end: calc(0.25rem * -5.5);
background-image: none;
}
}
&:focus, &:focus-within {
--input-color: var(--color-base-content);
box-shadow: 0 1px var(--input-color);
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
}
outline: 2px solid var(--input-color);
outline-offset: 2px;
isolation: isolate;
}
&:has(> select[disabled]), &:is(:disabled, [disabled]), fieldset:disabled & {
cursor: not-allowed;
border-color: var(--color-base-200);
background-color: var(--color-base-200);
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}
&::placeholder {
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
}
}
}
&:has(> select[disabled]) > select[disabled] {
cursor: not-allowed;
}
&, & select {
@supports (appearance: base-select) {
appearance: base-select;
}
@supports (appearance: base-select) {
&::picker(select) {
appearance: base-select;
}
}
&::picker(select) {
color: inherit;
max-height: min(24rem, 70dvh);
margin-inline: 0.5rem;
translate: -0.5rem 0;
border: var(--border) solid var(--color-base-200);
margin-block: calc(0.25rem * 2);
border-radius: var(--radius-box);
padding: calc(0.25rem * 2);
background-color: inherit;
box-shadow: 0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/0.2);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / calc(var(--depth) * 0.1)), 0 8px 10px -6px rgb(0 0 0 / calc(var(--depth) * 0.1));
}
&::picker-icon {
display: none;
}
optgroup {
padding-top: 0.5em;
option {
&:nth-child(1) {
margin-top: 0.5em;
}
}
}
option {
border-radius: var(--radius-field);
padding-inline: calc(0.25rem * 3);
padding-block: calc(0.25rem * 1.5);
transition-property: color, background-color;
transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
white-space: normal;
&:not(:disabled) {
&:hover, &:focus-visible {
cursor: pointer;
background-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
}
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
&:active {
background-color: var(--color-neutral);
color: var(--color-neutral-content);
box-shadow: 0 2px calc(var(--depth) * 3px) -2px var(--color-neutral);
}
}
}
}
}
}
.avatar {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -725,6 +993,23 @@
.static {
position: static;
}
.start {
inset-inline-start: var(--spacing);
}
.end {
inset-inline-end: var(--spacing);
}
.hero-content {
@layer daisyui.l1.l2.l3 {
isolation: isolate;
display: flex;
max-width: 80rem;
align-items: center;
justify-content: center;
gap: calc(0.25rem * 4);
padding: calc(0.25rem * 4);
}
}
.stack {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -806,6 +1091,19 @@
}
}
}
.hero {
@layer daisyui.l1.l2.l3 {
display: grid;
width: 100%;
place-items: center;
background-size: cover;
background-position: center;
& > * {
grid-column-start: 1;
grid-row-start: 1;
}
}
}
.container {
width: 100%;
@media (width >= 40rem) {
@@ -888,6 +1186,28 @@
}
}
}
.badge {
@layer daisyui.l1.l2.l3 {
display: inline-flex;
align-items: center;
justify-content: center;
gap: calc(0.25rem * 2);
border-radius: var(--radius-selector);
vertical-align: middle;
color: var(--badge-fg);
border: var(--border) solid var(--badge-color, var(--color-base-200));
font-size: 0.875rem;
width: fit-content;
background-size: auto, calc(var(--noise) * 100%);
background-image: none, var(--fx-noise);
background-color: var(--badge-bg);
--badge-bg: var(--badge-color, var(--color-base-100));
--badge-fg: var(--color-base-content);
--size: calc(var(--size-selector, 0.25rem) * 6);
height: var(--size);
padding-inline: calc(var(--size) / 2 - var(--border));
}
}
.footer {
@layer daisyui.l1.l2.l3 {
display: grid;
@@ -949,12 +1269,88 @@
}
}
}
.badge-outline {
@layer daisyui.l1.l2 {
color: var(--badge-color);
--badge-bg: #0000;
background-image: none;
border-color: currentColor;
}
}
.p-6 {
padding: calc(var(--spacing) * 6);
}
.text-center {
text-align: center;
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.btn-ghost {
@layer daisyui.l1 {
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
--btn-shadow: "";
--btn-bg: #0000;
--btn-border: #0000;
--btn-noise: none;
&:not(:disabled, [disabled], .btn-disabled) {
outline-color: currentcolor;
--btn-fg: var(--btn-color, currentColor);
}
}
@media (hover: none) {
&:not(.btn-active, :active, :focus-visible, input:checked:not(.filter .btn)):hover {
outline-color: currentcolor;
--btn-shadow: "";
--btn-bg: #0000;
--btn-fg: var(--btn-color, currentColor);
--btn-border: #0000;
--btn-noise: none;
}
}
}
}
.btn-outline {
@layer daisyui.l1 {
&:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) {
--btn-shadow: "";
--btn-bg: #0000;
--btn-fg: var(--btn-color);
--btn-border: var(--btn-color);
--btn-noise: none;
}
@media (hover: none) {
&:not(.btn-active, :active, :focus-visible, input:checked:not(.filter .btn)):hover {
--btn-shadow: "";
--btn-bg: #0000;
--btn-fg: var(--btn-color);
--btn-border: var(--btn-color);
--btn-noise: none;
}
}
}
}
.btn-lg {
@layer daisyui.l1.l2 {
--fontsize: 1.125rem;
--btn-p: 1.25rem;
--size: calc(var(--size-field, 0.25rem) * 12);
}
}
.btn-sm {
@layer daisyui.l1.l2 {
--fontsize: 0.75rem;
--btn-p: 0.75rem;
--size: calc(var(--size-field, 0.25rem) * 8);
}
}
.btn-primary {
@layer daisyui.l1.l2.l3 {
--btn-color: var(--color-primary);
--btn-fg: var(--color-primary-content);
}
}
}
@layer base {
:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light] {
@@ -1323,6 +1719,11 @@
syntax: "*";
inherits: false;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
@@ -1331,6 +1732,7 @@
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-outline-style: solid;
}
}
}

View File

@@ -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

View File

@@ -1,9 +0,0 @@
# CAI-1
This feature creates a new login/registration page for the GenAI admin dashboard. The user management is provided by Keycloak, which also serves the login/registration flow. The dioxus app should detect if a user is already logged-in or not, and if not, redirect the user to the keycloak landing page and after successful login, capture the user's access token in a state and save a session state.
Steps to follow:
- Create a docker-compose file for hosting a local keycloak and create a realm for testing and a client for Oauth.
- Setup the environment variables using .env. Fill the environment with keycloak URL, realm, client ID and secret.
- Create a user state in Dioxus which manages the session and the access token. Add other user identifying information like email address to the state.
- Modify dioxus to check the state and load the correct URL based on the state.

View File

@@ -1,3 +0,0 @@
# CERTifAI 2
This feature defines the types for database as well as the API between the dashboard backend and frontend.

View File

@@ -3,16 +3,48 @@ use dioxus::prelude::*;
/// Application routes.
///
/// `OverviewPage` is wrapped in the `AppShell` layout so the sidebar
/// renders around every authenticated page. The `/login` route remains
/// outside the shell (unauthenticated).
/// Public pages (`LandingPage`, `ImpressumPage`, `PrivacyPage`) live
/// 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 {
#[route("/")]
LandingPage {},
#[route("/impressum")]
ImpressumPage {},
#[route("/privacy")]
PrivacyPage {},
#[layout(AppShell)]
#[route("/")]
OverviewPage {},
#[route("/dashboard")]
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 },
}

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"
}
}
}
}
}
}

71
src/models/chat.rs Normal file
View 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
View 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
View 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,
}

View File

@@ -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
View 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,
}

View 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
View 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
View 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,
}

145
src/pages/chat.rs Normal file
View File

@@ -0,0 +1,145 @@
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(),
},
]
}

67
src/pages/dashboard.rs Normal file
View File

@@ -0,0 +1,67 @@
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 }
}
}
}
}
}

View 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" }
}
}
}
}

View File

@@ -0,0 +1,65 @@
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,
},
]
}

View 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" }
}
}
}
}

View 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> {} }
}
}
}

74
src/pages/impressum.rs Normal file
View File

@@ -0,0 +1,74 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
use dioxus_free_icons::Icon;
use crate::Route;
/// Impressum (legal notice) page required by German/EU law.
///
/// Displays placeholder company information. This page is publicly
/// accessible without authentication.
#[component]
pub fn ImpressumPage() -> Element {
rsx! {
div { class: "legal-page",
nav { class: "legal-nav",
Link { to: Route::LandingPage {}, class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 20, height: 20 }
}
span { "CERTifAI" }
}
}
main { class: "legal-content",
h1 { "Impressum" }
h2 { "Information according to 5 TMG" }
p {
"CERTifAI GmbH"
br {}
"Musterstrasse 1"
br {}
"10115 Berlin"
br {}
"Germany"
}
h2 { "Represented by" }
p { "Managing Director: [Name]" }
h2 { "Contact" }
p {
"Email: info@certifai.example"
br {}
"Phone: +49 (0) 30 1234567"
}
h2 { "Commercial Register" }
p {
"Registered at: Amtsgericht Berlin-Charlottenburg"
br {}
"Registration number: HRB XXXXXX"
}
h2 { "VAT ID" }
p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" }
h2 { "Responsible for content according to 55 Abs. 2 RStV" }
p {
"[Name]"
br {}
"CERTifAI GmbH"
br {}
"Musterstrasse 1"
br {}
"10115 Berlin"
}
}
footer { class: "legal-footer",
Link { to: Route::LandingPage {}, "Back to Home" }
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
}
}
}
}

124
src/pages/knowledge.rs Normal file
View 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,
},
]
}

508
src/pages/landing.rs Normal file
View File

@@ -0,0 +1,508 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsArrowRight, BsGlobe2, BsKey, BsRobot, BsServer, BsShieldCheck,
};
use dioxus_free_icons::icons::fa_solid_icons::FaCubes;
use dioxus_free_icons::Icon;
use crate::Route;
/// Public landing page for the CERTifAI platform.
///
/// Displays a marketing-oriented page with hero section, feature grid,
/// how-it-works steps, and call-to-action banners. This page is accessible
/// without authentication.
#[component]
pub fn LandingPage() -> Element {
rsx! {
div { class: "landing",
LandingNav {}
HeroSection {}
SocialProof {}
FeaturesGrid {}
HowItWorks {}
CtaBanner {}
LandingFooter {}
}
}
}
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
#[component]
fn LandingNav() -> Element {
rsx! {
nav { class: "landing-nav",
div { class: "landing-nav-inner",
Link { to: Route::LandingPage {}, class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 24, height: 24 }
}
span { "CERTifAI" }
}
div { class: "landing-nav-links",
a { href: "#features", "Features" }
a { href: "#how-it-works", "How It Works" }
a { href: "#pricing", "Pricing" }
}
div { class: "landing-nav-actions",
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-ghost btn-sm",
"Log In"
}
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-sm",
"Get Started"
}
}
}
}
}
}
/// Hero section with headline, subtitle, and CTA buttons.
#[component]
fn HeroSection() -> Element {
rsx! {
section { class: "hero-section",
div { class: "hero-content",
div { class: "hero-badge badge badge-outline", "Privacy-First GenAI Infrastructure" }
h1 { class: "hero-title",
"Your AI. Your Data."
br {}
span { class: "hero-title-accent", "Your Infrastructure." }
}
p { class: "hero-subtitle",
"Self-hosted, GDPR-compliant generative AI platform for "
"enterprises that refuse to compromise on data sovereignty. "
"Deploy LLMs, agents, and MCP servers on your own terms."
}
div { class: "hero-actions",
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
"Get Started"
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
a { href: "#features", class: "btn btn-outline btn-lg", "Learn More" }
}
}
div { class: "hero-graphic",
// Abstract shield/network SVG motif
svg {
view_box: "0 0 400 400",
fill: "none",
width: "100%",
height: "100%",
// Gradient definitions
defs {
linearGradient {
id: "grad1",
x1: "0%",
y1: "0%",
x2: "100%",
y2: "100%",
stop { offset: "0%", stop_color: "#91a4d2" }
stop { offset: "100%", stop_color: "#6d85c6" }
}
linearGradient {
id: "grad2",
x1: "0%",
y1: "100%",
x2: "100%",
y2: "0%",
stop { offset: "0%", stop_color: "#f97066" }
stop { offset: "100%", stop_color: "#f9a066" }
}
radialGradient {
id: "glow",
cx: "50%",
cy: "50%",
r: "50%",
stop {
offset: "0%",
stop_color: "rgba(145,164,210,0.3)",
}
stop {
offset: "100%",
stop_color: "rgba(145,164,210,0)",
}
}
}
// Background glow
circle {
cx: "200",
cy: "200",
r: "180",
fill: "url(#glow)",
}
// Shield outline
path {
d: "M200 40 L340 110 L340 230 C340 300 270 360 200 380 \
C130 360 60 300 60 230 L60 110 Z",
stroke: "url(#grad1)",
stroke_width: "2",
fill: "none",
opacity: "0.6",
}
// Inner shield
path {
d: "M200 80 L310 135 L310 225 C310 280 255 330 200 345 \
C145 330 90 280 90 225 L90 135 Z",
stroke: "url(#grad1)",
stroke_width: "1.5",
fill: "rgba(145,164,210,0.05)",
opacity: "0.8",
}
// Network nodes
circle {
cx: "200",
cy: "180",
r: "8",
fill: "url(#grad1)",
}
circle {
cx: "150",
cy: "230",
r: "6",
fill: "url(#grad2)",
}
circle {
cx: "250",
cy: "230",
r: "6",
fill: "url(#grad2)",
}
circle {
cx: "200",
cy: "280",
r: "6",
fill: "url(#grad1)",
}
circle {
cx: "130",
cy: "170",
r: "4",
fill: "#91a4d2",
opacity: "0.6",
}
circle {
cx: "270",
cy: "170",
r: "4",
fill: "#91a4d2",
opacity: "0.6",
}
// Network connections
line {
x1: "200",
y1: "180",
x2: "150",
y2: "230",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "200",
y1: "180",
x2: "250",
y2: "230",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "150",
y1: "230",
x2: "200",
y2: "280",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "250",
y1: "230",
x2: "200",
y2: "280",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.4",
}
line {
x1: "200",
y1: "180",
x2: "130",
y2: "170",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.3",
}
line {
x1: "200",
y1: "180",
x2: "270",
y2: "170",
stroke: "#91a4d2",
stroke_width: "1",
opacity: "0.3",
}
// Checkmark inside shield center
path {
d: "M180 200 L195 215 L225 185",
stroke: "url(#grad1)",
stroke_width: "3",
stroke_linecap: "round",
stroke_linejoin: "round",
fill: "none",
}
}
}
}
}
}
/// Social proof / trust indicator strip.
#[component]
fn SocialProof() -> Element {
rsx! {
section { class: "social-proof",
p { class: "social-proof-text",
"Built for enterprises that value "
span { class: "social-proof-highlight", "data sovereignty" }
}
div { class: "social-proof-stats",
div { class: "proof-stat",
span { class: "proof-stat-value", "100%" }
span { class: "proof-stat-label", "On-Premise" }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "GDPR" }
span { class: "proof-stat-label", "Compliant" }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "EU" }
span { class: "proof-stat-label", "Data Residency" }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "Zero" }
span { class: "proof-stat-label", "Third-Party Sharing" }
}
}
}
}
}
/// Feature cards grid section.
#[component]
fn FeaturesGrid() -> Element {
rsx! {
section { id: "features", class: "features-section",
h2 { class: "section-title", "Everything You Need" }
p { class: "section-subtitle",
"A complete, self-hosted GenAI stack under your full control."
}
div { class: "features-grid",
FeatureCard {
icon: rsx! {
Icon { icon: BsServer, width: 28, height: 28 }
},
title: "Self-Hosted Infrastructure",
description: "Deploy on your own hardware or private cloud. \
Full control over your AI stack with no external dependencies.",
}
FeatureCard {
icon: rsx! {
Icon { icon: BsShieldCheck, width: 28, height: 28 }
},
title: "GDPR Compliant",
description: "EU data residency guaranteed. Your data never \
leaves your infrastructure or gets shared with third parties.",
}
FeatureCard {
icon: rsx! {
Icon { icon: FaCubes, width: 28, height: 28 }
},
title: "LLM Management",
description: "Deploy, monitor, and manage multiple language \
models. Switch between models with zero downtime.",
}
FeatureCard {
icon: rsx! {
Icon { icon: BsRobot, width: 28, height: 28 }
},
title: "Agent Builder",
description: "Create custom AI agents with integrated Langchain \
and Langfuse for full observability and control.",
}
FeatureCard {
icon: rsx! {
Icon { icon: BsGlobe2, width: 28, height: 28 }
},
title: "MCP Server Management",
description: "Manage Model Context Protocol servers to extend \
your AI capabilities with external tool integrations.",
}
FeatureCard {
icon: rsx! {
Icon { icon: BsKey, width: 28, height: 28 }
},
title: "API Key Management",
description: "Generate API keys, track usage per seat, and \
set fine-grained permissions for every integration.",
}
}
}
}
}
/// Individual feature card.
///
/// # Arguments
///
/// * `icon` - The icon element to display
/// * `title` - Feature title
/// * `description` - Feature description text
#[component]
fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element {
rsx! {
div { class: "card feature-card",
div { class: "feature-card-icon", {icon} }
h3 { class: "feature-card-title", "{title}" }
p { class: "feature-card-desc", "{description}" }
}
}
}
/// Three-step "How It Works" section.
#[component]
fn HowItWorks() -> Element {
rsx! {
section { id: "how-it-works", class: "how-it-works-section",
h2 { class: "section-title", "Up and Running in Minutes" }
p { class: "section-subtitle", "Three steps to sovereign AI infrastructure." }
div { class: "steps-grid",
StepCard {
number: "01",
title: "Deploy",
description: "Install CERTifAI on your infrastructure \
with a single command. Supports Docker, Kubernetes, \
and bare metal.",
}
StepCard {
number: "02",
title: "Configure",
description: "Connect your identity provider, select \
your models, and set up team permissions through \
the admin dashboard.",
}
StepCard {
number: "03",
title: "Scale",
description: "Add users, deploy more models, and \
integrate with your existing tools via API keys \
and MCP servers.",
}
}
}
}
}
/// Individual step card.
///
/// # Arguments
///
/// * `number` - Step number string (e.g. "01")
/// * `title` - Step title
/// * `description` - Step description text
#[component]
fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element {
rsx! {
div { class: "step-card",
span { class: "step-number", "{number}" }
h3 { class: "step-title", "{title}" }
p { class: "step-desc", "{description}" }
}
}
}
/// Call-to-action banner before the footer.
#[component]
fn CtaBanner() -> Element {
rsx! {
section { class: "cta-banner",
h2 { class: "cta-title", "Ready to take control of your AI infrastructure?" }
p { class: "cta-subtitle",
"Start deploying sovereign GenAI today. No credit card required."
}
div { class: "cta-actions",
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
"Get Started Free"
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-outline btn-lg",
"Log In"
}
}
}
}
}
/// Landing page footer with links and copyright.
#[component]
fn LandingFooter() -> Element {
rsx! {
footer { class: "landing-footer",
div { class: "landing-footer-inner",
div { class: "footer-brand",
div { class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 20, height: 20 }
}
span { "CERTifAI" }
}
p { class: "footer-tagline", "Sovereign GenAI infrastructure for enterprises." }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Product" }
a { href: "#features", "Features" }
a { href: "#how-it-works", "How It Works" }
a { href: "#pricing", "Pricing" }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Legal" }
Link { to: Route::ImpressumPage {}, "Impressum" }
Link { to: Route::PrivacyPage {}, "Privacy Policy" }
}
div { class: "footer-links-group",
h4 { class: "footer-links-heading", "Resources" }
a { href: "#", "Documentation" }
a { href: "#", "API Reference" }
a { href: "#", "Support" }
}
}
div { class: "footer-bottom",
p { "2026 CERTifAI. All rights reserved." }
}
}
}
}

View File

@@ -1,2 +1,21 @@
mod overview;
pub use overview::*;
mod chat;
mod dashboard;
pub mod developer;
mod impressum;
mod knowledge;
mod landing;
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 organization::*;
pub use privacy::*;
pub use providers::*;
pub use tools::*;

View File

@@ -0,0 +1,170 @@
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(),
}
}

View 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> {} }
}
}
}

View 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,
},
]
}

View File

@@ -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=/".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())
}

106
src/pages/privacy.rs Normal file
View File

@@ -0,0 +1,106 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::BsShieldCheck;
use dioxus_free_icons::Icon;
use crate::Route;
/// Privacy Policy page.
///
/// Displays the platform's privacy policy. Publicly accessible
/// without authentication.
#[component]
pub fn PrivacyPage() -> Element {
rsx! {
div { class: "legal-page",
nav { class: "legal-nav",
Link { to: Route::LandingPage {}, class: "landing-logo",
span { class: "landing-logo-icon",
Icon { icon: BsShieldCheck, width: 20, height: 20 }
}
span { "CERTifAI" }
}
}
main { class: "legal-content",
h1 { "Privacy Policy" }
p { class: "legal-updated", "Last updated: February 2026" }
h2 { "1. Introduction" }
p {
"CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to "
"protecting your personal data. This privacy policy explains "
"how we collect, use, and safeguard your information when you "
"use our platform."
}
h2 { "2. Data Controller" }
p {
"CERTifAI GmbH"
br {}
"Musterstrasse 1, 10115 Berlin, Germany"
br {}
"Email: privacy@certifai.example"
}
h2 { "3. Data We Collect" }
p {
"We collect only the minimum data necessary to provide "
"our services:"
}
ul {
li {
strong { "Account data: " }
"Name, email address, and organization details "
"provided during registration."
}
li {
strong { "Usage data: " }
"API call logs, token counts, and feature usage "
"metrics for billing and analytics."
}
li {
strong { "Technical data: " }
"IP addresses, browser type, and session identifiers "
"for security and platform stability."
}
}
h2 { "4. How We Use Your Data" }
ul {
li { "To provide and maintain the CERTifAI platform" }
li { "To manage your account and subscription" }
li { "To communicate service updates and security notices" }
li { "To comply with legal obligations" }
}
h2 { "5. Data Storage and Sovereignty" }
p {
"CERTifAI is a self-hosted platform. All AI workloads, "
"model data, and inference results remain entirely within "
"your own infrastructure. We do not access, store, or "
"process your AI data on our servers."
}
h2 { "6. Your Rights (GDPR)" }
p { "Under the GDPR, you have the right to:" }
ul {
li { "Access your personal data" }
li { "Rectify inaccurate data" }
li { "Request erasure of your data" }
li { "Restrict or object to processing" }
li { "Data portability" }
li { "Lodge a complaint with a supervisory authority" }
}
h2 { "7. Contact" }
p {
"For privacy-related inquiries, contact us at "
"privacy@certifai.example."
}
}
footer { class: "legal-footer",
Link { to: Route::LandingPage {}, "Back to Home" }
Link { to: Route::ImpressumPage {}, "Impressum" }
}
}
}
}

221
src/pages/providers.rs Normal file
View File

@@ -0,0 +1,221 @@
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,
},
]
}

116
src/pages/tools.rs Normal file
View File

@@ -0,0 +1,116 @@
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(),
},
]
}