Hide hosted dashboard update controls
This commit is contained in:
parent
55cb4103be
commit
0b6b29a30c
@ -1224,6 +1224,17 @@ def _default_hermes_root_is_opt_data() -> bool:
|
|||||||
return root == _HOSTED_MANAGED_FILES_ROOT
|
return root == _HOSTED_MANAGED_FILES_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_hosted_agent_mode() -> bool:
|
||||||
|
"""Return true for the hosted/container dashboard layout.
|
||||||
|
|
||||||
|
Hosted agent dashboards run with the Hermes root at ``/opt/data``. This is
|
||||||
|
the same signal the Files page uses to lock browsing to the managed data
|
||||||
|
directory, and it keeps local remote-auth dashboards from being mistaken for
|
||||||
|
hosted service instances.
|
||||||
|
"""
|
||||||
|
return _default_hermes_root_is_opt_data()
|
||||||
|
|
||||||
|
|
||||||
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
|
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
|
||||||
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
||||||
if raw_forced_root:
|
if raw_forced_root:
|
||||||
@ -1654,6 +1665,7 @@ async def get_status():
|
|||||||
"release_date": __release_date__,
|
"release_date": __release_date__,
|
||||||
"config_version": current_ver,
|
"config_version": current_ver,
|
||||||
"latest_config_version": latest_ver,
|
"latest_config_version": latest_ver,
|
||||||
|
"can_update_hermes": not _dashboard_hosted_agent_mode(),
|
||||||
"gateway_running": gateway_running,
|
"gateway_running": gateway_running,
|
||||||
"gateway_state": gateway_state,
|
"gateway_state": gateway_state,
|
||||||
"gateway_platforms": gateway_platforms,
|
"gateway_platforms": gateway_platforms,
|
||||||
@ -2165,6 +2177,21 @@ async def restart_gateway():
|
|||||||
@app.post("/api/hermes/update")
|
@app.post("/api/hermes/update")
|
||||||
async def update_hermes():
|
async def update_hermes():
|
||||||
"""Kick off ``hermes update`` in the background."""
|
"""Kick off ``hermes update`` in the background."""
|
||||||
|
if _dashboard_hosted_agent_mode():
|
||||||
|
message = (
|
||||||
|
"Hermes updates are managed by the hosted agent service for this "
|
||||||
|
"dashboard. The built-in local updater is disabled here."
|
||||||
|
)
|
||||||
|
_record_completed_action("hermes-update", message, exit_code=1)
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"pid": None,
|
||||||
|
"name": "hermes-update",
|
||||||
|
"error": "hosted_update_managed",
|
||||||
|
"message": message,
|
||||||
|
"update_command": "managed by hosted agent service",
|
||||||
|
}
|
||||||
|
|
||||||
install_method = detect_install_method(PROJECT_ROOT)
|
install_method = detect_install_method(PROJECT_ROOT)
|
||||||
if install_method == "docker":
|
if install_method == "docker":
|
||||||
message = format_docker_update_message()
|
message = format_docker_update_message()
|
||||||
@ -2264,6 +2291,17 @@ async def check_hermes_update(force: bool = False):
|
|||||||
desktop's remote update overlay renders this as "what's
|
desktop's remote update overlay renders this as "what's
|
||||||
changed". Additive: existing consumers ignore it.
|
changed". Additive: existing consumers ignore it.
|
||||||
"""
|
"""
|
||||||
|
if _dashboard_hosted_agent_mode():
|
||||||
|
return {
|
||||||
|
"install_method": "hosted",
|
||||||
|
"current_version": __version__,
|
||||||
|
"behind": None,
|
||||||
|
"update_available": False,
|
||||||
|
"can_apply": False,
|
||||||
|
"update_command": "managed by hosted agent service",
|
||||||
|
"message": "Hermes updates are managed by the hosted agent service.",
|
||||||
|
}
|
||||||
|
|
||||||
install_method = detect_install_method(PROJECT_ROOT)
|
install_method = detect_install_method(PROJECT_ROOT)
|
||||||
update_command = recommended_update_command_for_method(install_method)
|
update_command = recommended_update_command_for_method(install_method)
|
||||||
|
|
||||||
|
|||||||
@ -772,6 +772,25 @@ class TestUpdateCheckEndpoint:
|
|||||||
assert body["message"]
|
assert body["message"]
|
||||||
assert body["behind"] is None
|
assert body["behind"] is None
|
||||||
|
|
||||||
|
def test_hosted_dashboard_is_not_applyable(self, monkeypatch):
|
||||||
|
import hermes_cli.web_server as ws
|
||||||
|
|
||||||
|
monkeypatch.setattr(ws, "_dashboard_hosted_agent_mode", lambda: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ws,
|
||||||
|
"detect_install_method",
|
||||||
|
lambda *a, **k: pytest.fail(
|
||||||
|
"hosted update check should not probe install method"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
body = self.client.get("/api/hermes/update/check").json()
|
||||||
|
assert body["install_method"] == "hosted"
|
||||||
|
assert body["can_apply"] is False
|
||||||
|
assert body["update_available"] is False
|
||||||
|
assert body["behind"] is None
|
||||||
|
assert "hosted agent service" in body["message"]
|
||||||
|
|
||||||
def test_check_failure_is_soft(self, monkeypatch):
|
def test_check_failure_is_soft(self, monkeypatch):
|
||||||
import hermes_cli.web_server as ws
|
import hermes_cli.web_server as ws
|
||||||
import hermes_cli.banner as banner
|
import hermes_cli.banner as banner
|
||||||
|
|||||||
@ -245,6 +245,16 @@ class TestWebServerEndpoints:
|
|||||||
assert "version" in data
|
assert "version" in data
|
||||||
assert "hermes_home" in data
|
assert "hermes_home" in data
|
||||||
assert "active_sessions" in data
|
assert "active_sessions" in data
|
||||||
|
assert data["can_update_hermes"] is True
|
||||||
|
|
||||||
|
def test_get_status_hides_update_capability_in_hosted_mode(self, monkeypatch):
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
monkeypatch.setattr(web_server, "_dashboard_hosted_agent_mode", lambda: True)
|
||||||
|
|
||||||
|
resp = self.client.get("/api/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["can_update_hermes"] is False
|
||||||
|
|
||||||
# ── GET /api/media (remote image display) ───────────────────────────
|
# ── GET /api/media (remote image display) ───────────────────────────
|
||||||
|
|
||||||
@ -912,6 +922,48 @@ class TestWebServerEndpoints:
|
|||||||
assert status_data["pid"] is None
|
assert status_data["pid"] is None
|
||||||
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])
|
assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"])
|
||||||
|
|
||||||
|
def test_update_hermes_returns_hosted_guidance_without_spawning(self, monkeypatch):
|
||||||
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
spawned = False
|
||||||
|
detected = False
|
||||||
|
|
||||||
|
def fail_spawn(*_args, **_kwargs):
|
||||||
|
nonlocal spawned
|
||||||
|
spawned = True
|
||||||
|
raise AssertionError("hosted update guard should not spawn hermes update")
|
||||||
|
|
||||||
|
def fail_detect(*_args, **_kwargs):
|
||||||
|
nonlocal detected
|
||||||
|
detected = True
|
||||||
|
raise AssertionError("hosted update guard should not detect install method")
|
||||||
|
|
||||||
|
monkeypatch.setattr(web_server, "_dashboard_hosted_agent_mode", lambda: True)
|
||||||
|
monkeypatch.setattr(web_server, "detect_install_method", fail_detect)
|
||||||
|
monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn)
|
||||||
|
web_server._ACTION_PROCS.pop("hermes-update", None)
|
||||||
|
web_server._ACTION_RESULTS.pop("hermes-update", None)
|
||||||
|
|
||||||
|
resp = self.client.post("/api/hermes/update")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert data["name"] == "hermes-update"
|
||||||
|
assert data["pid"] is None
|
||||||
|
assert data["error"] == "hosted_update_managed"
|
||||||
|
assert "hosted agent service" in data["message"]
|
||||||
|
assert spawned is False
|
||||||
|
assert detected is False
|
||||||
|
|
||||||
|
status = self.client.get("/api/actions/hermes-update/status")
|
||||||
|
assert status.status_code == 200
|
||||||
|
status_data = status.json()
|
||||||
|
assert status_data["running"] is False
|
||||||
|
assert status_data["exit_code"] == 1
|
||||||
|
assert status_data["pid"] is None
|
||||||
|
assert any("hosted agent service" in line for line in status_data["lines"])
|
||||||
|
|
||||||
def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch):
|
def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch):
|
||||||
import hermes_cli.web_server as web_server
|
import hermes_cli.web_server as web_server
|
||||||
|
|
||||||
|
|||||||
110
web/src/App.tsx
110
web/src/App.tsx
@ -5,6 +5,8 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
|
type FocusEvent,
|
||||||
|
type MouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@ -812,26 +814,33 @@ function SidebarNavLink({
|
|||||||
t,
|
t,
|
||||||
}: SidebarNavLinkProps) {
|
}: SidebarNavLinkProps) {
|
||||||
const { path, label, labelKey, icon: Icon } = item;
|
const { path, label, labelKey, icon: Icon } = item;
|
||||||
const liRef = useRef<HTMLLIElement>(null);
|
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const navLabel = labelKey
|
const navLabel = labelKey
|
||||||
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
||||||
: label;
|
: label;
|
||||||
|
const showTooltip = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
||||||
|
setHovered(true);
|
||||||
|
setTooltipAnchor(event.currentTarget);
|
||||||
|
};
|
||||||
|
const hideTooltip = () => {
|
||||||
|
setHovered(false);
|
||||||
|
setTooltipAnchor(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
ref={liRef}
|
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
|
||||||
>
|
>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={path}
|
to={path}
|
||||||
end={path === "/sessions"}
|
end={path === "/sessions"}
|
||||||
onClick={closeMobile}
|
onClick={closeMobile}
|
||||||
aria-label={collapsed ? navLabel : undefined}
|
aria-label={collapsed ? navLabel : undefined}
|
||||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
onFocus={collapsed ? showTooltip : undefined}
|
||||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
onBlur={collapsed ? hideTooltip : undefined}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
"group/nav relative flex items-center gap-3",
|
"group/nav relative flex items-center gap-3",
|
||||||
@ -877,8 +886,8 @@ function SidebarNavLink({
|
|||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{collapsed && hovered && liRef.current && (
|
{collapsed && hovered && tooltipAnchor && (
|
||||||
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
|
<SidebarTooltip anchor={tooltipAnchor} label={navLabel} warmRef={tooltipWarmRef} />
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -894,6 +903,7 @@ function SidebarSystemActions({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
|
||||||
useSystemActions();
|
useSystemActions();
|
||||||
|
const canUpdateHermes = status?.can_update_hermes === true;
|
||||||
|
|
||||||
const items: SystemActionItem[] = [
|
const items: SystemActionItem[] = [
|
||||||
{
|
{
|
||||||
@ -903,14 +913,16 @@ function SidebarSystemActions({
|
|||||||
runningLabel: t.status.restartingGateway,
|
runningLabel: t.status.restartingGateway,
|
||||||
spin: true,
|
spin: true,
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
if (canUpdateHermes) {
|
||||||
|
items.push({
|
||||||
action: "update",
|
action: "update",
|
||||||
icon: Download,
|
icon: Download,
|
||||||
label: t.status.updateHermes,
|
label: t.status.updateHermes,
|
||||||
runningLabel: t.status.updatingHermes,
|
runningLabel: t.status.updatingHermes,
|
||||||
spin: false,
|
spin: false,
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
const handleClick = (action: SystemAction) => {
|
const handleClick = (action: SystemAction) => {
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
@ -971,24 +983,31 @@ function SystemActionButton({
|
|||||||
tooltipWarmRef,
|
tooltipWarmRef,
|
||||||
}: SystemActionButtonProps) {
|
}: SystemActionButtonProps) {
|
||||||
const { icon: Icon, label, runningLabel, spin } = item;
|
const { icon: Icon, label, runningLabel, spin } = item;
|
||||||
const liRef = useRef<HTMLLIElement>(null);
|
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
|
||||||
const busy = isPending || isActionRunning;
|
const busy = isPending || isActionRunning;
|
||||||
const displayLabel = isActionRunning ? runningLabel : label;
|
const displayLabel = isActionRunning ? runningLabel : label;
|
||||||
|
const showTooltip = (event: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
||||||
|
setHovered(true);
|
||||||
|
setTooltipAnchor(event.currentTarget);
|
||||||
|
};
|
||||||
|
const hideTooltip = () => {
|
||||||
|
setHovered(false);
|
||||||
|
setTooltipAnchor(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
ref={liRef}
|
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-busy={busy}
|
aria-busy={busy}
|
||||||
aria-label={collapsed ? displayLabel : undefined}
|
aria-label={collapsed ? displayLabel : undefined}
|
||||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
onFocus={collapsed ? showTooltip : undefined}
|
||||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
onBlur={collapsed ? hideTooltip : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/action relative flex w-full items-center gap-3",
|
"group/action relative flex w-full items-center gap-3",
|
||||||
@ -1036,8 +1055,8 @@ function SystemActionButton({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{collapsed && hovered && liRef.current && (
|
{collapsed && hovered && tooltipAnchor && (
|
||||||
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
|
<SidebarTooltip anchor={tooltipAnchor} label={displayLabel} warmRef={tooltipWarmRef} />
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -1049,18 +1068,25 @@ function SidebarIconWithTooltip({
|
|||||||
label,
|
label,
|
||||||
tooltipWarmRef,
|
tooltipWarmRef,
|
||||||
}: SidebarIconWithTooltipProps) {
|
}: SidebarIconWithTooltipProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [hovered, setHovered] = useState(false);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-fit",
|
"relative w-fit",
|
||||||
collapsed && "group/icon",
|
collapsed && "group/icon",
|
||||||
)}
|
)}
|
||||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
@ -1071,8 +1097,8 @@ function SidebarIconWithTooltip({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{collapsed && hovered && ref.current && (
|
{collapsed && hovered && tooltipAnchor && (
|
||||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
<SidebarTooltip anchor={tooltipAnchor} label={label} warmRef={tooltipWarmRef} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1080,8 +1106,8 @@ function SidebarIconWithTooltip({
|
|||||||
|
|
||||||
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [tooltipAnchor, setTooltipAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const toneToColor: Record<string, string> = {
|
const toneToColor: Record<string, string> = {
|
||||||
"text-success": "bg-success",
|
"text-success": "bg-success",
|
||||||
@ -1101,10 +1127,17 @@ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
|
|||||||
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
|
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
|
||||||
label = `${t.status.gateway} ${gw.label}`;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
|
"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",
|
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"
|
role="status"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
tabIndex={collapsed ? 0 : -1}
|
tabIndex={collapsed ? 0 : -1}
|
||||||
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
|
onMouseEnter={collapsed ? showTooltip : undefined}
|
||||||
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
|
onMouseLeave={collapsed ? hideTooltip : undefined}
|
||||||
onFocus={collapsed ? () => setHovered(true) : undefined}
|
onFocus={collapsed ? showTooltip : undefined}
|
||||||
onBlur={collapsed ? () => setHovered(false) : undefined}
|
onBlur={collapsed ? hideTooltip : undefined}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn("h-1.5 w-1.5 rounded-full", color)}
|
className={cn("h-1.5 w-1.5 rounded-full", color)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hovered && ref.current && (
|
{hovered && tooltipAnchor && (
|
||||||
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
|
<SidebarTooltip anchor={tooltipAnchor} label={label} warmRef={tooltipWarmRef} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1133,11 +1166,16 @@ function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
|
|||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const sidebar = document.getElementById("app-sidebar");
|
const sidebar = document.getElementById("app-sidebar");
|
||||||
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
|
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
|
||||||
|
const [isWarm, setIsWarm] = useState(false);
|
||||||
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
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 () => {
|
return () => {
|
||||||
if (warmRef) warmRef.current = Date.now();
|
if (warmRef) warmRef.current = Date.now();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -74,19 +74,17 @@ export function SystemActionsProvider({
|
|||||||
setActiveAction(action);
|
setActiveAction(action);
|
||||||
} else {
|
} else {
|
||||||
const resp = await api.updateHermes();
|
const resp = await api.updateHermes();
|
||||||
// In a Docker install the image is immutable, so `hermes update`
|
// Some installs cannot apply updates from inside the dashboard. The
|
||||||
// can't apply — the endpoint returns 200 with a structured
|
// endpoint returns a structured {ok:false, message, update_command}
|
||||||
// {ok:false, error:"docker_update_unsupported", message, update_command}
|
// envelope instead of spawning the action; surface that guidance
|
||||||
// envelope instead of spawning the action (see #34347 / #36263).
|
// rather than polling a synthetic failed action.
|
||||||
// Surface that guidance to the user rather than starting the poll,
|
if (!resp.ok) {
|
||||||
// which would otherwise report a generic "failed (exit 1)".
|
|
||||||
if (!resp.ok && resp.error === "docker_update_unsupported") {
|
|
||||||
const cmd = resp.update_command ? ` ${resp.update_command}` : "";
|
const cmd = resp.update_command ? ` ${resp.update_command}` : "";
|
||||||
setToast({
|
setToast({
|
||||||
type: "success",
|
type: "success",
|
||||||
message:
|
message:
|
||||||
(resp.message ??
|
(resp.message ??
|
||||||
"Updates don't apply inside Docker — re-pull the image instead.") +
|
"Updates don't apply from this dashboard.") +
|
||||||
cmd,
|
cmd,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1570,6 +1570,9 @@ export interface StatusResponse {
|
|||||||
* Empty in loopback mode; empty + ``auth_required=true`` is a
|
* Empty in loopback mode; empty + ``auth_required=true`` is a
|
||||||
* fail-closed state (the dashboard will refuse to bind). */
|
* fail-closed state (the dashboard will refuse to bind). */
|
||||||
auth_providers?: string[];
|
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_path: string;
|
||||||
config_version: number;
|
config_version: number;
|
||||||
env_path: string;
|
env_path: string;
|
||||||
|
|||||||
@ -386,6 +386,7 @@ export default function SystemPage() {
|
|||||||
// ── Update check / apply ───────────────────────────────────────────
|
// ── Update check / apply ───────────────────────────────────────────
|
||||||
const checkForUpdate = useCallback(
|
const checkForUpdate = useCallback(
|
||||||
async (force = false) => {
|
async (force = false) => {
|
||||||
|
if (status?.can_update_hermes === false) return;
|
||||||
setCheckingUpdate(true);
|
setCheckingUpdate(true);
|
||||||
try {
|
try {
|
||||||
const info = await api.checkHermesUpdate(force);
|
const info = await api.checkHermesUpdate(force);
|
||||||
@ -410,20 +411,27 @@ export default function SystemPage() {
|
|||||||
setCheckingUpdate(false);
|
setCheckingUpdate(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showToast],
|
[showToast, status?.can_update_hermes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-check (cached) runs inside loadAll on mount; this is the
|
// Auto-check (cached) runs inside loadAll on mount; this is the
|
||||||
// user-triggered forced re-check from the "Check for updates" button.
|
// user-triggered forced re-check from the "Check for updates" button.
|
||||||
const applyUpdate = async () => {
|
const applyUpdate = async () => {
|
||||||
setUpdateConfirmOpen(false);
|
setUpdateConfirmOpen(false);
|
||||||
|
if (status?.can_update_hermes === false) {
|
||||||
|
showToast(
|
||||||
|
"Hermes updates are managed by the hosted agent service.",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await api.updateHermes();
|
const resp = await api.updateHermes();
|
||||||
if (!resp.ok && resp.error === "docker_update_unsupported") {
|
if (!resp.ok) {
|
||||||
showToast(
|
showToast(
|
||||||
resp.message ??
|
resp.message ??
|
||||||
"Updates don't apply inside Docker — re-pull the image instead.",
|
"Updates don't apply from this dashboard.",
|
||||||
"error",
|
"success",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -503,6 +511,7 @@ export default function SystemPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gatewayRunning = status?.gateway_running;
|
const gatewayRunning = status?.gateway_running;
|
||||||
|
const canUpdateHermes = status?.can_update_hermes !== false;
|
||||||
const validEvents = hooks?.valid_events?.length
|
const validEvents = hooks?.valid_events?.length
|
||||||
? hooks.valid_events
|
? hooks.valid_events
|
||||||
: HOOK_EVENTS_FALLBACK;
|
: HOOK_EVENTS_FALLBACK;
|
||||||
@ -512,7 +521,7 @@ export default function SystemPage() {
|
|||||||
<Toast toast={toast} />
|
<Toast toast={toast} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={updateConfirmOpen}
|
open={canUpdateHermes && updateConfirmOpen}
|
||||||
onCancel={() => setUpdateConfirmOpen(false)}
|
onCancel={() => setUpdateConfirmOpen(false)}
|
||||||
onConfirm={() => void applyUpdate()}
|
onConfirm={() => void applyUpdate()}
|
||||||
title="Update Hermes?"
|
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="text-xs uppercase tracking-wider text-muted-foreground">Hermes</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>v{stats?.hermes_version}</span>
|
<span>v{stats?.hermes_version}</span>
|
||||||
{updateInfo &&
|
{canUpdateHermes &&
|
||||||
|
updateInfo &&
|
||||||
(updateInfo.update_available ? (
|
(updateInfo.update_available ? (
|
||||||
<Badge tone="warning">
|
<Badge tone="warning">
|
||||||
{updateInfo.behind && updateInfo.behind > 0
|
{updateInfo.behind && updateInfo.behind > 0
|
||||||
@ -751,45 +761,47 @@ export default function SystemPage() {
|
|||||||
CPU / memory / disk metrics.
|
CPU / memory / disk metrics.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
{canUpdateHermes && (
|
||||||
<Button
|
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||||
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 && (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
prefix={<Download className="h-3.5 w-3.5" />}
|
ghost
|
||||||
onClick={() => setUpdateConfirmOpen(true)}
|
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>
|
</Button>
|
||||||
)}
|
{updateInfo?.update_available && updateInfo.can_apply && (
|
||||||
{updateInfo &&
|
<Button
|
||||||
!updateInfo.can_apply &&
|
size="sm"
|
||||||
updateInfo.update_available && (
|
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">
|
<span className="text-xs text-muted-foreground">
|
||||||
Update with{" "}
|
{updateInfo.message}
|
||||||
<span className="font-mono">{updateInfo.update_command}</span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{updateInfo?.message && !updateInfo.update_available && (
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
)}
|
||||||
{updateInfo.message}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user