mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
refactor(dashboard): split overview page into focused components
Reorganize layout: System Health full-width grid, 2-column Clients+Cron, Recent Requests moved above Quota Usage. Extract 8 modules from 768-line monolith (overview-page.tsx now 212 lines). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1d2e8982a
commit
a9ce64981d
9 files changed
+682
-566
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { ClientInfo } from "./types";
|
||||
import { formatClientTime } from "./hooks/use-live-uptime";
|
||||
|
||||
export function ConnectedClientsCard({
|
||||
clients,
|
||||
currentId,
|
||||
}: {
|
||||
clients: ClientInfo[];
|
||||
currentId?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" /> Connected Clients
|
||||
{clients.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{clients.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{clients.length === 0 ? (
|
||||
<p className="py-6 text-center 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>
|
||||
{clients.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}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ArrowRight, Timer } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { CronJob } from "./types";
|
||||
|
||||
export function CronJobsCard({ jobs }: { jobs: CronJob[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base">Cron Jobs</CardTitle>
|
||||
{jobs.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>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No cron jobs configured
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{jobs.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function formatUptime(ms: number | undefined): string {
|
||||
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 ${s}s`;
|
||||
if (hr > 0) return `${hr}h ${min}m ${s}s`;
|
||||
if (min > 0) return `${min}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export 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 "--";
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,10 @@
|
||||
import { useEffect, useCallback, useState, useRef } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
Hash,
|
||||
Radio,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Timer,
|
||||
Monitor,
|
||||
Database,
|
||||
Wrench,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Minus,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Activity, Bot, Hash, Radio, AlertTriangle } 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";
|
||||
import { useWsCall } from "@/hooks/use-ws-call";
|
||||
import { useWsEvent } from "@/hooks/use-ws-event";
|
||||
@@ -29,214 +12,22 @@ import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useTraces } from "@/pages/traces/hooks/use-traces";
|
||||
import { Methods, Events } from "@/api/protocol";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
formatTokens,
|
||||
formatDuration,
|
||||
} from "@/lib/format";
|
||||
import { formatTokens } from "@/lib/format";
|
||||
|
||||
// --- 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 {
|
||||
id: string;
|
||||
model: string;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
interface StatusPayload {
|
||||
agents?: AgentInfo[];
|
||||
agentTotal?: number;
|
||||
sessions?: number;
|
||||
clients?: number;
|
||||
}
|
||||
|
||||
interface ChannelStatusEntry {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
interface ChannelStatusPayload {
|
||||
channels: Record<string, ChannelStatusEntry>;
|
||||
}
|
||||
|
||||
interface QuotaUsage {
|
||||
used: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface QuotaUsageEntry {
|
||||
userId: string;
|
||||
hour: QuotaUsage;
|
||||
day: QuotaUsage;
|
||||
week: QuotaUsage;
|
||||
}
|
||||
|
||||
interface QuotaUsageResult {
|
||||
enabled: boolean;
|
||||
requestsToday: number;
|
||||
inputTokensToday: number;
|
||||
outputTokensToday: number;
|
||||
uniqueUsersToday: number;
|
||||
entries: QuotaUsageEntry[];
|
||||
}
|
||||
|
||||
interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
state: {
|
||||
nextRunAtMs?: number;
|
||||
lastStatus?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CronListPayload {
|
||||
jobs: CronJob[];
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaBar({ used, limit }: { used: number; limit: number }) {
|
||||
if (limit === 0) {
|
||||
return <span className="text-xs text-muted-foreground">no limit</span>;
|
||||
}
|
||||
const pct = Math.min((used / limit) * 100, 100);
|
||||
const color =
|
||||
pct > 85
|
||||
? "bg-red-500"
|
||||
: pct > 60
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${color}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaCell({ usage }: { usage: QuotaUsage }) {
|
||||
const label =
|
||||
usage.limit === 0
|
||||
? String(usage.used)
|
||||
: `${usage.used}/${usage.limit}`;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm tabular-nums">{label}</span>
|
||||
<QuotaBar used={usage.used} limit={usage.limit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 "--";
|
||||
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 ${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 ---
|
||||
import type {
|
||||
HealthPayload,
|
||||
StatusPayload,
|
||||
QuotaUsageResult,
|
||||
CronListPayload,
|
||||
ChannelStatusPayload,
|
||||
} from "./types";
|
||||
import { useLiveUptime } from "./hooks/use-live-uptime";
|
||||
import { StatCard } from "./stat-card";
|
||||
import { SystemHealthCard } from "./system-health-card";
|
||||
import { ConnectedClientsCard } from "./connected-clients-card";
|
||||
import { CronJobsCard } from "./cron-jobs-card";
|
||||
import { RecentRequestsCard } from "./recent-requests-card";
|
||||
import { QuotaUsageCard } from "./quota-usage-card";
|
||||
|
||||
const REFRESH_INTERVAL = 30_000;
|
||||
|
||||
@@ -269,7 +60,6 @@ export function OverviewPage() {
|
||||
fetchChannels();
|
||||
}, [fetchHealth, fetchStatus, fetchQuota, fetchCron, fetchChannels]);
|
||||
|
||||
// Initial fetch + auto-refresh every 30s
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
fetchAll();
|
||||
@@ -277,14 +67,12 @@ export function OverviewPage() {
|
||||
return () => clearInterval(id);
|
||||
}, [connected, fetchAll]);
|
||||
|
||||
// Health event listener
|
||||
const handleHealthEvent = useCallback(() => {
|
||||
fetchHealth();
|
||||
fetchStatus();
|
||||
}, [fetchHealth, fetchStatus]);
|
||||
useWsEvent(Events.HEALTH, handleHealthEvent);
|
||||
|
||||
// Live uptime tick
|
||||
const liveUptime = useLiveUptime(health?.uptime);
|
||||
|
||||
// Computed
|
||||
@@ -295,10 +83,8 @@ export function OverviewPage() {
|
||||
? Object.entries(channelStatusData.channels)
|
||||
: [];
|
||||
const channelsOnline = channelEntries.filter(([, c]) => c.running).length;
|
||||
const cronJobs = cronData?.jobs ?? [];
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
const clientList = health?.clients ?? [];
|
||||
const currentId = health?.currentId;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
@@ -395,345 +181,31 @@ export function OverviewPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quota Usage */}
|
||||
{quota?.enabled && quota.entries.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base">Quota Usage</CardTitle>
|
||||
<StatusBadge status="success" label="Enabled" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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-4 font-medium">User / Group</th>
|
||||
<th className="pb-2 px-4 font-medium w-36">Hour</th>
|
||||
<th className="pb-2 px-4 font-medium w-36">Day</th>
|
||||
<th className="pb-2 pl-4 font-medium w-36">Week</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quota.entries.map((entry) => (
|
||||
<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>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<QuotaCell usage={entry.hour} />
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<QuotaCell usage={entry.day} />
|
||||
</td>
|
||||
<td className="py-3 pl-4">
|
||||
<QuotaCell usage={entry.week} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* System Health */}
|
||||
<SystemHealthCard
|
||||
health={health}
|
||||
liveUptime={liveUptime}
|
||||
enabledProviderCount={enabledProviders.length}
|
||||
sessions={status?.sessions ?? 0}
|
||||
clientCount={clientList.length}
|
||||
channelEntries={channelEntries}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
{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>
|
||||
{/* Connected Clients + Cron Jobs */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ConnectedClientsCard
|
||||
clients={clientList}
|
||||
currentId={health?.currentId}
|
||||
/>
|
||||
<CronJobsCard jobs={cronData?.jobs ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Recent Requests */}
|
||||
{traces.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base">Recent Requests</CardTitle>
|
||||
<Link
|
||||
to={ROUTES.TRACES}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View All <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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-4 font-medium">Time</th>
|
||||
<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 pl-4 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{traces.map((t) => (
|
||||
<tr key={t.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatRelativeTime(t.created_at)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 max-w-[160px] truncate">
|
||||
{t.name || "--"}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-xs">
|
||||
{t.user_id || "--"}
|
||||
</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,
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatDuration(t.duration_ms)}
|
||||
</td>
|
||||
<td className="py-2.5 pl-4">
|
||||
<StatusBadge
|
||||
status={
|
||||
t.status === "completed"
|
||||
? "success"
|
||||
: t.status === "error"
|
||||
? "error"
|
||||
: t.status === "running"
|
||||
? "info"
|
||||
: "default"
|
||||
}
|
||||
label={t.status}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<RecentRequestsCard traces={traces} />
|
||||
|
||||
{/* Quota Usage */}
|
||||
{quota?.enabled && quota.entries.length > 0 && (
|
||||
<QuotaUsageCard quota={quota} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { QuotaUsage, QuotaUsageResult } from "./types";
|
||||
|
||||
function QuotaBar({ used, limit }: { used: number; limit: number }) {
|
||||
if (limit === 0) {
|
||||
return <span className="text-xs text-muted-foreground">no limit</span>;
|
||||
}
|
||||
const pct = Math.min((used / limit) * 100, 100);
|
||||
const color =
|
||||
pct > 85
|
||||
? "bg-red-500"
|
||||
: pct > 60
|
||||
? "bg-amber-500"
|
||||
: "bg-emerald-500";
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${color}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaCell({ usage }: { usage: QuotaUsage }) {
|
||||
const label =
|
||||
usage.limit === 0
|
||||
? String(usage.used)
|
||||
: `${usage.used}/${usage.limit}`;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm tabular-nums">{label}</span>
|
||||
<QuotaBar used={usage.used} limit={usage.limit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotaUsageCard({ quota }: { quota: QuotaUsageResult }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base">Quota Usage</CardTitle>
|
||||
<StatusBadge status="success" label="Enabled" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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-4 font-medium">User / Group</th>
|
||||
<th className="pb-2 px-4 font-medium w-36">Hour</th>
|
||||
<th className="pb-2 px-4 font-medium w-36">Day</th>
|
||||
<th className="pb-2 pl-4 font-medium w-36">Week</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quota.entries.map((entry) => (
|
||||
<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>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<QuotaCell usage={entry.hour} />
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<QuotaCell usage={entry.day} />
|
||||
</td>
|
||||
<td className="py-3 pl-4">
|
||||
<QuotaCell usage={entry.week} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
import {
|
||||
formatRelativeTime,
|
||||
formatTokens,
|
||||
formatDuration,
|
||||
} from "@/lib/format";
|
||||
|
||||
interface Trace {
|
||||
id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
channel: string;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
duration_ms: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function RecentRequestsCard({ traces }: { traces: Trace[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base">Recent Requests</CardTitle>
|
||||
{traces.length > 0 && (
|
||||
<Link
|
||||
to={ROUTES.TRACES}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View All <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{traces.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No recent requests
|
||||
</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-4 font-medium">Time</th>
|
||||
<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 pl-4 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{traces.map((t) => (
|
||||
<tr key={t.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-4 text-muted-foreground whitespace-nowrap">
|
||||
{formatRelativeTime(t.created_at)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 max-w-[160px] truncate">
|
||||
{t.name || "--"}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 font-mono text-xs">
|
||||
{t.user_id || "--"}
|
||||
</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,
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right tabular-nums">
|
||||
{formatDuration(t.duration_ms)}
|
||||
</td>
|
||||
<td className="py-2.5 pl-4">
|
||||
<StatusBadge
|
||||
status={
|
||||
t.status === "completed"
|
||||
? "success"
|
||||
: t.status === "error"
|
||||
? "error"
|
||||
: t.status === "running"
|
||||
? "info"
|
||||
: "default"
|
||||
}
|
||||
label={t.status}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Timer,
|
||||
Monitor,
|
||||
Database,
|
||||
Wrench,
|
||||
Radio,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Minus,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { HealthPayload, ChannelStatusEntry } from "./types";
|
||||
import { formatUptime } from "./hooks/use-live-uptime";
|
||||
|
||||
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 HealthCell({
|
||||
label,
|
||||
icon: Icon,
|
||||
value,
|
||||
statusOk,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
value: string;
|
||||
statusOk?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 p-3">
|
||||
<div className="rounded-md bg-muted p-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusOk !== undefined && <StatusDot ok={statusOk} />}
|
||||
<p className="text-sm font-semibold tabular-nums truncate">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemHealthCard({
|
||||
health,
|
||||
liveUptime,
|
||||
enabledProviderCount,
|
||||
sessions,
|
||||
clientCount,
|
||||
channelEntries,
|
||||
}: {
|
||||
health: HealthPayload | null;
|
||||
liveUptime: number | undefined;
|
||||
enabledProviderCount: number;
|
||||
sessions: number;
|
||||
clientCount: number;
|
||||
channelEntries: [string, ChannelStatusEntry][];
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4" /> System Health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<HealthCell
|
||||
label="Uptime"
|
||||
icon={Timer}
|
||||
value={formatUptime(liveUptime)}
|
||||
/>
|
||||
{health?.mode === "managed" && (
|
||||
<HealthCell
|
||||
label="Database"
|
||||
icon={Database}
|
||||
value={
|
||||
health.database === "ok"
|
||||
? "Connected"
|
||||
: health.database ?? "--"
|
||||
}
|
||||
statusOk={health.database === "ok"}
|
||||
/>
|
||||
)}
|
||||
<HealthCell
|
||||
label="Providers"
|
||||
icon={Radio}
|
||||
value={
|
||||
enabledProviderCount > 0
|
||||
? `${enabledProviderCount} active`
|
||||
: "none"
|
||||
}
|
||||
statusOk={enabledProviderCount > 0}
|
||||
/>
|
||||
<HealthCell
|
||||
label="Tools"
|
||||
icon={Wrench}
|
||||
value={String(health?.tools ?? 0)}
|
||||
/>
|
||||
<HealthCell
|
||||
label="Sessions"
|
||||
icon={Monitor}
|
||||
value={String(sessions)}
|
||||
/>
|
||||
<HealthCell
|
||||
label="Clients"
|
||||
icon={Users}
|
||||
value={String(clientCount)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{channelEntries.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Channels
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channelEntries.map(([name, ch]) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs"
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${ch.running ? "bg-emerald-500" : "bg-red-400"}`}
|
||||
/>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface ClientInfo {
|
||||
id: string;
|
||||
remoteAddr: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
connectedAt: string;
|
||||
}
|
||||
|
||||
export interface HealthPayload {
|
||||
status?: string;
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
mode?: string;
|
||||
database?: string;
|
||||
tools?: number;
|
||||
clients?: ClientInfo[];
|
||||
currentId?: string;
|
||||
}
|
||||
|
||||
export interface AgentInfo {
|
||||
id: string;
|
||||
model: string;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export interface StatusPayload {
|
||||
agents?: AgentInfo[];
|
||||
agentTotal?: number;
|
||||
sessions?: number;
|
||||
clients?: number;
|
||||
}
|
||||
|
||||
export interface ChannelStatusEntry {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelStatusPayload {
|
||||
channels: Record<string, ChannelStatusEntry>;
|
||||
}
|
||||
|
||||
export interface QuotaUsage {
|
||||
used: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface QuotaUsageEntry {
|
||||
userId: string;
|
||||
hour: QuotaUsage;
|
||||
day: QuotaUsage;
|
||||
week: QuotaUsage;
|
||||
}
|
||||
|
||||
export interface QuotaUsageResult {
|
||||
enabled: boolean;
|
||||
requestsToday: number;
|
||||
inputTokensToday: number;
|
||||
outputTokensToday: number;
|
||||
uniqueUsersToday: number;
|
||||
entries: QuotaUsageEntry[];
|
||||
}
|
||||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
state: {
|
||||
nextRunAtMs?: number;
|
||||
lastStatus?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CronListPayload {
|
||||
jobs: CronJob[];
|
||||
}
|
||||
Reference in new issue
Block a user