mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(hub): open the project Dashboard to every member, rename /insights → /dashboard (BEA-12) (#62)
* fix(hub): open the project Dashboard to every member, rename /insights → /dashboard (BEA-12) "Dashboard" was the first sidebar item a new member clicked and it always refused: it landed on /<project>/insights showing "Insights is for hub admins and org owners." The gate was client-side only — GET /heat is gated on project membership and returns counts without actor identities, so every member's browser could already fetch every number the page draws. Drops the canInsights gate (nav item, dedicated route, project-home embed, ⋯ menu entry) and renames the view route insights → dashboard so the nav label, the URL and the page title finally agree. The shipped /insights URL still resolves and normalizes to /dashboard (LEGACY_VIEWS in router.ts) so bookmarks don't 404 and only one URL stays live. No server change: /heat gating and response shape are untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(architecture): router VIEW_ROUTES now names dashboard, with LEGACY_VIEWS Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
224b3d6f53
commit
589be3a6de
@@ -13,7 +13,7 @@ knows what their agent knows. (People are covered too: any synced file
|
||||
becomes a public URL that renders as a page.) Notes, plans, findings, and
|
||||
artifacts follow the team everywhere — and unlike a memory API, they stay
|
||||
**real files with provenance**: every change is attributed to the human,
|
||||
agent, and device that made it, and the hub's Insights show what your
|
||||
agent, and device that made it, and the hub's Dashboard shows what your
|
||||
agents actually read (and which hot-but-stale docs nobody maintains).
|
||||
|
||||
<p align="center">
|
||||
@@ -394,8 +394,8 @@ Hubs also track **read heat**: viewer opens and downloads count as human
|
||||
reads, share-link hits as share reads, and agent tool reads (reported by
|
||||
the sync hooks via `bdrive read-log`) as agent reads — sync replication
|
||||
never counts. Folder listings show heat dots and 30-day read counts to
|
||||
every member, and admins / org owners get an **Insights** dashboard
|
||||
(⋯ menu), four sections with an all/human/agent lens: a **treemap** of
|
||||
every member, and every member gets the project **Dashboard**
|
||||
(sidebar or ⋯ menu), four sections with an all/human/agent lens: a **treemap** of
|
||||
every file (cell size = reads, color = staleness, ⚠ on hot+stale — click
|
||||
through to any file), the **reads × freshness** scatter whose hot-but-stale
|
||||
quadrant is the knowledge people rely on that nobody maintains, the
|
||||
|
||||
@@ -27,7 +27,8 @@ classDiagram
|
||||
}
|
||||
|
||||
class router {
|
||||
+VIEW_ROUTES insights history install settings
|
||||
+VIEW_ROUTES dashboard history install settings
|
||||
+LEGACY_VIEWS insights to dashboard
|
||||
+top-level routes orgs billing
|
||||
+parseRoute(url, mode) Route
|
||||
+Route.version ?v= sha, one past version
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { login, wikiId, MEMBER, READER, expectToast } from "./helpers";
|
||||
|
||||
// Phase 3: project home (connect guide + embedded insights), the dedicated
|
||||
// insights route, and the history views. Ports the original parity checks
|
||||
// Phase 3: project home (connect guide + embedded dashboard), the dedicated
|
||||
// dashboard route, and the history views. Ports the original parity checks
|
||||
// from the pre-migration smoke suite.
|
||||
|
||||
test("landing is the project home (guide), not an insights redirect", async ({ page }) => {
|
||||
test("landing is the project home (guide), not a dashboard redirect", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.waitForURL("/" + pid);
|
||||
@@ -51,11 +51,11 @@ test("guide: manual fallback has the full command list and the docs link", async
|
||||
await expect(page.locator('.gd-manual a[href="https://docs.beardrive.ai/manual/install/"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("admin home embeds insights below the guide; member home does not", async ({ page, browser }) => {
|
||||
test("home embeds the dashboard below the guide, for members too", async ({ page, browser }) => {
|
||||
await login(page);
|
||||
await page.waitForSelector(".guide");
|
||||
await expect(page.locator(".home-insights .insights")).toBeVisible();
|
||||
// Guide renders above the embedded insights
|
||||
// Guide renders above the embedded dashboard
|
||||
const order = await page.evaluate(() => {
|
||||
const g = document.querySelector(".guide");
|
||||
const i = document.querySelector(".home-insights");
|
||||
@@ -69,24 +69,51 @@ test("admin home embeds insights below the guide; member home does not", async (
|
||||
const p2 = await ctx.newPage();
|
||||
await login(p2, MEMBER);
|
||||
await p2.waitForSelector(".guide");
|
||||
await expect(p2.locator(".home-insights")).toHaveCount(0);
|
||||
await expect(p2.locator(".home-insights .insights")).toBeVisible();
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test("dedicated insights route still works and survives reload", async ({ page }) => {
|
||||
test("dedicated dashboard route still works and survives reload", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/insights`);
|
||||
await expect(page.locator("#crumb")).toHaveText("Insights — wiki");
|
||||
await page.goto(`/${pid}/dashboard`);
|
||||
await expect(page.locator("#crumb")).toHaveText("Dashboard — wiki");
|
||||
await expect(page.locator(".in-treemap")).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.locator(".in-treemap")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a plain member gets the Dashboard, not a refusal", async ({ page }) => {
|
||||
await login(page, MEMBER);
|
||||
const pid = await wikiId(page);
|
||||
await page.click("#nav-dashboard");
|
||||
await page.waitForURL(`/${pid}/dashboard`);
|
||||
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
|
||||
await expect(page.locator("#crumb")).toHaveText("Dashboard — wiki");
|
||||
await expect(page.locator(".in-treemap")).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText("hub admins and org owners");
|
||||
// …and a scoped deep link resolves on a cold page, back/forward included.
|
||||
await page.goto(`/${pid}/dashboard/notes`);
|
||||
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
|
||||
await page.goBack();
|
||||
await page.waitForURL(`/${pid}/dashboard`);
|
||||
});
|
||||
|
||||
test("the retired /insights URL still lands on the Dashboard", async ({ page }) => {
|
||||
await login(page, MEMBER);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/insights`);
|
||||
await page.waitForURL(`/${pid}/dashboard`); // normalized, one live URL per page
|
||||
await expect(page.locator(".in-treemap")).toBeVisible();
|
||||
await page.goto(`/${pid}/insights/notes`);
|
||||
await page.waitForURL(`/${pid}/dashboard/notes`);
|
||||
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
|
||||
});
|
||||
|
||||
test("hot path row opens the file", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/insights`);
|
||||
await page.goto(`/${pid}/dashboard`);
|
||||
await page.click(".in-hp-row:first-child");
|
||||
await page.waitForURL(/\/(index|guide)\.md$/);
|
||||
await expect(page.locator("#content h1")).toBeVisible();
|
||||
@@ -101,13 +128,13 @@ test("vault name returns to the project home", async ({ page }) => {
|
||||
await expect(page.locator(".guide")).toBeVisible();
|
||||
});
|
||||
|
||||
test("back/forward walks home → file → insights", async ({ page }) => {
|
||||
test("back/forward walks home → file → dashboard", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.waitForURL("/" + pid);
|
||||
await page.click('#tree .row[data-path="index.md"]');
|
||||
await page.waitForURL(`/${pid}/index.md`);
|
||||
await page.goto(`/${pid}/insights`);
|
||||
await page.goto(`/${pid}/dashboard`);
|
||||
await page.goBack();
|
||||
await expect(page.locator("#content h1")).toHaveText("Wiki");
|
||||
await page.goBack();
|
||||
@@ -206,13 +233,13 @@ test("folder listing's Full history goes to the subtree feed", async ({ page })
|
||||
for (const p of paths) expect(p).toContain("notes/");
|
||||
});
|
||||
|
||||
test("insights scopes to the selected folder via the ⋯ menu", async ({ page }) => {
|
||||
await login(page);
|
||||
test("the dashboard scopes to the selected folder via the ⋯ menu", async ({ page }) => {
|
||||
await login(page, MEMBER); // members get the scoped entry too
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/notes`);
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Insights')");
|
||||
await page.waitForURL(`/${pid}/insights/notes`);
|
||||
await page.click("#more-menu .more-item:has-text('Dashboard')");
|
||||
await page.waitForURL(`/${pid}/dashboard/notes`);
|
||||
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
|
||||
// Scope note in the subtitle is the stable assertion.
|
||||
await expect(page.locator(".insights .dl-sub")).toContainText("notes and everything in it");
|
||||
@@ -222,7 +249,7 @@ test("project menu pages each own a URL: Dashboard, Installation, Settings", asy
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.click("#nav-dashboard");
|
||||
await page.waitForURL(`/${pid}/insights`);
|
||||
await page.waitForURL(`/${pid}/dashboard`);
|
||||
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
|
||||
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
|
||||
await page.click("#nav-install");
|
||||
@@ -237,7 +264,7 @@ test("project menu pages each own a URL: Dashboard, Installation, Settings", asy
|
||||
await expect(page.locator("#crumb")).toHaveText("Project settings");
|
||||
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
|
||||
await page.click("#nav-dashboard");
|
||||
await page.waitForURL(`/${pid}/insights`);
|
||||
await page.waitForURL(`/${pid}/dashboard`);
|
||||
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
|
||||
// Deep link + reload land on the page, like any URL.
|
||||
await page.goto(`/${pid}/settings`);
|
||||
|
||||
@@ -45,7 +45,7 @@ test("every view shares one column system", async ({ page }) => {
|
||||
await visit("", "app"); // project home
|
||||
await visit("/install", "app"); // the same guide, so the same column
|
||||
await visit("/settings", "app");
|
||||
await visit("/insights", "app"); // charts cap their own measure; the column is normal
|
||||
await visit("/dashboard", "app"); // charts cap their own measure; the column is normal
|
||||
await visit("/history", "app"); // structured view, not a file render
|
||||
await visit("/index.md", "read"); // rendered markdown — the only read surface
|
||||
await visit("/notes", "app"); // folder listing is a structured view too
|
||||
@@ -73,7 +73,7 @@ test("charts never scale past the size they were drawn at", async ({ page }) =>
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`http://localhost:8993/${pid}/insights`);
|
||||
await page.goto(`http://localhost:8993/${pid}/dashboard`);
|
||||
await page.waitForSelector(".in-chart");
|
||||
const worst = await page.evaluate(() => {
|
||||
let max = 0;
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { login, wikiId } from "./helpers";
|
||||
|
||||
test("insights via ⋯ scopes to the open file", async ({ page }) => {
|
||||
test("dashboard via ⋯ scopes to the open file", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/notes/readme.md`);
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Insights')");
|
||||
await expect(page).toHaveURL(`/${pid}/insights/notes/readme.md`);
|
||||
await page.click("#more-menu .more-item:has-text('Dashboard')");
|
||||
await expect(page).toHaveURL(`/${pid}/dashboard/notes/readme.md`);
|
||||
await expect(page.locator(".in-title .in-scope")).toContainText("notes/readme.md");
|
||||
// The subject stays selected in the tree; Dashboard does NOT light up.
|
||||
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toHaveClass(/active/);
|
||||
await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test("insights via ⋯ scopes to the selected folder", async ({ page }) => {
|
||||
test("dashboard via ⋯ scopes to the selected folder", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/notes`);
|
||||
await page.click("#more-btn");
|
||||
await page.click("#more-menu .more-item:has-text('Insights')");
|
||||
await expect(page).toHaveURL(`/${pid}/insights/notes`);
|
||||
await page.click("#more-menu .more-item:has-text('Dashboard')");
|
||||
await expect(page).toHaveURL(`/${pid}/dashboard/notes`);
|
||||
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
|
||||
await expect(page.locator('#tree .row[data-path="notes"]')).toHaveClass(/active/);
|
||||
await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
const OUT = process.argv[2], B = "http://localhost:8993";
|
||||
const b = await chromium.launch();
|
||||
async function as(email) {
|
||||
const c = await b.newContext({ viewport: { width: 1280, height: 860 } });
|
||||
const p = await c.newPage();
|
||||
await p.goto(B + "/"); await p.waitForURL(/auth\/login/);
|
||||
await p.fill('input[name="email"]', email); await p.fill('input[name="password"]', "e2e-pass-1");
|
||||
await p.click("form button"); await p.waitForSelector("#sidebar");
|
||||
return p;
|
||||
}
|
||||
const r = await as("reader@example.com");
|
||||
const pid = (await (await r.request.get(B+"/api/projects")).json()).projects.find(x=>x.name==="wiki").id;
|
||||
await r.goto(`${B}/${pid}/dashboard`); await r.waitForTimeout(1200);
|
||||
console.log("reader crumb:", await r.locator("#crumb").innerText());
|
||||
console.log("reader treemap count:", await r.locator(".in-treemap").count());
|
||||
const m = await as("member@example.com");
|
||||
await m.goto(`${B}/${pid}/notes`); await m.waitForTimeout(800);
|
||||
await m.click("#more-btn"); await m.waitForTimeout(300);
|
||||
console.log("member ⋯ items:", await m.locator("#more-menu .more-item").allInnerTexts());
|
||||
await m.screenshot({ path: `${OUT}/after-05-member-more-menu.png` });
|
||||
const res = await m.request.get(`${B}/api/p/${pid}/heat`);
|
||||
const body = await res.text();
|
||||
console.log("member /heat:", res.status(), "identity leak:", /@|token|device_id/.test(body));
|
||||
await b.close();
|
||||
@@ -38,7 +38,6 @@ export default function Browser(props: {
|
||||
hub: boolean;
|
||||
project?: Project;
|
||||
projects?: Project[];
|
||||
canInsights?: boolean;
|
||||
sidebar: { vault: ReactNode; projectsNav?: ReactNode; orgBar?: ReactNode };
|
||||
// Admin panels (org admin, hub settings) replace the content pane without
|
||||
// touching the URL — matching the classic app, where they were never
|
||||
@@ -52,10 +51,10 @@ export default function Browser(props: {
|
||||
|
||||
const { tree, flatFiles, dirIndex, loaded } = useTree(apiBase, !hub || !!project);
|
||||
const heatMap = useHeat(apiBase, hub && !!project && !!config.reads?.enabled);
|
||||
// Insights data: the per-device breakdown, plus a fresh heat fetch when
|
||||
// an insights surface opens (the ambient heat cache may be a minute old).
|
||||
// Dashboard data: the per-device breakdown, plus a fresh heat fetch when
|
||||
// a dashboard surface opens (the ambient heat cache may be a minute old).
|
||||
const isHome = hub && !!project && !route.path && !route.view;
|
||||
const insightsOpen = !!props.canInsights && (route.view === "insights" || isHome);
|
||||
const insightsOpen = route.view === "dashboard" || isHome;
|
||||
const devices = useInsightsDevices(apiBase, insightsOpen);
|
||||
useEffect(() => {
|
||||
if (insightsOpen) qc.invalidateQueries({ queryKey: ["heat", apiBase] });
|
||||
@@ -64,9 +63,9 @@ export default function Browser(props: {
|
||||
const path = route.path;
|
||||
// ?v= belongs to the file page; a view route or folder ignores it.
|
||||
const version = !route.view ? route.version : undefined;
|
||||
// On scoped view routes (/insights/<p>, /history/<p>) the subject of the
|
||||
// On scoped view routes (/dashboard/<p>, /history/<p>) the subject of the
|
||||
// page is the target — the tree highlights it, not a menu item.
|
||||
const treePath = path || (route.view === "insights" || route.view === "history" ? route.viewTarget || "" : "");
|
||||
const treePath = path || (route.view === "dashboard" || route.view === "history" ? route.viewTarget || "" : "");
|
||||
const isDir = !!path && dirIndex.has(path);
|
||||
// A file only counts as one when the tree actually contains it — a
|
||||
// missing path gets the not-found view, not a broken file view.
|
||||
@@ -87,7 +86,7 @@ export default function Browser(props: {
|
||||
}, [tree]);
|
||||
useEffect(() => {
|
||||
// Opening any path (tree click, palette, wikilink, deep link — or a
|
||||
// scoped insights/history view of it) unfolds the way to it; a selected
|
||||
// scoped dashboard/history view of it) unfolds the way to it; a selected
|
||||
// folder itself opens too.
|
||||
if (!treePath || !loaded) return;
|
||||
setExpanded((s) => {
|
||||
@@ -248,8 +247,8 @@ export default function Browser(props: {
|
||||
let view: ReactNode;
|
||||
if (panel) {
|
||||
view = panel.body;
|
||||
} else if (route.view === "insights") {
|
||||
view = props.canInsights ? (
|
||||
} else if (route.view === "dashboard") {
|
||||
view = (
|
||||
<Insights
|
||||
flatFiles={flatFiles}
|
||||
heatMap={heatMap}
|
||||
@@ -259,8 +258,6 @@ export default function Browser(props: {
|
||||
onOpenFolder={openPath}
|
||||
isFolder={isFolderFn}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty">Insights is for hub admins and org owners.</div>
|
||||
);
|
||||
} else if (route.view === "history") {
|
||||
// structured view — default app column, like the folder listing it shares rows with
|
||||
@@ -338,23 +335,21 @@ export default function Browser(props: {
|
||||
);
|
||||
}
|
||||
} else if (isHome) {
|
||||
// The project's index page: the connect-an-agent guide, with Insights
|
||||
// below for admins/owners.
|
||||
// The project's index page: the connect-an-agent guide, with the
|
||||
// dashboard below it.
|
||||
view = (
|
||||
<>
|
||||
<ConnectGuide project={project!} />
|
||||
{props.canInsights && (
|
||||
<div className="home-insights">
|
||||
<Insights
|
||||
flatFiles={flatFiles}
|
||||
heatMap={heatMap}
|
||||
devices={devices}
|
||||
onOpenFile={openPath}
|
||||
onOpenFolder={openPath}
|
||||
isFolder={isFolderFn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="home-insights">
|
||||
<Insights
|
||||
flatFiles={flatFiles}
|
||||
heatMap={heatMap}
|
||||
devices={devices}
|
||||
onOpenFile={openPath}
|
||||
onOpenFolder={openPath}
|
||||
isFolder={isFolderFn}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
@@ -365,8 +360,8 @@ export default function Browser(props: {
|
||||
panel.crumb
|
||||
) : path ? (
|
||||
<Breadcrumbs path={path} onOpenFolder={openPath} />
|
||||
) : route.view === "insights" ? (
|
||||
"Insights — " + (route.viewTarget || project?.name || "")
|
||||
) : route.view === "dashboard" ? (
|
||||
"Dashboard — " + (route.viewTarget || project?.name || "")
|
||||
) : route.view === "history" ? (
|
||||
"History — " + historyTitle(route.viewTarget || "", isFolderFn)
|
||||
) : isHome ? (
|
||||
@@ -421,15 +416,15 @@ export default function Browser(props: {
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{props.canInsights && (
|
||||
{hub && !!project && (
|
||||
<button
|
||||
className="more-item"
|
||||
onClick={() => {
|
||||
props.onClosePanel?.();
|
||||
navigate(urlForView("insights", project?.id, path));
|
||||
navigate(urlForView("dashboard", project?.id, path));
|
||||
}}
|
||||
>
|
||||
Insights
|
||||
Dashboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -71,9 +71,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
|
||||
const brand = config.brand || "BearDrive";
|
||||
const org = (current && orgs?.find((o) => o.id === current.org)) || null;
|
||||
// Insights (embedded on the project home and behind the ⋯ menu) is for
|
||||
// hub admins and owners of the project's org.
|
||||
const canInsights = isAdmin || (org ? org.role === "owner" : false);
|
||||
|
||||
// Top of the sidebar is the brand; project and account actions live in
|
||||
// their own sections below (PropelAuth-style layout).
|
||||
@@ -211,6 +208,12 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
return <Redirect to={"/" + current.id} />;
|
||||
}
|
||||
|
||||
// A renamed view URL (/insights) still resolves; swap it for the current
|
||||
// one so there is one live URL per page.
|
||||
if (route.legacyView && route.view) {
|
||||
return <Redirect to={urlForView(route.view, current.id, route.viewTarget)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Browser
|
||||
key={current.id} // fresh tree/fold state per project
|
||||
@@ -220,7 +223,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
hub
|
||||
project={current}
|
||||
projects={projects}
|
||||
canInsights={canInsights}
|
||||
sidebar={{
|
||||
vault,
|
||||
projectsNav: (
|
||||
@@ -228,12 +230,12 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
projects={projects}
|
||||
currentId={current.id}
|
||||
menu={{
|
||||
// Scoped views (/insights/<path>, /history/<path>) belong to
|
||||
// Scoped views (/dashboard/<path>, /history/<path>) belong to
|
||||
// the file/folder — the tree carries the selection, no menu
|
||||
// item lights up.
|
||||
active: panel
|
||||
? null
|
||||
: route.view === "insights" && !route.viewTarget
|
||||
: route.view === "dashboard" && !route.viewTarget
|
||||
? "dashboard"
|
||||
: route.view === "install"
|
||||
? "install"
|
||||
@@ -246,7 +248,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
// same-path navigation doesn't change pathname.
|
||||
onDashboard: () => {
|
||||
setPanel(null);
|
||||
navigate(urlForView("insights", current.id));
|
||||
navigate(urlForView("dashboard", current.id));
|
||||
closeSidebarOnMobile();
|
||||
},
|
||||
onInstall: () => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { getJSON } from "../api/http";
|
||||
import type { HeatMap, Node } from "../api/types";
|
||||
import { heatTotal } from "../hooks/useBrowse";
|
||||
|
||||
/* ---- insights: the read×write matrix ----
|
||||
/* ---- the project Dashboard: the read×write matrix ----
|
||||
Every file plotted by how much it is read (30 days, from the heat API)
|
||||
against how long since it last changed (from the tree). The hot-but-stale
|
||||
quadrant is the danger zone: knowledge people still rely on that nobody
|
||||
maintains. Admin/org-owner only — members get the ambient heat dots. */
|
||||
maintains. Every project member sees this — /heat is gated on membership
|
||||
and returns counts only, never actor identities (reads.go). */
|
||||
|
||||
const HOT_READS = 3; // ≥ this many reads/30d = hot
|
||||
const STALE_DAYS = 30; // ≥ this many days since last write = stale
|
||||
|
||||
@@ -233,7 +233,7 @@ export function Mark({ size = 22 }: { size?: number }) {
|
||||
gutter; `<Page>` owns width and centering — nothing else may set either.
|
||||
Three widths cover every view: `read` for rendered files only (markdown
|
||||
prose), `app` for every structured view (guide, listings, history,
|
||||
insights, settings, admin), `wide` for content that is itself a
|
||||
dashboard, settings, admin), `wide` for content that is itself a
|
||||
page (a rendered HTML file in its frame). `read` and `app` both resolve
|
||||
to Tailwind's md (768px); they stay separate classes because the file
|
||||
view carries markdown typography and the widths may diverge again.
|
||||
|
||||
@@ -17,16 +17,20 @@ export function decodePath(p: string): string {
|
||||
|
||||
// Special views are RESTful routes under the project — the first segment
|
||||
// after the project id is reserved when it names a view:
|
||||
// /<project-id>/insights[/<path>] the Insights dashboard (optionally scoped)
|
||||
// /<project-id>/dashboard[/<path>] the read×staleness dashboard (optionally scoped)
|
||||
// /<project-id>/history[/<path>] change feed (project / subtree / file)
|
||||
// /<project-id>/install connect-a-device guide
|
||||
// /<project-id>/settings project settings
|
||||
// Rule: every page gets its own URL path (see CLAUDE.md) — new surfaces are
|
||||
// view routes here, not ephemeral panel state. (Root-level files literally
|
||||
// named like a view lose the URL shortcut and remain reachable via the tree.)
|
||||
export const VIEW_ROUTES = new Set(["insights", "history", "install", "settings"]);
|
||||
export const VIEW_ROUTES = new Set(["dashboard", "history", "install", "settings"]);
|
||||
|
||||
export type ViewName = "insights" | "history" | "install" | "settings";
|
||||
// Shipped URLs that were renamed. Parsed into the new view and normalized
|
||||
// away on arrival, so bookmarks resolve without a second live name.
|
||||
const LEGACY_VIEWS: Record<string, ViewName> = { insights: "dashboard" };
|
||||
|
||||
export type ViewName = "dashboard" | "history" | "install" | "settings";
|
||||
|
||||
export interface Route {
|
||||
// Org administration is not project-scoped, so it is a top-level route
|
||||
@@ -41,6 +45,9 @@ export interface Route {
|
||||
path: string;
|
||||
view?: ViewName;
|
||||
viewTarget?: string;
|
||||
// The URL used a renamed segment (e.g. /insights): the app replaces it
|
||||
// with the canonical one instead of leaving two URLs for one page.
|
||||
legacyView?: boolean;
|
||||
// A past version of `path`, by content hash (?v=<sha>). Not a view route:
|
||||
// the first segment after the project id is reserved for view names, and a
|
||||
// version is the same page pinned to older bytes, so it rides as a query
|
||||
@@ -71,8 +78,9 @@ function parsePath(pathname: string, mode: "volume" | "hub"): Route {
|
||||
const r: Route = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
|
||||
const seg = r.path.indexOf("/");
|
||||
const head = seg === -1 ? r.path : r.path.slice(0, seg);
|
||||
if (VIEW_ROUTES.has(head)) {
|
||||
r.view = head as ViewName;
|
||||
if (VIEW_ROUTES.has(head) || LEGACY_VIEWS[head]) {
|
||||
r.view = LEGACY_VIEWS[head] || (head as ViewName);
|
||||
if (LEGACY_VIEWS[head]) r.legacyView = true;
|
||||
r.viewTarget = seg === -1 ? "" : r.path.slice(seg + 1).replace(/\/+$/, "");
|
||||
r.path = "";
|
||||
}
|
||||
|
||||
+32
-32
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-Czoh6fKl.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CH2MFMbS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CzGgqyHx.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -200,8 +200,8 @@ URL and id filled in.
|
||||
Hubs aggregate reads per file — viewer opens and downloads count as human
|
||||
reads, share-link hits as share reads, and hook-reported agent reads as
|
||||
agent reads; `/store` sync replication never counts. The web UI shows heat
|
||||
dots and read counts on folder listings (all members), and admins / org
|
||||
owners get an **Insights** dashboard (⋯ menu, all/human/agent lens): a
|
||||
dots and read counts on folder listings, and every project member gets
|
||||
the **Dashboard** (sidebar or ⋯ menu, all/human/agent lens): a
|
||||
treemap of every file (size = 30-day reads, color = staleness, ⚠ =
|
||||
hot+stale, click-through), a reads × freshness scatter (hot-but-stale
|
||||
quadrant = fix first), the hot path (top files by reads, agent/human
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: What agents read
|
||||
description: The hub's read heat and Insights dashboard — which documents your agents actually consume, and which ones everyone relies on and nobody maintains.
|
||||
description: The hub's read heat and project Dashboard — which documents your agents actually consume, and which ones everyone relies on and nobody maintains.
|
||||
---
|
||||
|
||||
Writing to shared memory is easy to observe. *Reading* is the part that's
|
||||
@@ -29,9 +29,9 @@ Folder listings show heat dots and 30-day read counts to every member. It's the
|
||||
fastest way to tell which parts of a knowledge folder are load-bearing and which
|
||||
are decoration.
|
||||
|
||||
## Insights
|
||||
## The project Dashboard
|
||||
|
||||
Admins and org owners get an **Insights** dashboard with an all/human/agent
|
||||
Every project member gets a **Dashboard** with an all/human/agent
|
||||
lens — four views:
|
||||
|
||||
- **Treemap** — every file, cell size by reads, color by staleness, with ⚠ on
|
||||
|
||||
@@ -24,7 +24,7 @@ as memory, and your agent knows what their agent knows. Notes, plans, findings,
|
||||
and artifacts follow the team everywhere.
|
||||
|
||||
Unlike a memory API, they stay **real files with provenance**: every change is
|
||||
attributed to the human, agent, and device that made it, and the hub's Insights
|
||||
attributed to the human, agent, and device that made it, and the hub's Dashboard
|
||||
show what your agents actually read — including the hot-but-stale documents
|
||||
everyone relies on and nobody maintains.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ Three hooks, three jobs:
|
||||
- **Push**, after an edit, so teammates see the change within seconds rather
|
||||
than whenever a daemon tick lands.
|
||||
- **Read tracking**, on the agent's read-shaped tools, queued locally and sent
|
||||
on the next sync. This is what fills [Insights](/guides/what-agents-read/).
|
||||
on the next sync. This is what fills the [Dashboard](/guides/what-agents-read/).
|
||||
Listing tools are deliberately excluded: seeing a filename is not reading it.
|
||||
|
||||
Every platform pipes hook JSON with a session id, so one hook command serves all
|
||||
|
||||
@@ -47,7 +47,7 @@ Credentials use each provider's standard chain — nothing BearDrive-specific:
|
||||
"admins": ["you@example.com"],
|
||||
"users_db": "/var/lib/bdrive/auth.json"
|
||||
},
|
||||
"reads": { "enabled": true }, // agent read analytics (Insights)
|
||||
"reads": { "enabled": true }, // agent read analytics (Dashboard)
|
||||
"database": { "driver": "sqlite", "dsn": "/var/lib/bdrive/hub.db" }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -54,7 +54,7 @@ Open the project in a browser. Three things are worth a minute:
|
||||
ever deleted.
|
||||
- **The file browser** — folders show read counts, so you can see which
|
||||
documents your team's agents actually consume.
|
||||
- **Insights** (hub admins and org owners) — reads against staleness. The
|
||||
- **Dashboard** (every project member) — reads against staleness. The
|
||||
hot-but-stale quadrant is the knowledge everyone relies on and nobody
|
||||
maintains. See [What agents read](/guides/what-agents-read/).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Keep a wiki your agents maintain
|
||||
description: For a team whose knowledge lives in documents nobody updates. The wiki gets written as a side effect of work, every change is attributed, and Insights shows which pages are load-bearing and stale.
|
||||
description: For a team whose knowledge lives in documents nobody updates. The wiki gets written as a side effect of work, every change is attributed, and the project Dashboard shows which pages are load-bearing and stale.
|
||||
---
|
||||
|
||||
Every team has the same wiki problem: the pages that matter most are the ones
|
||||
@@ -43,7 +43,7 @@ model that has ever survived contact with a busy team.
|
||||
|
||||
## Reading the health of it
|
||||
|
||||
The hub's **Insights** (hub admins and org owners) plots reads against
|
||||
The hub's project **Dashboard** (every member) plots reads against
|
||||
staleness. The quadrant to watch is **hot and stale**: pages your agents read
|
||||
constantly that nobody has updated. That is your maintenance queue, and it is
|
||||
usually three pages, not thirty.
|
||||
@@ -53,7 +53,7 @@ behind an admin screen.
|
||||
|
||||
## What matters for this case
|
||||
|
||||
- **[What agents read](/guides/what-agents-read/)** — read heat, Insights, and
|
||||
- **[What agents read](/guides/what-agents-read/)** — read heat, the Dashboard, and
|
||||
what the hub deliberately does not record.
|
||||
- **[Shared agent memory](/guides/shared-agent-memory/)** — an `AGENTS.md` map
|
||||
at the wiki root so agents file things consistently instead of inventing a
|
||||
|
||||
Reference in New Issue
Block a user