Files
technitium-console/src/screens/dashboard/BlockingMenu.tsx
T
DenozordecandClaude Opus 5.5 34de910e04 Hub stage 6: blocking and cache actions on several servers
One endpoint, hub/servers/run (operate), runs a fixed set of actions on
the chosen servers in parallel with each server's admin token: add or
remove a domain in Blocked or Allowed, disable blocking for a time or
enable it, update block lists, replace the block list URLs, flush the
cache or delete a domain from it. Each server gets its own result and
the console offers Retry failed.

In "All servers", Blocking, Cache and Query Logs rows open this flow
instead of being disabled. The hub validates domains, minutes and the
server's list grammar (allow lists with !, switched-off lines with #)
and logs no URL list. Operators can now use the blocking switch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:50:09 +07:00

228 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useContext, useState } from 'react'
import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings'
import { Confirm } from '../../ui/Confirm'
import { Menu } from '../../ui/Menu'
import { Button } from '../../ui/Button'
import { Tooltip } from '../../ui/Tooltip'
import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubAll } from '../../lib/hub-all'
import type { AlertType } from '../../ui/Alert'
import { noticeFromFailure } from '../../lib/notice'
/*
The Dashboard's "Blocking" menu.
Upstream puts it in the header of "Top Blocked Domains"
(`btnDashboardBlockingOptions`, index.html) and here it was missing: turning
blocking off for a while from the Dashboard is one of the things most often done
in this console (the typical case is a page not loading because of a list), and it
forced a trip to Settings › Blocking to find it.
Two behavioural details copied as they are:
- The state is asked for ON OPENING the menu, not when drawing the screen
(`main.js:2429`): between the two the setting may have changed from another
tab, and showing "Enable Blocking" with blocking already on is worse than
taking 100 ms.
- Either "Enable" or "Disable" comes out, never both. While it is not known,
neither.
The confirmation and alert texts are upstream's literals
(`main.js:2448-2496`). What changes is the way of asking: there it is a browser
`confirm()` and here the console's dialog.
*/
/** Upstream's eight durations, with their exact labels. */
const DURATIONS: { minutes: number; text: string }[] = [
{ minutes: 1, text: 'Disable Blocking For 1 Minute' },
{ minutes: 2, text: 'Disable Blocking For 2 Minutes' },
{ minutes: 5, text: 'Disable Blocking For 5 Minutes' },
{ minutes: 10, text: 'Disable Blocking For 10 Minutes' },
{ minutes: 15, text: 'Disable Blocking For 15 Minutes' },
{ minutes: 30, text: 'Disable Blocking For 30 Minutes' },
{ minutes: 60, text: 'Disable Blocking For 1 Hour' },
{ minutes: 180, text: 'Disable Blocking For 3 Hours' },
]
interface Pending {
title: string
text: string
label: string
variant: 'primary' | 'danger'
perform: () => Promise<void>
}
export function BlockingMenu({
token,
onNotice,
text = 'Blocking',
onChanged,
node = '',
allowed = true,
}: {
token: string | null
/** Behind a hub: the server the change is made on (empty or "cluster": none chosen, so it is switched off). */
node?: string
/** Behind a hub: what the role may do. */
allowed?: boolean
onNotice: (a: { type: AlertType; title: string; text: string }) => void
/** The trigger text. The Dashboard keeps upstream's "Blocking". */
text?: string
/** After a successful change, for a caller that draws the state. */
onChanged?: () => void
}) {
const [active, setActive] = useState<boolean | null>(null)
const [pending, setPending] = useState<Pending | null>(null)
const [busy, setBusy] = useState(false)
/* Behind a hub with "All servers" chosen: the choice opens the flow that makes it on the servers picked next. */
const all = useContext(HubAll)
async function loadState() {
setActive(null)
const s = await getSettings(token, ...nodeArg(node))
if (s != null) setActive(s.enableBlocking)
}
function flip(turnOn: boolean): Pending {
return {
title: turnOn ? 'Enable Blocking' : 'Disable Blocking',
text: `Are you sure you want to ${turnOn ? 'enable' : 'disable'} blocking?`,
label: turnOn ? 'Enable' : 'Disable',
variant: turnOn ? 'primary' : 'danger',
perform: async () => {
const r = await setSettings(token, { enableBlocking: String(turnOn), ...(node ? { node } : {}) })
// The same text as the other thirty-six: it was the only one that said
// a bare "Session expired." for this very condition.
if (r.kind !== 'ok') throw new Error(noticeFromFailure(r).text)
setActive(turnOn)
onNotice({
type: 'success',
title: turnOn ? 'Blocking Enabled!' : 'Blocking Disabled!',
text: `Blocking was ${turnOn ? 'enabled' : 'disabled'} successfully.`,
})
},
}
}
function forAWhile(minutes: number): Pending {
return {
title: 'Temporarily Disable Blocking',
text: `Are you sure to temporarily disable blocking for ${minutes} minute(s)?`,
label: 'Disable',
variant: 'danger',
perform: async () => {
const till = await temporaryDisableBlocking(token, String(minutes), ...nodeArg(node))
if (till == null) throw new Error('The request failed.')
setActive(false)
onNotice({
type: 'success',
title: 'Blocking Disabled!',
text: `Blocking was successfully disabled temporarily for ${minutes} minute(s).`,
})
},
}
}
async function confirm() {
if (pending == null) return
setBusy(true)
try {
await pending.perform()
onChanged?.()
setPending(null)
} catch (e) {
onNotice({ type: 'danger', title: 'Error!', text: (e as Error).message })
setPending(null)
} finally {
setBusy(false)
}
}
/* Behind a hub, only on a chosen server the hub can write to; the tooltip says why not. */
const write = useHubWrite(node, allowed)
if (all !== null) {
return (
<Menu label="Blocking options" text={text}>
{(close) => (
<>
<button
role="menuitem"
onClick={() => {
close()
all({ action: 'blocking.enable', title: 'Enable blocking on several servers', confirmLabel: 'Enable blocking' })
}}
>
Enable Blocking
</button>
{DURATIONS.map((p) => (
<button
key={p.minutes}
role="menuitem"
onClick={() => {
close()
all({
action: 'blocking.disable',
params: { minutes: String(p.minutes) },
title: `Disable blocking for ${p.minutes} minute(s) on several servers`,
confirmLabel: 'Disable blocking',
})
}}
>
{p.text}
</button>
))}
</>
)}
</Menu>
)
}
if (!write.allowed) {
return (
<Tooltip text={write.reason ?? 'Requires Settings: Modify'}>
<Button disabled>{text}</Button>
</Tooltip>
)
}
return (
<>
<Menu label="Blocking options" text={text} onOpen={() => void loadState()}>
{(close) => (
<>
{active === false && (
<button role="menuitem" onClick={() => { close(); setPending(flip(true)) }}>
Enable Blocking
</button>
)}
{active === true && (
<button role="menuitem" onClick={() => { close(); setPending(flip(false)) }}>
Disable Blocking
</button>
)}
{DURATIONS.map((p) => (
<button
key={p.minutes}
role="menuitem"
onClick={() => { close(); setPending(forAWhile(p.minutes)) }}
>
{p.text}
</button>
))}
</>
)}
</Menu>
<Confirm
open={pending != null}
title={pending?.title ?? ''}
text={pending?.text ?? ''}
label={pending?.label ?? ''}
variant={pending?.variant}
busy={busy}
onClose={() => setPending(null)}
onConfirm={() => void confirm()}
/>
</>
)
}