diff --git a/ui/apps/docs/astro.config.mjs b/ui/apps/docs/astro.config.mjs index 72b1ddf9fab..496835ae654 100644 --- a/ui/apps/docs/astro.config.mjs +++ b/ui/apps/docs/astro.config.mjs @@ -2,9 +2,10 @@ import { defineConfig } from "astro/config"; import mdx from "@astrojs/mdx"; import tailwind from "@astrojs/tailwind"; import react from "@astrojs/react"; +import { shots } from "./src/lib/shots/integration.ts"; export default defineConfig({ server: { port: 8083, host: true, allowedHosts: true }, devToolbar: { enabled: false }, - integrations: [mdx(), tailwind(), react()], + integrations: [mdx(), tailwind(), react(), shots()], }); diff --git a/ui/apps/docs/package.json b/ui/apps/docs/package.json index 4dffac094c6..7e04877521f 100644 --- a/ui/apps/docs/package.json +++ b/ui/apps/docs/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "astro dev", "build": "astro build", + "shots": "./scripts/capture-shots.sh", "preview": "astro preview", "test": "vitest run --passWithNoTests", "test:watch": "vitest", diff --git a/ui/apps/docs/public/img/auth/admin-authentication.png b/ui/apps/docs/public/img/auth/admin-authentication.png deleted file mode 100644 index 7a32e85e12f..00000000000 Binary files a/ui/apps/docs/public/img/auth/admin-authentication.png and /dev/null differ diff --git a/ui/apps/docs/public/img/containers/add-docker-host.png b/ui/apps/docs/public/img/containers/add-docker-host.png deleted file mode 100644 index e9e390d3134..00000000000 Binary files a/ui/apps/docs/public/img/containers/add-docker-host.png and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-details.png b/ui/apps/docs/public/img/devices/device-details.png deleted file mode 100644 index a2238cfccf2..00000000000 Binary files a/ui/apps/docs/public/img/devices/device-details.png and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-list.png b/ui/apps/docs/public/img/devices/device-list.png deleted file mode 100644 index 86bb1576d79..00000000000 Binary files a/ui/apps/docs/public/img/devices/device-list.png and /dev/null differ diff --git a/ui/apps/docs/public/img/getting-started/dashboard.png b/ui/apps/docs/public/img/getting-started/dashboard.png deleted file mode 100644 index 66222fb8cdd..00000000000 Binary files a/ui/apps/docs/public/img/getting-started/dashboard.png and /dev/null differ diff --git a/ui/apps/docs/public/img/devices/device-add.gif b/ui/apps/docs/public/img/manual/device-add.gif similarity index 100% rename from ui/apps/docs/public/img/devices/device-add.gif rename to ui/apps/docs/public/img/manual/device-add.gif diff --git a/ui/apps/docs/public/img/devices/device-connect.gif b/ui/apps/docs/public/img/manual/device-connect.gif similarity index 100% rename from ui/apps/docs/public/img/devices/device-connect.gif rename to ui/apps/docs/public/img/manual/device-connect.gif diff --git a/ui/apps/docs/public/img/devices/device-pending.gif b/ui/apps/docs/public/img/manual/device-pending.gif similarity index 100% rename from ui/apps/docs/public/img/devices/device-pending.gif rename to ui/apps/docs/public/img/manual/device-pending.gif diff --git a/ui/apps/docs/public/img/firewall/add-rule.png b/ui/apps/docs/public/img/manual/firewall-add-rule.png similarity index 100% rename from ui/apps/docs/public/img/firewall/add-rule.png rename to ui/apps/docs/public/img/manual/firewall-add-rule.png diff --git a/ui/apps/docs/public/img/firewall/firewall-rules.png b/ui/apps/docs/public/img/manual/firewall-rules.png similarity index 100% rename from ui/apps/docs/public/img/firewall/firewall-rules.png rename to ui/apps/docs/public/img/manual/firewall-rules.png diff --git a/ui/apps/docs/public/img/account/mfa-enable.gif b/ui/apps/docs/public/img/manual/mfa-enable.gif similarity index 100% rename from ui/apps/docs/public/img/account/mfa-enable.gif rename to ui/apps/docs/public/img/manual/mfa-enable.gif diff --git a/ui/apps/docs/public/img/account/mfa-login.png b/ui/apps/docs/public/img/manual/mfa-login.png similarity index 100% rename from ui/apps/docs/public/img/account/mfa-login.png rename to ui/apps/docs/public/img/manual/mfa-login.png diff --git a/ui/apps/docs/public/img/account/mfa-recovery-codes.png b/ui/apps/docs/public/img/manual/mfa-recovery-codes.png similarity index 100% rename from ui/apps/docs/public/img/account/mfa-recovery-codes.png rename to ui/apps/docs/public/img/manual/mfa-recovery-codes.png diff --git a/ui/apps/docs/public/img/account/mfa-recovery-mail.png b/ui/apps/docs/public/img/manual/mfa-recovery-mail.png similarity index 100% rename from ui/apps/docs/public/img/account/mfa-recovery-mail.png rename to ui/apps/docs/public/img/manual/mfa-recovery-mail.png diff --git a/ui/apps/docs/public/img/account/mfa-setup.png b/ui/apps/docs/public/img/manual/mfa-setup.png similarity index 100% rename from ui/apps/docs/public/img/account/mfa-setup.png rename to ui/apps/docs/public/img/manual/mfa-setup.png diff --git a/ui/apps/docs/public/img/settings/namespace-copy-tenant-id.gif b/ui/apps/docs/public/img/manual/namespace-copy-tenant-id.gif similarity index 100% rename from ui/apps/docs/public/img/settings/namespace-copy-tenant-id.gif rename to ui/apps/docs/public/img/manual/namespace-copy-tenant-id.gif diff --git a/ui/apps/docs/public/img/settings/namespace-create.gif b/ui/apps/docs/public/img/manual/namespace-create.gif similarity index 100% rename from ui/apps/docs/public/img/settings/namespace-create.gif rename to ui/apps/docs/public/img/manual/namespace-create.gif diff --git a/ui/apps/docs/public/img/settings/namespace-delete.gif b/ui/apps/docs/public/img/manual/namespace-delete.gif similarity index 100% rename from ui/apps/docs/public/img/settings/namespace-delete.gif rename to ui/apps/docs/public/img/manual/namespace-delete.gif diff --git a/ui/apps/docs/public/img/settings/namespace-switch.gif b/ui/apps/docs/public/img/manual/namespace-switch.gif similarity index 100% rename from ui/apps/docs/public/img/settings/namespace-switch.gif rename to ui/apps/docs/public/img/manual/namespace-switch.gif diff --git a/ui/apps/docs/public/img/public-keys/public-keys.png b/ui/apps/docs/public/img/manual/public-keys.png similarity index 100% rename from ui/apps/docs/public/img/public-keys/public-keys.png rename to ui/apps/docs/public/img/manual/public-keys.png diff --git a/ui/apps/docs/public/img/sessions/session-play.gif b/ui/apps/docs/public/img/manual/session-play.gif similarity index 100% rename from ui/apps/docs/public/img/sessions/session-play.gif rename to ui/apps/docs/public/img/manual/session-play.gif diff --git a/ui/apps/docs/public/img/sessions/session-recording-enable.gif b/ui/apps/docs/public/img/manual/session-recording-enable.gif similarity index 100% rename from ui/apps/docs/public/img/sessions/session-recording-enable.gif rename to ui/apps/docs/public/img/manual/session-recording-enable.gif diff --git a/ui/apps/docs/public/img/sessions/session-detail.png b/ui/apps/docs/public/img/sessions/session-detail.png deleted file mode 100644 index f1295c7bc25..00000000000 Binary files a/ui/apps/docs/public/img/sessions/session-detail.png and /dev/null differ diff --git a/ui/apps/docs/public/img/sessions/session-list.png b/ui/apps/docs/public/img/sessions/session-list.png deleted file mode 100644 index b5f6b2c50ab..00000000000 Binary files a/ui/apps/docs/public/img/sessions/session-list.png and /dev/null differ diff --git a/ui/apps/docs/public/img/settings/namespace-settings.png b/ui/apps/docs/public/img/settings/namespace-settings.png deleted file mode 100644 index 4b5f701328a..00000000000 Binary files a/ui/apps/docs/public/img/settings/namespace-settings.png and /dev/null differ diff --git a/ui/apps/docs/public/img/shots/add-docker-host.png b/ui/apps/docs/public/img/shots/add-docker-host.png new file mode 100644 index 00000000000..6295da91763 Binary files /dev/null and b/ui/apps/docs/public/img/shots/add-docker-host.png differ diff --git a/ui/apps/docs/public/img/shots/admin-authentication.png b/ui/apps/docs/public/img/shots/admin-authentication.png new file mode 100644 index 00000000000..6c8edc12019 Binary files /dev/null and b/ui/apps/docs/public/img/shots/admin-authentication.png differ diff --git a/ui/apps/docs/public/img/shots/dashboard.png b/ui/apps/docs/public/img/shots/dashboard.png new file mode 100644 index 00000000000..761ba66df83 Binary files /dev/null and b/ui/apps/docs/public/img/shots/dashboard.png differ diff --git a/ui/apps/docs/public/img/shots/device-details.png b/ui/apps/docs/public/img/shots/device-details.png new file mode 100644 index 00000000000..902876b7986 Binary files /dev/null and b/ui/apps/docs/public/img/shots/device-details.png differ diff --git a/ui/apps/docs/public/img/shots/device-list.png b/ui/apps/docs/public/img/shots/device-list.png new file mode 100644 index 00000000000..3dda5000f02 Binary files /dev/null and b/ui/apps/docs/public/img/shots/device-list.png differ diff --git a/ui/apps/docs/public/img/shots/members.png b/ui/apps/docs/public/img/shots/members.png new file mode 100644 index 00000000000..a542d1041f5 Binary files /dev/null and b/ui/apps/docs/public/img/shots/members.png differ diff --git a/ui/apps/docs/public/img/shots/namespace-settings.png b/ui/apps/docs/public/img/shots/namespace-settings.png new file mode 100644 index 00000000000..9eebf497521 Binary files /dev/null and b/ui/apps/docs/public/img/shots/namespace-settings.png differ diff --git a/ui/apps/docs/public/img/shots/session-detail.png b/ui/apps/docs/public/img/shots/session-detail.png new file mode 100644 index 00000000000..d79f5339e9b Binary files /dev/null and b/ui/apps/docs/public/img/shots/session-detail.png differ diff --git a/ui/apps/docs/public/img/shots/session-list.png b/ui/apps/docs/public/img/shots/session-list.png new file mode 100644 index 00000000000..c1d302adf25 Binary files /dev/null and b/ui/apps/docs/public/img/shots/session-list.png differ diff --git a/ui/apps/docs/public/img/shots/web-endpoints.png b/ui/apps/docs/public/img/shots/web-endpoints.png new file mode 100644 index 00000000000..6089103b0bc Binary files /dev/null and b/ui/apps/docs/public/img/shots/web-endpoints.png differ diff --git a/ui/apps/docs/public/img/team/members.png b/ui/apps/docs/public/img/team/members.png deleted file mode 100644 index 4c1d9d398a8..00000000000 Binary files a/ui/apps/docs/public/img/team/members.png and /dev/null differ diff --git a/ui/apps/docs/public/img/web-endpoints/web-endpoints.png b/ui/apps/docs/public/img/web-endpoints/web-endpoints.png deleted file mode 100644 index addde0feecf..00000000000 Binary files a/ui/apps/docs/public/img/web-endpoints/web-endpoints.png and /dev/null differ diff --git a/ui/apps/docs/scripts/capture-shots.sh b/ui/apps/docs/scripts/capture-shots.sh new file mode 100755 index 00000000000..82e31234ef9 --- /dev/null +++ b/ui/apps/docs/scripts/capture-shots.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Refresh the screenshots the docs use, by handing shellhub-demo the manifest the last build +# wrote and the directory the pages read from. +# +# The demo deliberately knows neither path: it photographs whatever list it is given and +# writes wherever it is told, which is what keeps a demo-environment builder free of any +# knowledge of the documentation that consumes it. That boundary constrains the demo, not +# the caller - and this side knows both paths perfectly well, so it fills them in. + +set -euo pipefail + +docs_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +manifest="${docs_dir}/.astro/shots.json" +out_dir="${docs_dir}/public/img/shots" + +# Assumed to sit beside the shellhub checkout, which is how the two are normally cloned. +# Overridable because "normally" is not "always", and a wrong guess should be a sentence +# rather than a stack trace. +demo_dir="${SHELLHUB_DEMO_DIR:-${docs_dir}/../../../../shellhub-demo}" + +if [ ! -x "${demo_dir}/stage" ]; then + echo "no shellhub-demo checkout at ${demo_dir}" >&2 + echo "-> clone it beside shellhub, or set SHELLHUB_DEMO_DIR" >&2 + exit 1 +fi + +# Written at astro:build:done, so a stale manifest means a stale shot list - and a shot +# removed from a page would otherwise keep being photographed forever. +if [ ! -f "$manifest" ]; then + echo "no shot list at ${manifest}" >&2 + echo "-> npm run build -w @shellhub/docs first" >&2 + exit 1 +fi + +exec "${demo_dir}/stage" capture --manifest "$manifest" --out "$out_dir" "$@" diff --git a/ui/apps/docs/src/components/Shot.astro b/ui/apps/docs/src/components/Shot.astro new file mode 100644 index 00000000000..665271d774f --- /dev/null +++ b/ui/apps/docs/src/components/Shot.astro @@ -0,0 +1,42 @@ +--- +import { warnMissingShotImageInDev } from "../lib/shots/check"; +import type { + ShotEdition, + ShotInteraction, + ShotSelector, + ShotViewport, +} from "../lib/shots/manifest"; +import { shotImageHref } from "../lib/shots/paths"; +import { recordShot } from "../lib/shots/registry"; + +interface Props { + id: string; + route: string; + of?: ShotSelector; + viewport?: ShotViewport; + alt?: string; + edition?: ShotEdition; + before?: ShotInteraction[]; +} + +const { id, route, of, viewport, alt, edition, before } = Astro.props; + +recordShot({ id, route, of, viewport, edition, before, page: Astro.url.pathname }); + +warnMissingShotImageInDev(id); +--- +
+ {alt +
+ + diff --git a/ui/apps/docs/src/lib/shots/__tests__/build.test.ts b/ui/apps/docs/src/lib/shots/__tests__/build.test.ts new file mode 100644 index 00000000000..181fe2e6e91 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/__tests__/build.test.ts @@ -0,0 +1,78 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, rmSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { JSDOM } from "jsdom"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { ShotManifest } from "@/lib/shots/manifest"; + +const fixture = fileURLToPath( + new URL("../../../../test/fixtures/shots-site", import.meta.url), +); + +let manifest: ShotManifest; + +/** + * The component runs in Vite's SSR module graph and the integration in Node's. + * Those are separate module caches, so a registry that works under vitest can + * still hand the integration an empty array during a real build. Only an actual + * `astro build` exercises both graphs, which is why this one test is worth its + * cost. + */ +describe("shots integration, over a real astro build", () => { + beforeAll(() => { + rmSync(`${fixture}/dist`, { recursive: true, force: true }); + rmSync(`${fixture}/.astro`, { recursive: true, force: true }); + + execFileSync("npx", ["astro", "build", "--root", "."], { + cwd: fixture, + encoding: "utf-8", + env: { ...process.env, CI: "" }, + }); + + manifest = JSON.parse( + readFileSync(`${fixture}/.astro/shots.json`, "utf-8"), + ) as ShotManifest; + }, 120_000); + + it("emits every declared shot, merging the one used on both pages", () => { + expect(manifest.shots).toEqual([ + { + id: "dashboard", + route: "/", + viewport: { width: 800, height: 600 }, + edition: "ce", + usedBy: ["/one/"], + }, + { + id: "device-list", + route: "/devices", + viewport: { width: 1440, height: 900 }, + edition: "ce", + of: { role: "table" }, + usedBy: ["/one/", "/two/"], + }, + { + id: "session-list", + route: "/sessions", + viewport: { width: 1440, height: 900 }, + edition: "enterprise", + usedBy: ["/two/"], + }, + ]); + }); + + it("renders the image that displays each shot", () => { + const html = readFileSync(`${fixture}/dist/one/index.html`, "utf-8"); + const { document } = new JSDOM(html).window; + + const images = [...document.querySelectorAll("img")].map((image) => ({ + src: image.getAttribute("src"), + alt: image.getAttribute("alt"), + })); + + expect(images).toContainEqual({ + src: "/img/shots/device-list.png", + alt: "Devices", + }); + }); +}); diff --git a/ui/apps/docs/src/lib/shots/__tests__/check.test.ts b/ui/apps/docs/src/lib/shots/__tests__/check.test.ts new file mode 100644 index 00000000000..3c8d17ceda7 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/__tests__/check.test.ts @@ -0,0 +1,95 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { removeImages, unreferencedImages } from "@/lib/shots/check"; + +let root = ""; +const publicDir = () => join(root, "public"); +const outDir = () => join(root, "dist"); + +function image(dir: string, name: string): void { + mkdirSync(join(publicDir(), dir), { recursive: true }); + writeFileSync(join(publicDir(), dir, name), ""); +} + +function page(html: string): void { + mkdirSync(join(outDir(), "guides"), { recursive: true }); + writeFileSync(join(outDir(), "guides", "index.html"), html); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "shots-check-")); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe("unreferencedImages", () => { + it("keeps a captured shot the built page displays", () => { + image("img/shots", "device-list.png"); + page(''); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + }); + + it("reports a shot no page displays any more", () => { + image("img/shots", "device-list.png"); + image("img/shots", "firewall-rules.png"); + page(''); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([ + "img/shots/firewall-rules.png", + ]); + }); + + // Manual images are referenced by plain markdown, which no manifest knows + // about. Reading the built html is what lets one rule cover both directories. + it("reports a manual image no page displays any more", () => { + image("img/manual", "mfa-setup.png"); + image("img/manual", "public-keys.png"); + page('setup'); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([ + "img/manual/public-keys.png", + ]); + }); + + it("counts a reference from anywhere in the output", () => { + image("img/manual", "session-play.gif"); + mkdirSync(outDir(), { recursive: true }); + writeFileSync(join(outDir(), "styles.css"), "a{background:url(/img/manual/session-play.gif)}"); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + }); + + // Every image lives in one of the owned directories, so a build that wrote no + // html would otherwise propose deleting all of them at once. + it("proposes nothing when there is no build to read", () => { + image("img/shots", "device-list.png"); + + expect(unreferencedImages(publicDir(), join(root, "nowhere"))).toEqual([]); + }); + + it("ignores directories the docs do not own", () => { + image("img/logos", "shellhub.svg"); + page("

no images here

"); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + }); +}); + +describe("removeImages", () => { + it("deletes exactly what it was given", () => { + image("img/shots", "device-list.png"); + image("img/manual", "public-keys.png"); + page(''); + + removeImages(publicDir(), unreferencedImages(publicDir(), outDir())); + + expect(unreferencedImages(publicDir(), outDir())).toEqual([]); + expect(existsSync(join(publicDir(), "img/shots/device-list.png"))).toBe(true); + expect(existsSync(join(publicDir(), "img/manual/public-keys.png"))).toBe(false); + }); +}); diff --git a/ui/apps/docs/src/lib/shots/__tests__/manifest.test.ts b/ui/apps/docs/src/lib/shots/__tests__/manifest.test.ts new file mode 100644 index 00000000000..07f8636eb30 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/__tests__/manifest.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { ShotDeclaration } from "@/lib/shots/manifest"; +import { buildManifest } from "@/lib/shots/manifest"; + +function declare(overrides: Partial = {}): ShotDeclaration { + return { + id: "device-list", + route: "/devices", + page: "/getting-started/quick-start/", + ...overrides, + }; +} + +describe("buildManifest", () => { + describe("deduplication", () => { + it("collapses the same shot declared on two pages into one entry", () => { + const manifest = buildManifest([ + declare({ page: "/getting-started/quick-start/" }), + declare({ page: "/guides/devices/" }), + ]); + + expect(manifest.shots).toHaveLength(1); + expect(manifest.shots[0].usedBy).toEqual([ + "/getting-started/quick-start/", + "/guides/devices/", + ]); + }); + + it("records a page once when the same shot appears twice on it", () => { + const manifest = buildManifest([ + declare({ page: "/guides/devices/" }), + declare({ page: "/guides/devices/" }), + ]); + + expect(manifest.shots[0].usedBy).toEqual(["/guides/devices/"]); + }); + + it("keeps distinct ids as separate shots", () => { + const manifest = buildManifest([ + declare({ id: "device-list" }), + declare({ id: "session-list", route: "/sessions" }), + ]); + + expect(manifest.shots.map((shot) => shot.id)).toEqual([ + "device-list", + "session-list", + ]); + }); + + it("rejects one id declaring two different captures", () => { + const entries = [ + declare({ id: "device-list", route: "/devices" }), + declare({ id: "device-list", route: "/sessions" }), + ]; + + expect(() => buildManifest(entries)).toThrow(/device-list/); + }); + }); + + describe("defaults", () => { + it("falls back to a 1440x900 viewport and the community edition", () => { + const manifest = buildManifest([declare()]); + + expect(manifest.shots[0]).toMatchObject({ + viewport: { width: 1440, height: 900 }, + edition: "ce", + }); + }); + + it("keeps explicit values over the defaults", () => { + const manifest = buildManifest([ + declare({ viewport: { width: 800, height: 600 }, edition: "enterprise" }), + ]); + + expect(manifest.shots[0]).toMatchObject({ + viewport: { width: 800, height: 600 }, + edition: "enterprise", + }); + }); + + it("preserves the capture coordinates the author supplied", () => { + const of = { role: "table" }; + const before = [{ click: { role: "button", name: "Sort by Hostname" } }]; + + const manifest = buildManifest([declare({ of, before })]); + + expect(manifest.shots[0]).toMatchObject({ of, before }); + }); + + it("omits optional coordinates that were never declared", () => { + const manifest = buildManifest([declare()]); + + expect(manifest.shots[0]).not.toHaveProperty("of"); + expect(manifest.shots[0]).not.toHaveProperty("before"); + }); + }); + + describe("stability", () => { + it("sorts shots by id regardless of the order pages rendered in", () => { + const ids = ["session-list", "dashboard", "device-list"]; + + const manifest = buildManifest( + ids.map((id) => declare({ id, route: `/${id}` })), + ); + + expect(manifest.shots.map((shot) => shot.id)).toEqual([ + "dashboard", + "device-list", + "session-list", + ]); + }); + + it("sorts usedBy so a reordered build produces the same manifest", () => { + const forward = buildManifest([ + declare({ page: "/a/" }), + declare({ page: "/b/" }), + ]); + const reversed = buildManifest([ + declare({ page: "/b/" }), + declare({ page: "/a/" }), + ]); + + expect(reversed).toEqual(forward); + expect(forward.shots[0].usedBy).toEqual(["/a/", "/b/"]); + }); + + it("returns an empty shot list when no page declared one", () => { + expect(buildManifest([]).shots).toEqual([]); + }); + }); +}); diff --git a/ui/apps/docs/src/lib/shots/check.ts b/ui/apps/docs/src/lib/shots/check.ts new file mode 100644 index 00000000000..fc375461679 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/check.ts @@ -0,0 +1,107 @@ +import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { extname, join } from "node:path"; +import { SHOTS_DIR, shotImageFile } from "./paths"; + +export function missingShotImages(publicDir: string, ids: string[]): string[] { + return ids.filter((id) => !existsSync(join(publicDir, shotImageFile(id)))); +} + +/** + * The image directories the docs own outright. Everything in them exists to be + * shown by a page, so anything no page asks for is a leftover. + */ +const OWNED_DIRS = [SHOTS_DIR, "img/manual"]; + +const TEXT = new Set([".html", ".css", ".js", ".json", ".xml", ".txt"]); + +function* walk(dir: string): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + + if (entry.isDirectory()) yield* walk(path); + else yield path; + } +} + +/** + * Every image path the built site asks for. + * + * Read from the output rather than from the sources, because that is the only + * answer covering every way a page can reference one: a tag, a markdown + * image, a raw . Astro copies public/ verbatim and rewrites none of these + * URLs, so what is in the HTML is what a reader will request. + */ +function referencedImages(outDir: string): Set { + const found = new Set(); + if (!existsSync(outDir)) return found; + + for (const file of walk(outDir)) { + if (!TEXT.has(extname(file))) continue; + + for (const [path] of readFileSync(file, "utf-8").matchAll(/\/img\/[\w./-]+/g)) { + found.add(path); + } + } + + return found; +} + +/** Files in the owned directories that the built site never asks for. */ +export function unreferencedImages(publicDir: string, outDir: string): string[] { + const referenced = referencedImages(outDir); + + // A build that referenced nothing at all is a build that produced no pages, + // not a site that stopped using every image it has. Proposing the whole + // directory for deletion on that evidence is the one mistake here that would + // be expensive. + if (referenced.size === 0) return []; + + const orphans: string[] = []; + + for (const dir of OWNED_DIRS) { + const path = join(publicDir, dir); + if (!existsSync(path)) continue; + + for (const file of readdirSync(path)) { + if (!referenced.has(`/${dir}/${file}`)) orphans.push(`${dir}/${file}`); + } + } + + return orphans.sort(); +} + +export function removeImages(publicDir: string, files: string[]): void { + for (const file of files) rmSync(join(publicDir, file), { force: true }); +} + +/** + * Same split as the registry: the integration and the component load in + * different module graphs, and Vite inlines `process.env` reads during the SSR + * transform, so the dev server's state has to travel on `globalThis`. The + * integration supplies `publicDir` because only it knows which project is being + * served: resolving it relative to the component would resolve against this app + * even when another one is building. + */ +const DEV: unique symbol = Symbol.for("@shellhub/docs:shots:dev"); + +type Host = typeof globalThis & { [DEV]?: { publicDir: string } }; + +export function serveShotsInDev(publicDir: string): void { + (globalThis as Host)[DEV] = { publicDir }; +} + +const warned = new Set(); + +/** + * The dev server never fires `astro:build:done`, so an author writing a page + * would get no signal until they ran a build. Warning from the render is the + * only hook `astro dev` runs. + */ +export function warnMissingShotImageInDev(id: string): void { + const dev = (globalThis as Host)[DEV]; + if (!dev || warned.has(id)) return; + if (missingShotImages(dev.publicDir, [id]).length === 0) return; + + warned.add(id); + console.warn(`[shots] no image captured yet for: ${id}`); +} diff --git a/ui/apps/docs/src/lib/shots/integration.ts b/ui/apps/docs/src/lib/shots/integration.ts new file mode 100644 index 00000000000..5ce3a7dd4c1 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/integration.ts @@ -0,0 +1,77 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AstroIntegration } from "astro"; +import { + missingShotImages, + removeImages, + serveShotsInDev, + unreferencedImages, +} from "./check"; +import { buildManifest } from "./manifest"; +import { collectShots, resetShots } from "./registry"; + +const MANIFEST_PATH = ".astro/shots.json"; + +export function shots(): AstroIntegration { + let root = ""; + let publicDir = ""; + let command = ""; + + return { + name: "@shellhub/docs:shots", + hooks: { + "astro:config:setup": (options) => { + command = options.command; + }, + + "astro:config:done": ({ config }) => { + root = fileURLToPath(config.root); + publicDir = fileURLToPath(config.publicDir); + + if (command === "dev") serveShotsInDev(publicDir); + }, + + "astro:build:start": () => { + // A watched rebuild reuses the process, so last build's entries survive. + resetShots(); + }, + + "astro:build:done": ({ dir, logger }) => { + const manifest = buildManifest(collectShots()); + const out = join(root, MANIFEST_PATH); + + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, `${JSON.stringify(manifest, null, 2)}\n`); + logger.info(`wrote ${manifest.shots.length} shots to ${MANIFEST_PATH}`); + + const orphans = unreferencedImages(publicDir, fileURLToPath(dir)); + + if (orphans.length > 0) { + // CI reports what was left behind rather than deleting it: a build + // that quietly rewrites the checkout hides the fact that the removal + // was never committed. + const left = `no page references: ${orphans.join(", ")}`; + if (process.env.CI) throw new Error(left); + + removeImages(publicDir, orphans); + logger.info(`removed ${orphans.length} unused: ${orphans.join(", ")}`); + } + + const missing = missingShotImages( + publicDir, + manifest.shots.map((shot) => shot.id), + ); + + if (missing.length === 0) return; + + const message = `no image captured yet for: ${missing.join(", ")}`; + + // A missing image is normal while writing a page and unacceptable on the + // published site, so it is a warning locally and a failure in CI. + if (process.env.CI) throw new Error(message); + logger.warn(message); + }, + }, + }; +} diff --git a/ui/apps/docs/src/lib/shots/manifest.ts b/ui/apps/docs/src/lib/shots/manifest.ts new file mode 100644 index 00000000000..fd978218dbe --- /dev/null +++ b/ui/apps/docs/src/lib/shots/manifest.ts @@ -0,0 +1,102 @@ +import { isDeepStrictEqual } from "node:util"; + +export type ShotEdition = "ce" | "enterprise"; + +export interface ShotSelector { + role?: string; + name?: string; + text?: string; + testId?: string; +} + +export interface ShotInteraction { + click?: ShotSelector; +} + +export interface ShotViewport { + width: number; + height: number; +} + +/** A single `` tag, as recorded while the page that contains it renders. */ +export interface ShotDeclaration { + id: string; + route: string; + page: string; + of?: ShotSelector; + viewport?: ShotViewport; + edition?: ShotEdition; + before?: ShotInteraction[]; +} + +export interface ManifestShot { + id: string; + route: string; + viewport: ShotViewport; + edition: ShotEdition; + of?: ShotSelector; + before?: ShotInteraction[]; + usedBy: string[]; +} + +export interface ShotManifest { + shots: ManifestShot[]; +} + +export const DEFAULT_VIEWPORT: ShotViewport = { width: 1440, height: 900 }; +export const DEFAULT_EDITION: ShotEdition = "ce"; + +type Capture = Omit; + +function toCapture(declaration: ShotDeclaration): Capture { + const capture: Capture = { + id: declaration.id, + route: declaration.route, + viewport: declaration.viewport ?? DEFAULT_VIEWPORT, + edition: declaration.edition ?? DEFAULT_EDITION, + }; + + if (declaration.of) capture.of = declaration.of; + if (declaration.before) capture.before = declaration.before; + + return capture; +} + +/** + * Collapse the declarations gathered during a build into the manifest the + * capture tool consumes. + * + * Ordering is fixed rather than inherited from the build: Astro renders pages + * concurrently, so input order varies between runs that changed no content. + */ +export function buildManifest(declarations: ShotDeclaration[]): ShotManifest { + const collected = new Map }>(); + + for (const declaration of declarations) { + const capture = toCapture(declaration); + const existing = collected.get(capture.id); + + if (!existing) { + collected.set(capture.id, { + capture, + pages: new Set([declaration.page]), + }); + continue; + } + + if (!isDeepStrictEqual(existing.capture, capture)) { + throw new Error( + `shot "${capture.id}" is declared twice with different capture coordinates; ` + + "give one of them a new id, or make the two tags identical", + ); + } + + existing.pages.add(declaration.page); + } + + const shots = [...collected.values()] + .map(({ capture, pages }) => ({ ...capture, usedBy: [...pages].sort() })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + + return { shots }; +} diff --git a/ui/apps/docs/src/lib/shots/paths.ts b/ui/apps/docs/src/lib/shots/paths.ts new file mode 100644 index 00000000000..4c6b8f3b8a6 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/paths.ts @@ -0,0 +1,14 @@ +/** + * Shots are addressed by id alone, so the id is the whole filename. A flat + * directory is what lets the tag and the capture tool agree on a path without + * either of them carrying a second list. + */ +export const SHOTS_DIR = "img/shots"; + +export function shotImageHref(id: string): string { + return `/${SHOTS_DIR}/${id}.png`; +} + +export function shotImageFile(id: string): string { + return `${SHOTS_DIR}/${id}.png`; +} diff --git a/ui/apps/docs/src/lib/shots/registry.ts b/ui/apps/docs/src/lib/shots/registry.ts new file mode 100644 index 00000000000..3db2e282fd6 --- /dev/null +++ b/ui/apps/docs/src/lib/shots/registry.ts @@ -0,0 +1,31 @@ +import type { ShotDeclaration } from "./manifest"; + +/** + * The component is loaded through Vite's SSR module graph and the integration + * through Node's. Each graph gets its own module instance, so a module-level + * array would be created twice and the integration would read the copy the + * component never wrote to: an empty manifest and a green build. Hanging the + * store off `globalThis` under a shared key is what makes both graphs agree. + */ +const REGISTRY: unique symbol = Symbol.for("@shellhub/docs:shots"); + +type Host = typeof globalThis & { [REGISTRY]?: ShotDeclaration[] }; + +function store(): ShotDeclaration[] { + const host = globalThis as Host; + host[REGISTRY] ??= []; + + return host[REGISTRY]; +} + +export function recordShot(declaration: ShotDeclaration): void { + store().push(declaration); +} + +export function collectShots(): ShotDeclaration[] { + return [...store()]; +} + +export function resetShots(): void { + store().length = 0; +} diff --git a/ui/apps/docs/src/pages/getting-started/quick-start.mdx b/ui/apps/docs/src/pages/getting-started/quick-start.mdx index 4f619a07dd9..1905f7071b2 100644 --- a/ui/apps/docs/src/pages/getting-started/quick-start.mdx +++ b/ui/apps/docs/src/pages/getting-started/quick-start.mdx @@ -4,6 +4,8 @@ title: Quick Start description: Get ShellHub running and connect your first device in minutes --- +import Shot from '../../components/Shot.astro'; + # Quick Start This guide gets you from zero to your first SSH session through ShellHub. Pick your path: @@ -25,7 +27,7 @@ After logging in, you'll be asked to create a **namespace** — this is your wor Once your namespace is ready, you land on the **dashboard**: an overview of your namespace with device count cards (Accepted, Online, Pending), your Tenant ID, recent sessions, and the left navigation (Dashboard, Devices, Containers, Sessions, Public Keys, Firewall Rules, Secure Vault, Team, Settings). -![ShellHub dashboard](/img/getting-started/dashboard.png) + ### 2. Install the agent @@ -63,7 +65,7 @@ For everything you can do from here — searching, tagging, renaming, and removi Click the green **Connect** button next to any online device on the **Devices** page to open a web SSH session. Enter the device username and password — you're in. -![Device connecting](/img/devices/device-connect.gif) +![Device connecting](/img/manual/device-connect.gif) To connect with your own SSH client: diff --git a/ui/apps/docs/src/pages/guides/connecting.mdx b/ui/apps/docs/src/pages/guides/connecting.mdx index ac78ecd04c1..0fbf24c01f7 100644 --- a/ui/apps/docs/src/pages/guides/connecting.mdx +++ b/ui/apps/docs/src/pages/guides/connecting.mdx @@ -4,6 +4,8 @@ title: Connecting via SSH description: Access devices using the web terminal or native SSH clients --- +import Shot from '../../components/Shot.astro'; + # Connecting via SSH ShellHub gives you two ways to reach your devices: a web terminal in the dashboard and native SSH client support. @@ -14,7 +16,7 @@ From the **Devices** page, click the terminal icon next to any online device. A Supports copy/paste, terminal resizing, and multiple simultaneous tabs. -![Device connecting](/img/devices/device-connect.gif) +![Device connecting](/img/manual/device-connect.gif) ## Native SSH @@ -26,7 +28,7 @@ Connect with any standard SSH client (anything supporting SSH 2.0 or later) usin Open the device from the [Devices](/guides/devices) page. Its SSHID is shown at the top of the device details page, with a copy button next to it. -![Device details page showing the SSHID with a copy button](/img/devices/device-details.png) + ### SSHID format diff --git a/ui/apps/docs/src/pages/guides/container-access.mdx b/ui/apps/docs/src/pages/guides/container-access.mdx index bc391c82c4a..03dc6818ad6 100644 --- a/ui/apps/docs/src/pages/guides/container-access.mdx +++ b/ui/apps/docs/src/pages/guides/container-access.mdx @@ -4,6 +4,8 @@ title: Container Access description: SSH into Docker containers running on your devices through ShellHub --- +import Shot from '../../components/Shot.astro'; + # Container Access ShellHub lets you SSH directly into Docker containers running on a host. The **ShellHub Connector** runs on the Docker host, connects to the Docker API, and exposes the running containers as accessible targets — you connect to a container the same way you connect to a [device](/guides/devices). @@ -24,7 +26,7 @@ No SSH server needs to run inside the container. The Connector bridges the SSH s To add Docker containers to ShellHub, install the ShellHub Connector on the Docker host. Open the **Containers** page and click **Add Container** to open the *Registering a Docker Host* drawer. -![ShellHub Containers page with the Registering a Docker Host drawer open, showing the one-line Connector install command](/img/containers/add-docker-host.png) + The easiest way is the one-line install script. It installs and starts the Connector, which connects to the Docker API and exposes the running containers within ShellHub: diff --git a/ui/apps/docs/src/pages/guides/devices.mdx b/ui/apps/docs/src/pages/guides/devices.mdx index 2dfb39ecaf7..c3a375a9c13 100644 --- a/ui/apps/docs/src/pages/guides/devices.mdx +++ b/ui/apps/docs/src/pages/guides/devices.mdx @@ -4,17 +4,19 @@ title: Device Management description: Monitor and manage your fleet of Linux devices --- +import Shot from '../../components/Shot.astro'; + # Device Management Devices are Linux machines running the ShellHub agent. Once connected, you can monitor their status, organize them with tags, and open SSH sessions from anywhere. -![ShellHub Devices page listing accepted devices](/img/devices/device-list.png) + ## Adding a device Click **Add Device** on the Devices page and choose an installation method — **Auto Detect** (recommended), **Docker**, or **Standalone**. ShellHub generates a one-line install command that already carries your namespace's **Tenant ID** and the **server address**, so all you have to do is run it on the target machine. -![ShellHub Add Device page showing the installation method chooser and the one-line install command](/img/devices/device-add.gif) +![ShellHub Add Device page showing the installation method chooser and the one-line install command](/img/manual/device-add.gif) Once the agent starts, the device registers and appears in the **Pending** tab. You must accept it before anyone can connect — see [Accepting and rejecting](#accepting-and-rejecting) below. @@ -68,7 +70,7 @@ From the **Devices** page: - **Accepted** tab — View active devices, remove access if needed - **Rejected** tab — Re-accept previously rejected devices -![ShellHub Devices page on the Pending tab where new devices await acceptance](/img/devices/device-pending.gif) +![ShellHub Devices page on the Pending tab where new devices await acceptance](/img/manual/device-pending.gif) ## Renaming diff --git a/ui/apps/docs/src/pages/guides/firewall-rules.mdx b/ui/apps/docs/src/pages/guides/firewall-rules.mdx index 70110c4506e..bed87654754 100644 --- a/ui/apps/docs/src/pages/guides/firewall-rules.mdx +++ b/ui/apps/docs/src/pages/guides/firewall-rules.mdx @@ -12,7 +12,7 @@ import EditionBanner from '../../components/EditionBanner.astro'; Define rules that allow or deny SSH connections based on source IP, username, and target device. Rules are evaluated before the connection reaches the device. -![The Firewall Rules page showing a table of rules with Priority, Action, Source IP, Username, Device Filter, and Status columns](/img/firewall/firewall-rules.png) +![The Firewall Rules page showing a table of rules with Priority, Action, Source IP, Username, Device Filter, and Status columns](/img/manual/firewall-rules.png) ## How it works @@ -33,7 +33,7 @@ This gives you centralized network-level access control without touching individ 4. Configure source IP, username, and device filters 5. Save -![The New Firewall Rule drawer with Status, Priority, Policy, Source IP, Username, and Device filter fields](/img/firewall/add-rule.png) +![The New Firewall Rule drawer with Status, Priority, Policy, Source IP, Username, and Device filter fields](/img/manual/firewall-add-rule.png) The rule takes effect immediately for new connections. diff --git a/ui/apps/docs/src/pages/guides/local-authentication.mdx b/ui/apps/docs/src/pages/guides/local-authentication.mdx index 7a2219f829c..1d96cf28e3f 100644 --- a/ui/apps/docs/src/pages/guides/local-authentication.mdx +++ b/ui/apps/docs/src/pages/guides/local-authentication.mdx @@ -4,6 +4,8 @@ title: Local Authentication description: Manage built-in username and password sign-in for your ShellHub instance --- +import Shot from '../../components/Shot.astro'; + # Local Authentication Local authentication is ShellHub's built-in sign-in method: users authenticate with an email and password stored securely in ShellHub's own database, with no external identity provider required. It is enabled by default, so users can register and sign in immediately after installation. @@ -12,7 +14,7 @@ Local authentication is ShellHub's built-in sign-in method: users authenticate w An instance **admin** controls local authentication from the **Admin Console** under **Settings → Authentication**, using the **Local Authentication** toggle. -![The Admin Console Authentication page with the Local Authentication and SAML Authentication toggles](/img/auth/admin-authentication.png) + When local authentication is **disabled**, the email-and-password login is turned off and users sign in through [SSO](/guides/sso) instead. diff --git a/ui/apps/docs/src/pages/guides/mfa.mdx b/ui/apps/docs/src/pages/guides/mfa.mdx index fff4c424550..e15d248e742 100644 --- a/ui/apps/docs/src/pages/guides/mfa.mdx +++ b/ui/apps/docs/src/pages/guides/mfa.mdx @@ -25,7 +25,7 @@ If you lose access to your authenticator, you can regain entry using a one-time Open your **Profile** page and find the **Security** section, then click **Enable MFA**. -![The Profile page Security section with the Enable MFA option](/img/account/mfa-enable.gif) +![The Profile page Security section with the Enable MFA option](/img/manual/mfa-enable.gif) The setup runs in three steps. @@ -33,19 +33,19 @@ The setup runs in three steps. Provide a recovery email address. This is required and is used to regain access if you ever lose both your authenticator app and your recovery codes. -![Recovery mail](/img/account/mfa-recovery-mail.png) +![Recovery mail](/img/manual/mfa-recovery-mail.png) ### Step 2 - Save Download or copy the recovery codes, check "I have saved my recovery codes in a secure location" and then click "Next Step". -![Recovery codes screen](/img/account/mfa-recovery-codes.png) +![Recovery codes screen](/img/manual/mfa-recovery-codes.png) ### Step 3 — Scan the QR code Open your authenticator app and scan the displayed TOTP QR code (or enter the secret manually). Enter the 6-digit code your app generates to confirm, then complete the setup. -![MFA QR Code waiting to be scanned](/img/account/mfa-setup.png) +![MFA QR Code waiting to be scanned](/img/manual/mfa-setup.png) ## Logging in with MFA @@ -55,7 +55,7 @@ After enabling MFA, the login flow gains a second step: 2. When prompted, enter the current 6-digit code from your authenticator app 3. Submit to complete the login -![Login requiring 6-digit code](/img/account/mfa-login.png) +![Login requiring 6-digit code](/img/manual/mfa-login.png) ## Account recovery diff --git a/ui/apps/docs/src/pages/guides/namespaces.mdx b/ui/apps/docs/src/pages/guides/namespaces.mdx index 6cbfa08293a..3b8f1a0938f 100644 --- a/ui/apps/docs/src/pages/guides/namespaces.mdx +++ b/ui/apps/docs/src/pages/guides/namespaces.mdx @@ -20,13 +20,13 @@ Your first namespace is created when you set up ShellHub. To create additional n > Pick a short name. It becomes part of every device's SSH address. -![Switching namespaces](/img/settings/namespace-create.gif) +![Switching namespaces](/img/manual/namespace-create.gif) ## Switching namespaces Click the namespace selector in the top bar to see every namespace you belong to. Select one to switch — the dashboard, devices, sessions, and all other pages update to show that namespace's data. Your active namespace is always shown in the selector, so you can confirm which one you're working in at a glance. -![Switching namespaces](/img/settings/namespace-switch.gif) +![Switching namespaces](/img/manual/namespace-switch.gif) ## What's inside a namespace @@ -49,7 +49,7 @@ Every namespace has a unique **Tenant ID**. This is how ShellHub identifies a na To find it, open **Settings** and look at the **General** section. The Tenant ID is listed there with a copy button. -![Namespace Settings page showing the Tenant ID being copied](/img/settings/namespace-copy-tenant-id.gif) +![Namespace Settings page showing the Tenant ID being copied](/img/manual/namespace-copy-tenant-id.gif) Make sure you're in the correct namespace before copying — switch with the namespace selector if you manage more than one. See [Settings](/guides/settings) for the rest of the namespace configuration. @@ -73,7 +73,7 @@ Here `myteam` is the namespace. Your team will type it in every SSH command, so Only the owner can delete a namespace. This permanently removes all devices, keys, firewall rules, session history, and member access. The action is irreversible. -![Deleting namespaces](/img/settings/namespace-delete.gif) +![Deleting namespaces](/img/manual/namespace-delete.gif) ## Next steps diff --git a/ui/apps/docs/src/pages/guides/public-keys.mdx b/ui/apps/docs/src/pages/guides/public-keys.mdx index f59f739ffbe..8599fb82a6a 100644 --- a/ui/apps/docs/src/pages/guides/public-keys.mdx +++ b/ui/apps/docs/src/pages/guides/public-keys.mdx @@ -8,7 +8,7 @@ description: Manage SSH keys centrally with fine-grained access control Upload your SSH public key once to ShellHub and use it to authenticate across all your devices. No need to copy keys to each machine. Key-based authentication is passwordless and more secure than passwords, which are vulnerable to brute-force login attempts. -![The Public Keys page listing registered SSH keys with their scope, fingerprint, and management actions](/img/public-keys/public-keys.png) +![The Public Keys page listing registered SSH keys with their scope, fingerprint, and management actions](/img/manual/public-keys.png) The **Public Keys** page lists every key registered in your namespace along with its scope, fingerprint, and the date it was added. From here you can add a new key, edit an existing one, or remove it. diff --git a/ui/apps/docs/src/pages/guides/session-recording.mdx b/ui/apps/docs/src/pages/guides/session-recording.mdx index bc413f404cc..df67c5fd73a 100644 --- a/ui/apps/docs/src/pages/guides/session-recording.mdx +++ b/ui/apps/docs/src/pages/guides/session-recording.mdx @@ -22,7 +22,7 @@ Session recording is configured per namespace: Existing sessions are not affected. Recording starts from the moment you enable it. -![Session recording playback](/img/sessions/session-recording-enable.gif) +![Session recording playback](/img/manual/session-recording-enable.gif) ## Viewing recordings @@ -35,7 +35,7 @@ The player replays terminal output in real time — you see exactly what the use - Timing between keystrokes - Terminal resizing -![Session recording playback](/img/sessions/session-play.gif) +![Session recording playback](/img/manual/session-play.gif) Use the playback controls at the bottom to pause and resume, scrub through the timeline (elapsed and total time are shown alongside the scrubber), and adjust playback speed. diff --git a/ui/apps/docs/src/pages/guides/sessions.mdx b/ui/apps/docs/src/pages/guides/sessions.mdx index 967a8f76d72..fabf56f8290 100644 --- a/ui/apps/docs/src/pages/guides/sessions.mdx +++ b/ui/apps/docs/src/pages/guides/sessions.mdx @@ -4,11 +4,13 @@ title: Sessions description: Monitor and audit SSH sessions --- +import Shot from '../../components/Shot.astro'; + # Sessions Every SSH connection through ShellHub is recorded as a session and stored on the server for auditing. You get visibility into who connected to which device, when, and for how long — without instrumenting anything on the devices themselves. -![The Sessions page listing active and historical SSH sessions](/img/sessions/session-list.png) + ## Viewing sessions @@ -32,7 +34,7 @@ The **Sessions** page lists all sessions in the current namespace: Select any session to open its detail view. For audit purposes, ShellHub records metadata about the connection — the target device, the authenticating user, the client's source IP, whether authentication succeeded, and a timeline of activity from connection through close. -![A session detail page showing the session flow timeline, details, device, and play recording button](/img/sessions/session-detail.png) + The **Session flow** timeline walks through the session step by step — connection established from an IP, authenticated as a user, pseudo-terminal opened, commands executed, session recorded, and session closed. The **Details** panel shows the session UID, source IP, and start/end times, while the **Device** panel identifies the target. If the session was recorded, use **Play Recording** to replay it. diff --git a/ui/apps/docs/src/pages/guides/settings.mdx b/ui/apps/docs/src/pages/guides/settings.mdx index 348df798ed1..841717956db 100644 --- a/ui/apps/docs/src/pages/guides/settings.mdx +++ b/ui/apps/docs/src/pages/guides/settings.mdx @@ -4,13 +4,15 @@ title: Settings description: Configure namespace settings — session recording, connection announcements, and more --- +import Shot from '../../components/Shot.astro'; + import EditionBanner from '../../components/EditionBanner.astro'; # Settings Namespace settings control behavior that applies to all devices and sessions within the namespace. Only the Owner and Administrators can modify settings. -![Namespace Settings page](/img/settings/namespace-settings.png) + ## General diff --git a/ui/apps/docs/src/pages/guides/sso.mdx b/ui/apps/docs/src/pages/guides/sso.mdx index a46353af92b..1d59604ff99 100644 --- a/ui/apps/docs/src/pages/guides/sso.mdx +++ b/ui/apps/docs/src/pages/guides/sso.mdx @@ -4,6 +4,8 @@ title: Single Sign-On (SAML) description: Let users sign in to ShellHub through an external SAML identity provider --- +import Shot from '../../components/Shot.astro'; + import EditionBanner from '../../components/EditionBanner.astro'; # Single Sign-On (SAML) @@ -18,7 +20,7 @@ SSO is configured by an instance **admin** in the **Admin Console**. Once enable In the **Admin Console**, go to **Settings → Authentication** and toggle **SAML Authentication** on, then configure your IdP. -![The Admin Console Authentication page with the Local Authentication and SAML Authentication toggles](/img/auth/admin-authentication.png) + You can configure the IdP in one of two ways: diff --git a/ui/apps/docs/src/pages/guides/team.mdx b/ui/apps/docs/src/pages/guides/team.mdx index 4cf6d37c0ff..3e5551ba20f 100644 --- a/ui/apps/docs/src/pages/guides/team.mdx +++ b/ui/apps/docs/src/pages/guides/team.mdx @@ -4,6 +4,8 @@ title: Team description: Manage namespace members, roles, and API keys --- +import Shot from '../../components/Shot.astro'; + # Team The Team page is where you manage who has access to your namespace and what they can do. It has two sections: **Members** and **API Keys**. @@ -12,7 +14,7 @@ The Team page is where you manage who has access to your namespace and what they Members are users who have access to the current namespace. Each member has a role that determines their permissions. -![The Team page showing the Members tab with members and their roles](/img/team/members.png) + ### Adding a member diff --git a/ui/apps/docs/src/pages/guides/web-endpoints.mdx b/ui/apps/docs/src/pages/guides/web-endpoints.mdx index a51143ebcb1..d442f75e88b 100644 --- a/ui/apps/docs/src/pages/guides/web-endpoints.mdx +++ b/ui/apps/docs/src/pages/guides/web-endpoints.mdx @@ -4,6 +4,8 @@ title: Web Endpoints description: Expose HTTP services running on remote devices through ShellHub web endpoints --- +import Shot from '../../components/Shot.astro'; + import EditionBanner from '../../components/EditionBanner.astro'; # Web Endpoints @@ -14,7 +16,7 @@ Access HTTP services running on your devices through ShellHub — without openin The **Web Endpoints** page lists every endpoint in your namespace. Each row shows the generated public URL, the local target it forwards to (e.g. `127.0.0.1:5432`), the time remaining before it expires, and a delete action. -![The Web Endpoints page listing active endpoints with their public URLs, targets, and time remaining](/img/web-endpoints/web-endpoints.png) + ## How it works diff --git a/ui/apps/docs/test/fixtures/shots-site/astro.config.mjs b/ui/apps/docs/test/fixtures/shots-site/astro.config.mjs new file mode 100644 index 00000000000..8dd8f773c85 --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/astro.config.mjs @@ -0,0 +1,15 @@ +import { fileURLToPath } from "node:url"; +import mdx from "@astrojs/mdx"; +import { defineConfig } from "astro/config"; +import { shots } from "../../../src/lib/shots/integration.ts"; + +export default defineConfig({ + integrations: [mdx(), shots()], + vite: { + resolve: { + alias: { + "@": fileURLToPath(new URL("../../../src", import.meta.url)), + }, + }, + }, +}); diff --git a/ui/apps/docs/test/fixtures/shots-site/package.json b/ui/apps/docs/test/fixtures/shots-site/package.json new file mode 100644 index 00000000000..75c9b36356d --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/package.json @@ -0,0 +1,6 @@ +{ + "name": "@shellhub/docs-shots-fixture", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/ui/apps/docs/test/fixtures/shots-site/src/pages/one.mdx b/ui/apps/docs/test/fixtures/shots-site/src/pages/one.mdx new file mode 100644 index 00000000000..25daf458a75 --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/src/pages/one.mdx @@ -0,0 +1,7 @@ +import Shot from "@/components/Shot.astro"; + +# Page one + + + + diff --git a/ui/apps/docs/test/fixtures/shots-site/src/pages/two.mdx b/ui/apps/docs/test/fixtures/shots-site/src/pages/two.mdx new file mode 100644 index 00000000000..c3051ac85b9 --- /dev/null +++ b/ui/apps/docs/test/fixtures/shots-site/src/pages/two.mdx @@ -0,0 +1,7 @@ +import Shot from "@/components/Shot.astro"; + +# Page two + + + +