put the monitor scope in the url

/system-monitor/:scope, the same shape as /photos: route pair, one Navigate guard after the
hooks, scope list as react-router NavLinks, and both panels reading useParams instead of
agreeing over a `monitor:scope` channel. The Dock's isActive is a startsWith, so its
highlight survives the redirect off the bare route.
This commit is contained in:
2026-08-07 11:41:16 +00:00
parent 2502c33804
commit 00332e275a
8 changed files with 66 additions and 41 deletions
+1
View File
@@ -77,6 +77,7 @@ export function App() {
<Route path="/wallet" element={<Dashboard.WalletScreen />} />
<Route path="/wallet/:section" element={<Dashboard.WalletScreen />} />
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
@@ -1,14 +1,20 @@
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { WorkspaceView, DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /system-monitor uses the Workspace/Panel system (like /music): a horizontal split with an (empty for
// now) left panel and the system snapshot on the right.
// /system-monitor uses the Workspace/Panel system (like /photos): the scope list on the left, the scope
// itself on the right. Which scope is open is `:scope` in the URL, not a panel channel.
export const SystemMonitorScreen = () => {
const { scope } = useParams();
const workspace = useDashboardState<LayoutNode>('screens/system-monitor', defaultLayout);
// After the hooks — it returns early. Bare /system-monitor and a scope that doesn't exist both
// canonicalise, so the list's router-driven highlight can never disagree with the pane.
if (!isMonitorScope(scope)) return <Navigate to={monitorScopePath(DEFAULT_MONITOR_SCOPE)} replace />;
return (
<div className="h-full w-full pt-2">
<WorkspaceView
@@ -3,9 +3,10 @@ import { BtopView } from './BtopView';
import { Pm2View } from './Pm2View';
import { DockerView } from './DockerView';
// Right panel — renders whichever scope the left ScopeList selected (default: bTop).
// Right panel — renders whichever scope the URL names. It does not hear from the ScopeList; both read
// `/system-monitor/:scope`.
export const MonitorMain = ({ panelId: _panelId }: { panelId: string }) => {
const [scope] = useMonitorScope();
const scope = useMonitorScope();
if (scope === 'pm2') return <Pm2View />;
if (scope === 'docker') return <DockerView />;
return <BtopView />;
@@ -1,29 +1,29 @@
import { NavLink } from 'react-router';
import { Cpu, Server, Boxes } from 'lucide-react';
import { useMonitorScope, SCOPES, type MonitorScope } from './shared';
import { SCOPES, monitorScopePath, type MonitorScope } from './shared';
// Left panel — a single-column list of scopes; selecting one drives the right panel via the channel.
// Left panel — a single-column list of scopes. Each is a react-router NavLink to /system-monitor/<scope>,
// so active state comes from the router and a scope can be linked, bookmarked and cmd-clicked.
const ICONS: Record<MonitorScope, typeof Cpu> = { btop: Cpu, pm2: Server, docker: Boxes };
export const ScopeList = ({ panelId: _panelId }: { panelId: string }) => {
const [scope, setScope] = useMonitorScope();
return (
<div className="flex h-full flex-col overflow-y-auto p-3">
{SCOPES.map((s) => {
const Icon = ICONS[s.id];
return (
<button
key={s.id}
type="button"
onClick={() => setScope(s.id)}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${
scope === s.id ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<Icon size={15} className="shrink-0" />
<span className="truncate">{s.label}</span>
</button>
);
})}
</div>
);
};
export const ScopeList = ({ panelId: _panelId }: { panelId: string }) => (
<div className="flex h-full flex-col overflow-y-auto p-3">
{SCOPES.map((s) => {
const Icon = ICONS[s.id];
return (
<NavLink
key={s.id}
to={monitorScopePath(s.id)}
className={({ isActive }) =>
`flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${
isActive ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`
}
>
<Icon size={15} className="shrink-0" />
<span className="truncate">{s.label}</span>
</NavLink>
);
})}
</div>
);
@@ -1,18 +1,30 @@
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useParams } from 'react-router';
// The left panel picks a scope; the right panel renders it. Coordinated over a panel channel, like the
// /music browser → detail split.
// The left panel picks a scope and the right panel renders it, and neither tells the other: the scope is
// `/system-monitor/:scope`. It used to be a `monitor:scope` panel channel, which meant the URL said
// `/system-monitor` however deep you were — nothing to link, nothing to bookmark, and the list came back
// on whatever you last clicked in some other tab.
export type MonitorScope = 'btop' | 'pm2' | 'docker';
export const MONITOR_SCOPE_CHANNEL = 'monitor:scope';
export const SCOPES: { id: MonitorScope; label: string }[] = [
{ id: 'btop', label: 'bTop' },
{ id: 'pm2', label: 'pm2 processes' },
{ id: 'docker', label: 'dockers' },
];
export const useMonitorScope = () => usePanelChannel<MonitorScope>(MONITOR_SCOPE_CHANNEL, 'btop');
/** Where /system-monitor lands, and where an unrecognised scope redirects to. */
export const DEFAULT_MONITOR_SCOPE: MonitorScope = 'btop';
export const isMonitorScope = (value: string | undefined): value is MonitorScope => SCOPES.some((s) => s.id === value);
export const monitorScopePath = (scope: MonitorScope) => `/system-monitor/${scope}`;
// SystemMonitorScreen redirects anything unrecognised, so the fallback here only covers the instant before
// that lands.
export const useMonitorScope = (): MonitorScope => {
const { scope } = useParams();
return isMonitorScope(scope) ? scope : DEFAULT_MONITOR_SCOPE;
};
export function fmtBytes(n: number): string {
if (!n) return '0 B';
+4
View File
@@ -43,6 +43,10 @@ export type { PhotosSectionId } from './apps/Photos/shared';
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
// Same for /system-monitor, where the param is the scope rather than a section.
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
export type { MonitorScope } from './apps/SystemMonitor/shared';
// Same for /transmission.
export {
DEFAULT_TRANSMISSION_SECTION,