) : null}
diff --git a/packages/dashboard/src/components/shared/Section.tsx b/packages/dashboard/src/components/shared/Section.tsx
index e316f87..af42909 100644
--- a/packages/dashboard/src/components/shared/Section.tsx
+++ b/packages/dashboard/src/components/shared/Section.tsx
@@ -2,19 +2,56 @@
import { type ReactNode, useId } from 'react';
import { formatNumber } from '@/lib/format/bytes.ts';
import { cn } from '@/lib/utils.ts';
+import { type InfoDocs, InfoDot } from './InfoDot.tsx';
/** Props. */
export interface SectionProps {
readonly title: ReactNode;
readonly description?: ReactNode;
+ /** Explainer behind an info button after the title. */
+ readonly info?: ReactNode;
+ /** "Read the docs" link under `info`. */
+ readonly infoDocs?: InfoDocs;
readonly count?: number;
readonly actions?: ReactNode;
readonly children: ReactNode;
readonly className?: string;
}
+/** Info button beside a heading; named after the heading when it is plain text. */
+function TitleInfo({
+ title,
+ info,
+ docs,
+}: {
+ readonly title: ReactNode;
+ readonly info: ReactNode;
+ readonly docs: InfoDocs | undefined;
+}) {
+ return (
+
+
+ {info}
+
+
+ );
+}
+
/** Labelled section: heading row, then content. No surface of its own. */
-export function Section({ title, description, count, actions, children, className }: SectionProps) {
+export function Section({
+ title,
+ description,
+ info,
+ infoDocs,
+ count,
+ actions,
+ children,
+ className,
+}: SectionProps) {
const id = useId();
return (
@@ -27,6 +64,7 @@ export function Section({ title, description, count, actions, children, classNam
{formatNumber(count)}
) : null}
+ {info !== undefined ? : null}
{description !== undefined ? (
{description}
@@ -43,6 +81,10 @@ export function Section({ title, description, count, actions, children, classNam
export interface PanelProps {
readonly title?: ReactNode;
readonly description?: ReactNode;
+ /** Explainer behind an info button after the title. */
+ readonly info?: ReactNode;
+ /** "Read the docs" link under `info`. */
+ readonly infoDocs?: InfoDocs;
readonly actions?: ReactNode;
readonly children: ReactNode;
/** `default` 20px padding; `none` for flush content (tables, lists with their own row padding). */
@@ -55,6 +97,8 @@ export interface PanelProps {
export function Panel({
title,
description,
+ info,
+ infoDocs,
actions,
children,
padding = 'default',
@@ -80,8 +124,11 @@ export function Panel({
>
{title !== undefined ? (
-
+
{title}
+ {info !== undefined ? (
+
+ ) : null}
) : null}
{description !== undefined ? (
diff --git a/packages/dashboard/src/components/shared/StatTile.tsx b/packages/dashboard/src/components/shared/StatTile.tsx
index 01cd488..a828380 100644
--- a/packages/dashboard/src/components/shared/StatTile.tsx
+++ b/packages/dashboard/src/components/shared/StatTile.tsx
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
import { ICONS } from '@/lib/icons.ts';
import type { Tone } from '@/lib/status-registry.ts';
import { cn } from '@/lib/utils.ts';
-import { InfoDot } from './InfoDot.tsx';
+import { type InfoDocs, InfoDot } from './InfoDot.tsx';
import { Sparkline } from './Sparkline.tsx';
import { TONE_CLASSES } from './tones.ts';
@@ -19,6 +19,8 @@ export interface StatTileProps {
readonly spark?: readonly number[];
/** Explainer behind an info button next to the label (static tiles only). */
readonly info?: ReactNode;
+ /** "Read the docs" link under `info`. */
+ readonly infoDocs?: InfoDocs;
/** Makes the whole tile a link (hover lift + arrow + pointer). */
readonly to?: string;
/** Search params for `to`. */
@@ -34,6 +36,7 @@ export function StatTile({
tone,
spark,
info,
+ infoDocs,
to,
search,
className,
@@ -50,7 +53,13 @@ export function StatTile({
className="size-4 shrink-0 text-subtle-foreground transition-[color,transform] duration-(--duration-fast) group-hover/tile:translate-x-0.5 group-hover/tile:-translate-y-0.5 group-hover/tile:text-foreground"
/>
) : info !== undefined ? (
- {info}
+
+ {info}
+
) : null}
}
+ learnMoreDocs="attention"
actions={}
/>
} isEmpty={() => false} empty={null}>
diff --git a/packages/dashboard/src/features/blocklist/BlocklistPage.test.tsx b/packages/dashboard/src/features/blocklist/BlocklistPage.test.tsx
index f88bc39..6e1cc01 100644
--- a/packages/dashboard/src/features/blocklist/BlocklistPage.test.tsx
+++ b/packages/dashboard/src/features/blocklist/BlocklistPage.test.tsx
@@ -94,10 +94,12 @@ describe('BlocklistPage', () => {
expect(view.reloads()).toBe(1);
});
- it('shows one compact not-configured state, no external links, and a disabled reload', async () => {
+ it('shows one compact not-configured state with a website docs link, and a disabled reload', async () => {
const view = mount({ configured: false });
await screen.findByText('No blocklist is configured');
- expect(screen.queryByRole('link', { name: /docs/i })).toBeNull();
+ expect(screen.getByRole('link', { name: 'Read the docs' }).getAttribute('href')).toBe(
+ 'https://browserhive.ai/docs/guide/quick-start/#blocklist-file-format',
+ );
expect(screen.queryByText('Patterns loaded')).toBeNull();
expect(screen.queryByRole('region', { name: 'Blocked attempts' })).toBeNull();
expect(screen.queryByRole('group', { name: 'Time range' })).toBeNull();
diff --git a/packages/dashboard/src/features/blocklist/BlocklistPage.tsx b/packages/dashboard/src/features/blocklist/BlocklistPage.tsx
index 2160fc8..617f443 100644
--- a/packages/dashboard/src/features/blocklist/BlocklistPage.tsx
+++ b/packages/dashboard/src/features/blocklist/BlocklistPage.tsx
@@ -1,4 +1,5 @@
/** @module features/blocklist/BlocklistPage — `/blocklist`: short header (explainer in Learn more, Reload with a tooltip reason when disabled); not configured and never used → one compact empty state with a docs link; otherwise tiles, patterns + refused hosts as capped bar lists, and the attempts filter bar + table; live via the `blocklist` topic (spec 04 §12.6) */
+
import type { BlockedRequestRow } from '@browserhive/contracts/http';
import { useRef } from 'react';
import { useTopic } from '@/app/providers/SocketProvider.tsx';
@@ -21,6 +22,7 @@ import { BarList } from '@/features/websites/components/BarList.tsx';
import { toAppError } from '@/lib/api/errors.ts';
import { formatNumber } from '@/lib/format/bytes.ts';
import { ICONS } from '@/lib/icons.ts';
+import { docsUrl } from '@/lib/links.ts';
import { useSearchState } from '@/lib/search/use-search-state.ts';
import { cn } from '@/lib/utils.ts';
import { useBlocklist, useReloadBlocklist } from './api.ts';
@@ -99,13 +101,19 @@ export function BlocklistPage() {
description="URLs agents are refused, at the tool call and in the page."
learnMore={
<>
- The navigation tools reject a blocked target outright, and in-page navigations to one
- are aborted at the network layer, so a link or a redirect cannot get around the tool
- check. A bare host such as ads.example.com blocks
- that site and everything under it; *.example.com{' '}
- covers subdomains.
+
+ The navigation tools reject a blocked target outright, and in-page navigations to one
+ are aborted at the network layer, so a link or a redirect cannot get around the tool
+ check.
+
+
+ A bare host such as ads.example.com blocks that site and everything under
+ it; *.example.com covers subdomains. It is a guardrail for agents, not an
+ egress firewall.
+
@@ -151,6 +159,7 @@ export function BlocklistPage() {
variant="panel"
icon="blocklist"
title="No blocklist is configured"
+ docsHref={docsUrl('blocklistFile')}
className="max-w-2xl"
description={
<>
@@ -263,6 +272,20 @@ export function BlocklistPage() {
+
+ Each refused navigation, with where it was caught: at the tool call (the agent
+ asked for a blocked URL) or in the page (a link, redirect or script tried to load
+ one).
+
+
+ The blocklist keeps agents away from destinations; it is not an egress firewall
+ for everything the browser loads.
+
+ >
+ }
+ infoDocs="securityBlocklist"
description={
window.since === undefined && window.until === undefined
? 'Every refused navigation'
diff --git a/packages/dashboard/src/features/logs/LogsPage.tsx b/packages/dashboard/src/features/logs/LogsPage.tsx
index 95f15aa..c8b3c01 100644
--- a/packages/dashboard/src/features/logs/LogsPage.tsx
+++ b/packages/dashboard/src/features/logs/LogsPage.tsx
@@ -291,13 +291,19 @@ export function LogsPage() {
description="The daemon's log ring, newest first, tailed live."
learnMore={
<>
- The ring holds the most recent 5,000 records. New records stream in at the top while
- Live is on; scroll down to read and the tail pauses, holding new records until you
- return. Dashboard traffic (API reads and socket connects from open dashboards) is hidden
- unless you turn it on. Export downloads every matching record as NDJSON, dashboard
- traffic included.
+
+ The ring holds the most recent 5,000 records. New records stream in at the top while
+ Live is on; scroll down to read and the tail pauses, holding new records until you
+ return.
+
+
+ Dashboard traffic (API reads and socket connects from open dashboards) is hidden
+ unless you turn it on. Export downloads every matching record as NDJSON, dashboard
+ traffic included.
+
>
}
+ learnMoreDocs="dashboardLogs"
// On phones the header actions join the toolbar row instead of taking a row of their own.
{...(!phone && {
actions: (
diff --git a/packages/dashboard/src/features/overview/OverviewPage.tsx b/packages/dashboard/src/features/overview/OverviewPage.tsx
index 9d22ef2..89ed914 100644
--- a/packages/dashboard/src/features/overview/OverviewPage.tsx
+++ b/packages/dashboard/src/features/overview/OverviewPage.tsx
@@ -63,6 +63,17 @@ export function OverviewPage() {
+
+ Tiles and the chart cover the window picked on the right; the sub line of each tile
+ gives the all-time total. Click a bar in the activity chart to open the sessions of
+ that time slice.
+
+
An error is a tool call that failed or reported a failure in its result.
+ >
+ }
+ learnMoreDocs="dashboardOverview"
actions={
Tool calls in the selected window that failed, counted over {rangeLabel} (the same
window as the chart). Includes calls that reported a failure in an otherwise
- successful result: a vault_fill that came back{' '}
- auth_failed, a{' '}
- navigate that returned HTTP 404, an attention request
- that timed out.
+ successful result: a vault_fill that came back auth_failed, a{' '}
+ navigate that returned HTTP 404, an attention request that timed out.
>
}
/>,
diff --git a/packages/dashboard/src/features/sessions/SessionsPage.tsx b/packages/dashboard/src/features/sessions/SessionsPage.tsx
index 246b998..2f49c5b 100644
--- a/packages/dashboard/src/features/sessions/SessionsPage.tsx
+++ b/packages/dashboard/src/features/sessions/SessionsPage.tsx
@@ -102,11 +102,18 @@ export function SessionsPage() {
description={count ?? 'Every browser an agent launched.'}
learnMore={
<>
- Sessions are started by MCP clients with the launch_session tool. The
- dashboard observes them and takes over when an agent asks; it never launches browsers
- itself.
+
+ Sessions are started by MCP clients with the launch_session tool. Each
+ one is its own Chromium process with separate cookies and storage. The dashboard
+ observes them and takes over when an agent asks; it never launches browsers itself.
+
+
+ Archive hides finished sessions from this list. Delete erases their events, trace,
+ screenshots, profile and downloads.
+
+ What this browser presents to websites. With stealth on, the user agent, client
+ hints, locale and timezone are derived from this machine so they agree with each
+ other.
+
+
Stealth is scoped honestly: the guide lists what it does not hide.
+ The trace is written when the session closes. Open trace viewer replays it here
+ with DOM snapshots, network and console; trace.zip also opens in a local
+ Playwright install.
+
+
Vault keystrokes are excluded from traces.
+ >
+ }
+ infoDocs="dashboardSessionDetail"
>
{!secure ? (
diff --git a/packages/dashboard/src/features/sessions/list/session-empty.tsx b/packages/dashboard/src/features/sessions/list/session-empty.tsx
index f76775b..56b998b 100644
--- a/packages/dashboard/src/features/sessions/list/session-empty.tsx
+++ b/packages/dashboard/src/features/sessions/list/session-empty.tsx
@@ -1,11 +1,12 @@
/** @module features/sessions/list/session-empty — the three `/sessions` empty states (spec 04 §12.2, §11) */
+
import { EmptyState } from '@/components/shared/EmptyState.tsx';
import { buttonVariants } from '@/components/ui/button.tsx';
+import { docsUrl } from '@/lib/links.ts';
import { hasSessionFilters, type SessionsSearch } from '../search.ts';
/** Docs link for the zero-data CTA. */
-export const CONNECT_DOCS_URL =
- 'https://github.com/arg1998/BrowserHive/blob/main/docs/guide/mcp-clients.md';
+export const CONNECT_DOCS_URL = docsUrl('mcpClients');
/** Empty state for the current search. */
export function SessionsEmpty({
diff --git a/packages/dashboard/src/features/system/SystemPage.tsx b/packages/dashboard/src/features/system/SystemPage.tsx
index 131a2b6..efa1960 100644
--- a/packages/dashboard/src/features/system/SystemPage.tsx
+++ b/packages/dashboard/src/features/system/SystemPage.tsx
@@ -43,6 +43,20 @@ export function SystemPage() {
+
+ Status shows health checks, capacity, storage and the defaults new sessions get.
+ Agent tokens are the bearer tokens MCP clients use when agent authentication is
+ on. Configuration lists every effective setting with where it came from.
+
+
+ Settings are read at startup, from flags, browserhive.config.json and
+ environment variables; change them there and restart.
+
+ >
+ }
+ learnMoreDocs="configuration"
{...(!forbidden && {
tabs: (
diff --git a/packages/dashboard/src/features/system/components/SettingsList.tsx b/packages/dashboard/src/features/system/components/SettingsList.tsx
index df94909..7ecc781 100644
--- a/packages/dashboard/src/features/system/components/SettingsList.tsx
+++ b/packages/dashboard/src/features/system/components/SettingsList.tsx
@@ -1,11 +1,27 @@
/** @module features/system/components/SettingsList — a readable settings list: label (with an optional explainer), value (mono for identifiers, toned when it needs attention), a fix hint under problem values, copy on hover; wraps instead of overflowing at any width, paths between segments */
+import type { ReactNode } from 'react';
import { CopyButton } from '@/components/shared/CopyButton.tsx';
import { InfoDot } from '@/components/shared/InfoDot.tsx';
import { TONE_CLASSES } from '@/components/shared/tones.ts';
import { ICONS } from '@/lib/icons.ts';
+import { configKeyDocsUrl } from '@/lib/links.ts';
import { cn } from '@/lib/utils.ts';
import type { SettingRow } from '../model.ts';
+/** Flags (`--maxSessions`), MCP tool names (`vault_fill`) and paths (`/mcp`) in a hint render as code. */
+export function codeSpans(text: string): ReactNode[] {
+ const nodes: ReactNode[] = [];
+ let offset = 0;
+ for (const [i, part] of text
+ .split(/(--[A-Za-z][\w-]*|\b[a-z]+_[a-z_]+\b|(?{part} : part);
+ offset += part.length;
+ }
+ return nodes;
+}
+
/** A path with a line-break opportunity after every separator, so it wraps between segments. */
export function PathText({ value }: { readonly value: string }) {
const parts = value.split(/(?<=[/\\])/);
@@ -53,7 +69,18 @@ export function SettingsList({
+ Every key can be set as a flag, in browserhive.config.json or as a{' '}
+ BROWSERHIVE_* environment variable; the rightmost source wins. Values
+ another source overrode are listed as shadowed.
+
+
Unknown keys fail startup instead of being ignored.
+ >
+ }
+ infoDocs="configurationPrecedence"
description="The effective value of every key and where it came from: cli › file › env › default. Secrets are never shown."
padding="none"
>
diff --git a/packages/dashboard/src/features/system/model.ts b/packages/dashboard/src/features/system/model.ts
index 2390bec..6d08258 100644
--- a/packages/dashboard/src/features/system/model.ts
+++ b/packages/dashboard/src/features/system/model.ts
@@ -16,8 +16,10 @@ export interface SettingRow {
readonly muted?: boolean;
/** Colours the value when it needs attention. */
readonly tone?: Tone;
- /** One-line explanation behind an info button. */
+ /** One-line explanation behind an info button; `--flags` and tool names render as code. */
readonly hint?: string;
+ /** Configuration key whose reference entry the info button links to (`maxSessions`). */
+ readonly configKey?: string;
/** What to do about a problem value (shown under it). */
readonly fix?: string;
/** Value to copy (paths, addresses). */
@@ -54,22 +56,33 @@ export function settingsGroups(s: SystemInfo): readonly SettingGroup[] {
{
label: 'Transport',
value: s.transport,
- hint: 'How MCP clients reach the server: http (HTTP + WebSocket endpoint) or stdio. Set with --transport.',
+ hint: 'How MCP clients reach the server: http (Streamable HTTP, with the dashboard and API on the same port) or stdio. Set with --transport.',
+ configKey: 'transport',
},
{
label: 'Bind address',
value: `${s.host}:${s.port}`,
mono: true,
copy: `${s.host}:${s.port}`,
- hint: 'Where MCP clients and this dashboard connect. A non-loopback host requires --auth token or --allow-insecure-bind.',
+ hint: 'Where MCP clients and this dashboard connect. A non-loopback host requires --auth token or --allowInsecureBind.',
+ configKey: 'host',
},
{
label: 'Agent authentication',
value: s.auth_mode === 'token' ? 'Bearer token required' : 'Off (local principal)',
...(s.auth_mode === 'off' && { tone: 'warn' as const }),
- hint: 'With --auth token every /mcp request needs Authorization: Bearer .',
+ hint: 'With --auth token every /mcp request needs Authorization: Bearer . Create agent tokens on the Agent tokens tab.',
+ configKey: 'auth',
+ },
+ {
+ label: 'Data directory',
+ value: s.data_dir,
+ mono: true,
+ path: true,
+ copy: s.data_dir,
+ hint: 'Database, saved logins, persistent profiles, traces and backups live here. Set with --dataDir.',
+ configKey: 'dataDir',
},
- { label: 'Data directory', value: s.data_dir, mono: true, path: true, copy: s.data_dir },
],
},
{
@@ -79,29 +92,34 @@ export function settingsGroups(s: SystemInfo): readonly SettingGroup[] {
{
label: 'Max sessions',
value: s.capacity.max === null ? 'Unbounded' : formatNumber(s.capacity.max),
- hint: `Sessions requested past the cap are refused. Source: ${s.capacity.max_source}. Set with --max-sessions.`,
+ hint: `Sessions requested past the cap are refused. Source: ${s.capacity.max_source}. Set with --maxSessions.`,
+ configKey: 'maxSessions',
},
{
label: 'Persistence',
value: s.persistence_mode,
hint: 'Default for new sessions: memory (nothing kept), persistent (reused profile) or storage-state (cookies snapshot). Set with --persistence.',
+ configKey: 'persistence',
},
{
label: 'Evaluate tool',
value: s.allow_evaluate ? 'Enabled' : 'Disabled',
...(evaluateRisky(s) && { tone: 'danger' as const }),
- hint: 'Arbitrary page scripting. It can read a vault credential after vault_fill, so it is a risk only while the vault is in use. Set with --allow-evaluate.',
+ hint: 'Arbitrary page scripting. It can read a vault credential after vault_fill, so it is a risk only while the vault is in use. Set with --allowEvaluate.',
+ configKey: 'allowEvaluate',
},
{
label: 'URL blocklist',
value: s.blocklist.configured ? `${formatNumber(s.blocklist.patterns)} patterns` : 'Off',
...(s.blocklist.path !== null && { copy: s.blocklist.path }),
hint: 'Destinations agents may never open. Set with --blocklist .',
+ configKey: 'blocklist',
},
{
label: 'Retention',
value: `${formatNumber(s.retention.days)} days${s.retention.bytes > 0 ? ` · ${formatBytes(s.retention.bytes)} cap` : ''}`,
- hint: 'Events older than this, or past the byte cap, are pruned. Set with --retention-days and --retention-bytes.',
+ hint: 'Events older than this, or past the byte cap, are pruned. Set with --retentionDays and --retentionBytes.',
+ configKey: 'retentionDays',
},
],
},
@@ -112,20 +130,33 @@ export function settingsGroups(s: SystemInfo): readonly SettingGroup[] {
{
label: 'Profile',
value: s.stealth.profile,
- hint: 'off (raw Playwright), standard (patched driver and hardened launch) or max (adds fingerprint injection). Set with --stealth.',
+ hint: 'off (raw Playwright), standard (patched driver and hardened launch) or max (adds a display fingerprint). Set with --stealth.',
+ configKey: 'stealth',
},
{
label: 'Driver',
value: s.stealth.driver,
- hint: 'patchright hides the CDP Runtime.enable leak; playwright is used when Patchright is absent.',
+ hint: 'patchright hides the CDP Runtime.enable leak; playwright is used when Patchright is absent. Set with --stealthDriver.',
+ configKey: 'stealthDriver',
+ },
+ {
+ label: 'Fingerprint by default',
+ value: onOff(s.stealth.fingerprint),
+ hint: 'Coherent screen and window geometry per session. On by default with --stealth max. Set with --fingerprint.',
+ configKey: 'fingerprint',
+ },
+ {
+ label: 'Humanize by default',
+ value: onOff(s.stealth.humanize),
+ hint: 'Human-like mouse paths and typing rhythm for input tools. Slower, and needs stealth on. Set with --humanize.',
+ configKey: 'humanize',
},
- { label: 'Fingerprint by default', value: onOff(s.stealth.fingerprint) },
- { label: 'Humanize by default', value: onOff(s.stealth.humanize) },
{
label: 'CAPTCHA handling',
value: s.stealth.captcha,
...(s.stealth.captcha === 'solver' && { tone: 'warn' as const }),
- hint: 'attention hands the browser to a human, solver uses an external service, off does nothing. Set with --captcha.',
+ hint: 'attention hands the browser to a human when a CAPTCHA appears; off does nothing. Set with --captcha.',
+ configKey: 'captcha',
},
],
},
@@ -136,14 +167,16 @@ export function settingsGroups(s: SystemInfo): readonly SettingGroup[] {
{
label: 'Vault',
value: s.vault.enabled ? (s.vault.backend ?? 'On') : 'Off',
- hint: 'Credential injection for vault_fill. Set with --vault.',
+ hint: 'Credential injection for vault_fill: the agent names an entry, the password never reaches the model. Set with --vault.',
+ configKey: 'vault',
},
{
label: 'Telemetry',
value: s.otel.enabled
? `OTLP${s.otel.endpoint !== null ? ` → ${s.otel.endpoint}` : ''}${s.otel.protocol !== null ? ` (${s.otel.protocol})` : ''}`
: 'Off',
- hint: 'OpenTelemetry export of traces, metrics and logs. Set with --otel.',
+ hint: 'OpenTelemetry export of traces, metrics and logs. Off by default; nothing leaves this machine unless it is on. Set with --otel.',
+ configKey: 'otel',
},
],
},
@@ -282,7 +315,7 @@ export function systemNotices(
id: 'evaluate',
tone: 'danger',
title: 'Evaluate is enabled while the vault is in use',
- body: 'A credential filled by vault_fill is readable by page scripts unless a session opts out. Disable --allow-evaluate unless you need arbitrary scripting.',
+ body: 'A credential filled by vault_fill is readable by page scripts unless a session opts out. Disable --allowEvaluate unless you need arbitrary scripting.',
});
if (health !== undefined && health.status === 'degraded')
out.push({
diff --git a/packages/dashboard/src/features/system/status/StatusSection.tsx b/packages/dashboard/src/features/system/status/StatusSection.tsx
index a08b92d..9454a2d 100644
--- a/packages/dashboard/src/features/system/status/StatusSection.tsx
+++ b/packages/dashboard/src/features/system/status/StatusSection.tsx
@@ -1,4 +1,5 @@
/** @module features/system/status/StatusSection — System › Status: notices shown once, live KPI tiles, health (including the degraded 503 body), runtime with fix hints, degradations, storage and retention, realtime connections, migrations */
+
import type {
HealthResponse,
MigrationRow,
@@ -28,6 +29,7 @@ import {
import { formatBytes, formatNumber, formatPercent } from '@/lib/format/bytes.ts';
import { formatAbsoluteShort, formatDuration, formatMs } from '@/lib/format/time.ts';
import { ICONS } from '@/lib/icons.ts';
+import { configKeyDocsUrl } from '@/lib/links.ts';
import { useServerNow } from '@/lib/server-now.ts';
import { diskTone, type Tone } from '@/lib/status-registry.ts';
import { SettingsList } from '../components/SettingsList.tsx';
@@ -154,6 +156,13 @@ function DegradationsPanel({ events }: { readonly events: readonly SystemEvent[]
return (
+ Problems the daemon detected and worked around, such as a browser that failed to launch or
+ low disk space. Recurring problems are grouped by code.
+ >
+ }
+ infoDocs="troubleshooting"
description={
open > 0
? `${formatNumber(open)} open. Recurring problems are grouped by code.`
@@ -209,7 +218,16 @@ function StoragePanel({ system }: { readonly system: SystemInfo }) {
const ratio = dbRatio(system);
const never = Never;
return (
-
+
+ Events older than the retention window are pruned, oldest first, and so is anything past
+ the byte cap. Traces, screenshots and profiles of deleted sessions go with them.
+ >
+ }
+ infoDocs={{ href: configKeyDocsUrl('retentionDays'), label: 'Retention settings' }}
+ >
@@ -360,6 +378,13 @@ function MigrationsPanel({
return (
+ The database schema this daemon runs and the oldest BrowserHive version that can still
+ read it. Upgrades back up the database before migrating.
+ >
+ }
+ infoDocs="upgrading"
description={`Schema v${schema} · readable by v${minReader} and later`}
padding="none"
>
diff --git a/packages/dashboard/src/features/system/tokens/TokensSection.tsx b/packages/dashboard/src/features/system/tokens/TokensSection.tsx
index e9ca560..17ed110 100644
--- a/packages/dashboard/src/features/system/tokens/TokensSection.tsx
+++ b/packages/dashboard/src/features/system/tokens/TokensSection.tsx
@@ -10,6 +10,7 @@ import { Panel } from '@/components/shared/Section.tsx';
import { SkeletonKv } from '@/components/shared/Skeletons.tsx';
import { formatNumber } from '@/lib/format/bytes.ts';
import { ICONS } from '@/lib/icons.ts';
+import { docsUrl } from '@/lib/links.ts';
import { isForbidden, useCreateToken, useRevokeToken, useTokens } from './api.ts';
import { CreateTokenForm } from './CreateTokenForm.tsx';
import { type CreatedToken, TokenCreatedDialog } from './TokenCreatedDialog.tsx';
@@ -100,6 +101,17 @@ export function TokensSection({ origin, authMode, daemon }: TokensSectionProps)
Create one token per agent and revoke it when the agent is retired.
>
}
+ info={
+ <>
+
+ Tokens only matter when the daemon runs with --auth token, which a
+ non-loopback bind requires. Each token is shown once when it is created; store it in
+ the agent's MCP client configuration.
+
+
Revoking a token rejects the agent's next request.
- No agent tokens yet. The MCP clients guide{' '}
- (docs/guide/mcp-clients.md) shows
- where an agent puts one.
+ No agent tokens yet. The{' '}
+
+ MCP clients guide
+ {' '}
+ shows where an agent puts one.
}
diff --git a/packages/dashboard/src/features/vault/VaultPage.test.tsx b/packages/dashboard/src/features/vault/VaultPage.test.tsx
index b5c8cf4..c3b37fd 100644
--- a/packages/dashboard/src/features/vault/VaultPage.test.tsx
+++ b/packages/dashboard/src/features/vault/VaultPage.test.tsx
@@ -25,8 +25,9 @@ describe('VaultPage', () => {
});
expect(await screen.findByText('Vault backend is off')).toBeTruthy();
expect(screen.getByRole('list', { name: 'Enable the vault' })).toBeTruthy();
- // No external docs links until the documentation site exists.
- expect(screen.queryByRole('link', { name: /guide|docs/i })).toBeNull();
+ // The guide link goes to the website, never to GitHub.
+ const guide = screen.getByRole('link', { name: /Read the vault guide/ });
+ expect(guide.getAttribute('href')).toBe('https://browserhive.ai/docs/guide/vault/');
});
it('shows the disabled panel from /system without calling the vault endpoints', async () => {
diff --git a/packages/dashboard/src/features/vault/VaultPage.tsx b/packages/dashboard/src/features/vault/VaultPage.tsx
index a297552..3543ee0 100644
--- a/packages/dashboard/src/features/vault/VaultPage.tsx
+++ b/packages/dashboard/src/features/vault/VaultPage.tsx
@@ -43,6 +43,24 @@ const TAB_LABEL: Record = {
const TITLE = 'Vault';
const DESCRIPTION = 'Which agents may fill which credentials, where. Policy only, never secrets.';
+/** Header explainer shared by every state of the page. */
+const VAULT_LEARN_MORE = {
+ learnMore: (
+ <>
+
+ An agent calls vault_fill with an entry name and the form fields. BrowserHive
+ checks the page origin, the session and the policy, types the credential into the page and
+ returns only a status. The secret never reaches the model or this dashboard.
+
+
+ Folder policies decide what agents may use by default; bindings allow single entries on
+ specific origins and sessions.
+
+ >
+ ),
+ learnMoreDocs: 'vault',
+} as const;
+
/** Vault page. */
export function VaultPage() {
const { search, set } = useSearchState();
@@ -60,7 +78,7 @@ export function VaultPage() {
if (enabled === false || isVaultOff(overview.error)) {
return (
-
+
);
@@ -68,7 +86,7 @@ export function VaultPage() {
if (overview.isError) {
return (
-
+
-
+
@@ -109,6 +127,7 @@ export function VaultPage() {
title={TITLE}
badge={}
description={DESCRIPTION}
+ {...VAULT_LEARN_MORE}
meta={}
actions={
+ Entries with dashboard_confirm hold every fill until you approve it here. The
+ agent's vault_fill call waits; a denial is recorded in the vault log with
+ your reason.
+ >
+ }
+ infoDocs="vaultConfirmations"
actions={
<>
+
+ Every vault_fill and every denied listing is recorded: the entry name, the
+ result, whether the page origin matched the entry, whether evaluate was
+ enabled, the session and the page URL.
+
+
Credentials, usernames and form values are never written here.
+ >
+ }
+ learnMoreDocs="vaultAudit"
{...(total !== undefined && {
meta: {formatNumber(total)} events,
})}
diff --git a/packages/dashboard/src/features/vault/status/VaultDisabled.tsx b/packages/dashboard/src/features/vault/status/VaultDisabled.tsx
index 766ebd4..e08f8f3 100644
--- a/packages/dashboard/src/features/vault/status/VaultDisabled.tsx
+++ b/packages/dashboard/src/features/vault/status/VaultDisabled.tsx
@@ -1,7 +1,9 @@
/** @module features/vault/status/VaultDisabled — the one "vault is off" panel for `/vault` and `/vault/log`: what the vault does, three steps to enable it */
+
import { Link } from '@tanstack/react-router';
import { buttonVariants } from '@/components/ui/button.tsx';
import { ICONS } from '@/lib/icons.ts';
+import { docsUrl } from '@/lib/links.ts';
const STEPS = [
{
@@ -40,6 +42,7 @@ const STEPS = [
/** Disabled panel. `page` tailors the opening sentence. */
export function VaultDisabled({ page = 'vault' }: { readonly page?: 'vault' | 'log' }) {
const Lock = ICONS.lock;
+ const External = ICONS.external;
return (
))}
- {page === 'log' ? (
-
+ One row per navigation, from any tab of any session. Destinations that are not public
+ web pages get a category tag: raw IP addresses, local hosts (localhost,
+ file://), FTP and other schemes.
+
+
+ Query strings and fragments are stripped before a URL is stored, except parameters
+ listed in urlQueryAllowlist.
+
+ >
+ }
+ learnMoreDocs="dashboardWebsites"
actions={
{
+ const text = readFileSync(fileOf(page), 'utf8');
+ const anchors = new Set();
+ for (const m of text.matchAll(/\bid="([^"]+)"/g)) if (m[1] !== undefined) anchors.add(m[1]);
+ for (const m of text.replace(/```[\s\S]*?```/g, '').matchAll(/^#{2,6}\s+(.+)$/gm)) {
+ anchors.add(
+ (m[1] ?? '')
+ .replace(/`/g, '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
+ .replace(/\s/g, '-'),
+ );
+ }
+ return anchors;
+}
+
+describe('docs links', () => {
+ it('every docs page the dashboard links to exists, with its anchor', () => {
+ for (const [key, entry] of Object.entries(DOCS_PAGES) as [
+ DocsPage,
+ { page: string; anchor?: string },
+ ][]) {
+ expect(existsSync(fileOf(entry.page)), `${key}: ${entry.page}`).toBe(true);
+ if (entry.anchor !== undefined) {
+ expect(anchorsOf(entry.page).has(entry.anchor), `${key}: #${entry.anchor}`).toBe(true);
+ }
+ }
+ });
+
+ it('points at the website, not GitHub', () => {
+ expect(docsUrl('home')).toBe('https://browserhive.ai/docs/');
+ expect(docsUrl('vaultBindings')).toBe('https://browserhive.ai/docs/guide/vault/#bindings');
+ expect(configKeyDocsUrl('maxSessions')).toBe(
+ 'https://browserhive.ai/docs/reference/configuration/#maxSessions',
+ );
+ expect(errorCodeDocsUrl('SESSION_NOT_FOUND')).toBe(
+ 'https://browserhive.ai/docs/reference/errors/#SESSION_NOT_FOUND',
+ );
+ expect(toolDocsUrl('vault_fill')).toBe(
+ 'https://browserhive.ai/docs/reference/tools/#vault_fill',
+ );
+ expect(releaseUrl('0.1.2')).toBe(
+ 'https://github.com/arg1998/BrowserHive/releases/tag/browserhive@0.1.2',
+ );
+ });
+
+ it('config key and error anchors exist in the references', () => {
+ const config = anchorsOf('reference/configuration');
+ for (const key of ['maxSessions', 'transport', 'blocklist', 'stealth', 'vault', 'otel']) {
+ expect(config.has(key), key).toBe(true);
+ }
+ expect(anchorsOf('reference/errors').has('SESSION_NOT_FOUND')).toBe(true);
+ expect(anchorsOf('reference/tools').has('vault_fill')).toBe(true);
+ });
+});
diff --git a/packages/dashboard/src/lib/links.ts b/packages/dashboard/src/lib/links.ts
new file mode 100644
index 0000000..aba1266
--- /dev/null
+++ b/packages/dashboard/src/lib/links.ts
@@ -0,0 +1,94 @@
+/** @module lib/links — the one place for outbound links: browserhive.ai docs pages (checked against docs/ by links.test.ts), the website and the GitHub repository */
+
+/** Public website. */
+export const WEBSITE_URL = 'https://browserhive.ai';
+/** Docs home on the website (latest major). */
+export const DOCS_URL = `${WEBSITE_URL}/docs/`;
+/** Source repository. */
+export const REPO_URL = 'https://github.com/arg1998/BrowserHive';
+/** New issue form. */
+export const ISSUES_URL = `${REPO_URL}/issues/new`;
+/** Release notes. */
+export const RELEASES_URL = `${REPO_URL}/releases`;
+
+/**
+ * Docs pages the dashboard links to: the path under `/docs/` of the Markdown file in `docs/`
+ * (without `.md`) and an optional heading anchor. Keep anchors to headings that exist, the test
+ * resolves every entry.
+ */
+export const DOCS_PAGES = {
+ home: { page: '' },
+ quickStart: { page: 'guide/quick-start' },
+ blocklistFile: { page: 'guide/quick-start', anchor: 'blocklist-file-format' },
+ installation: { page: 'guide/installation' },
+ mcpClients: { page: 'guide/mcp-clients' },
+ agentTokens: { page: 'guide/mcp-clients', anchor: 'authentication-tokens' },
+ dashboard: { page: 'guide/dashboard' },
+ dashboardOverview: { page: 'guide/dashboard', anchor: 'overview' },
+ dashboardSessions: { page: 'guide/dashboard', anchor: 'sessions' },
+ dashboardSessionDetail: { page: 'guide/dashboard', anchor: 'session-detail' },
+ dashboardAttention: { page: 'guide/dashboard', anchor: 'attention' },
+ dashboardWebsites: { page: 'guide/dashboard', anchor: 'websites' },
+ dashboardBlocklist: { page: 'guide/dashboard', anchor: 'blocklist' },
+ dashboardVault: { page: 'guide/dashboard', anchor: 'vault' },
+ dashboardVaultLog: { page: 'guide/dashboard', anchor: 'vault-log' },
+ dashboardLogs: { page: 'guide/dashboard', anchor: 'logs' },
+ dashboardSystem: { page: 'guide/dashboard', anchor: 'system' },
+ dashboardNotifications: { page: 'guide/dashboard', anchor: 'notifications' },
+ attention: { page: 'guide/attention' },
+ attentionOperator: { page: 'guide/attention', anchor: 'the-operators-side' },
+ vault: { page: 'guide/vault' },
+ vaultPolicies: { page: 'guide/vault', anchor: 'folder-policies' },
+ vaultBindings: { page: 'guide/vault', anchor: 'bindings' },
+ vaultConfirmations: { page: 'guide/vault', anchor: '6-confirmations' },
+ vaultAudit: { page: 'guide/vault', anchor: '7-audit' },
+ stealth: { page: 'guide/stealth' },
+ stealthLevels: { page: 'guide/stealth', anchor: 'levels' },
+ security: { page: 'guide/security' },
+ securityRecorded: { page: 'guide/security', anchor: 'what-is-recorded' },
+ securityBlocklist: { page: 'guide/security', anchor: 'the-blocklist-is-not-an-egress-firewall' },
+ securityAuth: { page: 'guide/security', anchor: 'authentication' },
+ telemetry: { page: 'guide/telemetry' },
+ configuration: { page: 'guide/configuration' },
+ configurationPrecedence: { page: 'guide/configuration', anchor: 'precedence' },
+ configurationReference: { page: 'reference/configuration' },
+ upgrading: { page: 'guide/upgrading' },
+ troubleshooting: { page: 'guide/troubleshooting' },
+ troubleshootingDashboard: { page: 'guide/troubleshooting', anchor: 'dashboard' },
+ tools: { page: 'reference/tools' },
+ errors: { page: 'reference/errors' },
+} as const satisfies Record;
+
+/** Docs page key. */
+export type DocsPage = keyof typeof DOCS_PAGES;
+
+function withAnchor(page: string, anchor: string | undefined): string {
+ const path = page === '' ? DOCS_URL : `${DOCS_URL}${page}/`;
+ return anchor === undefined ? path : `${path}#${anchor}`;
+}
+
+/** Absolute URL of a docs page on browserhive.ai. */
+export function docsUrl(key: DocsPage): string {
+ const entry: { readonly page: string; readonly anchor?: string } = DOCS_PAGES[key];
+ return withAnchor(entry.page, entry.anchor);
+}
+
+/** A configuration key in the reference (`maxSessions` → `…/reference/configuration/#maxSessions`). */
+export function configKeyDocsUrl(key: string): string {
+ return withAnchor('reference/configuration', key);
+}
+
+/** An error code in the reference (`SESSION_NOT_FOUND` → `…/reference/errors/#SESSION_NOT_FOUND`). */
+export function errorCodeDocsUrl(code: string): string {
+ return withAnchor('reference/errors', code);
+}
+
+/** An MCP tool in the reference (`vault_fill` → `…/reference/tools/#vault_fill`). */
+export function toolDocsUrl(tool: string): string {
+ return withAnchor('reference/tools', tool);
+}
+
+/** Release notes for one version (the tag Changesets creates). */
+export function releaseUrl(version: string): string {
+ return `${RELEASES_URL}/tag/browserhive@${version}`;
+}
diff --git a/website/scripts/sync-docs.ts b/website/scripts/sync-docs.ts
index dbb22c6..d1bfdf9 100644
--- a/website/scripts/sync-docs.ts
+++ b/website/scripts/sync-docs.ts
@@ -154,6 +154,23 @@ function syncVersion(
return { sidebar: sidebarFromReadme(readme, { version, published }), pages };
}
+/**
+ * Cloudflare `_redirects`. Short URLs printed by the CLI and used as problem+json `type` identifiers
+ * (`/docs/errors#CODE`, `/docs/configuration`) land on the references; browsers keep the `#anchor`.
+ * Versioned links to the latest major keep working: `/docs/v1/x` → `/docs/x` while v1 is latest.
+ */
+export function redirects(latestLabel: string): string {
+ return [
+ '/docs/errors /docs/reference/errors/ 301',
+ '/docs/errors/ /docs/reference/errors/ 301',
+ '/docs/configuration /docs/reference/configuration/ 301',
+ '/docs/configuration/ /docs/reference/configuration/ 301',
+ `/docs/${latestLabel} /docs/ 302`,
+ `/docs/${latestLabel}/* /docs/:splat 302`,
+ '',
+ ].join('\n');
+}
+
export function syncDocs(): DocsManifest {
const pkg = JSON.parse(readFileSync(join(REPO, 'packages/browserhive/package.json'), 'utf8'));
let tags: string[] = [];
@@ -175,12 +192,7 @@ export function syncDocs(): DocsManifest {
removeStale(CONTENT_OUT, written);
removeStale(PUBLIC_OUT, written);
writeIfChanged(MANIFEST_OUT, `${JSON.stringify(manifest, null, 2)}\n`, written);
- // Versioned links to the latest major keep working: /docs/v1/x → /docs/x while v1 is latest.
- writeIfChanged(
- REDIRECTS_OUT,
- `/docs/${latest.label} /docs/ 302\n/docs/${latest.label}/* /docs/:splat 302\n`,
- written,
- );
+ writeIfChanged(REDIRECTS_OUT, redirects(latest.label), written);
return manifest;
}