fix
This commit is contained in:
parent
7dfb4e84f4
commit
3051f71edd
@ -54,7 +54,7 @@ LLM Provider Adapters (OpenAI, Anthropic, Azure, Mistral, Ollama)
|
|||||||
- Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin)
|
- Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin)
|
||||||
- HashiCorp Vault — secrets and API key rotation (90-day cycle)
|
- 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.
|
**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**.
|
Use `make` as the primary interface. The proxy runs on **:8090**, PII HTTP on **:8091**, PII gRPC on **:50051**.
|
||||||
|
|
||||||
```bash
|
```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-down # Stop and remove all containers and volumes
|
||||||
make dev-logs # Tail logs from all services
|
make dev-logs # Tail logs from all services
|
||||||
make build # go build → bin/proxy
|
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()`).
|
**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`.
|
**Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`.
|
||||||
|
|
||||||
**Tenant onboarding** (after `make dev`):
|
**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:
|
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
|
- **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)
|
- **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.
|
In production (`server.env=production`), any of the above causes a fatal startup error.
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/veylant/ia-gateway/internal/health"
|
"github.com/veylant/ia-gateway/internal/health"
|
||||||
"github.com/veylant/ia-gateway/internal/metrics"
|
"github.com/veylant/ia-gateway/internal/metrics"
|
||||||
"github.com/veylant/ia-gateway/internal/middleware"
|
"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/pii"
|
||||||
"github.com/veylant/ia-gateway/internal/provider"
|
"github.com/veylant/ia-gateway/internal/provider"
|
||||||
"github.com/veylant/ia-gateway/internal/provider/anthropic"
|
"github.com/veylant/ia-gateway/internal/provider/anthropic"
|
||||||
@ -225,6 +226,13 @@ func main() {
|
|||||||
logger.Warn("clickhouse.dsn not set — audit logging disabled")
|
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) ──────
|
// ── Feature flag store (E4-12 zero-retention + future flags + E11-07) ──────
|
||||||
var flagStore flags.FlagStore
|
var flagStore flags.FlagStore
|
||||||
if db != nil {
|
if db != nil {
|
||||||
@ -237,6 +245,31 @@ func main() {
|
|||||||
// Wire flag store into the provider router so it can check routing_enabled (E11-07).
|
// Wire flag store into the provider router so it can check routing_enabled (E11-07).
|
||||||
providerRouter.WithFlagStore(flagStore)
|
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 ─────────────────────────────────────────────────────────
|
// ── Proxy handler ─────────────────────────────────────────────────────────
|
||||||
proxyHandler := proxy.NewWithAudit(providerRouter, logger, piiClient, auditLogger, encryptor).
|
proxyHandler := proxy.NewWithAudit(providerRouter, logger, piiClient, auditLogger, encryptor).
|
||||||
WithFlagStore(flagStore)
|
WithFlagStore(flagStore)
|
||||||
@ -308,6 +341,11 @@ func main() {
|
|||||||
|
|
||||||
r.Post("/chat/completions", proxyHandler.ServeHTTP)
|
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).
|
// PII analyze endpoint for Playground (E8-11, Sprint 8).
|
||||||
piiAnalyzeHandler := pii.NewAnalyzeHandler(piiClient, logger)
|
piiAnalyzeHandler := pii.NewAnalyzeHandler(piiClient, logger)
|
||||||
r.Post("/pii/analyze", piiAnalyzeHandler.ServeHTTP)
|
r.Post("/pii/analyze", piiAnalyzeHandler.ServeHTTP)
|
||||||
|
|||||||
13
config.yaml
13
config.yaml
@ -114,3 +114,16 @@ rate_limit:
|
|||||||
default_tenant_burst: 200
|
default_tenant_burst: 200
|
||||||
default_user_rpm: 100
|
default_user_rpm: 100
|
||||||
default_user_burst: 20
|
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"
|
||||||
|
|||||||
@ -9,28 +9,28 @@ import "time"
|
|||||||
// prompt_anonymized is stored encrypted (AES-256-GCM) and is never
|
// prompt_anonymized is stored encrypted (AES-256-GCM) and is never
|
||||||
// returned to API callers.
|
// returned to API callers.
|
||||||
type AuditEntry struct {
|
type AuditEntry struct {
|
||||||
RequestID string
|
RequestID string `json:"request_id"`
|
||||||
TenantID string
|
TenantID string `json:"tenant_id"`
|
||||||
UserID string
|
UserID string `json:"user_id"`
|
||||||
Timestamp time.Time
|
Timestamp time.Time `json:"timestamp"`
|
||||||
ModelRequested string
|
ModelRequested string `json:"model_requested"`
|
||||||
ModelUsed string
|
ModelUsed string `json:"model_used"`
|
||||||
Provider string
|
Provider string `json:"provider"`
|
||||||
Department string
|
Department string `json:"department"`
|
||||||
UserRole string
|
UserRole string `json:"user_role"`
|
||||||
PromptHash string // hex SHA-256 of the original (pre-PII) prompt
|
PromptHash string `json:"prompt_hash"`
|
||||||
ResponseHash string // hex SHA-256 of the response content
|
ResponseHash string `json:"response_hash"`
|
||||||
PromptAnonymized string // AES-256-GCM base64-encoded anonymized prompt
|
PromptAnonymized string `json:"-"` // AES-256-GCM base64-encoded anonymized prompt — never returned to API callers
|
||||||
SensitivityLevel string // "none"|"low"|"medium"|"high"|"critical"
|
SensitivityLevel string `json:"sensitivity_level"` // "none"|"low"|"medium"|"high"|"critical"
|
||||||
TokenInput int
|
TokenInput int `json:"token_input"`
|
||||||
TokenOutput int
|
TokenOutput int `json:"token_output"`
|
||||||
TokenTotal int
|
TokenTotal int `json:"token_total"`
|
||||||
CostUSD float64
|
CostUSD float64 `json:"cost_usd"`
|
||||||
LatencyMs int
|
LatencyMs int `json:"latency_ms"`
|
||||||
Status string // "ok"|"error"
|
Status string `json:"status"` // "ok"|"error"
|
||||||
ErrorType string
|
ErrorType string `json:"error_type"`
|
||||||
PIIEntityCount int
|
PIIEntityCount int `json:"pii_entity_count"`
|
||||||
Stream bool
|
Stream bool `json:"stream"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditQuery filters audit log entries for the GET /v1/admin/logs endpoint.
|
// 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.
|
// AuditResult is the paginated response for AuditQuery.
|
||||||
type AuditResult struct {
|
type AuditResult struct {
|
||||||
Data []AuditEntry
|
Data []AuditEntry `json:"data"`
|
||||||
Total int
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CostQuery filters cost aggregation for the GET /v1/admin/costs endpoint.
|
// 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.
|
// CostSummary is one row in a cost aggregation result.
|
||||||
type CostSummary struct {
|
type CostSummary struct {
|
||||||
Key string
|
Key string `json:"key"`
|
||||||
TotalTokens int
|
TotalTokens int `json:"total_tokens"`
|
||||||
TotalCostUSD float64
|
TotalCostUSD float64 `json:"total_cost_usd"`
|
||||||
RequestCount int
|
RequestCount int `json:"request_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CostResult is the response for CostQuery.
|
// CostResult is the response for CostQuery.
|
||||||
type CostResult struct {
|
type CostResult struct {
|
||||||
Data []CostSummary
|
Data []CostSummary `json:"data"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,7 +52,7 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error)
|
|||||||
}
|
}
|
||||||
minLevel := sensitivityOrder[q.MinSensitivity]
|
minLevel := sensitivityOrder[q.MinSensitivity]
|
||||||
|
|
||||||
var filtered []AuditEntry
|
filtered := make([]AuditEntry, 0)
|
||||||
for _, e := range m.entries {
|
for _, e := range m.entries {
|
||||||
if e.TenantID != q.TenantID {
|
if e.TenantID != q.TenantID {
|
||||||
continue
|
continue
|
||||||
@ -81,7 +81,7 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error)
|
|||||||
if q.Offset < len(filtered) {
|
if q.Offset < len(filtered) {
|
||||||
filtered = filtered[q.Offset:]
|
filtered = filtered[q.Offset:]
|
||||||
} else {
|
} else {
|
||||||
filtered = nil
|
filtered = make([]AuditEntry, 0)
|
||||||
}
|
}
|
||||||
limit := q.Limit
|
limit := q.Limit
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
@ -133,7 +133,7 @@ func (m *MemLogger) QueryCosts(_ context.Context, q CostQuery) (*CostResult, err
|
|||||||
totals[key].count++
|
totals[key].count++
|
||||||
}
|
}
|
||||||
|
|
||||||
var data []CostSummary
|
data := make([]CostSummary, 0)
|
||||||
for k, v := range totals {
|
for k, v := range totals {
|
||||||
data = append(data, CostSummary{
|
data = append(data, CostSummary{
|
||||||
Key: k,
|
Key: k,
|
||||||
|
|||||||
@ -24,6 +24,25 @@ type Config struct {
|
|||||||
ClickHouse ClickHouseConfig `mapstructure:"clickhouse"`
|
ClickHouse ClickHouseConfig `mapstructure:"clickhouse"`
|
||||||
Crypto CryptoConfig `mapstructure:"crypto"`
|
Crypto CryptoConfig `mapstructure:"crypto"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
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
|
// 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_tenant_burst", 200)
|
||||||
v.SetDefault("rate_limit.default_user_rpm", 100)
|
v.SetDefault("rate_limit.default_user_rpm", 100)
|
||||||
v.SetDefault("rate_limit.default_user_burst", 20)
|
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 err := v.ReadInConfig(); err != nil {
|
||||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||||
|
|||||||
176
internal/notifications/handler.go
Normal file
176
internal/notifications/handler.go
Normal 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
|
||||||
|
}
|
||||||
102
internal/notifications/mailer.go
Normal file
102
internal/notifications/mailer.go
Normal 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()
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { Header } from "./Header";
|
import { Header } from "./Header";
|
||||||
|
import { NotificationWatcher } from "./NotificationWatcher";
|
||||||
import { useRegisterTokenAccessor } from "@/auth/AuthProvider";
|
import { useRegisterTokenAccessor } from "@/auth/AuthProvider";
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
@ -12,6 +13,8 @@ export function AppLayout() {
|
|||||||
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
{/* Invisible watcher: polls costs every 30 s and generates notifications */}
|
||||||
|
<NotificationWatcher />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,70 +1,34 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { LogOut, User } from "lucide-react";
|
||||||
import { LogOut, User, Bell } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "@/auth/AuthProvider";
|
import { useAuth } from "@/auth/AuthProvider";
|
||||||
import { useCosts } from "@/api/costs";
|
import { NotificationCenter } from "./NotificationCenter";
|
||||||
|
|
||||||
const breadcrumbMap: Record<string, string> = {
|
const breadcrumbMap: Record<string, string> = {
|
||||||
"/": "Vue d'ensemble",
|
"/dashboard": "Vue d'ensemble",
|
||||||
"/policies": "Politiques de routage",
|
"/dashboard/playground": "Playground IA",
|
||||||
"/users": "Utilisateurs",
|
"/dashboard/policies": "Politiques de routage",
|
||||||
"/providers": "Fournisseurs IA",
|
"/dashboard/users": "Utilisateurs",
|
||||||
"/alerts": "Alertes Budget",
|
"/dashboard/providers": "Fournisseurs IA",
|
||||||
"/logs": "Journaux d'audit",
|
"/dashboard/security": "Sécurité RSSI",
|
||||||
"/settings": "Paramètres",
|
"/dashboard/costs": "Coûts IA",
|
||||||
"/playground": "Playground IA",
|
"/dashboard/alerts": "Alertes Budget",
|
||||||
"/security": "Sécurité RSSI",
|
"/dashboard/logs": "Journaux d'audit",
|
||||||
"/costs": "Coûts IA",
|
"/dashboard/compliance": "Conformité RGPD / AI Act",
|
||||||
"/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() {
|
export function Header() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const title = breadcrumbMap[location.pathname] ?? "Dashboard";
|
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 (
|
return (
|
||||||
<header className="h-14 border-b bg-card flex items-center justify-between px-6 shrink-0">
|
<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>
|
<h1 className="text-base font-semibold">{title}</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Bell badge — E8-13 */}
|
<NotificationCenter />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
@ -75,6 +39,7 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" onClick={logout} title="Se déconnecter">
|
<Button variant="ghost" size="icon" onClick={logout} title="Se déconnecter">
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
272
web/src/components/NotificationCenter.tsx
Normal file
272
web/src/components/NotificationCenter.tsx
Normal 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'alerte →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
web/src/components/NotificationWatcher.tsx
Normal file
138
web/src/components/NotificationWatcher.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -71,7 +71,7 @@ export function Sidebar() {
|
|||||||
{/* Bottom nav */}
|
{/* Bottom nav */}
|
||||||
<div className="px-3 py-4">
|
<div className="px-3 py-4">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/dashboard/settings"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
|||||||
42
web/src/lib/notifications.ts
Normal file
42
web/src/lib/notifications.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@ -15,6 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useCosts } from "@/api/costs";
|
import { useCosts } from "@/api/costs";
|
||||||
|
|
||||||
interface BudgetAlert {
|
interface BudgetAlert {
|
||||||
@ -51,8 +51,17 @@ export function AlertsPage() {
|
|||||||
const [alerts, setAlerts] = useState<BudgetAlert[]>(loadAlerts);
|
const [alerts, setAlerts] = useState<BudgetAlert[]>(loadAlerts);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
const { data: costData } = useCosts({}, 30_000);
|
const { data: costData } = useCosts({ group_by: "provider" }, 30_000);
|
||||||
const totalCost = costData?.data.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0;
|
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>({
|
const form = useForm<AlertFormData>({
|
||||||
resolver: zodResolver(alertSchema),
|
resolver: zodResolver(alertSchema),
|
||||||
@ -64,14 +73,14 @@ export function AlertsPage() {
|
|||||||
}, [alerts]);
|
}, [alerts]);
|
||||||
|
|
||||||
const handleCreate = form.handleSubmit((data) => {
|
const handleCreate = form.handleSubmit((data) => {
|
||||||
const alert: BudgetAlert = {
|
const newAlert: BudgetAlert = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: data.name,
|
name: data.name,
|
||||||
scope: data.scope,
|
scope: data.scope,
|
||||||
threshold: data.threshold,
|
threshold: data.threshold,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
setAlerts((prev) => [...prev, alert]);
|
setAlerts((prev) => [...prev, newAlert]);
|
||||||
form.reset();
|
form.reset();
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
});
|
});
|
||||||
@ -80,54 +89,85 @@ export function AlertsPage() {
|
|||||||
setAlerts((prev) => prev.filter((a) => a.id !== id));
|
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 (
|
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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold">Alertes Budget</h2>
|
<h2 className="text-xl font-bold">Alertes Budget</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setShowForm(!showForm)}>
|
<Button onClick={() => setShowForm((v) => !v)}>
|
||||||
<Plus className="h-4 w-4 mr-2" /> Nouvelle alerte
|
<Plus className="h-4 w-4 mr-2" /> Nouvelle règle
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Triggered alerts */}
|
{/* Status strip */}
|
||||||
{triggeredAlerts.length > 0 && (
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<Alert variant="destructive">
|
<div className="rounded-lg border bg-card p-4 col-span-2 sm:col-span-1">
|
||||||
<TriangleAlert className="h-4 w-4" />
|
<p className="text-xs text-muted-foreground">Total dépensé</p>
|
||||||
<AlertTitle>
|
<p className="text-2xl font-bold mt-1">${totalCost.toFixed(6)}</p>
|
||||||
{triggeredAlerts.length} alerte{triggeredAlerts.length > 1 ? "s" : ""} déclenchée
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{triggeredAlerts.length > 1 ? "s" : ""}
|
{totalRequests.toLocaleString("fr-FR")} req · 30 s
|
||||||
</AlertTitle>
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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 */}
|
{/* Create form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleCreate} className="rounded-lg border p-4 space-y-4">
|
<>
|
||||||
<h3 className="font-semibold text-sm">Nouvelle alerte budget</h3>
|
<Separator />
|
||||||
<div className="space-y-1">
|
<form onSubmit={handleCreate} className="rounded-lg border bg-card p-5 space-y-4">
|
||||||
<Label htmlFor="alert-name">Nom</Label>
|
<h3 className="font-semibold text-sm">Nouvelle règle d'alerte</h3>
|
||||||
<Input id="alert-name" {...form.register("name")} placeholder="Alerte mensuelle" />
|
<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>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<Label>Périmètre</Label>
|
<Label>Périmètre</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.watch("scope")}
|
value={form.watch("scope")}
|
||||||
@ -137,75 +177,133 @@ export function AlertsPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="global">Global</SelectItem>
|
<SelectItem value="global">Global (tous fournisseurs)</SelectItem>
|
||||||
<SelectItem value="openai">OpenAI</SelectItem>
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||||
<SelectItem value="azure">Azure</SelectItem>
|
<SelectItem value="azure">Azure</SelectItem>
|
||||||
<SelectItem value="mistral">Mistral</SelectItem>
|
<SelectItem value="mistral">Mistral</SelectItem>
|
||||||
|
<SelectItem value="ollama">Ollama</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="threshold">Seuil (USD)</Label>
|
<Label htmlFor="threshold">Seuil (USD)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="threshold"
|
id="threshold"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.000001"
|
||||||
|
min="0"
|
||||||
{...form.register("threshold")}
|
{...form.register("threshold")}
|
||||||
placeholder="10.00"
|
placeholder="10.00"
|
||||||
/>
|
/>
|
||||||
|
{form.formState.errors.threshold && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.threshold.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">Créer l'alerte</Button>
|
<Button type="submit">Créer la règle</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alerts list */}
|
{/* Rules list */}
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{alerts.length === 0 ? (
|
{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" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{alerts.map((alert) => {
|
{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 (
|
return (
|
||||||
<div
|
<div key={alert.id} className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
key={alert.id}
|
<div className="flex items-start justify-between gap-3">
|
||||||
className="flex items-center justify-between rounded-lg border p-4"
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
>
|
{isTriggered ? (
|
||||||
<div className="flex items-center gap-3">
|
<TriangleAlert className="h-5 w-5 text-destructive shrink-0" />
|
||||||
<Bell
|
) : isWarning ? (
|
||||||
className={
|
<TrendingUp className="h-5 w-5 text-amber-500 shrink-0" />
|
||||||
isTriggered ? "h-5 w-5 text-destructive" : "h-5 w-5 text-muted-foreground"
|
) : (
|
||||||
}
|
<Bell className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
/>
|
)}
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-sm">{alert.name}</p>
|
<p className="font-medium text-sm truncate">{alert.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
{alert.scope} · Seuil : ${alert.threshold.toFixed(2)}
|
{alert.scope === "global" ? "Tous fournisseurs" : alert.scope} · seuil $
|
||||||
|
{alert.threshold.toFixed(6)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Badge variant={isTriggered ? "destructive" : "secondary"}>
|
<Badge
|
||||||
{isTriggered ? "Déclenchée" : "OK"}
|
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>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDelete(alert.id)}
|
onClick={() => handleDelete(alert.id)}
|
||||||
|
title="Supprimer la règle"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export function CostsPage() {
|
|||||||
const { data: byDept, isLoading: loadingDept } = useCosts({ group_by: "department" }, 60_000);
|
const { data: byDept, isLoading: loadingDept } = useCosts({ group_by: "department" }, 60_000);
|
||||||
const [alerts] = useState<BudgetAlert[]>(loadAlerts);
|
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 projection = projectMonthEnd(totalCost);
|
||||||
const maxAlert = alerts.length > 0 ? Math.min(...alerts.map((a) => a.threshold)) : null;
|
const maxAlert = alerts.length > 0 ? Math.min(...alerts.map((a) => a.threshold)) : null;
|
||||||
const budgetPct = maxAlert ? (totalCost / maxAlert) * 100 : null;
|
const budgetPct = maxAlert ? (totalCost / maxAlert) * 100 : null;
|
||||||
@ -87,10 +87,10 @@ export function CostsPage() {
|
|||||||
{overBudget && (
|
{overBudget && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<TriangleAlert className="h-4 w-4" />
|
<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>
|
<AlertDescription>
|
||||||
Dépenses actuelles : <strong>${totalCost.toFixed(4)}</strong> sur un seuil de{" "}
|
Dépenses actuelles : <strong>${totalCost.toFixed(6)}</strong> sur un seuil de{" "}
|
||||||
<strong>${maxAlert!.toFixed(2)}</strong>.
|
<strong>${maxAlert!.toFixed(6)}</strong>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -99,11 +99,11 @@ export function CostsPage() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<p className="text-xs text-muted-foreground">Total période</p>
|
<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>
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<p className="text-xs text-muted-foreground">Projection fin de mois</p>
|
<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>
|
<p className="text-xs text-muted-foreground mt-1">Extrapolation linéaire</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
@ -118,15 +118,15 @@ export function CostsPage() {
|
|||||||
variant={overBudget ? "destructive" : "secondary"}
|
variant={overBudget ? "destructive" : "secondary"}
|
||||||
className="ml-2 text-xs"
|
className="ml-2 text-xs"
|
||||||
>
|
>
|
||||||
{budgetPct!.toFixed(0)}%
|
{budgetPct!.toFixed(1)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold mt-1">
|
<p className="text-2xl font-bold mt-1">
|
||||||
{maxAlert ? `$${maxAlert.toFixed(2)}` : "—"}
|
${totalCost.toFixed(6)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{maxAlert ? "Seuil configuré" : "Aucune alerte"}
|
{maxAlert ? `Seuil : $${maxAlert.toFixed(6)}` : "Aucune alerte configurée"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -212,8 +212,8 @@ export function CostsPage() {
|
|||||||
<span className="text-xs text-muted-foreground w-20 text-right">
|
<span className="text-xs text-muted-foreground w-20 text-right">
|
||||||
{c.total_tokens.toLocaleString()} tok.
|
{c.total_tokens.toLocaleString()} tok.
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold w-24 text-right">
|
<span className="text-sm font-semibold w-28 text-right">
|
||||||
${c.total_cost_usd.toFixed(4)}
|
${c.total_cost_usd.toFixed(6)}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-24 bg-muted rounded-full h-1.5">
|
<div className="w-24 bg-muted rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -10,9 +10,9 @@ export function OverviewPage() {
|
|||||||
const { data: requestCount, isLoading: logsLoading, dataUpdatedAt } = useRequestCount();
|
const { data: requestCount, isLoading: logsLoading, dataUpdatedAt } = useRequestCount();
|
||||||
const { data: costData, isLoading: costsLoading } = useCosts({}, 30_000);
|
const { data: costData, isLoading: costsLoading } = useCosts({}, 30_000);
|
||||||
|
|
||||||
const totalCost = costData?.data.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 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 totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0;
|
||||||
const requestCountByProvider = costData?.data.length ?? 0;
|
const requestCountByProvider = costData?.data?.length ?? 0;
|
||||||
|
|
||||||
const lastUpdated = dataUpdatedAt
|
const lastUpdated = dataUpdatedAt
|
||||||
? format(new Date(dataUpdatedAt), "HH:mm:ss", { locale: fr })
|
? format(new Date(dataUpdatedAt), "HH:mm:ss", { locale: fr })
|
||||||
@ -41,7 +41,7 @@ export function OverviewPage() {
|
|||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Coût total (USD)"
|
title="Coût total (USD)"
|
||||||
value={`$${totalCost.toFixed(4)}`}
|
value={`$${totalCost.toFixed(6)}`}
|
||||||
subtitle="Toutes périodes confondues"
|
subtitle="Toutes périodes confondues"
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
isLoading={costsLoading}
|
isLoading={costsLoading}
|
||||||
@ -66,7 +66,7 @@ export function OverviewPage() {
|
|||||||
<VolumeChart />
|
<VolumeChart />
|
||||||
|
|
||||||
{/* Cost by provider */}
|
{/* 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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<h3 className="text-sm font-semibold mb-3">Coûts par fournisseur</h3>
|
<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">
|
<span className="text-muted-foreground">
|
||||||
{c.request_count.toLocaleString("fr-FR")} req
|
{c.request_count.toLocaleString("fr-FR")} req
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { ShieldAlert, Download } from "lucide-react";
|
import { ShieldAlert, Download } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
@ -47,7 +47,8 @@ const PERIOD_OPTIONS = [
|
|||||||
|
|
||||||
function getPeriodStart(days: number): string {
|
function getPeriodStart(days: number): string {
|
||||||
const d = new Date();
|
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();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,12 +97,25 @@ function exportCSV(entries: AuditEntry[]) {
|
|||||||
|
|
||||||
export function SecurityPage() {
|
export function SecurityPage() {
|
||||||
const [period, setPeriod] = useState("30");
|
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 entries = data?.data ?? [];
|
||||||
const blockedEntries = entries.filter((e) => e.status !== "ok");
|
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 chartData = buildSensitivityChart(entries);
|
||||||
const topUsers = buildTopUsers(entries);
|
const topUsers = buildTopUsers(entries);
|
||||||
|
|
||||||
|
|||||||
552
web/src/pages/SettingsPage.tsx
Normal file
552
web/src/pages/SettingsPage.tsx
Normal 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'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'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'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'application</h3>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">
|
||||||
|
Ces préférences contrôlent la cloche de notifications dans l'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import { PlaygroundPage } from "@/pages/PlaygroundPage";
|
|||||||
import { SecurityPage } from "@/pages/SecurityPage";
|
import { SecurityPage } from "@/pages/SecurityPage";
|
||||||
import { CostsPage } from "@/pages/CostsPage";
|
import { CostsPage } from "@/pages/CostsPage";
|
||||||
import { CompliancePage } from "@/pages/CompliancePage";
|
import { CompliancePage } from "@/pages/CompliancePage";
|
||||||
|
import { SettingsPage } from "@/pages/SettingsPage";
|
||||||
import { NotFoundPage } from "@/pages/NotFoundPage";
|
import { NotFoundPage } from "@/pages/NotFoundPage";
|
||||||
|
|
||||||
// Documentation site
|
// Documentation site
|
||||||
@ -77,6 +78,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "alerts", element: <AlertsPage /> },
|
{ path: "alerts", element: <AlertsPage /> },
|
||||||
{ path: "logs", element: <LogsPage /> },
|
{ path: "logs", element: <LogsPage /> },
|
||||||
{ path: "compliance", element: <CompliancePage /> },
|
{ path: "compliance", element: <CompliancePage /> },
|
||||||
|
{ path: "settings", element: <SettingsPage /> },
|
||||||
{ path: "*", element: <NotFoundPage /> },
|
{ path: "*", element: <NotFoundPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user