admin: support history/navigation for tabs in Logs and Plugins pages
Massimo Melina committed
Apr 27, 2026 at 17:17 UTC
6cbbff7f4cf72e4d7d83cab050776a83487def64
5 files changed
+61
-30
admin/src/App.ts
+10
-5
@@ -2,7 +2,7 @@
2
3
import { createElement as h, Fragment, ReactNode, useCallback, useEffect, useState } from 'react'
4
import { HashRouter, Route, Routes, useLocation, useNavigate } from './router'
5
-import MainMenu, { getMenuLabel, mainMenu } from './MainMenu'
5
+import MainMenu, { getMenuLabel, mainMenu, matchesMenuPath } from './MainMenu'
6
import { AppBar, Box, BoxProps, Drawer, IconButton, ThemeProvider, Toolbar, Typography } from '@mui/material'
7
import { anyDialogOpen, Dialogs } from './dialog'
8
import { useMyTheme } from './theme'
@@ -63,7 +63,7 @@ let titleSideSet: any
63
64
function Routed() {
65
const loc = useLocation().pathname.slice(1)
66
- const current = mainMenu.find(x => x.path === loc)
66
+ const current = mainMenu.find(x => matchesMenuPath(x, loc))
67
let { title } = useSnapState()
68
title = current && (current.title || getMenuLabel(current)) || title
69
const [open, setOpen] = useState(false)
@@ -128,9 +128,14 @@ function Routed() {
128
h(Flex, { ...titleSideFullWidth as any && { width: '100%' } }, titleSide),
129
),
130
h(Routes, {},
131
- mainMenu.map((it,idx) =>
132
- // @ts-ignore
133
- h(Route, { key: idx, path: it.path, element: h(it.comp, { setTitleSide: set }) })),
131
+ mainMenu.flatMap((it,idx) => [
132
+ // @ts-ignore
133
+ h(Route, { key: idx, path: it.path, element: h(it.comp, { setTitleSide: set }) }),
134
+ it.subRoutes &&
135
+ // tab pages encode their selected tab after the parent menu path
136
+ // @ts-ignore
137
+ h(Route, { key: it.path + '/:tab', path: it.path + '/:tab', element: h(it.comp, { setTitleSide: set }) })
138
+ ]),
139
h(Route, { path: 'config', element: h(ConfigFilePage) })
140
)
141
),
admin/src/LogsPage.ts
+4
-2
@@ -23,6 +23,7 @@ import { BoolField, SelectField } from '@hfs/mui-grid-form'
23
import { toast, useDialogBarColors } from './dialog'
24
import { BlockIpBtn } from './blockIp'
25
import { ALL as COUNTRIES } from './countries'
26
+import { useRoutedTab } from './router'
27
28
const logLabels = {
29
log: "Served",
@@ -31,12 +32,13 @@ const logLabels = {
32
disconnections: "Disconnections",
33
ips: "IPs",
34
}
35
+const LOG_FILES = typedKeys(logLabels)
36
37
let reloadIps: any
38
39
export default function LogsPage({ setTitleSide }: PageProps) {
38
- const [tab, setTab] = useState(0)
39
- const files = typedKeys(logLabels)
40
+ const files = LOG_FILES
41
+ const [tab, setTab] = useRoutedTab('logs', files)
42
const shorterLabels = !useBreakpoint('sm') && { error_log: "Not", console: h(Terminal), disconnections: h(LinkOff) }
43
const file = files[tab]
44
const fileAvailable = file.endsWith('log')
admin/src/MainMenu.ts
+9
-5
@@ -25,13 +25,14 @@ import { hTooltip } from './mui'
25
import { PageProps } from './App'
26
import { confirmDialog } from './dialog'
27
28
-interface MenuEntry {
28
+export interface MenuEntry {
29
path: string
30
icon: SvgIconComponent
31
label?: string
32
title?: string
33
comp: FC<PageProps>
34
noPaddingOnMobile?: true
35
+ subRoutes?: true
36
}
37
38
export const mainMenu: MenuEntry[] = [
@@ -41,9 +42,9 @@ export const mainMenu: MenuEntry[] = [
42
{ path: 'options', icon: Settings, comp: OptionsPage },
43
{ path: 'internet', icon: Public, comp: InternetPage },
44
{ path: 'monitoring', icon: Monitor, comp: MonitorPage, noPaddingOnMobile: true },
44
- { path: 'logs', icon: History, comp: LogsPage, noPaddingOnMobile: true },
45
+ { path: 'logs', icon: History, comp: LogsPage, noPaddingOnMobile: true, subRoutes: true },
46
{ path: 'language', icon: Translate, comp: LangPage },
46
- { path: 'plugins', icon: Extension, comp: PluginsPage, noPaddingOnMobile: true },
47
+ { path: 'plugins', icon: Extension, comp: PluginsPage, noPaddingOnMobile: true, subRoutes: true },
48
{ path: 'html', icon: Code, label: "Custom HTML", comp: CustomHtmlPage },
49
{ path: 'logout', icon: Logout, comp: LogoutPage }
50
]
@@ -80,8 +81,7 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item
81
component: Link,
82
to: it.path,
83
onClick: onSelect,
83
- // home has to match exactly because every path starts with an empty prefix
84
- selected: it.path ? currentPath === it.path || currentPath.startsWith(it.path + '/') : !currentPath,
84
+ selected: matchesMenuPath(it, currentPath),
85
sx: { '&.Mui-selected': { '&,&:hover': { bgcolor: 'primary.dark', textDecoration: 'underline' } } },
86
children: undefined, // shut up ts
87
},
@@ -98,3 +98,7 @@ export default function Menu({ onSelect, itemTitle }: { onSelect: ()=>void, item
98
export function getMenuLabel(it: MenuEntry) {
99
return it && (it.label ?? _.capitalize(it.path))
100
}
101
+
102
+export function matchesMenuPath(it: MenuEntry, path: string) {
103
+ return it.path ? path === it.path || path.startsWith(it.path + '/') : !path
104
+}
admin/src/PluginsPage.ts
+15
-15
@@ -1,30 +1,30 @@
1
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3
-import { createElement as h, Fragment, useState } from "react"
3
+import { createElement as h, Fragment } from 'react'
4
import { Tab, Tabs } from '@mui/material'
5
-import InstalledPlugins from "./InstalledPlugins"
6
-import OnlinePlugins from "./OnlinePlugins"
7
-import { useBreakpoint } from "./mui"
5
+import InstalledPlugins from './InstalledPlugins'
6
+import OnlinePlugins from './OnlinePlugins'
7
+import { useRoutedTab } from './router'
8
9
-const TABS = {
10
- "Installed": InstalledPlugins,
11
- "Get more": OnlinePlugins,
12
- "Check updates": () => h(InstalledPlugins, { updates: true }),
13
-}
14
-const LABELS = Object.keys(TABS)
15
-const PANES = Object.values(TABS)
9
+const TABS = [
10
+ { label: "Installed", path: 'installed', Pane: InstalledPlugins },
11
+ { label: "Get more", path: 'get', Pane: OnlinePlugins },
12
+ { label: "Check updates", path: 'updates', Pane: () => h(InstalledPlugins, { updates: true }) },
13
+]
14
+const TAB_PATHS = TABS.map(x => x.path)
15
export const PLUGIN_ERRORS = { ENOTFOUND: "Cannot reach github.com", ECONNREFUSED: "Cannot reach github.com" }
16
17
export default function PluginsPage() {
19
- const [tab, setTab] = useState(0)
18
+ const [tab, setTab] = useRoutedTab('plugins', TAB_PATHS)
19
+ const { Pane } = TABS[tab]
20
return h(Fragment, {},
21
h(Tabs, {
22
value: tab,
23
onChange(ev, i) {
24
setTab(i)
25
}
26
- }, LABELS.map(x =>
27
- h(Tab, { key: x, label: x }))),
28
- h(PANES[tab])
26
+ }, TABS.map(x =>
27
+ h(Tab, { key: x.path, label: x.label }))),
28
+ h(Pane)
29
)
30
}
admin/src/router.ts
+23
-3
@@ -1,4 +1,4 @@
1
-import { Children, cloneElement, createElement as h, forwardRef, Fragment, isValidElement } from 'react'
1
+import { Children, cloneElement, createElement as h, forwardRef, isValidElement, useEffect } from 'react'
2
import type { AnchorHTMLAttributes, ComponentType, ReactElement, ReactNode } from 'react'
3
import { Link as WouterLink, Route as WouterRoute, Router, Switch, useLocation as useWouterLocation } from 'wouter'
4
import { useHashLocation } from 'wouter/use-hash-location'
@@ -59,8 +59,6 @@ function normalizePath(path: string | undefined) {
59
return path.startsWith('/') ? path : `/${path}`
60
}
61
62
-export const BrowserRouter = Fragment
63
-
62
function normalizeChildRoutePath(child: ReactNode) {
63
if (!isValidElement(child))
64
return child
@@ -70,3 +68,25 @@ function normalizeChildRoutePath(child: ReactNode) {
68
return child
69
return cloneElement(child, { path: '/' })
70
}
71
+
72
+export function useRoutedTab(basePath: string, tabPaths: readonly string[]) {
73
+ const { pathname } = useLocation()
74
+ const navigate = useNavigate()
75
+ const prefix = `/${basePath}/`
76
+ const pathTab = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : ''
77
+ const pathTabIndex = tabPaths.indexOf(pathTab)
78
+ const tab = pathTabIndex < 0 ? 0 : pathTabIndex
79
+
80
+ useEffect(() => {
81
+ const wanted = `/${basePath}/${tabPaths[tab]}`
82
+ // replace bare/unknown tab URLs so refresh and history stay aligned with the visible tab
83
+ if (pathname !== wanted)
84
+ navigate(wanted, { replace: true })
85
+ }, [basePath, navigate, pathname, tab, tabPaths])
86
+
87
+ return [tab, setTab] as const
88
+
89
+ function setTab(i: number) {
90
+ navigate(`/${basePath}/${tabPaths[i]}`)
91
+ }
92
+}