mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
Hide hosted dashboard update controls
This commit is contained in:
parent
55cb4103be
commit
0b6b29a30c
7 changed files with 242 additions and 82 deletions
110
web/src/App.tsx
110
web/src/App.tsx
|
|
@ -5,6 +5,8 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type FocusEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
|
@ -812,26 +814,33 @@ function SidebarNavLink({
|
|||
t,
|
||||
}: SidebarNavLinkProps) {
|
||||
const { path, label, labelKey, icon: Icon } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const navLabel = labelKey
|
||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||
: label;
|
||||
const showTooltip = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
||||
setHovered(true);
|
||||
setTooltipAnchor(event.currentTarget);
|
||||
};
|
||||
const hideTooltip = () => {
|
||||
setHovered(false);
|
||||
setTooltipAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||
>
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/sessions"}
|
||||
onClick={closeMobile}
|
||||
aria-label={collapsed ? navLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
onFocus={collapsed ? showTooltip : undefined}
|
||||
onBlur={collapsed ? hideTooltip : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group/nav relative flex items-center gap-3",
|
||||
|
|
@ -877,8 +886,8 @@ function SidebarNavLink({
|
|||
)}
|
||||
</NavLink>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
|
||||
{collapsed && hovered && tooltipAnchor && (
|
||||
<SidebarTooltip anchor={tooltipAnchor} label={navLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
|
@ -894,6 +903,7 @@ function SidebarSystemActions({
|
|||
const navigate = useNavigate();
|
||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||
useSystemActions();
|
||||
const canUpdateHermes = status?.can_update_hermes === true;
|
||||
|
||||
const items: SystemActionItem[] = [
|
||||
{
|
||||
|
|
@ -903,14 +913,16 @@ function SidebarSystemActions({
|
|||
runningLabel: t.status.restartingGateway,
|
||||
spin: true,
|
||||
},
|
||||
{
|
||||
];
|
||||
if (canUpdateHermes) {
|
||||
items.push({
|
||||
action: "update",
|
||||
icon: Download,
|
||||
label: t.status.updateHermes,
|
||||
runningLabel: t.status.updatingHermes,
|
||||
spin: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const handleClick = (action: SystemAction) => {
|
||||
if (isBusy) return;
|
||||
|
|
@ -971,24 +983,31 @@ function SystemActionButton({
|
|||
tooltipWarmRef,
|
||||
}: SystemActionButtonProps) {
|
||||
const { icon: Icon, label, runningLabel, spin } = item;
|
||||
const liRef = useRef<HTMLLIElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
|
||||
const busy = isPending || isActionRunning;
|
||||
const displayLabel = isActionRunning ? runningLabel : label;
|
||||
const showTooltip = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
||||
setHovered(true);
|
||||
setTooltipAnchor(event.currentTarget);
|
||||
};
|
||||
const hideTooltip = () => {
|
||||
setHovered(false);
|
||||
setTooltipAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={liRef}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-busy={busy}
|
||||
aria-label={collapsed ? displayLabel : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
onFocus={collapsed ? showTooltip : undefined}
|
||||
onBlur={collapsed ? hideTooltip : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/action relative flex w-full items-center gap-3",
|
||||
|
|
@ -1036,8 +1055,8 @@ function SystemActionButton({
|
|||
)}
|
||||
</button>
|
||||
|
||||
{collapsed && hovered && liRef.current && (
|
||||
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
|
||||
{collapsed && hovered && tooltipAnchor && (
|
||||
<SidebarTooltip anchor={tooltipAnchor} label={displayLabel} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
|
@ -1049,18 +1068,25 @@ function SidebarIconWithTooltip({
|
|||
label,
|
||||
tooltipWarmRef,
|
||||
}: SidebarIconWithTooltipProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
|
||||
const showTooltip = (event: MouseEvent<HTMLDivElement>) => {
|
||||
setHovered(true);
|
||||
setTooltipAnchor(event.currentTarget);
|
||||
};
|
||||
const hideTooltip = () => {
|
||||
setHovered(false);
|
||||
setTooltipAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative w-fit",
|
||||
collapsed && "group/icon",
|
||||
)}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||
>
|
||||
{children}
|
||||
|
||||
|
|
@ -1071,8 +1097,8 @@ function SidebarIconWithTooltip({
|
|||
/>
|
||||
)}
|
||||
|
||||
{collapsed && hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
{collapsed && hovered && tooltipAnchor && (
|
||||
<SidebarTooltip anchor={tooltipAnchor} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1080,8 +1106,8 @@ function SidebarIconWithTooltip({
|
|||
|
||||
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
||||
const { t } = useI18n();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
|
||||
|
||||
const toneToColor: Record<string, string> = {
|
||||
"text-success": "bg-success",
|
||||
|
|
@ -1101,10 +1127,17 @@ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
|||
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
|
||||
label = `${t.status.gateway} ${gw.label}`;
|
||||
}
|
||||
const showTooltip = (event: MouseEvent<HTMLDivElement> | FocusEvent<HTMLDivElement>) => {
|
||||
setHovered(true);
|
||||
setTooltipAnchor(event.currentTarget);
|
||||
};
|
||||
const hideTooltip = () => {
|
||||
setHovered(false);
|
||||
setTooltipAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
|
||||
collapsed ? "lg:opacity-100" : "lg:opacity-0 lg:h-0 lg:py-0 lg:overflow-hidden",
|
||||
|
|
@ -1112,18 +1145,18 @@ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
|||
role="status"
|
||||
aria-label={label}
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
||||
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||
onFocus={collapsed ? showTooltip : undefined}
|
||||
onBlur={collapsed ? hideTooltip : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("h-1.5 w-1.5 rounded-full", color)}
|
||||
/>
|
||||
|
||||
{hovered && ref.current && (
|
||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
||||
{hovered && tooltipAnchor && (
|
||||
<SidebarTooltip anchor={tooltipAnchor} label={label} warmRef={tooltipWarmRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1133,11 +1166,16 @@ function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
|
|||
const rect = anchor.getBoundingClientRect();
|
||||
const sidebar = document.getElementById("app-sidebar");
|
||||
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
|
||||
|
||||
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
|
||||
const [isWarm, setIsWarm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
if (!warmRef) {
|
||||
setIsWarm(false);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
setIsWarm(now - warmRef.current < 300);
|
||||
warmRef.current = now;
|
||||
return () => {
|
||||
if (warmRef) warmRef.current = Date.now();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,19 +74,17 @@ export function SystemActionsProvider({
|
|||
setActiveAction(action);
|
||||
} else {
|
||||
const resp = await api.updateHermes();
|
||||
// In a Docker install the image is immutable, so `hermes update`
|
||||
// can't apply — the endpoint returns 200 with a structured
|
||||
// {ok:false, error:"docker_update_unsupported", message, update_command}
|
||||
// envelope instead of spawning the action (see #34347 / #36263).
|
||||
// Surface that guidance to the user rather than starting the poll,
|
||||
// which would otherwise report a generic "failed (exit 1)".
|
||||
if (!resp.ok && resp.error === "docker_update_unsupported") {
|
||||
// Some installs cannot apply updates from inside the dashboard. The
|
||||
// endpoint returns a structured {ok:false, message, update_command}
|
||||
// envelope instead of spawning the action; surface that guidance
|
||||
// rather than polling a synthetic failed action.
|
||||
if (!resp.ok) {
|
||||
const cmd = resp.update_command ? ` ${resp.update_command}` : "";
|
||||
setToast({
|
||||
type: "success",
|
||||
message:
|
||||
(resp.message ??
|
||||
"Updates don't apply inside Docker — re-pull the image instead.") +
|
||||
"Updates don't apply from this dashboard.") +
|
||||
cmd,
|
||||
});
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1570,6 +1570,9 @@ export interface StatusResponse {
|
|||
* Empty in loopback mode; empty + ``auth_required=true`` is a
|
||||
* fail-closed state (the dashboard will refuse to bind). */
|
||||
auth_providers?: string[];
|
||||
/** False when the dashboard is running in a hosted/managed layout where
|
||||
* updates are handled by the outer launcher instead of ``hermes update``. */
|
||||
can_update_hermes?: boolean;
|
||||
config_path: string;
|
||||
config_version: number;
|
||||
env_path: string;
|
||||
|
|
|
|||
|
|
@ -386,6 +386,7 @@ export default function SystemPage() {
|
|||
// ── Update check / apply ───────────────────────────────────────────
|
||||
const checkForUpdate = useCallback(
|
||||
async (force = false) => {
|
||||
if (status?.can_update_hermes === false) return;
|
||||
setCheckingUpdate(true);
|
||||
try {
|
||||
const info = await api.checkHermesUpdate(force);
|
||||
|
|
@ -410,20 +411,27 @@ export default function SystemPage() {
|
|||
setCheckingUpdate(false);
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
[showToast, status?.can_update_hermes],
|
||||
);
|
||||
|
||||
// Auto-check (cached) runs inside loadAll on mount; this is the
|
||||
// user-triggered forced re-check from the "Check for updates" button.
|
||||
const applyUpdate = async () => {
|
||||
setUpdateConfirmOpen(false);
|
||||
if (status?.can_update_hermes === false) {
|
||||
showToast(
|
||||
"Hermes updates are managed by the hosted agent service.",
|
||||
"success",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await api.updateHermes();
|
||||
if (!resp.ok && resp.error === "docker_update_unsupported") {
|
||||
if (!resp.ok) {
|
||||
showToast(
|
||||
resp.message ??
|
||||
"Updates don't apply inside Docker — re-pull the image instead.",
|
||||
"error",
|
||||
"Updates don't apply from this dashboard.",
|
||||
"success",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -503,6 +511,7 @@ export default function SystemPage() {
|
|||
}
|
||||
|
||||
const gatewayRunning = status?.gateway_running;
|
||||
const canUpdateHermes = status?.can_update_hermes !== false;
|
||||
const validEvents = hooks?.valid_events?.length
|
||||
? hooks.valid_events
|
||||
: HOOK_EVENTS_FALLBACK;
|
||||
|
|
@ -512,7 +521,7 @@ export default function SystemPage() {
|
|||
<Toast toast={toast} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={updateConfirmOpen}
|
||||
open={canUpdateHermes && updateConfirmOpen}
|
||||
onCancel={() => setUpdateConfirmOpen(false)}
|
||||
onConfirm={() => void applyUpdate()}
|
||||
title="Update Hermes?"
|
||||
|
|
@ -691,7 +700,8 @@ export default function SystemPage() {
|
|||
<div className="text-xs uppercase tracking-wider text-muted-foreground">Hermes</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>v{stats?.hermes_version}</span>
|
||||
{updateInfo &&
|
||||
{canUpdateHermes &&
|
||||
updateInfo &&
|
||||
(updateInfo.update_available ? (
|
||||
<Badge tone="warning">
|
||||
{updateInfo.behind && updateInfo.behind > 0
|
||||
|
|
@ -751,45 +761,47 @@ export default function SystemPage() {
|
|||
CPU / memory / disk metrics.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
ghost
|
||||
disabled={checkingUpdate}
|
||||
prefix={
|
||||
checkingUpdate ? (
|
||||
<Spinner className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
onClick={() => void checkForUpdate(true)}
|
||||
>
|
||||
Check for updates
|
||||
</Button>
|
||||
{updateInfo?.update_available && updateInfo.can_apply && (
|
||||
{canUpdateHermes && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
prefix={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => setUpdateConfirmOpen(true)}
|
||||
ghost
|
||||
disabled={checkingUpdate}
|
||||
prefix={
|
||||
checkingUpdate ? (
|
||||
<Spinner className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
onClick={() => void checkForUpdate(true)}
|
||||
>
|
||||
Update now
|
||||
Check for updates
|
||||
</Button>
|
||||
)}
|
||||
{updateInfo &&
|
||||
!updateInfo.can_apply &&
|
||||
updateInfo.update_available && (
|
||||
{updateInfo?.update_available && updateInfo.can_apply && (
|
||||
<Button
|
||||
size="sm"
|
||||
prefix={<Download className="h-3.5 w-3.5" />}
|
||||
onClick={() => setUpdateConfirmOpen(true)}
|
||||
>
|
||||
Update now
|
||||
</Button>
|
||||
)}
|
||||
{updateInfo &&
|
||||
!updateInfo.can_apply &&
|
||||
updateInfo.update_available && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Update with{" "}
|
||||
<span className="font-mono">{updateInfo.update_command}</span>
|
||||
</span>
|
||||
)}
|
||||
{updateInfo?.message && !updateInfo.update_available && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Update with{" "}
|
||||
<span className="font-mono">{updateInfo.update_command}</span>
|
||||
{updateInfo.message}
|
||||
</span>
|
||||
)}
|
||||
{updateInfo?.message && !updateInfo.update_available && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{updateInfo.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue