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",