From 37f1da270ca71b28d29d9bb03ac9b180cd3227d5 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 5 Oct 2025 23:33:16 +0300 Subject: [PATCH 1/5] Refactor sidebar and add AppNavbar with breadcrumbs - Move sidebar navigation to collapsible groups - Add AppNavbar component with dynamic breadcrumbs and version badge - Remove duplicate breadcrumb from ModelDetail page - Update sidebar user menu and icons for consistency - Replace deprecated sidebar header in layout with AppNavbar --- .../api/router/router_integration_test.go | 6 +- .../middleware/middleware_test.go | 8 +- internal/infrastructure/testutil/database.go | 4 +- .../data/redis/latency_tracker_test.go | 20 +- web/src/components/AppNavbar.tsx | 72 +++++++ web/src/components/Layout.tsx | 8 +- web/src/components/app-sidebar.tsx | 189 +++++++++--------- web/src/pages/ModelDetail.tsx | 29 --- 8 files changed, 184 insertions(+), 152 deletions(-) create mode 100644 web/src/components/AppNavbar.tsx diff --git a/internal/api/router/router_integration_test.go b/internal/api/router/router_integration_test.go index 6bde14e..3e65cd2 100644 --- a/internal/api/router/router_integration_test.go +++ b/internal/api/router/router_integration_test.go @@ -36,7 +36,7 @@ func TestRouterIntegration(t *testing.T) { defer redisCleanup() // Initialize cache for health checks - cache.Initialize(&cache.Config{ + _ = cache.Initialize(&cache.Config{ RedisURL: redisURL, TTL: 5 * time.Minute, }) @@ -291,7 +291,7 @@ func TestRouterLatencyRequirements(t *testing.T) { defer redisCleanup() // Initialize cache for health checks - cache.Initialize(&cache.Config{ + _ = cache.Initialize(&cache.Config{ RedisURL: redisURL, TTL: 5 * time.Minute, }) @@ -375,7 +375,7 @@ func TestRouterFailover(t *testing.T) { defer redisCleanup() // Initialize cache for health checks - cache.Initialize(&cache.Config{ + _ = cache.Initialize(&cache.Config{ RedisURL: redisURL, TTL: 5 * time.Minute, }) diff --git a/internal/infrastructure/middleware/middleware_test.go b/internal/infrastructure/middleware/middleware_test.go index a3d582d..35fe9c9 100644 --- a/internal/infrastructure/middleware/middleware_test.go +++ b/internal/infrastructure/middleware/middleware_test.go @@ -54,7 +54,7 @@ func TestAuthMiddleware(t *testing.T) { // Test handler testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) + _, _ = w.Write([]byte("success")) }) t.Run("Valid Master Key", func(t *testing.T) { @@ -226,7 +226,7 @@ func TestBudgetMiddleware(t *testing.T) { testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) + _, _ = w.Write([]byte("success")) }) // Setup auth middleware to set context @@ -316,7 +316,7 @@ func TestCacheMiddleware(t *testing.T) { testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(responseContent)) + _, _ = w.Write([]byte(responseContent)) }) t.Run("Cache Miss and Hit", func(t *testing.T) { @@ -400,7 +400,7 @@ func TestMiddlewareChain(t *testing.T) { testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) + _, _ = w.Write([]byte("success")) }) t.Run("Full Middleware Chain", func(t *testing.T) { diff --git a/internal/infrastructure/testutil/database.go b/internal/infrastructure/testutil/database.go index 6eb2328..22fd4af 100644 --- a/internal/infrastructure/testutil/database.go +++ b/internal/infrastructure/testutil/database.go @@ -101,7 +101,7 @@ func NewTestRedis(t *testing.T) (*redis.Client, func()) { // Return cleanup function that terminates the container cleanup := func() { - client.Close() + _ = client.Close() if err := container.Terminate(ctx); err != nil { t.Logf("Failed to terminate Redis container: %v", err) } @@ -144,7 +144,7 @@ func NewTestRedisWithURL(t *testing.T) (*redis.Client, string, func()) { require.NoError(t, err, "Failed to ping Redis") cleanup := func() { - client.Close() + _ = client.Close() if err := container.Terminate(ctx); err != nil { t.Logf("Failed to terminate Redis container: %v", err) } diff --git a/internal/services/data/redis/latency_tracker_test.go b/internal/services/data/redis/latency_tracker_test.go index df23c20..2487921 100644 --- a/internal/services/data/redis/latency_tracker_test.go +++ b/internal/services/data/redis/latency_tracker_test.go @@ -26,7 +26,7 @@ func setupTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) { func TestLatencyTracker_RecordAndRetrieve(t *testing.T) { client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() tracker := NewLatencyTracker(client, logger) @@ -68,7 +68,7 @@ func TestLatencyTracker_RecordAndRetrieve(t *testing.T) { func TestLatencyTracker_Percentiles(t *testing.T) { client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() tracker := NewLatencyTracker(client, logger) @@ -98,7 +98,7 @@ func TestLatencyTracker_Percentiles(t *testing.T) { func TestLatencyTracker_HealthScore(t *testing.T) { client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() tracker := NewLatencyTracker(client, logger) @@ -156,7 +156,7 @@ func TestLatencyTracker_HealthScore(t *testing.T) { func TestLatencyTracker_WindowExpiry(t *testing.T) { client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() tracker := NewLatencyTracker(client, logger) @@ -189,7 +189,7 @@ func TestLatencyTracker_MultiInstance(t *testing.T) { // This simulates multiple PLLM instances sharing Redis client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() @@ -236,7 +236,7 @@ func TestLatencyTracker_MultiInstance(t *testing.T) { func TestLatencyTracker_MaxSamples(t *testing.T) { client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() tracker := NewLatencyTracker(client, logger) @@ -264,7 +264,7 @@ func TestLatencyTracker_MaxSamples(t *testing.T) { func TestLatencyTracker_ClearLatencies(t *testing.T) { client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() tracker := NewLatencyTracker(client, logger) @@ -289,7 +289,7 @@ func TestLatencyTracker_ClearLatencies(t *testing.T) { func TestLatencyTracker_GetAllModelStats(t *testing.T) { client, mr := setupTestRedis(t) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger, _ := zap.NewDevelopment() tracker := NewLatencyTracker(client, logger) @@ -318,7 +318,7 @@ func TestLatencyTracker_GetAllModelStats(t *testing.T) { func BenchmarkLatencyTracker_RecordLatency(b *testing.B) { client, mr := setupTestRedis(&testing.T{}) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger := zap.NewNop() tracker := NewLatencyTracker(client, logger) @@ -335,7 +335,7 @@ func BenchmarkLatencyTracker_RecordLatency(b *testing.B) { func BenchmarkLatencyTracker_GetAverageLatency(b *testing.B) { client, mr := setupTestRedis(&testing.T{}) defer mr.Close() - defer client.Close() + defer func() { _ = client.Close() }() logger := zap.NewNop() tracker := NewLatencyTracker(client, logger) diff --git a/web/src/components/AppNavbar.tsx b/web/src/components/AppNavbar.tsx new file mode 100644 index 0000000..146968a --- /dev/null +++ b/web/src/components/AppNavbar.tsx @@ -0,0 +1,72 @@ +import { useLocation, Link } from "react-router-dom"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import { Separator } from "@/components/ui/separator"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Badge } from "@/components/ui/badge"; + +const routeNames: Record = { + "/dashboard": "Dashboard", + "/chat": "Chat", + "/models": "Models", + "/users": "Users", + "/teams": "Teams", + "/keys": "API Keys", + "/budget": "Budget", + "/audit-logs": "Audit Logs", + "/guardrails": "Guardrails", + "/settings": "Settings", +}; + +export function AppNavbar() { + const location = useLocation(); + const pathSegments = location.pathname.split("/").filter(Boolean); + + // Generate breadcrumb items + const breadcrumbItems: Array<{ path: string; label: string }> = []; + let currentPath = ""; + + pathSegments.forEach((segment) => { + currentPath += `/${segment}`; + const label = routeNames[currentPath] || segment; + breadcrumbItems.push({ path: currentPath, label }); + }); + + return ( +
+ + + + + + {breadcrumbItems.map((item, index) => ( +
+ {index > 0 && } + + {index === breadcrumbItems.length - 1 ? ( + {item.label} + ) : ( + + {item.label} + + )} + +
+ ))} +
+
+ +
+ + v1.0.0 + +
+
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index c129f3e..6c893df 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,4 +1,5 @@ -import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar"; +import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; +import { AppNavbar } from "@/components/AppNavbar"; import { AppSidebar } from "@/components/app-sidebar"; export default function Layout({ children }: { children: React.ReactNode }) { @@ -6,10 +7,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { -
- -
-
+
{children} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 41c7531..80dbc66 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -20,9 +20,10 @@ import { Github, LogOut, Activity, - ChevronUp, + ChevronsUpDown, FileText, Shield, + ChevronRight, } from "lucide-react"; import { @@ -35,11 +36,13 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarGroup, - - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, + SidebarGroupLabel, } from "@/components/ui/sidebar"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { DropdownMenu, DropdownMenuContent, @@ -47,10 +50,11 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, + DropdownMenuGroup, } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -// Navigation items configuration with submenus +// Navigation items configuration with groups const navigation = [ { title: "Core", @@ -205,9 +209,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
pLLM - - AI Model Router - + AI Model Router
@@ -215,48 +217,52 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - - - {filteredNavigation.map((section) => ( - - -
- {section.title} -
-
- {section.items?.length ? ( - - {section.items.map((item) => { - const isActive = location.pathname === item.href; - - const NavigationSubItem = ( - - - - - {item.title} - - - - ); + {filteredNavigation.map((section) => ( + + + + + {section.title} + + + + + + {section.items.map((item) => { + const isActive = location.pathname === item.href; + + const NavigationItem = ( + + + + + {item.title} + + + + ); - // If item has a permission requirement, wrap with CanAccess - if (item.permission) { - return ( - - {NavigationSubItem} - - ); - } + // If item has a permission requirement, wrap with CanAccess + if (item.permission) { + return ( + + {NavigationItem} + + ); + } - return NavigationSubItem; - })} - - ) : null} -
- ))} -
-
+ return NavigationItem; + })} + + + + + ))}
@@ -268,17 +274,17 @@ export function AppSidebar({ ...props }: React.ComponentProps) { size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - - + + {userInitials}
- {userName} + {userName} {userEmail}
- + ) {
- + {userInitials}
- {userName} + {userName} {userEmail}
- - - Profile - - - {isDark ? ( - - ) : ( - - )} - {isDark ? "Light Mode" : "Dark Mode"} - + + + + Profile + + + {isDark ? : } + {isDark ? "Light Mode" : "Dark Mode"} + + - - - - Documentation - - - - - - GitHub Repository - - + + + + + Documentation + + + + + + GitHub Repository + + + - + Logout
- - - -
-
-

- Version 1.0.0 -

-

- © 2025 pLLM -

-
-
-
-
diff --git a/web/src/pages/ModelDetail.tsx b/web/src/pages/ModelDetail.tsx index bd2b2af..187c329 100644 --- a/web/src/pages/ModelDetail.tsx +++ b/web/src/pages/ModelDetail.tsx @@ -4,14 +4,6 @@ import { ExternalLink, Settings, Activity, DollarSign, Zap, Clock, AlertCircle, import { Icon } from "@iconify/react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; import { Card, CardContent, @@ -295,27 +287,6 @@ export default function ModelDetail() { return (
- {/* Breadcrumb Navigation */} - - - - - Dashboard - - - - - - Models - - - - - {modelId} - - - - {/* Header */}
From b7c275e8dd0447de6009dfb543caa957b3835902 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sun, 5 Oct 2025 23:33:24 +0300 Subject: [PATCH 2/5] Suppress redisClient.Close errors in latency tests --- e2e/distributed_latency_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/distributed_latency_test.go b/e2e/distributed_latency_test.go index bdcb845..ca307f1 100644 --- a/e2e/distributed_latency_test.go +++ b/e2e/distributed_latency_test.go @@ -29,7 +29,7 @@ func TestDistributedLatencyTracking(t *testing.T) { redisClient := goredis.NewClient(&goredis.Options{ Addr: mr.Addr(), }) - defer redisClient.Close() + defer func() { _ = redisClient.Close() }() logger, _ := zap.NewDevelopment() @@ -129,7 +129,7 @@ func TestMultiPodFailover(t *testing.T) { redisClient := goredis.NewClient(&goredis.Options{ Addr: mr.Addr(), }) - defer redisClient.Close() + defer func() { _ = redisClient.Close() }() logger, _ := zap.NewDevelopment() tracker := redis.NewLatencyTracker(redisClient, logger) @@ -159,7 +159,7 @@ func TestMultiPodFailover(t *testing.T) { // Verify we can identify the fastest model var fastestModel string - var fastestLatency time.Duration = 1 * time.Hour + fastestLatency := 1 * time.Hour for model, stats := range allStats { t.Logf("Model: %s, Avg: %v, P95: %v", model, stats.Average, stats.P95) @@ -184,7 +184,7 @@ func TestConcurrentLatencyUpdates(t *testing.T) { redisClient := goredis.NewClient(&goredis.Options{ Addr: mr.Addr(), }) - defer redisClient.Close() + defer func() { _ = redisClient.Close() }() logger := zap.NewNop() // Silence logs for concurrency test tracker := redis.NewLatencyTracker(redisClient, logger) @@ -248,7 +248,7 @@ func TestLatencyBasedRouting(t *testing.T) { redisClient := goredis.NewClient(&goredis.Options{ Addr: mr.Addr(), }) - defer redisClient.Close() + defer func() { _ = redisClient.Close() }() logger, _ := zap.NewDevelopment() @@ -313,7 +313,7 @@ func TestHealthScoreCalculation(t *testing.T) { redisClient := goredis.NewClient(&goredis.Options{ Addr: mr.Addr(), }) - defer redisClient.Close() + defer func() { _ = redisClient.Close() }() logger, _ := zap.NewDevelopment() tracker := redis.NewLatencyTracker(redisClient, logger) From 8b1ca7f0a34733e6a3ce744c00927c078fc0fda2 Mon Sep 17 00:00:00 2001 From: Andrei Date: Tue, 7 Oct 2025 14:11:04 +0300 Subject: [PATCH 3/5] wip --- internal/api/handlers/dashboard.go | 140 +- internal/api/router/admin.go | 1 + web/package-lock.json | 135 ++ web/package.json | 2 + web/src/components/AppNavbar.tsx | 47 +- web/src/components/Layout.tsx | 2 +- web/src/components/audit-logs/columns.tsx | 160 ++ web/src/components/common/DataTable.tsx | 331 ++++ web/src/components/common/EmptyState.tsx | 78 + web/src/components/common/LoadingState.tsx | 115 ++ web/src/components/common/PageHeader.tsx | 142 ++ web/src/components/common/StatCard.tsx | 113 ++ web/src/components/common/index.ts | 17 + .../components/models/ModelCapabilities.tsx | 68 +- web/src/components/models/ModelTags.tsx | 34 +- web/src/components/models/ModelsCards.tsx | 41 +- web/src/components/teams/AddMemberModal.tsx | 128 ++ web/src/components/teams/CreateTeamModal.tsx | 106 ++ web/src/components/teams/EditMemberModal.tsx | 123 ++ web/src/components/teams/EditTeamModal.tsx | 118 ++ web/src/components/ui/drawer.tsx | 116 ++ web/src/components/ui/hover-card.tsx | 27 + web/src/components/users/columns.tsx | 110 ++ web/src/hooks/useAuditLogs.ts | 108 ++ web/src/hooks/useChat.ts | 199 +++ web/src/hooks/useChatAttachments.ts | 38 + web/src/hooks/useChatModels.ts | 46 + web/src/hooks/useGuardrails.ts | 102 ++ web/src/hooks/useKeys.ts | 91 + web/src/hooks/useTeamAnalytics.ts | 66 + web/src/hooks/useTeamMembers.ts | 97 + web/src/hooks/useTeams.ts | 80 + web/src/hooks/useUsers.ts | 27 + web/src/lib/api.ts | 1 + web/src/lib/chat-utils.ts | 52 + web/src/lib/date-utils.ts | 187 ++ web/src/lib/schemas/team-schemas.ts | 29 + web/src/pages/AuditLogs.tsx | 268 +-- web/src/pages/Chat.tsx | 272 +-- web/src/pages/Dashboard.tsx | 34 +- web/src/pages/GuardrailConfig.tsx | 96 +- web/src/pages/GuardrailMarketplace.tsx | 61 +- web/src/pages/Guardrails.tsx | 149 +- web/src/pages/Keys.tsx | 173 +- web/src/pages/ModelDetail.tsx | 101 +- web/src/pages/Models.tsx | 18 +- web/src/pages/Teams.tsx | 1564 ++++------------- web/src/pages/Users.tsx | 274 +-- 48 files changed, 4055 insertions(+), 2232 deletions(-) create mode 100644 web/src/components/audit-logs/columns.tsx create mode 100644 web/src/components/common/DataTable.tsx create mode 100644 web/src/components/common/EmptyState.tsx create mode 100644 web/src/components/common/LoadingState.tsx create mode 100644 web/src/components/common/PageHeader.tsx create mode 100644 web/src/components/common/StatCard.tsx create mode 100644 web/src/components/common/index.ts create mode 100644 web/src/components/teams/AddMemberModal.tsx create mode 100644 web/src/components/teams/CreateTeamModal.tsx create mode 100644 web/src/components/teams/EditMemberModal.tsx create mode 100644 web/src/components/teams/EditTeamModal.tsx create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/components/ui/hover-card.tsx create mode 100644 web/src/components/users/columns.tsx create mode 100644 web/src/hooks/useAuditLogs.ts create mode 100644 web/src/hooks/useChat.ts create mode 100644 web/src/hooks/useChatAttachments.ts create mode 100644 web/src/hooks/useChatModels.ts create mode 100644 web/src/hooks/useGuardrails.ts create mode 100644 web/src/hooks/useKeys.ts create mode 100644 web/src/hooks/useTeamAnalytics.ts create mode 100644 web/src/hooks/useTeamMembers.ts create mode 100644 web/src/hooks/useTeams.ts create mode 100644 web/src/hooks/useUsers.ts create mode 100644 web/src/lib/chat-utils.ts create mode 100644 web/src/lib/date-utils.ts create mode 100644 web/src/lib/schemas/team-schemas.ts diff --git a/internal/api/handlers/dashboard.go b/internal/api/handlers/dashboard.go index d049b75..97ae205 100644 --- a/internal/api/handlers/dashboard.go +++ b/internal/api/handlers/dashboard.go @@ -49,12 +49,16 @@ type DashboardMetrics struct { } type ModelUsage struct { - Model string `json:"model"` - Requests int64 `json:"requests"` - Tokens int64 `json:"tokens"` - Cost float64 `json:"cost"` - AvgLatency int64 `json:"avg_latency"` - SuccessRate float64 `json:"success_rate"` + Model string `json:"model"` + Requests int64 `json:"requests"` + Tokens int64 `json:"tokens"` + Cost float64 `json:"cost"` + AvgLatency int64 `json:"avg_latency"` + SuccessRate float64 `json:"success_rate"` + HealthScore float64 `json:"health_score"` + P95Latency float64 `json:"p95_latency"` + P99Latency float64 `json:"p99_latency"` + CacheHitRate float64 `json:"cache_hit_rate"` } func (h *DashboardHandler) GetDashboardMetrics(w http.ResponseWriter, r *http.Request) { @@ -139,17 +143,20 @@ func (h *DashboardHandler) GetDashboardMetrics(w http.ResponseWriter, r *http.Re h.logger.Error("Failed to get 1h stats", zap.Error(err)) } - // Get top models by requests (last 24h) + // Get top models by requests (last 24h) with enhanced metrics var topModels []ModelUsage err = h.db.Raw(` - SELECT + SELECT model, COUNT(*) as requests, SUM(total_tokens) as tokens, SUM(total_cost) as cost, ROUND(AVG(latency)) as avg_latency, - ROUND(AVG(CASE WHEN status_code = 200 THEN 100 ELSE 0 END), 2) as success_rate - FROM usage_logs + ROUND(AVG(CASE WHEN status_code = 200 THEN 100 ELSE 0 END), 2) as success_rate, + ROUND(AVG(CASE WHEN cache_hit THEN 100 ELSE 0 END), 2) as cache_hit_rate, + PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency) as p95_latency, + PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY latency) as p99_latency + FROM usage_logs WHERE timestamp >= ? GROUP BY model ORDER BY requests DESC @@ -159,6 +166,14 @@ func (h *DashboardHandler) GetDashboardMetrics(w http.ResponseWriter, r *http.Re if err != nil { h.logger.Error("Failed to get top models", zap.Error(err)) } else { + // Calculate health scores based on latency and success rate + for i := range topModels { + topModels[i].HealthScore = calculateHealthScore( + topModels[i].AvgLatency, + topModels[i].SuccessRate, + topModels[i].P99Latency, + ) + } metrics.TopModels = topModels } @@ -220,7 +235,7 @@ func (h *DashboardHandler) GetUsageTrends(w http.ResponseWriter, r *http.Request if days == "" { days = "30" } - + var daysInt int switch days { case "7": @@ -241,7 +256,7 @@ func (h *DashboardHandler) GetUsageTrends(w http.ResponseWriter, r *http.Request } err := h.db.Raw(` - SELECT + SELECT DATE(timestamp) as date, COUNT(*) as requests, SUM(total_tokens) as tokens, @@ -263,4 +278,105 @@ func (h *DashboardHandler) GetUsageTrends(w http.ResponseWriter, r *http.Request h.logger.Error("Failed to encode usage trends", zap.Error(err)) http.Error(w, "Failed to encode response", http.StatusInternalServerError) } +} + +func (h *DashboardHandler) GetModelTrends(w http.ResponseWriter, r *http.Request) { + modelName := chi.URLParam(r, "model") + if modelName == "" { + http.Error(w, "Model name is required", http.StatusBadRequest) + return + } + + days := r.URL.Query().Get("days") + if days == "" { + days = "30" + } + + var daysInt int + switch days { + case "7": + daysInt = 7 + case "30": + daysInt = 30 + default: + daysInt = 30 + } + + since := time.Now().AddDate(0, 0, -daysInt) + + var trends []struct { + Date string `json:"date"` + Requests int64 `json:"requests"` + Tokens int64 `json:"tokens"` + Cost float64 `json:"cost"` + AvgLatency int64 `json:"avg_latency"` + SuccessRate float64 `json:"success_rate"` + } + + err := h.db.Raw(` + SELECT + DATE(timestamp) as date, + COUNT(*) as requests, + SUM(total_tokens) as tokens, + SUM(total_cost) as cost, + ROUND(AVG(latency)) as avg_latency, + ROUND(AVG(CASE WHEN status_code = 200 THEN 100 ELSE 0 END), 2) as success_rate + FROM usage_logs + WHERE model = ? AND timestamp >= ? + GROUP BY DATE(timestamp) + ORDER BY date ASC + `, modelName, since).Scan(&trends).Error + + if err != nil { + h.logger.Error("Failed to get model trends", zap.String("model", modelName), zap.Error(err)) + http.Error(w, "Failed to get model trends", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(trends); err != nil { + h.logger.Error("Failed to encode model trends", zap.Error(err)) + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// calculateHealthScore computes a health score (0-100) based on latency and success rate +// Formula: Base score from success rate, penalties for high latency +func calculateHealthScore(avgLatency int64, successRate float64, p99Latency float64) float64 { + // Start with success rate as base (0-100) + score := successRate + + // Penalty for average latency + // Excellent: < 500ms (no penalty) + // Good: 500ms-1s (small penalty) + // Degraded: 1s-3s (medium penalty) + // Poor: > 3s (large penalty) + if avgLatency > 5000 { + score -= 30 + } else if avgLatency > 3000 { + score -= 20 + } else if avgLatency > 1000 { + score -= 10 + } else if avgLatency > 500 { + score -= 5 + } + + // Additional penalty for high p99 latency (tail latency) + if p99Latency > 10000 { + score -= 15 + } else if p99Latency > 5000 { + score -= 10 + } else if p99Latency > 2000 { + score -= 5 + } + + // Ensure score stays within bounds + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + + return score } \ No newline at end of file diff --git a/internal/api/router/admin.go b/internal/api/router/admin.go index cf49b02..c856cfb 100644 --- a/internal/api/router/admin.go +++ b/internal/api/router/admin.go @@ -86,6 +86,7 @@ func NewAdminSubRouter(cfg *AdminRouterConfig) http.Handler { // Dashboard metrics endpoints r.Get("/dashboard/metrics", dashboardHandler.GetDashboardMetrics) r.Get("/dashboard/models/{model}", dashboardHandler.GetModelMetrics) + r.Get("/dashboard/models/{model}/trends", dashboardHandler.GetModelTrends) r.Get("/dashboard/usage-trends", dashboardHandler.GetUsageTrends) // Protected admin routes - require authentication and admin role diff --git a/web/package-lock.json b/web/package-lock.json index b79af12..4c4a1b7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", @@ -56,6 +57,7 @@ "recharts": "^2.15.4", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", "zod": "^3.25.76", "zustand": "^4.4.7" }, @@ -1678,6 +1680,126 @@ } } }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-icons": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", @@ -8833,6 +8955,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/web/package.json b/web/package.json index 133fab7..128545e 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", @@ -58,6 +59,7 @@ "recharts": "^2.15.4", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", "zod": "^3.25.76", "zustand": "^4.4.7" }, diff --git a/web/src/components/AppNavbar.tsx b/web/src/components/AppNavbar.tsx index 146968a..fdd51d0 100644 --- a/web/src/components/AppNavbar.tsx +++ b/web/src/components/AppNavbar.tsx @@ -24,6 +24,33 @@ const routeNames: Record = { "/settings": "Settings", }; +// Function to get display name for dynamic segments +const getDynamicSegmentLabel = (segment: string, parentPath: string): string => { + // For model IDs, decode and shorten if needed + if (parentPath.includes('/models')) { + try { + const decoded = decodeURIComponent(segment); + // Shorten long model names for breadcrumb + return decoded.length > 40 ? decoded.substring(0, 40) + '...' : decoded; + } catch { + return segment; + } + } + + // For guardrail config + if (parentPath.includes('/guardrails/config')) { + return segment === 'new' ? 'New Guardrail' : `Config ${segment}`; + } + + // Default: capitalize and decode + try { + const decoded = decodeURIComponent(segment); + return decoded.charAt(0).toUpperCase() + decoded.slice(1); + } catch { + return segment.charAt(0).toUpperCase() + segment.slice(1); + } +}; + export function AppNavbar() { const location = useLocation(); const pathSegments = location.pathname.split("/").filter(Boolean); @@ -34,15 +61,25 @@ export function AppNavbar() { pathSegments.forEach((segment) => { currentPath += `/${segment}`; - const label = routeNames[currentPath] || segment; - breadcrumbItems.push({ path: currentPath, label }); + + // Check if this is a known route + const knownRoute = routeNames[currentPath]; + + if (knownRoute) { + breadcrumbItems.push({ path: currentPath, label: knownRoute }); + } else { + // This is a dynamic segment + const parentPath = breadcrumbItems.length > 0 ? breadcrumbItems[breadcrumbItems.length - 1].path : ''; + const label = getDynamicSegmentLabel(segment, parentPath); + breadcrumbItems.push({ path: currentPath, label }); + } }); return (
- + {breadcrumbItems.map((item, index) => ( @@ -50,10 +87,10 @@ export function AppNavbar() { {index > 0 && } {index === breadcrumbItems.length - 1 ? ( - {item.label} + {item.label} ) : ( - {item.label} + {item.label} )} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 6c893df..22840d7 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -8,7 +8,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { -
+
{children}
diff --git a/web/src/components/audit-logs/columns.tsx b/web/src/components/audit-logs/columns.tsx new file mode 100644 index 0000000..b3f5686 --- /dev/null +++ b/web/src/components/audit-logs/columns.tsx @@ -0,0 +1,160 @@ +"use client" + +import { ColumnDef } from "@tanstack/react-table" +import { ArrowUpDown } from "lucide-react" +import { format } from "date-fns" +import { Button } from "../ui/button" +import { Badge } from "../ui/badge" +import { AuditLog } from '@/types/api' + +export const getStatusBadge = (result: string) => { + switch (result) { + case 'success': + return Success + case 'failure': + return Failure + case 'error': + return Error + case 'warning': + return Warning + default: + return {result} + } +} + +export const getSeverityColor = (eventType: string) => { + const securityEvents = ['auth', 'login', 'logout', 'password_change', 'security_alert', 'access_denied'] + const highRiskEvents = ['budget_exceeded', 'key_revoke', 'user_delete'] + + if (securityEvents.includes(eventType)) return 'text-red-600' + if (highRiskEvents.includes(eventType)) return 'text-orange-600' + return 'text-gray-600' +} + +export const createAuditColumns = (onRowClick?: (log: AuditLog) => void): ColumnDef[] => [ + { + accessorKey: "timestamp", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const timestamp = new Date(row.getValue("timestamp")) + return ( +
+
{format(timestamp, "MMM dd, yyyy")}
+
{format(timestamp, "HH:mm:ss")}
+
+ ) + }, + }, + { + accessorKey: "user", + header: "User", + cell: ({ row }) => { + const auditLog = row.original + return ( +
+ {auditLog.user ? ( + <> +
{auditLog.user.name || auditLog.user.email || 'Unknown User'}
+ {auditLog.user.email &&
{auditLog.user.email}
} + + ) : ( + System + )} +
+ ) + }, + }, + { + accessorKey: "event_action", + header: "Action", + cell: ({ row }) => { + const auditLog = row.original + return ( +
+
+ {auditLog.event_action} +
+
+ {auditLog.event_type.replace(/_/g, ' ')} +
+
+ ) + }, + }, + { + accessorKey: "resource_type", + header: "Resource", + cell: ({ row }) => { + const auditLog = row.original + return auditLog.resource_type ? ( +
+
{auditLog.resource_type}
+ {auditLog.resource_id && ( +
+ {auditLog.resource_id.slice(0, 8)}... +
+ )} +
+ ) : ( + - + ) + }, + }, + { + accessorKey: "event_result", + header: "Result", + cell: ({ row }) => getStatusBadge(row.getValue("event_result")), + }, + { + accessorKey: "ip_address", + header: "IP Address", + cell: ({ row }) => ( +
{row.getValue("ip_address") || "-"}
+ ), + }, + { + accessorKey: "method", + header: "Method", + cell: ({ row }) => { + const method = row.getValue("method") as string + if (!method) return - + + const methodColors = { + GET: "bg-blue-100 text-blue-800 border-blue-200", + POST: "bg-green-100 text-green-800 border-green-200", + PUT: "bg-yellow-100 text-yellow-800 border-yellow-200", + DELETE: "bg-red-100 text-red-800 border-red-200", + } + + return ( + + {method} + + ) + }, + }, + { + id: "actions", + cell: ({ row }) => { + return ( + + ) + }, + }, +] diff --git a/web/src/components/common/DataTable.tsx b/web/src/components/common/DataTable.tsx new file mode 100644 index 0000000..f7f53e2 --- /dev/null +++ b/web/src/components/common/DataTable.tsx @@ -0,0 +1,331 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import { ChevronDown, Filter, Search, X } from "lucide-react" + +import { Button } from "../ui/button" +import { Input } from "../ui/input" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table" +import { Badge } from "../ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + searchPlaceholder?: string +} + +export function DataTable({ + columns, + data, + searchPlaceholder = "Search...", +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [rowSelection, setRowSelection] = React.useState({}) + const [globalFilter, setGlobalFilter] = React.useState("") + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: "includesString", + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + globalFilter, + }, + }) + + const statusOptions = [ + { value: "active", label: "Active", count: 0 }, + { value: "inactive", label: "Inactive", count: 0 }, + { value: "expired", label: "Expired", count: 0 }, + { value: "revoked", label: "Revoked", count: 0 }, + ] + + + const statusFilter = table.getColumn("status")?.getFilterValue() as string[] | undefined + const hasActiveFilters = columnFilters.length > 0 || globalFilter.length > 0 + + return ( +
+ {/* Toolbar */} +
+ {/* Search */} +
+
+ + setGlobalFilter(e.target.value)} + className="pl-9" + /> + {globalFilter && ( + + )} +
+
+ + {/* Filters and Actions */} +
+ {/* Status Filter */} + + + + + + Filter by Status + + {statusOptions.map((option) => ( + { + const currentFilter = statusFilter || [] + const newFilter = checked + ? [...currentFilter, option.value] + : currentFilter.filter((value) => value !== option.value) + + table.getColumn("status")?.setFilterValue( + newFilter.length > 0 ? newFilter : undefined + ) + }} + > + {option.label} + + ))} + + + + {/* Column Visibility */} + + + + + + Toggle Columns + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ) + })} + + + + {/* Clear Filters */} + {hasActiveFilters && ( + + )} +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No API keys found. + {hasActiveFilters && ( +
+ +
+ )} +
+
+ )} +
+
+
+ + {/* Pagination */} + {table.getRowModel().rows.length > 0 && ( +
+
+ Showing {table.getRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s). + {Object.keys(rowSelection).length > 0 && ( + + {Object.keys(rowSelection).length} row(s) selected. + + )} +
+
+
+

Rows per page

+ +
+
+ +
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+ +
+
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/web/src/components/common/EmptyState.tsx b/web/src/components/common/EmptyState.tsx new file mode 100644 index 0000000..41a1662 --- /dev/null +++ b/web/src/components/common/EmptyState.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +/** + * EmptyState component for displaying empty data states with icon, title, + * description, and optional action button. + * + * @example + * ```tsx + * Add Member} + * /> + * ``` + */ + +interface EmptyStateProps { + /** + * Lucide icon component to display + * @default undefined + */ + icon?: LucideIcon; + + /** + * Primary heading text + * @required + */ + title: string; + + /** + * Optional description text below title + * @default undefined + */ + description?: string; + + /** + * Optional action button or element + * @default undefined + */ + action?: React.ReactNode; + + /** + * Additional CSS classes + */ + className?: string; +} + +export function EmptyState({ + icon: Icon, + title, + description, + action, + className, +}: EmptyStateProps) { + return ( +
+ {Icon && ( + + )} +

{title}

+ {description && ( +

+ {description} +

+ )} + {action} +
+ ); +} + +export type { EmptyStateProps }; diff --git a/web/src/components/common/LoadingState.tsx b/web/src/components/common/LoadingState.tsx new file mode 100644 index 0000000..0949637 --- /dev/null +++ b/web/src/components/common/LoadingState.tsx @@ -0,0 +1,115 @@ +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; + +/** + * LoadingState component for consistent loading indicators + * + * @example + * ```tsx + * // Spinner variant + * + * + * // Skeleton variant for cards + * + * + * // Skeleton variant for table + * + * ``` + */ + +interface LoadingStateProps { + /** + * Loading indicator variant + * @default "spinner" + */ + variant?: 'spinner' | 'skeleton' | 'table' | 'cards'; + + /** + * Number of skeleton rows/items to display + * @default 3 + */ + rows?: number; + + /** + * Loading text to display with spinner + * @default "Loading..." + */ + text?: string; + + /** + * Additional CSS classes + */ + className?: string; +} + +export function LoadingState({ + variant = 'spinner', + rows = 3, + text = 'Loading...', + className, +}: LoadingStateProps) { + if (variant === 'spinner') { + return ( +
+
+ + {text} +
+
+ ); + } + + if (variant === 'table') { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (variant === 'cards') { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( + + +
+
+ + +
+
+ + + ))} +
+ ); + } + + // skeleton variant (default content skeleton) + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); +} + +export type { LoadingStateProps }; diff --git a/web/src/components/common/PageHeader.tsx b/web/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..ad2e9e8 --- /dev/null +++ b/web/src/components/common/PageHeader.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; + +/** + * PageHeader component for consistent page headers with title, description, + * actions, and optional breadcrumbs + * + * @example + * ```tsx + * Generate Key} + * breadcrumbs={[ + * { label: "Dashboard", href: "/" }, + * { label: "API Keys", href: "/keys" } + * ]} + * /> + * ``` + */ + +export interface BreadcrumbItemData { + label: string; + href?: string; +} + +interface PageHeaderProps { + /** + * Page title + * @required + */ + title: string; + + /** + * Optional page description + * @default undefined + */ + description?: string; + + /** + * Optional action buttons or elements + * @default undefined + */ + actions?: React.ReactNode; + + /** + * Optional breadcrumb items + * @default undefined + */ + breadcrumbs?: BreadcrumbItemData[]; + + /** + * Additional CSS classes for container + */ + className?: string; + + /** + * Additional CSS classes for title + */ + titleClassName?: string; + + /** + * Additional CSS classes for description + */ + descriptionClassName?: string; +} + +export function PageHeader({ + title, + description, + actions, + breadcrumbs, + className, + titleClassName, + descriptionClassName, +}: PageHeaderProps) { + return ( +
+ {/* Breadcrumbs */} + {breadcrumbs && breadcrumbs.length > 0 && ( + + + {breadcrumbs.map((item, index) => { + const isLast = index === breadcrumbs.length - 1; + return ( + + + {isLast || !item.href ? ( + {item.label} + ) : ( + + {item.label} + + )} + + {!isLast && } + + ); + })} + + + )} + + {/* Header content */} +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ + {/* Actions */} + {actions && ( +
+ {actions} +
+ )} +
+
+ ); +} + +export type { PageHeaderProps }; diff --git a/web/src/components/common/StatCard.tsx b/web/src/components/common/StatCard.tsx new file mode 100644 index 0000000..c9839a8 --- /dev/null +++ b/web/src/components/common/StatCard.tsx @@ -0,0 +1,113 @@ +import { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +/** + * StatCard component for displaying metric cards with consistent styling + * + * @example + * ```tsx + * + * ``` + */ + +interface StatCardProps { + /** + * Card title/metric name + * @required + */ + title: string; + + /** + * Main metric value to display + * @required + */ + value: string | number; + + /** + * Optional description text below value + * @default undefined + */ + description?: string; + + /** + * Optional icon to display in header + * @default undefined + */ + icon?: LucideIcon; + + /** + * Optional trend information + * @default undefined + */ + trend?: { + value: number; + label: string; + }; + + /** + * Additional CSS classes + */ + className?: string; + + /** + * Custom color for positive trends + * @default "text-emerald-500" + */ + trendColorPositive?: string; + + /** + * Custom color for negative trends + * @default "text-red-500" + */ + trendColorNegative?: string; +} + +export function StatCard({ + title, + value, + description, + icon: Icon, + trend, + className, + trendColorPositive = "text-emerald-500", + trendColorNegative = "text-red-500", +}: StatCardProps) { + const isPositiveTrend = trend && trend.value >= 0; + const trendColor = isPositiveTrend ? trendColorPositive : trendColorNegative; + + return ( + + + {title} + {Icon && } + + +
{value}
+ {description && ( +

+ {description} +

+ )} + {trend && ( +

+ {trend.label} +

+ )} +
+
+ ); +} + +export type { StatCardProps }; diff --git a/web/src/components/common/index.ts b/web/src/components/common/index.ts new file mode 100644 index 0000000..f1e11ba --- /dev/null +++ b/web/src/components/common/index.ts @@ -0,0 +1,17 @@ +/** + * Common reusable components + * + * This barrel file exports all common components for easier imports + */ + +export { EmptyState } from './EmptyState'; +export type { EmptyStateProps } from './EmptyState'; + +export { LoadingState } from './LoadingState'; +export type { LoadingStateProps } from './LoadingState'; + +export { PageHeader } from './PageHeader'; +export type { PageHeaderProps, BreadcrumbItemData } from './PageHeader'; + +export { StatCard } from './StatCard'; +export type { StatCardProps } from './StatCard'; diff --git a/web/src/components/models/ModelCapabilities.tsx b/web/src/components/models/ModelCapabilities.tsx index efccd6f..a18e35b 100644 --- a/web/src/components/models/ModelCapabilities.tsx +++ b/web/src/components/models/ModelCapabilities.tsx @@ -1,14 +1,15 @@ import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; import { ModelCapabilities as IModelCapabilities } from "@/types/api"; -import { - Eye, - Mic, - Volume2, - Zap, - FileText, - MessageSquare, - Brain, +import { + Eye, + Mic, + Volume2, + Zap, + FileText, + MessageSquare, + Brain, Search, Code, Layers @@ -102,13 +103,14 @@ export default function ModelCapabilities({ capabilities, showLabels = false, ma const visibleCapabilities = enabledCapabilities.slice(0, maxVisible); const remainingCount = enabledCapabilities.length - maxVisible; + const remainingCapabilities = enabledCapabilities.slice(maxVisible); return (
{visibleCapabilities.map((config) => { const Icon = config.icon; - + if (showLabels) { return ( @@ -124,7 +126,7 @@ export default function ModelCapabilities({ capabilities, showLabels = false, ma ); } - + return ( @@ -139,27 +141,35 @@ export default function ModelCapabilities({ capabilities, showLabels = false, ma ); })} - + {remainingCount > 0 && ( - - - - - +{remainingCount} - - - -
- {enabledCapabilities.slice(maxVisible).map((config) => ( -
- {config.label} -
- ))} + + + + +{remainingCount} more + + + +
+

Additional Capabilities

+
+ {remainingCapabilities.map((config) => { + const Icon = config.icon; + return ( +
+ +
+
{config.label}
+
{config.description}
+
+
+ ); + })}
- - - +
+
+
)}
); -} \ No newline at end of file +} diff --git a/web/src/components/models/ModelTags.tsx b/web/src/components/models/ModelTags.tsx index 9a35ff4..9043cac 100644 --- a/web/src/components/models/ModelTags.tsx +++ b/web/src/components/models/ModelTags.tsx @@ -1,5 +1,5 @@ import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; interface ModelTagsProps { tags?: string[]; @@ -13,6 +13,7 @@ export default function ModelTags({ tags, maxVisible = 3 }: ModelTagsProps) { const visibleTags = tags.slice(0, maxVisible); const remainingCount = tags.length - maxVisible; + const remainingTags = tags.slice(maxVisible); return (
@@ -21,27 +22,28 @@ export default function ModelTags({ tags, maxVisible = 3 }: ModelTagsProps) { {tag} ))} - + {remainingCount > 0 && ( - - - - - +{remainingCount} - - - -
- {tags.slice(maxVisible).map((tag, index) => ( + + + + +{remainingCount} more + + + +
+

All Tags

+
+ {remainingTags.map((tag, index) => ( {tag} ))}
- - - +
+
+
)}
); -} \ No newline at end of file +} diff --git a/web/src/components/models/ModelsCards.tsx b/web/src/components/models/ModelsCards.tsx index b9916f0..e9e773d 100644 --- a/web/src/components/models/ModelsCards.tsx +++ b/web/src/components/models/ModelsCards.tsx @@ -11,6 +11,7 @@ import { import { Badge } from "@/components/ui/badge"; import { ModelWithUsage } from "@/types/api"; import { detectProvider } from "@/lib/providers"; +import { formatDateTime, formatUnixTimestamp } from "@/lib/date-utils"; import { SparklineChart, MetricCard } from "./ModelCharts"; import ModelTags from "./ModelTags"; import ModelCapabilities from "./ModelCapabilities"; @@ -48,8 +49,8 @@ export default function ModelsCards({ models }: ModelsCardsProps) { const isActive = model.is_active !== false; return ( - handleModelClick(model.id)} > @@ -93,10 +94,10 @@ export default function ModelsCards({ models }: ModelsCardsProps) { {usage && (
= 90 - ? 'bg-green-500' - : usage.health_score >= 70 - ? 'bg-yellow-500' + usage.health_score >= 90 + ? 'bg-green-500' + : usage.health_score >= 70 + ? 'bg-yellow-500' : 'bg-red-500' }`} /> @@ -140,21 +141,21 @@ export default function ModelsCards({ models }: ModelsCardsProps) { icon={} color="#3b82f6" /> - + } color="#8b5cf6" /> - + } color="#10b981" /> - +
- - {new Date(model.created * 1000).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })} + {formatUnixTimestamp(model.created)}
- + {usage?.last_used && (
- Last used: {new Date(usage.last_used).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })} + Last used: {formatDateTime(usage.last_used)}
)}
@@ -225,4 +218,4 @@ export default function ModelsCards({ models }: ModelsCardsProps) { })}
); -} \ No newline at end of file +} diff --git a/web/src/components/teams/AddMemberModal.tsx b/web/src/components/teams/AddMemberModal.tsx new file mode 100644 index 0000000..67e2e44 --- /dev/null +++ b/web/src/components/teams/AddMemberModal.tsx @@ -0,0 +1,128 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useTeamMembers } from '@/hooks/useTeamMembers'; +import { addMemberSchema, type AddMemberFormData } from '@/lib/schemas/team-schemas'; +import { useToast } from '@/hooks/use-toast'; + +interface AddMemberModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + teamId: string | null; +} + +export function AddMemberModal({ open, onOpenChange, teamId }: AddMemberModalProps) { + const { addMember, isAdding } = useTeamMembers(teamId); + const { toast } = useToast(); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + setValue, + watch, + } = useForm({ + resolver: zodResolver(addMemberSchema), + defaultValues: { + role: 'member', + }, + }); + + const selectedRole = watch('role'); + + const onSubmit = async (data: AddMemberFormData) => { + try { + await addMember(data); + toast({ + title: 'Success', + description: 'Member added successfully', + }); + reset(); + onOpenChange(false); + } catch (error: any) { + toast({ + title: 'Error', + description: error.message || 'Failed to add member', + variant: 'destructive', + }); + } + }; + + return ( + + + + Add Team Member + + Add a new member to the team with a specific role. + + +
+
+
+ + + {errors.user_id && ( +

{errors.user_id.message}

+ )} +
+
+ + + {errors.role && ( +

{errors.role.message}

+ )} +
+
+ + + + +
+
+
+ ); +} diff --git a/web/src/components/teams/CreateTeamModal.tsx b/web/src/components/teams/CreateTeamModal.tsx new file mode 100644 index 0000000..0a3fcd4 --- /dev/null +++ b/web/src/components/teams/CreateTeamModal.tsx @@ -0,0 +1,106 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useTeams } from '@/hooks/useTeams'; +import { createTeamSchema, type CreateTeamFormData } from '@/lib/schemas/team-schemas'; +import { useToast } from '@/hooks/use-toast'; + +interface CreateTeamModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateTeamModal({ open, onOpenChange }: CreateTeamModalProps) { + const { createTeam, isCreating } = useTeams(); + const { toast } = useToast(); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(createTeamSchema), + }); + + const onSubmit = async (data: CreateTeamFormData) => { + try { + await createTeam(data); + toast({ + title: 'Success', + description: 'Team created successfully', + }); + reset(); + onOpenChange(false); + } catch (error: any) { + toast({ + title: 'Error', + description: error.message || 'Failed to create team', + variant: 'destructive', + }); + } + }; + + return ( + + + + Create New Team + + Create a new team to organize users and manage access. + + +
+
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+
+ +