mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 02:14:10 +00:00
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:
1 parent
ab9f582de6
commit
9cb73ac132
7 files changed
+554
-153
No files matched your search
+11
-1
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 +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()
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user