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 +}