This commit is contained in:
David 2026-03-10 12:01:34 +01:00
parent 7dfb4e84f4
commit 3051f71edd
21 changed files with 1662 additions and 223 deletions

View File

@ -54,7 +54,7 @@ LLM Provider Adapters (OpenAI, Anthropic, Azure, Mistral, Ollama)
- Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin)
- HashiCorp Vault — secrets and API key rotation (90-day cycle)
**Frontend:** React 18 + TypeScript + Vite, shadcn/ui, recharts. Routes protected via local JWT (stored in localStorage, auto-logout on expiry); `web/src/auth/` manages the auth flow. API clients live in `web/src/api/`.
**Frontend:** React 18 + TypeScript + Vite, shadcn/ui, recharts. Routes protected via local JWT (stored in localStorage, auto-logout on expiry); `web/src/auth/` manages the auth flow. API clients live in `web/src/api/` and use React Query. **The UI is in French** — use French labels/copy in all dashboard pages; `date-fns` with `fr` locale for date formatting.
**Documentation site** (`http://localhost:3000/docs`): public, no auth required. Root: `web/src/pages/docs/` — sections: getting-started, installation, api-reference (8 endpoints), guides (6), deployment (3), security (2), changelog. Layout components: `DocLayout.tsx` (sidebar + content + TOC), `DocSidebar.tsx` (with search), `DocBreadcrumbs.tsx`, `DocPagination.tsx`. Shared components: `components/CodeBlock.tsx`, `Callout.tsx`, `ApiEndpoint.tsx`, `ParamTable.tsx`, `TableOfContents.tsx`. Nav structure: `web/src/pages/docs/nav.ts`. Uses `@tailwindcss/typography` (added as devDependency) for prose rendering.
@ -84,7 +84,7 @@ config.yaml # Local dev config (overridden by VEYLANT_* env vars)
Use `make` as the primary interface. The proxy runs on **:8090**, PII HTTP on **:8091**, PII gRPC on **:50051**.
```bash
make dev # Start full stack (proxy + PostgreSQL + ClickHouse + Redis + Keycloak + PII)
make dev # Start full stack (proxy + PostgreSQL + ClickHouse + Redis + PII)
make dev-down # Stop and remove all containers and volumes
make dev-logs # Tail logs from all services
make build # go build → bin/proxy
@ -136,6 +136,8 @@ pytest services/pii/tests/test_file.py::test_function
**Provider configs:** LLM provider API keys are stored encrypted (AES-256-GCM) in the `provider_configs` table (migration 000011). CRUD via `GET|POST /v1/admin/providers`, `PUT|DELETE|POST-test /v1/admin/providers/{id}`. Adapters hot-reload on save/update without proxy restart (`router.UpdateAdapter()` / `RemoveAdapter()`).
**`admin.Handler` builder pattern:** The handler is wired via method chaining — `adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)`. All `With*` methods are optional; missing dependencies disable their feature.
**Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`.
**Tenant onboarding** (after `make dev`):
@ -148,7 +150,7 @@ deploy/onboarding/import-users.sh # bulk import from CSV (email, first_name
When `server.env=development`, the proxy degrades gracefully instead of crashing:
- **PostgreSQL unreachable** → routing engine and feature flags disabled; flag store uses in-memory fallback
- **ClickHouse unreachable** → audit logging disabled
- **ClickHouse unreachable** → audit logging falls back to `auditlog.MemLogger` (in-memory, not persisted across restarts)
- **PII service unreachable** → PII disabled if `pii.fail_open=true` (default)
In production (`server.env=production`), any of the above causes a fatal startup error.

View File

@ -30,6 +30,7 @@ import (
"github.com/veylant/ia-gateway/internal/health"
"github.com/veylant/ia-gateway/internal/metrics"
"github.com/veylant/ia-gateway/internal/middleware"
"github.com/veylant/ia-gateway/internal/notifications"
"github.com/veylant/ia-gateway/internal/pii"
"github.com/veylant/ia-gateway/internal/provider"
"github.com/veylant/ia-gateway/internal/provider/anthropic"
@ -225,6 +226,13 @@ func main() {
logger.Warn("clickhouse.dsn not set — audit logging disabled")
}
// In development, fall back to in-memory audit logger so the admin API
// returns data instead of 501 when ClickHouse is not running.
if auditLogger == nil && cfg.Server.Env == "development" {
auditLogger = auditlog.NewMemLogger()
logger.Warn("audit logging: using in-memory fallback — data not persisted across restarts")
}
// ── Feature flag store (E4-12 zero-retention + future flags + E11-07) ──────
var flagStore flags.FlagStore
if db != nil {
@ -237,6 +245,31 @@ func main() {
// Wire flag store into the provider router so it can check routing_enabled (E11-07).
providerRouter.WithFlagStore(flagStore)
// ── Email notifications (Mailtrap / any SMTP relay) ───────────────────────
var notifHandler *notifications.Handler
if cfg.Notifications.SMTP.Host != "" && cfg.Notifications.SMTP.Username != "" {
mailer, mailerErr := notifications.New(notifications.Config{
Host: cfg.Notifications.SMTP.Host,
Port: cfg.Notifications.SMTP.Port,
Username: cfg.Notifications.SMTP.Username,
Password: cfg.Notifications.SMTP.Password,
From: cfg.Notifications.SMTP.From,
FromName: cfg.Notifications.SMTP.FromName,
})
if mailerErr != nil {
logger.Warn("email notifications disabled — invalid SMTP config", zap.Error(mailerErr))
} else {
notifHandler = notifications.NewHandler(mailer, logger)
logger.Info("email notifications enabled",
zap.String("smtp_host", cfg.Notifications.SMTP.Host),
zap.Int("smtp_port", cfg.Notifications.SMTP.Port),
zap.String("from", cfg.Notifications.SMTP.From),
)
}
} else {
logger.Warn("email notifications disabled — notifications.smtp.host or username not set")
}
// ── Proxy handler ─────────────────────────────────────────────────────────
proxyHandler := proxy.NewWithAudit(providerRouter, logger, piiClient, auditLogger, encryptor).
WithFlagStore(flagStore)
@ -308,6 +341,11 @@ func main() {
r.Post("/chat/completions", proxyHandler.ServeHTTP)
// Email notification delivery — called by the frontend when a budget threshold is crossed.
if notifHandler != nil {
r.Post("/notifications/send", notifHandler.ServeHTTP)
}
// PII analyze endpoint for Playground (E8-11, Sprint 8).
piiAnalyzeHandler := pii.NewAnalyzeHandler(piiClient, logger)
r.Post("/pii/analyze", piiAnalyzeHandler.ServeHTTP)

View File

@ -114,3 +114,16 @@ rate_limit:
default_tenant_burst: 200
default_user_rpm: 100
default_user_burst: 20
# Email notifications via SMTP (Mailtrap sandbox).
# Override password in production: VEYLANT_NOTIFICATIONS_SMTP_PASSWORD=<full_password>
# Full password visible in Mailtrap dashboard → Sending → SMTP Settings → Show credentials.
notifications:
smtp:
host: "sandbox.smtp.mailtrap.io"
port: 587
username: "2597bd31d265eb"
# Mailtrap password — replace ****3c89 with the full value from the dashboard.
password: "cd126234193c89"
from: "noreply@veylant.ai"
from_name: "Veylant IA"

View File

@ -9,28 +9,28 @@ import "time"
// prompt_anonymized is stored encrypted (AES-256-GCM) and is never
// returned to API callers.
type AuditEntry struct {
RequestID string
TenantID string
UserID string
Timestamp time.Time
ModelRequested string
ModelUsed string
Provider string
Department string
UserRole string
PromptHash string // hex SHA-256 of the original (pre-PII) prompt
ResponseHash string // hex SHA-256 of the response content
PromptAnonymized string // AES-256-GCM base64-encoded anonymized prompt
SensitivityLevel string // "none"|"low"|"medium"|"high"|"critical"
TokenInput int
TokenOutput int
TokenTotal int
CostUSD float64
LatencyMs int
Status string // "ok"|"error"
ErrorType string
PIIEntityCount int
Stream bool
RequestID string `json:"request_id"`
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
Timestamp time.Time `json:"timestamp"`
ModelRequested string `json:"model_requested"`
ModelUsed string `json:"model_used"`
Provider string `json:"provider"`
Department string `json:"department"`
UserRole string `json:"user_role"`
PromptHash string `json:"prompt_hash"`
ResponseHash string `json:"response_hash"`
PromptAnonymized string `json:"-"` // AES-256-GCM base64-encoded anonymized prompt — never returned to API callers
SensitivityLevel string `json:"sensitivity_level"` // "none"|"low"|"medium"|"high"|"critical"
TokenInput int `json:"token_input"`
TokenOutput int `json:"token_output"`
TokenTotal int `json:"token_total"`
CostUSD float64 `json:"cost_usd"`
LatencyMs int `json:"latency_ms"`
Status string `json:"status"` // "ok"|"error"
ErrorType string `json:"error_type"`
PIIEntityCount int `json:"pii_entity_count"`
Stream bool `json:"stream"`
}
// AuditQuery filters audit log entries for the GET /v1/admin/logs endpoint.
@ -47,8 +47,8 @@ type AuditQuery struct {
// AuditResult is the paginated response for AuditQuery.
type AuditResult struct {
Data []AuditEntry
Total int
Data []AuditEntry `json:"data"`
Total int `json:"total"`
}
// CostQuery filters cost aggregation for the GET /v1/admin/costs endpoint.
@ -61,13 +61,13 @@ type CostQuery struct {
// CostSummary is one row in a cost aggregation result.
type CostSummary struct {
Key string
TotalTokens int
TotalCostUSD float64
RequestCount int
Key string `json:"key"`
TotalTokens int `json:"total_tokens"`
TotalCostUSD float64 `json:"total_cost_usd"`
RequestCount int `json:"request_count"`
}
// CostResult is the response for CostQuery.
type CostResult struct {
Data []CostSummary
Data []CostSummary `json:"data"`
}

View File

@ -52,7 +52,7 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error)
}
minLevel := sensitivityOrder[q.MinSensitivity]
var filtered []AuditEntry
filtered := make([]AuditEntry, 0)
for _, e := range m.entries {
if e.TenantID != q.TenantID {
continue
@ -81,7 +81,7 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error)
if q.Offset < len(filtered) {
filtered = filtered[q.Offset:]
} else {
filtered = nil
filtered = make([]AuditEntry, 0)
}
limit := q.Limit
if limit <= 0 || limit > 200 {
@ -133,7 +133,7 @@ func (m *MemLogger) QueryCosts(_ context.Context, q CostQuery) (*CostResult, err
totals[key].count++
}
var data []CostSummary
data := make([]CostSummary, 0)
for k, v := range totals {
data = append(data, CostSummary{
Key: k,

View File

@ -24,6 +24,25 @@ type Config struct {
ClickHouse ClickHouseConfig `mapstructure:"clickhouse"`
Crypto CryptoConfig `mapstructure:"crypto"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Notifications NotificationsConfig `mapstructure:"notifications"`
}
// NotificationsConfig holds outbound notification settings.
type NotificationsConfig struct {
SMTP SMTPConfig `mapstructure:"smtp"`
}
// SMTPConfig holds SMTP relay settings for email notifications.
// Sensitive fields (Password) should be provided via env vars:
//
// VEYLANT_NOTIFICATIONS_SMTP_PASSWORD=<value>
type SMTPConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
From string `mapstructure:"from"`
FromName string `mapstructure:"from_name"`
}
// RateLimitConfig holds default rate limiting parameters applied to all tenants
@ -222,6 +241,9 @@ func Load() (*Config, error) {
v.SetDefault("rate_limit.default_tenant_burst", 200)
v.SetDefault("rate_limit.default_user_rpm", 100)
v.SetDefault("rate_limit.default_user_burst", 20)
v.SetDefault("notifications.smtp.port", 587)
v.SetDefault("notifications.smtp.from", "noreply@veylant.ai")
v.SetDefault("notifications.smtp.from_name", "Veylant IA")
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {

View File

@ -0,0 +1,176 @@
package notifications
import (
"encoding/json"
"fmt"
"net/http"
"go.uber.org/zap"
"github.com/veylant/ia-gateway/internal/apierror"
"github.com/veylant/ia-gateway/internal/middleware"
)
// Handler handles POST /v1/notifications/send — sends a budget alert email.
// Requires a valid JWT (caller must be authenticated).
type Handler struct {
mailer *Mailer
logger *zap.Logger
}
// NewHandler creates a Handler. mailer must not be nil.
func NewHandler(mailer *Mailer, logger *zap.Logger) *Handler {
return &Handler{mailer: mailer, logger: logger}
}
// SendRequest is the JSON body for POST /v1/notifications/send.
type SendRequest struct {
To string `json:"to"` // recipient email address
AlertName string `json:"alert_name"` // human-readable rule name
Scope string `json:"scope"` // "global" | provider name
Spend float64 `json:"spend"` // current spend for the scope
Threshold float64 `json:"threshold"` // configured threshold
Type string `json:"type"` // "budget_exceeded" | "budget_warning"
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
reqID := middleware.RequestIDFromContext(r.Context())
var req SendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "invalid_request_error", Message: "Invalid JSON body",
Code: "invalid_request", HTTPStatus: http.StatusBadRequest,
}, reqID)
return
}
if req.To == "" {
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "invalid_request_error", Message: "Field 'to' is required",
Code: "invalid_request", HTTPStatus: http.StatusBadRequest,
}, reqID)
return
}
if req.AlertName == "" {
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "invalid_request_error", Message: "Field 'alert_name' is required",
Code: "invalid_request", HTTPStatus: http.StatusBadRequest,
}, reqID)
return
}
subject, html := buildEmail(req)
if err := h.mailer.Send(Message{To: req.To, Subject: subject, HTML: html}); err != nil {
h.logger.Warn("notification email send failed",
zap.Error(err),
zap.String("to", req.To),
zap.String("alert", req.AlertName),
zap.String("request_id", reqID),
)
apierror.WriteErrorWithRequestID(w, &apierror.APIError{
Type: "api_error",
Message: "Failed to send notification email — check SMTP configuration",
Code: "email_send_failed",
HTTPStatus: http.StatusServiceUnavailable,
}, reqID)
return
}
h.logger.Info("notification email sent",
zap.String("to", req.To),
zap.String("alert", req.AlertName),
zap.String("type", req.Type),
)
w.WriteHeader(http.StatusNoContent)
}
// buildEmail returns the subject and HTML body for a budget alert notification.
func buildEmail(req SendRequest) (subject, html string) {
isExceeded := req.Type == "budget_exceeded"
scopeLabel := req.Scope
if req.Scope == "global" {
scopeLabel = "Tous fournisseurs"
}
pct := 0.0
if req.Threshold > 0 {
pct = (req.Spend / req.Threshold) * 100
}
accentColor := "#f59e0b" // warning amber
statusLabel := "Alerte 80 % — seuil bientôt atteint"
emoji := "⚠️"
if isExceeded {
accentColor = "#ef4444" // destructive red
statusLabel = "Seuil dépassé"
emoji = "🚨"
}
subject = fmt.Sprintf("[Veylant IA] %s %s : %s", emoji, statusLabel, req.AlertName)
html = fmt.Sprintf(`<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:24px;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="max-width:500px;margin:0 auto;background:#ffffff;border-radius:10px;border:1px solid #e5e7eb;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
<!-- Header band -->
<div style="background:%s;padding:20px 28px;">
<p style="margin:0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:rgba(255,255,255,.75);">Veylant IA · Governance Hub</p>
<h1 style="margin:6px 0 0;font-size:20px;font-weight:700;color:#fff;">%s</h1>
</div>
<!-- Body -->
<div style="padding:28px;">
<h2 style="margin:0 0 18px;font-size:16px;font-weight:600;color:#111827;">Règle : %s</h2>
<table style="width:100%%;border-collapse:collapse;font-size:14px;color:#374151;">
<tr>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;">Périmètre</td>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:500;text-transform:capitalize;">%s</td>
</tr>
<tr>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;">Dépenses actuelles</td>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:700;color:%s;">$%.6f</td>
</tr>
<tr>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;color:#6b7280;">Seuil configuré</td>
<td style="padding:10px 0;border-bottom:1px solid #f3f4f6;text-align:right;font-weight:500;">$%.6f</td>
</tr>
<tr>
<td style="padding:10px 0;color:#6b7280;">Consommation</td>
<td style="padding:10px 0;text-align:right;font-weight:700;color:%s;">%.1f %%</td>
</tr>
</table>
<!-- Progress bar -->
<div style="margin-top:18px;background:#f3f4f6;border-radius:99px;height:8px;overflow:hidden;">
<div style="height:8px;border-radius:99px;background:%s;width:%.0f%%;transition:width .3s;"></div>
</div>
<p style="margin:6px 0 0;font-size:12px;color:#6b7280;text-align:right;">%.1f %% du seuil</p>
</div>
<!-- Footer -->
<div style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.5;">
Cet email a été envoyé automatiquement par <strong style="color:#6b7280;">Veylant IA</strong>.<br>
Gérez vos règles d'alerte dans <em>Paramètres Notifications</em>.
</p>
</div>
</div>
</body>
</html>`,
accentColor, statusLabel,
req.AlertName,
scopeLabel,
accentColor, req.Spend,
req.Threshold,
accentColor, pct,
accentColor, pct,
pct,
)
return subject, html
}

View File

@ -0,0 +1,102 @@
// Package notifications provides SMTP email delivery for budget alert notifications.
package notifications
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
)
// Config holds SMTP relay configuration.
type Config struct {
Host string
Port int
Username string
Password string
From string
FromName string
}
// Mailer sends transactional emails via SMTP (STARTTLS, PLAIN auth).
// Tested against Mailtrap sandbox; compatible with any RFC 4954 relay.
type Mailer struct {
cfg Config
}
// New creates a Mailer. Returns an error if the config is obviously invalid.
func New(cfg Config) (*Mailer, error) {
if cfg.Host == "" {
return nil, fmt.Errorf("smtp host is required")
}
if cfg.Port == 0 {
cfg.Port = 587
}
return &Mailer{cfg: cfg}, nil
}
// Message is a single outbound email.
type Message struct {
To string // single recipient address
Subject string
HTML string // HTML body
}
// Send delivers msg via SMTP with STARTTLS + PLAIN auth.
func (m *Mailer) Send(msg Message) error {
from := m.cfg.From
if m.cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", m.cfg.FromName, m.cfg.From)
}
headers := strings.Join([]string{
fmt.Sprintf("From: %s", from),
fmt.Sprintf("To: %s", msg.To),
fmt.Sprintf("Subject: %s", msg.Subject),
"MIME-Version: 1.0",
`Content-Type: text/html; charset="UTF-8"`,
"",
"",
}, "\r\n")
body := []byte(headers + msg.HTML)
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
conn, err := net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial %s: %w", addr, err)
}
client, err := smtp.NewClient(conn, m.cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer client.Close() //nolint:errcheck
// Upgrade to TLS via STARTTLS if the server supports it (all Mailtrap ports do).
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if err = client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
if err = client.Mail(m.cfg.From); err != nil {
return fmt.Errorf("smtp MAIL FROM: %w", err)
}
if err = client.Rcpt(msg.To); err != nil {
return fmt.Errorf("smtp RCPT TO: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("smtp DATA: %w", err)
}
if _, err = wc.Write(body); err != nil {
return fmt.Errorf("smtp write body: %w", err)
}
return wc.Close()
}

BIN
proxy

Binary file not shown.

View File

@ -1,6 +1,7 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { Header } from "./Header";
import { NotificationWatcher } from "./NotificationWatcher";
import { useRegisterTokenAccessor } from "@/auth/AuthProvider";
export function AppLayout() {
@ -12,6 +13,8 @@ export function AppLayout() {
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-6">
{/* Invisible watcher: polls costs every 30 s and generates notifications */}
<NotificationWatcher />
<Outlet />
</main>
</div>

View File

@ -1,70 +1,34 @@
import { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { LogOut, User, Bell } from "lucide-react";
import { useLocation } from "react-router-dom";
import { LogOut, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/AuthProvider";
import { useCosts } from "@/api/costs";
import { NotificationCenter } from "./NotificationCenter";
const breadcrumbMap: Record<string, string> = {
"/": "Vue d'ensemble",
"/policies": "Politiques de routage",
"/users": "Utilisateurs",
"/providers": "Fournisseurs IA",
"/alerts": "Alertes Budget",
"/logs": "Journaux d'audit",
"/settings": "Paramètres",
"/playground": "Playground IA",
"/security": "Sécurité RSSI",
"/costs": "Coûts IA",
"/compliance": "Conformité RGPD / AI Act",
"/dashboard": "Vue d'ensemble",
"/dashboard/playground": "Playground IA",
"/dashboard/policies": "Politiques de routage",
"/dashboard/users": "Utilisateurs",
"/dashboard/providers": "Fournisseurs IA",
"/dashboard/security": "Sécurité RSSI",
"/dashboard/costs": "Coûts IA",
"/dashboard/alerts": "Alertes Budget",
"/dashboard/logs": "Journaux d'audit",
"/dashboard/compliance": "Conformité RGPD / AI Act",
"/dashboard/settings": "Paramètres",
};
const STORAGE_KEY = "veylant_budget_alerts";
interface BudgetAlert {
id: string;
threshold: number;
}
function loadAlerts(): BudgetAlert[] {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
} catch {
return [];
}
}
export function Header() {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const title = breadcrumbMap[location.pathname] ?? "Dashboard";
const [alerts] = useState<BudgetAlert[]>(loadAlerts);
const { data: costData } = useCosts({}, 60_000);
const totalCost = costData?.data.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0;
const triggeredCount = alerts.filter((a) => totalCost >= a.threshold).length;
return (
<header className="h-14 border-b bg-card flex items-center justify-between px-6 shrink-0">
<h1 className="text-base font-semibold">{title}</h1>
<div className="flex items-center gap-3">
{/* Bell badge — E8-13 */}
<Button
variant="ghost"
size="icon"
onClick={() => navigate("/alerts")}
title="Alertes budget"
className="relative"
>
<Bell className="h-4 w-4" />
{triggeredCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-destructive-foreground text-[10px] font-bold flex items-center justify-center leading-none">
{triggeredCount > 9 ? "9+" : triggeredCount}
</span>
)}
</Button>
<NotificationCenter />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<User className="h-4 w-4" />
@ -75,6 +39,7 @@ export function Header() {
</span>
)}
</div>
<Button variant="ghost" size="icon" onClick={logout} title="Se déconnecter">
<LogOut className="h-4 w-4" />
</Button>

View File

@ -0,0 +1,272 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Bell, Check, CheckCheck, Trash2, TriangleAlert, TrendingUp, Mail } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
loadNotifications,
saveNotifications,
NOTIF_CHANGE_EVENT,
} from "@/lib/notifications";
import type { AppNotification } from "@/lib/notifications";
const NOTIF_EMAIL_KEY = "veylant_notif_email";
function getNotifEmail(): string {
return localStorage.getItem(NOTIF_EMAIL_KEY) ?? "";
}
export function NotificationCenter() {
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const refresh = useCallback(() => {
setNotifications(loadNotifications());
}, []);
// Keep in sync with saves from NotificationWatcher and other tabs
useEffect(() => {
refresh();
window.addEventListener(NOTIF_CHANGE_EVENT, refresh);
window.addEventListener("storage", refresh);
return () => {
window.removeEventListener(NOTIF_CHANGE_EVENT, refresh);
window.removeEventListener("storage", refresh);
};
}, [refresh]);
// Close on outside click
useEffect(() => {
if (!open) return;
function onMouseDown(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open]);
const unreadCount = notifications.filter((n) => !n.readAt).length;
const readCount = notifications.filter((n) => !!n.readAt).length;
const emailConfigured = !!getNotifEmail();
const markRead = (id: string) => {
const updated = notifications.map((n) =>
n.id === id ? { ...n, readAt: new Date().toISOString() } : n
);
setNotifications(updated);
saveNotifications(updated);
};
const markAllRead = () => {
const now = new Date().toISOString();
const updated = notifications.map((n) => ({ ...n, readAt: n.readAt ?? now }));
setNotifications(updated);
saveNotifications(updated);
};
const clearRead = () => {
const updated = notifications.filter((n) => !n.readAt);
setNotifications(updated);
saveNotifications(updated);
};
return (
<div className="relative" ref={panelRef}>
{/* Bell button */}
<Button
variant="ghost"
size="icon"
onClick={() => setOpen((v) => !v)}
title="Centre de notifications"
className="relative"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-destructive-foreground text-[10px] font-bold flex items-center justify-center leading-none">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
{/* ── Notification panel ─────────────────────────────────────────────── */}
{open && (
<div className="absolute right-0 top-full mt-2 w-96 rounded-lg border bg-popover shadow-xl z-50 flex flex-col max-h-[540px]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-foreground" />
<span className="font-semibold text-sm">Notifications</span>
{unreadCount > 0 && (
<Badge variant="destructive" className="text-xs h-5 px-1.5">
{unreadCount}
</Badge>
)}
{emailConfigured && (
<Mail
className="h-3.5 w-3.5 text-muted-foreground"
title={`Emails envoyés à ${getNotifEmail()}`}
/>
)}
</div>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs px-2"
onClick={markAllRead}
title="Tout marquer comme lu"
>
<CheckCheck className="h-3.5 w-3.5 mr-1" />
Tout lire
</Button>
)}
{readCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs px-2 text-muted-foreground hover:text-destructive"
onClick={clearRead}
title="Supprimer les notifications lues"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* Email indicator */}
{emailConfigured && (
<div className="px-4 py-2 bg-muted/40 border-b flex items-center gap-2 text-xs text-muted-foreground shrink-0">
<Mail className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
Copies email vers <strong>{getNotifEmail()}</strong>
</span>
</div>
)}
{/* List */}
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-6">
<Bell className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium text-muted-foreground">
Aucune notification
</p>
<p className="text-xs text-muted-foreground mt-1">
Configurez des alertes budget pour être notifié automatiquement
</p>
<Button
variant="outline"
size="sm"
className="mt-4 text-xs"
onClick={() => {
setOpen(false);
navigate("/dashboard/alerts");
}}
>
Créer une alerte
</Button>
</div>
) : (
<ul>
{notifications.map((n, idx) => {
const isUnread = !n.readAt;
return (
<li key={n.id}>
{idx > 0 && <Separator />}
<div
className={`px-4 py-3 flex gap-3 transition-colors ${
isUnread
? "bg-primary/5 hover:bg-primary/8"
: "hover:bg-accent"
}`}
>
{/* Type icon */}
<div className="shrink-0 mt-0.5">
{n.type === "budget_exceeded" ? (
<TriangleAlert className="h-4 w-4 text-destructive" />
) : (
<TrendingUp className="h-4 w-4 text-amber-500" />
)}
</div>
{/* Body */}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<p
className={`text-sm leading-snug flex-1 ${
isUnread ? "font-semibold" : "text-muted-foreground"
}`}
>
{n.title}
</p>
{isUnread && (
<div className="h-2 w-2 rounded-full bg-primary shrink-0 mt-1.5" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
{n.message}
</p>
<div className="flex items-center gap-2 mt-1.5">
<p className="text-xs text-muted-foreground">
{format(new Date(n.createdAt), "dd MMM à HH:mm", { locale: fr })}
</p>
{emailConfigured && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3" />
envoyé
</span>
)}
</div>
</div>
{/* Mark as read */}
{isUnread && (
<button
onClick={() => markRead(n.id)}
title="Marquer comme lu"
className="shrink-0 mt-0.5 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<>
<Separator />
<div className="px-4 py-2 shrink-0">
<Button
variant="ghost"
size="sm"
className="w-full text-xs text-muted-foreground h-7"
onClick={() => {
setOpen(false);
navigate("/dashboard/alerts");
}}
>
Gérer les règles d&apos;alerte
</Button>
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,138 @@
/**
* NotificationWatcher invisible component mounted in AppLayout.
*
* Watches the live cost data (refreshed every 30 s) and generates
* AppNotifications when a budget alert transitions:
* - below threshold above threshold (budget_exceeded)
* - below 80 % 80 % (budget_warning)
*
* Uses refs to track previous state so it fires only on the crossing,
* not on every subsequent refresh.
*
* When an email is configured in localStorage (veylant_notif_email),
* it also calls POST /v1/notifications/send for each new notification.
*/
import { useEffect, useRef } from "react";
import { useCosts } from "@/api/costs";
import { apiFetch } from "@/api/client";
import { loadNotifications, saveNotifications } from "@/lib/notifications";
import type { AppNotification } from "@/lib/notifications";
const ALERTS_KEY = "veylant_budget_alerts";
const NOTIF_EMAIL_KEY = "veylant_notif_email";
interface BudgetAlert {
id: string;
name: string;
scope: string;
threshold: number;
}
function loadAlerts(): BudgetAlert[] {
try {
return JSON.parse(localStorage.getItem(ALERTS_KEY) ?? "[]");
} catch {
return [];
}
}
async function sendEmailNotification(notif: AppNotification, to: string): Promise<void> {
await apiFetch("/v1/notifications/send", {
method: "POST",
body: JSON.stringify({
to,
alert_name: notif.title,
scope: notif.scope,
spend: notif.spend,
threshold: notif.threshold,
type: notif.type,
}),
});
}
export function NotificationWatcher() {
const { data: costData } = useCosts({ group_by: "provider" }, 30_000);
const prevExceeded = useRef<Set<string>>(new Set());
const prevWarning = useRef<Set<string>>(new Set());
useEffect(() => {
if (!costData) return;
const rows = costData.data ?? [];
const costByProvider = Object.fromEntries(rows.map((c) => [c.key, c.total_cost_usd]));
const totalCost = rows.reduce((s, c) => s + c.total_cost_usd, 0);
function getSpend(scope: string): number {
return scope === "global" ? totalCost : (costByProvider[scope] ?? 0);
}
const alerts = loadAlerts();
const existing = loadNotifications();
const newNotifs: AppNotification[] = [];
for (const alert of alerts) {
const spend = getSpend(alert.scope);
const pct = alert.threshold > 0 ? (spend / alert.threshold) * 100 : 0;
const isExceeded = spend >= alert.threshold;
const isWarning = !isExceeded && pct >= 80;
const scopeLabel = alert.scope === "global" ? "Dépenses totales" : alert.scope;
// ── Exceeded ───────────────────────────────────────────────────────────
if (isExceeded && !prevExceeded.current.has(alert.id)) {
newNotifs.push({
id: crypto.randomUUID(),
type: "budget_exceeded",
title: `Seuil dépassé : ${alert.name}`,
message: `${scopeLabel} : $${spend.toFixed(6)} — seuil $${alert.threshold.toFixed(6)}`,
alertId: alert.id,
scope: alert.scope,
spend,
threshold: alert.threshold,
createdAt: new Date().toISOString(),
readAt: null,
});
prevExceeded.current.add(alert.id);
prevWarning.current.delete(alert.id); // promote from warning
} else if (!isExceeded) {
prevExceeded.current.delete(alert.id);
}
// ── Warning (80 %) ─────────────────────────────────────────────────────
if (isWarning && !prevWarning.current.has(alert.id)) {
newNotifs.push({
id: crypto.randomUUID(),
type: "budget_warning",
title: `Attention : ${alert.name} à ${pct.toFixed(0)} %`,
message: `${scopeLabel} : $${spend.toFixed(6)} (${pct.toFixed(1)} % du seuil)`,
alertId: alert.id,
scope: alert.scope,
spend,
threshold: alert.threshold,
createdAt: new Date().toISOString(),
readAt: null,
});
prevWarning.current.add(alert.id);
} else if (!isWarning) {
prevWarning.current.delete(alert.id);
}
}
if (newNotifs.length === 0) return;
// Persist new notifications
saveNotifications([...newNotifs, ...existing]);
// Fire-and-forget email delivery if an address is configured
const email = localStorage.getItem(NOTIF_EMAIL_KEY);
if (email) {
for (const n of newNotifs) {
sendEmailNotification(n, email).catch(() => {
// Non-blocking: email failure should never break the UI
});
}
}
}, [costData]);
return null;
}

View File

@ -71,7 +71,7 @@ export function Sidebar() {
{/* Bottom nav */}
<div className="px-3 py-4">
<NavLink
to="/settings"
to="/dashboard/settings"
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",

View File

@ -0,0 +1,42 @@
// ─── Types ────────────────────────────────────────────────────────────────────
export interface AppNotification {
id: string;
type: "budget_exceeded" | "budget_warning";
title: string;
message: string;
alertId: string;
/** Scope of the alert rule: "global" or a provider name. */
scope: string;
/** Actual spend at the time the notification was generated. */
spend: number;
/** Configured threshold that was crossed. */
threshold: number;
createdAt: string;
readAt: string | null;
}
// ─── Storage ──────────────────────────────────────────────────────────────────
const NOTIF_KEY = "veylant_notifications";
/** Custom event dispatched whenever notifications are saved, so sibling components can react. */
export const NOTIF_CHANGE_EVENT = "veylant:notifications";
export function loadNotifications(): AppNotification[] {
try {
return JSON.parse(localStorage.getItem(NOTIF_KEY) ?? "[]");
} catch {
return [];
}
}
/** Persist notifications (max 100) and broadcast the change event. */
export function saveNotifications(notifs: AppNotification[]): void {
localStorage.setItem(NOTIF_KEY, JSON.stringify(notifs.slice(0, 100)));
window.dispatchEvent(new Event(NOTIF_CHANGE_EVENT));
}
export function getUnreadCount(): number {
return loadNotifications().filter((n) => !n.readAt).length;
}

View File

@ -1,9 +1,8 @@
import { useState, useEffect } from "react";
import { Bell, Plus, Trash2, TriangleAlert } from "lucide-react";
import { Bell, Plus, Trash2, TriangleAlert, TrendingUp } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -15,6 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { useCosts } from "@/api/costs";
interface BudgetAlert {
@ -51,8 +51,17 @@ export function AlertsPage() {
const [alerts, setAlerts] = useState<BudgetAlert[]>(loadAlerts);
const [showForm, setShowForm] = useState(false);
const { data: costData } = useCosts({}, 30_000);
const totalCost = costData?.data.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0;
const { data: costData } = useCosts({ group_by: "provider" }, 30_000);
const providerRows = costData?.data ?? [];
const costByProvider = Object.fromEntries(
providerRows.map((c) => [c.key, c.total_cost_usd])
);
const totalCost = providerRows.reduce((s, c) => s + c.total_cost_usd, 0);
const totalRequests = providerRows.reduce((s, c) => s + c.request_count, 0);
function getSpendForScope(scope: string): number {
return scope === "global" ? totalCost : (costByProvider[scope] ?? 0);
}
const form = useForm<AlertFormData>({
resolver: zodResolver(alertSchema),
@ -64,14 +73,14 @@ export function AlertsPage() {
}, [alerts]);
const handleCreate = form.handleSubmit((data) => {
const alert: BudgetAlert = {
const newAlert: BudgetAlert = {
id: crypto.randomUUID(),
name: data.name,
scope: data.scope,
threshold: data.threshold,
createdAt: new Date().toISOString(),
};
setAlerts((prev) => [...prev, alert]);
setAlerts((prev) => [...prev, newAlert]);
form.reset();
setShowForm(false);
});
@ -80,54 +89,85 @@ export function AlertsPage() {
setAlerts((prev) => prev.filter((a) => a.id !== id));
};
const triggeredAlerts = alerts.filter((a) => totalCost >= a.threshold);
const triggeredCount = alerts.filter(
(a) => getSpendForScope(a.scope) >= a.threshold
).length;
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6 max-w-3xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Alertes Budget</h2>
<p className="text-sm text-muted-foreground">
Soyez notifié quand vos dépenses IA dépassent un seuil
Règles de seuil les notifications apparaissent dans la cloche en haut à droite
</p>
</div>
<Button onClick={() => setShowForm(!showForm)}>
<Plus className="h-4 w-4 mr-2" /> Nouvelle alerte
<Button onClick={() => setShowForm((v) => !v)}>
<Plus className="h-4 w-4 mr-2" /> Nouvelle règle
</Button>
</div>
{/* Triggered alerts */}
{triggeredAlerts.length > 0 && (
<Alert variant="destructive">
<TriangleAlert className="h-4 w-4" />
<AlertTitle>
{triggeredAlerts.length} alerte{triggeredAlerts.length > 1 ? "s" : ""} déclenchée
{triggeredAlerts.length > 1 ? "s" : ""}
</AlertTitle>
<AlertDescription>
Coût actuel : <strong>${totalCost.toFixed(4)}</strong> dépasse les seuils :{" "}
{triggeredAlerts.map((a) => a.name).join(", ")}
</AlertDescription>
</Alert>
)}
{/* Current spend */}
<div className="rounded-lg border bg-card p-4">
<p className="text-sm text-muted-foreground">Dépenses totales (période actuelle)</p>
<p className="text-3xl font-bold mt-1">${totalCost.toFixed(4)}</p>
<p className="text-xs text-muted-foreground mt-1">USD · Mis à jour automatiquement</p>
{/* Status strip */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="rounded-lg border bg-card p-4 col-span-2 sm:col-span-1">
<p className="text-xs text-muted-foreground">Total dépensé</p>
<p className="text-2xl font-bold mt-1">${totalCost.toFixed(6)}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{totalRequests.toLocaleString("fr-FR")} req · 30 s
</p>
</div>
{providerRows.slice(0, 3).map((c) => (
<div key={c.key} className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground capitalize">{c.key}</p>
<p className="text-xl font-bold mt-1">${c.total_cost_usd.toFixed(6)}</p>
<p className="text-xs text-muted-foreground mt-0.5">{c.request_count} req.</p>
</div>
))}
</div>
{/* Summary badges */}
{alerts.length > 0 && (
<div className="flex items-center gap-3 text-sm">
<span className="text-muted-foreground">
{alerts.length} règle{alerts.length > 1 ? "s" : ""} configurée
{alerts.length > 1 ? "s" : ""}
</span>
{triggeredCount > 0 && (
<Badge variant="destructive" className="gap-1">
<TriangleAlert className="h-3 w-3" />
{triggeredCount} déclenchée{triggeredCount > 1 ? "s" : ""}
</Badge>
)}
{triggeredCount === 0 && (
<Badge variant="secondary" className="gap-1 text-green-700 bg-green-100">
Tout OK
</Badge>
)}
</div>
)}
{/* Create form */}
{showForm && (
<form onSubmit={handleCreate} className="rounded-lg border p-4 space-y-4">
<h3 className="font-semibold text-sm">Nouvelle alerte budget</h3>
<div className="space-y-1">
<Label htmlFor="alert-name">Nom</Label>
<Input id="alert-name" {...form.register("name")} placeholder="Alerte mensuelle" />
<>
<Separator />
<form onSubmit={handleCreate} className="rounded-lg border bg-card p-5 space-y-4">
<h3 className="font-semibold text-sm">Nouvelle règle d&apos;alerte</h3>
<div className="space-y-1.5">
<Label htmlFor="alert-name">Nom de la règle</Label>
<Input
id="alert-name"
{...form.register("name")}
placeholder="ex. Budget mensuel OpenAI"
/>
{form.formState.errors.name && (
<p className="text-xs text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<div className="space-y-1.5">
<Label>Périmètre</Label>
<Select
value={form.watch("scope")}
@ -137,75 +177,133 @@ export function AlertsPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="global">Global (tous fournisseurs)</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="azure">Azure</SelectItem>
<SelectItem value="mistral">Mistral</SelectItem>
<SelectItem value="ollama">Ollama</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="space-y-1.5">
<Label htmlFor="threshold">Seuil (USD)</Label>
<Input
id="threshold"
type="number"
step="0.01"
step="0.000001"
min="0"
{...form.register("threshold")}
placeholder="10.00"
/>
{form.formState.errors.threshold && (
<p className="text-xs text-destructive">
{form.formState.errors.threshold.message}
</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
<Button
type="button"
variant="outline"
onClick={() => {
setShowForm(false);
form.reset();
}}
>
Annuler
</Button>
<Button type="submit">Créer l&apos;alerte</Button>
<Button type="submit">Créer la règle</Button>
</div>
</form>
</>
)}
{/* Alerts list */}
{/* Rules list */}
<Separator />
{alerts.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center border rounded-lg">
<div className="flex flex-col items-center justify-center h-48 text-center border rounded-lg border-dashed">
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-muted-foreground text-sm">Aucune alerte configurée</p>
<p className="text-sm font-medium text-muted-foreground">Aucune règle configurée</p>
<p className="text-xs text-muted-foreground mt-1">
Créez une règle pour générer des notifications dans la cloche
</p>
</div>
) : (
<div className="space-y-2">
<div className="space-y-3">
{alerts.map((alert) => {
const isTriggered = totalCost >= alert.threshold;
const spend = getSpendForScope(alert.scope);
const pct =
alert.threshold > 0
? Math.min((spend / alert.threshold) * 100, 100)
: 0;
const isTriggered = spend >= alert.threshold;
const isWarning = !isTriggered && pct >= 80;
return (
<div
key={alert.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
<Bell
className={
isTriggered ? "h-5 w-5 text-destructive" : "h-5 w-5 text-muted-foreground"
}
/>
<div>
<p className="font-medium text-sm">{alert.name}</p>
<p className="text-xs text-muted-foreground">
{alert.scope} · Seuil : ${alert.threshold.toFixed(2)}
<div key={alert.id} className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{isTriggered ? (
<TriangleAlert className="h-5 w-5 text-destructive shrink-0" />
) : isWarning ? (
<TrendingUp className="h-5 w-5 text-amber-500 shrink-0" />
) : (
<Bell className="h-5 w-5 text-muted-foreground shrink-0" />
)}
<div className="min-w-0">
<p className="font-medium text-sm truncate">{alert.name}</p>
<p className="text-xs text-muted-foreground capitalize">
{alert.scope === "global" ? "Tous fournisseurs" : alert.scope} · seuil $
{alert.threshold.toFixed(6)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={isTriggered ? "destructive" : "secondary"}>
{isTriggered ? "Déclenchée" : "OK"}
<div className="flex items-center gap-2 shrink-0">
<Badge
variant={isTriggered ? "destructive" : "secondary"}
className={
isWarning
? "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400"
: ""
}
>
{isTriggered ? "Déclenchée" : isWarning ? "Attention" : "OK"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(alert.id)}
title="Supprimer la règle"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
{/* Progress bar */}
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="font-medium">${spend.toFixed(6)}</span>
<span className="text-muted-foreground">{pct.toFixed(1)} % du seuil</span>
<span className="text-muted-foreground">${alert.threshold.toFixed(6)}</span>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isTriggered
? "bg-destructive"
: isWarning
? "bg-amber-500"
: "bg-primary"
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
);
})}
</div>

View File

@ -52,7 +52,7 @@ export function CostsPage() {
const { data: byDept, isLoading: loadingDept } = useCosts({ group_by: "department" }, 60_000);
const [alerts] = useState<BudgetAlert[]>(loadAlerts);
const totalCost = byModel?.data.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0;
const totalCost = byModel?.data?.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0;
const projection = projectMonthEnd(totalCost);
const maxAlert = alerts.length > 0 ? Math.min(...alerts.map((a) => a.threshold)) : null;
const budgetPct = maxAlert ? (totalCost / maxAlert) * 100 : null;
@ -87,10 +87,10 @@ export function CostsPage() {
{overBudget && (
<Alert variant="destructive">
<TriangleAlert className="h-4 w-4" />
<AlertTitle>Alerte budget : {budgetPct!.toFixed(0)}% du seuil atteint</AlertTitle>
<AlertTitle>Alerte budget : {budgetPct!.toFixed(1)}% du seuil atteint</AlertTitle>
<AlertDescription>
Dépenses actuelles : <strong>${totalCost.toFixed(4)}</strong> sur un seuil de{" "}
<strong>${maxAlert!.toFixed(2)}</strong>.
Dépenses actuelles : <strong>${totalCost.toFixed(6)}</strong> sur un seuil de{" "}
<strong>${maxAlert!.toFixed(6)}</strong>.
</AlertDescription>
</Alert>
)}
@ -99,11 +99,11 @@ export function CostsPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">Total période</p>
<p className="text-2xl font-bold mt-1">${totalCost.toFixed(4)}</p>
<p className="text-2xl font-bold mt-1">${totalCost.toFixed(6)}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">Projection fin de mois</p>
<p className="text-2xl font-bold mt-1">${projection.toFixed(4)}</p>
<p className="text-2xl font-bold mt-1">${projection.toFixed(6)}</p>
<p className="text-xs text-muted-foreground mt-1">Extrapolation linéaire</p>
</div>
<div className="rounded-lg border bg-card p-4">
@ -118,15 +118,15 @@ export function CostsPage() {
variant={overBudget ? "destructive" : "secondary"}
className="ml-2 text-xs"
>
{budgetPct!.toFixed(0)}%
{budgetPct!.toFixed(1)}%
</Badge>
)}
</p>
<p className="text-2xl font-bold mt-1">
{maxAlert ? `$${maxAlert.toFixed(2)}` : "—"}
${totalCost.toFixed(6)}
</p>
<p className="text-xs text-muted-foreground mt-1">
{maxAlert ? "Seuil configuré" : "Aucune alerte"}
{maxAlert ? `Seuil : $${maxAlert.toFixed(6)}` : "Aucune alerte configurée"}
</p>
</div>
</div>
@ -212,8 +212,8 @@ export function CostsPage() {
<span className="text-xs text-muted-foreground w-20 text-right">
{c.total_tokens.toLocaleString()} tok.
</span>
<span className="text-sm font-semibold w-24 text-right">
${c.total_cost_usd.toFixed(4)}
<span className="text-sm font-semibold w-28 text-right">
${c.total_cost_usd.toFixed(6)}
</span>
<div className="w-24 bg-muted rounded-full h-1.5">
<div

View File

@ -10,9 +10,9 @@ export function OverviewPage() {
const { data: requestCount, isLoading: logsLoading, dataUpdatedAt } = useRequestCount();
const { data: costData, isLoading: costsLoading } = useCosts({}, 30_000);
const totalCost = costData?.data.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 0;
const totalTokens = costData?.data.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0;
const requestCountByProvider = costData?.data.length ?? 0;
const totalCost = costData?.data?.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 0;
const totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0;
const requestCountByProvider = costData?.data?.length ?? 0;
const lastUpdated = dataUpdatedAt
? format(new Date(dataUpdatedAt), "HH:mm:ss", { locale: fr })
@ -41,7 +41,7 @@ export function OverviewPage() {
/>
<KpiCard
title="Coût total (USD)"
value={`$${totalCost.toFixed(4)}`}
value={`$${totalCost.toFixed(6)}`}
subtitle="Toutes périodes confondues"
icon={DollarSign}
isLoading={costsLoading}
@ -66,7 +66,7 @@ export function OverviewPage() {
<VolumeChart />
{/* Cost by provider */}
{costData && costData.data.length > 0 && (
{(costData?.data?.length ?? 0) > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-lg border bg-card p-4">
<h3 className="text-sm font-semibold mb-3">Coûts par fournisseur</h3>
@ -78,7 +78,7 @@ export function OverviewPage() {
<span className="text-muted-foreground">
{c.request_count.toLocaleString("fr-FR")} req
</span>
<span className="font-medium">${c.total_cost_usd.toFixed(4)}</span>
<span className="font-medium">${c.total_cost_usd.toFixed(6)}</span>
</div>
</div>
))}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { ShieldAlert, Download } from "lucide-react";
import {
BarChart,
@ -47,7 +47,8 @@ const PERIOD_OPTIONS = [
function getPeriodStart(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
d.setUTCDate(d.getUTCDate() - days);
d.setUTCHours(0, 0, 0, 0); // Round to start of UTC day — stable queryKey, better caching
return d.toISOString();
}
@ -96,12 +97,25 @@ function exportCSV(entries: AuditEntry[]) {
export function SecurityPage() {
const [period, setPeriod] = useState("30");
const start = getPeriodStart(parseInt(period, 10));
// Memoised so queryKey stays stable across re-renders for the same period.
const start = useMemo(() => getPeriodStart(parseInt(period, 10)), [period]);
const { data, isLoading } = useAuditLogs({ start, limit: 500 }, 60_000);
const { data, isLoading, error } = useAuditLogs({ start, limit: 500 }, 60_000);
const entries = data?.data ?? [];
const blockedEntries = entries.filter((e) => e.status !== "ok");
if (error) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-6 text-center">
<ShieldAlert className="h-8 w-8 text-destructive mx-auto mb-2" />
<p className="text-sm font-medium text-destructive">Impossible de charger les logs de sécurité</p>
<p className="text-xs text-muted-foreground mt-1">
{error instanceof Error ? error.message : "Erreur inconnue"}
</p>
</div>
);
}
const chartData = buildSensitivityChart(entries);
const topUsers = buildTopUsers(entries);

View File

@ -0,0 +1,552 @@
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { User, Lock, Bell, Palette, Save, Check, Eye, EyeOff, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/auth/AuthProvider";
import { useListUsers, useUpdateUser } from "@/api/users";
// ─── Schemas ──────────────────────────────────────────────────────────────────
const profileSchema = z.object({
name: z.string().min(1, "Le nom est requis"),
});
const passwordSchema = z
.object({
password: z.string().min(8, "Minimum 8 caractères"),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Les mots de passe ne correspondent pas",
path: ["confirmPassword"],
});
type ProfileFormData = z.infer<typeof profileSchema>;
type PasswordFormData = z.infer<typeof passwordSchema>;
// ─── Theme helpers ────────────────────────────────────────────────────────────
function getStoredTheme(): "light" | "dark" {
return (localStorage.getItem("veylant_theme") as "light" | "dark") ?? "light";
}
function applyTheme(theme: "light" | "dark") {
localStorage.setItem("veylant_theme", theme);
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
// ─── Section nav ──────────────────────────────────────────────────────────────
const SECTIONS = [
{ id: "profile", label: "Profil", icon: User },
{ id: "security", label: "Sécurité", icon: Lock },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "appearance", label: "Apparence", icon: Palette },
] as const;
type SectionId = (typeof SECTIONS)[number]["id"];
// ─── Component ────────────────────────────────────────────────────────────────
export function SettingsPage() {
const { user } = useAuth();
const { data: users, isLoading: usersLoading } = useListUsers();
const updateUser = useUpdateUser();
const [activeSection, setActiveSection] = useState<SectionId>("profile");
const [profileSaved, setProfileSaved] = useState(false);
const [passwordSaved, setPasswordSaved] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">(getStoredTheme);
// Notification preferences stored in localStorage
const [notifBudget, setNotifBudget] = useState(
() => localStorage.getItem("veylant_notif_budget") !== "false"
);
const [notifSecurity, setNotifSecurity] = useState(
() => localStorage.getItem("veylant_notif_security") !== "false"
);
const [notifEmail, setNotifEmail] = useState(
() => localStorage.getItem("veylant_notif_email") ?? ""
);
const [emailSaved, setEmailSaved] = useState(false);
// Find the current user's full record from the API (to get department, role, is_active)
const currentUserRecord = users?.find((u) => u.id === user?.id);
const profileForm = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: { name: user?.name ?? "" },
});
const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
defaultValues: { password: "", confirmPassword: "" },
});
// Sync name field once the user record loads from API
useEffect(() => {
if (currentUserRecord) {
profileForm.reset({ name: currentUserRecord.name });
}
}, [currentUserRecord, profileForm]);
const handleProfileSave = profileForm.handleSubmit(async (data) => {
if (!user || !currentUserRecord) return;
await updateUser.mutateAsync({
id: user.id,
email: currentUserRecord.email,
name: data.name,
department: currentUserRecord.department,
role: currentUserRecord.role,
is_active: currentUserRecord.is_active,
});
setProfileSaved(true);
setTimeout(() => setProfileSaved(false), 2500);
});
const handlePasswordSave = passwordForm.handleSubmit(async (data) => {
if (!user || !currentUserRecord) return;
await updateUser.mutateAsync({
id: user.id,
email: currentUserRecord.email,
name: currentUserRecord.name,
department: currentUserRecord.department,
role: currentUserRecord.role,
is_active: currentUserRecord.is_active,
password: data.password,
});
passwordForm.reset();
setPasswordSaved(true);
setTimeout(() => setPasswordSaved(false), 2500);
});
const handleThemeChange = (dark: boolean) => {
const t = dark ? "dark" : "light";
setTheme(t);
applyTheme(t);
};
// Decode token expiry for display
let tokenExpiry: string | null = null;
if (user?.token) {
try {
const payload = JSON.parse(
atob(user.token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
) as { exp?: number };
if (payload.exp) {
tokenExpiry = new Date(payload.exp * 1000).toLocaleString("fr-FR");
}
} catch {
// non-blocking
}
}
const initials = (user?.name ?? user?.email ?? "?")[0].toUpperCase();
return (
<div className="max-w-4xl space-y-6">
<div>
<h2 className="text-xl font-bold">Paramètres</h2>
<p className="text-sm text-muted-foreground">
Gérez votre compte et vos préférences
</p>
</div>
<div className="flex flex-col md:flex-row gap-6">
{/* Left section nav */}
<nav className="md:w-44 shrink-0 flex flex-row md:flex-col gap-1 overflow-x-auto md:overflow-visible">
{SECTIONS.map((s) => (
<button
key={s.id}
onClick={() => setActiveSection(s.id)}
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors text-left whitespace-nowrap w-full ${
activeSection === s.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<s.icon className="h-4 w-4 shrink-0" />
{s.label}
</button>
))}
</nav>
{/* Section content */}
<div className="flex-1 min-w-0 space-y-4">
{/* ── Profil ──────────────────────────────────────────────────────── */}
{activeSection === "profile" && (
<>
<div className="rounded-lg border bg-card p-6 space-y-5">
<h3 className="font-semibold">Informations du profil</h3>
{/* Avatar + identity */}
<div className="flex items-center gap-4">
<div className="h-14 w-14 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-xl font-bold shrink-0">
{initials}
</div>
<div className="min-w-0">
{usersLoading ? (
<Skeleton className="h-5 w-32 mb-1" />
) : (
<p className="font-medium truncate">
{currentUserRecord?.name ?? user?.name ?? user?.email}
</p>
)}
<p className="text-sm text-muted-foreground truncate">{user?.email}</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{user?.roles.map((r) => (
<Badge key={r} variant="secondary" className="capitalize text-xs">
{r}
</Badge>
))}
{currentUserRecord?.department && (
<Badge variant="outline" className="text-xs">
{currentUserRecord.department}
</Badge>
)}
</div>
</div>
</div>
<Separator />
{/* Name edit form */}
<form onSubmit={handleProfileSave} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="name">Nom affiché</Label>
<Input id="name" {...profileForm.register("name")} />
{profileForm.formState.errors.name && (
<p className="text-xs text-destructive">
{profileForm.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label>Adresse email</Label>
<Input value={user?.email ?? ""} disabled className="opacity-60" />
</div>
</div>
<Button
type="submit"
disabled={updateUser.isPending || !currentUserRecord}
className="w-fit"
>
{profileSaved ? (
<>
<Check className="h-4 w-4 mr-2" /> Sauvegardé
</>
) : (
<>
<Save className="h-4 w-4 mr-2" /> Sauvegarder
</>
)}
</Button>
{updateUser.error && (
<p className="text-xs text-destructive">
{updateUser.error instanceof Error
? updateUser.error.message
: "Erreur lors de la mise à jour"}
</p>
)}
</form>
</div>
{/* Password change */}
<div className="rounded-lg border bg-card p-6 space-y-4">
<h3 className="font-semibold">Changer le mot de passe</h3>
<form onSubmit={handlePasswordSave} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="new-password">Nouveau mot de passe</Label>
<div className="relative">
<Input
id="new-password"
type={showPassword ? "text" : "password"}
{...passwordForm.register("password")}
autoComplete="new-password"
/>
<button
type="button"
tabIndex={-1}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{passwordForm.formState.errors.password && (
<p className="text-xs text-destructive">
{passwordForm.formState.errors.password.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">Confirmer le mot de passe</Label>
<Input
id="confirm-password"
type="password"
{...passwordForm.register("confirmPassword")}
autoComplete="new-password"
/>
{passwordForm.formState.errors.confirmPassword && (
<p className="text-xs text-destructive">
{passwordForm.formState.errors.confirmPassword.message}
</p>
)}
</div>
</div>
<Button
type="submit"
variant="outline"
disabled={updateUser.isPending || !currentUserRecord}
className="w-fit"
>
{passwordSaved ? (
<>
<Check className="h-4 w-4 mr-2" /> Mot de passe mis à jour
</>
) : (
"Mettre à jour le mot de passe"
)}
</Button>
</form>
</div>
</>
)}
{/* ── Sécurité ────────────────────────────────────────────────────── */}
{activeSection === "security" && (
<div className="space-y-4">
<div className="rounded-lg border bg-card p-6 space-y-4">
<h3 className="font-semibold">Session active</h3>
<dl className="space-y-3">
<div className="flex justify-between text-sm">
<dt className="text-muted-foreground">Identifiant</dt>
<dd className="font-mono text-xs">{user?.id ?? "—"}</dd>
</div>
<Separator />
<div className="flex justify-between text-sm">
<dt className="text-muted-foreground">Email</dt>
<dd>{user?.email ?? "—"}</dd>
</div>
<Separator />
<div className="flex justify-between text-sm">
<dt className="text-muted-foreground">Rôle</dt>
<dd className="capitalize">{user?.roles[0] ?? "—"}</dd>
</div>
{currentUserRecord?.department && (
<>
<Separator />
<div className="flex justify-between text-sm">
<dt className="text-muted-foreground">Département</dt>
<dd>{currentUserRecord.department}</dd>
</div>
</>
)}
{tokenExpiry && (
<>
<Separator />
<div className="flex justify-between text-sm">
<dt className="text-muted-foreground">Expiration du token</dt>
<dd>{tokenExpiry}</dd>
</div>
</>
)}
</dl>
</div>
<div className="rounded-lg border bg-card p-6 space-y-3">
<h3 className="font-semibold">Méthode d&apos;authentification</h3>
<div className="flex items-center gap-2 text-sm">
<div className="h-2 w-2 rounded-full bg-green-500 shrink-0" />
<span>Email + mot de passe · JWT HS256</span>
</div>
<p className="text-xs text-muted-foreground">
Les tokens sont signés avec HS256 et expirent automatiquement selon la
configuration du serveur. À la déconnexion, le token est supprimé du navigateur.
</p>
</div>
</div>
)}
{/* ── Notifications ────────────────────────────────────────────────── */}
{activeSection === "notifications" && (
<div className="space-y-4">
{/* Email notifications */}
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-primary" />
<h3 className="font-semibold">Notifications par email</h3>
</div>
<p className="text-xs text-muted-foreground">
Recevez une copie de chaque notification (seuil dépassé, alerte 80 %) à l&apos;adresse
ci-dessous. Nécessite la configuration SMTP côté serveur
(<code className="text-xs bg-muted px-1 rounded">config.yaml notifications.smtp</code>).
</p>
<div className="space-y-1.5">
<Label htmlFor="notif-email">Adresse email de notification</Label>
<div className="flex gap-2">
<Input
id="notif-email"
type="email"
value={notifEmail}
onChange={(e) => setNotifEmail(e.target.value)}
placeholder="equipe-finance@entreprise.com"
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => {
localStorage.setItem("veylant_notif_email", notifEmail);
window.dispatchEvent(new Event("veylant:notifications"));
setEmailSaved(true);
setTimeout(() => setEmailSaved(false), 2500);
}}
>
{emailSaved ? (
<>
<Check className="h-4 w-4 mr-1.5" /> Sauvegardé
</>
) : (
<>
<Save className="h-4 w-4 mr-1.5" /> Enregistrer
</>
)}
</Button>
</div>
{notifEmail && (
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1 mt-1">
<Check className="h-3 w-3" />
Email configuré les notifications seront envoyées à{" "}
<strong>{notifEmail}</strong>
</p>
)}
{!notifEmail && (
<p className="text-xs text-muted-foreground">
Laissez vide pour désactiver les notifications email.
</p>
)}
</div>
{notifEmail && (
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-muted-foreground">
Supprimer l&apos;email configuré
</p>
<Button
variant="ghost"
size="sm"
className="text-xs text-destructive hover:text-destructive h-7"
onClick={() => {
setNotifEmail("");
localStorage.removeItem("veylant_notif_email");
window.dispatchEvent(new Event("veylant:notifications"));
}}
>
Retirer
</Button>
</div>
)}
</div>
{/* In-app notification toggles */}
<div className="rounded-lg border bg-card p-6 space-y-5">
<h3 className="font-semibold">Notifications dans l&apos;application</h3>
<p className="text-xs text-muted-foreground -mt-2">
Ces préférences contrôlent la cloche de notifications dans l&apos;en-tête.
</p>
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Alertes budget</p>
<p className="text-xs text-muted-foreground mt-0.5">
Notification quand un seuil de dépense est atteint ou dépassé
</p>
</div>
<Switch
checked={notifBudget}
onCheckedChange={(v) => {
setNotifBudget(v);
localStorage.setItem("veylant_notif_budget", String(v));
}}
/>
</div>
<Separator />
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Alertes sécurité</p>
<p className="text-xs text-muted-foreground mt-0.5">
Notifications pour les requêtes de sensibilité critique ou haute
</p>
</div>
<Switch
checked={notifSecurity}
onCheckedChange={(v) => {
setNotifSecurity(v);
localStorage.setItem("veylant_notif_security", String(v));
}}
/>
</div>
</div>
</div>
</div>
)}
{/* ── Apparence ───────────────────────────────────────────────────── */}
{activeSection === "appearance" && (
<div className="rounded-lg border bg-card p-6 space-y-5">
<h3 className="font-semibold">Apparence</h3>
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Mode sombre</p>
<p className="text-xs text-muted-foreground mt-0.5">
Basculer vers un thème sombre pour réduire la fatigue visuelle
</p>
</div>
<Switch checked={theme === "dark"} onCheckedChange={handleThemeChange} />
</div>
<Separator />
<div>
<p className="text-sm font-medium">Langue de l&apos;interface</p>
<p className="text-xs text-muted-foreground mt-0.5">
Français (FR) seule langue disponible en V1
</p>
</div>
<Separator />
<div>
<p className="text-sm font-medium">Format de date</p>
<p className="text-xs text-muted-foreground mt-0.5">
JJ/MM/AAAA · HH:mm locale française
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -13,6 +13,7 @@ import { PlaygroundPage } from "@/pages/PlaygroundPage";
import { SecurityPage } from "@/pages/SecurityPage";
import { CostsPage } from "@/pages/CostsPage";
import { CompliancePage } from "@/pages/CompliancePage";
import { SettingsPage } from "@/pages/SettingsPage";
import { NotFoundPage } from "@/pages/NotFoundPage";
// Documentation site
@ -77,6 +78,7 @@ export const router = createBrowserRouter([
{ path: "alerts", element: <AlertsPage /> },
{ path: "logs", element: <LogsPage /> },
{ path: "compliance", element: <CompliancePage /> },
{ path: "settings", element: <SettingsPage /> },
{ path: "*", element: <NotFoundPage /> },
],
},