10 Commits

Author SHA1 Message Date
Sharang Parnerkar
78b215bb77 ci: retrigger after transient clippy failure
Some checks failed
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 2m54s
CI / Security Audit (push) Successful in 1m45s
CI / Tests (push) Successful in 4m11s
CI / Deploy (push) Successful in 1m28s
CI / E2E Tests (push) Failing after 1s
2026-04-08 16:20:43 +02:00
Sharang Parnerkar
58428892a8 ci: log orca webhook response so the step isnt silent on success
Some checks failed
CI / Format (push) Successful in 5s
CI / Clippy (push) Failing after 2s
CI / Security Audit (push) Successful in 1m52s
CI / Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
CI / E2E Tests (push) Has been skipped
2026-04-08 15:09:01 +02:00
Sharang Parnerkar
24b604ce51 ci: install openssl for orca webhook HMAC signing
Some checks failed
CI / Format (push) Successful in 5s
CI / Clippy (push) Successful in 2m59s
CI / Security Audit (push) Successful in 1m54s
CI / Tests (push) Successful in 4m16s
CI / Deploy (push) Successful in 1m25s
CI / E2E Tests (push) Failing after 1s
2026-04-08 14:56:12 +02:00
Sharang Parnerkar
a02827a34b ci: smoke test full deploy cycle (build → push → orca redeploy)
Some checks failed
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 3m4s
CI / Security Audit (push) Successful in 1m43s
CI / Tests (push) Successful in 3m59s
CI / Deploy (push) Failing after 1m19s
CI / E2E Tests (push) Has been skipped
2026-04-08 12:58:53 +02:00
Sharang Parnerkar
5b431f65dc chore(deps): cargo update to fix audit vulnerabilities
Some checks failed
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 2m46s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 3m53s
CI / Deploy (push) Successful in 13m45s
CI / E2E Tests (push) Failing after 15s
Bumps transitive deps (aws-lc-sys, quinn-proto, rustls-webpki, etc.)
to versions without RUSTSEC advisories. Two unmaintained-warning
deps remain (fxhash via scraper, instant via async-stripe) but
those are non-blocking warnings only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:05:36 +02:00
Sharang Parnerkar
b5ee887387 ci: replace coolify webhook with orca deploy
Some checks failed
CI / Format (push) Successful in 22s
CI / Clippy (push) Successful in 2m49s
CI / Security Audit (push) Failing after 1m43s
CI / Tests (push) Has been skipped
CI / Deploy (push) Has been skipped
CI / E2E Tests (push) Has been skipped
Build and push image to registry, then trigger orca redeploy via
HMAC-signed webhook. Coolify webhook is no longer the source of truth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:31:43 +02:00
75a35dbb85 feat(sidebar): add compliance scanner link from env config (#19)
Some checks failed
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 2m43s
CI / Security Audit (push) Successful in 1m37s
CI / Tests (push) Successful in 3m54s
CI / Deploy (push) Successful in 2s
CI / E2E Tests (push) Failing after 31s
2026-03-09 08:39:00 +00:00
Sharang Parnerkar
ca5da3c232 feat(ui): redesign landing page and update styling
Some checks failed
CI / Format (push) Successful in 22s
CI / Clippy (push) Successful in 2m29s
CI / Security Audit (push) Successful in 1m32s
CI / Tests (push) Successful in 3m32s
CI / Deploy (push) Successful in 2s
CI / E2E Tests (push) Failing after 31s
Overhaul landing page design with updated CSS, Tailwind config, and
i18n translations across all supported languages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:34:58 +01:00
Sharang Parnerkar
c9c5970971 fix(litellm): gate tests on server feature to fix cargo test --features web
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m56s
CI / Security Audit (push) Successful in 1m47s
CI / Tests (push) Successful in 4m4s
CI / Deploy (push) Successful in 4s
CI / E2E Tests (push) Failing after 30s
The litellm test module uses server-only structs (ActivityModelEntry,
SpendModelEntry) that are behind #[cfg(feature = "server")]. Gate the
test module with #[cfg(all(test, feature = "server"))] so
cargo test --features web compiles without errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:11:12 +01:00
fe4f8e84ae feat: replaced ollama with litellm (#18)
Some checks failed
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m53s
CI / Security Audit (push) Successful in 1m42s
CI / Tests (push) Failing after 3m59s
CI / Deploy (push) Has been skipped
CI / E2E Tests (push) Has been skipped
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #18
2026-02-26 17:52:47 +00:00
50 changed files with 7242 additions and 1583 deletions

View File

@@ -34,10 +34,11 @@ MONGODB_DATABASE=certifai
SEARXNG_URL=http://localhost:8888
# ---------------------------------------------------------------------------
# Ollama LLM instance [OPTIONAL - defaults shown]
# LiteLLM proxy [OPTIONAL - defaults shown]
# ---------------------------------------------------------------------------
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=llama3.1:8b
LITELLM_URL=http://localhost:4000
LITELLM_MODEL=qwen3-32b
LITELLM_API_KEY=
# ---------------------------------------------------------------------------
# LibreChat (external chat via SSO) [OPTIONAL - default: http://localhost:3080]
@@ -47,7 +48,7 @@ LIBRECHAT_URL=http://localhost:3080
# ---------------------------------------------------------------------------
# LLM Providers (comma-separated list) [OPTIONAL]
# ---------------------------------------------------------------------------
LLM_PROVIDERS=ollama
LLM_PROVIDERS=litellm
# ---------------------------------------------------------------------------
# SMTP (transactional email) [OPTIONAL]
@@ -73,6 +74,11 @@ LANGGRAPH_URL=
LANGFLOW_URL=
LANGFUSE_URL=
# ---------------------------------------------------------------------------
# Compliance scanner (external tool, opens in new tab) [OPTIONAL]
# ---------------------------------------------------------------------------
COMPLIANCE_SCANNER_URL=
# ---------------------------------------------------------------------------
# Vector database [OPTIONAL]
# ---------------------------------------------------------------------------

View File

@@ -262,10 +262,30 @@ jobs:
needs: [test]
if: github.ref == 'refs/heads/main'
container:
image: alpine:latest
image: docker:27-cli
steps:
- name: Trigger Coolify deploy
- name: Checkout
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
apk add --no-cache git curl openssl
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
- name: Build and push image
run: |
IMAGE=registry.meghsakha.com/certifai-dashboard
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
docker build -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
docker push "$IMAGE:latest"
docker push "$IMAGE:${GITHUB_SHA}"
- name: Trigger orca redeploy
run: |
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/certifai"},"head_commit":{"id":"%s","message":"CI deploy"}}' "${GITHUB_SHA}")
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
echo "Calling orca webhook for sharang/certifai@${GITHUB_SHA}"
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$PAYLOAD")
echo "$RESP"

726
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,7 @@ secrecy = { version = "0.10", default-features = false, optional = true }
serde_json = { version = "1.0.133", default-features = false }
maud = { version = "0.27", default-features = false }
url = { version = "2.5.4", default-features = false, optional = true }
js-sys = { version = "0.3", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
web-sys = { version = "0.3", optional = true, features = [
"Clipboard",
@@ -91,7 +92,7 @@ bytes = { version = "1", optional = true }
[features]
# default = ["web"]
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen"]
web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen", "dep:js-sys"]
server = [
"dioxus/server",
"dep:axum",

View File

@@ -46,7 +46,8 @@
"agents": "Agenten",
"flow": "Flow",
"analytics": "Analytics",
"pricing": "Preise"
"pricing": "Preise",
"compliance": "Compliance"
},
"auth": {
"redirecting_login": "Weiterleitung zur Anmeldung...",
@@ -58,15 +59,15 @@
"title": "Dashboard",
"subtitle": "KI-Nachrichten und Neuigkeiten",
"topic_placeholder": "Themenname...",
"ollama_settings": "Ollama-Einstellungen",
"settings_hint": "Leer lassen, um OLLAMA_URL / OLLAMA_MODEL aus .env zu verwenden",
"ollama_url": "Ollama-URL",
"ollama_url_placeholder": "Verwendet OLLAMA_URL aus .env",
"litellm_settings": "LiteLLM-Einstellungen",
"settings_hint": "Leer lassen, um LITELLM_URL / LITELLM_MODEL aus .env zu verwenden",
"litellm_url": "LiteLLM-URL",
"litellm_url_placeholder": "Verwendet LITELLM_URL aus .env",
"model": "Modell",
"model_placeholder": "Verwendet OLLAMA_MODEL aus .env",
"model_placeholder": "Verwendet LITELLM_MODEL aus .env",
"searching": "Suche laeuft...",
"search_failed": "Suche fehlgeschlagen: {e}",
"ollama_status": "Ollama-Status",
"litellm_status": "LiteLLM-Status",
"trending": "Im Trend",
"recent_searches": "Letzte Suchen"
},
@@ -144,6 +145,16 @@
"email_address": "E-Mail-Adresse",
"email_placeholder": "kollege@firma.de",
"send_invite": "Einladung senden",
"total_spend": "Gesamtausgaben",
"total_tokens": "Tokens gesamt",
"model_usage": "Nutzung nach Modell",
"model": "Modell",
"tokens": "Tokens",
"spend": "Ausgaben",
"usage_unavailable": "Nutzungsdaten nicht verfuegbar",
"loading_usage": "Nutzungsdaten werden geladen...",
"prompt_tokens": "Prompt-Tokens",
"completion_tokens": "Antwort-Tokens",
"pricing_title": "Preise",
"pricing_subtitle": "Waehlen Sie den passenden Plan fuer Ihre Organisation"
},
@@ -214,7 +225,13 @@
"documentation": "Dokumentation",
"api_reference": "API-Referenz",
"support": "Support",
"copyright": "2026 CERTifAI. Alle Rechte vorbehalten."
"copyright": "2026 CERTifAI. Alle Rechte vorbehalten.",
"pill_gdpr": "DSGVO-Nativ",
"pill_self_hosted": "Selbst gehostet",
"pill_eu": "EU-Souveraen",
"preview_models": "Aktive Modelle",
"preview_tokens": "Tokens / Monat",
"preview_spend": "Gesamtausgaben"
},
"article": {
"read_original": "Originalartikel lesen",

View File

@@ -46,7 +46,8 @@
"agents": "Agents",
"flow": "Flow",
"analytics": "Analytics",
"pricing": "Pricing"
"pricing": "Pricing",
"compliance": "Compliance"
},
"auth": {
"redirecting_login": "Redirecting to login...",
@@ -58,15 +59,15 @@
"title": "Dashboard",
"subtitle": "AI news and updates",
"topic_placeholder": "Topic name...",
"ollama_settings": "Ollama Settings",
"settings_hint": "Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env",
"ollama_url": "Ollama URL",
"ollama_url_placeholder": "Uses OLLAMA_URL from .env",
"litellm_settings": "LiteLLM Settings",
"settings_hint": "Leave empty to use LITELLM_URL / LITELLM_MODEL from .env",
"litellm_url": "LiteLLM URL",
"litellm_url_placeholder": "Uses LITELLM_URL from .env",
"model": "Model",
"model_placeholder": "Uses OLLAMA_MODEL from .env",
"model_placeholder": "Uses LITELLM_MODEL from .env",
"searching": "Searching...",
"search_failed": "Search failed: {e}",
"ollama_status": "Ollama Status",
"litellm_status": "LiteLLM Status",
"trending": "Trending",
"recent_searches": "Recent Searches"
},
@@ -144,6 +145,16 @@
"email_address": "Email Address",
"email_placeholder": "colleague@company.com",
"send_invite": "Send Invite",
"total_spend": "Total Spend",
"total_tokens": "Total Tokens",
"model_usage": "Usage by Model",
"model": "Model",
"tokens": "Tokens",
"spend": "Spend",
"usage_unavailable": "Usage data unavailable",
"loading_usage": "Loading usage data...",
"prompt_tokens": "Prompt Tokens",
"completion_tokens": "Completion Tokens",
"pricing_title": "Pricing",
"pricing_subtitle": "Choose the plan that fits your organization"
},
@@ -214,7 +225,13 @@
"documentation": "Documentation",
"api_reference": "API Reference",
"support": "Support",
"copyright": "2026 CERTifAI. All rights reserved."
"copyright": "2026 CERTifAI. All rights reserved.",
"pill_gdpr": "GDPR Native",
"pill_self_hosted": "Self-Hosted",
"pill_eu": "EU Sovereign",
"preview_models": "Active Models",
"preview_tokens": "Tokens / Month",
"preview_spend": "Total Spend"
},
"article": {
"read_original": "Read original article",

View File

@@ -46,7 +46,8 @@
"agents": "Agentes",
"flow": "Flujo",
"analytics": "Estadisticas",
"pricing": "Precios"
"pricing": "Precios",
"compliance": "Cumplimiento"
},
"auth": {
"redirecting_login": "Redirigiendo al inicio de sesion...",
@@ -58,15 +59,15 @@
"title": "Panel de control",
"subtitle": "Noticias y actualizaciones de IA",
"topic_placeholder": "Nombre del tema...",
"ollama_settings": "Configuracion de Ollama",
"settings_hint": "Dejar vacio para usar OLLAMA_URL / OLLAMA_MODEL del archivo .env",
"ollama_url": "URL de Ollama",
"ollama_url_placeholder": "Usa OLLAMA_URL del archivo .env",
"litellm_settings": "Configuracion de LiteLLM",
"settings_hint": "Dejar vacio para usar LITELLM_URL / LITELLM_MODEL del archivo .env",
"litellm_url": "URL de LiteLLM",
"litellm_url_placeholder": "Usa LITELLM_URL del archivo .env",
"model": "Modelo",
"model_placeholder": "Usa OLLAMA_MODEL del archivo .env",
"model_placeholder": "Usa LITELLM_MODEL del archivo .env",
"searching": "Buscando...",
"search_failed": "La busqueda fallo: {e}",
"ollama_status": "Estado de Ollama",
"litellm_status": "Estado de LiteLLM",
"trending": "Tendencias",
"recent_searches": "Busquedas recientes"
},
@@ -144,6 +145,16 @@
"email_address": "Direccion de correo electronico",
"email_placeholder": "colega@empresa.com",
"send_invite": "Enviar invitacion",
"total_spend": "Gasto total",
"total_tokens": "Tokens totales",
"model_usage": "Uso por modelo",
"model": "Modelo",
"tokens": "Tokens",
"spend": "Gasto",
"usage_unavailable": "Datos de uso no disponibles",
"loading_usage": "Cargando datos de uso...",
"prompt_tokens": "Tokens de entrada",
"completion_tokens": "Tokens de respuesta",
"pricing_title": "Precios",
"pricing_subtitle": "Elija el plan que se adapte a su organizacion"
},
@@ -214,7 +225,13 @@
"documentation": "Documentacion",
"api_reference": "Referencia API",
"support": "Soporte",
"copyright": "2026 CERTifAI. Todos los derechos reservados."
"copyright": "2026 CERTifAI. Todos los derechos reservados.",
"pill_gdpr": "RGPD Nativo",
"pill_self_hosted": "Autoalojado",
"pill_eu": "Soberania UE",
"preview_models": "Modelos Activos",
"preview_tokens": "Tokens / Mes",
"preview_spend": "Gasto Total"
},
"article": {
"read_original": "Leer articulo original",

View File

@@ -46,7 +46,8 @@
"agents": "Agents",
"flow": "Flux",
"analytics": "Analytique",
"pricing": "Tarifs"
"pricing": "Tarifs",
"compliance": "Conformite"
},
"auth": {
"redirecting_login": "Redirection vers la connexion...",
@@ -58,15 +59,15 @@
"title": "Tableau de bord",
"subtitle": "Actualites et mises a jour IA",
"topic_placeholder": "Nom du sujet...",
"ollama_settings": "Parametres Ollama",
"settings_hint": "Laissez vide pour utiliser OLLAMA_URL / OLLAMA_MODEL du fichier .env",
"ollama_url": "URL Ollama",
"ollama_url_placeholder": "Utilise OLLAMA_URL du fichier .env",
"litellm_settings": "Parametres LiteLLM",
"settings_hint": "Laissez vide pour utiliser LITELLM_URL / LITELLM_MODEL du fichier .env",
"litellm_url": "URL LiteLLM",
"litellm_url_placeholder": "Utilise LITELLM_URL du fichier .env",
"model": "Modele",
"model_placeholder": "Utilise OLLAMA_MODEL du fichier .env",
"model_placeholder": "Utilise LITELLM_MODEL du fichier .env",
"searching": "Recherche en cours...",
"search_failed": "Echec de la recherche : {e}",
"ollama_status": "Statut Ollama",
"litellm_status": "Statut LiteLLM",
"trending": "Tendances",
"recent_searches": "Recherches recentes"
},
@@ -144,6 +145,16 @@
"email_address": "Adresse e-mail",
"email_placeholder": "collegue@entreprise.com",
"send_invite": "Envoyer l'invitation",
"total_spend": "Depenses totales",
"total_tokens": "Tokens totaux",
"model_usage": "Utilisation par modele",
"model": "Modele",
"tokens": "Tokens",
"spend": "Depenses",
"usage_unavailable": "Donnees d'utilisation indisponibles",
"loading_usage": "Chargement des donnees d'utilisation...",
"prompt_tokens": "Tokens d'entree",
"completion_tokens": "Tokens de reponse",
"pricing_title": "Tarifs",
"pricing_subtitle": "Choisissez le plan adapte a votre organisation"
},
@@ -214,7 +225,13 @@
"documentation": "Documentation",
"api_reference": "Reference API",
"support": "Support",
"copyright": "2026 CERTifAI. Tous droits reserves."
"copyright": "2026 CERTifAI. Tous droits reserves.",
"pill_gdpr": "RGPD Natif",
"pill_self_hosted": "Auto-heberge",
"pill_eu": "Souverainete UE",
"preview_models": "Modeles Actifs",
"preview_tokens": "Tokens / Mois",
"preview_spend": "Depenses Totales"
},
"article": {
"read_original": "Lire l'article original",

View File

@@ -46,7 +46,8 @@
"agents": "Agentes",
"flow": "Fluxo",
"analytics": "Analise",
"pricing": "Precos"
"pricing": "Precos",
"compliance": "Conformidade"
},
"auth": {
"redirecting_login": "A redirecionar para o inicio de sessao...",
@@ -58,15 +59,15 @@
"title": "Painel",
"subtitle": "Noticias e atualizacoes de IA",
"topic_placeholder": "Nome do topico...",
"ollama_settings": "Definicoes do Ollama",
"settings_hint": "Deixe vazio para usar OLLAMA_URL / OLLAMA_MODEL do .env",
"ollama_url": "URL do Ollama",
"ollama_url_placeholder": "Utiliza OLLAMA_URL do .env",
"litellm_settings": "Definicoes do LiteLLM",
"settings_hint": "Deixe vazio para usar LITELLM_URL / LITELLM_MODEL do .env",
"litellm_url": "URL do LiteLLM",
"litellm_url_placeholder": "Utiliza LITELLM_URL do .env",
"model": "Modelo",
"model_placeholder": "Utiliza OLLAMA_MODEL do .env",
"model_placeholder": "Utiliza LITELLM_MODEL do .env",
"searching": "A pesquisar...",
"search_failed": "A pesquisa falhou: {e}",
"ollama_status": "Estado do Ollama",
"litellm_status": "Estado do LiteLLM",
"trending": "Em destaque",
"recent_searches": "Pesquisas recentes"
},
@@ -144,6 +145,16 @@
"email_address": "Endereco de Email",
"email_placeholder": "colleague@company.com",
"send_invite": "Enviar Convite",
"total_spend": "Gasto total",
"total_tokens": "Tokens totais",
"model_usage": "Uso por modelo",
"model": "Modelo",
"tokens": "Tokens",
"spend": "Gasto",
"usage_unavailable": "Dados de uso indisponiveis",
"loading_usage": "Carregando dados de uso...",
"prompt_tokens": "Tokens de entrada",
"completion_tokens": "Tokens de resposta",
"pricing_title": "Precos",
"pricing_subtitle": "Escolha o plano adequado a sua organizacao"
},
@@ -214,7 +225,13 @@
"documentation": "Documentacao",
"api_reference": "Referencia API",
"support": "Suporte",
"copyright": "2026 CERTifAI. Todos os direitos reservados."
"copyright": "2026 CERTifAI. Todos os direitos reservados.",
"pill_gdpr": "RGPD Nativo",
"pill_self_hosted": "Auto-Alojado",
"pill_eu": "Soberania UE",
"preview_models": "Modelos Ativos",
"preview_tokens": "Tokens / Mes",
"preview_spend": "Gasto Total"
},
"article": {
"read_original": "Ler artigo original",

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
@@ -9,6 +9,15 @@
"Courier New", monospace;
--color-black: #000;
--spacing: 0.25rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
@@ -162,59 +171,6 @@
}
}
@layer utilities {
.diff {
@layer daisyui.l1.l2.l3 {
position: relative;
display: grid;
width: 100%;
overflow: hidden;
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);
}
&: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;
}
}
&: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;
}
}
&:has(.diff-item-1:focus) {
.diff-resizer {
min-width: 95cqi;
max-width: 95cqi;
}
}
}
}
}
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
@@ -1110,31 +1066,98 @@
}
}
}
.chat-bubble {
.range {
@layer daisyui.l1.l2.l3 {
position: relative;
display: block;
width: fit-content;
border-radius: var(--radius-field);
background-color: var(--color-base-300);
padding-inline: calc(0.25rem * 4);
padding-block: calc(0.25rem * 2);
color: var(--color-base-content);
grid-row-end: 3;
min-height: 2rem;
min-width: 2.5rem;
max-width: 90%;
&:before {
position: absolute;
bottom: calc(0.25rem * 0);
height: calc(0.25rem * 3);
width: calc(0.25rem * 3);
background-color: inherit;
content: "";
mask-repeat: no-repeat;
mask-image: var(--mask-chat);
mask-position: 0px -1px;
mask-size: 0.8125rem;
appearance: none;
webkit-appearance: none;
--range-thumb: var(--color-base-100);
--range-thumb-size: calc(var(--size-selector, 0.25rem) * 6);
--range-progress: currentColor;
--range-fill: 1;
--range-p: 0.25rem;
--range-bg: currentColor;
@supports (color: color-mix(in lab, red, red)) {
--range-bg: color-mix(in oklab, currentColor 10%, #0000);
}
cursor: pointer;
overflow: hidden;
background-color: transparent;
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
--radius-selector-max: calc(
var(--radius-selector) + var(--radius-selector) + var(--radius-selector)
);
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
border: none;
height: var(--range-thumb-size);
[dir="rtl"] & {
--range-dir: -1;
}
&:focus {
outline: none;
}
&:focus-visible {
outline: 2px solid;
outline-offset: 2px;
}
&::-webkit-slider-runnable-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
@media (forced-colors: active) {
&::-webkit-slider-runnable-track {
border: 1px solid;
}
}
@media (forced-colors: active) {
&::-moz-range-track {
border: 1px solid;
}
}
&::-webkit-slider-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: var(--range-thumb);
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
appearance: none;
webkit-appearance: none;
top: 50%;
color: var(--range-progress);
transform: translateY(-50%);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
}
}
&::-moz-range-track {
width: 100%;
background-color: var(--range-bg);
border-radius: var(--radius-selector);
height: calc(var(--range-thumb-size) * 0.5);
}
&::-moz-range-thumb {
position: relative;
box-sizing: border-box;
border-radius: calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));
background-color: currentColor;
height: var(--range-thumb-size);
width: var(--range-thumb-size);
border: var(--range-p) solid;
top: 50%;
color: var(--range-progress);
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
@supports (color: color-mix(in lab, red, red)) {
box-shadow: 0 -1px oklch(0% 0 0 / calc(var(--depth) * 0.1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir, 1) * -100cqw) - (var(--range-dir, 1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));
}
}
&:disabled {
cursor: not-allowed;
opacity: 30%;
}
}
}
@@ -1525,81 +1548,6 @@
padding: calc(0.25rem * 4);
}
}
.textarea {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid #0000;
min-height: calc(0.25rem * 20);
flex-shrink: 1;
appearance: none;
border-radius: var(--radius-field);
background-color: var(--color-base-100);
padding-block: calc(0.25rem * 2);
vertical-align: middle;
width: clamp(3rem, 20rem, 100%);
padding-inline-start: 0.75rem;
padding-inline-end: 0.75rem;
font-size: max(var(--font-size, 0.875rem), 0.875rem);
touch-action: manipulation;
border-color: var(--input-color);
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;
}
--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);
}
textarea {
appearance: none;
background-color: transparent;
border: none;
&:focus, &:focus-within {
--tw-outline-style: none;
outline-style: none;
@media (forced-colors: active) {
outline: 2px solid transparent;
outline-offset: 2px;
}
}
}
&: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;
}
@media (pointer: coarse) {
@supports (-webkit-touch-callout: none) {
&:focus, &:focus-within {
--font-size: 1rem;
}
}
}
&:has(> textarea[disabled]), &:is(:disabled, [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);
}
}
box-shadow: none;
}
&:has(> textarea[disabled]) > textarea[disabled] {
cursor: not-allowed;
}
}
}
.stack {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -1694,6 +1642,14 @@
}
}
}
.stat-value {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
white-space: nowrap;
font-size: 2rem;
font-weight: 800;
}
}
.container {
width: 100%;
@media (width >= 40rem) {
@@ -1879,6 +1835,23 @@
}
}
}
.stat {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
width: 100%;
column-gap: calc(0.25rem * 4);
padding-inline: calc(0.25rem * 6);
padding-block: calc(0.25rem * 4);
grid-template-columns: repeat(1, 1fr);
&:not(:last-child) {
border-inline-end: var(--border) dashed currentColor;
@supports (color: color-mix(in lab, red, red)) {
border-inline-end: var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);
}
border-block-end: none;
}
}
}
.chat {
@layer daisyui.l1.l2.l3 {
display: grid;
@@ -1897,6 +1870,9 @@
font-weight: 600;
}
}
.flex {
display: flex;
}
.grid {
display: grid;
}
@@ -1909,6 +1885,9 @@
.table {
display: table;
}
.border-collapse {
border-collapse: collapse;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -1930,13 +1909,12 @@
}
}
}
.badge-outline {
@layer daisyui.l1.l2 {
color: var(--badge-color);
--badge-bg: #0000;
background-image: none;
border-color: currentColor;
}
.flex-wrap {
flex-wrap: wrap;
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.glass {
border: none;
@@ -1955,10 +1933,6 @@
.lowercase {
text-transform: lowercase;
}
.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)) {
@@ -1986,6 +1960,15 @@
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
.backdrop-filter {
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.btn-outline {
@layer daisyui.l1 {
&:not( .btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn), :disabled, [disabled], .btn-disabled ) {
@@ -2400,7 +2383,7 @@
syntax: "*";
inherits: false;
}
@property --tw-outline-style {
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
@@ -2458,6 +2441,42 @@
syntax: "*";
inherits: false;
}
@property --tw-backdrop-blur {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-brightness {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-contrast {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-invert {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-opacity {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-saturate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-sepia {
syntax: "*";
inherits: false;
}
@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 {
@@ -2466,7 +2485,7 @@
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-outline-style: solid;
--tw-border-style: solid;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
@@ -2480,6 +2499,15 @@
--tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial;
--tw-backdrop-blur: initial;
--tw-backdrop-brightness: initial;
--tw-backdrop-contrast: initial;
--tw-backdrop-grayscale: initial;
--tw-backdrop-hue-rotate: initial;
--tw-backdrop-invert: initial;
--tw-backdrop-opacity: initial;
--tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial;
}
}
}

View File

@@ -0,0 +1,907 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 1: Nordic Frost</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700&family=Playfair+Display:wght@500;600;700;800&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 1: NORDIC FROST
========================================================================
Mood: Clean, minimal, premium, Scandinavian-inspired
Audience: Enterprise, banking, legal, healthcare
Palette: Cool whites, slate greys, muted teal accents
Fonts: Playfair Display (headings) + DM Sans (body)
Feel: Trustworthy, understated luxury, whisper-quiet confidence
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-primary: #f7f8fa;
--bg-secondary: #ffffff;
--bg-tertiary: #eef0f4;
--bg-dark: #1a1e2e;
--bg-dark-card: #232838;
--text-primary: #1a1e2e;
--text-secondary: #5a6178;
--text-muted: #8b92a8;
--text-inverse: #f0f1f5;
--accent: #3a8f8b;
--accent-light: #4aada8;
--accent-muted: rgba(58, 143, 139, 0.08);
--accent-border: rgba(58, 143, 139, 0.2);
--border: #e4e7ee;
--border-subtle: #eef0f4;
--shadow-sm: 0 1px 3px rgba(26, 30, 46, 0.04);
--shadow-md: 0 4px 16px rgba(26, 30, 46, 0.06);
--shadow-lg: 0 8px 32px rgba(26, 30, 46, 0.08);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
}
body {
font-family: 'DM Sans', sans-serif;
color: var(--text-primary);
background: var(--bg-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Playfair Display', serif;
font-weight: 600;
line-height: 1.2;
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
gap: 6px;
background: var(--bg-dark);
padding: 6px;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
}
.view-switcher button {
font-family: 'DM Sans', sans-serif;
font-size: 13px;
font-weight: 500;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
background: transparent;
color: rgba(255,255,255,0.5);
transition: all 0.2s;
}
.view-switcher button.active {
background: var(--accent);
color: #fff;
}
.view-switcher button:hover:not(.active) {
color: rgba(255,255,255,0.8);
}
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING PAGE ===== */
/* -- Navbar -- */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 64px;
background: rgba(255,255,255,0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Playfair Display', serif;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.nav-logo span { color: var(--accent); }
.nav-links {
display: flex;
gap: 36px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text-primary); }
.nav-cta {
display: flex;
gap: 12px;
align-items: center;
}
.btn-ghost {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 500;
padding: 10px 20px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.2s;
}
.btn-ghost:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.btn-primary {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 10px 24px;
border: none;
background: var(--accent);
color: #fff;
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.25s;
}
.btn-primary:hover { background: var(--accent-light); transform: translateY(-1px); box-shadow: var(--shadow-md); }
/* -- Hero -- */
.hero {
padding: 120px 64px 100px;
text-align: center;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -200px;
left: 50%;
transform: translateX(-50%);
width: 800px;
height: 800px;
background: radial-gradient(circle, rgba(58,143,139,0.06) 0%, transparent 70%);
pointer-events: none;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
background: var(--accent-muted);
border: 1px solid var(--accent-border);
border-radius: 100px;
font-size: 13px;
font-weight: 500;
color: var(--accent);
margin-bottom: 32px;
animation: fadeUp 0.6s ease;
}
.hero h1 {
font-size: 64px;
letter-spacing: -1.5px;
margin-bottom: 24px;
animation: fadeUp 0.6s ease 0.1s both;
}
.hero h1 em {
font-style: italic;
color: var(--accent);
}
.hero p {
font-size: 18px;
color: var(--text-secondary);
max-width: 560px;
margin: 0 auto 40px;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-actions {
display: flex;
gap: 16px;
justify-content: center;
animation: fadeUp 0.6s ease 0.3s both;
}
.btn-outline {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 12px 28px;
border: 1.5px solid var(--border);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.25s;
}
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-lg { padding: 14px 32px; font-size: 15px; }
/* -- Trust Bar -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 48px;
padding: 48px 64px;
border-top: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.trust-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--text-muted);
font-weight: 500;
}
.trust-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-muted);
border-radius: var(--radius-sm);
color: var(--accent);
font-size: 16px;
}
/* -- Features -- */
.features {
padding: 100px 64px;
}
.section-header {
text-align: center;
margin-bottom: 64px;
}
.section-header h2 {
font-size: 40px;
letter-spacing: -0.8px;
margin-bottom: 16px;
}
.section-header p {
font-size: 16px;
color: var(--text-secondary);
max-width: 480px;
margin: 0 auto;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
max-width: 1100px;
margin: 0 auto;
}
.feature-card {
padding: 32px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: all 0.3s ease;
}
.feature-card:hover {
border-color: var(--accent-border);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.feature-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-muted);
border-radius: var(--radius-md);
color: var(--accent);
font-size: 20px;
margin-bottom: 20px;
}
.feature-card h3 {
font-family: 'DM Sans', sans-serif;
font-size: 17px;
font-weight: 600;
margin-bottom: 10px;
}
.feature-card p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.7;
}
/* -- CTA Section -- */
.cta-section {
padding: 80px 64px;
text-align: center;
}
.cta-box {
max-width: 700px;
margin: 0 auto;
padding: 64px;
background: var(--bg-dark);
border-radius: var(--radius-xl);
color: var(--text-inverse);
}
.cta-box h2 {
font-size: 36px;
margin-bottom: 16px;
letter-spacing: -0.5px;
}
.cta-box p {
font-size: 16px;
color: rgba(240,241,245,0.6);
margin-bottom: 32px;
}
.btn-white {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 14px 32px;
border: none;
background: #fff;
color: var(--bg-dark);
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.25s;
}
.btn-white:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(255,255,255,0.15); }
/* -- Footer -- */
.landing-footer {
padding: 48px 64px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border-subtle);
font-size: 13px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--text-primary); }
/* ===== DASHBOARD PAGE ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-primary);
}
/* -- Sidebar -- */
.sidebar {
width: 260px;
min-width: 260px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 24px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-brand h2 {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.3px;
}
.sidebar-brand h2 span { color: var(--accent); }
.sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 20px;
border-bottom: 1px solid var(--border-subtle);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
}
.user-info { min-width: 0; }
.user-name { font-size: 14px; font-weight: 600; }
.user-email { font-size: 12px; color: var(--text-muted); }
.sidebar-nav {
flex: 1;
padding: 16px 12px;
}
.nav-section-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 12px 12px 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.nav-item.active {
background: var(--accent-muted);
color: var(--accent);
font-weight: 600;
}
.nav-item svg { width: 18px; height: 18px; opacity: 0.6; }
.nav-item.active svg { opacity: 1; }
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-subtle);
font-size: 12px;
color: var(--text-muted);
}
/* -- Main Content -- */
.main-content {
flex: 1;
padding: 40px 48px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 36px;
}
.page-title { font-size: 28px; letter-spacing: -0.5px; }
.page-subtitle { font-size: 14px; color: var(--text-muted); margin-top: 4px; font-family: 'DM Sans', sans-serif; }
/* -- Stats Row -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 36px;
}
.stat-card {
padding: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: all 0.2s;
}
.stat-card:hover { box-shadow: var(--shadow-sm); }
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.stat-value {
font-family: 'Playfair Display', serif;
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
.stat-change {
font-size: 12px;
font-weight: 500;
margin-top: 6px;
color: var(--accent);
}
/* -- Content Grid -- */
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 28px;
}
.card-title {
font-family: 'DM Sans', sans-serif;
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title .badge {
font-size: 11px;
font-weight: 600;
padding: 4px 10px;
background: var(--accent-muted);
color: var(--accent);
border-radius: 100px;
}
/* -- Table -- */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
text-align: left;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.data-table td {
font-size: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
}
.data-table tr:last-child td { border-bottom: none; }
.model-tag {
font-size: 12px;
font-weight: 500;
padding: 3px 10px;
background: var(--bg-tertiary);
border-radius: 100px;
color: var(--text-secondary);
}
/* -- Status Indicator -- */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
}
.status-dot.online { background: #3a8f8b; box-shadow: 0 0 6px rgba(58,143,139,0.4); }
.status-dot.offline { background: #c4c4c4; }
/* -- Members List -- */
.member-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border-subtle);
}
.member-item:last-child { border-bottom: none; }
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: #fff;
}
.member-name { font-size: 14px; font-weight: 500; }
.member-role { font-size: 12px; color: var(--text-muted); }
.member-role-badge {
margin-left: auto;
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
border-radius: 100px;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
/* ===== ANIMATIONS ===== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeUp 0.5s ease both; }
.fade-in-1 { animation-delay: 0.1s; }
.fade-in-2 { animation-delay: 0.2s; }
.fade-in-3 { animation-delay: 0.3s; }
.fade-in-4 { animation-delay: 0.4s; }
</style>
</head>
<body>
<!-- View Switcher -->
<div class="view-switcher">
<button class="active" onclick="showView('landing')">Landing</button>
<button onclick="showView('dashboard')">Dashboard</button>
</div>
<!-- ===== LANDING PAGE ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">CERT<span>if</span>AI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">How It Works</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Docs</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Get Started</button>
</div>
</nav>
<section class="hero">
<div class="hero-badge">&#9679; GDPR-Compliant &middot; 100% On-Premise</div>
<h1>Your AI infrastructure,<br><em>your rules.</em></h1>
<p>Self-hosted generative AI for companies that refuse to compromise on data sovereignty. Deploy, configure, and scale without leaving the EU.</p>
<div class="hero-actions">
<button class="btn-primary btn-lg">Start Free Trial</button>
<button class="btn-outline btn-lg">View Documentation</button>
</div>
</section>
<div class="trust-bar">
<div class="trust-item">
<div class="trust-icon">&#9745;</div>
100% On-Premise
</div>
<div class="trust-item">
<div class="trust-icon">&#9878;</div>
GDPR Compliant
</div>
<div class="trust-item">
<div class="trust-icon">&#9873;</div>
EU Data Residency
</div>
<div class="trust-item">
<div class="trust-icon">&#8709;</div>
Zero Third-Party Access
</div>
</div>
<section class="features">
<div class="section-header">
<h2>Built for sovereignty</h2>
<p>Every component designed to keep your intellectual property exactly where it belongs.</p>
</div>
<div class="features-grid">
<div class="feature-card fade-in fade-in-1">
<div class="feature-icon">&#9881;</div>
<h3>Self-Hosted Infrastructure</h3>
<p>Deploy on your hardware or private cloud. No data ever leaves your perimeter.</p>
</div>
<div class="feature-card fade-in fade-in-2">
<div class="feature-icon">&#9741;</div>
<h3>Multi-LLM Gateway</h3>
<p>Route between providers through a single API. LiteLLM proxy with full observability.</p>
</div>
<div class="feature-card fade-in fade-in-3">
<div class="feature-icon">&#10070;</div>
<h3>Agent Orchestration</h3>
<p>Build and manage LangGraph agents with visual workflows and real-time monitoring.</p>
</div>
<div class="feature-card fade-in fade-in-4">
<div class="feature-icon">&#9211;</div>
<h3>SSO & Identity</h3>
<p>Keycloak-powered authentication. Connect your existing LDAP or SAML provider.</p>
</div>
<div class="feature-card fade-in fade-in-3">
<div class="feature-icon">&#9776;</div>
<h3>Full Observability</h3>
<p>Langfuse integration for traces, cost tracking, and prompt engineering analytics.</p>
</div>
<div class="feature-card fade-in fade-in-4">
<div class="feature-icon">&#10132;</div>
<h3>API-First Design</h3>
<p>RESTful endpoints, API key management, and MCP server support built in.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Ready to take control?</h2>
<p>Deploy your private AI infrastructure in under 30 minutes. No credit card required.</p>
<button class="btn-white">Start Your Free Trial</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI. All rights reserved.</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Terms</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD PAGE ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand">
<h2>CERT<span>if</span>AI</h2>
</div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div class="user-info">
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section-label">Main</div>
<a class="nav-item active" href="#">&#9632; Dashboard</a>
<a class="nav-item" href="#">&#9674; Providers</a>
<a class="nav-item" href="#">&#9993; Chat</a>
<div class="nav-section-label">Developer</div>
<a class="nav-item" href="#">&#9881; Agents</a>
<a class="nav-item" href="#">&#10697; Workflows</a>
<a class="nav-item" href="#">&#9776; Analytics</a>
<div class="nav-section-label">Organization</div>
<a class="nav-item" href="#">&#9733; Billing</a>
<a class="nav-item" href="#">&#10070; Members</a>
</nav>
<div class="sidebar-footer">CERTifAI v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">AI news and system overview</p>
</div>
<button class="btn-primary">New Search</button>
</div>
<div class="stats-row">
<div class="stat-card fade-in fade-in-1">
<div class="stat-label">Total Spend</div>
<div class="stat-value">$47.82</div>
<div class="stat-change">+12% this month</div>
</div>
<div class="stat-card fade-in fade-in-2">
<div class="stat-label">Total Tokens</div>
<div class="stat-value">847K</div>
<div class="stat-change">of 1M limit</div>
</div>
<div class="stat-card fade-in fade-in-3">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-change"><span class="status-dot online"></span>LiteLLM Online</div>
</div>
<div class="stat-card fade-in fade-in-4">
<div class="stat-label">Team Members</div>
<div class="stat-value">4/25</div>
<div class="stat-change">Seats used</div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-title">
Usage by Model
<span class="badge">This Month</span>
</div>
<table class="data-table">
<thead>
<tr>
<th>Model</th>
<th>Tokens</th>
<th>Spend</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="model-tag">Qwen3-Coder-30B</span></td>
<td>342K</td>
<td>$18.40</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Llama-3.1-70B</span></td>
<td>285K</td>
<td>$15.20</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Mistral-7B</span></td>
<td>120K</td>
<td>$8.42</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Gemma-2-9B</span></td>
<td>65K</td>
<td>$3.80</td>
<td><span class="status-dot online"></span>Active</td>
</tr>
<tr>
<td><span class="model-tag">Phi-3-mini</span></td>
<td>35K</td>
<td>$2.00</td>
<td><span class="status-dot offline"></span>Idle</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Team</div>
<div class="member-item">
<div class="member-avatar" style="background:#3a8f8b">MM</div>
<div>
<div class="member-name">Max Mustermann</div>
<div class="member-role">max@company.de</div>
</div>
<span class="member-role-badge">Admin</span>
</div>
<div class="member-item">
<div class="member-avatar" style="background:#6d85c6">EM</div>
<div>
<div class="member-name">Erika Musterfrau</div>
<div class="member-role">erika@company.de</div>
</div>
<span class="member-role-badge">Member</span>
</div>
<div class="member-item">
<div class="member-avatar" style="background:#8b6db8">JS</div>
<div>
<div class="member-name">Johann Schmidt</div>
<div class="member-role">johann@company.de</div>
</div>
<span class="member-role-badge">Member</span>
</div>
<div class="member-item">
<div class="member-avatar" style="background:#b8886d">AW</div>
<div>
<div class="member-name">Anna Weber</div>
<div class="member-role">anna@company.de</div>
</div>
<span class="member-role-badge">Viewer</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,942 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 2: Cyber Command</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 2: CYBER COMMAND
========================================================================
Mood: Dark, high-tech, cyberpunk-inspired command center
Audience: DevOps, security teams, tech-forward startups
Palette: Deep blacks, neon cyan/green accents, electric highlights
Fonts: Outfit (headings) + JetBrains Mono (body/data)
Feel: Powerful, technical, mission-critical, the Matrix meets Bloomberg Terminal
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-void: #050508;
--bg-primary: #0a0b10;
--bg-secondary: #0f1018;
--bg-card: #12141e;
--bg-surface: #181b28;
--bg-hover: #1a1d2c;
--text-primary: #d4dae8;
--text-secondary: #7a8499;
--text-muted: #484f64;
--text-bright: #f0f3fa;
--cyan: #00e5c8;
--cyan-dim: rgba(0, 229, 200, 0.12);
--cyan-border: rgba(0, 229, 200, 0.2);
--cyan-glow: rgba(0, 229, 200, 0.06);
--green: #34d399;
--red: #f87171;
--yellow: #fbbf24;
--border: #1a1d2c;
--border-bright: #252a3a;
--shadow-glow: 0 0 40px rgba(0, 229, 200, 0.05);
}
body {
font-family: 'JetBrains Mono', monospace;
color: var(--text-primary);
background: var(--bg-void);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Outfit', sans-serif;
font-weight: 700;
line-height: 1.15;
color: var(--text-bright);
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
gap: 2px;
background: var(--bg-card);
border: 1px solid var(--border-bright);
padding: 4px;
border-radius: 8px;
}
.view-switcher button {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
background: transparent;
color: var(--text-muted);
transition: all 0.2s;
}
.view-switcher button.active {
background: var(--cyan);
color: var(--bg-void);
}
.view-switcher button:hover:not(.active) { color: var(--text-primary); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING PAGE ===== */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 48px;
background: rgba(10, 11, 16, 0.85);
backdrop-filter: blur(24px);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Outfit', sans-serif;
font-size: 20px;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-bright);
}
.nav-logo .accent { color: var(--cyan); }
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--cyan); }
.nav-cta { display: flex; gap: 10px; }
.btn-ghost {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
padding: 10px 20px;
border: 1px solid var(--border-bright);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.btn-ghost:hover { border-color: var(--cyan-border); color: var(--cyan); }
.btn-primary {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
padding: 10px 24px;
border: 1px solid var(--cyan);
background: var(--cyan);
color: var(--bg-void);
cursor: pointer;
border-radius: 6px;
transition: all 0.25s;
}
.btn-primary:hover { box-shadow: 0 0 24px rgba(0,229,200,0.3); transform: translateY(-1px); }
/* -- Hero -- */
.hero {
padding: 140px 48px 120px;
text-align: center;
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
top: -100px;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--cyan-glow) 0%, transparent 70%);
pointer-events: none;
}
.hero::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 229, 200, 0.01) 2px,
rgba(0, 229, 200, 0.01) 4px
);
pointer-events: none;
}
.hero-tag {
display: inline-block;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--cyan);
padding: 8px 20px;
border: 1px solid var(--cyan-border);
border-radius: 4px;
margin-bottom: 36px;
background: var(--cyan-dim);
animation: fadeIn 0.5s ease;
}
.hero h1 {
font-size: 72px;
letter-spacing: -2px;
margin-bottom: 24px;
animation: fadeUp 0.6s ease 0.1s both;
}
.hero h1 .gradient {
background: linear-gradient(135deg, var(--cyan), #34d399, var(--cyan));
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s ease infinite;
}
.hero p {
font-size: 14px;
color: var(--text-secondary);
max-width: 520px;
margin: 0 auto 44px;
line-height: 1.8;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-actions {
display: flex;
gap: 16px;
justify-content: center;
animation: fadeUp 0.6s ease 0.3s both;
}
.btn-outline {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
padding: 12px 28px;
border: 1px solid var(--border-bright);
background: transparent;
color: var(--text-primary);
cursor: pointer;
border-radius: 6px;
transition: all 0.25s;
}
.btn-outline:hover { border-color: var(--cyan-border); color: var(--cyan); }
/* -- Terminal Preview -- */
.terminal-preview {
max-width: 700px;
margin: 60px auto 0;
background: var(--bg-card);
border: 1px solid var(--border-bright);
border-radius: 10px;
overflow: hidden;
animation: fadeUp 0.6s ease 0.4s both;
}
.terminal-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
.terminal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.terminal-dot.r { background: var(--red); opacity: 0.7; }
.terminal-dot.y { background: var(--yellow); opacity: 0.7; }
.terminal-dot.g { background: var(--green); opacity: 0.7; }
.terminal-title {
font-size: 11px;
color: var(--text-muted);
margin-left: 8px;
}
.terminal-body {
padding: 20px;
font-size: 13px;
line-height: 1.9;
color: var(--text-secondary);
}
.terminal-body .cmd { color: var(--cyan); }
.terminal-body .comment { color: var(--text-muted); }
.terminal-body .success { color: var(--green); }
/* -- Trust Bar -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 48px;
padding: 40px 48px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.trust-item {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 10px;
}
.trust-dot {
width: 6px;
height: 6px;
background: var(--cyan);
border-radius: 50%;
box-shadow: 0 0 8px rgba(0,229,200,0.4);
}
/* -- Features -- */
.features {
padding: 100px 48px;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 64px;
}
.section-header h2 {
font-size: 42px;
letter-spacing: -1px;
margin-bottom: 16px;
}
.section-header p {
font-size: 13px;
color: var(--text-secondary);
max-width: 480px;
margin: 0 auto;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.feature-card {
padding: 28px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.feature-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
opacity: 0;
transition: opacity 0.3s;
}
.feature-card:hover::before { opacity: 1; }
.feature-card:hover { border-color: var(--border-bright); box-shadow: var(--shadow-glow); }
.feature-num {
font-family: 'Outfit', sans-serif;
font-size: 11px;
font-weight: 700;
color: var(--cyan);
letter-spacing: 0.1em;
margin-bottom: 16px;
opacity: 0.6;
}
.feature-card h3 {
font-family: 'Outfit', sans-serif;
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
.feature-card p {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.7;
}
/* -- CTA -- */
.cta-section {
padding: 80px 48px;
text-align: center;
}
.cta-box {
max-width: 720px;
margin: 0 auto;
padding: 56px;
background: var(--bg-card);
border: 1px solid var(--cyan-border);
border-radius: 12px;
box-shadow: var(--shadow-glow);
}
.cta-box h2 { font-size: 32px; margin-bottom: 12px; }
.cta-box p { font-size: 13px; color: var(--text-secondary); margin-bottom: 32px; }
/* -- Footer -- */
.landing-footer {
padding: 40px 48px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--cyan); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-primary);
}
.sidebar {
width: 240px;
min-width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-brand h2 {
font-size: 16px;
font-weight: 800;
letter-spacing: 2px;
text-transform: uppercase;
}
.sidebar-brand .accent { color: var(--cyan); }
.sidebar-status {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-muted);
}
.pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 8px rgba(52,211,153,0.5);
animation: pulse 2s ease infinite;
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
}
.nav-group-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
padding: 12px 12px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
border-left: 2px solid transparent;
}
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.nav-item.active {
background: var(--cyan-dim);
color: var(--cyan);
border-left-color: var(--cyan);
}
.sidebar-user {
padding: 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar-sm {
width: 28px;
height: 28px;
border-radius: 4px;
background: var(--cyan-dim);
color: var(--cyan);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
}
.user-info-sm .name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.user-info-sm .role { font-size: 10px; color: var(--text-muted); }
/* -- Main -- */
.main-content {
flex: 1;
padding: 32px 40px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.page-title { font-size: 24px; letter-spacing: -0.5px; }
.page-subtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
font-family: 'JetBrains Mono', monospace;
}
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 28px;
}
.stat-card {
padding: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
position: relative;
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--cyan), transparent);
opacity: 0.3;
}
.stat-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.stat-value {
font-family: 'Outfit', sans-serif;
font-size: 26px;
font-weight: 700;
color: var(--text-bright);
}
.stat-change {
font-size: 11px;
margin-top: 6px;
color: var(--green);
}
/* -- Grid -- */
.content-grid {
display: grid;
grid-template-columns: 5fr 3fr;
gap: 16px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
}
.card-title {
font-family: 'Outfit', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-badge {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 4px 10px;
background: var(--cyan-dim);
color: var(--cyan);
border-radius: 4px;
border: 1px solid var(--cyan-border);
}
/* -- Table -- */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.data-table td {
font-size: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
.data-table tr:last-child td { border-bottom: none; }
.model-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
background: var(--bg-surface);
border: 1px solid var(--border-bright);
border-radius: 4px;
color: var(--text-primary);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.status-dot.on { background: var(--green); box-shadow: 0 0 6px rgba(52,211,153,0.5); }
.status-dot.off { background: var(--text-muted); }
/* -- Activity List -- */
.activity-item {
display: flex;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border);
align-items: flex-start;
}
.activity-item:last-child { border-bottom: none; }
.activity-time {
font-size: 10px;
color: var(--text-muted);
white-space: nowrap;
padding-top: 2px;
min-width: 48px;
}
.activity-text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.activity-text strong { color: var(--text-primary); font-weight: 600; }
.activity-text .hl { color: var(--cyan); }
/* ===== Animations ===== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes shimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">LANDING</button>
<button onclick="showView('dashboard')">DASHBOARD</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">CERT<span class="accent">IF</span>AI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">Architecture</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Docs</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Deploy Now</button>
</div>
</nav>
<section class="hero">
<div class="hero-tag">// SELF-HOSTED &middot; GDPR NATIVE &middot; ZERO TRUST</div>
<h1>Private AI<br><span class="gradient">Command Center</span></h1>
<p>Deploy sovereign AI infrastructure that never phones home. Route LLMs, orchestrate agents, track every token&mdash;all inside your perimeter.</p>
<div class="hero-actions">
<button class="btn-primary" style="padding:14px 32px">Deploy Now</button>
<button class="btn-outline" style="padding:14px 32px">Read the Docs</button>
</div>
<div class="terminal-preview">
<div class="terminal-bar">
<div class="terminal-dot r"></div>
<div class="terminal-dot y"></div>
<div class="terminal-dot g"></div>
<span class="terminal-title">certifai-cli &mdash; deploy</span>
</div>
<div class="terminal-body">
<span class="comment"># Deploy CERTifAI to your private cluster</span><br>
<span class="cmd">$</span> certifai deploy --region eu-west-1 --gpu a100<br>
<span class="success">&#10003;</span> Keycloak SSO configured<br>
<span class="success">&#10003;</span> LiteLLM proxy ready (5 models loaded)<br>
<span class="success">&#10003;</span> LangGraph agents online<br>
<span class="success">&#10003;</span> Langfuse observability active<br>
<br>
<span class="cmd">$</span> certifai status<br>
<span class="success">All systems operational.</span> Uptime: 99.97%
</div>
</div>
</section>
<div class="trust-bar">
<div class="trust-item"><div class="trust-dot"></div>100% ON-PREMISE</div>
<div class="trust-item"><div class="trust-dot"></div>GDPR ARTICLE 28</div>
<div class="trust-item"><div class="trust-dot"></div>EU DATA RESIDENCY</div>
<div class="trust-item"><div class="trust-dot"></div>ZERO THIRD-PARTY</div>
<div class="trust-item"><div class="trust-dot"></div>SOC 2 TYPE II</div>
</div>
<section class="features">
<div class="section-header">
<h2>Full-spectrum control</h2>
<p>From model routing to cost analytics, every layer is yours to command.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-num">01</div>
<h3>LLM Gateway</h3>
<p>LiteLLM proxy routes requests across providers. One API, full model flexibility, zero vendor lock-in.</p>
</div>
<div class="feature-card">
<div class="feature-num">02</div>
<h3>Agent Orchestration</h3>
<p>LangGraph + LangFlow for building, deploying, and monitoring autonomous agent workflows.</p>
</div>
<div class="feature-card">
<div class="feature-num">03</div>
<h3>Full Observability</h3>
<p>Langfuse tracing, cost attribution, prompt versioning. Know exactly what your AI is doing.</p>
</div>
<div class="feature-card">
<div class="feature-num">04</div>
<h3>Identity & Access</h3>
<p>Keycloak SSO with SAML, OIDC, LDAP. Fine-grained RBAC across all services.</p>
</div>
<div class="feature-card">
<div class="feature-num">05</div>
<h3>MCP Servers</h3>
<p>Model Context Protocol support for tool-augmented AI with secure function calling.</p>
</div>
<div class="feature-card">
<div class="feature-num">06</div>
<h3>API-First</h3>
<p>REST endpoints, API key rotation, webhook events. Integrate CERTifAI into your existing stack.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Your cluster. Your models. Your rules.</h2>
<p>Spin up a fully operational AI stack in under 30 minutes.</p>
<button class="btn-primary" style="padding:14px 36px; font-size:13px;">Start Deployment</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI GmbH</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Status</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand">
<h2>CERT<span class="accent">IF</span>AI</h2>
</div>
<div class="sidebar-status">
<div class="pulse-dot"></div>
ALL SYSTEMS OPERATIONAL
</div>
<nav class="sidebar-nav">
<div class="nav-group-label">Core</div>
<a class="nav-item active" href="#">&gt;_ Dashboard</a>
<a class="nav-item" href="#">&#9674; Providers</a>
<a class="nav-item" href="#">&#9993; Chat</a>
<div class="nav-group-label">Developer</div>
<a class="nav-item" href="#">&#10070; Agents</a>
<a class="nav-item" href="#">&#10697; Workflows</a>
<a class="nav-item" href="#">&#9776; Analytics</a>
<div class="nav-group-label">Organization</div>
<a class="nav-item" href="#">&#9733; Billing</a>
<a class="nav-item" href="#">&#9823; Members</a>
</nav>
<div class="sidebar-user">
<div class="user-avatar-sm">MM</div>
<div class="user-info-sm">
<div class="name">Max Mustermann</div>
<div class="role">Admin</div>
</div>
</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Command Center</h1>
<p class="page-subtitle">// system overview &middot; feb 2026</p>
</div>
<button class="btn-primary">+ New Search</button>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Total Spend</div>
<div class="stat-value">$47.82</div>
<div class="stat-change">+12.4% vs last month</div>
</div>
<div class="stat-card">
<div class="stat-label">Tokens Processed</div>
<div class="stat-value">847K</div>
<div class="stat-change">of 1M monthly cap</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-change"><span class="status-dot on"></span>LiteLLM proxy online</div>
</div>
<div class="stat-card">
<div class="stat-label">Team Seats</div>
<div class="stat-value">4/25</div>
<div class="stat-change">21 available</div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-title">
Model Usage
<span class="card-badge">LIVE</span>
</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td><span class="model-tag">Qwen3-Coder-30B</span></td><td>342K</td><td>$18.40</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Llama-3.1-70B</span></td><td>285K</td><td>$15.20</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Mistral-7B</span></td><td>120K</td><td>$8.42</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Gemma-2-9B</span></td><td>65K</td><td>$3.80</td><td><span class="status-dot on"></span>Active</td></tr>
<tr><td><span class="model-tag">Phi-3-mini</span></td><td>35K</td><td>$2.00</td><td><span class="status-dot off"></span>Idle</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Activity Log</div>
<div class="activity-item">
<span class="activity-time">2m ago</span>
<span class="activity-text"><strong>Erika M.</strong> queried <span class="hl">Qwen3-Coder</span></span>
</div>
<div class="activity-item">
<span class="activity-time">8m ago</span>
<span class="activity-text"><strong>Johann S.</strong> deployed agent <span class="hl">doc-parser</span></span>
</div>
<div class="activity-item">
<span class="activity-time">14m ago</span>
<span class="activity-text"><strong>Anna W.</strong> viewed analytics trace</span>
</div>
<div class="activity-item">
<span class="activity-time">31m ago</span>
<span class="activity-text"><strong>Max M.</strong> rotated API key for <span class="hl">LiteLLM</span></span>
</div>
<div class="activity-item">
<span class="activity-time">1h ago</span>
<span class="activity-text">System: <span class="hl">Llama-3.1-70B</span> model health check passed</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,963 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 3: Warm Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;0,9..144,800;1,9..144,400;1,9..144,500&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 3: WARM STUDIO
========================================================================
Mood: Warm, approachable, creative studio atmosphere
Audience: Creative agencies, education, non-technical leadership
Palette: Warm cream/sand base, terracotta/amber accents, soft shadows
Fonts: Fraunces (headings) + Plus Jakarta Sans (body)
Feel: Friendly, human, inviting, like a well-designed co-working space
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-warm: #faf6f1;
--bg-white: #ffffff;
--bg-cream: #f5efe7;
--bg-sand: #ede4d8;
--bg-dark: #2c2622;
--text-dark: #2c2622;
--text-body: #5c524a;
--text-muted: #9a8e84;
--text-light: #c4b8ac;
--text-inverse: #faf6f1;
--terracotta: #c4653a;
--terracotta-light: #d77a54;
--terracotta-muted: rgba(196, 101, 58, 0.08);
--terracotta-border: rgba(196, 101, 58, 0.18);
--amber: #c99a2e;
--amber-muted: rgba(201, 154, 46, 0.1);
--sage: #6b8a6b;
--sage-muted: rgba(107, 138, 107, 0.1);
--border: #e8dfd5;
--border-subtle: #f0e9e0;
--shadow-warm: 0 2px 12px rgba(44, 38, 34, 0.06);
--shadow-lg: 0 8px 32px rgba(44, 38, 34, 0.08);
--radius: 14px;
--radius-sm: 8px;
--radius-xl: 24px;
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
color: var(--text-body);
background: var(--bg-warm);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Fraunces', serif;
color: var(--text-dark);
line-height: 1.2;
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
gap: 4px;
background: var(--bg-dark);
padding: 5px;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
}
.view-switcher button {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 13px;
font-weight: 600;
border: none;
padding: 8px 18px;
border-radius: 8px;
cursor: pointer;
background: transparent;
color: rgba(250,246,241,0.4);
transition: all 0.2s;
}
.view-switcher button.active {
background: var(--terracotta);
color: #fff;
}
.view-switcher button:hover:not(.active) { color: rgba(250,246,241,0.7); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING ===== */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 56px;
background: rgba(250, 246, 241, 0.9);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border-subtle);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Fraunces', serif;
font-size: 24px;
font-weight: 700;
color: var(--text-dark);
}
.nav-logo em { font-style: italic; color: var(--terracotta); }
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-muted);
font-size: 15px;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text-dark); }
.nav-cta { display: flex; gap: 12px; }
.btn-ghost {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 10px 22px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.btn-ghost:hover { color: var(--text-dark); background: var(--bg-cream); }
.btn-primary {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 10px 26px;
border: none;
background: var(--terracotta);
color: #fff;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.25s;
}
.btn-primary:hover { background: var(--terracotta-light); transform: translateY(-1px); box-shadow: var(--shadow-warm); }
.btn-outline {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 12px 28px;
border: 1.5px solid var(--border);
background: var(--bg-white);
color: var(--text-dark);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.btn-outline:hover { border-color: var(--terracotta-border); }
/* -- Hero -- */
.hero {
padding: 100px 56px 80px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
align-items: center;
max-width: 1200px;
margin: 0 auto;
}
.hero-text { animation: fadeUp 0.6s ease; }
.hero-eyebrow {
font-size: 13px;
font-weight: 600;
color: var(--terracotta);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 16px;
}
.hero h1 {
font-size: 52px;
font-weight: 800;
letter-spacing: -1px;
margin-bottom: 20px;
}
.hero h1 em {
font-style: italic;
font-weight: 500;
color: var(--terracotta);
}
.hero p {
font-size: 17px;
line-height: 1.7;
color: var(--text-body);
margin-bottom: 36px;
max-width: 480px;
}
.hero-actions { display: flex; gap: 14px; }
.hero-visual {
position: relative;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-card {
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 32px;
box-shadow: var(--shadow-lg);
}
.hero-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.hero-card-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--sage);
}
.hero-card-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.hero-metric {
margin-bottom: 16px;
}
.hero-metric-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
}
.hero-metric-value {
font-family: 'Fraunces', serif;
font-size: 32px;
font-weight: 700;
color: var(--text-dark);
}
.hero-bar {
height: 8px;
background: var(--bg-cream);
border-radius: 100px;
overflow: hidden;
margin-bottom: 20px;
}
.hero-bar-fill {
height: 100%;
border-radius: 100px;
background: linear-gradient(90deg, var(--terracotta), var(--amber));
width: 68%;
}
.hero-models {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.hero-model-chip {
font-size: 12px;
font-weight: 500;
padding: 6px 14px;
background: var(--bg-cream);
border-radius: 100px;
color: var(--text-body);
}
.hero-floating-badge {
position: absolute;
top: -12px;
right: -12px;
background: var(--sage);
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 8px 16px;
border-radius: 100px;
box-shadow: var(--shadow-warm);
}
/* -- Trust -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 40px;
padding: 48px 56px;
background: var(--bg-white);
border-top: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border-subtle);
}
.trust-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
}
.trust-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--terracotta-muted);
border-radius: var(--radius-sm);
color: var(--terracotta);
font-size: 14px;
}
/* -- Features -- */
.features {
padding: 100px 56px;
max-width: 1100px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 60px;
}
.section-header h2 {
font-size: 38px;
font-weight: 700;
margin-bottom: 14px;
}
.section-header p {
font-size: 16px;
color: var(--text-muted);
max-width: 440px;
margin: 0 auto;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.feature-card {
padding: 28px;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all 0.3s;
}
.feature-card:hover {
box-shadow: var(--shadow-warm);
transform: translateY(-3px);
}
.feature-emoji {
font-size: 28px;
margin-bottom: 16px;
display: block;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-cream);
border-radius: 12px;
}
.feature-card h3 {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
.feature-card p {
font-size: 14px;
color: var(--text-muted);
line-height: 1.7;
}
/* -- CTA -- */
.cta-section {
padding: 60px 56px 80px;
}
.cta-box {
max-width: 800px;
margin: 0 auto;
padding: 56px;
background: var(--bg-dark);
border-radius: var(--radius-xl);
text-align: center;
color: var(--text-inverse);
position: relative;
overflow: hidden;
}
.cta-box::before {
content: '';
position: absolute;
top: -60px;
right: -60px;
width: 200px;
height: 200px;
background: var(--terracotta);
border-radius: 50%;
opacity: 0.15;
}
.cta-box h2 {
font-size: 34px;
color: var(--text-inverse);
margin-bottom: 12px;
position: relative;
}
.cta-box p {
font-size: 15px;
color: rgba(250,246,241,0.6);
margin-bottom: 28px;
position: relative;
}
.btn-warm {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 14px 32px;
border: none;
background: var(--terracotta);
color: #fff;
cursor: pointer;
border-radius: var(--radius-sm);
position: relative;
transition: all 0.25s;
}
.btn-warm:hover { background: var(--terracotta-light); }
.landing-footer {
padding: 40px 56px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--text-light);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-light); text-decoration: none; }
.footer-links a:hover { color: var(--text-dark); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-warm);
}
.sidebar {
width: 260px;
min-width: 260px;
background: var(--bg-white);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 24px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-brand h2 {
font-size: 22px;
font-weight: 700;
}
.sidebar-brand em { font-style: italic; color: var(--terracotta); }
.sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px;
border-bottom: 1px solid var(--border-subtle);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--terracotta), var(--amber));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.user-name { font-size: 14px; font-weight: 600; color: var(--text-dark); }
.user-email { font-size: 12px; color: var(--text-muted); }
.sidebar-nav {
flex: 1;
padding: 16px 12px;
}
.nav-section {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-light);
padding: 16px 16px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
color: var(--text-body);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover { background: var(--bg-cream); }
.nav-item.active {
background: var(--terracotta-muted);
color: var(--terracotta);
font-weight: 600;
}
.sidebar-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-subtle);
font-size: 12px;
color: var(--text-light);
}
.main-content {
flex: 1;
padding: 40px 48px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 36px;
}
.page-title {
font-size: 28px;
font-weight: 700;
}
.page-subtitle {
font-size: 15px;
color: var(--text-muted);
margin-top: 4px;
font-family: 'Plus Jakarta Sans', sans-serif;
}
/* -- Welcome Card -- */
.welcome-card {
background: linear-gradient(135deg, var(--bg-dark) 0%, #3d3530 100%);
border-radius: var(--radius-xl);
padding: 36px 40px;
margin-bottom: 28px;
color: var(--text-inverse);
display: flex;
justify-content: space-between;
align-items: center;
}
.welcome-text h2 {
font-size: 24px;
color: var(--text-inverse);
margin-bottom: 8px;
}
.welcome-text p {
font-size: 14px;
color: rgba(250,246,241,0.6);
}
.welcome-stats {
display: flex;
gap: 32px;
}
.welcome-stat-value {
font-family: 'Fraunces', serif;
font-size: 28px;
font-weight: 700;
color: var(--terracotta-light);
}
.welcome-stat-label {
font-size: 12px;
color: rgba(250,246,241,0.5);
margin-top: 2px;
}
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
padding: 24px;
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius);
transition: all 0.2s;
}
.stat-card:hover { box-shadow: var(--shadow-warm); }
.stat-label {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 6px;
}
.stat-value {
font-family: 'Fraunces', serif;
font-size: 28px;
font-weight: 700;
color: var(--text-dark);
}
.stat-badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 100px;
margin-top: 6px;
}
.stat-badge.up { background: var(--sage-muted); color: var(--sage); }
.stat-badge.neutral { background: var(--amber-muted); color: var(--amber); }
/* -- Content Grid -- */
.content-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 20px;
}
.card {
background: var(--bg-white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
}
.card-title {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 15px;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 18px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-size: 12px;
font-weight: 600;
color: var(--text-light);
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.data-table td {
font-size: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-body);
}
.data-table tr:last-child td { border-bottom: none; }
.model-chip {
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
background: var(--bg-cream);
border-radius: 100px;
color: var(--text-body);
}
/* -- Quick Actions -- */
.quick-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.quick-action {
padding: 18px;
background: var(--bg-cream);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: block;
}
.quick-action:hover { background: var(--bg-sand); }
.quick-action-icon {
font-size: 20px;
margin-bottom: 8px;
}
.quick-action-title {
font-size: 13px;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 2px;
}
.quick-action-desc {
font-size: 11px;
color: var(--text-muted);
}
/* ===== Animations ===== */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">Landing</button>
<button onclick="showView('dashboard')">Dashboard</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">Cert<em>if</em>AI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">How It Works</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Contact</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Get Started</button>
</div>
</nav>
<section class="hero">
<div class="hero-text">
<div class="hero-eyebrow">Private AI Infrastructure</div>
<h1>AI that stays <em>in-house</em></h1>
<p>A friendly, powerful dashboard for managing your self-hosted GenAI tools. No data leaves your servers. No compromises on capability.</p>
<div class="hero-actions">
<button class="btn-primary" style="padding:14px 32px; font-size:15px;">Start Free Trial</button>
<button class="btn-outline" style="padding:14px 32px; font-size:15px;">Watch Demo</button>
</div>
</div>
<div class="hero-visual">
<div class="hero-floating-badge">5 models live</div>
<div class="hero-card">
<div class="hero-card-header">
<div class="hero-card-dot"></div>
<span class="hero-card-label">System Overview</span>
</div>
<div class="hero-metric">
<div class="hero-metric-label">Token usage this month</div>
<div class="hero-metric-value">847,000</div>
</div>
<div class="hero-bar"><div class="hero-bar-fill"></div></div>
<div class="hero-models">
<span class="hero-model-chip">Qwen3-Coder</span>
<span class="hero-model-chip">Llama 3.1</span>
<span class="hero-model-chip">Mistral</span>
<span class="hero-model-chip">Gemma 2</span>
</div>
</div>
</div>
</section>
<div class="trust-bar">
<div class="trust-item"><div class="trust-icon">&#9745;</div>100% On-Premise</div>
<div class="trust-item"><div class="trust-icon">&#9878;</div>GDPR Compliant</div>
<div class="trust-item"><div class="trust-icon">&#9873;</div>EU Data Only</div>
<div class="trust-item"><div class="trust-icon">&#9711;</div>Zero Third-Party</div>
</div>
<section class="features">
<div class="section-header">
<h2>Everything you need, nothing you don't</h2>
<p>Simple tools for managing sophisticated AI infrastructure.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-emoji">&#9881;</div>
<h3>Self-Hosted Infrastructure</h3>
<p>Your hardware, your cloud, your rules. Deploy with a single command.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#9741;</div>
<h3>Multi-Model Gateway</h3>
<p>One API for all your models. Switch providers without changing a line of code.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#10070;</div>
<h3>Visual Agent Builder</h3>
<p>Drag-and-drop workflows for building AI agents that actually work.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#9211;</div>
<h3>Single Sign-On</h3>
<p>Connect your existing identity provider. One login for everything.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#9776;</div>
<h3>Usage Analytics</h3>
<p>Track every token, every dollar, every model. Full transparency.</p>
</div>
<div class="feature-card">
<div class="feature-emoji">&#10132;</div>
<h3>API & Integrations</h3>
<p>REST APIs, webhooks, and MCP server support out of the box.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Let's get you set up</h2>
<p>No credit card. No sales call. Just your AI infrastructure, ready in minutes.</p>
<button class="btn-warm">Start Your Free Trial</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Contact</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand"><h2>Cert<em>if</em>AI</h2></div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div>
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">Main</div>
<a class="nav-item active">&#9632; Dashboard</a>
<a class="nav-item">&#9674; Providers</a>
<a class="nav-item">&#9993; Chat</a>
<div class="nav-section">Developer</div>
<a class="nav-item">&#9881; Agents</a>
<a class="nav-item">&#10697; Workflows</a>
<a class="nav-item">&#9776; Analytics</a>
<div class="nav-section">Organization</div>
<a class="nav-item">&#9733; Billing</a>
<a class="nav-item">&#10070; Members</a>
</nav>
<div class="sidebar-footer">v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Good morning, Max</h1>
<p class="page-subtitle">Here's what's happening with your AI stack today.</p>
</div>
<button class="btn-primary">New Search</button>
</div>
<div class="welcome-card">
<div class="welcome-text">
<h2>February Overview</h2>
<p>Your team used 847K tokens across 5 models this month.</p>
</div>
<div class="welcome-stats">
<div>
<div class="welcome-stat-value">$47.82</div>
<div class="welcome-stat-label">Total spend</div>
</div>
<div>
<div class="welcome-stat-value">4/25</div>
<div class="welcome-stat-label">Seats used</div>
</div>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<span class="stat-badge up">All healthy</span>
</div>
<div class="stat-card">
<div class="stat-label">Agents Running</div>
<div class="stat-value">3</div>
<span class="stat-badge neutral">2 idle</span>
</div>
<div class="stat-card">
<div class="stat-label">Billing Cycle Ends</div>
<div class="stat-value">Mar 1</div>
<span class="stat-badge up">3 days left</span>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-title">Usage by Model</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th></tr>
</thead>
<tbody>
<tr><td><span class="model-chip">Qwen3-Coder-30B</span></td><td>342K</td><td>$18.40</td></tr>
<tr><td><span class="model-chip">Llama-3.1-70B</span></td><td>285K</td><td>$15.20</td></tr>
<tr><td><span class="model-chip">Mistral-7B</span></td><td>120K</td><td>$8.42</td></tr>
<tr><td><span class="model-chip">Gemma-2-9B</span></td><td>65K</td><td>$3.80</td></tr>
<tr><td><span class="model-chip">Phi-3-mini</span></td><td>35K</td><td>$2.00</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-title">Quick Actions</div>
<div class="quick-actions">
<a class="quick-action">
<div class="quick-action-icon">&#9993;</div>
<div class="quick-action-title">Open Chat</div>
<div class="quick-action-desc">Ask your models anything</div>
</a>
<a class="quick-action">
<div class="quick-action-icon">&#9881;</div>
<div class="quick-action-title">Manage Agents</div>
<div class="quick-action-desc">View running agents</div>
</a>
<a class="quick-action">
<div class="quick-action-icon">&#9776;</div>
<div class="quick-action-title">View Analytics</div>
<div class="quick-action-desc">Langfuse dashboard</div>
</a>
<a class="quick-action">
<div class="quick-action-icon">&#10070;</div>
<div class="quick-action-title">Invite Team</div>
<div class="quick-action-desc">Add new members</div>
</a>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,963 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 4: Glass Aurora</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700;800&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;0,7..72,600;0,7..72,700;1,7..72,400&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 4: GLASS AURORA
========================================================================
Mood: Vibrant, modern, glassmorphic, bold gradients
Audience: SaaS-savvy buyers, modern enterprises, AI-native teams
Palette: Deep navy/purple base, aurora gradient accents, glass effects
Fonts: Sora (headings) + Literata (body)
Feel: Premium SaaS, forward-looking, rich, confident
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #0c0a1d;
--bg-primary: #100e24;
--bg-card: rgba(22, 19, 48, 0.7);
--bg-glass: rgba(255, 255, 255, 0.04);
--bg-glass-hover: rgba(255, 255, 255, 0.07);
--text-bright: #f4f0ff;
--text-primary: #cfc8e8;
--text-secondary: #8b82aa;
--text-muted: #5a5280;
--gradient-start: #6366f1;
--gradient-mid: #a855f7;
--gradient-end: #ec4899;
--aurora: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7, #ec4899);
--aurora-muted: linear-gradient(135deg, rgba(99,102,241,0.12), rgba(168,85,247,0.12));
--glass-border: rgba(255, 255, 255, 0.08);
--glass-border-bright: rgba(255, 255, 255, 0.12);
--green: #34d399;
--green-dim: rgba(52, 211, 153, 0.15);
--shadow-glow: 0 0 60px rgba(139, 92, 246, 0.08);
--radius: 16px;
--radius-sm: 10px;
--radius-xl: 24px;
}
body {
font-family: 'Literata', serif;
color: var(--text-primary);
background: var(--bg-deep);
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'Sora', sans-serif;
font-weight: 700;
line-height: 1.15;
color: var(--text-bright);
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
gap: 2px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
padding: 4px;
border-radius: 12px;
}
.view-switcher button {
font-family: 'Sora', sans-serif;
font-size: 12px;
font-weight: 600;
border: none;
padding: 8px 18px;
border-radius: 9px;
cursor: pointer;
background: transparent;
color: var(--text-muted);
transition: all 0.25s;
}
.view-switcher button.active {
background: var(--aurora);
color: #fff;
}
.view-switcher button:hover:not(.active) { color: var(--text-primary); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING ===== */
.landing-bg {
position: relative;
overflow: hidden;
}
.landing-bg::before {
content: '';
position: fixed;
top: -40%;
left: -20%;
width: 80%;
height: 80%;
background: radial-gradient(ellipse, rgba(99,102,241,0.12) 0%, transparent 60%);
pointer-events: none;
}
.landing-bg::after {
content: '';
position: fixed;
bottom: -30%;
right: -20%;
width: 70%;
height: 70%;
background: radial-gradient(ellipse, rgba(236,72,153,0.08) 0%, transparent 60%);
pointer-events: none;
}
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 56px;
background: rgba(16, 14, 36, 0.6);
backdrop-filter: blur(24px);
border-bottom: 1px solid var(--glass-border);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'Sora', sans-serif;
font-size: 22px;
font-weight: 800;
background: var(--aurora);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--text-secondary);
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover { color: var(--text-bright); }
.nav-cta { display: flex; gap: 10px; }
.btn-ghost {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
padding: 10px 22px;
border: 1px solid var(--glass-border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.2s;
}
.btn-ghost:hover { border-color: var(--glass-border-bright); color: var(--text-bright); }
.btn-primary {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 10px 24px;
border: none;
background: var(--aurora);
background-size: 200% 200%;
color: #fff;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.3s;
}
.btn-primary:hover { background-position: 100% 0; transform: translateY(-1px); box-shadow: 0 4px 24px rgba(139,92,246,0.3); }
/* -- Hero -- */
.hero {
padding: 120px 56px 100px;
text-align: center;
position: relative;
}
.hero-pills {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 32px;
animation: fadeUp 0.6s ease;
}
.pill {
font-family: 'Sora', sans-serif;
font-size: 12px;
font-weight: 500;
padding: 6px 16px;
background: var(--bg-glass);
border: 1px solid var(--glass-border);
border-radius: 100px;
color: var(--text-secondary);
backdrop-filter: blur(8px);
}
.pill.accent {
background: rgba(99,102,241,0.15);
border-color: rgba(99,102,241,0.3);
color: #a5b4fc;
}
.hero h1 {
font-size: 68px;
letter-spacing: -2px;
margin-bottom: 20px;
animation: fadeUp 0.6s ease 0.1s both;
}
.hero h1 .gradient-text {
background: var(--aurora);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
font-size: 17px;
color: var(--text-secondary);
max-width: 540px;
margin: 0 auto 40px;
animation: fadeUp 0.6s ease 0.2s both;
}
.hero-actions {
display: flex;
gap: 14px;
justify-content: center;
animation: fadeUp 0.6s ease 0.3s both;
}
.btn-lg { padding: 14px 36px; font-size: 14px; }
.btn-glass {
font-family: 'Sora', sans-serif;
font-size: 14px;
font-weight: 500;
padding: 14px 36px;
background: var(--bg-glass);
backdrop-filter: blur(8px);
border: 1px solid var(--glass-border);
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.25s;
}
.btn-glass:hover { background: var(--bg-glass-hover); border-color: var(--glass-border-bright); }
/* -- Glass Preview -- */
.preview-container {
max-width: 900px;
margin: 64px auto 0;
animation: fadeUp 0.8s ease 0.4s both;
}
.glass-preview {
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 32px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.preview-stat {
padding: 20px;
background: var(--bg-glass);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
text-align: center;
}
.preview-stat-value {
font-family: 'Sora', sans-serif;
font-size: 28px;
font-weight: 700;
color: var(--text-bright);
margin-bottom: 4px;
}
.preview-stat-label {
font-family: 'Sora', sans-serif;
font-size: 12px;
color: var(--text-muted);
}
/* -- Trust -- */
.trust-bar {
display: flex;
justify-content: center;
gap: 40px;
padding: 48px 56px;
border-top: 1px solid var(--glass-border);
}
.trust-item {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.trust-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--aurora);
}
/* -- Features -- */
.features {
padding: 100px 56px;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 60px;
}
.section-header h2 { font-size: 40px; letter-spacing: -1px; margin-bottom: 12px; }
.section-header p { font-size: 16px; color: var(--text-secondary); max-width: 450px; margin: 0 auto; }
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.feature-card {
padding: 28px;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.feature-card:hover {
border-color: var(--glass-border-bright);
box-shadow: var(--shadow-glow);
transform: translateY(-2px);
}
.feature-icon-bar {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--aurora);
margin-bottom: 18px;
}
.feature-card h3 {
font-size: 16px;
margin-bottom: 8px;
}
.feature-card p {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.7;
}
/* -- CTA -- */
.cta-section {
padding: 80px 56px;
text-align: center;
}
.cta-box {
max-width: 700px;
margin: 0 auto;
padding: 60px;
background: var(--bg-card);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
position: relative;
overflow: hidden;
}
.cta-box::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--aurora);
}
.cta-box h2 { font-size: 32px; margin-bottom: 12px; }
.cta-box p { font-size: 15px; color: var(--text-secondary); margin-bottom: 28px; }
.landing-footer {
padding: 40px 56px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--glass-border);
font-family: 'Sora', sans-serif;
font-size: 12px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--text-bright); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-deep);
}
.sidebar {
width: 256px;
min-width: 256px;
background: rgba(16, 14, 36, 0.8);
backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 22px 20px;
border-bottom: 1px solid var(--glass-border);
}
.sidebar-brand h2 {
font-size: 20px;
font-weight: 800;
background: var(--aurora);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--glass-border);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--aurora);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 700;
}
.user-name { font-family: 'Sora', sans-serif; font-size: 13px; font-weight: 600; color: var(--text-bright); }
.user-email { font-family: 'Sora', sans-serif; font-size: 11px; color: var(--text-muted); }
.sidebar-nav {
flex: 1;
padding: 12px 10px;
}
.nav-group {
font-family: 'Sora', sans-serif;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
padding: 14px 14px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
border-radius: var(--radius-sm);
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.nav-item:hover { background: var(--bg-glass-hover); color: var(--text-primary); }
.nav-item.active {
background: rgba(99,102,241,0.12);
color: #a5b4fc;
font-weight: 600;
}
.sidebar-footer {
padding: 14px 20px;
border-top: 1px solid var(--glass-border);
font-family: 'Sora', sans-serif;
font-size: 11px;
color: var(--text-muted);
}
.main-content {
flex: 1;
padding: 36px 44px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.page-title { font-size: 26px; letter-spacing: -0.5px; }
.page-subtitle { font-family: 'Sora', sans-serif; font-size: 13px; color: var(--text-muted); margin-top: 4px; }
/* -- Gradient Banner -- */
.gradient-banner {
background: var(--aurora);
border-radius: var(--radius-xl);
padding: 32px 36px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
overflow: hidden;
}
.gradient-banner::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
90deg,
transparent,
transparent 100px,
rgba(255,255,255,0.03) 100px,
rgba(255,255,255,0.03) 101px
);
}
.banner-text h3 { font-size: 20px; margin-bottom: 4px; position: relative; }
.banner-text p { font-size: 13px; color: rgba(255,255,255,0.7); position: relative; font-family: 'Sora', sans-serif; }
.banner-stats {
display: flex;
gap: 36px;
position: relative;
}
.banner-stat-value { font-family: 'Sora', sans-serif; font-size: 24px; font-weight: 700; color: #fff; }
.banner-stat-label { font-family: 'Sora', sans-serif; font-size: 11px; color: rgba(255,255,255,0.6); }
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.stat-card {
padding: 22px;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
transition: all 0.2s;
}
.stat-card:hover { border-color: var(--glass-border-bright); box-shadow: var(--shadow-glow); }
.stat-label {
font-family: 'Sora', sans-serif;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 8px;
}
.stat-value {
font-family: 'Sora', sans-serif;
font-size: 26px;
font-weight: 700;
color: var(--text-bright);
}
.stat-sub {
font-family: 'Sora', sans-serif;
font-size: 11px;
margin-top: 4px;
color: var(--green);
}
/* -- Grid -- */
.content-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 14px;
}
.card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
padding: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.card-title {
font-family: 'Sora', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-bright);
}
.card-badge {
font-family: 'Sora', sans-serif;
font-size: 10px;
font-weight: 600;
padding: 4px 12px;
background: var(--green-dim);
color: var(--green);
border-radius: 100px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-family: 'Sora', sans-serif;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--glass-border);
}
.data-table td {
font-size: 13px;
padding: 12px 0;
border-bottom: 1px solid var(--glass-border);
}
.data-table tr:last-child td { border-bottom: none; }
.model-tag {
font-family: 'Sora', sans-serif;
font-size: 11px;
font-weight: 500;
padding: 3px 10px;
background: var(--bg-glass);
border: 1px solid var(--glass-border);
border-radius: 6px;
}
.status-online {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
background: var(--green);
box-shadow: 0 0 6px rgba(52,211,153,0.5);
}
/* -- Service Status -- */
.service-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid var(--glass-border);
}
.service-item:last-child { border-bottom: none; }
.service-name {
font-family: 'Sora', sans-serif;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.service-url {
font-size: 11px;
color: var(--text-muted);
}
.service-status {
font-family: 'Sora', sans-serif;
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 100px;
}
.service-status.online {
background: var(--green-dim);
color: var(--green);
}
.service-status.offline {
background: rgba(248,113,113,0.12);
color: #f87171;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">Landing</button>
<button onclick="showView('dashboard')">Dashboard</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active landing-bg">
<nav class="landing-nav">
<div class="nav-logo">CERTifAI</div>
<ul class="nav-links">
<li><a href="#">Features</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Docs</a></li>
<li><a href="#">Blog</a></li>
</ul>
<div class="nav-cta">
<button class="btn-ghost">Sign In</button>
<button class="btn-primary">Get Started Free</button>
</div>
</nav>
<section class="hero">
<div class="hero-pills">
<span class="pill accent">GDPR Native</span>
<span class="pill">Self-Hosted</span>
<span class="pill">EU Sovereign</span>
</div>
<h1>Sovereign AI<br><span class="gradient-text">infrastructure</span></h1>
<p>The complete platform for deploying, managing, and scaling private generative AI. Your data never leaves your perimeter.</p>
<div class="hero-actions">
<button class="btn-primary btn-lg">Start Free Trial</button>
<button class="btn-glass">Live Demo</button>
</div>
<div class="preview-container">
<div class="glass-preview">
<div class="preview-stat">
<div class="preview-stat-value">5</div>
<div class="preview-stat-label">Active Models</div>
</div>
<div class="preview-stat">
<div class="preview-stat-value">847K</div>
<div class="preview-stat-label">Tokens / Month</div>
</div>
<div class="preview-stat">
<div class="preview-stat-value">$47.82</div>
<div class="preview-stat-label">Total Spend</div>
</div>
</div>
</div>
</section>
<div class="trust-bar">
<div class="trust-item"><div class="trust-dot"></div>100% On-Premise</div>
<div class="trust-item"><div class="trust-dot"></div>GDPR Compliant</div>
<div class="trust-item"><div class="trust-dot"></div>EU Data Residency</div>
<div class="trust-item"><div class="trust-dot"></div>Zero Third-Party</div>
</div>
<section class="features">
<div class="section-header">
<h2>Your AI, your way</h2>
<p>Every tool you need to run production AI without compromise.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>LLM Gateway</h3>
<p>Route between any model through a unified API. LiteLLM proxy with full cost tracking.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>Agent Platform</h3>
<p>Build and deploy LangGraph agents with visual workflows and real-time monitoring.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>Observability</h3>
<p>Langfuse integration for traces, prompt engineering, and cost attribution.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>Identity & SSO</h3>
<p>Keycloak-powered auth with SAML, OIDC, and LDAP. One login across services.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>MCP Servers</h3>
<p>Model Context Protocol for secure, tool-augmented AI with function calling.</p>
</div>
<div class="feature-card">
<div class="feature-icon-bar"></div>
<h3>API-First</h3>
<p>REST endpoints, API keys, webhooks. Plug CERTifAI into your existing stack.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<h2>Deploy in 30 minutes</h2>
<p>No credit card required. Full access to every feature.</p>
<button class="btn-primary btn-lg">Start Free Trial</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI GmbH</span>
<div class="footer-links">
<a href="#">Privacy</a>
<a href="#">Impressum</a>
<a href="#">Terms</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand"><h2>CERTifAI</h2></div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div>
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-group">Main</div>
<a class="nav-item active">&#9632; Dashboard</a>
<a class="nav-item">&#9674; Providers</a>
<a class="nav-item">&#9993; Chat</a>
<div class="nav-group">Developer</div>
<a class="nav-item">&#10070; Agents</a>
<a class="nav-item">&#10697; Workflows</a>
<a class="nav-item">&#9776; Analytics</a>
<div class="nav-group">Organization</div>
<a class="nav-item">&#9733; Billing</a>
<a class="nav-item">&#9823; Members</a>
</nav>
<div class="sidebar-footer">v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">AI infrastructure overview</p>
</div>
<button class="btn-primary">New Search</button>
</div>
<div class="gradient-banner">
<div class="banner-text">
<h3>February 2026</h3>
<p>Your infrastructure is healthy. All models are responding.</p>
</div>
<div class="banner-stats">
<div>
<div class="banner-stat-value">$47.82</div>
<div class="banner-stat-label">Total Spend</div>
</div>
<div>
<div class="banner-stat-value">847K</div>
<div class="banner-stat-label">Tokens Used</div>
</div>
</div>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-sub">All responding</div>
</div>
<div class="stat-card">
<div class="stat-label">Team Seats</div>
<div class="stat-value">4/25</div>
<div class="stat-sub">21 available</div>
</div>
<div class="stat-card">
<div class="stat-label">Running Agents</div>
<div class="stat-value">3</div>
<div class="stat-sub">via LangGraph</div>
</div>
<div class="stat-card">
<div class="stat-label">Cycle Ends</div>
<div class="stat-value">Mar 1</div>
<div class="stat-sub" style="color:var(--text-muted)">3 days left</div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-header">
<div class="card-title">Model Usage</div>
<span class="card-badge">This Month</span>
</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td><span class="model-tag">Qwen3-Coder-30B</span></td><td>342K</td><td>$18.40</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Llama-3.1-70B</span></td><td>285K</td><td>$15.20</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Mistral-7B</span></td><td>120K</td><td>$8.42</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Gemma-2-9B</span></td><td>65K</td><td>$3.80</td><td><span class="status-online"></span>Active</td></tr>
<tr><td><span class="model-tag">Phi-3-mini</span></td><td>35K</td><td>$2.00</td><td style="color:var(--text-muted)">Idle</td></tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Services</div>
</div>
<div class="service-item">
<div>
<div class="service-name">LiteLLM Proxy</div>
<div class="service-url">llm-dev.meghsakha.com</div>
</div>
<span class="service-status online">Online</span>
</div>
<div class="service-item">
<div>
<div class="service-name">LangGraph</div>
<div class="service-url">agents.internal</div>
</div>
<span class="service-status online">Online</span>
</div>
<div class="service-item">
<div>
<div class="service-name">Langfuse</div>
<div class="service-url">analytics.internal</div>
</div>
<span class="service-status online">Online</span>
</div>
<div class="service-item">
<div>
<div class="service-name">LangFlow</div>
<div class="service-url">--</div>
</div>
<span class="service-status offline">Not Configured</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,928 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CERTifAI - Template 5: Swiss Grid</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* ========================================================================
TEMPLATE 5: SWISS GRID
========================================================================
Mood: Structured, editorial, Swiss design / International Typographic Style
Audience: Government, defense, Mittelstand, compliance-heavy industries
Palette: High-contrast B&W with a single signal red accent
Fonts: IBM Plex Sans (body) + IBM Plex Mono (data/code)
Feel: Authoritative, engineered, precise, like a Braun product manual
======================================================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-white: #ffffff;
--bg-light: #f5f5f5;
--bg-warm: #fafafa;
--bg-dark: #111111;
--bg-darkgrey: #1a1a1a;
--text-black: #111111;
--text-dark: #333333;
--text-body: #555555;
--text-muted: #888888;
--text-light: #bbbbbb;
--text-inverse: #ffffff;
--red: #e63525;
--red-muted: rgba(230, 53, 37, 0.06);
--red-border: rgba(230, 53, 37, 0.15);
--green: #1a8754;
--green-muted: rgba(26, 135, 84, 0.08);
--border: #e5e5e5;
--border-dark: #d0d0d0;
--shadow: 0 1px 3px rgba(0,0,0,0.04);
}
body {
font-family: 'IBM Plex Sans', sans-serif;
color: var(--text-dark);
background: var(--bg-white);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 {
font-family: 'IBM Plex Sans', sans-serif;
font-weight: 600;
line-height: 1.2;
color: var(--text-black);
}
/* ===== View Switcher ===== */
.view-switcher {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
gap: 0;
background: var(--bg-dark);
padding: 3px;
border-radius: 6px;
}
.view-switcher button {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
background: transparent;
color: rgba(255,255,255,0.35);
transition: all 0.15s;
}
.view-switcher button.active {
background: var(--red);
color: #fff;
}
.view-switcher button:hover:not(.active) { color: rgba(255,255,255,0.7); }
.view { display: none; }
.view.active { display: block; }
/* ===== LANDING ===== */
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 56px;
height: 64px;
background: var(--bg-white);
border-bottom: 2px solid var(--text-black);
position: sticky;
top: 0;
z-index: 100;
}
.nav-logo {
font-family: 'IBM Plex Mono', monospace;
font-size: 18px;
font-weight: 700;
color: var(--text-black);
letter-spacing: -0.5px;
}
.nav-logo .red { color: var(--red); }
.nav-links {
display: flex;
gap: 32px;
list-style: none;
}
.nav-links a {
text-decoration: none;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.03em;
text-transform: uppercase;
transition: color 0.15s;
}
.nav-links a:hover { color: var(--text-black); }
.nav-cta { display: flex; gap: 0; }
.btn-dark {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 10px 28px;
border: 2px solid var(--text-black);
background: var(--text-black);
color: var(--text-inverse);
cursor: pointer;
transition: all 0.15s;
}
.btn-dark:hover { background: var(--red); border-color: var(--red); }
.btn-bordered {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 13px;
font-weight: 600;
padding: 10px 28px;
border: 2px solid var(--text-black);
background: transparent;
color: var(--text-black);
cursor: pointer;
transition: all 0.15s;
}
.btn-bordered:hover { background: var(--bg-light); }
/* -- Hero -- */
.hero {
padding: 100px 56px 80px;
display: grid;
grid-template-columns: 7fr 5fr;
gap: 80px;
max-width: 1200px;
margin: 0 auto;
border-bottom: 1px solid var(--border);
}
.hero-text {
animation: slideIn 0.5s ease;
}
.hero-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--red);
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.hero-label::before {
content: '';
width: 24px;
height: 2px;
background: var(--red);
}
.hero h1 {
font-size: 56px;
font-weight: 700;
letter-spacing: -2px;
margin-bottom: 24px;
line-height: 1.05;
}
.hero p {
font-size: 17px;
color: var(--text-body);
line-height: 1.7;
margin-bottom: 40px;
max-width: 500px;
}
.hero-actions { display: flex; gap: 0; }
.hero-actions .btn-dark { border-right: none; }
.hero-right {
display: flex;
flex-direction: column;
gap: 16px;
animation: slideIn 0.5s ease 0.15s both;
}
.hero-fact {
padding: 24px;
border: 1px solid var(--border);
background: var(--bg-warm);
}
.hero-fact-number {
font-family: 'IBM Plex Mono', monospace;
font-size: 36px;
font-weight: 700;
color: var(--text-black);
margin-bottom: 4px;
}
.hero-fact-text {
font-size: 14px;
color: var(--text-muted);
}
.hero-fact.accent {
background: var(--text-black);
border-color: var(--text-black);
}
.hero-fact.accent .hero-fact-number { color: var(--red); }
.hero-fact.accent .hero-fact-text { color: rgba(255,255,255,0.5); }
/* -- Principles Bar -- */
.principles {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-bottom: 1px solid var(--border);
}
.principle {
padding: 36px 32px;
border-right: 1px solid var(--border);
}
.principle:last-child { border-right: none; }
.principle-num {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 600;
color: var(--red);
margin-bottom: 12px;
letter-spacing: 0.05em;
}
.principle h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.principle p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
}
/* -- Features -- */
.features {
padding: 80px 56px;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
margin-bottom: 56px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
align-items: end;
}
.section-header h2 {
font-size: 36px;
letter-spacing: -1px;
}
.section-header p {
font-size: 15px;
color: var(--text-body);
line-height: 1.7;
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border: 1px solid var(--border);
}
.feature-cell {
padding: 32px;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
.feature-cell:nth-child(3n) { border-right: none; }
.feature-cell:nth-child(n+4) { border-bottom: none; }
.feature-cell:hover { background: var(--bg-light); }
.feature-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--red);
margin-bottom: 12px;
}
.feature-cell h3 {
font-size: 16px;
margin-bottom: 8px;
}
.feature-cell p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
}
/* -- CTA -- */
.cta-section {
padding: 0 56px 80px;
}
.cta-box {
max-width: 1200px;
margin: 0 auto;
padding: 64px;
background: var(--bg-dark);
display: grid;
grid-template-columns: 1fr auto;
gap: 40px;
align-items: center;
}
.cta-box h2 {
font-size: 32px;
color: var(--text-inverse);
letter-spacing: -0.5px;
}
.cta-box p {
font-size: 15px;
color: rgba(255,255,255,0.4);
margin-top: 8px;
}
.btn-red {
font-family: 'IBM Plex Sans', sans-serif;
font-size: 14px;
font-weight: 600;
padding: 14px 36px;
border: none;
background: var(--red);
color: #fff;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.btn-red:hover { opacity: 0.9; }
.landing-footer {
padding: 32px 56px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 2px solid var(--text-black);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
.footer-links { display: flex; gap: 24px; }
.footer-links a { color: var(--text-muted); text-decoration: none; }
.footer-links a:hover { color: var(--text-black); }
/* ===== DASHBOARD ===== */
.dashboard-layout {
display: flex;
min-height: 100vh;
background: var(--bg-light);
}
.sidebar {
width: 248px;
min-width: 248px;
background: var(--bg-dark);
display: flex;
flex-direction: column;
height: 100vh;
position: sticky;
top: 0;
}
.sidebar-brand {
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.sidebar-brand h2 {
font-family: 'IBM Plex Mono', monospace;
font-size: 16px;
font-weight: 700;
color: var(--text-inverse);
letter-spacing: -0.5px;
}
.sidebar-brand .red { color: var(--red); }
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.user-avatar {
width: 32px;
height: 32px;
background: var(--red);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
font-weight: 700;
}
.user-name { font-size: 13px; font-weight: 600; color: var(--text-inverse); }
.user-email { font-size: 11px; color: rgba(255,255,255,0.3); }
.sidebar-nav {
flex: 1;
padding: 12px 8px;
}
.nav-section {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255,255,255,0.2);
padding: 16px 14px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: rgba(255,255,255,0.4);
cursor: pointer;
transition: all 0.12s;
text-decoration: none;
border-left: 2px solid transparent;
}
.nav-item:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.03); }
.nav-item.active {
color: #fff;
border-left-color: var(--red);
background: rgba(255,255,255,0.05);
font-weight: 600;
}
.sidebar-footer {
padding: 14px 20px;
border-top: 1px solid rgba(255,255,255,0.08);
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
color: rgba(255,255,255,0.2);
}
.main-content {
flex: 1;
padding: 32px 40px;
min-width: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 2px solid var(--text-black);
}
.page-title { font-size: 24px; letter-spacing: -0.5px; }
.page-subtitle {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
/* -- Stats -- */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
margin-bottom: 28px;
border: 1px solid var(--border);
background: var(--bg-white);
}
.stat-cell {
padding: 24px;
border-right: 1px solid var(--border);
}
.stat-cell:last-child { border-right: none; }
.stat-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.stat-value {
font-family: 'IBM Plex Mono', monospace;
font-size: 28px;
font-weight: 700;
color: var(--text-black);
}
.stat-bar {
height: 3px;
background: var(--border);
margin-top: 12px;
}
.stat-bar-fill {
height: 100%;
background: var(--red);
}
/* -- Content -- */
.content-grid {
display: grid;
grid-template-columns: 5fr 3fr;
gap: 0;
}
.card {
background: var(--bg-white);
border: 1px solid var(--border);
padding: 24px;
}
.card + .card { border-left: none; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.card-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.card-badge {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
background: var(--red-muted);
color: var(--red);
border: 1px solid var(--red-border);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 8px 0;
border-bottom: 2px solid var(--text-black);
}
.data-table td {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
color: var(--text-dark);
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover { background: var(--bg-light); }
.model-mono {
font-weight: 600;
color: var(--text-black);
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.status-dot {
width: 6px;
height: 6px;
}
.status-dot.on { background: var(--green); }
.status-dot.off { background: var(--text-light); }
/* -- Member List -- */
.member-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.member-row:last-child { border-bottom: none; }
.member-initial {
width: 28px;
height: 28px;
background: var(--bg-dark);
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 700;
}
.member-name { font-size: 13px; font-weight: 600; color: var(--text-black); }
.member-email { font-size: 11px; color: var(--text-muted); }
.member-role {
margin-left: auto;
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
</style>
</head>
<body>
<div class="view-switcher">
<button class="active" onclick="showView('landing')">LANDING</button>
<button onclick="showView('dashboard')">DASHBOARD</button>
</div>
<!-- ===== LANDING ===== -->
<div id="landing" class="view active">
<nav class="landing-nav">
<div class="nav-logo">CERTIF<span class="red">AI</span></div>
<ul class="nav-links">
<li><a href="#">Principles</a></li>
<li><a href="#">Capabilities</a></li>
<li><a href="#">Pricing</a></li>
<li><a href="#">Documentation</a></li>
</ul>
<div class="nav-cta">
<button class="btn-bordered">Sign In</button>
<button class="btn-dark">Deploy Now</button>
</div>
</nav>
<section class="hero">
<div class="hero-text">
<div class="hero-label">Private AI Infrastructure</div>
<h1>AI that answers<br>only to you.</h1>
<p>Sovereign generative AI infrastructure for organizations that treat data protection as non-negotiable. Deploy on your terms, inside your perimeter.</p>
<div class="hero-actions">
<button class="btn-dark">Request Access</button>
<button class="btn-bordered">Read Documentation</button>
</div>
</div>
<div class="hero-right">
<div class="hero-fact">
<div class="hero-fact-number">100%</div>
<div class="hero-fact-text">On-premise deployment. No data egress.</div>
</div>
<div class="hero-fact accent">
<div class="hero-fact-number">GDPR</div>
<div class="hero-fact-text">Article 28 compliant by architecture.</div>
</div>
<div class="hero-fact">
<div class="hero-fact-number">&lt;30min</div>
<div class="hero-fact-text">Full deployment including SSO and models.</div>
</div>
</div>
</section>
<div class="principles">
<div class="principle">
<div class="principle-num">01</div>
<h3>Data Sovereignty</h3>
<p>Your data never leaves your infrastructure. No telemetry, no third-party calls.</p>
</div>
<div class="principle">
<div class="principle-num">02</div>
<h3>Full Control</h3>
<p>Choose your models, set your policies, define your access rules.</p>
</div>
<div class="principle">
<div class="principle-num">03</div>
<h3>Transparency</h3>
<p>Every token tracked, every cost attributed, every trace logged.</p>
</div>
<div class="principle">
<div class="principle-num">04</div>
<h3>EU-Native</h3>
<p>Designed in Germany for EU compliance. No US-dependent services.</p>
</div>
</div>
<section class="features">
<div class="section-header">
<h2>Capabilities</h2>
<p>A complete platform for operating AI infrastructure at enterprise scale. Every component self-hosted, every interface yours to configure.</p>
</div>
<div class="features-grid">
<div class="feature-cell">
<div class="feature-label">ROUTING</div>
<h3>LLM Gateway</h3>
<p>LiteLLM proxy for unified multi-provider access through a single endpoint.</p>
</div>
<div class="feature-cell">
<div class="feature-label">ORCHESTRATION</div>
<h3>Agent Platform</h3>
<p>LangGraph for autonomous agents. LangFlow for visual workflow design.</p>
</div>
<div class="feature-cell">
<div class="feature-label">OBSERVABILITY</div>
<h3>Analytics</h3>
<p>Langfuse integration for tracing, cost tracking, and prompt versioning.</p>
</div>
<div class="feature-cell">
<div class="feature-label">IDENTITY</div>
<h3>SSO & RBAC</h3>
<p>Keycloak with SAML, OIDC, LDAP. Fine-grained role-based access control.</p>
</div>
<div class="feature-cell">
<div class="feature-label">INTEGRATION</div>
<h3>MCP & APIs</h3>
<p>Model Context Protocol servers and REST APIs for tool-augmented AI.</p>
</div>
<div class="feature-cell">
<div class="feature-label">MANAGEMENT</div>
<h3>Admin Dashboard</h3>
<p>Billing, team management, usage monitoring, and system configuration.</p>
</div>
</div>
</section>
<section class="cta-section">
<div class="cta-box">
<div>
<h2>Ready to deploy?</h2>
<p>Full operational AI stack. Under 30 minutes. No external dependencies.</p>
</div>
<button class="btn-red">Request Access</button>
</div>
</section>
<footer class="landing-footer">
<span>&copy; 2026 CERTifAI GmbH. Handelsregister HRB XXXXX.</span>
<div class="footer-links">
<a href="#">Datenschutz</a>
<a href="#">Impressum</a>
<a href="#">AGB</a>
</div>
</footer>
</div>
<!-- ===== DASHBOARD ===== -->
<div id="dashboard" class="view">
<div class="dashboard-layout">
<aside class="sidebar">
<div class="sidebar-brand"><h2>CERTIF<span class="red">AI</span></h2></div>
<div class="sidebar-user">
<div class="user-avatar">MM</div>
<div>
<div class="user-name">Max Mustermann</div>
<div class="user-email">max@company.de</div>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">Core</div>
<a class="nav-item active">&#9632; Dashboard</a>
<a class="nav-item">&#9674; Providers</a>
<a class="nav-item">&#9993; Chat</a>
<div class="nav-section">Developer</div>
<a class="nav-item">&#10070; Agents</a>
<a class="nav-item">&#10697; Workflows</a>
<a class="nav-item">&#9776; Analytics</a>
<div class="nav-section">Organization</div>
<a class="nav-item">&#9733; Billing</a>
<a class="nav-item">&#9823; Members</a>
</nav>
<div class="sidebar-footer">CERTifAI v0.1.0</div>
</aside>
<main class="main-content">
<div class="page-header">
<div>
<h1 class="page-title">Dashboard</h1>
<p class="page-subtitle">System overview / February 2026</p>
</div>
<button class="btn-dark">+ New Search</button>
</div>
<div class="stats-row">
<div class="stat-cell">
<div class="stat-label">Total Spend</div>
<div class="stat-value">$47.82</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:48%"></div></div>
</div>
<div class="stat-cell">
<div class="stat-label">Tokens Used</div>
<div class="stat-value">847K</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:85%"></div></div>
</div>
<div class="stat-cell">
<div class="stat-label">Active Models</div>
<div class="stat-value">5</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:100%"></div></div>
</div>
<div class="stat-cell">
<div class="stat-label">Team Seats</div>
<div class="stat-value">4/25</div>
<div class="stat-bar"><div class="stat-bar-fill" style="width:16%"></div></div>
</div>
</div>
<div class="content-grid">
<div class="card">
<div class="card-header">
<div class="card-title">Model Usage</div>
<span class="card-badge">CURRENT MONTH</span>
</div>
<table class="data-table">
<thead>
<tr><th>Model</th><th>Tokens</th><th>Spend</th><th>Status</th></tr>
</thead>
<tbody>
<tr>
<td><span class="model-mono">Qwen3-Coder-30B</span></td>
<td>342,000</td>
<td>$18.40</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Llama-3.1-70B</span></td>
<td>285,000</td>
<td>$15.20</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Mistral-7B</span></td>
<td>120,000</td>
<td>$8.42</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Gemma-2-9B</span></td>
<td>65,000</td>
<td>$3.80</td>
<td><span class="status-indicator"><span class="status-dot on"></span>Active</span></td>
</tr>
<tr>
<td><span class="model-mono">Phi-3-mini</span></td>
<td>35,000</td>
<td>$2.00</td>
<td><span class="status-indicator"><span class="status-dot off"></span>Idle</span></td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">Team</div>
</div>
<div class="member-row">
<div class="member-initial">MM</div>
<div>
<div class="member-name">Max Mustermann</div>
<div class="member-email">max@company.de</div>
</div>
<span class="member-role">Admin</span>
</div>
<div class="member-row">
<div class="member-initial">EM</div>
<div>
<div class="member-name">Erika Musterfrau</div>
<div class="member-email">erika@company.de</div>
</div>
<span class="member-role">Member</span>
</div>
<div class="member-row">
<div class="member-initial">JS</div>
<div>
<div class="member-name">Johann Schmidt</div>
<div class="member-email">johann@company.de</div>
</div>
<span class="member-role">Member</span>
</div>
<div class="member-row">
<div class="member-initial">AW</div>
<div>
<div class="member-name">Anna Weber</div>
<div class="member-email">anna@company.de</div>
</div>
<span class="member-role">Viewer</span>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function showView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
document.getElementById(id).classList.add('active');
event.target.classList.add('active');
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -55,6 +55,8 @@ services:
mongo:
condition: service_started
environment:
# LiteLLM API key (used by librechat.yaml endpoint config)
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
# MongoDB (use localhost since we're on host network)
MONGO_URI: mongodb://root:example@localhost:27017/librechat?authSource=admin
DOMAIN_CLIENT: http://localhost:3080
@@ -70,7 +72,6 @@ services:
OPENID_CALLBACK_URL: /oauth/openid/callback
OPENID_SCOPE: openid profile email
OPENID_BUTTON_LABEL: Login with CERTifAI
OPENID_AUTH_EXTRA_PARAMS: prompt=none
# Disable local auth (SSO only)
ALLOW_EMAIL_LOGIN: "false"
ALLOW_REGISTRATION: "false"

View File

@@ -1,5 +1,5 @@
# CERTifAI LibreChat Configuration
# Ollama backend for self-hosted LLM inference.
# LiteLLM proxy for unified multi-provider LLM access.
version: 1.2.8
cache: true
@@ -19,22 +19,16 @@ interface:
endpoints:
custom:
- name: "Ollama"
apiKey: "ollama"
baseURL: "https://mac-mini-von-benjamin-2:11434/v1/"
- name: "LiteLLM"
apiKey: "${LITELLM_API_KEY}"
baseURL: "https://llm-dev.meghsakha.com/v1/"
models:
default:
- "llama3.1:8b"
- "qwen3:30b-a3b"
- "Qwen3-Coder-30B-A3B-Instruct"
fetch: true
titleConvo: true
titleModel: "current_model"
summarize: false
summaryModel: "current_model"
forcePrompt: false
modelDisplayLabel: "CERTifAI Ollama"
dropParams:
- stop
- user
- frequency_penalty
- presence_penalty
modelDisplayLabel: "CERTifAI LiteLLM"

View File

@@ -49,10 +49,10 @@ const MAIN_CSS: Asset = asset!("/assets/main.css");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
const MANIFEST: Asset = asset!("/assets/manifest.json");
/// Google Fonts URL for Inter (body) and Space Grotesk (headings).
/// Google Fonts URL for Literata (body) and Sora (headings).
const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\
family=Inter:wght@400;500;600&\
family=Space+Grotesk:wght@500;600;700&\
family=Sora:wght@300;400;500;600;700;800&\
family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,500;0,7..72,600;0,7..72,700;1,7..72,400&\
display=swap";
/// Root application component. Loads global assets and mounts the router.
@@ -85,14 +85,14 @@ pub fn App() -> Element {
src: "https://seggwat.com/static/widgets/v1/seggwat-feedback.js",
r#defer: true,
"data-project-key": "a04b8cf1-9177-42ce-8a7b-084f38b99799",
"data-button-color": "#6d85c6",
"data-button-color": "#8b5cf6",
"data-button-position": "right-side",
"data-enable-screenshots": "true",
}
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "manifest", href: MANIFEST }
document::Meta { name: "theme-color", content: "#4B3FE0" }
document::Meta { name: "theme-color", content: "#0c0a1d" }
document::Meta { name: "apple-mobile-web-app-capable", content: "yes" }
document::Meta {
name: "apple-mobile-web-app-status-bar-style",

View File

@@ -76,6 +76,7 @@ pub fn AppShell() -> Element {
name: info.name,
avatar_url: info.avatar_url,
librechat_url: info.librechat_url,
compliance_scanner_url: info.compliance_scanner_url,
class: sidebar_cls,
on_nav: move |_| mobile_menu_open.set(false),
}

View File

@@ -1,9 +1,9 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
use crate::infrastructure::litellm::{get_litellm_status, LitellmStatus};
/// Right sidebar for the dashboard, showing Ollama status, trending topics,
/// Right sidebar for the dashboard, showing LiteLLM status, trending topics,
/// and recent search history.
///
/// Appears when no article card is selected. Disappears when the user opens
@@ -11,13 +11,13 @@ use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus};
///
/// # Props
///
/// * `ollama_url` - Ollama instance URL for status polling
/// * `litellm_url` - LiteLLM proxy URL for status polling
/// * `trending` - Trending topic keywords extracted from recent news headlines
/// * `recent_searches` - Recent search topics stored in localStorage
/// * `on_topic_click` - Fires when a trending or recent topic is clicked
#[component]
pub fn DashboardSidebar(
ollama_url: String,
litellm_url: String,
trending: Vec<String>,
recent_searches: Vec<String>,
on_topic_click: EventHandler<String>,
@@ -25,26 +25,26 @@ pub fn DashboardSidebar(
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
// Fetch Ollama status once on mount.
// Fetch LiteLLM status once on mount.
// use_resource with no signal dependencies runs exactly once and
// won't re-fire on parent re-renders (unlike use_effect).
let url = ollama_url.clone();
let url = litellm_url.clone();
let status_resource = use_resource(move || {
let u = url.clone();
async move {
get_ollama_status(u).await.unwrap_or(OllamaStatus {
get_litellm_status(u).await.unwrap_or(LitellmStatus {
online: false,
models: Vec::new(),
})
}
});
let current_status: OllamaStatus =
let current_status: LitellmStatus =
status_resource
.read()
.as_ref()
.cloned()
.unwrap_or(OllamaStatus {
.unwrap_or(LitellmStatus {
online: false,
models: Vec::new(),
});
@@ -52,9 +52,9 @@ pub fn DashboardSidebar(
rsx! {
aside { class: "dashboard-sidebar",
// -- Ollama Status Section --
// -- LiteLLM Status Section --
div { class: "sidebar-section",
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.ollama_status\")}" }
h4 { class: "sidebar-section-title", "{t(l, \"dashboard.litellm_status\")}" }
div { class: "sidebar-status-row",
span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } }
span { class: "sidebar-status-label",

View File

@@ -112,12 +112,12 @@ pub fn mock_news() -> Vec<NewsCardModel> {
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(),
content: "Ollama now supports multi-GPU scheduling with automatic \
model sharding. Users can run models across multiple GPUs \
for improved inference performance."
title: "LiteLLM Adds Multi-Provider Routing".into(),
source: "LiteLLM".into(),
summary: "Route requests across multiple LLM providers with automatic fallback.".into(),
content: "LiteLLM now supports multi-provider routing with automatic \
fallback. Users can route requests across multiple providers \
for improved reliability and cost optimization."
.into(),
category: "Infrastructure".into(),
url: "#".into(),

View File

@@ -1,7 +1,7 @@
use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::{
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsGithub, BsGlobe2,
BsGrid, BsHouseDoor, BsMoonFill, BsSunFill,
BsGrid, BsHouseDoor, BsMoonFill, BsShieldCheck, BsSunFill,
};
use dioxus_free_icons::Icon;
@@ -44,13 +44,14 @@ pub fn Sidebar(
email: String,
avatar_url: String,
#[props(default = "http://localhost:3080".to_string())] librechat_url: String,
#[props(default)] compliance_scanner_url: String,
#[props(default = "sidebar".to_string())] class: String,
#[props(default)] on_nav: EventHandler<()>,
) -> Element {
let locale = use_context::<Signal<Locale>>();
let locale_val = *locale.read();
let nav_items: Vec<NavItem> = vec![
let mut nav_items: Vec<NavItem> = vec![
NavItem {
key: "dashboard",
label: t(locale_val, "nav.dashboard"),
@@ -84,6 +85,16 @@ pub fn Sidebar(
},
];
// Only show the compliance scanner link when a URL is configured.
if !compliance_scanner_url.is_empty() {
nav_items.push(NavItem {
key: "compliance",
label: t(locale_val, "nav.compliance"),
target: NavTarget::External(compliance_scanner_url.clone()),
icon: rsx! { Icon { icon: BsShieldCheck, width: 18, height: 18 } },
});
}
// Determine current path to highlight the active nav link.
let current_route = use_route::<Route>();
let logout_label = t(locale_val, "common.logout");

View File

@@ -35,6 +35,7 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
let langgraph_url = state.services.langgraph_url.clone();
let langflow_url = state.services.langflow_url.clone();
let langfuse_url = state.services.langfuse_url.clone();
let compliance_scanner_url = state.services.compliance_scanner_url.clone();
Ok(AuthInfo {
authenticated: true,
@@ -46,6 +47,7 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
langgraph_url,
langflow_url,
langfuse_url,
compliance_scanner_url,
})
}
None => Ok(AuthInfo::default()),

View File

@@ -134,7 +134,7 @@ pub async fn list_chat_sessions() -> Result<Vec<ChatSession>, ServerFnError> {
///
/// * `title` - Display title for the session
/// * `namespace` - Namespace string: `"General"` or `"News"`
/// * `provider` - LLM provider name (e.g. "ollama")
/// * `provider` - LLM provider name (e.g. "litellm")
/// * `model` - Model ID (e.g. "llama3.1:8b")
/// * `article_url` - Source article URL (only for `News` namespace, empty if none)
///
@@ -441,8 +441,8 @@ pub async fn chat_complete(
// Resolve provider URL and model
let (base_url, model) = resolve_provider_url(
&state.services.ollama_url,
&state.services.ollama_model,
&state.services.litellm_url,
&state.services.litellm_model,
&session.provider,
&session.model,
);
@@ -485,22 +485,22 @@ pub async fn chat_complete(
.ok_or_else(|| ServerFnError::new("empty LLM response"))
}
/// Resolve the base URL for a provider, falling back to Ollama defaults.
/// Resolve the base URL for a provider, falling back to LiteLLM defaults.
///
/// # Arguments
///
/// * `ollama_url` - Default Ollama base URL from config
/// * `ollama_model` - Default Ollama model from config
/// * `litellm_url` - Default LiteLLM base URL from config
/// * `litellm_model` - Default LiteLLM model from config
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
/// * `model` - Model ID (may be empty for Ollama default)
/// * `model` - Model ID (may be empty for LiteLLM default)
///
/// # Returns
///
/// A `(base_url, model)` tuple resolved for the given provider.
#[cfg(feature = "server")]
pub(crate) fn resolve_provider_url(
ollama_url: &str,
ollama_model: &str,
litellm_url: &str,
litellm_model: &str,
provider: &str,
model: &str,
) -> (String, String) {
@@ -511,11 +511,11 @@ pub(crate) fn resolve_provider_url(
format!("https://api-inference.huggingface.co/models/{}", model),
model.to_string(),
),
// Default to Ollama
// Default to LiteLLM
_ => (
ollama_url.to_string(),
litellm_url.to_string(),
if model.is_empty() {
ollama_model.to_string()
litellm_model.to_string()
} else {
model.to_string()
},
@@ -595,7 +595,7 @@ mod tests {
"_id": oid,
"user_sub": "u",
"title": "t",
"provider": "ollama",
"provider": "litellm",
"model": "m",
"created_at": "c",
"updated_at": "u",
@@ -612,7 +612,7 @@ mod tests {
"user_sub": "u",
"title": "t",
"namespace": "News",
"provider": "ollama",
"provider": "litellm",
"model": "m",
"created_at": "c",
"updated_at": "u",
@@ -684,13 +684,13 @@ mod tests {
// -- resolve_provider_url --
const TEST_OLLAMA_URL: &str = "http://localhost:11434";
const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
const TEST_LITELLM_URL: &str = "http://localhost:4000";
const TEST_LITELLM_MODEL: &str = "qwen3-32b";
#[test]
fn resolve_openai_returns_api_openai() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o");
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "openai", "gpt-4o");
assert_eq!(url, "https://api.openai.com");
assert_eq!(model, "gpt-4o");
}
@@ -698,8 +698,8 @@ mod tests {
#[test]
fn resolve_anthropic_returns_api_anthropic() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
TEST_LITELLM_URL,
TEST_LITELLM_MODEL,
"anthropic",
"claude-3-opus",
);
@@ -710,8 +710,8 @@ mod tests {
#[test]
fn resolve_huggingface_returns_model_url() {
let (url, model) = resolve_provider_url(
TEST_OLLAMA_URL,
TEST_OLLAMA_MODEL,
TEST_LITELLM_URL,
TEST_LITELLM_MODEL,
"huggingface",
"meta-llama/Llama-2-7b",
);
@@ -723,19 +723,19 @@ mod tests {
}
#[test]
fn resolve_unknown_defaults_to_ollama() {
fn resolve_unknown_defaults_to_litellm() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, "mistral:7b");
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", "qwen3-32b");
assert_eq!(url, TEST_LITELLM_URL);
assert_eq!(model, "qwen3-32b");
}
#[test]
fn resolve_empty_model_falls_back_to_server_default() {
let (url, model) =
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
assert_eq!(url, TEST_OLLAMA_URL);
assert_eq!(model, TEST_OLLAMA_MODEL);
resolve_provider_url(TEST_LITELLM_URL, TEST_LITELLM_MODEL, "litellm", "");
assert_eq!(url, TEST_LITELLM_URL);
assert_eq!(model, TEST_LITELLM_MODEL);
}
}
}

View File

@@ -141,13 +141,15 @@ impl SmtpConfig {
// ServiceUrls
// ---------------------------------------------------------------------------
/// URLs and credentials for external services (Ollama, SearXNG, S3, etc.).
/// URLs and credentials for external services (LiteLLM, SearXNG, S3, etc.).
#[derive(Debug)]
pub struct ServiceUrls {
/// Ollama LLM instance base URL.
pub ollama_url: String,
/// Default Ollama model to use.
pub ollama_model: String,
/// LiteLLM proxy base URL.
pub litellm_url: String,
/// Default LiteLLM model to use.
pub litellm_model: String,
/// LiteLLM API key for authenticated requests.
pub litellm_api_key: String,
/// SearXNG meta-search engine base URL.
pub searxng_url: String,
/// LangChain service URL.
@@ -166,6 +168,8 @@ pub struct ServiceUrls {
pub s3_access_key: String,
/// S3 secret key (wrapped for debug safety).
pub s3_secret_key: SecretString,
/// Compliance scanner URL (external tool opened in a new tab).
pub compliance_scanner_url: String,
}
impl ServiceUrls {
@@ -178,9 +182,10 @@ impl ServiceUrls {
/// Currently infallible but returns `Result` for consistency.
pub fn from_env() -> Result<Self, Error> {
Ok(Self {
ollama_url: std::env::var("OLLAMA_URL")
.unwrap_or_else(|_| "http://localhost:11434".into()),
ollama_model: std::env::var("OLLAMA_MODEL").unwrap_or_else(|_| "llama3.1:8b".into()),
litellm_url: std::env::var("LITELLM_URL")
.unwrap_or_else(|_| "http://localhost:4000".into()),
litellm_model: std::env::var("LITELLM_MODEL").unwrap_or_else(|_| "qwen3-32b".into()),
litellm_api_key: optional_env("LITELLM_API_KEY"),
searxng_url: std::env::var("SEARXNG_URL")
.unwrap_or_else(|_| "http://localhost:8888".into()),
langchain_url: optional_env("LANGCHAIN_URL"),
@@ -191,6 +196,7 @@ impl ServiceUrls {
s3_url: optional_env("S3_URL"),
s3_access_key: optional_env("S3_ACCESS_KEY"),
s3_secret_key: SecretString::from(optional_env("S3_SECRET_KEY")),
compliance_scanner_url: optional_env("COMPLIANCE_SCANNER_URL"),
})
}
}
@@ -231,7 +237,7 @@ impl StripeConfig {
/// Comma-separated list of enabled LLM provider identifiers.
///
/// For example: `LLM_PROVIDERS=ollama,openai,anthropic`
/// For example: `LLM_PROVIDERS=litellm,openai,anthropic`
#[derive(Debug)]
pub struct LlmProvidersConfig {
/// Parsed provider names.
@@ -331,36 +337,36 @@ mod tests {
#[test]
#[serial]
fn llm_providers_single() {
std::env::set_var("LLM_PROVIDERS", "ollama");
std::env::set_var("LLM_PROVIDERS", "litellm");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama"]);
assert_eq!(cfg.providers, vec!["litellm"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_multiple() {
std::env::set_var("LLM_PROVIDERS", "ollama,openai,anthropic");
std::env::set_var("LLM_PROVIDERS", "litellm,openai,anthropic");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai", "anthropic"]);
assert_eq!(cfg.providers, vec!["litellm", "openai", "anthropic"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_trims_whitespace() {
std::env::set_var("LLM_PROVIDERS", " ollama , openai ");
std::env::set_var("LLM_PROVIDERS", " litellm , openai ");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
#[test]
#[serial]
fn llm_providers_filters_empty_entries() {
std::env::set_var("LLM_PROVIDERS", "ollama,,openai,");
std::env::set_var("LLM_PROVIDERS", "litellm,,openai,");
let cfg = LlmProvidersConfig::from_env().unwrap();
assert_eq!(cfg.providers, vec!["ollama", "openai"]);
assert_eq!(cfg.providers, vec!["litellm", "openai"]);
std::env::remove_var("LLM_PROVIDERS");
}
@@ -370,18 +376,18 @@ mod tests {
#[test]
#[serial]
fn service_urls_default_ollama_url() {
std::env::remove_var("OLLAMA_URL");
fn service_urls_default_litellm_url() {
std::env::remove_var("LITELLM_URL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_url, "http://localhost:11434");
assert_eq!(svc.litellm_url, "http://localhost:4000");
}
#[test]
#[serial]
fn service_urls_default_ollama_model() {
std::env::remove_var("OLLAMA_MODEL");
fn service_urls_default_litellm_model() {
std::env::remove_var("LITELLM_MODEL");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_model, "llama3.1:8b");
assert_eq!(svc.litellm_model, "qwen3-32b");
}
#[test]
@@ -394,11 +400,11 @@ mod tests {
#[test]
#[serial]
fn service_urls_custom_ollama_url() {
std::env::set_var("OLLAMA_URL", "http://gpu-host:11434");
fn service_urls_custom_litellm_url() {
std::env::set_var("LITELLM_URL", "http://litellm-host:4000");
let svc = ServiceUrls::from_env().unwrap();
assert_eq!(svc.ollama_url, "http://gpu-host:11434");
std::env::remove_var("OLLAMA_URL");
assert_eq!(svc.litellm_url, "http://litellm-host:4000");
std::env::remove_var("LITELLM_URL");
}
#[test]

View File

@@ -0,0 +1,403 @@
#[cfg(feature = "server")]
use std::collections::HashMap;
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::LitellmUsageStats;
#[cfg(feature = "server")]
use crate::models::ModelUsage;
/// Status of a LiteLLM proxy instance, including connectivity and available models.
///
/// # Fields
///
/// * `online` - Whether the LiteLLM API responded successfully
/// * `models` - List of model IDs available through the proxy
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LitellmStatus {
pub online: bool,
pub models: Vec<String>,
}
/// Response from LiteLLM's `GET /v1/models` endpoint (OpenAI-compatible).
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ModelObject>,
}
/// A single model entry from the OpenAI-compatible models list.
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct ModelObject {
id: String,
}
/// Check the status of a LiteLLM proxy by querying its models endpoint.
///
/// Calls `GET <litellm_url>/v1/models` to list available models and determine
/// whether the instance is reachable. Sends the API key as a Bearer token
/// if configured.
///
/// # Arguments
///
/// * `litellm_url` - Base URL of the LiteLLM proxy (e.g. "http://localhost:4000")
///
/// # Returns
///
/// A `LitellmStatus` with `online: true` and model IDs if reachable,
/// or `online: false` with an empty model list on failure
///
/// # Errors
///
/// Returns `ServerFnError` only on serialization issues; network failures
/// are caught and returned as `online: false`
#[post("/api/litellm-status")]
pub async fn get_litellm_status(litellm_url: String) -> Result<LitellmStatus, ServerFnError> {
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
} else {
litellm_url
};
let api_key = state.services.litellm_api_key.clone();
let url = format!("{}/v1/models", base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
let mut request = client.get(&url);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = match request.send().await {
Ok(r) if r.status().is_success() => r,
_ => {
return Ok(LitellmStatus {
online: false,
models: Vec::new(),
});
}
};
let body: ModelsResponse = match resp.json().await {
Ok(b) => b,
Err(_) => {
return Ok(LitellmStatus {
online: true,
models: Vec::new(),
});
}
};
let models = body.data.into_iter().map(|m| m.id).collect();
Ok(LitellmStatus {
online: true,
models,
})
}
/// Response from LiteLLM's `GET /global/activity` endpoint.
///
/// Returns aggregate token counts and API request totals for a date range.
/// Available on the free tier (no Enterprise license needed).
#[cfg(feature = "server")]
#[derive(Debug, Deserialize)]
struct ActivityResponse {
/// Total tokens across all models in the date range
#[serde(default)]
sum_total_tokens: u64,
}
/// Per-model entry from `GET /global/activity/model`.
///
/// Each entry contains a model name and its aggregated token total.
#[cfg(feature = "server")]
#[derive(Debug, Deserialize)]
struct ActivityModelEntry {
/// Model identifier (may be empty for unattributed traffic)
#[serde(default)]
model: String,
/// Sum of tokens used by this model in the date range
#[serde(default)]
sum_total_tokens: u64,
}
/// Per-model spend entry from `GET /global/spend/models`.
///
/// Each entry maps a model name to its total spend in USD.
#[cfg(feature = "server")]
#[derive(Debug, Deserialize)]
struct SpendModelEntry {
/// Model identifier
#[serde(default)]
model: String,
/// Total spend in USD
#[serde(default)]
total_spend: f64,
}
/// Merge per-model token counts and spend data into `ModelUsage` entries.
///
/// Joins `activity_models` (tokens) and `spend_models` (spend) by model
/// name using a HashMap for O(n + m) merge. Entries with empty model
/// names are skipped.
///
/// # Arguments
///
/// * `activity_models` - Per-model token data from `/global/activity/model`
/// * `spend_models` - Per-model spend data from `/global/spend/models`
///
/// # Returns
///
/// Merged list sorted by total tokens descending
#[cfg(feature = "server")]
fn merge_model_data(
activity_models: Vec<ActivityModelEntry>,
spend_models: Vec<SpendModelEntry>,
) -> Vec<ModelUsage> {
let mut model_map: HashMap<String, ModelUsage> = HashMap::new();
for entry in activity_models {
if entry.model.is_empty() {
continue;
}
model_map
.entry(entry.model.clone())
.or_insert_with(|| ModelUsage {
model: entry.model,
..Default::default()
})
.total_tokens = entry.sum_total_tokens;
}
for entry in spend_models {
if entry.model.is_empty() {
continue;
}
model_map
.entry(entry.model.clone())
.or_insert_with(|| ModelUsage {
model: entry.model,
..Default::default()
})
.spend = entry.total_spend;
}
let mut result: Vec<ModelUsage> = model_map.into_values().collect();
result.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
result
}
/// Fetch aggregated usage statistics from LiteLLM's free-tier APIs.
///
/// Combines three endpoints to build a complete usage picture:
/// - `GET /global/activity` - total token counts
/// - `GET /global/activity/model` - per-model token breakdown
/// - `GET /global/spend/models` - per-model spend in USD
///
/// # Arguments
///
/// * `start_date` - Start of the reporting period in `YYYY-MM-DD` format
/// * `end_date` - End of the reporting period in `YYYY-MM-DD` format
///
/// # Returns
///
/// Aggregated usage stats; returns default (zeroed) stats on network
/// failure or permission errors
///
/// # Errors
///
/// Returns `ServerFnError` only on HTTP client construction failure
#[post("/api/litellm-usage")]
pub async fn get_litellm_usage(
start_date: String,
end_date: String,
) -> Result<LitellmUsageStats, ServerFnError> {
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = &state.services.litellm_url;
let api_key = &state.services.litellm_api_key;
if base_url.is_empty() {
return Ok(LitellmUsageStats::default());
}
let base = base_url.trim_end_matches('/');
let date_params = format!("start_date={start_date}&end_date={end_date}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
// Helper closure to build an authenticated GET request
let auth_get = |url: String| {
let mut req = client.get(url);
if !api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {api_key}"));
}
req
};
// Fire all three requests concurrently to minimise latency
let (activity_res, model_activity_res, model_spend_res) = tokio::join!(
auth_get(format!("{base}/global/activity?{date_params}")).send(),
auth_get(format!("{base}/global/activity/model?{date_params}")).send(),
auth_get(format!("{base}/global/spend/models?{date_params}")).send(),
);
// Parse total token count from /global/activity
let total_tokens = match activity_res {
Ok(r) if r.status().is_success() => r
.json::<ActivityResponse>()
.await
.map(|a| a.sum_total_tokens)
.unwrap_or(0),
_ => 0,
};
// Parse per-model token breakdown from /global/activity/model
let activity_models: Vec<ActivityModelEntry> = match model_activity_res {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => Vec::new(),
};
// Parse per-model spend from /global/spend/models
let spend_models: Vec<SpendModelEntry> = match model_spend_res {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
_ => Vec::new(),
};
let total_spend: f64 = spend_models.iter().map(|m| m.total_spend).sum();
let model_breakdown = merge_model_data(activity_models, spend_models);
Ok(LitellmUsageStats {
total_spend,
// Free-tier endpoints don't provide prompt/completion split;
// total_tokens comes from /global/activity.
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens,
model_breakdown,
})
}
#[cfg(all(test, feature = "server"))]
mod tests {
use super::*;
#[test]
fn merge_empty_inputs() {
let result = merge_model_data(Vec::new(), Vec::new());
assert!(result.is_empty());
}
#[test]
fn merge_activity_only() {
let activity = vec![ActivityModelEntry {
model: "gpt-4".into(),
sum_total_tokens: 1500,
}];
let result = merge_model_data(activity, Vec::new());
assert_eq!(result.len(), 1);
assert_eq!(result[0].model, "gpt-4");
assert_eq!(result[0].total_tokens, 1500);
assert_eq!(result[0].spend, 0.0);
}
#[test]
fn merge_spend_only() {
let spend = vec![SpendModelEntry {
model: "gpt-4".into(),
total_spend: 2.5,
}];
let result = merge_model_data(Vec::new(), spend);
assert_eq!(result.len(), 1);
assert_eq!(result[0].model, "gpt-4");
assert_eq!(result[0].spend, 2.5);
assert_eq!(result[0].total_tokens, 0);
}
#[test]
fn merge_joins_by_model_name() {
let activity = vec![
ActivityModelEntry {
model: "gpt-4".into(),
sum_total_tokens: 5000,
},
ActivityModelEntry {
model: "claude-3".into(),
sum_total_tokens: 3000,
},
];
let spend = vec![
SpendModelEntry {
model: "gpt-4".into(),
total_spend: 1.0,
},
SpendModelEntry {
model: "claude-3".into(),
total_spend: 0.5,
},
];
let result = merge_model_data(activity, spend);
assert_eq!(result.len(), 2);
// Sorted by tokens descending: gpt-4 (5000) before claude-3 (3000)
assert_eq!(result[0].model, "gpt-4");
assert_eq!(result[0].total_tokens, 5000);
assert_eq!(result[0].spend, 1.0);
assert_eq!(result[1].model, "claude-3");
assert_eq!(result[1].total_tokens, 3000);
assert_eq!(result[1].spend, 0.5);
}
#[test]
fn merge_skips_empty_model_names() {
let activity = vec![
ActivityModelEntry {
model: "".into(),
sum_total_tokens: 100,
},
ActivityModelEntry {
model: "gpt-4".into(),
sum_total_tokens: 500,
},
];
let spend = vec![SpendModelEntry {
model: "".into(),
total_spend: 0.01,
}];
let result = merge_model_data(activity, spend);
assert_eq!(result.len(), 1);
assert_eq!(result[0].model, "gpt-4");
}
#[test]
fn merge_unmatched_models_appear_in_both_directions() {
let activity = vec![ActivityModelEntry {
model: "tokens-only".into(),
sum_total_tokens: 1000,
}];
let spend = vec![SpendModelEntry {
model: "spend-only".into(),
total_spend: 0.5,
}];
let result = merge_model_data(activity, spend);
assert_eq!(result.len(), 2);
// tokens-only has 1000 tokens, spend-only has 0 tokens
assert_eq!(result[0].model, "tokens-only");
assert_eq!(result[0].total_tokens, 1000);
assert_eq!(result[1].model, "spend-only");
assert_eq!(result[1].spend, 0.5);
}
}

View File

@@ -4,23 +4,23 @@ use dioxus::prelude::*;
mod inner {
use serde::{Deserialize, Serialize};
/// A single message in the OpenAI-compatible chat format used by Ollama.
/// A single message in the OpenAI-compatible chat format used by LiteLLM.
#[derive(Serialize)]
pub(super) struct ChatMessage {
pub role: String,
pub content: String,
}
/// Request body for Ollama's OpenAI-compatible chat completions endpoint.
/// Request body for the OpenAI-compatible chat completions endpoint.
#[derive(Serialize)]
pub(super) struct OllamaChatRequest {
pub(super) struct ChatCompletionRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
/// Disable streaming so we get a single JSON response.
pub stream: bool,
}
/// A single choice in the Ollama chat completions response.
/// A single choice in the chat completions response.
#[derive(Deserialize)]
pub(super) struct ChatChoice {
pub message: ChatResponseMessage,
@@ -32,9 +32,9 @@ mod inner {
pub content: String,
}
/// Top-level response from Ollama's `/v1/chat/completions` endpoint.
/// Top-level response from the `/v1/chat/completions` endpoint.
#[derive(Deserialize)]
pub(super) struct OllamaChatResponse {
pub(super) struct ChatCompletionResponse {
pub choices: Vec<ChatChoice>,
}
@@ -157,7 +157,7 @@ mod inner {
}
}
/// Summarize an article using a local Ollama instance.
/// Summarize an article using a LiteLLM proxy.
///
/// First attempts to fetch the full article text from the provided URL.
/// If that fails (paywall, timeout, etc.), falls back to the search snippet.
@@ -167,8 +167,8 @@ mod inner {
///
/// * `snippet` - The search result snippet (fallback content)
/// * `article_url` - The original article URL to fetch full text from
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
/// * `model` - The Ollama model ID to use (e.g. "llama3.1:8b")
/// * `litellm_url` - Base URL of the LiteLLM proxy (e.g. "http://localhost:4000")
/// * `model` - The model ID to use (e.g. "qwen3-32b")
///
/// # Returns
///
@@ -176,36 +176,38 @@ mod inner {
///
/// # Errors
///
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
/// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails
#[post("/api/summarize")]
pub async fn summarize_article(
snippet: String,
article_url: String,
ollama_url: String,
litellm_url: String,
model: String,
) -> Result<String, ServerFnError> {
use inner::{fetch_article_text, ChatMessage, OllamaChatRequest, OllamaChatResponse};
use inner::{fetch_article_text, ChatCompletionRequest, ChatCompletionResponse, ChatMessage};
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
// Use caller-provided values or fall back to ServerState config
let base_url = if ollama_url.is_empty() {
state.services.ollama_url.clone()
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
} else {
ollama_url
litellm_url
};
let model = if model.is_empty() {
state.services.ollama_model.clone()
state.services.litellm_model.clone()
} else {
model
};
let api_key = state.services.litellm_api_key.clone();
// Try to fetch the full article; fall back to the search snippet
let article_text = fetch_article_text(&article_url).await.unwrap_or(snippet);
let request_body = OllamaChatRequest {
let request_body = ChatCompletionRequest {
model,
stream: false,
messages: vec![ChatMessage {
@@ -223,42 +225,48 @@ pub async fn summarize_article(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
let mut request = client
.post(&url)
.header("content-type", "application/json")
.json(&request_body)
.json(&request_body);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.send()
.await
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
.map_err(|e| ServerFnError::new(format!("LiteLLM request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!(
"Ollama returned {status}: {body}"
"LiteLLM returned {status}: {body}"
)));
}
let body: OllamaChatResponse = resp
let body: ChatCompletionResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?;
body.choices
.first()
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
.ok_or_else(|| ServerFnError::new("Empty response from LiteLLM"))
}
/// A lightweight chat message for the follow-up conversation.
/// Uses simple String role ("system"/"user"/"assistant") for Ollama compatibility.
/// Uses simple String role ("system"/"user"/"assistant") for OpenAI compatibility.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FollowUpMessage {
pub role: String,
pub content: String,
}
/// Send a follow-up question about an article using a local Ollama instance.
/// Send a follow-up question about an article using a LiteLLM proxy.
///
/// Accepts the full conversation history (system context + prior turns) and
/// returns the assistant's next response. The system message should contain
@@ -267,8 +275,8 @@ pub struct FollowUpMessage {
/// # Arguments
///
/// * `messages` - The conversation history including system context
/// * `ollama_url` - Base URL of the Ollama instance
/// * `model` - The Ollama model ID to use
/// * `litellm_url` - Base URL of the LiteLLM proxy
/// * `model` - The model ID to use
///
/// # Returns
///
@@ -276,30 +284,32 @@ pub struct FollowUpMessage {
///
/// # Errors
///
/// Returns `ServerFnError` if the Ollama request fails or response parsing fails
/// Returns `ServerFnError` if the LiteLLM request fails or response parsing fails
#[post("/api/chat")]
pub async fn chat_followup(
messages: Vec<FollowUpMessage>,
ollama_url: String,
litellm_url: String,
model: String,
) -> Result<String, ServerFnError> {
use inner::{ChatMessage, OllamaChatRequest, OllamaChatResponse};
use inner::{ChatCompletionRequest, ChatCompletionResponse, ChatMessage};
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = if ollama_url.is_empty() {
state.services.ollama_url.clone()
let base_url = if litellm_url.is_empty() {
state.services.litellm_url.clone()
} else {
ollama_url
litellm_url
};
let model = if model.is_empty() {
state.services.ollama_model.clone()
state.services.litellm_model.clone()
} else {
model
};
let api_key = state.services.litellm_api_key.clone();
// Convert FollowUpMessage to inner ChatMessage for the request
let chat_messages: Vec<ChatMessage> = messages
.into_iter()
@@ -309,7 +319,7 @@ pub async fn chat_followup(
})
.collect();
let request_body = OllamaChatRequest {
let request_body = ChatCompletionRequest {
model,
stream: false,
messages: chat_messages,
@@ -317,31 +327,37 @@ pub async fn chat_followup(
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let resp = client
let mut request = client
.post(&url)
.header("content-type", "application/json")
.json(&request_body)
.json(&request_body);
if !api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {api_key}"));
}
let resp = request
.send()
.await
.map_err(|e| ServerFnError::new(format!("Ollama request failed: {e}")))?;
.map_err(|e| ServerFnError::new(format!("LiteLLM request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ServerFnError::new(format!(
"Ollama returned {status}: {body}"
"LiteLLM returned {status}: {body}"
)));
}
let body: OllamaChatResponse = resp
let body: ChatCompletionResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(format!("Failed to parse Ollama response: {e}")))?;
.map_err(|e| ServerFnError::new(format!("Failed to parse LiteLLM response: {e}")))?;
body.choices
.first()
.map(|choice| choice.message.content.clone())
.ok_or_else(|| ServerFnError::new("Empty response from Ollama"))
.ok_or_else(|| ServerFnError::new("Empty response from LiteLLM"))
}
#[cfg(test)]

View File

@@ -3,8 +3,8 @@
pub mod auth_check;
pub mod chat;
pub mod langgraph;
pub mod litellm;
pub mod llm;
pub mod ollama;
pub mod searxng;
// Server-only modules (Axum handlers, state, configs, DB, etc.)

View File

@@ -1,92 +0,0 @@
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
/// Status of a local Ollama instance, including connectivity and loaded models.
///
/// # Fields
///
/// * `online` - Whether the Ollama API responded successfully
/// * `models` - List of model names currently available on the instance
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OllamaStatus {
pub online: bool,
pub models: Vec<String>,
}
/// Response from Ollama's `GET /api/tags` endpoint.
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct OllamaTagsResponse {
models: Vec<OllamaModel>,
}
/// A single model entry from Ollama's tags API.
#[cfg(feature = "server")]
#[derive(Deserialize)]
struct OllamaModel {
name: String,
}
/// Check the status of a local Ollama instance by querying its tags endpoint.
///
/// Calls `GET <ollama_url>/api/tags` to list available models and determine
/// whether the instance is reachable.
///
/// # Arguments
///
/// * `ollama_url` - Base URL of the Ollama instance (e.g. "http://localhost:11434")
///
/// # Returns
///
/// An `OllamaStatus` with `online: true` and model names if reachable,
/// or `online: false` with an empty model list on failure
///
/// # Errors
///
/// Returns `ServerFnError` only on serialization issues; network failures
/// are caught and returned as `online: false`
#[post("/api/ollama-status")]
pub async fn get_ollama_status(ollama_url: String) -> Result<OllamaStatus, ServerFnError> {
let state: crate::infrastructure::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let base_url = if ollama_url.is_empty() {
state.services.ollama_url.clone()
} else {
ollama_url
};
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| ServerFnError::new(format!("HTTP client error: {e}")))?;
let resp = match client.get(&url).send().await {
Ok(r) if r.status().is_success() => r,
_ => {
return Ok(OllamaStatus {
online: false,
models: Vec::new(),
});
}
};
let body: OllamaTagsResponse = match resp.json().await {
Ok(b) => b,
Err(_) => {
return Ok(OllamaStatus {
online: true,
models: Vec::new(),
});
}
};
let models = body.models.into_iter().map(|m| m.name).collect();
Ok(OllamaStatus {
online: true,
models,
})
}

View File

@@ -1,6 +1,6 @@
//! Unified LLM provider dispatch.
//!
//! Routes chat completion requests to Ollama, OpenAI, Anthropic, or
//! Routes chat completion requests to LiteLLM, OpenAI, Anthropic, or
//! HuggingFace based on the session's provider setting. All providers
//! except Anthropic use the OpenAI-compatible chat completions format.
@@ -20,11 +20,11 @@ pub struct ProviderMessage {
///
/// # Arguments
///
/// * `state` - Server state (for default Ollama URL/model)
/// * `provider` - Provider name (`"ollama"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `state` - Server state (for default LiteLLM URL/model)
/// * `provider` - Provider name (`"litellm"`, `"openai"`, `"anthropic"`, `"huggingface"`)
/// * `model` - Model ID
/// * `messages` - Conversation history
/// * `api_key` - API key (required for non-Ollama providers)
/// * `api_key` - API key (required for non-LiteLLM providers; LiteLLM uses server config)
/// * `stream` - Whether to request streaming
///
/// # Returns
@@ -123,11 +123,11 @@ pub async fn send_chat_request(
.send()
.await
}
// Default: Ollama (OpenAI-compatible endpoint)
// Default: LiteLLM proxy (OpenAI-compatible endpoint)
_ => {
let base_url = &state.services.ollama_url;
let base_url = &state.services.litellm_url;
let resolved_model = if model.is_empty() {
&state.services.ollama_model
&state.services.litellm_model
} else {
model
};
@@ -137,12 +137,15 @@ pub async fn send_chat_request(
"messages": messages,
"stream": stream,
});
client
let litellm_key = &state.services.litellm_api_key;
let mut request = client
.post(&url)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.json(&body);
if !litellm_key.is_empty() {
request = request.header("Authorization", format!("Bearer {litellm_key}"));
}
request.send().await
}
}
}

View File

@@ -45,7 +45,7 @@ pub struct ServerStateInner {
pub keycloak: &'static KeycloakConfig,
/// Outbound email settings.
pub smtp: &'static SmtpConfig,
/// URLs for Ollama, SearXNG, LangChain, S3, etc.
/// URLs for LiteLLM, SearXNG, LangChain, S3, etc.
pub services: &'static ServiceUrls,
/// Stripe billing keys.
pub stripe: &'static StripeConfig,

View File

@@ -60,8 +60,8 @@ pub struct Attachment {
/// * `user_sub` - Keycloak subject ID (session owner)
/// * `title` - Display title (auto-generated or user-renamed)
/// * `namespace` - Grouping for sidebar sections
/// * `provider` - LLM provider used (e.g. "ollama", "openai")
/// * `model` - Model ID used (e.g. "llama3.1:8b")
/// * `provider` - LLM provider used (e.g. "litellm", "openai")
/// * `model` - Model ID used (e.g. "qwen3-32b")
/// * `created_at` - ISO 8601 creation timestamp
/// * `updated_at` - ISO 8601 last-activity timestamp
/// * `article_url` - Source article URL (for News namespace sessions)
@@ -171,8 +171,8 @@ mod tests {
user_sub: "user-1".into(),
title: "Test Chat".into(),
namespace: ChatNamespace::General,
provider: "ollama".into(),
model: "llama3.1:8b".into(),
provider: "litellm".into(),
model: "qwen3-32b".into(),
created_at: "2025-01-01T00:00:00Z".into(),
updated_at: "2025-01-01T01:00:00Z".into(),
article_url: None,
@@ -189,7 +189,7 @@ mod tests {
"_id": "mongo-id",
"user_sub": "u1",
"title": "t",
"provider": "ollama",
"provider": "litellm",
"model": "m",
"created_at": "2025-01-01",
"updated_at": "2025-01-01"
@@ -205,7 +205,7 @@ mod tests {
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "ollama".into(),
provider: "litellm".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),
@@ -223,7 +223,7 @@ mod tests {
user_sub: "u1".into(),
title: "t".into(),
namespace: ChatNamespace::default(),
provider: "ollama".into(),
provider: "litellm".into(),
model: "m".into(),
created_at: "2025-01-01".into(),
updated_at: "2025-01-01".into(),

View File

@@ -83,6 +83,42 @@ pub struct BillingUsage {
pub billing_cycle_end: String,
}
/// Aggregated token usage statistics from LiteLLM's spend tracking API.
///
/// # Fields
///
/// * `total_spend` - Total cost in USD across all models
/// * `total_prompt_tokens` - Sum of prompt (input) tokens
/// * `total_completion_tokens` - Sum of completion (output) tokens
/// * `total_tokens` - Sum of all tokens (prompt + completion)
/// * `model_breakdown` - Per-model usage breakdown
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LitellmUsageStats {
pub total_spend: f64,
pub total_prompt_tokens: u64,
pub total_completion_tokens: u64,
pub total_tokens: u64,
pub model_breakdown: Vec<ModelUsage>,
}
/// Token and spend usage for a single LLM model.
///
/// # Fields
///
/// * `model` - Model identifier (e.g. "gpt-4", "claude-3-opus")
/// * `spend` - Cost in USD for this model
/// * `prompt_tokens` - Prompt (input) tokens consumed
/// * `completion_tokens` - Completion (output) tokens generated
/// * `total_tokens` - Total tokens (prompt + completion)
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelUsage {
pub model: String,
pub spend: f64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
/// Organisation-level settings stored in MongoDB.
///
/// These complement Keycloak's Organizations feature with
@@ -234,4 +270,82 @@ mod tests {
assert_eq!(record.seats_used, 0);
assert_eq!(record.tokens_used, 0);
}
#[test]
fn litellm_usage_stats_default() {
let stats = LitellmUsageStats::default();
assert_eq!(stats.total_spend, 0.0);
assert_eq!(stats.total_prompt_tokens, 0);
assert_eq!(stats.total_completion_tokens, 0);
assert_eq!(stats.total_tokens, 0);
assert!(stats.model_breakdown.is_empty());
}
#[test]
fn litellm_usage_stats_serde_round_trip() {
let stats = LitellmUsageStats {
total_spend: 12.34,
total_prompt_tokens: 50_000,
total_completion_tokens: 25_000,
total_tokens: 75_000,
model_breakdown: vec![
ModelUsage {
model: "gpt-4".into(),
spend: 10.0,
prompt_tokens: 40_000,
completion_tokens: 20_000,
total_tokens: 60_000,
},
ModelUsage {
model: "claude-3-opus".into(),
spend: 2.34,
prompt_tokens: 10_000,
completion_tokens: 5_000,
total_tokens: 15_000,
},
],
};
let json = serde_json::to_string(&stats).expect("serialize LitellmUsageStats");
let back: LitellmUsageStats =
serde_json::from_str(&json).expect("deserialize LitellmUsageStats");
assert_eq!(stats, back);
}
#[test]
fn model_usage_default() {
let usage = ModelUsage::default();
assert_eq!(usage.model, "");
assert_eq!(usage.spend, 0.0);
assert_eq!(usage.prompt_tokens, 0);
assert_eq!(usage.completion_tokens, 0);
assert_eq!(usage.total_tokens, 0);
}
#[test]
fn model_usage_serde_round_trip() {
let usage = ModelUsage {
model: "gpt-4-turbo".into(),
spend: 5.67,
prompt_tokens: 30_000,
completion_tokens: 15_000,
total_tokens: 45_000,
};
let json = serde_json::to_string(&usage).expect("serialize ModelUsage");
let back: ModelUsage = serde_json::from_str(&json).expect("deserialize ModelUsage");
assert_eq!(usage, back);
}
#[test]
fn litellm_usage_stats_empty_breakdown_round_trip() {
let stats = LitellmUsageStats {
total_spend: 0.0,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens: 0,
model_breakdown: Vec::new(),
};
let json = serde_json::to_string(&stats).expect("serialize empty stats");
let back: LitellmUsageStats = serde_json::from_str(&json).expect("deserialize empty stats");
assert_eq!(stats, back);
}
}

View File

@@ -3,8 +3,8 @@ use serde::{Deserialize, Serialize};
/// Supported LLM provider backends.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LlmProvider {
/// Self-hosted models via Ollama
Ollama,
/// LiteLLM proxy for unified model access
LiteLlm,
/// Hugging Face Inference API
HuggingFace,
/// OpenAI-compatible endpoints
@@ -17,7 +17,7 @@ impl LlmProvider {
/// Returns the display name for a provider.
pub fn label(&self) -> &'static str {
match self {
Self::Ollama => "Ollama",
Self::LiteLlm => "LiteLLM",
Self::HuggingFace => "Hugging Face",
Self::OpenAi => "OpenAI",
Self::Anthropic => "Anthropic",
@@ -29,7 +29,7 @@ impl LlmProvider {
///
/// # Fields
///
/// * `id` - Unique model identifier (e.g. "llama3.1:8b")
/// * `id` - Unique model identifier (e.g. "qwen3-32b")
/// * `name` - Human-readable display name
/// * `provider` - Which provider hosts this model
/// * `context_window` - Maximum context length in tokens
@@ -79,8 +79,8 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn llm_provider_label_ollama() {
assert_eq!(LlmProvider::Ollama.label(), "Ollama");
fn llm_provider_label_litellm() {
assert_eq!(LlmProvider::LiteLlm.label(), "LiteLLM");
}
#[test]
@@ -101,7 +101,7 @@ mod tests {
#[test]
fn llm_provider_serde_round_trip() {
for variant in [
LlmProvider::Ollama,
LlmProvider::LiteLlm,
LlmProvider::HuggingFace,
LlmProvider::OpenAi,
LlmProvider::Anthropic,
@@ -117,10 +117,10 @@ mod tests {
#[test]
fn model_entry_serde_round_trip() {
let entry = ModelEntry {
id: "llama3.1:8b".into(),
name: "Llama 3.1 8B".into(),
provider: LlmProvider::Ollama,
context_window: 8192,
id: "qwen3-32b".into(),
name: "Qwen3 32B".into(),
provider: LlmProvider::LiteLlm,
context_window: 32,
};
let json = serde_json::to_string(&entry).expect("serialize ModelEntry");
let back: ModelEntry = serde_json::from_str(&json).expect("deserialize ModelEntry");

View File

@@ -30,17 +30,19 @@ pub struct AuthInfo {
pub langflow_url: String,
/// Langfuse observability URL (empty if not configured)
pub langfuse_url: String,
/// Compliance scanner URL (empty if not configured)
pub compliance_scanner_url: String,
}
/// Per-user LLM provider configuration stored in MongoDB.
///
/// Controls which provider and model the user's chat sessions default
/// to, and stores API keys for non-Ollama providers.
/// to, and stores API keys for non-LiteLLM providers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UserProviderConfig {
/// Default provider name (e.g. "ollama", "openai")
/// Default provider name (e.g. "litellm", "openai")
pub default_provider: String,
/// Default model ID (e.g. "llama3.1:8b", "gpt-4o")
/// Default model ID (e.g. "qwen3-32b", "gpt-4o")
pub default_model: String,
/// OpenAI API key (empty if not configured)
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -51,8 +53,8 @@ pub struct UserProviderConfig {
/// HuggingFace API key
#[serde(default, skip_serializing_if = "Option::is_none")]
pub huggingface_api_key: Option<String>,
/// Custom Ollama URL override (empty = use server default)
pub ollama_url_override: String,
/// Custom LiteLLM URL override (empty = use server default)
pub litellm_url_override: String,
}
/// Per-user preferences stored in MongoDB.
@@ -66,10 +68,10 @@ pub struct UserPreferences {
pub org_id: String,
/// User-selected news/search topics
pub custom_topics: Vec<String>,
/// Per-user Ollama URL override (empty = use server default)
pub ollama_url_override: String,
/// Per-user Ollama model override (empty = use server default)
pub ollama_model_override: String,
/// Per-user LiteLLM URL override (empty = use server default)
pub litellm_url_override: String,
/// Per-user LiteLLM model override (empty = use server default)
pub litellm_model_override: String,
/// Recently searched queries for quick access
pub recent_searches: Vec<String>,
/// LLM provider configuration
@@ -100,6 +102,7 @@ mod tests {
assert_eq!(info.langgraph_url, "");
assert_eq!(info.langflow_url, "");
assert_eq!(info.langfuse_url, "");
assert_eq!(info.compliance_scanner_url, "");
}
#[test]
@@ -114,6 +117,7 @@ mod tests {
langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".into(),
compliance_scanner_url: "http://localhost:9090".into(),
};
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");
@@ -132,12 +136,12 @@ mod tests {
#[test]
fn user_provider_config_optional_keys_skip_none() {
let cfg = UserProviderConfig {
default_provider: "ollama".into(),
default_model: "llama3.1:8b".into(),
default_provider: "litellm".into(),
default_model: "qwen3-32b".into(),
openai_api_key: None,
anthropic_api_key: None,
huggingface_api_key: None,
ollama_url_override: String::new(),
litellm_url_override: String::new(),
};
let json = serde_json::to_string(&cfg).expect("serialize UserProviderConfig");
assert!(!json.contains("openai_api_key"));
@@ -153,7 +157,7 @@ mod tests {
openai_api_key: Some("sk-test".into()),
anthropic_api_key: Some("ak-test".into()),
huggingface_api_key: None,
ollama_url_override: "http://custom:11434".into(),
litellm_url_override: "http://custom:4000".into(),
};
let json = serde_json::to_string(&cfg).expect("serialize");
let back: UserProviderConfig = serde_json::from_str(&json).expect("deserialize");

View File

@@ -25,8 +25,8 @@ const DEFAULT_TOPICS: &[&str] = &[
///
/// State is persisted across sessions using localStorage:
/// - `certifai_topics`: custom user-defined search topics
/// - `certifai_ollama_url`: Ollama instance URL for summarization
/// - `certifai_ollama_model`: Ollama model ID for summarization
/// - `certifai_litellm_url`: LiteLLM proxy URL for summarization
/// - `certifai_litellm_model`: LiteLLM model ID for summarization
#[component]
pub fn DashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -34,11 +34,11 @@ pub fn DashboardPage() -> Element {
// Persistent state stored in localStorage
let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::<String>::new);
// Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL
// Default to empty so the server functions use LITELLM_URL / LITELLM_MODEL
// from .env. Only stores a non-empty value when the user explicitly saves
// an override via the Settings panel.
let mut ollama_url = use_persistent("certifai_ollama_url".to_string(), String::new);
let mut ollama_model = use_persistent("certifai_ollama_model".to_string(), String::new);
let mut litellm_url = use_persistent("certifai_litellm_url".to_string(), String::new);
let mut litellm_model = use_persistent("certifai_litellm_model".to_string(), String::new);
// Reactive signals for UI state
let mut active_topic = use_signal(|| "AI".to_string());
@@ -235,8 +235,8 @@ pub fn DashboardPage() -> Element {
onclick: move |_| {
let currently_shown = *show_settings.read();
if !currently_shown {
settings_url.set(ollama_url.read().clone());
settings_model.set(ollama_model.read().clone());
settings_url.set(litellm_url.read().clone());
settings_model.set(litellm_model.read().clone());
}
show_settings.set(!currently_shown);
},
@@ -247,16 +247,16 @@ pub fn DashboardPage() -> Element {
// Settings panel (collapsible)
if *show_settings.read() {
div { class: "settings-panel",
h4 { class: "settings-panel-title", "{t(l, \"dashboard.ollama_settings\")}" }
h4 { class: "settings-panel-title", "{t(l, \"dashboard.litellm_settings\")}" }
p { class: "settings-hint",
"{t(l, \"dashboard.settings_hint\")}"
}
div { class: "settings-field",
label { "{t(l, \"dashboard.ollama_url\")}" }
label { "{t(l, \"dashboard.litellm_url\")}" }
input {
class: "settings-input",
r#type: "text",
placeholder: "{t(l, \"dashboard.ollama_url_placeholder\")}",
placeholder: "{t(l, \"dashboard.litellm_url_placeholder\")}",
value: "{settings_url}",
oninput: move |e| settings_url.set(e.value()),
}
@@ -274,8 +274,8 @@ pub fn DashboardPage() -> Element {
button {
class: "btn btn-primary",
onclick: move |_| {
*ollama_url.write() = settings_url.read().trim().to_string();
*ollama_model.write() = settings_model.read().trim().to_string();
*litellm_url.write() = settings_url.read().trim().to_string();
*litellm_model.write() = settings_model.read().trim().to_string();
show_settings.set(false);
},
"{t(l, \"common.save\")}"
@@ -320,14 +320,14 @@ pub fn DashboardPage() -> Element {
news_session_id.set(None);
let oll_url = ollama_url.read().clone();
let mdl = ollama_model.read().clone();
let ll_url = litellm_url.read().clone();
let mdl = litellm_model.read().clone();
spawn(async move {
is_summarizing.set(true);
match crate::infrastructure::llm::summarize_article(
snippet.clone(),
article_url,
oll_url,
ll_url,
mdl,
)
.await
@@ -373,8 +373,8 @@ pub fn DashboardPage() -> Element {
chat_messages: chat_messages.read().clone(),
is_chatting: *is_chatting.read(),
on_chat_send: move |question: String| {
let oll_url = ollama_url.read().clone();
let mdl = ollama_model.read().clone();
let ll_url = litellm_url.read().clone();
let mdl = litellm_model.read().clone();
let ctx = article_context.read().clone();
// Capture article info for News session creation
let card_title = selected_card
@@ -394,7 +394,7 @@ pub fn DashboardPage() -> Element {
content: question.clone(),
});
// Build full message history for Ollama
// Build full message history for LiteLLM
let system_msg = format!(
"You are a helpful assistant. The user is reading \
a news article. Use the following context to answer \
@@ -422,7 +422,7 @@ pub fn DashboardPage() -> Element {
match create_chat_session(
card_title,
"News".to_string(),
"ollama".to_string(),
"litellm".to_string(),
mdl.clone(),
card_url,
)
@@ -458,7 +458,7 @@ pub fn DashboardPage() -> Element {
}
match crate::infrastructure::llm::chat_followup(
msgs, oll_url, mdl,
msgs, ll_url, mdl,
)
.await
{
@@ -495,7 +495,7 @@ pub fn DashboardPage() -> Element {
// Right: sidebar (when no card selected)
if !has_selection {
DashboardSidebar {
ollama_url: ollama_url.read().clone(),
litellm_url: litellm_url.read().clone(),
trending: trending_topics.clone(),
recent_searches: recent_searches.read().clone(),
on_topic_click: move |topic: String| {

View File

@@ -1,8 +1,5 @@
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::icons::bs_icons::{BsArrowRight, BsShieldCheck};
use dioxus_free_icons::Icon;
use crate::i18n::{t, Locale};
@@ -12,14 +9,15 @@ use crate::Route;
///
/// 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.
/// without authentication. Uses the Glass Aurora design with glassmorphic
/// effects, aurora gradients, and centered hero layout.
#[component]
pub fn LandingPage() -> Element {
rsx! {
div { class: "landing",
LandingNav {}
HeroSection {}
SocialProof {}
TrustBar {}
FeaturesGrid {}
HowItWorks {}
CtaBanner {}
@@ -29,6 +27,7 @@ pub fn LandingPage() -> Element {
}
/// Sticky top navigation bar with logo, nav links, and CTA buttons.
/// Uses Glass Aurora glassmorphic nav with backdrop-filter blur.
#[component]
fn LandingNav() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -69,7 +68,8 @@ fn LandingNav() -> Element {
}
}
/// Hero section with headline, subtitle, and CTA buttons.
/// Hero section with pill badges, headline, subtitle, CTA buttons, and
/// a glass-preview stat panel. Centered layout per Glass Aurora design.
#[component]
fn HeroSection() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -78,7 +78,11 @@ fn HeroSection() -> Element {
rsx! {
section { class: "hero-section",
div { class: "hero-content",
div { class: "hero-badge badge badge-outline", {t(l, "landing.badge")} }
div { class: "hero-pills",
span { class: "pill accent", {t(l, "landing.pill_gdpr")} }
span { class: "pill", {t(l, "landing.pill_self_hosted")} }
span { class: "pill", {t(l, "landing.pill_eu")} }
}
h1 { class: "hero-title",
{t(l, "landing.hero_title_1")}
br {}
@@ -100,176 +104,26 @@ fn HeroSection() -> Element {
{t(l, "landing.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)",
div { class: "preview-container",
div { class: "glass-preview",
div { class: "preview-stat",
div { class: "preview-stat-value", "5" }
div { class: "preview-stat-label",
{t(l, "landing.preview_models")}
}
}
div { class: "preview-stat",
div { class: "preview-stat-value", "847K" }
div { class: "preview-stat-label",
{t(l, "landing.preview_tokens")}
}
}
div { class: "preview-stat",
div { class: "preview-stat-value", "$47.82" }
div { class: "preview-stat-label",
{t(l, "landing.preview_spend")}
}
}
}
// 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",
}
}
}
@@ -277,44 +131,36 @@ fn HeroSection() -> Element {
}
}
/// Social proof / trust indicator strip.
/// Trust bar with aurora dot indicators and stat labels.
/// Replaces the previous text-based social proof section.
#[component]
fn SocialProof() -> Element {
fn TrustBar() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "social-proof",
p { class: "social-proof-text",
{t(l, "landing.social_proof")}
span { class: "social-proof-highlight", {t(l, "landing.data_sovereignty")} }
section { class: "trust-bar",
div { class: "trust-item",
div { class: "trust-dot" }
span { "100% " {t(l, "landing.on_premise")} }
}
div { class: "social-proof-stats",
div { class: "proof-stat",
span { class: "proof-stat-value", "100%" }
span { class: "proof-stat-label", {t(l, "landing.on_premise")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "GDPR" }
span { class: "proof-stat-label", {t(l, "landing.compliant")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "EU" }
span { class: "proof-stat-label", {t(l, "landing.data_residency")} }
}
div { class: "proof-divider" }
div { class: "proof-stat",
span { class: "proof-stat-value", "Zero" }
span { class: "proof-stat-label", {t(l, "landing.third_party")} }
}
div { class: "trust-item",
div { class: "trust-dot" }
span { "GDPR " {t(l, "landing.compliant")} }
}
div { class: "trust-item",
div { class: "trust-dot" }
span { "EU " {t(l, "landing.data_residency")} }
}
div { class: "trust-item",
div { class: "trust-dot" }
span { "Zero " {t(l, "landing.third_party")} }
}
}
}
}
/// Feature cards grid section.
/// Feature cards grid section. Uses gradient icon bars instead of SVG icons.
#[component]
fn FeaturesGrid() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -328,44 +174,26 @@ fn FeaturesGrid() -> Element {
}
div { class: "features-grid",
FeatureCard {
icon: rsx! {
Icon { icon: BsServer, width: 28, height: 28 }
},
title: t(l, "landing.feat_infra_title"),
description: t(l, "landing.feat_infra_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsShieldCheck, width: 28, height: 28 }
},
title: t(l, "landing.feat_gdpr_title"),
description: t(l, "landing.feat_gdpr_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: FaCubes, width: 28, height: 28 }
},
title: t(l, "landing.feat_llm_title"),
description: t(l, "landing.feat_llm_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsRobot, width: 28, height: 28 }
},
title: t(l, "landing.feat_agent_title"),
description: t(l, "landing.feat_agent_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsGlobe2, width: 28, height: 28 }
},
title: t(l, "landing.feat_mcp_title"),
description: t(l, "landing.feat_mcp_desc"),
}
FeatureCard {
icon: rsx! {
Icon { icon: BsKey, width: 28, height: 28 }
},
title: t(l, "landing.feat_api_title"),
description: t(l, "landing.feat_api_desc"),
}
@@ -374,18 +202,17 @@ fn FeaturesGrid() -> Element {
}
}
/// Individual feature card.
/// Individual feature card with a gradient icon bar accent.
///
/// # Arguments
///
/// * `icon` - The icon element to display
/// * `title` - Feature title (owned String from translation lookup)
/// * `description` - Feature description text (owned String from translation lookup)
#[component]
fn FeatureCard(icon: Element, title: String, description: String) -> Element {
fn FeatureCard(title: String, description: String) -> Element {
rsx! {
div { class: "card feature-card",
div { class: "feature-card-icon", {icon} }
div { class: "feature-icon-bar" }
h3 { class: "feature-card-title", "{title}" }
p { class: "feature-card-desc", "{description}" }
}
@@ -441,33 +268,35 @@ fn StepCard(number: &'static str, title: String, description: String) -> Element
}
}
/// Call-to-action banner before the footer.
/// Call-to-action banner wrapped in a glass box with aurora top border.
#[component]
fn CtaBanner() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
rsx! {
section { class: "cta-banner",
h2 { class: "cta-title", {t(l, "landing.cta_title")} }
p { class: "cta-subtitle",
{t(l, "landing.cta_subtitle")}
}
div { class: "cta-actions",
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
{t(l, "landing.get_started_free")}
Icon { icon: BsArrowRight, width: 18, height: 18 }
section { class: "cta-section",
div { class: "cta-box",
h2 { class: "cta-title", {t(l, "landing.cta_title")} }
p { class: "cta-subtitle",
{t(l, "landing.cta_subtitle")}
}
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-outline btn-lg",
{t(l, "common.log_in")}
div { class: "cta-actions",
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-primary btn-lg",
{t(l, "landing.get_started_free")}
Icon { icon: BsArrowRight, width: 18, height: 18 }
}
Link {
to: Route::Login {
redirect_url: "/dashboard".into(),
},
class: "btn btn-outline btn-lg",
{t(l, "common.log_in")}
}
}
}
}
@@ -475,6 +304,7 @@ fn CtaBanner() -> Element {
}
/// Landing page footer with links and copyright.
/// Uses glass border-top styling per Glass Aurora design.
#[component]
fn LandingFooter() -> Element {
let locale = use_context::<Signal<Locale>>();

View File

@@ -2,12 +2,14 @@ use dioxus::prelude::*;
use crate::components::{MemberRow, PageHeader};
use crate::i18n::{t, tw, Locale};
use crate::models::{BillingUsage, MemberRole, OrgMember};
use crate::infrastructure::litellm::get_litellm_usage;
use crate::models::{BillingUsage, LitellmUsageStats, 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.
/// Shows current billing usage (fetched from LiteLLM), a per-model
/// breakdown table, a table of organization members with role
/// management, and a button to invite new members.
#[component]
pub fn OrgDashboardPage() -> Element {
let locale = use_context::<Signal<Locale>>();
@@ -20,6 +22,20 @@ pub fn OrgDashboardPage() -> Element {
let members_list = members.read().clone();
// Compute date range: 1st of current month to today
let (start_date, end_date) = current_month_range();
// Fetch real usage stats from LiteLLM via server function.
// use_resource memoises and won't re-fire on parent re-renders.
let usage_resource = use_resource(move || {
let start = start_date.clone();
let end = end_date.clone();
async move { get_litellm_usage(start, end).await }
});
// Clone out of Signal to avoid holding the borrow across rsx!
let usage_snapshot = usage_resource.read().clone();
// Format token counts for display
let tokens_display = format_tokens(usage.tokens_used);
let tokens_limit_display = format_tokens(usage.tokens_limit);
@@ -30,26 +46,39 @@ pub fn OrgDashboardPage() -> Element {
title: t(l, "org.title"),
subtitle: t(l, "org.subtitle"),
actions: rsx! {
button { class: "btn-primary", onclick: move |_| show_invite.set(true), {t(l, "org.invite_member")} }
button {
class: "btn-primary",
onclick: move |_| show_invite.set(true),
{t(l, "org.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-value",
"{usage.seats_used}/{usage.seats_total}"
}
span { class: "org-stat-label", {t(l, "org.seats_used")} }
}
div { class: "org-stat",
span { class: "org-stat-value", "{tokens_display}" }
span { class: "org-stat-label", {tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])} }
span { class: "org-stat-label",
{tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])}
}
}
div { class: "org-stat",
span { class: "org-stat-value", "{usage.billing_cycle_end}" }
span { class: "org-stat-value",
"{usage.billing_cycle_end}"
}
span { class: "org-stat-label", {t(l, "org.cycle_ends")} }
}
}
// LiteLLM usage stats section
{render_usage_section(l, &usage_snapshot)}
// Members table
div { class: "org-table-wrapper",
table { class: "org-table",
@@ -114,6 +143,144 @@ pub fn OrgDashboardPage() -> Element {
}
}
/// Render the LiteLLM usage stats section: totals bar + per-model table.
///
/// Shows a loading state while the resource is pending, an error/empty
/// message on failure, and the full breakdown on success.
fn render_usage_section(
l: Locale,
snapshot: &Option<Result<LitellmUsageStats, ServerFnError>>,
) -> Element {
match snapshot {
None => rsx! {
div { class: "org-usage-loading",
span { {t(l, "org.loading_usage")} }
}
},
Some(Err(_)) => rsx! {
div { class: "org-usage-unavailable",
span { {t(l, "org.usage_unavailable")} }
}
},
Some(Ok(stats)) if stats.total_tokens == 0 && stats.model_breakdown.is_empty() => {
rsx! {
div { class: "org-usage-unavailable",
span { {t(l, "org.usage_unavailable")} }
}
}
}
Some(Ok(stats)) => {
let spend_display = format!("${:.2}", stats.total_spend);
let total_display = format_tokens(stats.total_tokens);
// Free-tier LiteLLM doesn't provide prompt/completion split
let has_token_split =
stats.total_prompt_tokens > 0 || stats.total_completion_tokens > 0;
rsx! {
// Usage totals bar
div { class: "org-stats-bar",
div { class: "org-stat",
span { class: "org-stat-value", "{spend_display}" }
span { class: "org-stat-label",
{t(l, "org.total_spend")}
}
}
div { class: "org-stat",
span { class: "org-stat-value",
"{total_display}"
}
span { class: "org-stat-label",
{t(l, "org.total_tokens")}
}
}
// Only show prompt/completion split when available
if has_token_split {
div { class: "org-stat",
span { class: "org-stat-value",
{format_tokens(stats.total_prompt_tokens)}
}
span { class: "org-stat-label",
{t(l, "org.prompt_tokens")}
}
}
div { class: "org-stat",
span { class: "org-stat-value",
{format_tokens(stats.total_completion_tokens)}
}
span { class: "org-stat-label",
{t(l, "org.completion_tokens")}
}
}
}
}
// Per-model breakdown table
if !stats.model_breakdown.is_empty() {
h3 { class: "org-section-title",
{t(l, "org.model_usage")}
}
div { class: "org-table-wrapper",
table { class: "org-table",
thead {
tr {
th { {t(l, "org.model")} }
th { {t(l, "org.tokens")} }
th { {t(l, "org.spend")} }
}
}
tbody {
for model in &stats.model_breakdown {
tr { key: "{model.model}",
td { "{model.model}" }
td {
{format_tokens(model.total_tokens)}
}
td {
{format!(
"${:.2}", model.spend
)}
}
}
}
}
}
}
}
}
}
}
}
/// Compute the date range for the current billing month.
///
/// Returns `(start_date, end_date)` as `YYYY-MM-DD` strings where
/// start_date is the 1st of the current month and end_date is today.
///
/// On the web target this uses `js_sys::Date` to read the browser clock.
/// On the server target (SSR) it falls back to `chrono::Utc::now()`.
fn current_month_range() -> (String, String) {
#[cfg(feature = "web")]
{
// js_sys::Date accesses the browser's local clock in WASM.
let now = js_sys::Date::new_0();
let year = now.get_full_year();
// JS months are 0-indexed, so add 1 for calendar month
let month = now.get_month() + 1;
let day = now.get_date();
let start = format!("{year:04}-{month:02}-01");
let end = format!("{year:04}-{month:02}-{day:02}");
(start, end)
}
#[cfg(not(feature = "web"))]
{
use chrono::Datelike;
let today = chrono::Utc::now().date_naive();
let start = format!("{:04}-{:02}-01", today.year(), today.month());
let end = today.format("%Y-%m-%d").to_string();
(start, end)
}
}
/// 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;

View File

@@ -13,8 +13,8 @@ pub fn ProvidersPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
let mut selected_provider = use_signal(|| LlmProvider::Ollama);
let mut selected_model = use_signal(|| "llama3.1:8b".to_string());
let mut selected_provider = use_signal(|| LlmProvider::LiteLlm);
let mut selected_model = use_signal(|| "qwen3-32b".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);
@@ -59,12 +59,12 @@ pub fn ProvidersPage() -> Element {
"Hugging Face" => LlmProvider::HuggingFace,
"OpenAI" => LlmProvider::OpenAi,
"Anthropic" => LlmProvider::Anthropic,
_ => LlmProvider::Ollama,
_ => LlmProvider::LiteLlm,
};
selected_provider.set(prov);
saved.set(false);
},
option { value: "Ollama", "Ollama" }
option { value: "LiteLLM", "LiteLLM" }
option { value: "Hugging Face", "Hugging Face" }
option { value: "OpenAI", "OpenAI" }
option { value: "Anthropic", "Anthropic" }
@@ -156,23 +156,29 @@ pub fn ProvidersPage() -> Element {
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,
id: "qwen3-32b".into(),
name: "Qwen3 32B".into(),
provider: LlmProvider::LiteLlm,
context_window: 32,
},
ModelEntry {
id: "llama-3.3-70b".into(),
name: "Llama 3.3 70B".into(),
provider: LlmProvider::LiteLlm,
context_window: 128,
},
ModelEntry {
id: "mistral-small-24b".into(),
name: "Mistral Small 24B".into(),
provider: LlmProvider::LiteLlm,
context_window: 32,
},
ModelEntry {
id: "deepseek-r1-70b".into(),
name: "DeepSeek R1 70B".into(),
provider: LlmProvider::LiteLlm,
context_window: 64,
},
ModelEntry {
id: "meta-llama/Llama-3.1-8B".into(),
name: "Llama 3.1 8B".into(),
@@ -200,7 +206,7 @@ fn mock_embeddings() -> Vec<EmbeddingEntry> {
EmbeddingEntry {
id: "nomic-embed-text".into(),
name: "Nomic Embed Text".into(),
provider: LlmProvider::Ollama,
provider: LlmProvider::LiteLlm,
dimensions: 768,
},
EmbeddingEntry {