web: say what the guard actually applies

The Blocker page showed all_domains even when nothing was in force, and its
copy still called a bare '*' an invalid expansion. Hostnames now comes from
the live list (empty while blocking is off), the routed DNS zones get their
own stat, and the banners distinguish junk labels from masks no hosts file
can express — which is what the server now reports.
This commit is contained in:
timka123 committed 2026-10-19 18:45:42 +03:00
1 parent a75a011edb
commit a531a62afc
2 files changed
+59 -12

No files matched your search

+5
View File
@@ -79,7 +79,12 @@ export interface RefreshResult {
export interface BlockerPolicy { export interface BlockerPolicy {
active: boolean; active: boolean;
/** Masks a hosts file cannot express: `*.x.com` (answered by the desktop app's routed DNS zone) and `*gram*` (no zone exists). */
unexpandable?: string[]; unexpandable?: string[];
/** Expansion entries that are not subdomain labels (`not a label`) — dropped. A bare `*` is kept: it means every subdomain. */
invalid_expansions?: string[];
/** The `allowed` masks, normalized — what the desktop app's resolver serves as an exception. */
allowed?: string[];
reason: string; reason: string;
mode: string; mode: string;
enabled: boolean; enabled: boolean;
+54 -12
View File
@@ -2,8 +2,10 @@
* The focus guard: what is blocked, when, and what it caught. * The focus guard: what is blocked, when, and what it caught.
* *
* Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is * Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is
* expanded server-side into real hostnames — this page shows that expansion, and * expanded server-side into real hostnames, and the patterns that leaves over
* says plainly when a pattern (`*gram*`) cannot be expressed that way. * are answered by the desktop app's local DNS stub for the zones it can route.
* The stats show what is really in force (`domains` is empty while blocking is
* off) and every banner says plainly what could not be enforced.
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
@@ -28,13 +30,24 @@ import type { BlockEvents, BlockerPolicy } from "../lib/types";
const MASK_HELP = [ const MASK_HELP = [
["youtube.com", "the site itself, plus www. and m."], ["youtube.com", "the site itself, plus www. and m."],
["*.google.com", "every subdomain in the expansion list below"], ["*.google.com", "the bare domain plus every subdomain in the expansion list"],
["*gram*", "matches apps; a hosts file cannot express it"], ["*gram*", "matches apps, and URLs on the phone; no DNS zone can be routed for it"],
["* in the list below", "makes *.site cover every subdomain — answered by the desktop app's DNS stub"],
]; ];
/**
* The DNS zone a mask can be routed through: what follows its last `*`, and at
* least two labels deep — `*gram*` has nothing after its last `*`, and `*.site`
* has only one label, so neither can be handed to a resolver.
*/
const zoneOf = (mask: string): string | null => {
const zone = mask.slice(mask.lastIndexOf("*") + 1).replace(/^\.+/, "");
return zone.split(".").filter(Boolean).length >= 2 ? zone : null;
};
export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) { export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) {
const { settings, defaults, save } = useSettings(); const { settings, defaults, save } = useSettings();
const policy = useResource<BlockerPolicy & { unexpandable?: string[] }>("/api/blocker/policy", 5000); const policy = useResource<BlockerPolicy>("/api/blocker/policy", 5000);
const events = useResource<BlockEvents>("/api/blocker/events", 20000); const events = useResource<BlockEvents>("/api/blocker/events", 20000);
const [domains, setDomains] = useState(""); const [domains, setDomains] = useState("");
@@ -78,6 +91,12 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
const blocker = settings?.blocker; const blocker = settings?.blocker;
const unexpandable = policy.data?.unexpandable ?? []; const unexpandable = policy.data?.unexpandable ?? [];
const unroutable = useMemo(() => unexpandable.filter((mask) => zoneOf(mask) === null), [unexpandable]);
const zones = useMemo(
() => [...new Set(unexpandable.map(zoneOf).filter((zone): zone is string => zone !== null))],
[unexpandable],
);
const badExpansions = policy.data?.invalid_expansions ?? [];
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]); const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
return ( return (
@@ -100,8 +119,22 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
/> />
<StatRow> <StatRow>
<Stat label="Hostnames" value={policy.data?.all_domains.length ?? 0} caption="after expansion" /> <Stat
<Stat label="Apps" value={policy.data?.all_apps.length ?? 0} caption="closed on sight" /> label="Hostnames"
value={policy.data?.domains.length ?? 0}
caption={policy.data?.active ? "in force" : "not written (blocking off)"}
/>
<Stat
label="Zones"
value={zones.length}
caption={policy.data?.active ? "the desktop app routes these" : "unrouted while blocking is off"}
size="sm"
/>
<Stat
label="Apps"
value={policy.data?.all_apps.length ?? 0}
caption={blocker?.kill_blocked_apps ?? true ? "closed on sight" : "warned only"}
/>
<Stat label="Attempts today" value={events.data?.total ?? 0} caption="turned away" /> <Stat label="Attempts today" value={events.data?.total ?? 0} caption="turned away" />
<Stat <Stat
label="Mode" label="Mode"
@@ -163,11 +196,19 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
</Stack> </Stack>
</Panel> </Panel>
{unexpandable.length > 0 && ( {badExpansions.length > 0 && (
<Alert severity="warning" variant="outlined" sx={{ mb: 3 }}>
{badExpansions.join(", ")} {badExpansions.length === 1 ? "is not" : "are not"} a subdomain label
{badExpansions.length === 1 ? "" : "s"}, so {badExpansions.length === 1 ? "it was" : "they were"} ignored.
Each entry is one label — web, m, mobile. A bare <code>*</code> is kept and means every subdomain.
</Alert>
)}
{policy.data?.active && unroutable.length > 0 && (
<Alert severity="info" variant="outlined" sx={{ mb: 3 }}> <Alert severity="info" variant="outlined" sx={{ mb: 3 }}>
{unexpandable.join(", ")} {unexpandable.length === 1 ? "is a pattern" : "are patterns"} a hosts file {unroutable.join(", ")} cannot be routed to a DNS zone — nothing usable follows the last{" "}
cannot express — {unexpandable.length === 1 ? "it is" : "they are"} still matched against application <code>*</code>. A hosts file cannot express {unroutable.length === 1 ? "it" : "them"} either;
names. your phone still matches {unroutable.length === 1 ? "it" : "them"} against the URLs you open.
</Alert> </Alert>
)} )}
@@ -249,7 +290,8 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
setSubdomains(event.target.value); setSubdomains(event.target.value);
setDirty(true); setDirty(true);
}} }}
helperText="Comma separated. Add the subdomains you actually visit." error={badExpansions.length > 0}
helperText="Comma separated, one label each: web, m, mobile. A bare * means every subdomain."
/> />
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
<Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}> <Button variant="contained" disabled={!dirty} onClick={() => void saveLists()}>