Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
## [Unreleased]

### Added
- Behind the Data Agile portal, the portal can name the local PC for its user (`pc.name` in `/api/portal/me`) and turn
adding PCs off for them (`pc.add_pc: false`). With it off, Add PC (sidebar footer, command palette, Settings →
Remote PCs) and every control that starts a PC setup (Reconnect / setup, Update bridge…, Set up…, Sign in and
update…, and the banner's Set up… / Reconnect) are not shown; Connect / Disconnect, Remove PC and the bridge
update that needs no sign-in stay. Without the field nothing changes. ([#21](https://github.com/dataagile/herdr-web-ui/pull/21))
- The **Files** dialog is a tree: folders open in place and read their children on demand (open folders are
remembered per PC and pane folder), with file-type icons, a selected-row rail and arrow-key navigation (up/down,
right/left to open and close, Home/End). A **Filter by name** field keeps the matching names and their folders,
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ bun scripts/voice-server-regression.ts
bun scripts/machine-dialog-regression.ts
bun scripts/feedback-regression.ts
bun scripts/machine-conflict-regression.ts
bun scripts/portal-pc-regression.ts
122 changes: 122 additions & 0 deletions scripts/portal-pc-regression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import { resolve } from "node:path";
import { chromium } from "playwright-core";
import type { Machine } from "../shared/machines.ts";
import { openSettingsPage } from "./settings-page.ts";

// A built client with a fictional roster and an in-memory API standing in for the portal's
// `/api/portal/me`. `pc.add_pc: false` removes every control that ends in a PC setup (the footer,
// the palette, Settings → Remote PCs, Reconnect / setup, Update bridge…, the notices and the
// banner) and nothing else; `pc.name` renames the local PC. Without `pc` the app is unchanged.
const snapshot = { version: "0.9.3", protocol: 22, focused_workspace_id: null, focused_tab_id: null, focused_pane_id: null, workspaces: [], tabs: [], panes: [], layouts: [], agents: [] };
const local = { id: "local", name: "QA host", kind: "local", state: "connected", enabled: true, error: null, snapshot } as Machine;
const remote = { ...local, id: "qa-remote", name: "QA remote", kind: "ssh", target: { destination: "qa.invalid" }, state: "error", error: null, action_required: "setup" } as unknown as Machine;
let remoteAction = "setup";
let meDelay = 0;
let updatePosts = 0;
let me: unknown = { authenticated: true };
const setups: string[] = [];
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
const path = new URL(request.url).pathname;
if (path === "/api/portal/me") { if (meDelay) await Bun.sleep(meDelay); return Response.json(me); }
if (path === "/api/health") return Response.json({ ok: true, auth: { authenticated: true, required: false, role: "drive" }, herdr: { version: "0.9.3", protocol: 22 }, web_ui: { revision: null, boot_id: "qa" } });
if (path === "/api/machines") return Response.json({ machines: [local, { ...remote, action_required: remoteAction }] });
if (path === "/api/session") return Response.json({ snapshot });
if (path === "/api/machines/settings") return Response.json({ auto_update_bridges: false });
if (path === "/api/machines/qa-remote/update-bridge") { updatePosts++; await Bun.sleep(300); } if (path === "/api/machines/qa-remote/update-bridge") return Response.json({ error: { code: "qa", message: "Bridge update refused" } }, { status: 500 });
if (path === "/api/machines/setup") { setups.push(request.method); return Response.json({ error: { code: "add_pc_disabled", message: "off" } }, { status: 403 }); }
if (path.startsWith("/api/") || path === "/ws") return Response.json({ error: { code: "qa", message: "Unavailable in fixture" } }, { status: 404 });
const file = Bun.file(resolve("dist", path === "/" ? "index.html" : path.slice(1)));
return new Response(await file.exists() ? file : Bun.file("dist/index.html"));
} });
const browser = await chromium.launch({ headless: true, executablePath: process.env["CHROME_PATH"] || chromium.executablePath() });
try {
const open = async (answer: unknown) => {
me = answer;
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await page.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" })));
const answered = page.waitForResponse("**/api/portal/me");
await page.goto(server.url.href);
await answered;
await page.locator(".machine-action").waitFor({ timeout: 10_000 });
await page.waitForTimeout(500);
return page;
};
const buttons = async (page: import("playwright-core").Page) => {
const count = (name: string) => page.getByRole("button", { name, exact: true }).count();
const result = { footer: await count("Add PC"), notice: await page.locator(".machine-action").getByRole("button", { name: "Set up…", exact: true }).count(), banner: await page.locator(".update-notice").getByRole("button", { name: "Set up…", exact: true }).count() };
await page.getByRole("button", { name: "Manage QA remote" }).click();
const manage = { reconnect: await count("Reconnect / setup"), update: await count("Update bridge…"), disconnect: await count("Disconnect"), remove: await count("Remove PC") };
const dialog = page.getByRole("dialog", { name: "Command palette", exact: true });
await page.keyboard.press("Control+Shift+k");
await dialog.waitFor();
const palette = await dialog.getByRole("option", { name: "Add PC" }).count();
await dialog.getByRole("option", { name: "Settings" }).click();
await openSettingsPage(page, "Remote PCs");
const settings = await page.locator(".settings-dialog").getByRole("button", { name: "Add PC", exact: true }).count();
await page.keyboard.press("Escape");
return { ...result, ...manage, palette, settings };
};

// without `pc`, or with a `pc` that changes nothing: everything as before
for (const answer of [{ authenticated: true }, { authenticated: true, pc: null }, { authenticated: true, pc: { name: null, add_pc: true } }]) {
const page = await open(answer);
const on = await buttons(page);
assert.deepEqual(on, { footer: 1, notice: 1, banner: 1, reconnect: 1, update: 1, disconnect: 1, remove: 1, palette: 1, settings: 1 }, JSON.stringify(answer));
await page.getByText("QA host", { exact: true }).first().waitFor();
await page.close();
}

// add_pc: false hides every setup control and keeps the rest; name renames the local PC
const page = await open({ authenticated: true, pc: { name: "Notebook da Ana", add_pc: false } });
const off = await buttons(page);
assert.deepEqual(off, { footer: 0, notice: 0, banner: 0, reconnect: 0, update: 0, disconnect: 1, remove: 1, palette: 0, settings: 0 });
await page.getByText("Notebook da Ana", { exact: true }).first().waitFor();
assert.equal(await page.getByText("QA host", { exact: true }).count(), 0);
assert.deepEqual(setups, [], "no setup request was made");
await page.close();

// the setup controls wait for the portal's answer: a slow /me never lets them flash for add_pc false
meDelay = 1500;
for (const addPc of [false, true]) {
remoteAction = "setup";
me = { authenticated: true, pc: { name: null, add_pc: addPc } };
const slow = await browser.newPage({ viewport: { width: 1280, height: 900 } });
await slow.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" })));
await slow.goto(server.url.href);
await slow.locator(".machine-action").waitFor({ timeout: 10_000 });
const footer = slow.locator(".sidebar-footer").getByRole("button", { name: "Add PC", exact: true });
assert.equal(await footer.count(), 0, "no Add PC before /api/portal/me answers");
if (addPc) await footer.waitFor(); else { await slow.waitForTimeout(2500); assert.equal(await footer.count(), 0, "still off after the answer"); }
await slow.close();
}
meDelay = 0;

// the other two reasons a PC waits: only the sign-in and the plain reconnect need a setup
const waiting = async (action: string, addPc: boolean) => {
remoteAction = action;
const p = await open({ authenticated: true, pc: { name: null, add_pc: addPc } });
const count = (scope: string, name: string) => p.locator(scope).getByRole("button", { name, exact: true }).count();
const seen = { signIn: await count(".machine-action", "Sign in and update…"), noticeUpdate: await count(".machine-action", "Update bridge"), noticeReconnect: await count(".machine-action", "Reconnect"), bannerUpdate: await count(".update-notice", "Update bridge"), bannerReconnect: await count(".update-notice", "Reconnect") };
return { p, seen };
};
let w = await waiting("update_bridge", true);
assert.deepEqual(w.seen, { signIn: 1, noticeUpdate: 1, noticeReconnect: 0, bannerUpdate: 1, bannerReconnect: 0 });
await w.p.close();
w = await waiting("update_bridge", false);
assert.deepEqual(w.seen, { signIn: 0, noticeUpdate: 1, noticeReconnect: 0, bannerUpdate: 1, bannerReconnect: 0 });
// a refused one-click update has no setup dialog to fall back to: the banner says why
const bannerUpdate = w.p.locator(".update-notice").getByRole("button", { name: "Update bridge", exact: true });
await bannerUpdate.dblclick();
await w.p.locator(".update-notice .machine-error").getByText("Bridge update refused").waitFor();
assert.equal(await w.p.getByRole("dialog").count(), 0);
assert.equal(updatePosts, 1, "a double click sends one update-bridge request");
await w.p.close();
w = await waiting("bridge_conflict", true);
assert.deepEqual(w.seen, { signIn: 0, noticeUpdate: 0, noticeReconnect: 1, bannerUpdate: 0, bannerReconnect: 1 });
await w.p.close();
w = await waiting("bridge_conflict", false);
assert.deepEqual(w.seen, { signIn: 0, noticeUpdate: 0, noticeReconnect: 0, bannerUpdate: 0, bannerReconnect: 0 });
await w.p.close();
console.log("PASS: add_pc false hides every PC setup control and a portal name renames the local PC; absent or null pc changes nothing");
} finally { await browser.close(); server.stop(true); }
20 changes: 13 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { MachineDialog } from "./components/MachineDialog.tsx";
import { copyText } from "./lib/clipboard.ts";
import { focusWorkspaceListToggle } from "./lib/focus.ts";
import { headerCrumb, showsChat } from "./lib/headerCrumb.ts";
import { paneStorageId, type Machine, type MachineEvent } from "../shared/machines.ts";
import { LOCAL_MACHINE, paneStorageId, type Machine, type MachineEvent } from "../shared/machines.ts";
import { takeAuthTokenFromUrl } from "./lib/authLink.ts";
import { applyLayout, applyPaneStatus, pinLayouts, type LayoutPin } from "./lib/snapshot.ts";
import { showsSplit, SPLIT_MIN_WIDTH, tabLayout, type ResizeStep } from "./lib/splitLayout.ts";
Expand Down Expand Up @@ -161,7 +161,10 @@ export function App() {
const alertsOn = settings.alertsOn;
const alertsOnRef = useRef(alertsOn);
alertsOnRef.current = alertsOn;
const [machines, setMachines] = useState<Machine[]>([]);
const [rawMachines, setMachines] = useState<Machine[]>([]);
// the portal names the local PC for its user: one mapping here, so every path that sets the roster shows it
const [portalPcName, setPortalPcName] = useState<string | null>(null);
const machines = useMemo(() => portalPcName ? rawMachines.map((m) => m.id === LOCAL_MACHINE ? { ...m, name: portalPcName } : m) : rawMachines, [rawMachines, portalPcName]);
const [selectedMachineId, setSelectedMachineId] = useState(() => {
const query = new URLSearchParams(window.location.search);
if (query.has("pane")) return query.get("machine") ?? "local";
Expand Down Expand Up @@ -196,11 +199,14 @@ export function App() {
useEffect(() => {
void fetchPortalSession().then((session) => {
setPortalSession(session);
setPortalPcName(session.pc.name);
// the feedback's technical data (console, failed requests) is recorded only for a portal that takes feedback
if (session.portal && session.feedback.enabled) installFeedbackBuffer();
});
}, []);
const portal = portalSession?.portal === true;
// off until /api/portal/me answers, so the setup controls never flash for a user the portal turns them off for
const canAddPc = portalSession !== null && portalSession.pc.addPc;
const canSignOut = !portal && auth?.authenticated === true && (auth.via === "token" || auth.via === "device");
// a device that is in only because nothing is paired yet still pairs from the QR code's address
const pairedFromAddress = useRef(false);
Expand Down Expand Up @@ -938,13 +944,13 @@ export function App() {
setSettingsSection(null);
setSettingsOpen(true);
},
openAddPc: () => {
openAddPc: canAddPc ? () => {
// the new PC reports its progress in the sidebar: nothing should sit over it
setSettingsOpen(false);
setUpdateRemote(false);
addPcFocusReturn.current = true;
setMachineDialog("new");
},
} : null,
toggleSidebar: () => {
if (window.matchMedia("(max-width: 768px)").matches) setDrawerOpen((open) => !open);
else setSidebarCollapsed((collapsed) => !collapsed);
Expand All @@ -956,7 +962,7 @@ export function App() {
refresh: () => void load(),
openFiles: selectedPaneId !== null ? () => { setDrawerOpen(false); setFilesOpen(true); } : null,
}),
[pickPane, selectPane, selectedPaneId, selectedMachineId, setView, view, splitting, updateSettings, resolvedTheme, canSignOut, lock, portal, portalSignOut, bellVisible, bell.run, enableNotifications, load],
[pickPane, selectPane, selectedPaneId, selectedMachineId, setView, view, splitting, updateSettings, resolvedTheme, canSignOut, canAddPc, lock, portal, portalSignOut, bellVisible, bell.run, enableNotifications, load],
);

useShortcuts(actions, locked === false);
Expand Down Expand Up @@ -1167,7 +1173,7 @@ export function App() {
</button>
</div>
{error && <div className="error-state" role="alert"><p>{error}</p><button className="btn" onClick={() => void load()}>{t("Retry")}</button></div>}
<MachineSidebar herdrVersion={targetHerdr?.version ?? null} machines={machines} selectedMachineId={selectedMachineId} selectedPaneId={selectedPaneId} actions={actions} seenRefused={seenRefusedIds} onSelect={pickTarget} onSetup={(machine, update = false) => { setUpdateRemote(update); setMachineDialog(machine); }} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} />
<MachineSidebar herdrVersion={targetHerdr?.version ?? null} machines={machines} selectedMachineId={selectedMachineId} selectedPaneId={selectedPaneId} actions={actions} seenRefused={seenRefusedIds} onSelect={pickTarget} onSetup={canAddPc ? (machine, update = false) => { setUpdateRemote(update); setMachineDialog(machine); } : null} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} />
<SidebarResizer width={sidebarWidth} onResize={setSidebarWidth} />
</aside>

Expand All @@ -1178,7 +1184,7 @@ export function App() {
<div className={`pane-column${chatShown ? " is-chat" : ""}`}>
{/* over the pane only: a bar across the window would cut the sidebar off from its top row in the header */}
<UpdateNotice updates={updates} onOpen={() => { setSettingsSection("updates"); setSettingsOpen(true); }} />
<MachineActionBanner machines={machines} onSetup={(machine, update = false) => { setDrawerOpen(false); setUpdateRemote(update); setMachineDialog(machine); }} />
<MachineActionBanner machines={machines} onSetup={canAddPc ? (machine, update = false) => { setDrawerOpen(false); setUpdateRemote(update); setMachineDialog(machine); } : null} />
{snapshot && selectedPane && selectedWorkspace && (
<TabStrip snapshot={snapshot} workspace={selectedWorkspace} selectedPane={selectedPane} onSelectPane={pickPane} onNewTab={() => actions.openNewTab()} sideBySide={splitting} />
)}
Expand Down
2 changes: 1 addition & 1 deletion src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function CommandPalette({ open, onClose, snapshot, selectedPaneId, view,
{ id: "sidebar", label: t("Toggle sidebar"), icon: PanelLeft, shortcut: "toggle-sidebar", run: actions.toggleSidebar },
{ id: "theme", label: t("Toggle theme"), icon: SunMoon, run: actions.toggleTheme },
{ id: "settings", label: t("Settings"), icon: Settings, shortcut: "settings", run: actions.openSettings },
{ id: "add-pc", label: t("Add PC"), icon: Monitor, run: actions.openAddPc },
...(actions.openAddPc ? [{ id: "add-pc", label: t("Add PC"), icon: Monitor, run: actions.openAddPc }] : []),
...(actions.enableNotifications ? [{ id: "notifications", label: t("Enable notifications"), icon: Bell, run: actions.enableNotifications }] : []),
...(actions.portalSignOut ? [{ id: "portal-sign-out", label: t("Sign out"), icon: LogOut, run: actions.portalSignOut }] : []),
...(actions.lock ? [{ id: "lock", label: t("Sign out"), icon: LockKeyhole, run: actions.lock }] : []),
Expand Down
Loading