This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit d40d80ab09938a85f768a8fe2ed125a37dd17d0a Author: Yang Luo <[email protected]> AuthorDate: Sun Sep 6 01:34:45 2026 +0800 feat(web): cut the home screen down to a summary strip and slim cards --- web/src/components/AgentCatalog.tsx | 23 +- web/src/components/AgentGridCard.tsx | 316 ++++++++-------------------- web/src/components/AuthenticityOverview.tsx | 80 ++++--- web/src/components/CcSwitchBanner.tsx | 64 +++--- web/src/components/HomeSummary.tsx | 170 +++++++++++++++ web/src/components/OnboardingChecklist.tsx | 10 - web/src/components/shared/fold.tsx | 61 ++++++ web/src/lib/agents.ts | 49 ++++- web/src/locales/en/data.json | 9 +- web/src/locales/zh/data.json | 9 +- web/src/pages/AgentDetailPage.tsx | 34 ++- web/src/pages/HomePage.tsx | 198 ++++++++++------- 12 files changed, 623 insertions(+), 400 deletions(-) diff --git a/web/src/components/AgentCatalog.tsx b/web/src/components/AgentCatalog.tsx index e699a4d..8f17055 100644 --- a/web/src/components/AgentCatalog.tsx +++ b/web/src/components/AgentCatalog.tsx @@ -14,6 +14,7 @@ import i18next from "i18next"; +import {Fold} from "@/components/shared/fold"; import {ToolInstallRow} from "@/components/ToolInstallRow"; import {useAgentCatalog, type useAgentInstall} from "@/lib/agents"; import type {Agent} from "@/types"; @@ -21,16 +22,21 @@ import type {Agent} from "@/types"; /** * The agents Gateway supports that this machine has none of, each with the * click that installs it here. Without them a fresh machine says only that - * nothing was found, which is the one thing it cannot act on. + * nothing was found, which is the one thing it cannot act on - but on a machine + * that already has agents it is a dozen cards about software nobody asked for, + * so it folds down to the line that names it. */ export function AgentCatalog({ agents, enabled = true, + defaultOpen = true, installer, onLocated, }: { agents: Agent[]; enabled?: boolean; + /** False where the page has more to say than this, which is the home screen. */ + defaultOpen?: boolean; /** Called once a program is picked, which is when a rescan drops its row. */ onLocated?: () => void; /** @@ -48,12 +54,13 @@ export function AgentCatalog({ } return ( - <section className="space-y-3"> - <div> - <h2 className="text-sm font-medium">{i18next.t("agent:Not installed")}</h2> - <p className="text-muted-foreground text-xs">{i18next.t("agent:Not installed hint")}</p> - </div> - + <Fold + defaultOpen={defaultOpen} + title={i18next + .t("agent:{count} agents not installed") + .replace("{count}", String(missing.length))} + description={i18next.t("agent:Not installed hint")} + > <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {missing.map(item => ( <ToolInstallRow @@ -66,6 +73,6 @@ export function AgentCatalog({ /> ))} </div> - </section> + </Fold> ); } diff --git a/web/src/components/AgentGridCard.tsx b/web/src/components/AgentGridCard.tsx index d67080e..670e091 100644 --- a/web/src/components/AgentGridCard.tsx +++ b/web/src/components/AgentGridCard.tsx @@ -12,22 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import * as React from "react"; import {Link} from "react-router-dom"; -import {Bot, ChevronRight, UserRound} from "lucide-react"; +import {Bot, ChevronRight} from "lucide-react"; import i18next from "i18next"; import {AgentIcon} from "@/components/AgentIcon"; -import {AgentCardAccounts} from "@/components/AgentAccounts"; import {AgentCardInstances} from "@/components/AgentInstances"; -import {AgentUsageTrend, hasUsageTrend} from "@/components/AgentUsageTrend"; -import {AgentUpdateBadge} from "@/components/AgentVersionDialog"; import {RunButton, RunDot} from "@/components/AgentRunControl"; import {ProviderIcon} from "@/components/ProviderIcon"; -import {QuotaBadge} from "@/components/ProviderQuota"; import {ConfirmDialog} from "@/components/shared/confirm-dialog"; import {Card} from "@/components/ui/card"; -import {Progress} from "@/components/ui/progress"; import { Select, SelectContent, @@ -42,133 +36,35 @@ import { agentCanUse, agentDetailPath, builtinProvider, - directMode, - type AgentAccountControls, type AgentInstanceControls, + type AgentSpend, } from "@/lib/agents"; import {providerIdOf, providerProtocol} from "@/lib/providers"; -import {formatCost, formatTokens} from "@/lib/usage"; -import type { - Agent, - AgentAccount, - AgentRuntime, - AgentUpdate, - AgentUsageStat, - LlmAgentStat, - Provider, - ProviderHealth, - ProviderQuota, -} from "@/types"; - -/** A hairline across the whole card, drawn out of a section that is padded. */ -function Divider() { - return <div className="border-border -mx-4 border-t" />; -} - -/** One labelled line of the card, with the value pinned right. */ -function Row({label, children}: {label: React.ReactNode; children: React.ReactNode}) { - return ( - <div className="flex min-w-0 items-center justify-between gap-2"> - <span className="text-muted-foreground shrink-0 text-[11px]">{label}</span> - {children} - </div> - ); -} - -/** One number of the card, and what it is. */ -function Metric({ - label, - value, - hint, -}: { - label: React.ReactNode; - value: React.ReactNode; - hint?: React.ReactNode; -}) { - const shown = <span className="block truncate text-[13px] font-medium tabular-nums">{value}</span>; - - return ( - <div className="min-w-0"> - <span className="text-muted-foreground block truncate text-[11px]">{label}</span> - {hint ? ( - <SimpleTooltip title={hint}> - <span className="block min-w-0">{shown}</span> - </SimpleTooltip> - ) : ( - shown - )} - </div> - ); -} +import {formatCost} from "@/lib/usage"; +import type {Agent, AgentAccount, AgentRuntime, AgentUpdate, Provider, ProviderHealth} from "@/types"; /** The label for a signed-in account: its name, or its email when unnamed. */ export function accountLabel(account: AgentAccount) { return account.name || account.email || ""; } -/** The signed-in account of an agent, shown under its path. */ -function AccountLine({account}: {account?: AgentAccount}) { - const label = account ? accountLabel(account) : ""; - if (!label) { - return null; - } - const title = account?.email && account.email !== label ? `${label} · ${account.email}` : label; - - return ( - <SimpleTooltip title={title}> - <p className="text-muted-foreground flex items-center gap-1 truncate text-[11px]"> - <UserRound className="size-3 shrink-0" /> - <span className="truncate">{label}</span> - </p> - </SimpleTooltip> - ); -} - -/** What the vendor of the bound provider says is left on the plan. */ -function PlanUsage({quota}: {quota?: ProviderQuota}) { - // A provider nobody can read a balance from has nothing to show here. - if (!quota || (!quota.supported && quota.error === "")) { - return null; - } - - const percent = - quota.total !== null && quota.total > 0 && quota.used !== null - ? Math.min(100, (quota.used / quota.total) * 100) - : null; - - return ( - <div className="space-y-1.5"> - <Row label={i18next.t("provider:Plan usage")}> - <QuotaBadge quota={quota} /> - </Row> - {percent === null ? null : ( - <Progress - value={percent} - tone={percent >= 90 ? "danger" : percent >= 75 ? "warning" : "default"} - className="h-1" - /> - )} - </div> - ); -} - /** * One agent installed on this machine, as the card the home page is built out - * of: what it is on now, what it has spent there, and the controls that change - * both. Picking another provider is the one click the page exists for. + * of. It carries three things and no more: which agent it is, what it has spent, + * and the provider box that switches it over - the one click the page exists + * for. Its account, its instances, its plan balance and its configuration are a + * click deeper, on the page named at the bottom right, because eighteen cards + * carrying all of that is a page nobody reads. */ export function AgentGridCard({ agent, agents, providers, health, - quota, - stats, - usage, + spend, status, update, instances, - accounts, recording, busy, runBusy, @@ -182,20 +78,13 @@ export function AgentGridCard({ agents: Agent[]; providers: Provider[]; health: ProviderHealth[]; - quota?: ProviderQuota; - stats?: LlmAgentStat; - /** - * What this agent's own transcripts say it spent, which counts the requests - * it made straight to its vendor as well as the ones Gateway relayed. - */ - usage?: AgentUsageStat; + /** What this agent spent, from whichever of the two accounts has it. */ + spend: AgentSpend; status?: AgentRuntime; /** The release check for this installation, absent until it lands. */ update?: AgentUpdate; /** The extra copies of this agent, for the agents that can run more than one. */ instances?: AgentInstanceControls; - /** The sign-ins kept for this agent, for the agents that hold more than one. */ - accounts?: AgentAccountControls; /** False while llmRecordMode is off, when a zero would be a lie. */ recording: boolean; busy: boolean; @@ -206,7 +95,6 @@ export function AgentGridCard({ onToggleRunning: (agent: Agent, running: boolean) => void; onTogglePatch: () => void; }) { - const bound = providers.find(provider => providerIdOf(provider) === agent.provider); const boundHealth = health.find(item => item.provider === agent.provider); // An agent bound directly reads the provider's own answers, so only the ones // it can speak to are offered; through the gateway they all are. Whatever it @@ -215,25 +103,25 @@ export function AgentGridCard({ provider => agentCanUse(agent, provider) || providerIdOf(provider) === agent.provider, ); const patchAction = i18next.t(`agent:${agent.patched ? "Unpatch" : "Patch"}`); - const dash = <span className="text-muted-foreground">-</span>; - const offHint = recording ? undefined : i18next.t("llm:Recording is off"); - // An agent's own transcript accounts for every request it made, the ones that - // never came near Gateway included, so it is the fuller of the two and wins - // wherever it has anything to say. The relayed totals answer for the agents - // that keep no transcript, and for the failures a transcript cannot show. - const spent = usage && usage.requests > 0 ? usage : undefined; - const sourceHint = spent ? i18next.t("agent:Read from the agent's own logs") : offHint; + const detail = agentDetailPath(agent, agents); + // Off is only the truth about the relayed totals; a transcript is written // whether or not Gateway is recording anything. - const counted = spent !== undefined || recording; + const counted = spend.own !== undefined || recording; + const sourceHint = spend.own + ? i18next.t("agent:Read from the agent's own logs") + : recording + ? undefined + : i18next.t("llm:Recording is off"); const costHint = - spent && spent.unpriced > 0 + spend.own && spend.own.unpriced > 0 ? i18next .t("llm:{count} of these requests have no list price") - .replace("{count}", spent.unpriced.toLocaleString()) + .replace("{count}", spend.own.unpriced.toLocaleString()) : sourceHint; - const lastModel = spent?.lastModel || stats?.lastModel; - const lastTime = spent?.lastTime || stats?.lastTime; + const subtitle = [agent.version || i18next.t("agent:Unknown"), agent.account ? accountLabel(agent.account) : ""] + .filter(Boolean) + .join(" · "); return ( <Card className="hover:border-foreground/25 gap-0 overflow-hidden py-0 shadow-xs transition-colors"> @@ -245,45 +133,62 @@ export function AgentGridCard({ fallback={<Bot className="text-muted-foreground size-5" />} /> <div className="min-w-0 flex-1"> - <Link - to={agentDetailPath(agent, agents)} - className="block truncate text-sm font-medium hover:underline" - > - {agent.name} - </Link> + <div className="flex min-w-0 items-center gap-1.5"> + <Link to={detail} className="truncate text-sm font-medium hover:underline"> + {agent.name} + </Link> + {/* A waiting release is a dot rather than a line of its own: it is + worth noticing and not worth a sentence on every card. */} + {update?.available ? ( + <SimpleTooltip title={`${i18next.t("agent:New version")} ${update.latest}`}> + <span className="bg-warning size-1.5 shrink-0 rounded-full" /> + </SimpleTooltip> + ) : null} + </div> <SimpleTooltip title={agent.path}> - <p className="text-muted-foreground/80 truncate font-mono text-[11px]"> - <span className="tabular-nums">{agent.version || i18next.t("agent:Unknown")}</span> - <span className="mx-1">·</span> - {agent.path} - </p> + <p className="text-muted-foreground/80 truncate text-[11px]">{subtitle}</p> </SimpleTooltip> - <AgentUpdateBadge update={update} className="mt-1 text-[11px]" /> - <AccountLine account={agent.account} /> </div> <RunDot status={status} /> </div> - <Divider /> - - <div className="space-y-1.5"> - <Row label={i18next.t("agent:Provider")}> - <span className="flex min-w-0 items-center gap-2"> - {boundHealth && !boundHealth.healthy ? ( - <SimpleTooltip title={boundHealth.lastError}> - <span className="text-warning truncate text-[11px]"> - {i18next.t("agent:Cooling down")} - </span> - </SimpleTooltip> - ) : null} - {bound === undefined ? null : ( + {/* The one number. An agent nobody has run says so in words: a zero + there reads as free rather than as unused. */} + <SimpleTooltip title={costHint}> + <div className="flex min-w-0 flex-col gap-0.5"> + {counted && spend.requests > 0 ? ( + <> + <span className="text-lg leading-none font-semibold tabular-nums"> + {formatCost(spend.cost)} + </span> + <span className="text-muted-foreground min-w-0 truncate text-[11px] tabular-nums"> + {i18next.t("llm:{count} requests").replace("{count}", spend.requests.toLocaleString())} + {spend.lastModel ? <span className="font-mono"> · {spend.lastModel}</span> : null} + </span> + </> + ) : ( + <> + <span className="text-muted-foreground text-lg leading-none font-semibold">-</span> <span className="text-muted-foreground truncate text-[11px]"> - {i18next.t(agent.mode === directMode ? "agent:Direct" : "agent:Gateway")} + {i18next.t(counted ? "agent:Never used" : "llm:Recording is off")} </span> - )} + </> + )} + </div> + </SimpleTooltip> + + {boundHealth && !boundHealth.healthy ? ( + <SimpleTooltip title={boundHealth.lastError}> + <span className="text-warning -mb-1 truncate text-[11px]"> + {i18next.t("agent:Cooling down")} </span> - </Row> + </SimpleTooltip> + ) : null} + {/* The bottom block of the card: where it points, and the copies that + point there. Held together so the boxes line up across a row of + cards whose spend lines are different heights. */} + <div className="mt-auto flex flex-col gap-2"> <Select value={agent.provider === "" ? builtinProvider : agent.provider} disabled={busy} @@ -296,9 +201,7 @@ export function AgentGridCard({ <SelectItem value={builtinProvider}> <AgentIcon agent={agent.agentId || agent.name} size={16} fallback={<Bot className="size-4" />} /> {agentBuiltin(agent)} - <span className="text-muted-foreground ml-2 text-xs"> - {i18next.t("agent:Built-in")} - </span> + <span className="text-muted-foreground ml-2 text-xs">{i18next.t("agent:Built-in")}</span> </SelectItem> {options.map(provider => ( <SelectItem key={providerIdOf(provider)} value={providerIdOf(provider)}> @@ -312,64 +215,11 @@ export function AgentGridCard({ </SelectContent> </Select> - {accounts ? <AgentCardAccounts agent={agent} controls={accounts} /> : null} - - <Row label={i18next.t("agent:Model")}> - {lastModel ? ( - <SimpleTooltip - title={`${i18next.t("agent:Last activity")}: ${new Date(lastTime ?? "").toLocaleString()}`} - > - <span className="min-w-0 truncate font-mono text-[11px]">{lastModel}</span> - </SimpleTooltip> - ) : ( - <span className="text-muted-foreground text-[11px]">-</span> - )} - </Row> - </div> - - <Divider /> - - <div className="space-y-3"> - {/* The shape of the spend wherever the transcripts carry a history, - and the bare totals for the agents whose only account is what - Gateway relayed - that source keeps no per-day breakdown. */} - {spent && hasUsageTrend(spent) ? ( - <AgentUsageTrend - stat={spent} - hints={{tokens: sourceHint, requests: sourceHint, cost: costHint}} - /> - ) : ( - <div className="grid grid-cols-3 gap-1.5"> - <Metric - label={i18next.t("llm:Requests")} - value={counted ? ((spent ?? stats)?.requests ?? 0).toLocaleString() : dash} - hint={ - sourceHint ?? - (stats && stats.failed > 0 - ? `${stats.failed.toLocaleString()} ${i18next.t("llm:failed")}` - : undefined) - } - /> - <Metric - label={i18next.t("llm:Tokens")} - value={counted ? formatTokens(spent?.totalTokens ?? stats?.tokens ?? 0) : dash} - hint={sourceHint} - /> - <Metric - label={i18next.t("llm:Cost")} - value={counted ? formatCost(spent?.cost ?? stats?.cost ?? 0) : dash} - hint={costHint} - /> - </div> - )} - - <PlanUsage quota={quota} /> - {instances ? <AgentCardInstances agent={agent} controls={instances} /> : null} </div> </div> - <div className="bg-muted/40 mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 border-t px-3 py-2"> + <div className="bg-muted/40 mt-auto flex items-center gap-2 border-t px-3 py-1.5"> <RunButton agent={agent} status={status} @@ -379,8 +229,8 @@ export function AgentGridCard({ onToggle={onToggleRunning} /> - <label className="text-muted-foreground flex items-center gap-1.5 text-[11px]"> - {i18next.t("agent:Monitored")} + <label className="text-muted-foreground flex min-w-0 items-center gap-1.5 text-[11px]"> + <span className="truncate">{i18next.t("agent:Monitored")}</span> {agent.supported ? ( <ConfirmDialog title={`${patchAction} ${agent.name}?`} @@ -407,13 +257,15 @@ export function AgentGridCard({ )} </label> - <Link - to={agentDetailPath(agent, agents)} - className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center text-xs" - > - {i18next.t("agent:Details")} - <ChevronRight className="size-3.5" /> - </Link> + <SimpleTooltip title={i18next.t("agent:Details")}> + <Link + to={detail} + aria-label={i18next.t("agent:Details")} + className="text-muted-foreground hover:text-foreground ml-auto inline-flex shrink-0 items-center" + > + <ChevronRight className="size-4" /> + </Link> + </SimpleTooltip> </div> </Card> ); diff --git a/web/src/components/AuthenticityOverview.tsx b/web/src/components/AuthenticityOverview.tsx index 7098304..c1211ef 100644 --- a/web/src/components/AuthenticityOverview.tsx +++ b/web/src/components/AuthenticityOverview.tsx @@ -108,24 +108,9 @@ function ProviderScore({ ); } -/** - * Whether the APIs on this machine are what they are sold as, in one card. It - * is measured without being asked for: every provider is probed when it is - * added and again once its report goes stale, so this says something before - * anyone thinks to check. - */ -export function AuthenticityOverview({ - providers, - showLink = true, - className, -}: { - providers: Provider[]; - /** False on the page this links to, where the link would point at itself. */ - showLink?: boolean; - className?: string; -}) { +/** The scores as they stand, re-read while the page is open. */ +export function useProviderProbes() { const [probes, setProbes] = React.useState<ProviderProbe[]>([]); - const [cases, setCases] = React.useState<ProbeCase[]>([]); const [mode, setMode] = React.useState<ProviderProbeMode>("auto"); const [loaded, setLoaded] = React.useState(false); @@ -148,14 +133,15 @@ export function AuthenticityOverview({ return () => clearInterval(interval); }, []); - // Read once: the suite names the cases a row failed, and it does not change - // while a page is open. - React.useEffect(() => { - ProbeCaseBackend.getProbeCases() - .then(res => setCases(res.status === "ok" ? (res.data ?? []) : [])) - .catch(() => undefined); - }, []); + return {probes: probes, mode: mode, loaded: loaded}; +} +/** + * The scores of the providers in use, worst first, and what they add up to. The + * headline grade is the worst one held rather than the average: a machine with + * four honest providers and one that is not is not four fifths fine. + */ +export function authenticityOf(providers: Provider[], probes: ProviderProbe[]) { const configured = providers.filter(provider => provider.status !== "disabled"); const byProvider = new Map(probes.map(probe => [probe.provider, probe])); const scored = configured @@ -166,13 +152,45 @@ export function AuthenticityOverview({ return score(left) - score(right); }); - const summary = summarizeAuthenticity( - scored.filter(entry => entry.probe).map(entry => entry.probe as ProviderProbe), - configured.length, - ); - // The headline grade is the worst one held, not the average: a machine with - // four honest providers and one that is not is not four fifths fine. - const worst = scored.find(entry => entry.probe?.ok && entry.probe.grade !== "unknown")?.probe; + return { + configured: configured, + scored: scored, + summary: summarizeAuthenticity( + scored.filter(entry => entry.probe).map(entry => entry.probe as ProviderProbe), + configured.length, + ), + worst: scored.find(entry => entry.probe?.ok && entry.probe.grade !== "unknown")?.probe, + }; +} + +/** + * Whether the APIs on this machine are what they are sold as, in one card. It + * is measured without being asked for: every provider is probed when it is + * added and again once its report goes stale, so this says something before + * anyone thinks to check. + */ +export function AuthenticityOverview({ + providers, + showLink = true, + className, +}: { + providers: Provider[]; + /** False on the page this links to, where the link would point at itself. */ + showLink?: boolean; + className?: string; +}) { + const [cases, setCases] = React.useState<ProbeCase[]>([]); + const {probes, mode, loaded} = useProviderProbes(); + + // Read once: the suite names the cases a row failed, and it does not change + // while a page is open. + React.useEffect(() => { + ProbeCaseBackend.getProbeCases() + .then(res => setCases(res.status === "ok" ? (res.data ?? []) : [])) + .catch(() => undefined); + }, []); + + const {configured, scored, summary, worst} = authenticityOf(providers, probes); const style = gradeStyleOf(worst?.grade); if (configured.length === 0) { diff --git a/web/src/components/CcSwitchBanner.tsx b/web/src/components/CcSwitchBanner.tsx index 9dc2e72..e527f8d 100644 --- a/web/src/components/CcSwitchBanner.tsx +++ b/web/src/components/CcSwitchBanner.tsx @@ -14,11 +14,10 @@ import * as React from "react"; import {Link} from "react-router-dom"; -import {ArrowDownToLine} from "lucide-react"; +import {ArrowDownToLine, X} from "lucide-react"; import i18next from "i18next"; import * as CcSwitchBackend from "@/backend/CcSwitchBackend"; -import {MessageAlert} from "@/components/ui/alert"; import {Button} from "@/components/ui/button"; import type {CcSwitchImport} from "@/types"; @@ -90,40 +89,33 @@ export function CcSwitchBanner() { return null; } + // One row rather than a titled alert: the button says what happens, so the + // sentence that used to say it as well was a line of reading for nothing. return ( - <MessageAlert - variant="info" - title={i18next.t("link:CC Switch is installed here")} - // Two lines rather than one sentence: the counts read as a list, and - // joining them onto the hint would need a separator that is a space in - // English and nothing at all in Chinese. - description={ - <> - <span className="block">{i18next.t("link:CC Switch is installed here hint")}</span> - <span className="block font-medium">{summary(found)}</span> - </> - } - action={ - <div className="flex flex-wrap gap-2"> - <Button asChild size="sm"> - <Link to="/import"> - <ArrowDownToLine /> - {i18next.t("provider:Bring it all over")} - </Link> - </Button> - <Button - type="button" - size="sm" - variant="ghost" - onClick={() => { - writeDismissed(); - setDismissed(true); - }} - > - {i18next.t("general:Dismiss")} - </Button> - </div> - } - /> + <div className="bg-card flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border px-4 py-2.5"> + <ArrowDownToLine className="text-info size-4 shrink-0" /> + <span className="min-w-0 truncate text-sm font-medium"> + {i18next.t("link:CC Switch is installed here")} + </span> + <span className="text-muted-foreground min-w-0 truncate text-xs">{summary(found)}</span> + + <div className="ml-auto flex shrink-0 items-center gap-1"> + <Button asChild size="sm"> + <Link to="/import">{i18next.t("provider:Bring it all over")}</Link> + </Button> + <Button + type="button" + size="icon-sm" + variant="ghost" + aria-label={i18next.t("general:Dismiss")} + onClick={() => { + writeDismissed(); + setDismissed(true); + }} + > + <X /> + </Button> + </div> + </div> ); } diff --git a/web/src/components/HomeSummary.tsx b/web/src/components/HomeSummary.tsx new file mode 100644 index 0000000..00f81ab --- /dev/null +++ b/web/src/components/HomeSummary.tsx @@ -0,0 +1,170 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from "react"; +import {Link} from "react-router-dom"; +import {ChevronRight} from "lucide-react"; +import i18next from "i18next"; + +import {authenticityOf, useProviderProbes} from "@/components/AuthenticityOverview"; +import {Card} from "@/components/ui/card"; +import {SimpleTooltip} from "@/components/ui/tooltip"; +import {formatScore, gradeLetter, gradeStyleOf} from "@/lib/authenticity"; +import {cn} from "@/lib/utils"; +import {formatCost} from "@/lib/usage"; +import type {AgentSpend} from "@/lib/agents"; +import type {Provider} from "@/types"; + +/** Fills the {name} placeholders these strings carry. */ +function fill(key: string, values: Record<string, string | number>) { + return Object.entries(values).reduce( + (text, [name, value]) => text.split(`{${name}}`).join(String(value)), + i18next.t(key), + ); +} + +interface Cell { + key: string; + label: React.ReactNode; + value: React.ReactNode; + note?: React.ReactNode; + tone?: string; + to?: string; + hint?: React.ReactNode; +} + +/** One figure of the strip: what it reads, then what it counts. */ +function Figure({cell, className}: {cell: Cell; className?: string}) { + const body = ( + <> + <span + className={cn( + "flex items-baseline gap-1 truncate text-xl font-semibold tabular-nums", + cell.tone, + )} + > + {cell.value} + {cell.to ? <ChevronRight className="text-muted-foreground size-4 shrink-0 self-center" /> : null} + </span> + <span className="text-muted-foreground truncate text-xs"> + {cell.label} + {cell.note ? <span className="opacity-70"> · {cell.note}</span> : null} + </span> + </> + ); + + const inner = cell.to ? ( + <Link to={cell.to} className="flex min-w-0 flex-col gap-0.5"> + {body} + </Link> + ) : ( + <div className="flex min-w-0 flex-col gap-0.5">{body}</div> + ); + + return ( + <div className={cn("min-w-0 px-4 py-3", cell.to && "hover:bg-accent/40 transition-colors", className)}> + {cell.hint ? <SimpleTooltip title={cell.hint}>{inner}</SimpleTooltip> : inner} + </div> + ); +} + +/** + * The whole machine in one line: what the agents on it have spent, how many of + * them there are, and whether the keys behind them are what they were sold as. + * Everything here is otherwise only readable by adding up the cards below it, + * which is the reading the home screen should not ask anyone to do. + */ +export function HomeSummary({ + total, + agents, + running, + providers, + loaded, +}: { + /** The spend of every agent added up, by the same rule each card uses. */ + total: Pick<AgentSpend, "requests" | "cost">; + agents: number; + running: number; + providers: Provider[]; + /** False until the first listing lands, when a zero would be a guess. */ + loaded: boolean; +}) { + const {probes} = useProviderProbes(); + const {configured, summary, worst} = authenticityOf(providers, probes); + const style = gradeStyleOf(worst?.grade); + const dash = <span className="text-muted-foreground">-</span>; + + const cells: Cell[] = [ + { + key: "cost", + label: i18next.t("llm:Cost"), + note: i18next.t("llm:All time"), + value: loaded ? formatCost(total.cost) : dash, + hint: i18next.t("agent:Read from the agent's own logs"), + }, + { + key: "requests", + label: i18next.t("llm:Requests"), + value: loaded ? total.requests.toLocaleString() : dash, + to: "/usage", + }, + { + key: "agents", + label: i18next.t("agent:Agents"), + note: fill("agent:{count} running", {count: running}), + value: agents, + }, + ]; + + if (configured.length > 0) { + cells.push({ + key: "authenticity", + label: i18next.t("audit:Authenticity"), + note: + summary.alerting > 0 + ? fill("audit:Providers alerting", {count: summary.alerting}) + : fill("audit:Graded providers", {graded: summary.graded, total: configured.length}), + value: ( + <span className="tabular-nums"> + {gradeLetter(worst?.grade)} + {worst ? ` · ${formatScore(worst)}` : ""} + </span> + ), + tone: style.text, + to: "/authenticity", + hint: i18next.t(style.verdict), + }); + } + + return ( + <Card className="gap-0 overflow-hidden py-0 shadow-xs"> + <div className={cn("grid grid-cols-2", cells.length > 3 ? "sm:grid-cols-4" : "sm:grid-cols-3")}> + {cells.map((cell, index) => ( + <Figure + key={cell.key} + cell={cell} + // Two columns on a phone rule a cross through the middle; one row on + // a wide screen rules only between the figures. + className={cn( + index % 2 === 1 && "border-l", + index >= 2 && "border-t", + "sm:border-t-0", + index > 0 && "sm:border-l", + )} + /> + ))} + </div> + </Card> + ); +} diff --git a/web/src/components/OnboardingChecklist.tsx b/web/src/components/OnboardingChecklist.tsx index 94b7c09..24d6ba9 100644 --- a/web/src/components/OnboardingChecklist.tsx +++ b/web/src/components/OnboardingChecklist.tsx @@ -118,16 +118,6 @@ export function useOnboarding({ }; } -/** The entry that brings the card back, for whoever closed it. */ -export function OnboardingButton({onboarding}: {onboarding: Onboarding}) { - return ( - <Button variant="ghost" onClick={onboarding.reopen}> - <ListChecks /> - {i18next.t("agent:Getting started")} - </Button> - ); -} - export function OnboardingChecklist({onboarding}: {onboarding: Onboarding}) { const steps = onboarding.steps; const doneCount = steps.filter(step => step.done).length; diff --git a/web/src/components/shared/fold.tsx b/web/src/components/shared/fold.tsx new file mode 100644 index 0000000..46674ae --- /dev/null +++ b/web/src/components/shared/fold.tsx @@ -0,0 +1,61 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from "react"; +import {ChevronDown} from "lucide-react"; + +import {cn} from "@/lib/utils"; + +/** + * A block of the page that stays folded until it is asked for, named by the one + * line that says what is inside it. What is worth a whole screen to somebody + * looking for it is worth a line to everybody else. + */ +export function Fold({ + title, + description, + defaultOpen = false, + className, + children, +}: { + title: React.ReactNode; + /** Shown only once the block is open, where it has something to describe. */ + description?: React.ReactNode; + defaultOpen?: boolean; + className?: string; + children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(defaultOpen); + + return ( + <section className={cn("flex flex-col gap-3", className)}> + <button + type="button" + onClick={() => setOpen(!open)} + aria-expanded={open} + className="text-muted-foreground hover:text-foreground -mx-1 flex w-fit items-center gap-1.5 px-1 text-xs transition-colors" + > + <ChevronDown className={cn("size-3.5 transition-transform", open && "rotate-180")} /> + {title} + </button> + + {open ? ( + <> + {description ? <p className="text-muted-foreground -mt-1 text-xs">{description}</p> : null} + {children} + </> + ) : null} + </section> + ); +} diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts index cb4af5a..a723901 100644 --- a/web/src/lib/agents.ts +++ b/web/src/lib/agents.ts @@ -30,6 +30,7 @@ import type { AgentUpdate, AgentUsage, AgentUsageStat, + LlmAgentStat, Provider, SavedAccount, SavedAccounts, @@ -779,12 +780,14 @@ export function useAgentSessions(enabled = true, agent = "", refreshMs = 0) { } /** - * Transcripts are written per tool rather than per installation: every Codex - * front end shares one directory, so all of them read the same usage. That is - * also the limit of what the files can tell apart. + * Transcripts are written per tool rather than per installation, and the Codex + * front ends share one directory: the reader tells the desktop app apart from + * the CLI by the originator each session reports, and keys them the way + * agentmonitor spells them. The VS Code extension is counted as the CLI, which + * is the one front end that keeps no id of its own. */ export function usageAgentId(agentId: string) { - return agentId.startsWith("codex") ? "codex-cli" : agentId; + return agentId === "codex_vscode" || agentId === "codex-vscode" ? "codex-cli" : agentId; } /** What one installation spent, out of the totals read off the transcripts. */ @@ -792,6 +795,44 @@ export function usageOf(usage: AgentUsage | undefined, agent: Agent): AgentUsage return usage?.agents.find(item => item.name === usageAgentId(agent.agentId)); } +/** What one agent spent, and which of the two accounts it was read from. */ +export interface AgentSpend { + requests: number; + tokens: number; + cost: number; + /** The transcript this was read from, absent when it had nothing to say. */ + own?: AgentUsageStat; + relayed?: LlmAgentStat; + lastModel: string; + lastTime: string; +} + +/** + * What one agent spent. An agent's own transcript accounts for every request it + * made, the ones that never came near Gateway included, so it wins wherever it + * has anything to say; the relayed totals answer for the agents that keep no + * transcript. One rule, so a card and the strip above it never disagree. + */ +export function agentSpend( + agent: Agent, + usage: AgentUsage | undefined, + stats: LlmAgentStat[], +): AgentSpend { + const own = usageOf(usage, agent); + const relayed = stats.find(item => item.agent === agent.agentId); + const spent = own && own.requests > 0 ? own : undefined; + + return { + requests: spent?.requests ?? relayed?.requests ?? 0, + tokens: spent?.totalTokens ?? relayed?.tokens ?? 0, + cost: spent?.cost ?? relayed?.cost ?? 0, + own: spent, + relayed: relayed, + lastModel: spent?.lastModel || relayed?.lastModel || "", + lastTime: spent?.lastTime || relayed?.lastTime || "", + }; +} + /** The badge colour every record table paints an outcome with. */ export function getOutcomeVariant(outcome: string | undefined): BadgeVariant { return ( diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 8df8495..37e4c07 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -374,8 +374,11 @@ "opencode config hint": "opencode reads no base URL from the environment: the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, loaded through @ai-sdk/openai-compatible and selected with \"model\": \"<provider>/<model>\".", "switches": "switches", "updates available": "updates available", + "{count} agents not in use": "{count} agents not in use", + "{count} agents not installed": "{count} agents not installed", "{count} from transcripts": "{count} from transcripts", - "{count} messages": "{count} messages" + "{count} messages": "{count} messages", + "{count} running": "{count} running" }, "agentConfig": { "1 file": "1 file", @@ -953,6 +956,7 @@ "Loading": "Loading...", "Logged in successfully": "Logged in successfully", "Login Error": "Login Error", + "More": "More", "Name": "Name", "Name cannot be empty": "Name cannot be empty", "Neutral": "Neutral", @@ -1153,7 +1157,8 @@ "written": "written", "{count} new records": "{count} new records", "{count} of these requests have no list price": "{count} of these requests have no list price", - "{count} records have no price": "{count} records have no price" + "{count} records have no price": "{count} records have no price", + "{count} requests": "{count} requests" }, "provider": { "API Key": "API Key", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index df2c987..7bbe8a1 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -374,8 +374,11 @@ "opencode config hint": "opencode不从环境变量读取base URL:端点是~/.config/opencode/opencode.json里的一个\"provider\"条目,用@ai-sdk/openai-compatible加载,再用\"model\": \"<provider>/<model>\"选中它。", "switches": "个开关", "updates available": "个可更新", + "{count} agents not in use": "{count}个Agent未在使用", + "{count} agents not installed": "{count}个Agent未安装", "{count} from transcripts": "{count}个来自会话记录", - "{count} messages": "{count}条消息" + "{count} messages": "{count}条消息", + "{count} running": "{count}个在运行" }, "agentConfig": { "1 file": "1个文件", @@ -1022,6 +1025,7 @@ "Loading": "加载中...", "Logged in successfully": "登录成功", "Login Error": "登录失败", + "More": "更多", "Name": "名称", "Name cannot be empty": "名称不能为空", "Neutral": "中性灰", @@ -1222,7 +1226,8 @@ "written": "写入", "{count} new records": "{count}条新记录", "{count} of these requests have no list price": "其中{count}个请求没有对应的价目", - "{count} records have no price": "{count}条记录没有价格" + "{count} records have no price": "{count}条记录没有价格", + "{count} requests": "{count}次请求" }, "provider": { "API Key": "API密钥", diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx index 82ec6a6..7d9a2af 100644 --- a/web/src/pages/AgentDetailPage.tsx +++ b/web/src/pages/AgentDetailPage.tsx @@ -25,6 +25,7 @@ import * as Setting from "@/Setting"; import {AgentIcon} from "@/components/AgentIcon"; import {AgentAccounts} from "@/components/AgentAccounts"; import {AgentInstances} from "@/components/AgentInstances"; +import {AgentUsageTrend, hasUsageTrend} from "@/components/AgentUsageTrend"; import {accountLabel} from "@/components/AgentGridCard"; import {RunBadge, RunButton} from "@/components/AgentRunControl"; import { @@ -55,6 +56,8 @@ import { useAgents, useAgentSessions, useAgentUpdates, + usageAgentId, + usageOf, } from "@/lib/agents"; import {formatCost, formatTokens} from "@/lib/usage"; import type { @@ -63,6 +66,8 @@ import type { AgentRecord, AgentRuntime, AgentSession, + AgentUsage, + AgentUsageStat, Provider, ProviderHealth, LlmRecordStats, @@ -183,14 +188,29 @@ function RuntimeCard({ ); } -/** What this agent has spent through the proxy, and on which provider. */ -function UsageCard({stats}: {stats: LlmRecordStats | null}) { +/** + * What this agent has spent through the proxy, and on which provider - with the + * shape of what its own transcripts say above it, which is the fuller of the two + * accounts and the only one that sees a request Gateway never relayed. + */ +function UsageCard({stats, spent}: {stats: LlmRecordStats | null; spent?: AgentUsageStat}) { return ( <Card> <CardHeader className="p-4 pb-2"> <CardTitle className="text-base">{i18next.t("agent:Usage")}</CardTitle> </CardHeader> <CardContent className="space-y-3 p-4 pt-0"> + {spent && hasUsageTrend(spent) ? ( + <AgentUsageTrend + stat={spent} + hints={{ + tokens: i18next.t("agent:Read from the agent's own logs"), + requests: i18next.t("agent:Read from the agent's own logs"), + cost: i18next.t("agent:Read from the agent's own logs"), + }} + /> + ) : null} + <div className="grid grid-cols-3 gap-2"> <div> <div className="text-xs text-muted-foreground">{i18next.t("llm:Requests")}</div> @@ -264,6 +284,7 @@ export default function AgentDetailPage({account}: {account: Account}) { const [providers, setProviders] = React.useState<Provider[]>([]); const [health, setHealth] = React.useState<ProviderHealth[]>([]); const [stats, setStats] = React.useState<LlmRecordStats | null>(null); + const [usage, setUsage] = React.useState<AgentUsage>(); const agentId = params.agentId ?? ""; const path = searchParams.get("path") ?? ""; @@ -303,8 +324,15 @@ export default function AgentDetailPage({account}: {account: Account}) { LlmRecordBackend.getLlmRecordStats({agent: agentId}) .then(res => setStats(res.status === "ok" ? (res.data ?? null) : null)) .catch(() => setStats(null)); + + // What the agent wrote down itself, which counts the requests that never + // came through Gateway. Read once: walking the transcripts is not cheap. + AgentBackend.getAgentUsage(usageAgentId(agentId)) + .then(res => setUsage(res.status === "ok" ? (res.data ?? undefined) : undefined)) + .catch(() => undefined); }, [isAdmin, agentId]); + const spent = agent ? usageOf(usage, agent) : undefined; const monitorId = agent ? monitorAgentId(agent.agentId) : ""; const watching = Boolean(agent?.patched); const {sessions} = useAgentSessions(isAdmin && watching, monitorId, 5000); @@ -496,7 +524,7 @@ export default function AgentDetailPage({account}: {account: Account}) { onWrite={restore => writeProvider(agent, restore)} /> - <UsageCard stats={stats} /> + <UsageCard stats={stats} spent={spent} /> <Card> <CardHeader className="p-4 pb-2"> diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index cfb9754..30d7385 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -14,7 +14,7 @@ import * as React from "react"; import {Link} from "react-router-dom"; -import {Bot, Container, Plus, RefreshCw, Table2} from "lucide-react"; +import {Bot, Container, ListChecks, MoreHorizontal, Plus, RefreshCw, Table2} from "lucide-react"; import i18next from "i18next"; import * as AgentBackend from "@/backend/AgentBackend"; @@ -23,36 +23,35 @@ import * as ProviderBackend from "@/backend/ProviderBackend"; import * as Setting from "@/Setting"; import {AgentCatalog} from "@/components/AgentCatalog"; import {CcSwitchBanner} from "@/components/CcSwitchBanner"; -import {AuthenticityOverview} from "@/components/AuthenticityOverview"; -import {AgentSigninDialog} from "@/components/AgentAccounts"; import {AgentGridCard} from "@/components/AgentGridCard"; -import {OnboardingButton, OnboardingChecklist, useOnboarding} from "@/components/OnboardingChecklist"; +import {HomeSummary} from "@/components/HomeSummary"; +import {OnboardingChecklist, useOnboarding} from "@/components/OnboardingChecklist"; import {EmptyState, ErrorState} from "@/components/shared/empty-state"; +import {Fold} from "@/components/shared/fold"; import {Loading} from "@/components/shared/loading"; import {UnauthorizedResult} from "@/components/shared/misc"; import {PageContainer, PageHeader} from "@/components/shared/page-header"; import {MessageAlert} from "@/components/ui/alert"; import {Button} from "@/components/ui/button"; import {Card} from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { agentKey, + agentSpend, runtimeOf, - useAgentAccounts, useAgentInstall, useAgentInstances, useAgents, useAgentUpdates, updateOf, - usageOf, + type AgentSpend, } from "@/lib/agents"; -import type { - Account, - AgentUsage, - LlmAgentStat, - Provider, - ProviderHealth, - ProviderQuota, -} from "@/types"; +import type {Agent, Account, AgentUsage, LlmAgentStat, Provider, ProviderHealth} from "@/types"; /** How often the numbers on the cards are read again. */ const refreshMs = 10000; @@ -64,12 +63,17 @@ const refreshMs = 10000; */ const usageRefreshMs = 60000; +/** Whether this installation is worth a card before anyone asks for one. */ +function inUse(agent: Agent, spend: AgentSpend, running: boolean) { + return running || agent.provider !== "" || spend.requests > 0; +} + /** - * The home screen: one card per agent installed on this machine. Each card - * carries what that agent is on, what it has spent there and whether it is - * running, and the provider box switches it over in one click — the routing is - * stored and, where Gateway writes the agent's configuration file, that file is - * rewritten with it. + * The home screen. It answers two questions and leaves the rest to the pages + * below it: what did this machine spend, and what is each agent on. Everything + * an agent carries beyond that - its path, its account, its extra copies, its + * plan balance - is a click away on its own page, because eighteen cards saying + * all of it at once is a page that gets scrolled past rather than read. */ export default function HomePage({account}: {account: Account}) { const isAdmin = Setting.isAdminUser(account); @@ -98,10 +102,8 @@ export default function HomePage({account}: {account: Account}) { // Every agent's extra copies in one listing, so a page of cards costs one // request rather than one per card. const instances = useAgentInstances("", isAdmin); - const accounts = useAgentAccounts(agents, isAdmin); const [providers, setProviders] = React.useState<Provider[]>([]); const [health, setHealth] = React.useState<ProviderHealth[]>([]); - const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]); const [stats, setStats] = React.useState<LlmAgentStat[]>([]); const [usage, setUsage] = React.useState<AgentUsage>(); // Nothing is recorded until it is asked for, and until it is a zero on a card @@ -128,16 +130,6 @@ export default function HomePage({account}: {account: Account}) { }) .catch(failure => setProviderError(failure.message || String(failure))) .then(() => setLoaded(true)); - - // What is already known paints at once; the refresh then asks only the - // vendors whose answer has gone stale, so a page view costs at most one - // request per provider every ten minutes. - ProviderBackend.getProviderQuotas() - .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : [])) - .catch(() => undefined) - .then(() => ProviderBackend.refreshProviderQuotas("", false)) - .then(res => setQuotas(res?.status === "ok" ? (res.data ?? []) : [])) - .catch(() => undefined); }, [isAdmin, account.name]); React.useEffect(() => { @@ -197,6 +189,28 @@ export default function HomePage({account}: {account: Account}) { // back to whoever closed it. const onboarding = useOnboarding({providers: providers, agents: agents, stats: stats}); + // One reading of each agent, shared by the cards, the strip above them and the + // split below: the totals cannot disagree with the cards they add up. + const rows = agents + .map(agent => { + const status = runtimeOf(runtime, agent); + const spend = agentSpend(agent, usage, stats); + return {agent: agent, status: status, spend: spend, using: inUse(agent, spend, status?.running === true)}; + }) + // Busiest first, and whatever is running before whatever is not: a card is + // worth its place by what it is doing, not by where it was installed. + .sort((left, right) => { + const run = Number(right.status?.running ?? false) - Number(left.status?.running ?? false); + return run !== 0 ? run : right.spend.cost - left.spend.cost || right.spend.requests - left.spend.requests; + }); + const using = rows.filter(row => row.using); + const idle = rows.filter(row => !row.using); + const running = rows.filter(row => row.status?.running).length; + const total = rows.reduce( + (sum, row) => ({requests: sum.requests + row.spend.requests, cost: sum.cost + row.spend.cost}), + {requests: 0, cost: 0}, + ); + if (!isAdmin) { return <UnauthorizedResult />; } @@ -215,6 +229,31 @@ export default function HomePage({account}: {account: Account}) { </Button> ); + const cards = (rendered: typeof rows) => ( + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> + {rendered.map(row => ( + <AgentGridCard + key={agentKey(row.agent)} + agent={row.agent} + agents={agents} + providers={providers} + health={health} + spend={row.spend} + status={row.status} + update={updateOf(updates.updates, row.agent)} + instances={instances} + recording={recording} + busy={busyKey === agentKey(row.agent)} + runBusy={runBusyKey === agentKey(row.agent)} + onEnable={providerId => activateProvider(row.agent, providerId)} + onLocated={() => scan(true)} + onToggleRunning={toggleRunning} + onTogglePatch={() => togglePatch(row.agent)} + /> + ))} + </div> + ); + return ( <PageContainer> <PageHeader @@ -222,13 +261,27 @@ export default function HomePage({account}: {account: Account}) { description={account.hostname} actions={ <> - {loaded && !onboarding.open ? <OnboardingButton onboarding={onboarding} /> : null} - <Button asChild variant="ghost"> - <Link to="/agents"> - <Table2 /> - {i18next.t("agent:Advanced view")} - </Link> - </Button> + {/* Two controls, not four: the rescan is the one anybody presses, + and the rest are places rather than actions. */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon" aria-label={i18next.t("general:More")}> + <MoreHorizontal /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem asChild> + <Link to="/agents"> + <Table2 /> + {i18next.t("agent:Advanced view")} + </Link> + </DropdownMenuItem> + <DropdownMenuItem onSelect={onboarding.reopen}> + <ListChecks /> + {i18next.t("agent:Getting started")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> {rescan} </> } @@ -240,6 +293,19 @@ export default function HomePage({account}: {account: Account}) { the import page is the first thing they want rather than a form. */} <CcSwitchBanner /> + {/* What the machine adds up to, including whether the keys behind it are + what they were sold as. Every figure here is otherwise only reachable + by reading every card below. */} + {scanned && agents.length > 0 ? ( + <HomeSummary + total={total} + agents={agents.length} + running={running} + providers={providers} + loaded={usage !== undefined} + /> + ) : null} + {/* With no agents to show, the failure is the whole page and is rendered below instead; here it sits above the cards that are still on screen. */} {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : null} @@ -271,11 +337,6 @@ export default function HomePage({account}: {account: Account}) { /> ) : null} - {/* Whether the keys behind these agents reach what they are sold as. It - is measured without being asked for, so it belongs above the fold - rather than behind a button on another page. */} - {providers.length > 0 ? <AuthenticityOverview providers={providers} /> : null} - {!scanned ? ( <Loading tip={i18next.t("agent:Scan")} /> ) : error !== "" && agents.length === 0 ? ( @@ -298,38 +359,31 @@ export default function HomePage({account}: {account: Account}) { /> </Card> ) : ( - <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> - {agents.map(agent => ( - <AgentGridCard - key={agentKey(agent)} - agent={agent} - agents={agents} - providers={providers} - health={health} - quota={quotas.find(item => item.provider === agent.provider)} - stats={stats.find(item => item.agent === agent.agentId)} - usage={usageOf(usage, agent)} - status={runtimeOf(runtime, agent)} - update={updateOf(updates.updates, agent)} - instances={instances} - accounts={accounts} - recording={recording} - busy={busyKey === agentKey(agent)} - runBusy={runBusyKey === agentKey(agent)} - onEnable={providerId => activateProvider(agent, providerId)} - onLocated={() => scan(true)} - onToggleRunning={toggleRunning} - onTogglePatch={() => togglePatch(agent)} - /> - ))} - </div> - )} + <> + {/* Everything bound, running or spending. On a machine where nothing + is any of those the split has nothing to hide, so it is skipped. */} + {cards(using.length > 0 ? using : rows)} - {/* One sign-in runs at a time, so the whole grid shares one dialog. */} - <AgentSigninDialog controls={accounts} /> + {using.length > 0 && idle.length > 0 ? ( + <Fold + title={i18next + .t("agent:{count} agents not in use") + .replace("{count}", String(idle.length))} + > + {cards(idle)} + </Fold> + ) : null} + </> + )} {/* What is not here yet, so an empty machine has somewhere to go. */} - <AgentCatalog agents={agents} enabled={scanned} installer={installer} onLocated={() => scan(true)} /> + <AgentCatalog + agents={agents} + enabled={scanned} + defaultOpen={agents.length === 0} + installer={installer} + onLocated={() => scan(true)} + /> </PageContainer> ); } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
