diff --git a/.changeset/dashboard-docs-links.md b/.changeset/dashboard-docs-links.md new file mode 100644 index 0000000..886a71c --- /dev/null +++ b/.changeset/dashboard-docs-links.md @@ -0,0 +1,12 @@ +--- +"browserhive": patch +--- + +Dashboard: links to the documentation on browserhive.ai, the website and GitHub. + +- A Help menu in the top bar opens the guide for the current page, the dashboard guide, MCP client setup, troubleshooting, release notes and issue reporting. +- The sidebar footer links to Docs, GitHub and the website, and the version links to its release notes. +- The command palette can open the docs, the tool reference, the website and GitHub. +- Info popovers are fixed: inline code and bold text no longer break onto their own lines. They now have paragraph spacing and a "Read the docs" link. +- New explainers were added across Overview, Websites, Vault, Vault log, System (tokens, configuration, storage, migrations, degradations) and session details. +- System setting hints now use the real camelCase flags (`--maxSessions`, `--allowEvaluate`, `--retentionDays`) and link to each key in the configuration reference. diff --git a/packages/dashboard/src/app/shell/HelpMenu.tsx b/packages/dashboard/src/app/shell/HelpMenu.tsx new file mode 100644 index 0000000..6d23221 --- /dev/null +++ b/packages/dashboard/src/app/shell/HelpMenu.tsx @@ -0,0 +1,139 @@ +/** @module app/shell/HelpMenu — topbar help dropdown: the docs section for the current page, guides, keyboard shortcuts, and the project links (website, GitHub, release notes, issues); every outbound item opens in a new tab */ +import { useRouterState } from '@tanstack/react-router'; +import type { ReactNode } from 'react'; +import { Button } from '@/components/ui/button.tsx'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu.tsx'; +import { Hint } from '@/components/ui/tooltip.tsx'; +import { ICONS, type IconName } from '@/lib/icons.ts'; +import { + type DocsPage, + docsUrl, + ISSUES_URL, + RELEASES_URL, + REPO_URL, + releaseUrl, + WEBSITE_URL, +} from '@/lib/links.ts'; +import { useNavSignals } from './SidebarNav.tsx'; + +/** Dashboard guide section for a route (`/vault/log` → `dashboardVaultLog`). */ +export function pageDocs(pathname: string): { readonly key: DocsPage; readonly label: string } { + const first = pathname.split('/').filter(Boolean); + const [top, second] = first; + switch (top) { + case 'sessions': + return second === undefined + ? { key: 'dashboardSessions', label: 'Sessions' } + : { key: 'dashboardSessionDetail', label: 'Session detail' }; + case 'attention': + return { key: 'dashboardAttention', label: 'Attention' }; + case 'websites': + return { key: 'dashboardWebsites', label: 'Websites' }; + case 'blocklist': + return { key: 'dashboardBlocklist', label: 'Blocklist' }; + case 'vault': + return second === 'log' + ? { key: 'dashboardVaultLog', label: 'Vault log' } + : { key: 'dashboardVault', label: 'Vault' }; + case 'logs': + return { key: 'dashboardLogs', label: 'Logs' }; + case 'system': + return { key: 'dashboardSystem', label: 'System' }; + case 'notifications': + return { key: 'dashboardNotifications', label: 'Notifications' }; + default: + return { key: 'dashboardOverview', label: 'Overview' }; + } +} + +function ExternalItem({ + href, + icon, + children, +}: { + readonly href: string; + readonly icon: IconName; + readonly children: ReactNode; +}) { + const Icon = ICONS[icon]; + const External = ICONS.external; + return ( + }> + + ); +} + +/** Help menu. */ +export function HelpMenu({ onOpenKeyboardMap }: { readonly onOpenKeyboardMap: () => void }) { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const { version } = useNavSignals(); + const here = pageDocs(pathname); + const Help = ICONS.help; + const Keyboard = ICONS.keyboard; + return ( + + + } + > + + + + + Documentation + + Help for {here.label} + + + Dashboard guide + + + Connect an MCP client + + + Troubleshooting + + + All documentation + + + + + + + + + + BrowserHive + + browserhive.ai + + + GitHub repository + + + {version !== null ? `What's new in v${version}` : 'Release notes'} + + + Report an issue + + + + + ); +} diff --git a/packages/dashboard/src/app/shell/Sidebar.tsx b/packages/dashboard/src/app/shell/Sidebar.tsx index c91531e..6c4927a 100644 --- a/packages/dashboard/src/app/shell/Sidebar.tsx +++ b/packages/dashboard/src/app/shell/Sidebar.tsx @@ -4,12 +4,13 @@ import { useRouterState } from '@tanstack/react-router'; import { useEffect } from 'react'; import { useShortcut } from '@/app/providers/KeyboardProvider.tsx'; import { BrandMark } from '@/components/shared/brand-mark.tsx'; -import { Button } from '@/components/ui/button.tsx'; +import { Button, buttonVariants } from '@/components/ui/button.tsx'; import { Kbd } from '@/components/ui/kbd.tsx'; import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet.tsx'; import { Hint } from '@/components/ui/tooltip.tsx'; import { ICONS } from '@/lib/icons.ts'; import { formatCombo } from '@/lib/keyboard.ts'; +import { DOCS_URL, RELEASES_URL, REPO_URL, releaseUrl, WEBSITE_URL } from '@/lib/links.ts'; import { cn } from '@/lib/utils.ts'; import { isMacLike } from './platform.ts'; import { SidebarNav, useNavSignals } from './SidebarNav.tsx'; @@ -88,9 +89,33 @@ function Footer({ }) { const { version } = useNavSignals(); const Keyboard = ICONS.keyboard; + const Book = ICONS.book; + const GitHub = ICONS.github; if (collapsed) { return ( -
+
+ + + + + + + + - {version !== null ? ( - v{version} - ) : null} +
+ +
+ + {version !== null ? ( + + 0 ? releaseUrl(version) : RELEASES_URL} + target="_blank" + rel="noreferrer" + className="ml-auto rounded-sm px-1 font-mono text-xs text-muted-foreground underline-offset-4 transition-colors focus-ring hover:text-foreground hover:underline" + > + v{version} + + + ) : null} +
); } +/** A small outbound link in the sidebar footer (opens in a new tab). */ +function FooterLink({ + href, + icon, + children, +}: { + readonly href: string; + readonly icon: React.ReactNode; + readonly children: React.ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + /** Sidebar. */ export function Sidebar({ band, drawerOpen, onDrawerOpenChange, onOpenKeyboardMap }: SidebarProps) { const [preference, setPreference] = useSidebarPreference(); diff --git a/packages/dashboard/src/app/shell/Topbar.tsx b/packages/dashboard/src/app/shell/Topbar.tsx index 7f7b1b6..35a3022 100644 --- a/packages/dashboard/src/app/shell/Topbar.tsx +++ b/packages/dashboard/src/app/shell/Topbar.tsx @@ -8,6 +8,7 @@ import { Hint } from '@/components/ui/tooltip.tsx'; import { ICONS } from '@/lib/icons.ts'; import { formatCombo } from '@/lib/keyboard.ts'; import { HealthPill } from './HealthPill.tsx'; +import { HelpMenu } from './HelpMenu.tsx'; import { NotificationBell } from './NotificationBell.tsx'; import { PrincipalMenu } from './PrincipalMenu.tsx'; import { isMacLike } from './platform.ts'; @@ -121,6 +122,7 @@ export function Topbar({ )} + void { + return () => { + window.open(href, '_blank', 'noopener,noreferrer'); + }; +} + +/** Outbound links to the docs and the project. */ +const DOCS_COMMANDS: readonly PaletteCommand[] = [ + { + id: 'action:docs', + group: 'Actions', + label: 'Open documentation', + hint: 'browserhive.ai/docs', + icon: 'book', + keywords: ['docs', 'help', 'guide', 'manual'], + run: openExternal(docsUrl('home')), + }, + { + id: 'action:docs:dashboard', + group: 'Actions', + label: 'Open the dashboard guide', + icon: 'book', + keywords: ['docs', 'help', 'dashboard'], + run: openExternal(docsUrl('dashboard')), + }, + { + id: 'action:docs:mcp-clients', + group: 'Actions', + label: 'How to connect an MCP client', + icon: 'book', + keywords: ['docs', 'claude', 'cursor', 'vs code', 'setup', 'connect'], + run: openExternal(docsUrl('mcpClients')), + }, + { + id: 'action:docs:tools', + group: 'Actions', + label: 'Open the MCP tool reference', + icon: 'book', + keywords: ['docs', 'tools', 'reference', 'api'], + run: openExternal(docsUrl('tools')), + }, + { + id: 'action:website', + group: 'Actions', + label: 'Open browserhive.ai', + icon: 'globe', + keywords: ['website', 'home'], + run: openExternal(WEBSITE_URL), + }, + { + id: 'action:github', + group: 'Actions', + label: 'Open the GitHub repository', + icon: 'github', + keywords: ['github', 'source', 'code', 'repo'], + run: openExternal(REPO_URL), + }, + { + id: 'action:issue', + group: 'Actions', + label: 'Report an issue', + icon: 'bug', + keywords: ['bug', 'github', 'feedback'], + run: openExternal(ISSUES_URL), + }, +]; + /** Substring match over label, hint and keywords. */ export function matchesQuery( command: Pick, @@ -213,6 +282,7 @@ export function usePaletteCommands( keywords: ['account', 'security'], run: () => void navigate({ to: '/change-password', search: { voluntary: true } }), }, + ...DOCS_COMMANDS, { id: 'action:logout', group: 'Actions', diff --git a/packages/dashboard/src/components/shared/InfoDot.tsx b/packages/dashboard/src/components/shared/InfoDot.tsx index d6fac2c..b66c5bd 100644 --- a/packages/dashboard/src/components/shared/InfoDot.tsx +++ b/packages/dashboard/src/components/shared/InfoDot.tsx @@ -1,19 +1,47 @@ -/** @module components/shared/InfoDot — "Learn more" explainer: a small info icon button that opens a popover */ +/** @module components/shared/InfoDot — "Learn more" explainer: a small info icon button that opens a popover with an optional title, flowing prose (inline code stays inline) and a "Read the docs" link to browserhive.ai */ import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button.tsx'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.tsx'; import { ICONS } from '@/lib/icons.ts'; +import { type DocsPage, docsUrl } from '@/lib/links.ts'; +import { cn } from '@/lib/utils.ts'; + +/** Where "Read the docs" goes: a known docs page, or any URL with its own link text. */ +export type InfoDocs = DocsPage | { readonly href: string; readonly label?: string }; /** Props. */ export interface InfoDotProps { /** Accessible name of the trigger (`About open attention`). */ readonly label: string; + /** Bold first line of the popover. */ + readonly title?: string; + /** Explainer body: plain text or inline JSX (``, ``); wrap separate paragraphs in `

`. */ readonly children: ReactNode; + /** Adds a "Read the docs" link to the website. */ + readonly docs?: InfoDocs; + readonly side?: 'top' | 'bottom' | 'left' | 'right'; + readonly align?: 'start' | 'center' | 'end'; + readonly className?: string; } /** Info popover. */ -export function InfoDot({ label, children }: InfoDotProps) { +export function InfoDot({ + label, + title, + children, + docs, + side = 'bottom', + align = 'center', + className, +}: InfoDotProps) { const Icon = ICONS.info; + const External = ICONS.external; + const link = + docs === undefined + ? undefined + : typeof docs === 'string' + ? { href: docsUrl(docs), label: 'Read the docs' } + : { href: docs.href, label: docs.label ?? 'Read the docs' }; return ( event.stopPropagation()} /> } > - - {children} + event.stopPropagation()} + > +

+ {title !== undefined ? ( +

{title}

+ ) : null} +
p+p]:mt-2 [&_b]:font-semibold [&_b]:text-foreground [&_strong]:font-semibold [&_strong]:text-foreground', + '[&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[0.8125rem] [&_code]:text-foreground dark:[&_code]:bg-white/[0.08]', + '[&_ul]:mt-1.5 [&_ul]:flex [&_ul]:list-none [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:p-0', + )} + > + {children} +
+
+ {link !== undefined ? ( + + {link.label} + + ) : null} ); diff --git a/packages/dashboard/src/components/shared/PageHeader.tsx b/packages/dashboard/src/components/shared/PageHeader.tsx index 276f5f6..10c2a11 100644 --- a/packages/dashboard/src/components/shared/PageHeader.tsx +++ b/packages/dashboard/src/components/shared/PageHeader.tsx @@ -1,7 +1,7 @@ /** @module components/shared/PageHeader — page title (20px semibold), one-line description with an optional "Learn more" popover, right-aligned actions, meta line and tabs underneath */ import type { ReactNode } from 'react'; import { cn } from '@/lib/utils.ts'; -import { InfoDot } from './InfoDot.tsx'; +import { type InfoDocs, InfoDot } from './InfoDot.tsx'; /** Breadcrumb segment (the topbar renders the trail). */ export interface Crumb { @@ -23,6 +23,8 @@ export interface PageHeaderProps { readonly description?: ReactNode; /** Explainer shown in a popover behind an info button at the end of the description. */ readonly learnMore?: ReactNode; + /** "Read the docs" link at the bottom of the `learnMore` popover. */ + readonly learnMoreDocs?: InfoDocs; /** Primary + secondary actions, right-aligned (wrap under the title on narrow screens). */ readonly actions?: ReactNode; /** @@ -45,6 +47,7 @@ export function PageHeader({ badge, description, learnMore, + learnMoreDocs, actions, actionsInline = false, meta, @@ -80,7 +83,13 @@ export function PageHeader({

{description}

) : null} {learnMore !== undefined ? ( - {learnMore} + + {learnMore} + ) : null}
) : 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. +

} + learnMoreDocs="blocklistFile" actions={ state.isPending ? undefined : (
@@ -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. +

} + learnMoreDocs="dashboardSessions" /> {search.since !== undefined || search.until !== undefined ? (
- + +

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

+ + } + infoDocs="stealth" + >
diff --git a/packages/dashboard/src/features/sessions/detail/TraceSection.tsx b/packages/dashboard/src/features/sessions/detail/TraceSection.tsx index 6491c12..5d79d0b 100644 --- a/packages/dashboard/src/features/sessions/detail/TraceSection.tsx +++ b/packages/dashboard/src/features/sessions/detail/TraceSection.tsx @@ -30,6 +30,17 @@ export function TraceSection({ detail }: { readonly detail: SessionDetail }) { +

+ 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({
{row.label} {row.hint !== undefined ? ( - {row.hint} + + {codeSpans(row.hint)} + ) : null}
diff --git a/packages/dashboard/src/features/system/config/ConfigSection.tsx b/packages/dashboard/src/features/system/config/ConfigSection.tsx index 4fde53d..a10ec6e 100644 --- a/packages/dashboard/src/features/system/config/ConfigSection.tsx +++ b/packages/dashboard/src/features/system/config/ConfigSection.tsx @@ -46,6 +46,17 @@ export function ConfigSection({ system, config, filter, onFilterChange }: Config
+

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

+ + } + infoDocs="agentTokens" padding="none" > {forbidden ? ( @@ -131,9 +143,16 @@ export function TokensSection({ origin, authMode, daemon }: TokensSectionProps)

} 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' ? ( -
+
+ {page === 'log' ? ( Go to Vault -
- ) : null} + ) : null} + + Read the vault guide + +
); } diff --git a/packages/dashboard/src/features/websites/WebsitesPage.tsx b/packages/dashboard/src/features/websites/WebsitesPage.tsx index 82d9d1a..f54b6f2 100644 --- a/packages/dashboard/src/features/websites/WebsitesPage.tsx +++ b/packages/dashboard/src/features/websites/WebsitesPage.tsx @@ -213,6 +213,20 @@ export function WebsitesPage() { +

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