fix(dashboard): enrich overview with real stats, client tracking, and IP extraction (#51)

- Extract QueryTodaySummary to package-level so quota.usage returns
  requestsToday/tokensToday even when QuotaChecker is nil (standalone mode)
- Enrich health RPC: version, uptime, mode, DB ping, tool count,
  connected clients list with real IP and connectedAt
- Add handleStatus: sessions count, agentTotal from DB (managed mode)
- Extract client IP from X-Real-IP / X-Forwarded-For during WS upgrade
- Rebuild overview UI: live uptime, System Health panel, Connected Clients
  panel, inline channel status with dots, auto-refresh 30s

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
Luvu182andLuvu182 authored and GitHub committed 2026-03-04 23:24:07 +07:00
1 parent ab9f582de6
commit 9cb73ac132
7 files changed
+554 -153

No files matched your search

+11 -1
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
@@ -617,6 +618,10 @@ func runGateway() {
// Create gateway server and wire enforcement
server := gateway.NewServer(cfg, msgBus, agentRouter, sessStore, toolsReg)
server.SetVersion(Version)
if managedStores != nil {
server.SetDB(managedStores.DB)
}
server.SetPolicyEngine(permPE)
server.SetPairingService(pairingStore)
@@ -940,7 +945,12 @@ func runGateway() {
}
// Register quota usage RPC (nil-safe — returns {enabled: false} in standalone mode).
methods.NewQuotaMethods(quotaChecker).Register(server.Router())
// Pass DB so summary cards still work when quota is disabled (queries traces directly).
var quotaDB *sql.DB
if managedStores != nil {
quotaDB = managedStores.DB
}
methods.NewQuotaMethods(quotaChecker, quotaDB).Register(server.Router())
// Reload quota config on config changes via pub/sub.
if quotaChecker != nil {
+7 -6
View File
@@ -212,7 +212,7 @@ func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult {
}
if !cfg.Enabled {
// Still return today's summary even when quota is disabled
qc.queryTodaySummary(ctx, &result)
QueryTodaySummary(ctx, qc.db, &result)
return result
}
@@ -237,7 +237,7 @@ func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult {
)
if err != nil {
slog.Warn("quota.usage: failed to query user counts", "error", err)
qc.queryTodaySummary(ctx, &result)
QueryTodaySummary(ctx, qc.db, &result)
return result
}
defer rows.Close()
@@ -258,16 +258,17 @@ func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult {
})
}
qc.queryTodaySummary(ctx, &result)
QueryTodaySummary(ctx, qc.db, &result)
return result
}
// queryTodaySummary fills today's aggregate stats into the result.
func (qc *QuotaChecker) queryTodaySummary(ctx context.Context, result *QuotaUsageResult) {
// QueryTodaySummary fills today's aggregate stats into the result.
// Exported so QuotaMethods can call it directly when QuotaChecker is nil.
func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult) {
now := time.Now().UTC()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
err := qc.db.QueryRowContext(ctx, `
err := db.QueryRowContext(ctx, `
SELECT
COUNT(*),
COALESCE(SUM(total_input_tokens), 0),
+16 -5
View File
@@ -25,17 +25,22 @@ type Client struct {
send chan []byte
mu sync.Mutex
connectedAt time.Time // when the client connected
remoteAddr string // peer IP:port from websocket
// Browser pairing state
pairingCode string // 8-char code if pending approval
pairingPending bool // true while waiting for admin approval
}
func NewClient(conn *websocket.Conn, server *Server) *Client {
func NewClient(conn *websocket.Conn, server *Server, remoteIP string) *Client {
return &Client{
id: uuid.NewString(),
conn: conn,
server: server,
send: make(chan []byte, 256),
id: uuid.NewString(),
conn: conn,
server: server,
send: make(chan []byte, 256),
connectedAt: time.Now(),
remoteAddr: remoteIP,
}
}
@@ -178,6 +183,12 @@ func (c *Client) Role() permissions.Role { return c.role }
// UserID returns the external user ID set during connect.
func (c *Client) UserID() string { return c.userID }
// ConnectedAt returns when the client connected.
func (c *Client) ConnectedAt() time.Time { return c.connectedAt }
// RemoteAddr returns the peer IP:port.
func (c *Client) RemoteAddr() string { return c.remoteAddr }
// Close shuts down the client connection.
func (c *Client) Close() {
close(c.send)
+12 -4
View File
@@ -2,6 +2,7 @@ package methods
import (
"context"
"database/sql"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
@@ -10,12 +11,14 @@ import (
// QuotaMethods handles quota.usage — returns per-user quota consumption for the dashboard.
// Nil-safe: returns {enabled: false} when quotaChecker is nil (standalone mode).
// When checker is nil but db is available, still queries today's summary from traces.
type QuotaMethods struct {
checker *channels.QuotaChecker
db *sql.DB
}
func NewQuotaMethods(checker *channels.QuotaChecker) *QuotaMethods {
return &QuotaMethods{checker: checker}
func NewQuotaMethods(checker *channels.QuotaChecker, db *sql.DB) *QuotaMethods {
return &QuotaMethods{checker: checker, db: db}
}
func (m *QuotaMethods) Register(router *gateway.MethodRouter) {
@@ -24,10 +27,15 @@ func (m *QuotaMethods) Register(router *gateway.MethodRouter) {
func (m *QuotaMethods) handleUsage(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
if m.checker == nil {
client.SendResponse(protocol.NewOKResponse(req.ID, channels.QuotaUsageResult{
result := channels.QuotaUsageResult{
Enabled: false,
Entries: []channels.QuotaUsageEntry{},
}))
}
// Still query today's summary from traces when DB is available
if m.db != nil {
channels.QueryTodaySummary(ctx, m.db, &result)
}
client.SendResponse(protocol.NewOKResponse(req.ID, result))
return
}
+74 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -162,16 +163,86 @@ func (r *MethodRouter) sendConnectResponse(client *Client, reqID string) {
}
func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *protocol.RequestFrame) {
s := r.server
uptimeMs := time.Since(s.startedAt).Milliseconds()
mode := "standalone"
if s.cfg.Database.Mode == "managed" {
mode = "managed"
}
// Database status (real ping)
dbStatus := "n/a"
if s.db != nil {
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := s.db.PingContext(pingCtx); err != nil {
dbStatus = "error"
} else {
dbStatus = "ok"
}
}
// Connected clients list
type clientInfo struct {
ID string `json:"id"`
RemoteAddr string `json:"remoteAddr"`
UserID string `json:"userId"`
Role string `json:"role"`
ConnectedAt string `json:"connectedAt"`
}
clients := s.ClientList()
clientList := make([]clientInfo, 0, len(clients))
for _, c := range clients {
clientList = append(clientList, clientInfo{
ID: c.ID(),
RemoteAddr: c.RemoteAddr(),
UserID: c.UserID(),
Role: string(c.Role()),
ConnectedAt: c.ConnectedAt().UTC().Format(time.RFC3339),
})
}
// Tool count
toolCount := 0
if s.tools != nil {
toolCount = s.tools.Count()
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
"status": "ok",
"status": "ok",
"version": s.version,
"uptime": uptimeMs,
"mode": mode,
"database": dbStatus,
"tools": toolCount,
"clients": clientList,
"currentId": client.ID(),
}))
}
func (r *MethodRouter) handleStatus(ctx context.Context, client *Client, req *protocol.RequestFrame) {
agents := r.server.agents.ListInfo()
sessionCount := 0
if r.server.sessions != nil {
sessionCount = len(r.server.sessions.List(""))
}
// In managed mode, agents are lazily resolved — router only has loaded agents.
// Query the DB store for the real total count.
agentTotal := len(agents)
if r.server.agentStore != nil {
if dbAgents, err := r.server.agentStore.List(ctx, ""); err == nil && len(dbAgents) > agentTotal {
agentTotal = len(dbAgents)
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
"agents": agents,
"clients": len(r.server.clients),
"agents": agents,
"agentTotal": agentTotal,
"clients": len(r.server.clients),
"sessions": sessionCount,
}))
}
+49 -6
View File
@@ -49,6 +49,10 @@ type Server struct {
clients map[string]*Client
mu sync.RWMutex
startedAt time.Time
version string
db interface{ PingContext(context.Context) error } // for health check DB ping
httpServer *http.Server
mux *http.ServeMux
}
@@ -56,11 +60,12 @@ type Server struct {
// NewServer creates a new gateway server.
func NewServer(cfg *config.Config, eventPub bus.EventPublisher, agents *agent.Router, sess store.SessionStore, toolsReg ...*tools.Registry) *Server {
s := &Server{
cfg: cfg,
eventPub: eventPub,
agents: agents,
sessions: sess,
clients: make(map[string]*Client),
cfg: cfg,
eventPub: eventPub,
agents: agents,
sessions: sess,
clients: make(map[string]*Client),
startedAt: time.Now(),
}
s.upgrader = websocket.Upgrader{
@@ -222,7 +227,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
client := NewClient(conn, s)
client := NewClient(conn, s, clientIP(r))
s.registerClient(client)
defer func() {
@@ -240,6 +245,21 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"status":"ok","protocol":%d}`, protocol.ProtocolVersion)
}
// clientIP extracts the real client IP from the request, checking proxy headers first.
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
// Router returns the method router for registering additional handlers.
func (s *Server) Router() *MethodRouter { return s.router }
@@ -283,6 +303,29 @@ func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) {
// SetAgentStore sets the agent store for context injection in tools_invoke.
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
// SetVersion sets the server version for health responses.
func (s *Server) SetVersion(v string) { s.version = v }
// SetDB sets the database connection for health check pings.
func (s *Server) SetDB(db interface{ PingContext(context.Context) error }) { s.db = db }
// StartedAt returns the server start time.
func (s *Server) StartedAt() time.Time { return s.startedAt }
// Version returns the server version string.
func (s *Server) Version() string { return s.version }
// ClientList returns a snapshot of all connected clients.
func (s *Server) ClientList() []*Client {
s.mu.RLock()
defer s.mu.RUnlock()
list := make([]*Client, 0, len(s.clients))
for _, c := range s.clients {
list = append(list, c)
}
return list
}
// BroadcastEvent sends an event to all connected clients.
func (s *Server) BroadcastEvent(event protocol.EventFrame) {
s.mu.RLock()
+385 -128
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback } from "react";
import { useEffect, useCallback, useState, useRef } from "react";
import {
Activity,
Bot,
@@ -8,10 +8,18 @@ import {
ArrowRight,
Clock,
Timer,
Monitor,
Database,
Wrench,
CheckCircle2,
XCircle,
Minus,
Users,
} from "lucide-react";
import { Link } from "react-router";
import { PageHeader } from "@/components/shared/page-header";
import { StatusBadge } from "@/components/shared/status-badge";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuthStore } from "@/stores/use-auth-store";
@@ -29,9 +37,23 @@ import {
// --- Types ---
interface ClientInfo {
id: string;
remoteAddr: string;
userId: string;
role: string;
connectedAt: string;
}
interface HealthPayload {
status?: string;
version?: string;
uptime?: number;
mode?: string;
database?: string;
tools?: number;
clients?: ClientInfo[];
currentId?: string;
}
interface AgentInfo {
@@ -42,6 +64,7 @@ interface AgentInfo {
interface StatusPayload {
agents?: AgentInfo[];
agentTotal?: number;
sessions?: number;
clients?: number;
}
@@ -153,70 +176,153 @@ function QuotaCell({ usage }: { usage: QuotaUsage }) {
);
}
function StatusDot({ ok }: { ok: boolean | undefined }) {
if (ok === undefined)
return <Minus className="h-3.5 w-3.5 text-muted-foreground/40" />;
return ok ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
) : (
<XCircle className="h-3.5 w-3.5 text-red-500" />
);
}
function useLiveUptime(serverUptimeMs: number | undefined) {
const [tick, setTick] = useState(0);
const baseRef = useRef<{ serverMs: number; localTs: number } | null>(null);
useEffect(() => {
if (serverUptimeMs != null) {
baseRef.current = { serverMs: serverUptimeMs, localTs: Date.now() };
}
}, [serverUptimeMs]);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(id);
}, []);
if (!baseRef.current) return undefined;
void tick; // used for re-render
return baseRef.current.serverMs + (Date.now() - baseRef.current.localTs);
}
function formatUptime(ms: number | undefined): string {
if (!ms) return "—";
if (!ms) return "--";
const sec = Math.floor(ms / 1000);
const s = sec % 60;
const min = Math.floor(sec / 60) % 60;
const hr = Math.floor(sec / 3600) % 24;
const day = Math.floor(sec / 86400);
if (day > 0) return `${day}d ${hr}h ${min}m`;
if (hr > 0) return `${hr}h ${min}m`;
return `${min}m`;
if (day > 0) return `${day}d ${hr}h ${min}m ${s}s`;
if (hr > 0) return `${hr}h ${min}m ${s}s`;
if (min > 0) return `${min}m ${s}s`;
return `${s}s`;
}
function formatClientTime(iso: string): string {
try {
const d = new Date(iso);
const now = Date.now();
const diffMs = now - d.getTime();
if (diffMs < 0) return "just now";
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
return `${hr}h ${min % 60}m ago`;
} catch {
return "--";
}
}
// --- Page ---
const REFRESH_INTERVAL = 30_000;
export function OverviewPage() {
const connected = useAuthStore((s) => s.connected);
const { call: fetchHealth, data: health } = useWsCall<HealthPayload>(Methods.HEALTH);
const { call: fetchStatus, data: status } = useWsCall<StatusPayload>(Methods.STATUS);
const { call: fetchQuota, data: quota } = useWsCall<QuotaUsageResult>(Methods.QUOTA_USAGE);
const { call: fetchCron, data: cronData } = useWsCall<CronListPayload>(Methods.CRON_LIST);
const { call: fetchChannels, data: channelStatusData } = useWsCall<ChannelStatusPayload>(Methods.CHANNELS_STATUS);
const { call: fetchHealth, data: health } =
useWsCall<HealthPayload>(Methods.HEALTH);
const { call: fetchStatus, data: status } =
useWsCall<StatusPayload>(Methods.STATUS);
const { call: fetchQuota, data: quota } =
useWsCall<QuotaUsageResult>(Methods.QUOTA_USAGE);
const { call: fetchCron, data: cronData } =
useWsCall<CronListPayload>(Methods.CRON_LIST);
const { call: fetchChannels, data: channelStatusData } =
useWsCall<ChannelStatusPayload>(Methods.CHANNELS_STATUS);
const { providers, loading: providersLoading } = useProviders();
const { traces } = useTraces({ limit: 8 });
const hasNoProviders = !providersLoading && providers.length === 0;
const hasNoEnabledProviders =
!providersLoading && providers.length > 0 && !providers.some((p) => p.enabled);
!providersLoading &&
providers.length > 0 &&
!providers.some((p) => p.enabled);
const fetchAll = useCallback(() => {
fetchHealth();
fetchStatus();
fetchQuota();
fetchCron({ includeDisabled: true });
fetchChannels();
}, [fetchHealth, fetchStatus, fetchQuota, fetchCron, fetchChannels]);
// Initial fetch + auto-refresh every 30s
useEffect(() => {
if (connected) {
fetchHealth();
fetchStatus();
fetchQuota();
fetchCron({ includeDisabled: true });
fetchChannels();
}
}, [connected, fetchHealth, fetchStatus, fetchQuota, fetchCron, fetchChannels]);
if (!connected) return;
fetchAll();
const id = setInterval(fetchAll, REFRESH_INTERVAL);
return () => clearInterval(id);
}, [connected, fetchAll]);
const handleHealth = useCallback(() => {
// Health event listener
const handleHealthEvent = useCallback(() => {
fetchHealth();
fetchStatus();
}, [fetchHealth, fetchStatus]);
useWsEvent(Events.HEALTH, handleHealthEvent);
useWsEvent(Events.HEALTH, handleHealth);
// Live uptime tick
const liveUptime = useLiveUptime(health?.uptime);
// Compute stats
// Computed
const agents = status?.agents ?? [];
const runningAgents = agents.filter((a) => a.isRunning).length;
const agentTotal = status?.agentTotal ?? agents.length;
const channelEntries = channelStatusData?.channels
? Object.values(channelStatusData.channels)
? Object.entries(channelStatusData.channels)
: [];
const channelsOnline = channelEntries.filter((c) => c.running).length;
const channelsOnline = channelEntries.filter(([, c]) => c.running).length;
const cronJobs = cronData?.jobs ?? [];
const enabledProviders = providers.filter((p) => p.enabled).length;
const enabledProviders = providers.filter((p) => p.enabled);
const clientList = health?.clients ?? [];
const currentId = health?.currentId;
return (
<div className="space-y-6 p-6">
{/* Header */}
<PageHeader
title="Dashboard"
description="Gateway overview and quota usage"
actions={
<StatusBadge
status={connected ? "success" : "error"}
label={connected ? "Connected" : "Disconnected"}
/>
<div className="flex items-center gap-2">
{health?.mode && (
<Badge variant={health.mode === "managed" ? "info" : "secondary"}>
{health.mode}
</Badge>
)}
{health?.version && (
<span className="text-xs text-muted-foreground">
v{health.version}
</span>
)}
<StatusBadge
status={connected ? "success" : "error"}
label={connected ? "Connected" : "Disconnected"}
/>
</div>
}
/>
@@ -249,7 +355,11 @@ export function OverviewPage() {
icon={Activity}
label="Requests Today"
value={quota?.requestsToday ?? 0}
sub={quota?.uniqueUsersToday ? `${quota.uniqueUsersToday} users` : undefined}
sub={
quota?.uniqueUsersToday
? `${quota.uniqueUsersToday} users`
: undefined
}
/>
<StatCard
icon={Hash}
@@ -266,8 +376,12 @@ export function OverviewPage() {
<StatCard
icon={Bot}
label="Agents"
value={agents.length > 0 ? `${runningAgents} / ${agents.length}` : "0"}
sub={agents.length > 0 ? "running" : undefined}
value={
agentTotal > 0
? `${runningAgents} / ${agentTotal}`
: "0"
}
sub={agentTotal > 0 ? "running" : undefined}
/>
<StatCard
icon={Radio}
@@ -301,9 +415,14 @@ export function OverviewPage() {
</thead>
<tbody>
{quota.entries.map((entry) => (
<tr key={entry.userId} className="border-b last:border-0">
<tr
key={entry.userId}
className="border-b last:border-0"
>
<td className="py-3 pr-4">
<span className="font-mono text-xs">{entry.userId}</span>
<span className="font-mono text-xs">
{entry.userId}
</span>
</td>
<td className="py-3 px-4">
<QuotaCell usage={entry.hour} />
@@ -323,6 +442,226 @@ export function OverviewPage() {
</Card>
)}
{/* Middle row: System + Cron + Connected Clients */}
<div className="grid gap-4 lg:grid-cols-3">
{/* System Health */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Monitor className="h-4 w-4" /> System
</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3 text-sm">
<div className="flex justify-between">
<dt className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Uptime
</dt>
<dd className="font-medium tabular-nums">
{formatUptime(liveUptime)}
</dd>
</div>
{health?.mode === "managed" && (
<div className="flex justify-between">
<dt className="flex items-center gap-2 text-muted-foreground">
<Database className="h-3.5 w-3.5" /> Database
</dt>
<dd className="flex items-center gap-1.5 font-medium">
<StatusDot ok={health.database === "ok"} />
{health.database === "ok" ? "Connected" : health.database}
</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-muted-foreground">Providers</dt>
<dd className="font-medium">
{enabledProviders.length > 0 ? (
<span className="flex items-center gap-1.5">
<StatusDot ok={true} />
{enabledProviders.length} active
</span>
) : (
<span className="flex items-center gap-1.5">
<StatusDot ok={false} />
none
</span>
)}
</dd>
</div>
{channelEntries.length > 0 && (
<div className="flex justify-between">
<dt className="text-muted-foreground">Channels</dt>
<dd className="flex flex-wrap gap-x-3 gap-y-1">
{channelEntries.map(([name, ch]) => (
<span
key={name}
className="inline-flex items-center gap-1 text-xs"
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${ch.running ? "bg-emerald-500" : "bg-red-400"}`}
/>
{name}
</span>
))}
</dd>
</div>
)}
{(health?.tools ?? 0) > 0 && (
<div className="flex justify-between">
<dt className="flex items-center gap-2 text-muted-foreground">
<Wrench className="h-3.5 w-3.5" /> Tools
</dt>
<dd className="font-medium">{health!.tools} registered</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-muted-foreground">Sessions</dt>
<dd className="font-medium">{status?.sessions ?? 0}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Cron Jobs */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base">Cron Jobs</CardTitle>
{cronJobs.length > 0 && (
<Link
to={ROUTES.CRON}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Manage <ArrowRight className="h-3 w-3" />
</Link>
)}
</CardHeader>
<CardContent>
{cronJobs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No cron jobs configured
</p>
) : (
<div className="space-y-2.5">
{cronJobs.slice(0, 5).map((job) => (
<div
key={job.id}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full ${
job.enabled
? "bg-emerald-500"
: "bg-muted-foreground/40"
}`}
/>
<span
className={
job.enabled ? "" : "text-muted-foreground"
}
>
{job.name}
</span>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{job.enabled && job.state.nextRunAtMs ? (
<>
<Timer className="h-3 w-3" />
{formatRelativeTime(
new Date(job.state.nextRunAtMs),
).replace(" ago", "")}
</>
) : !job.enabled ? (
"disabled"
) : (
"--"
)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Connected Clients */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" /> Connected Clients
{clientList.length > 0 && (
<Badge variant="secondary" className="ml-1">
{clientList.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
{clientList.length === 0 ? (
<p className="text-sm text-muted-foreground">
No clients connected
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 pr-3 font-medium">IP</th>
<th className="pb-2 px-3 font-medium">User</th>
<th className="pb-2 px-3 font-medium">Role</th>
<th className="pb-2 pl-3 font-medium">Connected</th>
</tr>
</thead>
<tbody>
{clientList.map((c) => {
const isYou = c.id === currentId;
return (
<tr
key={c.id}
className={`border-b last:border-0 ${isYou ? "bg-muted/50" : ""}`}
>
<td className="py-2 pr-3 font-mono text-xs">
{c.remoteAddr.replace(/:\d+$/, "")}
{isYou && (
<Badge
variant="info"
className="ml-1.5 text-[10px] px-1 py-0"
>
you
</Badge>
)}
</td>
<td className="py-2 px-3 font-mono text-xs">
{c.userId || "--"}
</td>
<td className="py-2 px-3">
<Badge
variant={
c.role === "admin"
? "default"
: c.role === "operator"
? "secondary"
: "outline"
}
className="text-[10px]"
>
{c.role}
</Badge>
</td>
<td className="py-2 pl-3 text-xs text-muted-foreground whitespace-nowrap">
{formatClientTime(c.connectedAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
{/* Recent Requests */}
{traces.length > 0 && (
<Card>
@@ -344,8 +683,12 @@ export function OverviewPage() {
<th className="pb-2 px-4 font-medium">Name</th>
<th className="pb-2 px-4 font-medium">User</th>
<th className="pb-2 px-4 font-medium">Channel</th>
<th className="pb-2 px-4 font-medium text-right">Tokens</th>
<th className="pb-2 px-4 font-medium text-right">Duration</th>
<th className="pb-2 px-4 font-medium text-right">
Tokens
</th>
<th className="pb-2 px-4 font-medium text-right">
Duration
</th>
<th className="pb-2 pl-4 font-medium">Status</th>
</tr>
</thead>
@@ -356,14 +699,16 @@ export function OverviewPage() {
{formatRelativeTime(t.created_at)}
</td>
<td className="py-2.5 px-4 max-w-[160px] truncate">
{t.name || "—"}
{t.name || "--"}
</td>
<td className="py-2.5 px-4 font-mono text-xs">
{t.user_id || "—"}
{t.user_id || "--"}
</td>
<td className="py-2.5 px-4">{t.channel || "—"}</td>
<td className="py-2.5 px-4">{t.channel || "--"}</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatTokens(t.total_input_tokens + t.total_output_tokens)}
{formatTokens(
t.total_input_tokens + t.total_output_tokens,
)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatDuration(t.duration_ms)}
@@ -390,94 +735,6 @@ export function OverviewPage() {
</CardContent>
</Card>
)}
{/* Bottom row: System + Cron */}
<div className="grid gap-4 lg:grid-cols-2">
{/* System */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">System</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3 text-sm">
<div className="flex justify-between">
<dt className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-3.5 w-3.5" /> Uptime
</dt>
<dd className="font-medium">{formatUptime(health?.uptime)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Sessions</dt>
<dd className="font-medium">{status?.sessions ?? 0}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Connected Clients</dt>
<dd className="font-medium">{status?.clients ?? 0}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Providers</dt>
<dd className="font-medium">
{enabledProviders > 0
? `${enabledProviders} active`
: "none"}
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Cron Jobs */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base">Cron Jobs</CardTitle>
{cronJobs.length > 0 && (
<Link
to={ROUTES.CRON}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Manage <ArrowRight className="h-3 w-3" />
</Link>
)}
</CardHeader>
<CardContent>
{cronJobs.length === 0 ? (
<p className="text-sm text-muted-foreground">No cron jobs configured</p>
) : (
<div className="space-y-2.5">
{cronJobs.slice(0, 5).map((job) => (
<div
key={job.id}
className="flex items-center justify-between text-sm"
>
<div className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full ${
job.enabled ? "bg-emerald-500" : "bg-muted-foreground/40"
}`}
/>
<span className={job.enabled ? "" : "text-muted-foreground"}>
{job.name}
</span>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{job.enabled && job.state.nextRunAtMs ? (
<>
<Timer className="h-3 w-3" />
{formatRelativeTime(new Date(job.state.nextRunAtMs)).replace(" ago", "")}
</>
) : !job.enabled ? (
"disabled"
) : (
"—"
)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}