mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: use grid/cell components
This commit is contained in:
parent
923539a46b
commit
60fd4b7d16
12 changed files with 434 additions and 181 deletions
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Agent</title>
|
||||
<title>Hermes Agent - Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
9
web/package-lock.json
generated
9
web/package-lock.json
generated
|
|
@ -29,6 +29,7 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"three": "^0.180.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
|
|
@ -3840,6 +3841,14 @@
|
|||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"three": "^0.180.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
|
|
|
|||
144
web/src/App.tsx
144
web/src/App.tsx
|
|
@ -1,12 +1,30 @@
|
|||
import { useMemo } from "react";
|
||||
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
|
||||
import {
|
||||
Activity, BarChart3, Clock, FileText, KeyRound,
|
||||
MessageSquare, Package, Settings, Puzzle,
|
||||
Sparkles, Terminal, Globe, Database, Shield,
|
||||
Wrench, Zap, Heart, Star, Code, Eye,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
FileText,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Globe,
|
||||
Database,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
Heart,
|
||||
Star,
|
||||
Code,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
|
||||
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
|
|
@ -25,8 +43,18 @@ import type { RegisteredPlugin } from "@/plugins";
|
|||
|
||||
const BUILTIN_NAV: NavItem[] = [
|
||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||
{ path: "/sessions", labelKey: "sessions", label: "Sessions", icon: MessageSquare },
|
||||
{ path: "/analytics", labelKey: "analytics", label: "Analytics", icon: BarChart3 },
|
||||
{
|
||||
path: "/sessions",
|
||||
labelKey: "sessions",
|
||||
label: "Sessions",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
path: "/analytics",
|
||||
labelKey: "analytics",
|
||||
label: "Analytics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
||||
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
||||
|
|
@ -37,17 +65,38 @@ const BUILTIN_NAV: NavItem[] = [
|
|||
// Plugins can reference any of these by name in their manifest — keeps bundle
|
||||
// size sane vs. importing the full lucide-react set.
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Activity, BarChart3, Clock, FileText, KeyRound,
|
||||
MessageSquare, Package, Settings, Puzzle,
|
||||
Sparkles, Terminal, Globe, Database, Shield,
|
||||
Wrench, Zap, Heart, Star, Code, Eye,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
FileText,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Globe,
|
||||
Database,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
Heart,
|
||||
Star,
|
||||
Code,
|
||||
Eye,
|
||||
};
|
||||
|
||||
function resolveIcon(name: string): React.ComponentType<{ className?: string }> {
|
||||
function resolveIcon(
|
||||
name: string,
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
return ICON_MAP[name] ?? Puzzle;
|
||||
}
|
||||
|
||||
function buildNavItems(builtIn: NavItem[], plugins: RegisteredPlugin[]): NavItem[] {
|
||||
function buildNavItems(
|
||||
builtIn: NavItem[],
|
||||
plugins: RegisteredPlugin[],
|
||||
): NavItem[] {
|
||||
const items = [...builtIn];
|
||||
|
||||
for (const { manifest } of plugins) {
|
||||
|
|
@ -97,28 +146,34 @@ export default function App() {
|
|||
"bg-background-base/90 backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-12 max-w-[1600px] items-stretch">
|
||||
<div className="flex items-center border-r border-current/20 px-3 sm:px-5 shrink-0">
|
||||
<span
|
||||
className="font-sans font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
<div className="mx-auto flex h-12 max-w-[1600px]">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
|
||||
<Grid
|
||||
className="h-full !border-t-0 !border-b-0"
|
||||
style={{
|
||||
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
|
||||
}}
|
||||
>
|
||||
<Cell className="flex items-center !p-0 !px-3 sm:!px-5">
|
||||
<Typography
|
||||
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
Hermes
|
||||
<br />
|
||||
Agent
|
||||
</span>
|
||||
</div>
|
||||
</Typography>
|
||||
</Cell>
|
||||
|
||||
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
|
||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
|
||||
<Cell key={path} className="relative !p-0">
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
end={path === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative inline-flex items-center gap-1.5 shrink-0",
|
||||
"border-r border-current/20 px-2.5 sm:px-4 py-2",
|
||||
"group relative flex h-full w-full items-center gap-1.5",
|
||||
"px-2.5 sm:px-4 py-2",
|
||||
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
|
|
@ -132,7 +187,11 @@ export default function App() {
|
|||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
{labelKey ? (t.app.nav as Record<string, string>)[labelKey] ?? label : label}
|
||||
{labelKey
|
||||
? ((t.app.nav as Record<string, string>)[
|
||||
labelKey
|
||||
] ?? label)
|
||||
: label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
|
|
@ -150,16 +209,23 @@ export default function App() {
|
|||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</Cell>
|
||||
))}
|
||||
</nav>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 border-l border-current/20 px-2 sm:px-4">
|
||||
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
|
||||
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
<span className="hidden sm:inline font-mondwest text-[0.7rem] tracking-[0.15em] opacity-50">
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
|
||||
>
|
||||
{t.app.webUi}
|
||||
</span>
|
||||
</div>
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -187,17 +253,25 @@ export default function App() {
|
|||
</main>
|
||||
|
||||
<footer className="relative z-2 border-t border-current/20">
|
||||
<div className="mx-auto flex max-w-[1600px] items-center justify-between px-3 sm:px-6 py-3">
|
||||
<span className="font-mondwest text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60">
|
||||
<Grid className="mx-auto max-w-[1600px] !border-t-0 !border-b-0">
|
||||
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</span>
|
||||
<span
|
||||
className="font-mondwest text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
</Typography>
|
||||
</Cell>
|
||||
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</span>
|
||||
</div>
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
/**
|
||||
|
|
@ -19,9 +20,12 @@ export function LanguageSwitcher() {
|
|||
>
|
||||
{/* Show the *current* language's flag — tooltip advertises the click action */}
|
||||
<span className="text-base leading-none">{locale === "en" ? "🇬🇧" : "🇨🇳"}</span>
|
||||
<span className="hidden sm:inline font-mondwest tracking-wide uppercase text-[0.65rem]">
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
>
|
||||
{locale === "en" ? "EN" : "中文"}
|
||||
</span>
|
||||
</Typography>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -12,9 +13,21 @@ interface Props {
|
|||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error";
|
||||
type Phase =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "awaiting_user"
|
||||
| "submitting"
|
||||
| "polling"
|
||||
| "approved"
|
||||
| "error";
|
||||
|
||||
export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) {
|
||||
export function OAuthLoginModal({
|
||||
provider,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: Props) {
|
||||
const [phase, setPhase] = useState<Phase>("starting");
|
||||
const [start, setStart] = useState<OAuthStartResponse | null>(null);
|
||||
const [pkceCode, setPkceCode] = useState("");
|
||||
|
|
@ -81,13 +94,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
if (!isMounted.current) return;
|
||||
if (resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
if (pollTimer.current !== null)
|
||||
window.clearInterval(pollTimer.current);
|
||||
onSuccess(`${provider.name} connected`);
|
||||
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
||||
} else if (resp.status !== "pending") {
|
||||
setPhase("error");
|
||||
setErrorMsg(resp.error_message || `Login ${resp.status}`);
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
if (pollTimer.current !== null)
|
||||
window.clearInterval(pollTimer.current);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isMounted.current) return;
|
||||
|
|
@ -107,7 +122,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
setPhase("submitting");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim());
|
||||
const resp = await api.submitOAuthCode(
|
||||
provider.id,
|
||||
start.session_id,
|
||||
pkceCode.trim(),
|
||||
);
|
||||
if (!isMounted.current) return;
|
||||
if (resp.ok && resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
|
|
@ -175,12 +194,22 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
</button>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 id="oauth-modal-title" className="font-mondwest text-base tracking-wider uppercase">
|
||||
<Typography
|
||||
as="h2"
|
||||
mondwest
|
||||
id="oauth-modal-title"
|
||||
className="text-base tracking-wider uppercase"
|
||||
>
|
||||
{t.oauth.connect} {provider.name}
|
||||
</h2>
|
||||
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
|
||||
</Typography>
|
||||
{secondsLeft !== null &&
|
||||
phase !== "approved" &&
|
||||
phase !== "error" && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}
|
||||
{t.oauth.sessionExpires.replace(
|
||||
"{time}",
|
||||
fmtTime(secondsLeft),
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -211,7 +240,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url}
|
||||
href={
|
||||
(start as Extract<OAuthStartResponse, { flow: "pkce" }>)
|
||||
.auth_url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
|
|
@ -219,7 +251,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
<ExternalLink className="h-3 w-3" />
|
||||
{t.oauth.reOpenAuth}
|
||||
</a>
|
||||
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
|
||||
<Button
|
||||
onClick={handleSubmitPkceCode}
|
||||
disabled={!pkceCode.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{t.oauth.submitCode}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -243,23 +279,46 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
</p>
|
||||
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
|
||||
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
|
||||
{(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code}
|
||||
{
|
||||
(
|
||||
start as Extract<
|
||||
OAuthStartResponse,
|
||||
{ flow: "device_code" }
|
||||
>
|
||||
).user_code
|
||||
}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleCopyUserCode(
|
||||
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code,
|
||||
(
|
||||
start as Extract<
|
||||
OAuthStartResponse,
|
||||
{ flow: "device_code" }
|
||||
>
|
||||
).user_code,
|
||||
)
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{codeCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url}
|
||||
href={
|
||||
(
|
||||
start as Extract<
|
||||
OAuthStartResponse,
|
||||
{ flow: "device_code" }
|
||||
>
|
||||
).verification_url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
|
|
@ -302,17 +361,32 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
setStart(null);
|
||||
setPkceCode("");
|
||||
setPhase("starting");
|
||||
api.startOAuthLogin(provider.id).then((resp) => {
|
||||
api
|
||||
.startOAuthLogin(provider.id)
|
||||
.then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
setPhase(
|
||||
resp.flow === "device_code"
|
||||
? "polling"
|
||||
: "awaiting_user",
|
||||
);
|
||||
if (resp.flow === "pkce") {
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
window.open(
|
||||
resp.auth_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
} else {
|
||||
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
||||
window.open(
|
||||
resp.verification_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
}).catch((e) => {
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`${t.common.retry} failed: ${e}`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -56,9 +57,12 @@ export function ThemeSwitcher() {
|
|||
aria-haspopup="listbox"
|
||||
>
|
||||
<Palette className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline font-mondwest tracking-wide uppercase text-[0.65rem]">
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Typography>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
|
@ -72,9 +76,12 @@ export function ThemeSwitcher() {
|
|||
)}
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<span className="font-mondwest text-[0.65rem] tracking-[0.15em] uppercase text-midground/70">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
||||
>
|
||||
{t.theme?.title ?? "Theme"}
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{availableThemes.map((th) => {
|
||||
|
|
@ -100,13 +107,16 @@ export function ThemeSwitcher() {
|
|||
{preset ? <ThemeSwatch theme={preset.name} /> : <PlaceholderSwatch />}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-mondwest text-[0.75rem] tracking-wide uppercase">
|
||||
<Typography
|
||||
mondwest
|
||||
className="truncate text-[0.75rem] tracking-wide uppercase"
|
||||
>
|
||||
{th.label}
|
||||
</span>
|
||||
</Typography>
|
||||
{th.description && (
|
||||
<span className="truncate font-sans text-[0.65rem] normal-case tracking-normal text-midground/50">
|
||||
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
|
||||
{th.description}
|
||||
</span>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -195,10 +196,10 @@ export default function CronPage() {
|
|||
|
||||
{/* Jobs list */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</h2>
|
||||
</H2>
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { FileText, RefreshCw, ChevronRight } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -104,7 +105,7 @@ export default function LogsPage() {
|
|||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">{t.logs.title}</h1>
|
||||
<H2 variant="sm">{t.logs.title}</H2>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Hash,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
|
|
@ -383,7 +384,7 @@ export default function SessionsPage() {
|
|||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">{t.sessions.title}</h1>
|
||||
<H2 variant="sm">{t.sessions.title}</H2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{total}
|
||||
</Badge>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
Code,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -193,7 +194,7 @@ export default function SkillsPage() {
|
|||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">{t.skills.title}</h1>
|
||||
<H2 variant="sm">{t.skills.title}</H2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
||||
import { timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
|
|
@ -23,8 +24,14 @@ export default function StatusPage() {
|
|||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {});
|
||||
api
|
||||
.getStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
api
|
||||
.getSessions(50)
|
||||
.then((resp) => setSessions(resp.sessions))
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
|
|
@ -39,13 +46,19 @@ export default function StatusPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
|
||||
const PLATFORM_STATE_BADGE: Record<
|
||||
string,
|
||||
{ variant: "success" | "warning" | "destructive"; label: string }
|
||||
> = {
|
||||
connected: { variant: "success", label: t.status.connected },
|
||||
disconnected: { variant: "warning", label: t.status.disconnected },
|
||||
fatal: { variant: "destructive", label: t.status.error },
|
||||
};
|
||||
|
||||
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
|
||||
const GATEWAY_STATE_DISPLAY: Record<
|
||||
string,
|
||||
{ badge: "success" | "warning" | "destructive" | "outline"; label: string }
|
||||
> = {
|
||||
running: { badge: "success", label: t.status.running },
|
||||
starting: { badge: "warning", label: t.status.starting },
|
||||
startup_failed: { badge: "destructive", label: t.status.failed },
|
||||
|
|
@ -53,15 +66,19 @@ export default function StatusPage() {
|
|||
};
|
||||
|
||||
function gatewayValue(): string {
|
||||
if (status!.gateway_running && status!.gateway_health_url) return status!.gateway_health_url;
|
||||
if (status!.gateway_running && status!.gateway_pid) return `${t.status.pid} ${status!.gateway_pid}`;
|
||||
if (status!.gateway_running && status!.gateway_health_url)
|
||||
return status!.gateway_health_url;
|
||||
if (status!.gateway_running && status!.gateway_pid)
|
||||
return `${t.status.pid} ${status!.gateway_pid}`;
|
||||
if (status!.gateway_running) return t.status.runningRemote;
|
||||
if (status!.gateway_state === "startup_failed") return t.status.startFailed;
|
||||
return t.status.notRunning;
|
||||
}
|
||||
|
||||
function gatewayBadge() {
|
||||
const info = status!.gateway_state ? GATEWAY_STATE_DISPLAY[status!.gateway_state] : null;
|
||||
const info = status!.gateway_state
|
||||
? GATEWAY_STATE_DISPLAY[status!.gateway_state]
|
||||
: null;
|
||||
if (info) return info;
|
||||
return status!.gateway_running
|
||||
? { badge: "success" as const, label: t.status.running }
|
||||
|
|
@ -88,9 +105,14 @@ export default function StatusPage() {
|
|||
{
|
||||
icon: Activity,
|
||||
label: t.status.activeSessions,
|
||||
value: status.active_sessions > 0 ? `${status.active_sessions} ${t.status.running.toLowerCase()}` : t.status.noneRunning,
|
||||
value:
|
||||
status.active_sessions > 0
|
||||
? `${status.active_sessions} ${t.status.running.toLowerCase()}`
|
||||
: t.status.noneRunning,
|
||||
badgeText: status.active_sessions > 0 ? t.common.live : t.common.off,
|
||||
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
|
||||
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as
|
||||
| "success"
|
||||
| "outline",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -106,9 +128,14 @@ export default function StatusPage() {
|
|||
detail: status.gateway_exit_reason ?? undefined,
|
||||
});
|
||||
}
|
||||
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
|
||||
const failedPlatforms = platforms.filter(
|
||||
([, info]) => info.state === "fatal" || info.state === "disconnected",
|
||||
);
|
||||
for (const [name, info] of failedPlatforms) {
|
||||
const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected;
|
||||
const stateLabel =
|
||||
info.state === "fatal"
|
||||
? t.status.platformError
|
||||
: t.status.platformDisconnected;
|
||||
alerts.push({
|
||||
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
|
||||
detail: info.error_message ?? undefined,
|
||||
|
|
@ -117,7 +144,6 @@ export default function StatusPage() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Alert banner — breaks grid monotony for critical states */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -125,9 +151,13 @@ export default function StatusPage() {
|
|||
<div className="flex flex-col gap-2 min-w-0">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-sm font-medium text-destructive">{alert.message}</p>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{alert.message}
|
||||
</p>
|
||||
{alert.detail && (
|
||||
<p className="text-xs text-destructive/70 mt-0.5">{alert.detail}</p>
|
||||
<p className="text-xs text-destructive/70 mt-0.5">
|
||||
{alert.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -136,32 +166,41 @@ export default function StatusPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Grid className="border-b lg:!grid-cols-3">
|
||||
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
|
||||
<Card key={label} className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<Cell
|
||||
key={label}
|
||||
className="flex min-w-0 flex-col gap-2 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mondwest truncate" title={value}>{value}</div>
|
||||
<div
|
||||
className="truncate text-2xl font-bold font-mondwest"
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{badgeText && (
|
||||
<Badge variant={badgeVariant} className="mt-2">
|
||||
<Badge variant={badgeVariant} className="self-start">
|
||||
{badgeVariant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{badgeText}
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Cell>
|
||||
))}
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<PlatformsCard platforms={platforms} platformStateBadge={PLATFORM_STATE_BADGE} />
|
||||
<PlatformsCard
|
||||
platforms={platforms}
|
||||
platformStateBadge={PLATFORM_STATE_BADGE}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
|
|
@ -169,7 +208,9 @@ export default function StatusPage() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-success" />
|
||||
<CardTitle className="text-base">{t.status.activeSessions}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.status.activeSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -181,7 +222,9 @@ export default function StatusPage() {
|
|||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<Badge variant="success" className="text-[10px] shrink-0">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
|
|
@ -190,7 +233,11 @@ export default function StatusPage() {
|
|||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -204,7 +251,9 @@ export default function StatusPage() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.status.recentSessions}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -215,10 +264,16 @@ export default function StatusPage() {
|
|||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
|
|
@ -228,7 +283,10 @@ export default function StatusPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="text-[10px] shrink-0 self-start sm:self-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] shrink-0 self-start sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
</Badge>
|
||||
|
|
@ -249,7 +307,9 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.status.connectedPlatforms}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.status.connectedPlatforms}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -259,7 +319,12 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
variant: "outline" as const,
|
||||
label: info.state,
|
||||
};
|
||||
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff;
|
||||
const IconComponent =
|
||||
info.state === "connected"
|
||||
? Wifi
|
||||
: info.state === "fatal"
|
||||
? AlertTriangle
|
||||
: WifiOff;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -267,19 +332,25 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 w-full">
|
||||
<IconComponent className={`h-4 w-4 shrink-0 ${
|
||||
<IconComponent
|
||||
className={`h-4 w-4 shrink-0 ${
|
||||
info.state === "connected"
|
||||
? "text-success"
|
||||
: info.state === "fatal"
|
||||
? "text-destructive"
|
||||
: "text-warning"
|
||||
}`} />
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium capitalize truncate">{name}</span>
|
||||
<span className="text-sm font-medium capitalize truncate">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{info.error_message && (
|
||||
<span className="text-xs text-destructive">{info.error_message}</span>
|
||||
<span className="text-xs text-destructive">
|
||||
{info.error_message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{info.updated_at && (
|
||||
|
|
@ -290,7 +361,10 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={display.variant} className="shrink-0 self-start sm:self-center">
|
||||
<Badge
|
||||
variant={display.variant}
|
||||
className="shrink-0 self-start sm:self-center"
|
||||
>
|
||||
{display.variant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
|
|
@ -306,5 +380,8 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
|
||||
interface PlatformsCardProps {
|
||||
platforms: [string, PlatformStatus][];
|
||||
platformStateBadge: Record<string, { variant: "success" | "warning" | "destructive"; label: string }>;
|
||||
platformStateBadge: Record<
|
||||
string,
|
||||
{ variant: "success" | "warning" | "destructive"; label: string }
|
||||
>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue