diff --git a/.github/workflows/compose-smoke.yml b/.github/workflows/compose-smoke.yml new file mode 100644 index 0000000..3dae560 --- /dev/null +++ b/.github/workflows/compose-smoke.yml @@ -0,0 +1,104 @@ +name: Compose smoke + +# Brings up the one-command demo stack (deploy/docker-compose.demo.yml) on a +# clean runner and checks it works end to end: the dashboard answers and the +# pre-enrolled worker is online. Guards the demo against rot. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + demo-stack: + name: Demo compose stack starts and enrols + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + COMPOSE: docker compose -f deploy/docker-compose.demo.yml + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Start the stack + run: | + echo "STARTED_AT=$(date +%s)" >> "$GITHUB_ENV" + # Not `up --wait`: the stack has one-shot services (certs, seed) + # that exit by design, which --wait can treat as a failure. + # `up -d` still waits on depends_on conditions (server healthy, seed + # completed) and exits non-zero if one is not met in time, e.g. while + # SQL Server warms up or a service restarts. The polling steps below + # are the real gate, so record the exit code and state, then go on. + rc=0 + $COMPOSE up -d || rc=$? + if [ "$rc" -ne 0 ]; then + echo "::warning::compose up exited $rc; continuing to the readiness checks" + $COMPOSE ps -a + $COMPOSE logs --no-color --tail=50 worker server seed certs || true + fi + + - name: Wait for the dashboard + run: | + for i in $(seq 1 120); do + if curl -fsS -o /dev/null http://localhost:8080/api/auth/config; then + echo "Dashboard answered after ~$((i * 5))s of polling" + exit 0 + fi + sleep 5 + done + echo "Dashboard did not answer on http://localhost:8080 in 10 minutes" >&2 + exit 1 + + - name: Dashboard serves HTML + run: | + curl -fsS http://localhost:8080/ | grep -qi ' /dev/null + api -X POST http://localhost:8080/api/auth/login \ + -d '{"username":"admin","password":"rsagent-demo"}' > /dev/null + for i in $(seq 1 90); do + if api http://localhost:8080/api/workers \ + | jq -e '.workers | any(.hostName == "DEMO-SQLHOST01" and .online == true)' > /dev/null; then + echo "Worker DEMO-SQLHOST01 is online" + exit 0 + fi + sleep 5 + done + echo "Worker DEMO-SQLHOST01 never came online" >&2 + api http://localhost:8080/api/workers | jq . >&2 || true + exit 1 + + - name: Record startup duration + if: success() + run: | + secs=$(( $(date +%s) - STARTED_AT )) + { + echo "### Demo stack startup" + echo + echo "Dashboard reachable and worker enrolled in **${secs}s** from \`compose up\`." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Logs and container state + if: failure() + run: | + $COMPOSE ps -a + echo "::group::worker logs" + $COMPOSE logs --no-color --tail=200 worker || true + echo "::endgroup::" + $COMPOSE logs --no-color --tail=200 + + - name: Tear down + if: always() + run: $COMPOSE down -v diff --git a/.github/workflows/readme-screenshots.yml b/.github/workflows/readme-screenshots.yml new file mode 100644 index 0000000..bd66665 --- /dev/null +++ b/.github/workflows/readme-screenshots.yml @@ -0,0 +1,114 @@ +name: README screenshots + +# Manual only. Starts the one-command demo stack (deploy/docker-compose.demo.yml), +# drives the dashboard with Playwright and uploads the README screenshots +# (docs/demo.md "Notes for screenshots") as a workflow artifact. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + screenshots: + name: Capture README screenshots + runs-on: ubuntu-latest + timeout-minutes: 40 + env: + COMPOSE: docker compose -f deploy/docker-compose.demo.yml + NODE_VERSION: '24' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - run: pnpm install --frozen-lockfile --filter @remote-sql-agent/dashboard... + + - name: Install Playwright browser + run: pnpm --filter @remote-sql-agent/dashboard exec playwright install --with-deps chromium + + - name: Start the stack + run: $COMPOSE up -d + + - name: Wait for the dashboard + run: | + for i in $(seq 1 120); do + if curl -fsS -o /dev/null http://localhost:8080/api/auth/config; then + echo "Dashboard answered after ~$((i * 5))s of polling" + exit 0 + fi + sleep 5 + done + echo "Dashboard did not answer on http://localhost:8080 in 10 minutes" >&2 + exit 1 + + - name: Wait for the worker to come online + run: | + jar="$RUNNER_TEMP/cookies.txt" + api() { curl -fsS -b "$jar" -c "$jar" -H 'content-type: application/json' \ + -H "x-rsagent-csrf: $(awk '$6=="rsagent_csrf"{print $7}' "$jar")" "$@"; } + api http://localhost:8080/api/auth/config > /dev/null + api -X POST http://localhost:8080/api/auth/login \ + -d '{"username":"admin","password":"rsagent-demo"}' > /dev/null + for i in $(seq 1 90); do + if api http://localhost:8080/api/workers \ + | jq -e '.workers | any(.hostName == "DEMO-SQLHOST01" and .online == true)' > /dev/null; then + echo "Worker DEMO-SQLHOST01 is online" + exit 0 + fi + sleep 5 + done + echo "Worker DEMO-SQLHOST01 never came online" >&2 + exit 1 + + # The drift edit only shows as a second version if the worker has already + # mirrored the job's original definition, so wait for that first. + - name: Wait for the fixture jobs to be mirrored + run: | + jar="$RUNNER_TEMP/cookies.txt" + api() { curl -fsS -b "$jar" -c "$jar" -H 'content-type: application/json' \ + -H "x-rsagent-csrf: $(awk '$6=="rsagent_csrf"{print $7}' "$jar")" "$@"; } + for i in $(seq 1 60); do + if api 'http://localhost:8080/api/jobs/groups?by=name' \ + | jq -e '.groups | any(.label == "RSAgent Fixture - Heartbeat Log")' > /dev/null; then + echo "Heartbeat Log mirrored" + break + fi + sleep 5 + done + # Let the first sync settle so the original definition is stored as version 1. + sleep 45 + + # Edits the Heartbeat Log step in msdb; the capture script waits for the badge. + - name: Run the drift profile + # --no-deps: the drift service depends on seed, and `run` would start the + # seed container again, which recreates every fixture job and discards + # the original definition the edit is meant to be compared against. + run: $COMPOSE run --no-deps --rm drift + + - name: Capture screenshots + run: pnpm --filter @remote-sql-agent/dashboard exec node ../../docs/screenshots/capture.mjs + env: + BASE_URL: http://localhost:8080 + OUT_DIR: ${{ github.workspace }}/docs/screenshots/out + + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: readme-screenshots + path: docs/screenshots/out/*.png + if-no-files-found: error + + - name: Logs and container state + if: failure() + run: | + $COMPOSE ps -a + $COMPOSE logs --no-color --tail=200 + + - name: Tear down + if: always() + run: $COMPOSE down -v diff --git a/.gitignore b/.gitignore index d3e8507..e10f08b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ test-results/ playwright-report/ blob-report/ .playwright/ +docs/screenshots/out/ diff --git a/README.md b/README.md index d850aa6..a0cb810 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,19 @@ --- +## Screenshots + +Captured from the [one-command demo stack](docs/demo.md). + +| | | +|---|---| +| ![Estate grid: every instance, worker state and failures at a glance](docs/screenshots/1-estate-grid.png) | ![Job history with step-level failure detail](docs/screenshots/2-failed-job-history.png) | +| **Estate grid.** Every instance, worker state and running jobs. | **Failed job history.** Run and step detail, including retries. | +| ![Cross-server search by text inside job step bodies](docs/screenshots/3-cross-server-search.png) | ![Version history with a step diff](docs/screenshots/4-drift-diff.png) | +| **Cross-server search.** Find a job by text inside any step. | **Drift diff.** An on-server edit shown against the previous version. | +| ![Workers and their capability ceilings](docs/screenshots/5-workers-capabilities.png) | | +| **Workers.** What each worker can actually do. | | + ## Why this exists rsagent exists because our team runs multiple on-premise SQL Server Agent diff --git a/deploy/demo/mint-token.mjs b/deploy/demo/mint-token.mjs index 5050f27..9a74c9f 100644 --- a/deploy/demo/mint-token.mjs +++ b/deploy/demo/mint-token.mjs @@ -36,18 +36,33 @@ async function call(path, init = {}) { }); remember(response); if (!response.ok) { - throw new Error(`${init.method ?? 'GET'} ${path} -> ${response.status} ${await response.text()}`); + const error = new Error(`${init.method ?? 'GET'} ${path} -> ${response.status} ${await response.text()}`); + error.status = response.status; + throw error; } return response.json(); } -await call('/api/auth/config'); -await call('/api/auth/login', { - method: 'POST', - body: JSON.stringify({ username, password: adminPassword }), -}); -const minted = await call('/api/enrolment-tokens', { - method: 'POST', - body: JSON.stringify({ hostName, credentialMode: 'token', capabilities: ['readOnly'] }), -}); -process.stdout.write(minted.token); +async function mint() { + await call('/api/auth/config'); + await call('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password: adminPassword }), + }); + const minted = await call('/api/enrolment-tokens', { + method: 'POST', + body: JSON.stringify({ hostName, credentialMode: 'token', capabilities: ['observe'] }), + }); + return minted.token; +} + +// Exit 78 (EX_CONFIG) means the server rejected the request (4xx: bad +// credentials, bad capability, rate limited). Retrying cannot fix that, so +// worker-entry.sh stops instead of hammering /api/auth/login. Anything else +// (server still starting, network) exits 1 and is retried by compose. +try { + process.stdout.write(await mint()); +} catch (error) { + console.error(`[demo] ${error.message}`); + process.exit(error.status >= 400 && error.status < 500 ? 78 : 1); +} diff --git a/deploy/demo/worker-entry.sh b/deploy/demo/worker-entry.sh index c6296f2..d291e15 100644 --- a/deploy/demo/worker-entry.sh +++ b/deploy/demo/worker-entry.sh @@ -12,7 +12,16 @@ PKG=/app/worker-packages/rsagent-worker-linux.tar.gz tar -xzf "$PKG" -C "$RUN" ./rsagent-worker.mjs echo "[demo] minting an enrolment token as the bootstrap admin..." -TOKEN="$(node /demo/mint-token.mjs)" +rc=0 +TOKEN="$(node /demo/mint-token.mjs)" || rc=$? +if [ "$rc" -eq 78 ]; then + # The server answered with a 4xx. Retrying cannot help and would only burn + # the login rate limit, so exit 0 (compose's on-failure policy won't restart). + echo "[demo] the control plane rejected the enrolment request (see above); not retrying." >&2 + exit 0 +elif [ "$rc" -ne 0 ]; then + exit "$rc" +fi echo "[demo] enrolling ${RSAGENT_WORKER_HOST}..." node "$RUN/rsagent-worker.mjs" enrol --token "$TOKEN" /demo/worker.demo.yaml diff --git a/deploy/docker-compose.demo.yml b/deploy/docker-compose.demo.yml index 957699d..9f12479 100644 --- a/deploy/docker-compose.demo.yml +++ b/deploy/docker-compose.demo.yml @@ -195,7 +195,7 @@ services: cap_drop: - ALL read_only: true - restart: on-failure + restart: on-failure:5 mem_limit: 256m # --- Drift scenario -------------------------------------------------------- diff --git a/docs/screenshots/1-estate-grid.png b/docs/screenshots/1-estate-grid.png new file mode 100644 index 0000000..34d96af Binary files /dev/null and b/docs/screenshots/1-estate-grid.png differ diff --git a/docs/screenshots/2-failed-job-history.png b/docs/screenshots/2-failed-job-history.png new file mode 100644 index 0000000..d531577 Binary files /dev/null and b/docs/screenshots/2-failed-job-history.png differ diff --git a/docs/screenshots/3-cross-server-search.png b/docs/screenshots/3-cross-server-search.png new file mode 100644 index 0000000..cf641c1 Binary files /dev/null and b/docs/screenshots/3-cross-server-search.png differ diff --git a/docs/screenshots/4-drift-diff.png b/docs/screenshots/4-drift-diff.png new file mode 100644 index 0000000..1c24548 Binary files /dev/null and b/docs/screenshots/4-drift-diff.png differ diff --git a/docs/screenshots/5-workers-capabilities.png b/docs/screenshots/5-workers-capabilities.png new file mode 100644 index 0000000..d51edc9 Binary files /dev/null and b/docs/screenshots/5-workers-capabilities.png differ diff --git a/docs/screenshots/capture.mjs b/docs/screenshots/capture.mjs new file mode 100644 index 0000000..25b6422 --- /dev/null +++ b/docs/screenshots/capture.mjs @@ -0,0 +1,114 @@ +// Captures the README screenshots from the running demo stack +// (deploy/docker-compose.demo.yml). Run by .github/workflows/readme-screenshots.yml; +// to run it locally, start the demo, then: +// +// pnpm --filter @remote-sql-agent/dashboard exec node ../../docs/screenshots/capture.mjs +// +// Environment: BASE_URL (default http://localhost:8080), OUT_DIR (default docs/screenshots/out). +// The credentials are the throwaway demo admin published in the compose file. +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; + +// This script lives outside any workspace package, so resolve Playwright from +// the dashboard package (the current directory when run via pnpm exec). +const { chromium } = createRequire(join(process.cwd(), 'package.json'))('@playwright/test'); + +const BASE = process.env.BASE_URL ?? 'http://localhost:8080'; +const OUT = process.env.OUT_DIR ?? 'docs/screenshots/out'; +const FAILED_JOB = 'RSAgent Fixture - Known Failure'; +const DRIFT_JOB = 'RSAgent Fixture - Heartbeat Log'; + +mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch(); +const context = await browser.newContext({ + baseURL: BASE, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + colorScheme: 'light', +}); +const page = await context.newPage(); +page.setDefaultTimeout(30_000); + +async function shot(name) { + // Let fonts, polling and Monaco settle before the frame is taken. + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + await page.screenshot({ path: join(OUT, `${name}.png`), type: 'png' }); + console.log(`captured ${name}.png`); +} + +async function openJob(name) { + // The Jobs page groups by job name: one panel per name, collapsed unless it + // has a failing member, with the instances as links inside. + await page.goto('/jobs'); + await page.getByLabel('Filter jobs').fill(name); + const panel = page.locator('section.panel', { + has: page.locator('header.panel-head > span', { hasText: new RegExp(`^${name}$`) }), + }); + await panel.waitFor(); + const show = panel.getByRole('button', { name: 'Show', exact: true }); + if (await show.count()) await show.click(); + await panel.locator('a.mono').first().click(); + await page.getByRole('button', { name: 'History', exact: true }).waitFor(); +} + +// Sign in. +await page.goto(`${BASE}/`); +await page.getByLabel('Username').fill('admin'); +await page.getByLabel('Password').fill('rsagent-demo'); +await page.getByRole('button', { name: /sign in/i }).click(); +await page.getByRole('link', { name: 'Estate' }).waitFor(); + +// 1. Estate grid. Give the first poll time to land. +await page.goto(`${BASE}/estate`); +await page.getByText('DEMO-SQLHOST01').first().waitFor(); +await page.waitForTimeout(15_000); +await shot('1-estate-grid'); + +// 2. Failed job history with step detail. +await openJob(FAILED_JOB); +await page.getByRole('button', { name: 'History', exact: true }).click(); +const run = page.locator('tr.expandable').first(); +await run.waitFor(); +await run.click(); +await page.getByText(/^Step 1/).first().waitFor(); +await shot('2-failed-job-history'); + +// 3. Cross-server search. +await page.goto(`${BASE}/search`); +await page.getByLabel('Search job names and step bodies').fill('LEGACYFIN01'); +await page.getByRole('button', { name: /search/i }).click(); +await page.getByText('LEGACYFIN01').first().waitFor(); +await shot('3-cross-server-search'); + +// 4. Drift diff. The workflow runs the drift profile before this script. The +// Versions tab picks the newest two versions by default, so once the worker has +// reported the edit ("on the server") the step diff is already on screen. +let drifted = false; +for (let attempt = 0; attempt < 12 && !drifted; attempt++) { + await openJob(DRIFT_JOB); + await page.getByRole('button', { name: 'Versions', exact: true }).click(); + drifted = await page + .locator('.monaco-diff-editor') + .waitFor({ timeout: 15_000 }) + .then(() => true, () => false); + if (!drifted) await page.waitForTimeout(10_000); +} +if (!drifted) { + // Diagnostics ride in the error message so they show in the failure summary. + await page.screenshot({ path: join(OUT, 'debug-versions.png') }); + const text = (await page.locator('main, .page').first().innerText()).replace(/\s+/g, ' '); + throw new Error(`No drift diff appeared on the Versions tab. Page text: ${text.slice(0, 900)}`); +} +await page.waitForTimeout(2000); +await shot('4-drift-diff'); + +// 5. Workers and capabilities. +await page.goto(`${BASE}/admin`); +await page.getByRole('button', { name: 'Workers', exact: true }).click(); +await page.getByText('DEMO-SQLHOST01').first().waitFor(); +await shot('5-workers-capabilities'); + +await browser.close(); diff --git a/eslint.config.js b/eslint.config.js index 44c91db..7644c8b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,8 @@ export default tseslint.config( 'packages/protocol/src/gen/**', // Plain scripts that run inside the demo containers, outside any tsconfig. 'deploy/demo/**', + // Standalone Playwright capture script for the README screenshots. + 'docs/screenshots/**', ], }, js.configs.recommended,