From c7f95bc9c445de60d29da7f4ed2f2b4b48df6210 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Mon, 22 Jun 2026 17:02:40 -0300 Subject: [PATCH] docs(screenshots): add a reusable toolkit to seed data and capture UI shots Adds ui/apps/docs/screenshots/, automation that seeds demo data and re-captures the dashboard screenshots used throughout the documentation. It drives a headless Chromium (playwright-core) against a local dev environment through the branded host shellhub.example.com so the UI rebrands itself with no backend change. seed.mjs does idempotent API seeding (devices, a public key, firewall rules, a web endpoint/tunnel); manifest.mjs and capture.mjs are the declarative shot list and runner. Enterprise screens are enabled entirely through .env.override environment variables consumed by the existing enterprise and cloud compose files, with no docker-compose.override.yml: the license is a single SHELLHUB_LICENSE_FILE var and GeoIP is disabled with an empty SHELLHUB_MAXMIND_MIRROR. Gated steps degrade gracefully on plain CE. --- ui/apps/docs/package.json | 8 +- ui/apps/docs/screenshots/.gitignore | 5 + ui/apps/docs/screenshots/README.md | 240 ++++++++++++++++ ui/apps/docs/screenshots/auth.mjs | 84 ++++++ ui/apps/docs/screenshots/capture.mjs | 219 ++++++++++++++ ui/apps/docs/screenshots/config.mjs | 93 ++++++ ui/apps/docs/screenshots/env.override.example | 41 +++ ui/apps/docs/screenshots/lib.mjs | 142 +++++++++ ui/apps/docs/screenshots/manifest.mjs | 173 +++++++++++ ui/apps/docs/screenshots/seed.mjs | 269 ++++++++++++++++++ ui/package-lock.json | 16 ++ 11 files changed, 1289 insertions(+), 1 deletion(-) create mode 100644 ui/apps/docs/screenshots/.gitignore create mode 100644 ui/apps/docs/screenshots/README.md create mode 100644 ui/apps/docs/screenshots/auth.mjs create mode 100644 ui/apps/docs/screenshots/capture.mjs create mode 100644 ui/apps/docs/screenshots/config.mjs create mode 100644 ui/apps/docs/screenshots/env.override.example create mode 100644 ui/apps/docs/screenshots/lib.mjs create mode 100644 ui/apps/docs/screenshots/manifest.mjs create mode 100644 ui/apps/docs/screenshots/seed.mjs diff --git a/ui/apps/docs/package.json b/ui/apps/docs/package.json index 3ae8aac823a..0a26214fbd6 100644 --- a/ui/apps/docs/package.json +++ b/ui/apps/docs/package.json @@ -10,7 +10,10 @@ "test": "vitest run --passWithNoTests", "test:watch": "vitest", "sync": "astro sync", - "lint": "eslint \"src/**/*.ts\"" + "lint": "eslint \"src/**/*.ts\"", + "shots:seed": "node screenshots/seed.mjs", + "shots:capture": "node screenshots/capture.mjs", + "shots": "node screenshots/seed.mjs && node screenshots/capture.mjs" }, "dependencies": { "@astrojs/mdx": "^4.3.0", @@ -22,5 +25,8 @@ "astro": "^5.7.5", "react": "^19.1.0", "react-dom": "^19.1.0" + }, + "devDependencies": { + "playwright-core": "^1.49.0" } } diff --git a/ui/apps/docs/screenshots/.gitignore b/ui/apps/docs/screenshots/.gitignore new file mode 100644 index 00000000000..82d1b1fcfcd --- /dev/null +++ b/ui/apps/docs/screenshots/.gitignore @@ -0,0 +1,5 @@ +# Playwright auth state (contains a session token — never commit) +.state.json + +# Local deps if installed here +node_modules diff --git a/ui/apps/docs/screenshots/README.md b/ui/apps/docs/screenshots/README.md new file mode 100644 index 00000000000..837a0e75b33 --- /dev/null +++ b/ui/apps/docs/screenshots/README.md @@ -0,0 +1,240 @@ +# Docs screenshot toolkit + +Reusable automation that seeds demo data and re-captures the dashboard +screenshots used throughout the documentation. Re-run it whenever the UI +changes instead of hand-rebuilding shots. + +It drives a headless Chromium (via `playwright-core`) against a local ShellHub +dev environment, browsing through the **branded host** `shellhub.example.com` so +the UI rebrands itself automatically (SSHIDs, agent install command, etc.) with +no backend change. + +> **No `/etc/hosts` entry needed.** The browser reaches the branded host because +> Chromium maps it to `127.0.0.1` via `--host-resolver-rules` — that mapping is +> internal to Chromium and does not affect Node. Node-side code (seeding, dynamic +> route token lookups) uses its own `fetch`, which ignores that mapping, so it +> talks to the REST API on `localhost` (`SHOTS_API_URL`, default +> `http://localhost`) instead. The REST API does not affect branding, so this +> split is safe: the browser stays branded while Node stays resolvable. + +> ## ⚠️ Never commit your license +> +> The enterprise license is **private**. Never commit a license file, never put +> a real license path into any tracked file. Keep its path in your gitignored +> `shellhub/.env.override` (see `env.override.example`) and keep the license +> itself outside the repo. + +--- + +## Layout + +``` +screenshots/ +├── config.mjs # central config from env vars (+ required secrets) +├── lib.mjs # shared helpers (browser, api, overlay, key/MAC gen) +├── auth.mjs # ensureAuth(): login -> .state.json (gitignored) +├── seed.mjs # idempotent data seeding (npm run shots:seed) +├── manifest.mjs # declarative list of shots +├── capture.mjs # main runner (npm run shots:capture) +├── env.override.example # enterprise dev settings for .env.override (TEMPLATE) +├── README.md +└── .gitignore # .state.json, node_modules +``` + +--- + +## Prerequisites + +- Node 20+ (uses global `fetch` and top-level `await`). +- `playwright-core` installed (declared as a devDependency of + `@shellhub/docs`). Run `npm install` in `ui/apps/docs/`. +- A Chromium/Chrome binary. Point `SHOTS_CHROMIUM` at it (e.g. system Chromium, + or `npx playwright install chromium` and use the printed path). +- A running ShellHub dev environment (`./bin/docker-compose up`). +- `ssh-keygen` (soft dependency, for the public-key seed). If missing, that one + seed step is skipped with a warning. + +--- + +## Environment variables + +| Variable | Required | Default | Purpose | +| ------------------- | -------- | ------------------------------------------ | ---------------------------------------------------- | +| `SHOTS_CHROMIUM` | **yes** | — | Path to the Chromium/Chrome executable. | +| `SHOTS_PASSWORD` | **yes** | — | Password of the login user (no insecure default). | +| `SHOTS_USERNAME` | no | `dev` | Login user. | +| `SHOTS_DOMAIN` | no | `shellhub.example.com` | Branded host (mapped to 127.0.0.1). | +| `SHOTS_BASE_URL` | no | `http://shellhub.example.com` | UI base URL (browser navigates this branded host). | +| `SHOTS_API_URL` | no | `http://localhost` | REST API base for Node-side seeding/queries — must be directly resolvable, unlike the browser's branded host. | +| `SHOTS_NAMESPACE` | no | `acme` | Namespace name to set. | +| `SHOTS_TENANT` | no | `00000000-0000-4000-0000-000000000000` | Dev tenant id. | +| `SHOTS_OUTPUT_DIR` | no | `../public/img` | Where PNGs are written. | + +Never set a password or license path inside a tracked file — pass them via the +environment only. + +--- + +## Step by step + +### a) Bring up the dev env and create the `dev` user / namespace + +```bash +./bin/docker-compose up -d +# Create the login user (pick your own password): +./bin/cli user create dev 'your-dev-password' dev@local +# Sign in once in the browser to create the namespace, or let the UI prompt you. +``` + +Then export the required secrets: + +```bash +export SHOTS_CHROMIUM="$(which chromium)" # or a playwright-installed chromium +export SHOTS_PASSWORD='your-dev-password' +``` + +### b) Enterprise screens (firewall, web endpoints, admin auth, recording) + +The enterprise shots are skipped automatically on plain CE (the toolkit detects +402/404/405 and moves on). To capture them you need the enterprise features +enabled with **your own** license. + +Everything is driven by environment variables in your gitignored +`shellhub/.env.override` — no `docker-compose.override.yml` needed. Append the +template settings (see `env.override.example`) and point `SHELLHUB_LICENSE_FILE` +at your own license: + +```bash +# Append the enterprise settings to your (gitignored) override file: +cat ui/apps/docs/screenshots/env.override.example >> .env.override +# Then edit .env.override and set SHELLHUB_LICENSE_FILE to YOUR license path. +# NEVER commit .env.override or your license. (Both are gitignored / private.) +./bin/docker-compose up -d +``` + +This enables `SHELLHUB_ENTERPRISE=true`, `SHELLHUB_WEB_ENDPOINTS=true`, the +branded `SHELLHUB_DOMAIN`, and — via the env-driven mount in +`cloud/docker-compose.enterprise.dev.yml` — mounts your license at +`/etc/shellhub/license.dat` and sets `ADMIN_API_LICENSE_FILE` accordingly. The +api loads the license once at startup; it then persists in the database. + +> **GeoIP needs no mounts.** `cloud/.env` ships a default `MAXMIND_MIRROR` that +> isn't reachable for local dev, and the enterprise api FATALs at boot if its +> configured GeoIP source can't be fetched. The template sets +> `SHELLHUB_MAXMIND_MIRROR=` (empty) so the api boots with the GeoIP locator +> **disabled** — geo lookups are unused by the screenshots (sessions are local). +> For real geo data instead, drop that line and set `SHELLHUB_MAXMIND_LICENSE` +> to your free MaxMind license key. + +### c) Seed demo data + +```bash +npm run shots:seed # in ui/apps/docs/ +``` + +Idempotent. Renames the namespace to `acme`, registers/accepts a set of fake +devices (leaving one pending), adds a sample public key, and — when enterprise +is enabled — firewall rules and a web endpoint. Gated steps log +`skipped (needs enterprise license / feature flag)` and are not fatal. + +### d) Capture + +```bash +npm run shots:capture # all shots +npm run shots:capture -- --ce-only +npm run shots:capture -- --only=device-list,dashboard +npm run shots # seed + capture in one go +``` + +PNGs are written to `public/img/...` (1440×900, deviceScaleFactor 2, dark +theme). A summary of captured / skipped / errored shots is printed at the end. + +--- + +## Page map (old → new) and output files + +| Shot id | Route | Output (`public/img/…`) | Edition | +| ---------------------- | ---------------------------------- | ---------------------------------- | ----------- | +| `dashboard` | `/dashboard` | `getting-started/dashboard.png` | ce | +| `device-list` | `/devices` | `devices/device-list.png` | ce | +| `add-device` | `/devices/add` | `devices/add-device.png` | ce | +| `devices-pending` | `/devices?tab=pending` | `devices/devices-pending.png` | ce | +| `device-details` | `/devices/:device` | `devices/device-details.png` | ce | +| `public-keys` | `/sshkeys/public-keys` | `public-keys/public-keys.png` | ce | +| `firewall-rules` | `/firewall-rules` | `firewall/firewall-rules.png` | enterprise | +| `firewall-add-rule` | `/firewall-rules` (+click) | `firewall/add-rule.png` | enterprise | +| `sessions-list` | `/sessions` | `sessions/sessions-list.png` | ce | +| `session-detail` | `/sessions/:session` | `sessions/session-detail.png` | ce | +| `session-recording` | `/sessions/:session` (+Play) | `sessions/session-recording.png` | enterprise | +| `namespace-settings` | `/settings` | `settings/namespace-settings.png` | ce | +| `members` | `/team` | `team/members.png` | ce | +| `profile` | `/profile` | `account/profile.png` | ce | +| `mfa-enable` | `/profile` (+Enable MFA) | `account/mfa-enable.png` | ce | +| `admin-authentication` | `/admin/settings/authentication` | `auth/admin-authentication.png` | enterprise | +| `web-endpoints` | `/web-endpoints` | `web-endpoints/web-endpoints.png` | enterprise | +| `add-docker-host` | `/containers` (+Add Docker Host) | `containers/add-docker-host.png` | ce | + +The `:device` / `:session` tokens are resolved at capture time to the first +accepted device / most recent session uid. + +### SSHID format + +ShellHub SSHIDs are `.@` — e.g. +`acme.rpi-edge-01@shellhub.example.com`. Because the UI derives this from +`window.location`, browsing via the branded host produces correctly branded +SSHIDs in every screenshot. + +--- + +## Members (manual prerequisite) + +Members must reference **existing** users — the seed script does not create +them (that needs the CLI). To populate the team members screen: + +```bash +./bin/cli user create alice 'pw' alice@acme.test +./bin/cli user create bob 'pw' bob@acme.test +``` + +Then add them to the namespace (roles: `administrator` | `operator` | +`observer`): + +``` +POST /api/namespaces//members { "email": "alice@acme.test", "role": "operator" } +``` + +--- + +## Capturing sessions and recordings (manual / optional) + +The `session-detail` and `session-recording` shots need a **real** session in +the database. A live SSH session requires an online agent, an authorized SSH +key, and the deny-root firewall rule temporarily removed — this is hard to do +reliably from Node, so `seed.mjs` does **not** attempt it. + +To produce a session manually: + +1. Make sure an agent is online (e.g. `shellhub-agent-1`) and accepted (named + `rpi-edge-01` by the seed). +2. Authorize an SSH public key for it (Public Keys screen or the seeded key) and + temporarily disable/remove the deny-`root` firewall rule. +3. Open a PTY session (recording requires a PTY — the `-tt` flag): + + ```bash + ssh -tt -i root@acme.rpi-edge-01@shellhub.example.com + ``` + + Type a few commands, then exit. The session (and, on enterprise with + recording enabled, the recording) will now appear under `/sessions`. + +4. Re-run `npm run shots:capture -- --only=session-detail,session-recording`. + +--- + +## Known skips / TODOs + +- **Device tags**: the tags endpoints returned 404/405 in testing; tagging is a + documented skip in `seed.mjs` (see the `TODO(tags)` note). Not blocking. +- **Enterprise-gated endpoints** (firewall, web endpoints) are skipped + gracefully on CE. +- **Sessions / recordings** are a documented manual step (above). diff --git a/ui/apps/docs/screenshots/auth.mjs b/ui/apps/docs/screenshots/auth.mjs new file mode 100644 index 00000000000..db339b37018 --- /dev/null +++ b/ui/apps/docs/screenshots/auth.mjs @@ -0,0 +1,84 @@ +// Authentication: log into the branded UI and persist a Playwright +// storageState so the capture run starts already signed in. +// +// The app stores its session under the localStorage key `shellhub-session`, +// which storageState captures automatically. + +import fs from "node:fs"; + +import { + BASE_URL, + PASSWORD, + STATE_MAX_AGE_MS, + STATE_PATH, + USERNAME, +} from "./config.mjs"; +import { contextOptions, launchBrowser } from "./lib.mjs"; + +/** True if a cached storageState exists and is younger than STATE_MAX_AGE_MS. */ +function freshStateExists() { + try { + const stat = fs.statSync(STATE_PATH); + return Date.now() - stat.mtimeMs < STATE_MAX_AGE_MS; + } catch { + return false; + } +} + +/** + * Ensure a valid auth state exists at STATE_PATH. + * + * Reuses a fresh cached state when present (unless `force` is true), otherwise + * performs an interactive login through the branded host and writes the state. + * + * @param {{ force?: boolean }} [options] + * @returns {Promise} the path to the storageState file + */ +export async function ensureAuth({ force = false } = {}) { + if (!force && freshStateExists()) { + console.log(`auth: reusing cached state (${STATE_PATH})`); + return STATE_PATH; + } + + console.log(`auth: logging in as ${USERNAME} at ${BASE_URL}/login`); + const browser = await launchBrowser(); + try { + const context = await browser.newContext(contextOptions()); + const page = await context.newPage(); + + await page.goto(`${BASE_URL}/login`, { + waitUntil: "networkidle", + timeout: 30000, + }); + + await page.getByPlaceholder("username").fill(USERNAME); + await page.getByPlaceholder("password").fill(PASSWORD); + + await Promise.all([ + page + .waitForURL((url) => url.pathname.startsWith("/dashboard"), { + timeout: 30000, + }) + .catch(() => {}), + page.getByRole("button", { name: /sign in/i }).click(), + ]); + + if (!page.url().includes("/dashboard")) { + throw new Error( + `Login did not reach /dashboard (ended at ${page.url()}). ` + + "Check credentials and that the namespace exists.", + ); + } + + await context.storageState({ path: STATE_PATH }); + console.log(`auth: saved state to ${STATE_PATH}`); + return STATE_PATH; + } finally { + await browser.close(); + } +} + +// Allow running standalone: `node auth.mjs [--force]` +if (import.meta.url === `file://${process.argv[1]}`) { + await ensureAuth({ force: process.argv.includes("--force") }); +} diff --git a/ui/apps/docs/screenshots/capture.mjs b/ui/apps/docs/screenshots/capture.mjs new file mode 100644 index 00000000000..12f8814ccc1 --- /dev/null +++ b/ui/apps/docs/screenshots/capture.mjs @@ -0,0 +1,219 @@ +// Main screenshot runner. +// +// Flow: ensureAuth() -> load manifest -> launch browser (branded host mapped to +// localhost) -> iterate shots -> write PNGs to OUTPUT_DIR -> print a summary. +// +// Flags: +// --ce-only skip shots with edition:'enterprise' +// --only= capture only the listed manifest ids +// +// Usage: node capture.mjs [--ce-only] [--only=device-list,dashboard] + +import fs from "node:fs"; +import path from "node:path"; + +import { ensureAuth } from "./auth.mjs"; +import { + API, + BASE_URL, + OUTPUT_DIR, + PASSWORD, + STATE_PATH, + USERNAME, +} from "./config.mjs"; +import { + api, + contextOptions, + launchBrowser, + login, + removeDevOverlay, + sleep, +} from "./lib.mjs"; +import manifest from "./manifest.mjs"; + +const DEFAULT_SETTLE_MS = 1500; + +// --- CLI args --------------------------------------------------------------- + +function parseArgs(argv) { + const ceOnly = argv.includes("--ce-only"); + const onlyArg = argv.find((a) => a.startsWith("--only=")); + const only = onlyArg + ? onlyArg + .slice("--only=".length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : null; + return { ceOnly, only }; +} + +function selectShots({ ceOnly, only }) { + let shots = manifest; + if (ceOnly) shots = shots.filter((s) => s.edition !== "enterprise"); + if (only) shots = shots.filter((s) => only.includes(s.id)); + return shots; +} + +// --- Dynamic route tokens --------------------------------------------------- + +/** + * Resolve :device / :session tokens to real uids via the API. Returns a map of + * token -> uid (or null when unavailable). Done once up front so we don't query + * per shot. + */ +async function resolveTokens(shots) { + const needsDevice = shots.some((s) => s.route.includes(":device")); + const needsSession = shots.some((s) => s.route.includes(":session")); + const tokens = {}; + + if (!needsDevice && !needsSession) return tokens; + + const request = api(null); + const token = await login(request, USERNAME, PASSWORD); + const authed = api(token); + + if (needsDevice) { + const res = await authed("GET", "/devices?status=accepted"); + tokens[":device"] = Array.isArray(res.data) ? res.data[0]?.uid : null; + } + if (needsSession) { + const res = await authed("GET", "/sessions"); + tokens[":session"] = Array.isArray(res.data) ? res.data[0]?.uid : null; + } + return tokens; +} + +function applyTokens(route, tokens) { + for (const [token, value] of Object.entries(tokens)) { + if (route.includes(token)) { + if (!value) return null; // unresolved -> caller should skip + route = route.replace(token, value); + } + } + return route; +} + +// --- Capture ---------------------------------------------------------------- + +async function captureShot(page, shot, tokens) { + const route = applyTokens(shot.route, tokens); + if (route === null) { + return { id: shot.id, status: "skipped", reason: "no data for dynamic route" }; + } + + await page.goto(`${BASE_URL}${route}`, { + waitUntil: "networkidle", + timeout: 30000, + }); + + if (shot.waitForText) { + const found = await page + .getByText(shot.waitForText, { exact: false }) + .first() + .waitFor({ timeout: 15000 }) + .then(() => true) + .catch(() => false); + if (!found) { + console.log( + `capture: warn ${shot.id} — waitForText "${shot.waitForText}" not found, proceeding`, + ); + } + } + + await sleep(shot.waitMs ?? DEFAULT_SETTLE_MS); + + if (shot.click) { + const clicked = await page + .getByRole("button", { name: shot.click, exact: false }) + .first() + .click({ timeout: 8000 }) + .then(() => true) + .catch(() => + page + .getByText(shot.click, { exact: false }) + .first() + .click({ timeout: 5000 }) + .then(() => true) + .catch(() => false), + ); + if (!clicked) { + return { + id: shot.id, + status: "skipped", + reason: `click target not found: "${shot.click}"`, + }; + } + await sleep(shot.waitMs ?? DEFAULT_SETTLE_MS); + } + + await removeDevOverlay(page); + + const outPath = path.join(OUTPUT_DIR, shot.output); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + await page.screenshot({ path: outPath }); + + return { id: shot.id, status: "ok", output: shot.output }; +} + +// --- Main ------------------------------------------------------------------- + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const shots = selectShots(args); + + if (shots.length === 0) { + console.log("capture: no shots selected."); + return; + } + + console.log(`capture: ${shots.length} shot(s) via ${BASE_URL} (api: ${API})`); + if (args.ceOnly) console.log("capture: --ce-only (skipping enterprise shots)"); + + await ensureAuth(); + + const tokens = await resolveTokens(shots); + + const browser = await launchBrowser(); + const results = []; + try { + const context = await browser.newContext( + contextOptions({ storageState: STATE_PATH }), + ); + const page = await context.newPage(); + // Tailwind media dark mode needs this on the PAGE, not just the context. + await page.emulateMedia({ colorScheme: "dark" }); + + for (const shot of shots) { + try { + const result = await captureShot(page, shot, tokens); + results.push(result); + const tag = result.status === "ok" ? "shot" : "skip"; + console.log( + `capture: ${tag} ${shot.id}` + + (result.reason ? ` (${result.reason})` : ` -> ${result.output}`), + ); + } catch (err) { + results.push({ id: shot.id, status: "error", reason: err.message }); + console.log(`capture: FAIL ${shot.id} (${err.message})`); + } + } + } finally { + await browser.close(); + } + + // --- Summary -------------------------------------------------------------- + const ok = results.filter((r) => r.status === "ok").length; + const skipped = results.filter((r) => r.status === "skipped").length; + const errored = results.filter((r) => r.status === "error").length; + + console.log("\ncapture summary:"); + console.log(` captured: ${ok}`); + console.log(` skipped: ${skipped}`); + console.log(` errors: ${errored}`); + console.log(` output: ${OUTPUT_DIR}`); + + if (errored > 0) process.exitCode = 1; +} + +await main(); diff --git a/ui/apps/docs/screenshots/config.mjs b/ui/apps/docs/screenshots/config.mjs new file mode 100644 index 00000000000..2191cc7a451 --- /dev/null +++ b/ui/apps/docs/screenshots/config.mjs @@ -0,0 +1,93 @@ +// Central configuration for the docs screenshot toolkit. +// +// Everything is driven by environment variables with sane defaults so the +// toolkit can run unattended in CI or locally. Two values are intentionally +// REQUIRED (no insecure defaults): the login password and the Chromium binary +// path. +// +// NOTE: never put a real password or a license path in here. Pass secrets via +// the environment only. + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** Resolve a path relative to the screenshots/ directory. */ +export const resolveFromHere = (p) => path.resolve(__dirname, p); + +/** Read a required env var or throw a helpful error. */ +function requireEnv(name, hint) { + const value = process.env[name]; + if (!value) { + throw new Error( + `Missing required environment variable ${name}.\n${hint}`, + ); + } + return value; +} + +// --- Target / branding ------------------------------------------------------ + +// The branded host. The UI derives SSHIDs and the agent install command from +// window.location, so capturing through the branded host rebrands the whole UI +// without any backend change. lib.launchBrowser() maps this domain to +// 127.0.0.1 via Chromium's --host-resolver-rules. +export const DOMAIN = process.env.SHOTS_DOMAIN || "shellhub.example.com"; +export const BASE_URL = process.env.SHOTS_BASE_URL || `http://${DOMAIN}`; + +// Two different worlds reach the backend: +// - The BROWSER navigates BASE_URL (the branded host). Chromium's +// --host-resolver-rules maps that host to 127.0.0.1, so no /etc/hosts entry +// is needed for page.goto(). +// - Node's global fetch() (seed.mjs, capture.mjs token resolution) does NOT +// honor the browser's host-resolver, so it must hit a host the OS can +// actually resolve. Hence API_URL defaults to localhost. The REST API does +// not affect branding (SSHIDs come from window.location in the UI), so +// Node can safely talk to localhost while the browser uses the branded host. +export const API_URL = process.env.SHOTS_API_URL || "http://localhost"; +export const API = `${API_URL}/api`; + +export const NAMESPACE = process.env.SHOTS_NAMESPACE || "acme"; +export const TENANT = + process.env.SHOTS_TENANT || "00000000-0000-4000-0000-000000000000"; + +// --- Credentials ------------------------------------------------------------ + +export const USERNAME = process.env.SHOTS_USERNAME || "dev"; + +// No insecure hardcoded default: the password must come from the environment. +export const PASSWORD = requireEnv( + "SHOTS_PASSWORD", + "Set it to the password of the dev user, e.g.:\n" + + " export SHOTS_PASSWORD='your-dev-password'\n" + + "Create the user first with: ./bin/cli user create dev dev@local", +); + +// --- Browser ---------------------------------------------------------------- + +// Path to a Chromium/Chrome executable usable by playwright-core. Required so we +// never hardcode a /nix/store (or any machine-specific) path. +export const CHROMIUM = requireEnv( + "SHOTS_CHROMIUM", + "Point it at a Chromium/Chrome binary, e.g.:\n" + + " export SHOTS_CHROMIUM=\"$(which chromium)\"\n" + + " # or the path printed by: npx playwright install chromium", +); + +export const VIEWPORT = { width: 1440, height: 900 }; +export const DEVICE_SCALE_FACTOR = 2; + +// --- Output / state --------------------------------------------------------- + +// Where PNGs are written. Defaults to the docs app's committed image folder so +// re-running the toolkit refreshes the shots in place. +export const OUTPUT_DIR = process.env.SHOTS_OUTPUT_DIR + ? path.resolve(process.env.SHOTS_OUTPUT_DIR) + : resolveFromHere("../public/img"); + +// Playwright storageState cache (gitignored). +export const STATE_PATH = resolveFromHere(".state.json"); + +// Reuse a cached auth state if it is younger than this many milliseconds. +export const STATE_MAX_AGE_MS = 1000 * 60 * 60 * 6; // 6 hours diff --git a/ui/apps/docs/screenshots/env.override.example b/ui/apps/docs/screenshots/env.override.example new file mode 100644 index 00000000000..161006b2e9f --- /dev/null +++ b/ui/apps/docs/screenshots/env.override.example @@ -0,0 +1,41 @@ +# ============================================================================ +# Docs screenshots — enterprise dev settings (env-var TEMPLATE) +# ============================================================================ +# +# Append these to shellhub/.env.override (the repo-root override file, which is +# GITIGNORED) to enable the enterprise features needed for the enterprise +# screenshots. No docker-compose.override.yml is required — every setting below +# is consumed by the existing enterprise/cloud compose files via env-var +# interpolation. +# +# !! NEVER COMMIT YOUR LICENSE FILE OR YOUR .env.override !! +# !! The license is private. Keep its path out of git entirely. !! +# +# After appending, run `./bin/docker-compose up -d` from the repo root. +# ============================================================================ + +SHELLHUB_ENV=development +SHELLHUB_ENTERPRISE=true + +# Branded domain (the screenshot browser maps it to 127.0.0.1 via +# --host-resolver-rules, so the UI rebrands SSHIDs / install command / etc.). +SHELLHUB_DOMAIN=shellhub.example.com + +# Enterprise web endpoints / tunnels. +SHELLHUB_WEB_ENDPOINTS=true +SHELLHUB_WEB_ENDPOINTS_DOMAIN=shellhub.example.com + +# Path to YOUR OWN enterprise license file on the host. Drives the env-driven +# mount in cloud/docker-compose.enterprise.dev.yml — the api mounts it read-only +# and loads it once at startup (it then persists in the database). Leave unset +# to run plain enterprise dev without Pro features (firewall/web-endpoints will +# return 402 and the toolkit skips those shots gracefully). +SHELLHUB_LICENSE_FILE=/path/to/your-license.dat + +# GeoIP: disable it for docs dev. cloud/.env ships a default MAXMIND_MIRROR that +# is not reachable for local dev, and the enterprise api FATALs at boot if its +# configured GeoIP source can't be fetched. Setting the mirror empty here makes +# the api boot with the locator disabled (geo lookups are unused by the +# screenshots — sessions are local). To get real geo data instead, leave this +# unset and set SHELLHUB_MAXMIND_LICENSE=. +SHELLHUB_MAXMIND_MIRROR= diff --git a/ui/apps/docs/screenshots/lib.mjs b/ui/apps/docs/screenshots/lib.mjs new file mode 100644 index 00000000000..4c58df19065 --- /dev/null +++ b/ui/apps/docs/screenshots/lib.mjs @@ -0,0 +1,142 @@ +// Shared helpers for the docs screenshot toolkit. + +import crypto from "node:crypto"; +import { chromium } from "playwright-core"; + +import { + API, + CHROMIUM, + DEVICE_SCALE_FACTOR, + DOMAIN, + VIEWPORT, +} from "./config.mjs"; + +/** + * Launch a headless Chromium suitable for capturing the dashboard. + * + * The --host-resolver-rules arg makes the branded DOMAIN resolve to localhost + * so the UI rebrands itself (SSHIDs, install command) from window.location. + */ +export async function launchBrowser() { + return chromium.launch({ + executablePath: CHROMIUM, + args: [ + "--no-sandbox", + "--disable-dev-shm-usage", + `--host-resolver-rules=MAP ${DOMAIN} 127.0.0.1`, + ], + }); +} + +/** Standard browser context options shared by auth + capture. */ +export function contextOptions(extra = {}) { + return { + viewport: VIEWPORT, + deviceScaleFactor: DEVICE_SCALE_FACTOR, + // colorScheme here is not sufficient for Tailwind media dark mode; callers + // must ALSO call page.emulateMedia({ colorScheme: 'dark' }) on each page. + colorScheme: "dark", + ...extra, + }; +} + +/** + * Minimal fetch wrapper for the ShellHub REST API. + * + * Returns a function `request(method, path, body?)` that resolves to + * `{ status, ok, data }`. Never throws on non-2xx so callers can branch on + * enterprise-gated status codes (402/404/405). + */ +export function api(token) { + return async function request(method, path, body) { + const headers = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${API}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + let data = null; + const text = await res.text(); + if (text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + + return { status: res.status, ok: res.ok, data }; + }; +} + +/** Log in and return a bearer token (throws on failure). */ +export async function login(request, username, password) { + const res = await request("POST", "/login", { username, password }); + if (!res.ok || !res.data?.token) { + throw new Error( + `Login failed (status ${res.status}). Check SHOTS_USERNAME / SHOTS_PASSWORD ` + + "and that the dev user exists (./bin/cli user create ...).", + ); + } + return res.data.token; +} + +/** + * Remove dev-only fixed overlays before a screenshot. + * + * The TanStack Query devtools button renders into a shadow DOM host + * ([class*="tsqd"]); we remove the host element. We also defensively strip any + * tiny high-z-index fixed widget pinned to the bottom of the viewport (chat + * bubbles, etc.). + */ +export async function removeDevOverlay(page) { + await page + .evaluate(() => { + document + .querySelectorAll( + '[class*="tsqd"], .tsqd-open-btn-container, .tsqd-parent-container', + ) + .forEach((el) => el.remove()); + + for (const el of document.querySelectorAll("body *")) { + const style = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + if ( + style.position === "fixed" && + parseInt(style.zIndex || "0", 10) >= 99999 && + rect.width < 120 && + rect.bottom > 700 + ) { + el.remove(); + } + } + }) + .catch(() => {}); +} + +/** + * Generate an RSA public key in PEM form for a fake device's `public_key` + * field. ShellHub's /devices/auth accepts a PKCS#1 / SubjectPublicKeyInfo PEM + * here. Uses Node crypto only (no openssl dependency). + */ +export function genDevicePublicKeyPem() { + const { publicKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + return publicKey.export({ type: "spki", format: "pem" }).toString(); +} + +/** Generate a locally-administered random MAC address (02:xx:xx:xx:xx:xx). */ +export function randomMac() { + const octet = () => + Math.floor(Math.random() * 256) + .toString(16) + .padStart(2, "0"); + return `02:${octet()}:${octet()}:${octet()}:${octet()}:${octet()}`; +} + +/** Small sleep helper. */ +export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/ui/apps/docs/screenshots/manifest.mjs b/ui/apps/docs/screenshots/manifest.mjs new file mode 100644 index 00000000000..b718eac4b33 --- /dev/null +++ b/ui/apps/docs/screenshots/manifest.mjs @@ -0,0 +1,173 @@ +// Declarative list of screenshots to capture. +// +// Each entry: +// id unique slug, also usable with capture.mjs --only= +// route in-app path to visit (relative to BASE_URL). May contain the +// token ":device" or ":session", resolved at capture time to the +// first accepted device / most recent session uid. +// output path of the PNG relative to OUTPUT_DIR (matches the filenames +// already committed under public/img/). +// edition 'ce' (works on Community Edition) or 'enterprise' +// (license/flag-gated; skipped with --ce-only). +// waitForText optional substring to wait for before shooting (stability). +// click optional accessible-name / text to click before shooting +// (best-effort; failures are logged, not fatal). +// waitMs optional extra settle time in ms (default applied in capture). +// +// Output names were matched exactly against the committed files under +// ui/apps/docs/public/img/. + +export const manifest = [ + // --- Getting started ------------------------------------------------------ + { + id: "dashboard", + route: "/dashboard", + output: "getting-started/dashboard.png", + edition: "ce", + waitForText: "Dashboard", + }, + + // --- Devices -------------------------------------------------------------- + { + id: "device-list", + route: "/devices", + output: "devices/device-list.png", + edition: "ce", + waitForText: "Devices", + }, + { + id: "add-device", + route: "/devices/add", + output: "devices/add-device.png", + edition: "ce", + }, + { + id: "devices-pending", + route: "/devices?tab=pending", + output: "devices/devices-pending.png", + edition: "ce", + waitForText: "Pending", + }, + { + id: "device-details", + route: "/devices/:device", + output: "devices/device-details.png", + edition: "ce", + }, + + // --- Public keys ---------------------------------------------------------- + { + id: "public-keys", + route: "/sshkeys/public-keys", + output: "public-keys/public-keys.png", + edition: "ce", + waitForText: "Public Keys", + }, + + // --- Firewall (enterprise) ------------------------------------------------ + { + id: "firewall-rules", + route: "/firewall-rules", + output: "firewall/firewall-rules.png", + edition: "enterprise", + waitForText: "Firewall", + }, + { + id: "firewall-add-rule", + route: "/firewall-rules", + output: "firewall/add-rule.png", + edition: "enterprise", + click: "Add Rule", + }, + + // --- Sessions ------------------------------------------------------------- + { + id: "sessions-list", + route: "/sessions", + output: "sessions/sessions-list.png", + edition: "ce", + waitForText: "Sessions", + }, + { + id: "session-detail", + route: "/sessions/:session", + output: "sessions/session-detail.png", + edition: "ce", + }, + { + // Recording playback: open a session, click "Play Recording". + // Requires a recorded session to exist (enterprise + a real PTY session). + id: "session-recording", + route: "/sessions/:session", + output: "sessions/session-recording.png", + edition: "enterprise", + click: "Play Recording", + waitMs: 2500, + }, + + // --- Settings ------------------------------------------------------------- + { + id: "namespace-settings", + route: "/settings", + output: "settings/namespace-settings.png", + edition: "ce", + waitForText: "Settings", + }, + + // --- Team ----------------------------------------------------------------- + { + id: "members", + route: "/team", + output: "team/members.png", + edition: "ce", + waitForText: "Members", + }, + + // --- Account -------------------------------------------------------------- + { + id: "profile", + route: "/profile", + output: "account/profile.png", + edition: "ce", + waitForText: "Profile", + }, + { + id: "mfa-enable", + route: "/profile", + output: "account/mfa-enable.png", + edition: "ce", + click: "Enable MFA", + waitMs: 1500, + }, + + // --- Admin (enterprise) --------------------------------------------------- + { + id: "admin-authentication", + route: "/admin/settings/authentication", + output: "auth/admin-authentication.png", + edition: "enterprise", + waitForText: "Authentication", + }, + + // --- Web endpoints (enterprise) ------------------------------------------- + { + id: "web-endpoints", + route: "/web-endpoints", + output: "web-endpoints/web-endpoints.png", + edition: "enterprise", + waitForText: "Web Endpoints", + }, + + // --- Containers ----------------------------------------------------------- + { + // The "Add Docker Host" connector drawer is opened from the containers page. + id: "add-docker-host", + route: "/containers", + output: "containers/add-docker-host.png", + edition: "ce", + click: "Add Docker Host", + waitMs: 1500, + }, +]; + +export default manifest; diff --git a/ui/apps/docs/screenshots/seed.mjs b/ui/apps/docs/screenshots/seed.mjs new file mode 100644 index 00000000000..bdc6a04431a --- /dev/null +++ b/ui/apps/docs/screenshots/seed.mjs @@ -0,0 +1,269 @@ +// Idempotent data seeding for the docs screenshots. +// +// Seeds (or refreshes) the demo data the screenshots depend on: +// - renames the dev namespace to `acme` +// - registers + accepts + names a handful of fake devices +// - (CE) a sample public key +// - (enterprise) firewall rules +// - (enterprise) a web endpoint / tunnel +// +// Enterprise-gated endpoints return 402 (no license) or 404/405 (feature flag +// off). We log "skipped" and continue, so this script also works against a +// plain Community Edition install. +// +// Usage: node seed.mjs +// +// Soft dependency: `ssh-keygen` (for the public-key seed). If it is missing we +// warn and skip that step. +// +// Manual / not seeded here: +// - device TAGS: the tags endpoints returned 404/405 in testing; treated as a +// known-unsupported skip (TODO below). +// - SSH SESSIONS + RECORDING: require a live PTY session against a real online +// agent; see README "Capturing sessions and recordings". + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; + +import { NAMESPACE, PASSWORD, TENANT, USERNAME } from "./config.mjs"; +import { + api, + genDevicePublicKeyPem, + login, + randomMac, +} from "./lib.mjs"; + +// Devices to present in the dashboard. `id`/`pretty_name` drive the OS icon. +const DEVICES = [ + { name: "web-server-01", os: "ubuntu" }, + { name: "db-primary", os: "ubuntu" }, + { name: "edge-gateway", os: "debian" }, + { name: "rpi-edge-01", os: "raspbian" }, + { name: "build-runner-03", os: "arch" }, +]; + +// Leave one device pending so the "pending devices" screenshot has content. +const PENDING_DEVICE = { name: "new-laptop", os: "ubuntu" }; + +const FIREWALL_RULES = [ + { + priority: 1, + action: "deny", + active: true, + source_ip: ".*", + username: "root", + filter: { hostname: ".*" }, + }, + { + priority: 2, + action: "allow", + active: true, + source_ip: "192.168.0.0/16", + username: ".*", + filter: { hostname: ".*" }, + }, +]; + +function logStep(msg) { + console.log(`seed: ${msg}`); +} + +/** Treat enterprise/feature-gated responses as a soft skip. */ +function isGated(status) { + return status === 402 || status === 404 || status === 405; +} + +// --- Namespace -------------------------------------------------------------- + +async function seedNamespace(request) { + const res = await request("PUT", `/namespaces/${TENANT}`, { + name: NAMESPACE, + }); + if (res.ok) { + logStep(`namespace renamed to "${NAMESPACE}"`); + } else { + logStep(`namespace rename returned ${res.status} (continuing)`); + } +} + +// --- Devices ---------------------------------------------------------------- + +/** Register a fake device and return its uid (or null on failure). */ +async function registerDevice(request, { name, os: prettyName }) { + const res = await request("POST", "/devices/auth", { + sessions: [], + info: { + id: prettyName, + pretty_name: prettyName, + version: "latest", + platform: "native", + }, + identity: { mac: randomMac() }, + public_key: genDevicePublicKeyPem(), + tenant_id: TENANT, + }); + + if (!res.ok || !res.data?.uid) { + logStep(`device "${name}" registration failed (${res.status})`); + return null; + } + return res.data.uid; +} + +async function clearDevices(request) { + for (const status of ["accepted", "pending", "rejected"]) { + const res = await request("GET", `/devices?status=${status}`); + if (!res.ok || !Array.isArray(res.data)) continue; + for (const device of res.data) { + if (device?.uid) await request("DELETE", `/devices/${device.uid}`); + } + } + logStep("cleared existing devices for a clean slate"); +} + +async function seedDevices(request) { + await clearDevices(request); + + for (const device of DEVICES) { + const uid = await registerDevice(request, device); + if (!uid) continue; + await request("PATCH", `/devices/${uid}/accept`); + await request("PUT", `/devices/${uid}`, { name: device.name }); + + // TODO(tags): the device tags endpoints returned 404/405 in testing, so + // tagging is intentionally skipped here. Re-enable once the API supports it: + // await request("POST", `/devices/${uid}/tags`, { tag: "production" }); + + logStep(`device "${device.name}" (${device.os}) accepted`); + } + + // A single pending device for the "pending" screenshot. + const pendingUid = await registerDevice(request, PENDING_DEVICE); + if (pendingUid) { + logStep(`device "${PENDING_DEVICE.name}" left pending`); + } +} + +// --- Public keys (CE) ------------------------------------------------------- + +/** Mint an OpenSSH public key line using ssh-keygen, or null if unavailable. */ +function generateOpenSshPublicKey() { + const tmp = `${os.tmpdir()}/shots-seed-key-${process.pid}`; + try { + execFileSync( + "ssh-keygen", + ["-t", "ed25519", "-N", "", "-C", "docs@acme", "-f", tmp], + { stdio: "ignore" }, + ); + const pub = fs.readFileSync(`${tmp}.pub`, "utf8").trim(); + return pub; + } catch { + return null; + } finally { + fs.rmSync(tmp, { force: true }); + fs.rmSync(`${tmp}.pub`, { force: true }); + } +} + +async function seedPublicKey(request) { + const sshLine = generateOpenSshPublicKey(); + if (!sshLine) { + logStep( + "public keys skipped (ssh-keygen not found — install openssh-client)", + ); + return; + } + + const data = Buffer.from(sshLine).toString("base64"); + const res = await request("POST", "/sshkeys/public-keys", { + name: "ops-laptop", + username: ".*", + data, + filter: { hostname: ".*" }, + }); + + if (res.ok) { + logStep('public key "ops-laptop" created'); + } else if (res.status === 409) { + logStep("public key already exists (skipping)"); + } else { + logStep(`public key creation returned ${res.status} (continuing)`); + } +} + +// --- Firewall rules (enterprise) -------------------------------------------- + +async function seedFirewallRules(request) { + let gated = false; + for (const rule of FIREWALL_RULES) { + const res = await request("POST", "/firewall/rules", rule); + if (res.ok) { + logStep(`firewall rule "${rule.action}" (priority ${rule.priority}) created`); + } else if (isGated(res.status)) { + gated = true; + break; + } else { + logStep(`firewall rule returned ${res.status} (continuing)`); + } + } + if (gated) { + logStep("firewall rules skipped (needs enterprise license / feature flag)"); + } +} + +// --- Web endpoints / tunnels (enterprise) ----------------------------------- + +async function seedWebEndpoint(request) { + // host MUST be a valid IP (not "localhost"). + const accepted = await request("GET", "/devices?status=accepted"); + const uid = Array.isArray(accepted.data) ? accepted.data[0]?.uid : null; + if (!uid) { + logStep("web endpoint skipped (no accepted device available)"); + return; + } + + const res = await request("POST", `/devices/${uid}/tunnels`, { + host: "127.0.0.1", + port: 8080, + ttl: 3600, + }); + + if (res.ok) { + logStep("web endpoint / tunnel created"); + } else if (isGated(res.status)) { + logStep( + "web endpoints skipped (needs enterprise license + SHELLHUB_WEB_ENDPOINTS=true)", + ); + } else { + logStep(`web endpoint returned ${res.status} (continuing)`); + } +} + +// --- Members (documented manual step) --------------------------------------- +// +// Members must reference EXISTING users. Create them first, e.g.: +// ./bin/cli user create alice alice@acme.test +// then add to the namespace via: +// POST /api/namespaces//members { email, role } role: administrator|operator|observer +// +// We do not create users from here (that requires the CLI), so member seeding +// is left as a documented manual step in the README. + +// --- Main ------------------------------------------------------------------- + +async function main() { + const request = api(null); + const token = await login(request, USERNAME, PASSWORD); + const authed = api(token); + + await seedNamespace(authed); + await seedDevices(authed); + await seedPublicKey(authed); + await seedFirewallRules(authed); + await seedWebEndpoint(authed); + + logStep("done"); +} + +await main(); diff --git a/ui/package-lock.json b/ui/package-lock.json index d68a9987e36..b6c5a0e3529 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -190,6 +190,9 @@ "astro": "^5.7.5", "react": "^19.1.0", "react-dom": "^19.1.0" + }, + "devDependencies": { + "playwright-core": "^1.49.0" } }, "apps/docs/node_modules/@types/react": { @@ -12207,6 +12210,19 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pngjs": { "version": "5.0.0", "license": "MIT",