diff --git a/.claude/skills/shipnode/SKILL.md b/.claude/skills/shipnode/SKILL.md index 7dd8770..3a8fe83 100644 --- a/.claude/skills/shipnode/SKILL.md +++ b/.claude/skills/shipnode/SKILL.md @@ -85,10 +85,11 @@ Target one app: `shipnode deploy --app api`, `shipnode logs --app web`. `rollbac ## Workflows ### First deploy -1. `shipnode init` -2. `shipnode setup` — creates `deploy` user by default (`--no-deploy-user` to skip) -3. `shipnode env` (per app if multi-app: `--app api`) -4. `shipnode deploy` +1. `shipnode init` — asks only for the server IP and an optional domain; `--host --domain --non-interactive` skips prompts +2. `shipnode setup` — creates the `deploy` user (`--no-deploy-user` to skip). When the config says `user: 'deploy'` and that user does not exist yet, it logs in as `root` for this run +3. `shipnode deploy` — the first deploy uploads the local `envFile` when the server has none (outside CI). With no local `.env`, it starts from an empty one. Use `shipnode env` to push later changes + +The health check passes on any response below 500 until `.healthCheck(path)` is set; a configured path must return 2xx/3xx. ### CI/CD @@ -170,8 +171,10 @@ shipnode cloudflare init # one tunnel, ingress per app domain ### Monitor ```bash -shipnode monitor # live TUI: PM2, system, health, logs -shipnode monitor --app api +shipnode monitor # live TUI across every server: fleet overview, replica detail, streaming logs +shipnode monitor --app api # one app on all its servers +shipnode monitor --on web-2 +shipnode monitor --once # one snapshot; --json for machine-readable ``` ## Day-to-day @@ -179,6 +182,7 @@ shipnode monitor --app api ```bash shipnode status [--app name] shipnode logs [--app name] [--lines 500] +shipnode logs --follow [--level error] [--grep '/timeout/i'] [--on server] # live, merged across servers shipnode restart [--app name] shipnode stop [--app name] shipnode run "pnpm db:apply" [--app name] diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6e0a0..865f27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to `@devalade/shipnode` will be documented here. ## [Unreleased] +### Added +- **`monitor` watches the whole fleet.** With no `--on` it connects to every server and opens a fleet overview: each app on each server it runs on, the release each replica serves, process and health state, and a one-line verdict per app (a half-finished roll reads `split across 2 releases` and marks the replica that is `behind`; an unreachable server keeps its row). `Enter` opens the per-replica view the old single-server monitor showed. `--on` and `--app` still narrow it. Rollback from the monitor is refused for an app that runs on several servers, since it would split the fleet; use `shipnode rollback`. +- **Live log streaming with filters.** `f` opens a merged, streaming view of every server's logs (PM2, systemd and Caddy access logs) over the existing SSH connections, replacing the 2-second `pm2 logs --nostream` poll and its duplicated or dropped lines. Filter by server (`s`), app (`a`), process (`p`), level (`v`: all, warn+, error) and text, `/regex/` or `!exclude` search (`/`), in hide or dim mode (`m`), with live `ERR`/`WARN` counts. Pause, scroll back, and clear are supported. A dropped connection is retried with backoff without replaying lines already shown. +- **The monitor has a consistent visual design.** Panels carry their title in the border, data sits in aligned tables with column headings, and colour is reserved for state (green ok, amber degraded, red failing) with one accent for titles and selection. A breadcrumb header shows where you are and how fresh the data is (`live`, `stale` once polls stop arriving), alerts get their own line, and the footer lists the keys for the current view and briefly confirms actions. Tables drop their least important columns on narrow terminals instead of overflowing, and nothing paints a background, so light terminal themes work. +- **`shipnode logs --follow`** streams the same merged logs to the terminal, with `--level`, `--grep`, `--app`, `--on` and `--process`. `--level` and `--grep` also filter the one-shot `logs` output. + +### Changed +- **`init` asks at most six questions instead of up to fifteen:** what you're deploying (pre-selected from detection), server IP, port, domain, and for backends whether to install a database or Redis. SSH user and port, deploy path, PM2 name, runtime, health check path and extra users take defaults you can edit in the file. `--host` and `--domain` let `init --non-interactive` write a complete config. A DB password is no longer written into the config as a literal; it reads `process.env.DB_PASSWORD`. If `shipnode.config.ts` already exists, `init` says so before asking anything. +- **The default health check accepts any HTTP answer below 500.** Apps without a `/health` route no longer fail their first deploy on a 404. A path set with `.healthCheck(path)` (or `healthCheck.path`) is still held to 2xx/3xx; `healthCheck.strict` overrides either way. Failures now say whether nothing answered on the port or the configured route returned 404. + +### Fixed +- **`setup` could not reach a fresh server with the config `init` wrote.** `init` sets `user: 'deploy'`, which only exists after `setup` creates it. When the server refuses `deploy`, `setup` now logs in as `root` for that run. If the config uses another user, the "switch ssh.user" hint is shown as before. +- **The first deploy failed with "Remote environment file is missing".** When the server has no env file, `deploy` now uploads the local one (what `shipnode env` would do). With no local `.env` either, it creates an empty one. A custom `envFile` name that is missing locally still fails. None of this happens in CI, where the env belongs to `ci env-sync` (ADR-0006). + ## [3.2.0-beta.3] - 2026-10-01 ### Changed diff --git a/README.md b/README.md index c548b5d..fb1ac95 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,17 @@ npm install -g @devalade/shipnode ## Quick start ```bash -# 1. Generate config -shipnode init - -# 2. Provision the server. Installs Node, PM2, Caddy, mise, DB/Redis if -# configured, and bootstraps a `deploy` user (sudo, NOPASSWD) keyed off -# ${ssh.identityFile}.pub so subsequent runs don't need root SSH. -shipnode setup - -# 3. Switch ssh.user in shipnode.config.ts to 'deploy', then lock down SSH: -shipnode harden - -# 4. Deploy -shipnode deploy +shipnode init # asks for your server's IP (and a domain, if you have one) +shipnode setup # once per server: installs Node, PM2, Caddy, creates a 'deploy' user +shipnode deploy # every time you ship ``` -Pass `--no-deploy-user` to `setup` if you want to manage users yourself. +That's it. All you need is a fresh Ubuntu/Debian VPS you can `ssh root@` into with your key. + +- `setup` logs in as `root` the first time, because the `deploy` user it creates doesn't exist yet. After that, everything runs as `deploy`. +- On the first deploy, your local `.env` is uploaded to the server. After you change it, run `shipnode env` to push it again. +- The health check passes once your app answers HTTP on its port. Add `.healthCheck('/health')` to require a real 2xx from a specific route. +- `shipnode harden` (optional) disables root and password SSH and turns on a firewall. ## Configuration @@ -329,9 +324,9 @@ See [ADR-0007](docs/adr/0007-fleet-replication.md) and [ADR-0008](docs/adr/0008- | `.zeroDowntime(altPort?)` | automatic for Caddy backends | Force blue-green releases and optionally choose the green port | | `.blueGreenRetention('warm' \| 'rollback' \| 'none')` | `'warm'` | What happens to the old colour after the switch: stop it but keep its release so `rollback` can start it again (`warm`), keep it running for an instant flip (`rollback`), or stop it with no rollback (`none`) | | `.noZeroDowntime()` | — | Opt out and recreate PM2 processes during deploy | -| `.healthCheck(path, opts?)` | `/health`, 30s, 3 retries | Post-deploy health check | +| `.healthCheck(path, opts?)` | `/health`, 30s, 3 retries; any response below 500 passes until you set a path | Post-deploy health check — a configured path must return 2xx/3xx | | `.noHealthCheck()` | — | Skip health check | -| `.envFile(f)` | `.env` | Local .env file to upload | +| `.envFile(f)` | `.env` | Local .env file to upload (automatically on the first deploy) | | `.sharedDirs(dirs)` | — | Dirs persisted across releases | | `.sharedFiles(files)` | — | Files persisted across releases | | `.database(opts)` | — | Database connection config | diff --git a/docs/adr/0006-ci-environment-ownership.md b/docs/adr/0006-ci-environment-ownership.md index a4057cb..94fcdd8 100644 --- a/docs/adr/0006-ci-environment-ownership.md +++ b/docs/adr/0006-ci-environment-ownership.md @@ -24,6 +24,10 @@ Generated workflows do not run a generic repository-root build. Frontend strateg Production jobs use GitHub Environment protection, least-privilege repository permissions, a timeout, and serialized concurrency with `cancel-in-progress: false`. Root-level monorepo changes continue to trigger deployments; no `paths` filter is generated. +## First deploy from a developer machine + +When the server has no env file, `deploy` uploads the local one, which is what `shipnode env` would have done. With no local file and the default `.env` name, it creates an empty one. This never happens when `CI` is set: a missing file there means a misconfigured workflow, and the preflight still fails. + ## Trade-offs - Server-managed env minimizes secret exposure but requires an out-of-band env upload or rotation step. diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 05c20b8..1fd047d 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -46,7 +46,7 @@ function showApp(app: ShipnodeApp, nodeVersion: string): void { if (app.appType === 'backend') { ui.section('Health Check', [ ['enabled', String(app.healthCheck.enabled)], - ['path', app.healthCheck.path], + ['path', app.healthCheck.strict === false ? `${app.healthCheck.path} (any response below 500)` : app.healthCheck.path], ['timeout', String(app.healthCheck.timeout)], ['retries', String(app.healthCheck.retries)], ['startupDelay', String(app.healthCheck.startupDelay)], diff --git a/src/cli/commands/deploy.ts b/src/cli/commands/deploy.ts index 0c59a64..18839a7 100644 --- a/src/cli/commands/deploy.ts +++ b/src/cli/commands/deploy.ts @@ -390,7 +390,9 @@ function renderAppPlan( if (app.hooks?.preDeploy) steps.push('Run preDeploy hook'); steps.push('Switch symlink (atomic)'); if (app.appType === 'backend') steps.push('Reload PM2'); - if (app.healthCheck.enabled) steps.push(`Health check ${app.healthCheck.path}`); + if (app.healthCheck.enabled) { + steps.push(`Health check ${app.healthCheck.path}${app.healthCheck.strict === false ? ' (any response below 500)' : ''}`); + } steps.push('Record release'); if (app.hooks?.postDeploy) steps.push('Run postDeploy hook'); if (app.hooks?.afterFleet) steps.push('Run afterFleet hook (last replica only)'); diff --git a/src/cli/commands/env.ts b/src/cli/commands/env.ts index c96c353..3a63ec7 100644 --- a/src/cli/commands/env.ts +++ b/src/cli/commands/env.ts @@ -5,30 +5,7 @@ import { runRemoteCommandForTargets } from '../runner.js'; import { ui } from '../ui.js'; import { getDeploymentName } from '../../domain/pm2/apps.js'; import { isWatt, resolveWattUnits, restartUnitCommand } from '../../domain/runtime/watt.js'; -import type { RemoteExecutor } from '../../domain/remote/executor.js'; - -function shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, `'"'"'`)}'`; -} - -/** Atomically replace a remote environment file without exposing its raw content to shell parsing. */ -export async function uploadEnvironmentFile( - executor: RemoteExecutor, - remotePath: string, - content: Buffer, -): Promise { - const b64 = content.toString('base64'); - await executor.execOrThrow(`mkdir -p "$(dirname ${shellSingleQuote(remotePath)})"`); - const temporaryEnv = `${remotePath}.shipnode.XXXXXX`; - await executor.execOrThrow([ - `tmp=$(mktemp ${shellSingleQuote(temporaryEnv)})`, - `trap 'rm -f "$tmp"' EXIT`, - `printf '%s' ${shellSingleQuote(b64)} | base64 -d > "$tmp"`, - 'chmod 600 "$tmp"', - `mv -f "$tmp" ${shellSingleQuote(remotePath)}`, - 'trap - EXIT', - ].join(' && ')); -} +import { uploadEnvironmentFile } from '../../domain/deploy/dotenv.js'; export async function cmdEnv( cwd: string, diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index acf885e..affd970 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,7 +1,7 @@ import { writeFile, readFile as readFileNode } from 'node:fs/promises'; import { ensureDir, pathExists } from 'fs-extra'; import { resolve } from 'path'; -import { text, select, confirm, isCancel, group } from '@clack/prompts'; +import { text, select, confirm, isCancel } from '@clack/prompts'; import { detectFramework, detectPkgManager } from '../../domain/framework/detector.js'; import { isValidIpOrHostname, isValidPort } from '../../domain/validation/ip.js'; import { ui } from '../ui.js'; @@ -14,18 +14,50 @@ function cancelIfNeeded(v: unknown): asserts v is string | boolean | number { } } -export async function cmdInit(cwd: string, options: { nonInteractive?: boolean; print?: boolean }): Promise { +interface InitOptions { + nonInteractive?: boolean; + print?: boolean; + host?: string; + domain?: string; +} + +/** + * Ask only what shipnode cannot work out by itself: where the server is, and + * the domain if there is one. Everything else — app type, port, package + * manager — is detected, and the rest takes a default the developer can change + * in the generated file. Power-user options (workers, watt, fleets, users) live + * in the config, not in a questionnaire every newcomer has to sit through. + */ +export async function cmdInit(cwd: string, options: InitOptions): Promise { + const configPath = resolve(cwd, 'shipnode.config.ts'); + if (!options.print && (await pathExists(configPath))) { + ui.warn('shipnode.config.ts already exists — edit it, or delete it to start over.'); + return; + } + const detection = await detectFramework(cwd); const pkgManager = await detectPkgManager(cwd); - const isBackend = detection.appType === 'backend'; const appName = await getAppName(cwd); const defaultPort = detection.port ?? 3000; + if (options.host !== undefined && !isValidIpOrHostname(options.host)) { + ui.error('Invalid --host. Must be an IP address or hostname.'); + process.exit(1); + } + + // The domain is written into the generated TypeScript as a string literal. + if (options.domain !== undefined && options.domain.trim() !== '' && !isValidIpOrHostname(options.domain.trim())) { + ui.error('Invalid --domain. Must be a hostname, like api.example.com.'); + process.exit(1); + } + if (options.nonInteractive || options.print) { const config = generateConfig({ - app: isBackend ? 'backend' : 'frontend', + app: detection.appType === 'backend' ? 'backend' : 'frontend', appName, + sshHost: options.host, backendPort: defaultPort, + domain: options.domain, pkgManager: pkgManager ?? undefined, }); @@ -34,14 +66,9 @@ export async function cmdInit(cwd: string, options: { nonInteractive?: boolean; return; } - const configPath = resolve(cwd, 'shipnode.config.ts'); - if (await pathExists(configPath)) { - ui.warn('shipnode.config.ts already exists. Use --force to overwrite.'); - return; - } - await writeFile(configPath, config, 'utf-8'); ui.success('Created shipnode.config.ts'); + if (!options.host) ui.warn("Set your server's IP in .ssh({ host }) before running shipnode setup."); await generateShipnodeDir(cwd); return; } @@ -53,201 +80,89 @@ export async function cmdInit(cwd: string, options: { nonInteractive?: boolean; 'Project', ); - // ── Server ───────────────────────────────────────────────────── const appTypeVal = await select({ - message: 'App type', - initialValue: isBackend ? 'backend' : 'frontend', + message: 'What are you deploying?', + initialValue: detection.appType === 'backend' ? 'backend' : 'frontend', options: [ - { value: 'backend', label: 'Backend', hint: 'Node.js server, PM2 managed' }, - { value: 'frontend', label: 'Frontend', hint: 'Static files, served by Caddy' }, + { value: 'backend', label: 'A Node.js server', hint: 'API, SSR, Express, Fastify, Nest…' }, + { value: 'frontend', label: 'A static site', hint: 'Vite, SPA, docs — built locally, served by Caddy' }, ], }); cancelIfNeeded(appTypeVal); - const appType = appTypeVal as string; + const appType = appTypeVal as 'backend' | 'frontend'; - const sshHost = await text({ message: 'SSH host', placeholder: '1.2.3.4' }); + const sshHost = options.host ?? await text({ + message: "Server IP address", + placeholder: '1.2.3.4', + validate: (value) => (isValidIpOrHostname(value ?? '') ? undefined : 'Enter an IP address or hostname'), + }); cancelIfNeeded(sshHost); - if (!isValidIpOrHostname(sshHost as string)) { - ui.error('Invalid host. Must be an IP address or hostname.'); - process.exit(1); - } - - const sshUser = await text({ message: 'SSH user', initialValue: 'deploy' }); - cancelIfNeeded(sshUser); - const sshPortStr = await text({ message: 'SSH port', initialValue: '22' }); - cancelIfNeeded(sshPortStr); - const sshPort = parseInt(sshPortStr as string, 10); - if (!isValidPort(sshPort)) { - ui.error('Invalid port number.'); - process.exit(1); + let backendPort = defaultPort; + if (appType === 'backend') { + const portVal = await text({ + message: 'Which port does your app listen on?', + initialValue: String(defaultPort), + validate: (value) => (isValidPort(parseInt(value ?? '', 10)) ? undefined : 'Enter a port number'), + }); + cancelIfNeeded(portVal); + backendPort = parseInt(portVal as string, 10); } - const remotePath = await text({ message: 'Remote deploy path', initialValue: `/var/www/${appName}` }); - cancelIfNeeded(remotePath); + const domainVal = options.domain ?? await text({ + message: 'Domain (leave empty to skip — you get HTTPS automatically when set)', + placeholder: appType === 'backend' ? 'api.example.com' : 'example.com', + validate: (value) => (!value?.trim() || isValidIpOrHostname(value.trim()) ? undefined : 'Enter a hostname, like api.example.com'), + }); + cancelIfNeeded(domainVal); + const domain = (domainVal as string | undefined)?.trim() || undefined; - let pm2Name = ''; - let wattMain: string | undefined; - let backendPort = defaultPort; - let domain = ''; - let healthCheckPath = '/health'; let dbType: DatabaseType | undefined; - let dbHost: string | undefined; - let dbPort: number | undefined; - let dbName: string | undefined; - let dbUser: string | undefined; - let dbPassword: string | undefined; - let redisHost: string | undefined; - let redisPort: number | undefined; - let redisPassword: string | undefined; - + let hasRedis = false; if (appType === 'backend') { - const pm2Val = await text({ message: 'PM2 app name', initialValue: appName }); - cancelIfNeeded(pm2Val); - pm2Name = pm2Val as string; - - const runtimeVal = await select({ - message: 'Process runtime', + const dbVal = await select({ + message: 'Install a database on the server?', + initialValue: 'none', options: [ - { value: 'pm2', label: 'PM2 (default)' }, - { value: 'watt', label: 'wattpm — worker threads sharing one port (Linux, opt-in)' }, + { value: 'none', label: 'No' }, + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'mongodb', label: 'MongoDB' }, + { value: 'sqlite', label: 'SQLite', hint: 'a file, nothing to install' }, ], - initialValue: 'pm2', }); - cancelIfNeeded(runtimeVal); - if (runtimeVal === 'watt') { - const mainVal = await text({ - message: 'Entry file each worker thread loads (must listen on process.env.PORT)', - initialValue: 'dist/server.js', - }); - cancelIfNeeded(mainVal); - wattMain = mainVal as string; - } + cancelIfNeeded(dbVal); + dbType = dbVal === 'none' ? undefined : (dbVal as DatabaseType); - const portVal = await text({ message: 'Backend port', initialValue: String(defaultPort) }); - cancelIfNeeded(portVal); - backendPort = parseInt(portVal as string, 10); - - const domainVal = await text({ message: 'Domain', placeholder: 'api.example.com (optional)' }); - cancelIfNeeded(domainVal); - domain = domainVal as string; - - const hcVal = await text({ message: 'Health check path', initialValue: '/health' }); - cancelIfNeeded(hcVal); - healthCheckPath = hcVal as string; - - const hasDb = await confirm({ message: 'Configure a database?', initialValue: false }); - cancelIfNeeded(hasDb); - - if (hasDb) { - const dbTypeVal = await select({ - message: 'Database type', - options: [ - { value: 'postgres', label: 'PostgreSQL', hint: 'port 5432' }, - { value: 'mysql', label: 'MySQL', hint: 'port 3306' }, - { value: 'sqlite', label: 'SQLite', hint: 'file-based' }, - { value: 'mongodb', label: 'MongoDB', hint: 'port 27017' }, - ], - }); - cancelIfNeeded(dbTypeVal); - dbType = dbTypeVal as DatabaseType; - - if (dbType !== 'sqlite') { - const defaultDbPort = dbType === 'postgres' ? '5432' : dbType === 'mysql' ? '3306' : '27017'; - const dbVals = await group({ - dbHost: () => text({ message: 'Database host', initialValue: 'localhost' }), - dbPort: () => text({ message: 'Database port', initialValue: defaultDbPort }), - dbName: () => text({ message: 'Database name', initialValue: appName }), - dbUser: () => text({ message: 'Database user', initialValue: appName }), - dbPassword: () => text({ message: 'Database password', placeholder: 'optional' }), - }); - dbHost = dbVals.dbHost as string; - dbPort = parseInt(dbVals.dbPort as string, 10); - dbName = dbVals.dbName as string; - dbUser = dbVals.dbUser as string; - dbPassword = (dbVals.dbPassword as string) || undefined; - } else { - const sqliteVal = await text({ message: 'SQLite file path', initialValue: './data.db' }); - cancelIfNeeded(sqliteVal); - dbName = sqliteVal as string; - } - } - - const hasRedis = await confirm({ message: 'Configure Redis?', initialValue: false }); - cancelIfNeeded(hasRedis); - - if (hasRedis) { - const redisVals = await group({ - redisHost: () => text({ message: 'Redis host', initialValue: 'localhost' }), - redisPort: () => text({ message: 'Redis port', initialValue: '6379' }), - redisPassword: () => text({ message: 'Redis password', placeholder: 'optional' }), - }); - redisHost = redisVals.redisHost as string; - redisPort = parseInt(redisVals.redisPort as string, 10); - redisPassword = (redisVals.redisPassword as string) || undefined; - } - } else { - const domainVal = await text({ message: 'Domain', placeholder: 'example.com (optional)' }); - cancelIfNeeded(domainVal); - domain = domainVal as string; - } - - // ── Users ────────────────────────────────────────────────────── - const users: Array<{ username: string; publicKey: string; sudo: boolean }> = []; - const addUsers = await confirm({ message: 'Add SSH users to the server?', initialValue: false }); - cancelIfNeeded(addUsers); - - if (addUsers) { - let addMore = true; - while (addMore) { - const username = await text({ message: 'Username' }); - cancelIfNeeded(username); - if (!(username as string)) break; - const publicKey = await text({ message: `Public key for ${username as string}`, placeholder: 'ssh-ed25519 AAAA...' }); - cancelIfNeeded(publicKey); - const sudo = await confirm({ message: `Grant sudo to ${username as string}?`, initialValue: false }); - cancelIfNeeded(sudo); - users.push({ username: username as string, publicKey: publicKey as string, sudo: sudo as boolean }); - const more = await confirm({ message: 'Add another user?', initialValue: false }); - cancelIfNeeded(more); - addMore = more as boolean; - } + const redisVal = await confirm({ message: 'Install Redis on the server?', initialValue: false }); + cancelIfNeeded(redisVal); + hasRedis = redisVal as boolean; } const config = generateConfig({ - app: appType as 'backend' | 'frontend', + app: appType, appName, sshHost: sshHost as string, - sshUser: sshUser as string, - sshPort, - remotePath: remotePath as string, - pm2Name, - wattMain, backendPort, - domain: domain || undefined, - healthCheckPath, + domain, pkgManager: pkgManager ?? undefined, dbType, - dbHost, - dbPort, - dbName, - dbUser, - dbPassword: dbPassword || undefined, - redisHost, - redisPort, - redisPassword: redisPassword || undefined, + redis: hasRedis, }); - const configPath = resolve(cwd, 'shipnode.config.ts'); - if (await pathExists(configPath)) { - ui.warn('shipnode.config.ts already exists. Use --force to overwrite.'); - return; - } - await writeFile(configPath, config, 'utf-8'); - await generateShipnodeDir(cwd, users); + ui.success('Created shipnode.config.ts'); + await generateShipnodeDir(cwd); - ui.outro('Ready! Run shipnode setup to provision your server.'); + ui.note( + [ + 'shipnode setup # once: installs Node, PM2, Caddy… on the server', + 'shipnode deploy # every time you ship', + ].join('\n'), + 'Next', + ); + if (domain) ui.info(`Point ${domain}'s DNS A record at ${sshHost as string} before deploying.`); + ui.outro('Ready!'); } async function getAppName(cwd: string): Promise { @@ -268,50 +183,21 @@ interface ConfigOptions { app: 'backend' | 'frontend'; appName: string; sshHost?: string; - sshUser?: string; - sshPort?: number; - remotePath?: string; - pm2Name?: string; - wattMain?: string; backendPort?: number; domain?: string; - healthCheckPath?: string; pkgManager?: string; dbType?: DatabaseType; - dbHost?: string; - dbPort?: number; - dbName?: string; - dbUser?: string; - dbPassword?: string; - redisHost?: string; - redisPort?: number; - redisPassword?: string; + redis?: boolean; } -async function generateShipnodeDir( - cwd: string, - users: Array<{ username: string; publicKey: string; sudo: boolean }> = [], -): Promise { - const dir = resolve(cwd, '.shipnode'); - await ensureDir(dir); +async function generateShipnodeDir(cwd: string): Promise { + await ensureDir(resolve(cwd, '.shipnode')); const ignorePath = resolve(cwd, '.shipnodeignore'); if (!(await pathExists(ignorePath))) { await writeFile(ignorePath, generateShipnodeIgnore(), 'utf-8'); + ui.success('Generated .shipnodeignore'); } - - if (users.length > 0) { - const usersPath = resolve(dir, 'users.yml'); - const yaml = users.map((u) => [ - `- username: ${u.username}`, - ` publicKey: ${u.publicKey}`, - ` sudo: ${u.sudo}`, - ].join('\n')).join('\n') + '\n'; - await writeFile(usersPath, yaml, 'utf-8'); - ui.success(`Created .shipnode/users.yml with ${users.length} user(s)`); - } - - ui.success('Generated .shipnodeignore'); } function generateShipnodeIgnore(): string { @@ -328,74 +214,48 @@ coverage/ `; } -function generateConfig(opts: ConfigOptions): string { +const DEFAULT_DB_PORT: Record, number> = { + postgres: 5432, + mysql: 3306, + mongodb: 27017, +}; + +export function generateConfig(opts: ConfigOptions): string { const lines = [ "import { shipnode } from '@devalade/shipnode';", '', 'export default shipnode', ` .${opts.app}()`, + // 'deploy' is the user `shipnode setup` creates. Setup itself logs in as + // root the first time, while that user does not exist yet. + ` .ssh({ host: '${opts.sshHost ?? 'YOUR_SERVER_IP'}', user: 'deploy' })`, + ` .deployTo('/var/www/${opts.appName}')`, ]; - const sshOpts: string[] = []; - if (opts.sshHost) sshOpts.push(`host: '${opts.sshHost}'`); - if (opts.sshUser) sshOpts.push(`user: '${opts.sshUser}'`); - sshOpts.push(`port: ${opts.sshPort ?? 22}`); - - if (sshOpts.length > 0) { - lines.push(` .ssh({ ${sshOpts.join(', ')} })`); - } - - if (opts.remotePath) { - lines.push(` .deployTo('${opts.remotePath}')`); - } - - if (opts.pm2Name) { - lines.push(` .pm2('${opts.pm2Name}')`); - } - if (opts.app === 'backend') { + lines.push(` .pm2('${opts.appName}')`); lines.push(` .port(${opts.backendPort ?? 3000})`); } - if (opts.wattMain) { - lines.push(` .runtime('watt', { main: '${opts.wattMain}' })`); - } - if (opts.domain) { lines.push(` .domain('${opts.domain}')`); } - if (opts.healthCheckPath) { - lines.push(` .healthCheck('${opts.healthCheckPath}')`); - } - if (opts.pkgManager) { lines.push(` .pkgManager('${opts.pkgManager}')`); } - if (opts.dbType) { - if (opts.dbType === 'sqlite') { - lines.push(` .database({ type: 'sqlite', name: '${opts.dbName ?? './data.db'}' })`); - } else { - const dbOpts = [ - `type: '${opts.dbType}'`, - `host: '${opts.dbHost ?? 'localhost'}'`, - `port: ${opts.dbPort ?? 5432}`, - `name: '${opts.dbName ?? ''}'`, - `user: '${opts.dbUser ?? ''}'`, - ...(opts.dbPassword ? [`password: process.env.DB_PASSWORD ?? '${opts.dbPassword}'`] : [`// password: process.env.DB_PASSWORD`]), - ]; - lines.push(` .database({ ${dbOpts.join(', ')} })`); - } + if (opts.dbType === 'sqlite') { + lines.push(` .database({ type: 'sqlite', name: './data.db' })`); + } else if (opts.dbType) { + lines.push( + ` .database({ type: '${opts.dbType}', host: 'localhost', port: ${DEFAULT_DB_PORT[opts.dbType]}, ` + + `name: '${opts.appName}', user: '${opts.appName}', password: process.env.DB_PASSWORD })`, + ); } - if (opts.redisHost !== undefined || opts.redisPort !== undefined) { - const redisOpts = [ - `host: '${opts.redisHost ?? 'localhost'}'`, - `port: ${opts.redisPort ?? 6379}`, - ...(opts.redisPassword ? [`password: process.env.REDIS_PASSWORD ?? '${opts.redisPassword}'`] : [`// password: process.env.REDIS_PASSWORD`]), - ]; - lines.push(` .redis({ ${redisOpts.join(', ')} })`); + if (opts.redis) { + lines.push(` .redis({ host: 'localhost', port: 6379, password: process.env.REDIS_PASSWORD })`); } lines.push(' .build();'); diff --git a/src/cli/commands/logs.ts b/src/cli/commands/logs.ts index 9b54454..cad1c66 100644 --- a/src/cli/commands/logs.ts +++ b/src/cli/commands/logs.ts @@ -1,5 +1,33 @@ +import chalk from 'chalk'; import { runRemoteCommandForTargets } from '../runner.js'; import { isWatt, logsCommand, resolveWattUnits } from '../../domain/runtime/watt.js'; +import { loadConfig } from '../../config/loader.js'; +import { + compileQuery, + EMPTY_LOG_FILTER, + matchesLogFilter, + parseLevelOption, + type LogFilter, +} from '../../domain/observe/log-filter.js'; +import { detectLevel, isContinuation, LOG_LEVEL_RANK, type LogLevel, type LogLine } from '../../domain/observe/log-line.js'; +import { planLogSources } from '../../domain/observe/log-source.js'; +import { getPm2Name } from '../../domain/pm2/apps.js'; +import { LogStream, type LogBinding } from '../../services/observe/log-stream.js'; +import { connectFleet, planObserveHosts } from '../observe.js'; +import { ui } from '../ui.js'; + +export interface LogsOptions { + lines?: number; + config?: string; + process?: string; + app?: string; + on?: string; + follow?: boolean; + /** Show this level and above: warn or error. */ + level?: string; + /** Plain text, `/regex/`, or `!not`. */ + grep?: string; +} /** * Prefix every line, not every block. @@ -8,15 +36,58 @@ import { isWatt, logsCommand, resolveWattUnits } from '../../domain/runtime/watt * leaves the reader guessing which box a stack trace came from once the second * server's output starts. */ -function prefixLines(text: string, label: string): string { +function prefixLines(text: string, label: string, keep: (line: string) => boolean): string { return text .split('\n') - .filter((line) => line.length > 0) + .filter((line) => line.length > 0 && keep(line)) .map((line) => `${label} ${line}`) .join('\n'); } -export async function cmdLogs(cwd: string, options: { lines?: number; config?: string; process?: string; app?: string; on?: string }): Promise { +/** + * Level and text filtering for raw text, where no stream has classified the + * line yet. A stack frame takes the level of the line above it, as it does in + * `--follow`, so `--level error` keeps a trace with its error. Stateful: make + * one per block of output. + */ +export function textFilter(filter: LogFilter): (line: string) => boolean { + const { matcher } = compileQuery(filter.query); + const min = filter.minLevel === null ? 0 : LOG_LEVEL_RANK[filter.minLevel]; + let previous: LogLevel | undefined; + return (line) => { + const detected = detectLevel(line); + const level = detected ?? (isContinuation(line) ? previous : undefined) ?? 'info'; + if (detected !== undefined) previous = detected; + return LOG_LEVEL_RANK[level] >= min && (matcher === null || matcher(line)); + }; +} + +function filterFromOptions(options: LogsOptions): LogFilter | null { + const level = parseLevelOption(options.level); + if (level.error !== undefined) { + ui.error(level.error); + return null; + } + const { error } = compileQuery(options.grep ?? ''); + if (error !== undefined) { + ui.error(`Invalid --grep: ${error}`); + return null; + } + return { ...EMPTY_LOG_FILTER, minLevel: level.level, query: options.grep ?? '' }; +} + +export async function cmdLogs(cwd: string, options: LogsOptions): Promise { + const filter = filterFromOptions(options); + if (filter === null) { + process.exit(1); + return; + } + if (options.follow) { + await followLogs(cwd, options, filter); + return; + } + + const keep = (): ((line: string) => boolean) => textFilter(filter); await runRemoteCommandForTargets( cwd, async ({ config, executor, serverName }) => { @@ -45,8 +116,8 @@ export async function cmdLogs(cwd: string, options: { lines?: number; config?: s for (const unit of units) { const result = await executor.exec(logsCommand(unit, { lines })); const label = `[${serverName} ${app.name} ${unit}]`; - if (result.stdout) process.stdout.write(`${prefixLines(result.stdout, label)}\n`); - if (result.stderr) process.stderr.write(`${prefixLines(result.stderr, label)}\n`); + if (result.stdout) process.stdout.write(`${prefixLines(result.stdout, label, keep())}\n`); + if (result.stderr) process.stderr.write(`${prefixLines(result.stderr, label, keep())}\n`); } continue; } @@ -58,10 +129,101 @@ export async function cmdLogs(cwd: string, options: { lines?: number; config?: s `${mise}; mise exec "node@${nodeVersion}" -- pm2 logs ${target} --lines ${lines} --nostream`, ); const label = `[${serverName} ${app.name}]`; - if (result.stdout) process.stdout.write(`${prefixLines(result.stdout, label)}\n`); - if (result.stderr) process.stderr.write(`${prefixLines(result.stderr, label)}\n`); + if (result.stdout) process.stdout.write(`${prefixLines(result.stdout, label, keep())}\n`); + if (result.stderr) process.stderr.write(`${prefixLines(result.stderr, label, keep())}\n`); } }, { configPath: options.config, appName: options.app, serverName: options.on }, ); } + +const SERVER_COLORS = [chalk.cyan, chalk.magenta, chalk.blue, chalk.greenBright, chalk.whiteBright]; + +/** + * `shipnode logs --follow`: every server at once, merged, until Ctrl-C. + * + * The same stream the monitor uses, so what counts as an error and how a + * reconnect avoids replaying lines are not re-implemented for the CLI. Lines go + * to stdout and status to stderr, so `shipnode logs -f | grep ...` stays clean. + */ +async function followLogs(cwd: string, options: LogsOptions, initialFilter: LogFilter): Promise { + let filter = initialFilter; + const config = await loadConfig(cwd, options.config); + const plan = planObserveHosts(config, { app: options.app, on: options.on }); + if (plan.isErr()) { + ui.error(plan.error.message); + process.exit(1); + return; + } + + const fleet = await connectFleet(config, plan.value); + try { + // A process name only means something within one app; the stream labels + // lines with the full PM2-style name, so translate the short one once. + if (options.process !== undefined) { + const candidates = [...new Map(fleet.hosts.flatMap((host) => host.apps).map((app) => [app.name, app])).values()]; + if (candidates.length !== 1) { + ui.error('--process requires --app to target a specific app'); + process.exit(1); + return; + } + const app = candidates[0]; + const declared = app.pm2?.apps ?? []; + if (!declared.some((p) => p.name === options.process)) { + ui.error(`No process named '${options.process}'. Known: ${declared.map((p) => p.name).join(', ') || '(none)'}`); + process.exit(1); + return; + } + filter = { ...filter, process: getPm2Name(declared[0].name, options.process) }; + } + + const bindings: LogBinding[] = []; + for (const host of fleet.hosts) { + if (host.executor === null) { + ui.warn(`${host.name}: ${host.error ?? 'unreachable'}`); + continue; + } + const apps = options.app === undefined ? host.apps : host.apps.filter((app) => app.name === options.app); + for (const app of apps) { + try { + const sources = await planLogSources(host.executor, host.name, host.config.remotePath, app); + for (const source of sources) bindings.push({ executor: host.executor, source }); + } catch (cause: unknown) { + ui.warn(`${host.name}/${app.name}: ${cause instanceof Error ? cause.message : String(cause)}`); + } + } + } + + if (bindings.length === 0) { + ui.error('Nothing to follow: no reachable server runs a matching app.'); + process.exit(1); + return; + } + + const serverNames = [...new Set(bindings.map((binding) => binding.source.server))].sort(); + const multiServer = serverNames.length > 1; + const multiApp = new Set(bindings.map((binding) => binding.source.app)).size > 1; + + const print = (line: LogLine): void => { + if (!matchesLogFilter(line, filter)) return; + const who = line.process === null || line.process === line.app ? '' : `:${line.process}`; + const head = [multiServer ? line.server : null, multiApp ? line.app : null].filter((p) => p !== null).join(' '); + const label = head === '' && who === '' ? '' : `[${head}${who}] `; + const color = SERVER_COLORS[serverNames.indexOf(line.server) % SERVER_COLORS.length]; + const text = line.level === 'error' ? chalk.red(line.text) : line.level === 'warn' ? chalk.yellow(line.text) : line.text; + process.stdout.write(`${label === '' ? '' : color(label)}${text}\n`); + }; + + const stream = new LogStream({ bindings, backlog: options.lines ?? 100, onLine: print }); + stream.start(); + ui.info(`Following ${bindings.length} source(s) on ${serverNames.join(', ')} — Ctrl-C to stop`); + + await new Promise((resolve) => { + process.once('SIGINT', resolve); + process.once('SIGTERM', resolve); + }); + await stream.stop(); + } finally { + fleet.close(); + } +} diff --git a/src/cli/commands/monitor.ts b/src/cli/commands/monitor.ts index 3c16903..2b03885 100644 --- a/src/cli/commands/monitor.ts +++ b/src/cli/commands/monitor.ts @@ -1,14 +1,7 @@ import { loadConfig } from '../../config/loader.js'; -import { SshConnection } from '../../infrastructure/ssh/connection.js'; -import { configForServer } from '../../domain/servers.js'; import type { ShipnodeConfig } from '../../shared/types.js'; import { runMonitor } from '../monitor/index.js'; -import { - getAccessoriesForMonitorTarget, - getAppsForMonitorTarget, - resolveMonitorSession, -} from '../monitor/monitor-session.js'; -import { observeStateJson, printObserveStatus, takeSnapshot } from '../observe.js'; +import { connectFleet, observeStateJson, planObserveHosts, printObserveStatus, takeSnapshot } from '../observe.js'; import { ui } from '../ui.js'; export async function cmdMonitor( @@ -49,50 +42,24 @@ async function runLiveDashboard( config: ShipnodeConfig, options: { app?: string; on?: string; interval: number }, ): Promise { - const session = resolveMonitorSession(config, options.app, options.on); - if (session.isErr()) { - ui.error(session.error.message); + // The whole workspace by default; --app and --on narrow it. A fleet is + // exactly what this view is for, so nothing here insists on a single replica. + const plan = planObserveHosts(config, { app: options.app, on: options.on }); + if (plan.isErr()) { + ui.error(plan.error.message); process.exit(1); return; } - - const apps = getAppsForMonitorTarget(config, session.value.target.name); - if (apps.isErr()) { - ui.error(apps.error.message); - process.exit(1); - return; - } - const accessoryNames = getAccessoriesForMonitorTarget(config, session.value.target.name); - if (accessoryNames.isErr()) { - ui.error(accessoryNames.error.message); - process.exit(1); - return; - } - - const { target, app } = session.value; - const host = `${target.ssh.user}@${target.ssh.host}:${target.ssh.port}`; - const ssh = new SshConnection(); - try { - await ssh.connect(target.ssh); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - ui.error(`Failed to connect to ${host}: ${msg}`); + if (plan.value.length === 0) { + ui.error('Nothing to monitor: no server runs an app or accessory.'); process.exit(1); return; } + const fleet = await connectFleet(config, plan.value); try { - await runMonitor({ - executor: ssh, - config: configForServer(config, target.name), - app, - apps: apps.value, - accessoryNames: accessoryNames.value, - targetName: target.name, - host, - interval: options.interval, - }); + await runMonitor({ fleet, interval: options.interval, focusApp: options.app }); } finally { - ssh.disconnect(); + fleet.close(); } } diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 2f05636..306e67d 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -29,18 +29,18 @@ interface SetupOptions { export async function cmdSetup(cwd: string, options: SetupOptions): Promise { await runRemoteCommandForTargets( cwd, - async ({ config, executor, serverName }) => { + async ({ config, executor, serverName, ssh }) => { ui.banner(); - ui.step(`Setting up ${serverName} (${config.ssh.user}@${config.ssh.host})`); + ui.step(`Setting up ${serverName} (${ssh.user}@${ssh.host})`); const created = !options.noDeployUser && (await bootstrapDeployUser(cwd, config, executor)); await buildTasks(executor, config, created ? DEPLOY_USER : null).run(); - if (created) { + if (created && config.ssh.user !== DEPLOY_USER) { ui.note( [ `A '${DEPLOY_USER}' user was created and owns ${config.remotePath}.`, `Switch ssh.user in shipnode.config.ts to '${DEPLOY_USER}', then:`, - ` shipnode harden # disable root SSH`, ` shipnode deploy`, + ` shipnode harden # optional: disable root and password SSH`, ].join('\n'), 'Next steps', ); @@ -48,7 +48,18 @@ export async function cmdSetup(cwd: string, options: SetupOptions): Promise (ssh.user === DEPLOY_USER ? { ...ssh, user: 'root' } : undefined), + }, ); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 7e33c20..a69c226 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -55,6 +55,8 @@ program program .command('init') .description('Initialize a new shipnode.config.ts') + .option('--host ', "Your server's IP address or hostname") + .option('--domain ', 'Domain to serve the app on (HTTPS is automatic)') .option('--non-interactive', 'Generate config without prompts') .option('--print', 'Print config to stdout without writing file') .action((opts) => cmdInit(process.cwd(), opts)); @@ -139,13 +141,25 @@ program program .command('logs') - .description('Show application logs') - .option('--lines ', 'Number of log lines to show', '100') + .description('Show application logs (add --follow to stream every server live)') + .option('--lines ', 'Number of log lines to show (also the backlog for --follow)', '100') + .option('-f, --follow', 'Stream new lines from every server until Ctrl-C') + .option('--level ', 'Only this level and above: warn or error') + .option('--grep ', 'Only lines matching text, /regex/, or !text to exclude') .option('--process ', 'Target a specific process (PM2 app or watt unit)') .option('--app ', 'Target a specific app') .option('--config ', 'Use a specific config file') .option('--on ', 'Target a specific server') - .action((opts) => cmdLogs(process.cwd(), { lines: parseInt(opts.lines, 10), process: opts.process, app: opts.app, config: opts.config, on: opts.on })); + .action((opts) => cmdLogs(process.cwd(), { + lines: parseInt(opts.lines, 10), + follow: opts.follow, + level: opts.level, + grep: opts.grep, + process: opts.process, + app: opts.app, + config: opts.config, + on: opts.on, + })); program .command('restart') @@ -175,10 +189,10 @@ program program .command('monitor') - .description('Live TUI dashboard with process stats (PM2 or systemd), system metrics, and logs') + .description('Live TUI dashboard across every server: fleet overview, per-replica detail, and filterable streaming logs') .option('--interval ', 'Polling interval in seconds (default: 2)', '2') - .option('--app ', 'Target a specific app') - .option('--on ', 'Watch one replica (required for a live TUI on a fleet)') + .option('--app ', 'Watch a specific app') + .option('--on ', 'Watch one server instead of the whole fleet') .option('--once', 'Collect one snapshot and exit') .option('--json', 'Print one snapshot as JSON (implies --once)') .option('--config ', 'Use a specific config file') diff --git a/src/cli/monitor/App.tsx b/src/cli/monitor/App.tsx index 09d422a..86e8bdb 100644 --- a/src/cli/monitor/App.tsx +++ b/src/cli/monitor/App.tsx @@ -1,116 +1,190 @@ -import { Box, useInput, useApp, useStdout } from 'ink'; -import { useState } from 'react'; -import type { RemoteExecutor } from '../../domain/remote/executor.js'; -import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js'; +import { Box, Text, useApp, useInput, useStdout } from 'ink'; +import { useEffect, useReducer, useRef, useState } from 'react'; +import { HEALTH_ALERT_THRESHOLD } from '../../services/observe/session.js'; +import type { ObserveEvent } from '../../services/observe/events.js'; +import { applyLogFilter, countLevels, logFacets } from '../../domain/observe/log-filter.js'; +import type { FleetConnection } from '../observe.js'; import { Pm2Panel } from './panels/Pm2Panel.js'; import { SystemPanel } from './panels/SystemPanel.js'; -import { ReleasePanel } from './panels/ReleasePanel.js'; -import { LogPanel } from './panels/LogPanel.js'; +import { ReleasePanel, releasePanelHeight } from './panels/ReleasePanel.js'; import { EventsPanel } from './panels/EventsPanel.js'; +import { AppsPanel, ServersPanel, serversPanelHeight } from './panels/FleetPanel.js'; +import { LOG_VIEWER_CHROME, LogViewer } from './panels/LogViewer.js'; import { StaticFrontendPanel } from './panels/StaticFrontendPanel.js'; import { AccessoriesPanel } from './panels/AccessoriesPanel.js'; -import { AppSelector } from './app-selector.js'; import { HelpOverlay } from './components/HelpOverlay.js'; import { ConfirmDialog } from './components/ConfirmDialog.js'; import { restartProcess, rollbackToRelease } from './actions.js'; -import chalk from 'chalk'; -import { useMonitorData, HEALTH_ALERT_THRESHOLD } from './hooks/use-monitor-data.js'; -import { useLiveLogs } from './hooks/use-live-logs.js'; -import { MonitorFrame, WaitingPanel } from './layout/MonitorFrame.js'; +import { MonitorFrame, type Flash, type HeaderAlert } from './layout/MonitorFrame.js'; +import { Panel } from './components/Panel.js'; +import type { Hint } from './components/KeyHints.js'; +import type { Tone } from './theme.js'; +import { buildFleetRows, toMetricsSnapshot } from './fleet-model.js'; +import { INITIAL_LOG_VIEW, logViewReducer } from './log-view-state.js'; +import { useFleet } from './hooks/use-fleet.js'; +import { useLogStream } from './hooks/use-log-stream.js'; -type View = 'dashboard' | 'logs'; -type Overlay = 'none' | 'selector' | 'help' | 'confirmRestart' | 'confirmRollback'; +type View = 'fleet' | 'detail' | 'logs'; +type Overlay = 'none' | 'help' | 'confirmRestart' | 'confirmRollback'; + +/** Lines of log or activity in the strip under the fleet and replica views. */ +const STRIP_ROWS = 5; +/** The strip's panel: its lines plus top and bottom edges. */ +const BOTTOM_HEIGHT = STRIP_ROWS + 2; +const MAX_ALERTS = 3; +const FLASH_MS = 4000; interface AppProps { - executor: RemoteExecutor; - config: ShipnodeConfig; - app: ShipnodeApp; - apps: ShipnodeApp[]; - accessoryNames: string[]; - targetName: string; - host: string; + fleet: FleetConnection; interval: number; + /** Open on this app instead of the fleet overview. */ + focusApp?: string; } -export function App({ executor, config, app: initialApp, apps, accessoryNames, targetName, host, interval }: AppProps) { +export function App({ fleet: connection, interval, focusApp }: AppProps) { const { exit } = useApp(); const { stdout } = useStdout(); - const [currentApp, setCurrentApp] = useState(initialApp); - const [view, setView] = useState('dashboard'); + const { state, session, refresh } = useFleet(connection.targets, interval); + const [view, setView] = useState('fleet'); + const [returnTo, setReturnTo] = useState>('fleet'); const [overlay, setOverlay] = useState('none'); - const [liveMode, setLiveMode] = useState(false); - const [logFilter, setLogFilter] = useState(null); - const [logSearch, setLogSearch] = useState(''); - const [searchTyping, setSearchTyping] = useState(false); - const [logsPaused, setLogsPaused] = useState(false); - const [selectedRow, setSelectedRow] = useState(0); - const monitor = useMonitorData(executor, config, currentApp, interval, accessoryNames); - const liveActive = liveMode || view === 'logs'; - const liveLogs = useLiveLogs(executor, currentApp, liveActive && !logsPaused, interval, logFilter); - - // On the 24-row terminals this layout was tuned for, the releases box has - // room for exactly one row; taller terminals get a few more so a rollback - // selection made with ↑/↓ is actually visible before confirming. + const [stripOn, setStripOn] = useState(false); + const [selectedKey, setSelectedKey] = useState(null); + const [detailKey, setDetailKey] = useState(null); + const [detailRow, setDetailRow] = useState(0); + const [logView, dispatchLog] = useReducer(logViewReducer, INITIAL_LOG_VIEW); + const [flash, setFlash] = useState(null); + const flashTimer = useRef | null>(null); + + /** Say something in the footer for a moment. */ + const showFlash = (next: Flash): void => { + setFlash(next); + if (flashTimer.current !== null) clearTimeout(flashTimer.current); + flashTimer.current = setTimeout(() => setFlash(null), FLASH_MS); + }; + + /** Flash it and keep it in the activity log. */ + const notify = (tone: Tone, text: string): void => { + session.notice(text); + showFlash({ tone, text }); + }; + useEffect(() => () => { + if (flashTimer.current !== null) clearTimeout(flashTimer.current); + }, []); + + const rows = buildFleetRows(state.fleets); + const logs = useLogStream(connection.hosts, stripOn || view === 'logs'); const terminalRows = stdout?.rows ?? 24; - const releaseBoxExtra = Math.max(0, Math.min(6, terminalRows - 30)); - - // Selection runs over processes first, then the visible release rows, so - // ↓ walks straight from the PM2 panel into the Releases panel. - const processes = monitor.snapshot?.processes ?? []; - const maxReleases = accessoryNames.length > 0 ? 4 : 5; - const releaseRows = (monitor.snapshot?.releases ?? []).slice(0, maxReleases); - const totalRows = processes.length + releaseRows.length; - const selectedIndex = totalRows === 0 ? 0 : Math.min(selectedRow, totalRows - 1); - const selectedInfo = selectedIndex < processes.length ? processes[selectedIndex] : undefined; - const selectedRelease = - selectedIndex >= processes.length ? releaseRows[selectedIndex - processes.length] : undefined; - const currentTimestamp = monitor.snapshot?.currentRelease?.split('/').pop() ?? null; - const alertStreak = - monitor.healthFailStreak >= HEALTH_ALERT_THRESHOLD ? monitor.healthFailStreak : 0; + const terminalCols = stdout?.columns ?? 100; + + // ── Fleet selection ────────────────────────────────────────────── + const selectedAt = Math.max(0, rows.findIndex((row) => row.key === selectedKey)); + const selected = rows[selectedAt]; + + // A fleet of one is not worth an overview: open it directly, once. + const autoOpened = useRef(false); + useEffect(() => { + if (autoOpened.current || rows.length === 0) return; + autoOpened.current = true; + const focus = focusApp === undefined ? undefined : rows.find((row) => row.app === focusApp); + const target = rows.length === 1 ? rows[0] : focus; + if (target !== undefined && rows.length === 1) { + setDetailKey(target.key); + setView('detail'); + setReturnTo('detail'); + } + setSelectedKey((focus ?? rows[0]).key); + }, [rows.length]); + + // ── Detail target ──────────────────────────────────────────────── + const detail = view === 'detail' || overlay.startsWith('confirm') ? rows.find((row) => row.key === detailKey) : undefined; + const detailHost = detail === undefined ? undefined : connection.hosts.find((host) => host.name === detail.server); + const detailApp = detail === undefined ? undefined : detailHost?.apps.find((app) => app.name === detail.app); + const detailServer = detail === undefined ? undefined : state.servers.find((server) => server.server === detail.server); + const detailSnapshot = detailServer !== undefined && detail?.snapshot !== undefined ? toMetricsSnapshot(detailServer, detail.snapshot) : null; + const detailHistory = detail === undefined ? undefined : session.history(detail.server, detail.app); + + const processes = detailSnapshot?.processes ?? []; + const maxReleases = (detailHost?.accessoryNames.length ?? 0) > 0 ? 4 : 5; + const releaseRows = (detailSnapshot?.releases ?? []).slice(0, maxReleases); + const totalDetailRows = processes.length + releaseRows.length; + const detailIndex = totalDetailRows === 0 ? 0 : Math.min(detailRow, totalDetailRows - 1); + const selectedProcess = detailIndex < processes.length ? processes[detailIndex] : undefined; + const selectedRelease = detailIndex >= processes.length ? releaseRows[detailIndex - processes.length] : undefined; + const currentTimestamp = detailSnapshot?.currentRelease?.split('/').pop() ?? null; + + // ── Logs ───────────────────────────────────────────────────────── + const source = logView.frozen ?? logs.lines; + const filtered = applyLogFilter(source, logView.filter); + const counts = countLevels(source, logView.filter); + const facets = logFacets(logs.lines, logView.filter); + const serverNames = connection.hosts.map((host) => host.name).sort(); + const showServer = serverNames.length > 1; + const showApp = new Set(logs.lines.map((line) => line.app)).size > 1; + const troubled = logs.health.filter((entry) => entry.state !== 'live'); + + // ── Alerts ─────────────────────────────────────────────────────── + const alerts: HeaderAlert[] = []; + for (const server of state.servers) { + if (server.error !== undefined) alerts.push({ text: `${server.server} unreachable` }); + else if (server.deployLock != null) alerts.push({ text: `${server.server} deploy lock ${server.deployLock.ageSeconds}s` }); + } + for (const row of state.fleets.flatMap((f) => f.replicas.map((r) => ({ app: f.app, server: r.server })))) { + const streak = session.healthFailStreak(row.server, row.app); + if (streak >= HEALTH_ALERT_THRESHOLD) alerts.push({ text: `${row.app} on ${row.server} health failing ×${streak}` }); + } + const shownAlerts = alerts.slice(0, MAX_ALERTS); + if (alerts.length > MAX_ALERTS) shownAlerts.push({ text: `+${alerts.length - MAX_ALERTS} more` }); + + // ── Height budget ──────────────────────────────────────────────── + // Every view gets the terminal minus the header, the alert bar when shown, and the footer. + const bodyHeight = Math.max(8, terminalRows - 2 - (shownAlerts.length > 0 ? 1 : 0)); + const logHeight = Math.max(3, bodyHeight - LOG_VIEWER_CHROME); + const maxScroll = Math.max(0, filtered.lines.length - logHeight); + + // Ring the terminal bell once when a health check starts failing. + const lastEvent = useRef(undefined); + useEffect(() => { + const events = state.events; + const from = lastEvent.current === undefined ? 0 : events.indexOf(lastEvent.current) + 1; + if (events.slice(from).some((event) => event.kind === 'health-failing')) stdout?.write('\x07'); + lastEvent.current = events[events.length - 1]; + }, [state.events]); + // ── Actions ────────────────────────────────────────────────────── const confirmRestart = async (): Promise => { setOverlay('none'); - if (selectedInfo === undefined) return; - monitor.appendEvent(chalk.yellow(`Restarting ${selectedInfo.pm2Name}…`)); - const result = await restartProcess(executor, selectedInfo.pm2Name, selectedInfo.supervisor); - if (result.isOk()) { - monitor.appendEvent(chalk.green(`Restarted ${chalk.bold(selectedInfo.pm2Name)}`)); - } else { - monitor.appendEvent(chalk.red(result.error.message)); - } - void monitor.refresh(); + if (selectedProcess === undefined || detailHost?.executor == null) return; + notify('info', `Restarting ${selectedProcess.pm2Name} on ${detailHost.name}…`); + const result = await restartProcess(detailHost.executor, selectedProcess.pm2Name, selectedProcess.supervisor); + if (result.isOk()) notify('ok', `Restarted ${selectedProcess.pm2Name} on ${detailHost.name}`); + else notify('bad', result.error.message); + void refresh(); }; const confirmRollback = async (): Promise => { setOverlay('none'); - if (selectedRelease === undefined) return; - monitor.appendEvent(chalk.yellow(`Rolling back to ${selectedRelease.timestamp}…`)); - const result = await rollbackToRelease(executor, config, currentApp, selectedRelease.timestamp); - if (result.isOk()) { - monitor.appendEvent(chalk.green(`Rolled back to ${chalk.bold(selectedRelease.timestamp)}`)); - } else { - monitor.appendEvent(chalk.red(result.error.message)); - } - void monitor.refresh(); + if (selectedRelease === undefined || detailHost?.executor == null || detailApp === undefined) return; + notify('info', `Rolling back ${detailApp.name} on ${detailHost.name} to ${selectedRelease.timestamp}…`); + const result = await rollbackToRelease(detailHost.executor, detailHost.config, detailApp, selectedRelease.timestamp); + if (result.isOk()) notify('ok', `Rolled back ${detailApp.name} to ${selectedRelease.timestamp}`); + else notify('bad', result.error.message); + void refresh(); }; - const exitLogsView = (): void => { - setView('dashboard'); - setSearchTyping(false); - setLogSearch(''); - setLogsPaused(false); + const openLogs = (from: Exclude): void => { + const scope = from === 'detail' ? detail : selected; + dispatchLog({ type: 'enter', server: from === 'detail' ? (scope?.server ?? null) : null, app: scope?.app ?? null }); + setReturnTo(from); + setView('logs'); }; - const cycleLogFilter = (direction: 1 | -1): void => { - if (currentApp.appType === 'frontend') return; - const names = monitor.snapshot?.processes.map((p) => p.pm2Name) ?? []; - if (names.length === 0) return; - const cycle: Array = [null, ...names]; - const index = cycle.indexOf(logFilter); - const next = cycle[(index + direction + cycle.length) % cycle.length]; - setLogFilter(next); + const closeLogs = (): void => { + dispatchLog({ type: 'resume' }); + setView(returnTo); }; + // ── Keys ───────────────────────────────────────────────────────── useInput((input, key) => { if (overlay === 'help') { setOverlay('none'); @@ -119,24 +193,12 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t if (overlay !== 'none') return; // While typing a search query every printable key belongs to the query, - // including q/f/r — this branch must stay ahead of the global bindings. - if (view === 'logs' && searchTyping) { - if (key.return) { - setSearchTyping(false); - return; - } - if (key.escape) { - setSearchTyping(false); - setLogSearch(''); - return; - } - if (key.backspace || key.delete) { - setLogSearch((s) => s.slice(0, -1)); - return; - } - if (input !== '' && !key.ctrl && !key.meta) { - setLogSearch((s) => s + input); - } + // including q/f/r - this branch must stay ahead of the global bindings. + if (view === 'logs' && logView.typing) { + if (key.return) dispatchLog({ type: 'commit-search' }); + else if (key.escape) dispatchLog({ type: 'cancel-search' }); + else if (key.backspace || key.delete) dispatchLog({ type: 'backspace' }); + else if (input !== '' && !key.ctrl && !key.meta) dispatchLog({ type: 'type', text: input }); return; } @@ -149,90 +211,90 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t return; } if (input === 'r' || input === 'R') { - void monitor.refresh(); - monitor.appendEvent(chalk.green('Refresh triggered')); - return; - } - if (input === 'f' || input === 'F') { - if (view === 'logs') exitLogsView(); - else setView('logs'); + void refresh(); + showFlash({ tone: 'muted', text: 'refreshing…' }); return; } if (view === 'logs') { - if (key.escape) exitLogsView(); - if (key.leftArrow) cycleLogFilter(-1); - if (key.rightArrow) cycleLogFilter(1); - if (input === '/') { - setSearchTyping(true); - setLogSearch(''); + if (input === 'f' || input === 'F' || key.escape) return closeLogs(); + if (input === 's' || input === 'S') return dispatchLog({ type: 'cycle', dimension: 'server', direction: input === 's' ? 1 : -1, facets }); + if (input === 'a' || input === 'A') return dispatchLog({ type: 'cycle', dimension: 'app', direction: input === 'a' ? 1 : -1, facets }); + if (input === 'p' || input === 'P') return dispatchLog({ type: 'cycle', dimension: 'process', direction: input === 'p' ? 1 : -1, facets }); + if (input === 'v') return dispatchLog({ type: 'cycle-level' }); + if (input === 'm') return dispatchLog({ type: 'toggle-mode' }); + if (input === '/') return dispatchLog({ type: 'start-search' }); + if (input === 'c') return dispatchLog({ type: 'clear-filters' }); + if (input === 'C') { + dispatchLog({ type: 'resume' }); + return logs.clear(); } - if (input === ' ') setLogsPaused((p) => !p); + if (input === ' ') return dispatchLog({ type: 'toggle-pause', live: logs.lines }); + if (input === 'G' || key.end) return dispatchLog({ type: 'resume' }); + if (key.upArrow) return dispatchLog({ type: 'scroll', delta: 1, live: logs.lines, max: maxScroll }); + if (key.downArrow) return dispatchLog({ type: 'scroll', delta: -1, live: logs.lines, max: maxScroll }); + if (key.pageUp) return dispatchLog({ type: 'scroll', delta: logHeight - 1, live: logs.lines, max: maxScroll }); + if (key.pageDown) return dispatchLog({ type: 'scroll', delta: -(logHeight - 1), live: logs.lines, max: maxScroll }); return; } - if (key.tab) { - setOverlay('selector'); + if (input === 'f' || input === 'F') return openLogs(view); + if (input === 'l' || input === 'L') { + setStripOn((on) => !on); return; } - if (key.upArrow) { - setSelectedRow(Math.max(0, selectedIndex - 1)); + + if (view === 'fleet') { + if (key.upArrow) setSelectedKey(rows[Math.max(0, selectedAt - 1)]?.key ?? null); + else if (key.downArrow) setSelectedKey(rows[Math.min(rows.length - 1, selectedAt + 1)]?.key ?? null); + else if (key.return && selected !== undefined) { + setDetailKey(selected.key); + setDetailRow(0); + setReturnTo('fleet'); + setView('detail'); + } return; } - if (key.downArrow) { - setSelectedRow(Math.min(Math.max(totalRows - 1, 0), selectedIndex + 1)); + + // detail + if (key.escape) { + setView('fleet'); return; } + if (key.upArrow) return setDetailRow(Math.max(0, detailIndex - 1)); + if (key.downArrow) return setDetailRow(Math.min(Math.max(totalDetailRows - 1, 0), detailIndex + 1)); if (key.return || input === 'x' || input === 'X') { - if (selectedInfo !== undefined) { - if (monitor.snapshot?.deployLock != null) { - monitor.appendEvent(chalk.red('Restart blocked: a deploy is in progress (lock held)')); - return; - } - setOverlay('confirmRestart'); + if (detailSnapshot?.deployLock != null) { + notify('bad', `A deploy holds the lock on ${detail?.server} - try again when it finishes`); return; } + if (selectedProcess !== undefined) return setOverlay('confirmRestart'); if (selectedRelease !== undefined) { - if (monitor.snapshot?.deployLock != null) { - monitor.appendEvent(chalk.red('Rollback blocked: a deploy is in progress (lock held)')); + if (detail?.replicated === true) { + notify('warn', `Rolling back one replica would split ${detail.app} - use 'shipnode rollback'`); return; } if (selectedRelease.status !== 'success') { - monitor.appendEvent(chalk.red('Cannot roll back to a failed release')); + notify('warn', 'That release failed to deploy - pick a successful one'); return; } if (selectedRelease.timestamp === currentTimestamp) { - monitor.appendEvent(chalk.yellow(`${selectedRelease.timestamp} is already the current release`)); + notify('muted', `${selectedRelease.timestamp} is already current`); return; } setOverlay('confirmRollback'); - return; } - return; - } - if (input === 'l' || input === 'L') { - setLiveMode((v) => { - const next = !v; - if (next) { - monitor.appendEvent(chalk.yellow('Live logs enabled')); - } else { - liveLogs.clearLogs(); - monitor.appendEvent(chalk.yellow('Live logs disabled')); - } - return next; - }); } }); - if (overlay === 'help') { - return ; - } + // ── Overlays ───────────────────────────────────────────────────── + if (overlay === 'help') return ; - if (overlay === 'confirmRestart' && selectedInfo !== undefined) { - const dropsRequests = selectedInfo.execMode !== 'cluster' || selectedInfo.instances <= 1; + if (overlay === 'confirmRestart' && selectedProcess !== undefined) { + const dropsRequests = selectedProcess.execMode !== 'cluster' || selectedProcess.instances <= 1; return ( { void confirmRestart(); @@ -242,12 +304,12 @@ export function App({ executor, config, app: initialApp, apps, accessoryNames, t ); } - if (overlay === 'confirmRollback' && selectedRelease !== undefined) { + if (overlay === 'confirmRollback' && selectedRelease !== undefined && detailApp !== undefined) { return ( 0 ? { live: logs.health.length - troubled.length, total: logs.health.length } : null, + alerts: shownAlerts, + flash, + }; + + const viewerProps = { + matched: filtered.matched, + filter: logView.filter, + total: source.length, + servers: serverNames, + showServer, + showApp, + frozen: logView.frozen !== null, + health: logs.health, + }; + + if (view === 'logs') { + const scope = [logView.filter.server, logView.filter.app].filter((part): part is string => part !== null); + const hints: Hint[] = logView.typing + ? [['⏎', 'keep search'], ['esc', 'drop search']] + : [['s a p', 'server·app·proc'], ['v', 'level'], ['/', 'search'], ['space', logView.frozen ? 'resume' : 'pause'], ['c', 'clear'], ['esc', 'back']]; return ( - { - setCurrentApp(app); - setOverlay('none'); - exitLogsView(); - setLogFilter(null); - setSelectedRow(0); - monitor.reset(); - liveLogs.clearLogs(); - setLiveMode(false); - monitor.appendEvent(chalk.green(`Switched to ${chalk.bold(app.name)}`)); - }} - onCancel={() => setOverlay('none')} - /> + 0 ? scope : ['everything'])]} hints={hints}> + + ); } - if (view === 'logs') { - const rows = stdout?.rows ?? 24; - const title = - currentApp.appType === 'frontend' - ? `Caddy Access Log — ${currentApp.name}` - : `Live Logs — ${logFilter ?? 'all processes'} (←/→ filter, / search, space pause, F back)`; + const bottom = stripOn ? ( + + ) : ( + + ); + + if (view === 'fleet') { + const serversHeight = serversPanelHeight(connection.hosts.length); + // The apps panel takes what is left; its body loses the two edges and the column header. + const appsBody = bodyHeight - serversHeight - BOTTOM_HEIGHT - 3; return ( - - + + + + {bottom} ); } + // detail + const crumbs = detail === undefined ? ['fleet', 'replica'] : ['fleet', detail.app, detail.server]; + const accessories = detailHost?.accessoryNames ?? []; + const rollbackNote = detail?.replicated === true ? 'roll back fleets with shipnode rollback' : undefined; + const hints: Hint[] = [ + ['↑↓', 'select'], + ['⏎', selectedRelease !== undefined ? 'roll back' : 'restart'], + ['f', 'logs'], + ['l', stripOn ? 'activity' : 'log strip'], + ['esc', 'fleet'], + ]; + + if (detail !== undefined && !detail.reachable) { + return ( + + + {detail.error ?? 'The last poll could not reach this server.'} + The overview keeps polling; this view fills in once the server answers. + + + ); + } + + // The host column is fixed; the process table takes the rest and drops columns to fit it. + const sideWidth = terminalCols >= 120 ? 46 : terminalCols >= 100 ? 40 : 34; + const mainWidth = terminalCols - sideWidth; + + const waiting = ( + + Waiting for the first poll… + + ); + return ( - + - - {currentApp.appType === 'frontend' ? ( - - ) : monitor.snapshot ? ( + + {detailApp?.appType === 'frontend' ? ( + + ) : detailSnapshot && detailHistory ? ( ) : ( - - )} - - - {monitor.snapshot ? ( - <> - - {accessoryNames.length > 0 && ( - - )} - - ) : ( - + waiting )} + {detailSnapshot && detail !== undefined && ( + + + + + {accessories.length > 0 && ( + + + + )} + + )} - {monitor.snapshot && ( - 0 ? 6 : 7) + releaseBoxExtra}> + {detailSnapshot && ( + )} - - {liveMode ? ( - - ) : ( - - )} - + {bottom} ); } diff --git a/src/cli/monitor/app-selector.tsx b/src/cli/monitor/app-selector.tsx deleted file mode 100644 index 2a7cc11..0000000 --- a/src/cli/monitor/app-selector.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Box, Text, useInput } from 'ink'; -import { useState } from 'react'; -import type { ShipnodeApp } from '../../shared/types.js'; - -const BG = '#0d1117'; -const BG_OVERLAY = '#161b22'; -const BG_SELECTED = '#1f2937'; - -interface AppSelectorProps { - apps: ShipnodeApp[]; - targetName: string; - onSelect: (app: ShipnodeApp) => void; - onCancel: () => void; -} - -export function AppSelector({ apps, targetName, onSelect, onCancel }: AppSelectorProps) { - const [selected, setSelected] = useState(0); - - useInput((input, key) => { - if (key.return) { - onSelect(apps[selected]); - } else if (key.escape || input === 'q') { - onCancel(); - } else if (key.upArrow) { - setSelected(Math.max(0, selected - 1)); - } else if (key.downArrow) { - setSelected(Math.min(apps.length - 1, selected + 1)); - } - }); - - return ( - - - Select App ({apps.length} on {targetName}) - - {apps.map((app, i) => { - const typeLabel = app.appType === 'backend' ? 'backend' : 'frontend'; - const isSelected = i === selected; - return ( - - - {isSelected ? '❯' : ' '} {app.name} - - ({typeLabel}) - {app.domain && {app.domain}} - - ); - })} - ↑↓ navigate Enter select q/Escape cancel - - ); -} diff --git a/src/cli/monitor/components/ConfirmDialog.tsx b/src/cli/monitor/components/ConfirmDialog.tsx index 361d361..b7745d8 100644 --- a/src/cli/monitor/components/ConfirmDialog.tsx +++ b/src/cli/monitor/components/ConfirmDialog.tsx @@ -1,7 +1,7 @@ import { Box, Text, useInput } from 'ink'; - -const BG = '#0d1117'; -const ACCENT = '#d6a85d'; +import { Panel } from './Panel.js'; +import { KeyHints } from './KeyHints.js'; +import { color } from '../theme.js'; interface ConfirmDialogProps { title: string; @@ -20,26 +20,16 @@ export function ConfirmDialog({ title, lines, onConfirm, onCancel }: ConfirmDial }); return ( - - {title} - {lines.map((line, i) => ( - {line} - ))} - - - y/Enter - confirm · - n/Esc - cancel - - + + + {title} + {lines.map((line, i) => ( + {line} + ))} + + + + ); } diff --git a/src/cli/monitor/components/HelpOverlay.tsx b/src/cli/monitor/components/HelpOverlay.tsx index 53148fc..cf0c6e8 100644 --- a/src/cli/monitor/components/HelpOverlay.tsx +++ b/src/cli/monitor/components/HelpOverlay.tsx @@ -1,44 +1,84 @@ import { Box, Text } from 'ink'; +import { Panel } from './Panel.js'; +import { color } from '../theme.js'; -const BG = '#0d1117'; -const ACCENT = '#d6a85d'; +type Section = { title: string; bindings: Array<[string, string]> }; -const BINDINGS: Array<[string, string]> = [ - ['q', 'quit'], - ['Tab', 'switch app on this server'], - ['r', 'refresh now'], - ['l', 'toggle live log strip'], - ['f', 'fullscreen logs'], - ['←/→', 'filter logs by process (fullscreen)'], - ['/', 'search logs (fullscreen)'], - ['space', 'pause/resume logs (fullscreen)'], - ['↑/↓', 'select process or release'], - ['Enter/x', 'restart process / rollback to release'], - ['?', 'this help'], +const LEFT: Section[] = [ + { + title: 'Everywhere', + bindings: [ + ['f', 'logs, scoped to the selection'], + ['l', 'log strip under the view'], + ['r', 'refresh now'], + ['?', 'this help'], + ['q', 'quit'], + ], + }, + { + title: 'Fleet', + bindings: [ + ['↑ ↓', 'select a replica'], + ['⏎', 'open it'], + ], + }, + { + title: 'Replica', + bindings: [ + ['↑ ↓', 'select process or release'], + ['⏎ x', 'restart / roll back'], + ['esc', 'back to the fleet'], + ], + }, ]; +const RIGHT: Section[] = [ + { + title: 'Logs', + bindings: [ + ['s S', 'server: next / previous'], + ['a A', 'app'], + ['p P', 'process'], + ['v', 'level: all, ≥warn, error'], + ['/', 'search: text, /regex/, !not'], + ['m', 'hide or dim non-matches'], + ['c', 'clear filters'], + ['space', 'pause'], + ['↑ ↓ pgup pgdn', 'scroll back'], + ['G', 'follow newest'], + ['C', 'clear buffer'], + ['esc f', 'back'], + ], + }, +]; + +function Column({ sections }: { sections: Section[] }) { + return ( + + {sections.map((section) => ( + + {section.title} + {section.bindings.map(([keys, description]) => ( + + {keys} + {description} + + ))} + + ))} + + ); +} + export function HelpOverlay() { return ( - - Keybindings - - {BINDINGS.map(([keys, description]) => ( - - {keys.padEnd(9)} - {description} - - ))} - - - press any key to close - + + any key closes} width={96} focused> + + + + + ); } diff --git a/src/cli/monitor/components/KeyHints.tsx b/src/cli/monitor/components/KeyHints.tsx new file mode 100644 index 0000000..8d65956 --- /dev/null +++ b/src/cli/monitor/components/KeyHints.tsx @@ -0,0 +1,19 @@ +import { Text } from 'ink'; +import { Fragment } from 'react'; + +export type Hint = readonly [keys: string, label: string]; + +/** `⏎ open ↑↓ select f logs` - keys bright, labels dim, the k9s / lazygit footer. */ +export function KeyHints({ hints }: { hints: readonly Hint[] }) { + return ( + + {hints.map(([keys, label], index) => ( + + {index > 0 && {' '}} + {keys} + {label} + + ))} + + ); +} diff --git a/src/cli/monitor/components/Panel.tsx b/src/cli/monitor/components/Panel.tsx new file mode 100644 index 0000000..d6ad15c --- /dev/null +++ b/src/cli/monitor/components/Panel.tsx @@ -0,0 +1,70 @@ +import { Box, Text } from 'ink'; +import type { ReactNode } from 'react'; +import { color } from '../theme.js'; + +/** Long enough for any terminal; clipped to the panel's width by overflow. */ +const RULE = '─'.repeat(512); + +interface PanelProps { + title: string; + /** Short, dim context after the title: "2 servers", "health 200 · 12ms". */ + subtitle?: ReactNode; + /** Right-aligned in the top border. */ + right?: ReactNode; + /** The panel the keys act on gets an accent border. */ + focused?: boolean; + width?: number | string; + height?: number; + flexGrow?: number; + children?: ReactNode; +} + +/** Columns a panel's border and padding take from its width. */ +export const PANEL_INSET = 4; + +/** + * A rounded box with its title set into the top border, the way a tiling + * terminal app labels panes. Ink has no titled border, so the top edge is drawn + * by hand: a rule clipped by overflow to whatever width the layout grants. + */ +export function Panel({ title, subtitle, right, focused = false, width, height, flexGrow, children }: PanelProps) { + const edge = focused ? color.accent : color.border; + return ( + + + + ╭─ + {title} + + {/* When the edge is crowded the subtitle gives way first, then the rule. */} + {subtitle !== undefined && ( + + {subtitle} + + )} + + + {RULE} + + {right !== undefined && ( + + {right} + + )} + + ─╮ + + + + {children} + + + ); +} diff --git a/src/cli/monitor/components/Table.tsx b/src/cli/monitor/components/Table.tsx new file mode 100644 index 0000000..35a8723 --- /dev/null +++ b/src/cli/monitor/components/Table.tsx @@ -0,0 +1,77 @@ +import { Box, Text } from 'ink'; +import type { ReactNode } from 'react'; + +export interface Column { + key: string; + header: string; + width: number; + align?: 'left' | 'right'; + /** + * Narrow terminals drop columns rather than overflow: higher numbers go + * first. Columns without a priority are the ones the table is for and stay. + */ + drop?: number; +} + +/** + * The columns that fit in `available` cells after `reserved` (gutters). + * + * Ink does not reliably clip text that overruns its box - it bleeds into the + * neighbouring panel - so a table must never be laid out wider than it is. + */ +export function fitColumns(columns: readonly Column[], available: number, reserved: number = 0): Column[] { + let kept = [...columns]; + const total = (): number => reserved + kept.reduce((sum, column) => sum + column.width, 0); + const droppable = columns + .filter((column) => column.drop !== undefined) + .sort((a, b) => (b.drop ?? 0) - (a.drop ?? 0)); + for (const column of droppable) { + if (total() <= available) break; + kept = kept.filter((candidate) => candidate !== column); + } + // Still too wide with only the essentials: the first column (a name) gives up the difference. + const over = total() - available; + if (over > 0 && kept.length > 0) { + kept[0] = { ...kept[0], width: Math.max(MIN_FIRST_COLUMN, kept[0].width - over) }; + } + return kept; +} + +const MIN_FIRST_COLUMN = 8; + +/** One fixed-width cell. Content that does not fit is cut, never wrapped, so rows stay one line. */ +export function Cell({ width, align = 'left', children }: { width: number; align?: 'left' | 'right'; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Dim uppercase column headings aligned to the cells below. `lead` matches the rows' marker gutter. */ +export function TableHeader({ columns, lead = 0 }: { columns: readonly Column[]; lead?: number }) { + return ( + + {lead > 0 && } + {columns.map((column) => ( + + {column.header} + + ))} + + ); +} + +/** A row laid out by `columns`, taking each cell's content from `cells` by key. */ +export function TableRow({ columns, cells, lead }: { columns: readonly Column[]; cells: Record; lead?: ReactNode }) { + return ( + + {lead} + {columns.map((column) => ( + + {cells[column.key]} + + ))} + + ); +} diff --git a/src/cli/monitor/components/charts.tsx b/src/cli/monitor/components/charts.tsx index 27d8b37..b648161 100644 --- a/src/cli/monitor/components/charts.tsx +++ b/src/cli/monitor/components/charts.tsx @@ -1,9 +1,16 @@ import { Text } from 'ink'; import { buildGauge, buildSparkline } from '../charts.js'; +/** Filled part in its threshold colour, the remainder dim, so the eye reads the fill. */ export function Gauge({ percent, width }: { percent: number; width: number }) { const { bar, color } = buildGauge(percent, width); - return {bar}; + const filled = bar.replace(/░/g, ''); + return ( + + {filled} + {bar.slice(filled.length)} + + ); } export function Sparkline({ values, width }: { values: number[]; width: number }) { @@ -20,3 +27,14 @@ export function Sparkline({ values, width }: { values: number[]; width: number } ); } + +/** A gauge with its percentage, right-aligned to a fixed width so columns line up. */ +export function Meter({ percent, width = 10 }: { percent: number; width?: number }) { + const clamped = Math.max(0, Math.min(1, percent)); + return ( + + + {`${Math.round(clamped * 100)}%`.padStart(4)} + + ); +} diff --git a/src/cli/monitor/fleet-model.ts b/src/cli/monitor/fleet-model.ts new file mode 100644 index 0000000..9067a21 --- /dev/null +++ b/src/cli/monitor/fleet-model.ts @@ -0,0 +1,151 @@ +import type { AppSnapshot, FleetView, ServerSnapshot } from '../../domain/observe/snapshot.js'; +import { releaseNameOf } from '../../domain/observe/snapshot.js'; +import type { HealthInfo, SystemInfo } from '../../domain/observe/types.js'; +import type { ObserveEvent } from '../../services/observe/events.js'; +import type { MetricsSnapshot } from './state.js'; + +/** + * One line of the fleet table: one app on one server. + * + * Derived from `FleetView`s, so a replica the poll could not reach still gets a + * row - it is exactly the row the operator needs to see. + */ +export interface FleetRow { + /** Stable across ticks, so a selection survives a refresh. */ + key: string; + app: string; + appType: 'backend' | 'frontend'; + server: string; + reachable: boolean; + error?: string; + release: string | null; + /** `behind` marks a replica not on the newest release while the fleet is split. */ + releaseState: 'current' | 'behind' | 'none' | 'unknown'; + online: number; + total: number; + cpu: number; + memoryMb: number; + restarts: number; + health?: HealthInfo; + /** Whether the app runs on more than one server, which decides if skew is meaningful. */ + replicated: boolean; + snapshot?: AppSnapshot; + system?: SystemInfo; +} + +export function buildFleetRows(fleets: readonly FleetView[]): FleetRow[] { + const rows: FleetRow[] = []; + for (const fleet of fleets) { + const replicated = fleet.replicas.length + fleet.unreachable.length > 1; + const newest = fleet.convergence.releases[0]; + + for (const replica of fleet.replicas) { + const snapshot = replica.snapshot; + const release = releaseNameOf(snapshot); + const processes = snapshot.processes; + rows.push({ + key: rowKey(fleet.app, replica.server), + app: fleet.app, + appType: fleet.appType, + server: replica.server, + reachable: replica.reachable, + error: snapshot.error, + release, + releaseState: + release === null ? 'none' : fleet.convergence.converged || release === newest ? 'current' : 'behind', + online: processes.filter((p) => p.status === 'online').length, + total: processes.length, + cpu: processes.reduce((sum, p) => sum + p.cpu, 0), + memoryMb: processes.reduce((sum, p) => sum + p.memory, 0), + restarts: processes.reduce((sum, p) => sum + p.restarts, 0), + health: snapshot.health, + replicated, + snapshot, + system: replica.system, + }); + } + + for (const server of fleet.unreachable) { + rows.push({ + key: rowKey(fleet.app, server), + app: fleet.app, + appType: fleet.appType, + server, + reachable: false, + release: null, + releaseState: 'unknown', + online: 0, + total: 0, + cpu: 0, + memoryMb: 0, + restarts: 0, + replicated, + }); + } + } + return rows; +} + +/** NUL-joined: app and server names are free-form, so a printable separator could collide. */ +export function rowKey(app: string, server: string): string { + return `${app}\u0000${server}`; +} + +export interface FleetHeadline { + tone: 'ok' | 'warn' | 'bad'; + text: string; +} + +/** + * One line per app: is the fleet agreed on a release, and can every replica be + * seen? Every problem is named, worst first, because an unreachable replica can + * hide a split roll behind it. + */ +export function describeFleet(fleet: FleetView): FleetHeadline { + const { convergence, unreachable, replicas } = fleet; + const total = replicas.length + unreachable.length; + const bad: string[] = []; + const warn: string[] = []; + if (unreachable.length > 0) bad.push(`${unreachable.length}/${total} unreachable: ${unreachable.join(', ')}`); + if (convergence.releases.length > 1) bad.push(`split across ${convergence.releases.length} releases - a roll stopped partway`); + if (convergence.undeployed.length > 0) warn.push(`no release on ${convergence.undeployed.join(', ')}`); + + if (bad.length + warn.length > 0) { + return { tone: bad.length > 0 ? 'bad' : 'warn', text: [...bad, ...warn].join(' · ') }; + } + return { tone: 'ok', text: total > 1 ? `converged on ${convergence.releases[0] ?? 'no release'}` : 'ok' }; +} + +/** The flattened shape the per-app panels still take. */ +export function toMetricsSnapshot(server: ServerSnapshot, app: AppSnapshot): MetricsSnapshot { + return { + timestamp: server.timestamp, + processes: app.processes, + system: server.system, + currentRelease: app.currentRelease, + releases: app.releases, + deployLock: server.deployLock, + health: app.health, + accessories: server.accessories, + caddy: app.caddy, + error: server.error ?? app.error, + }; +} + +/** Wording for an event; colour is the renderer's business. */ +export function describeEvent(event: ObserveEvent): { tone: 'ok' | 'warn' | 'bad' | 'info'; text: string } { + switch (event.kind) { + case 'health-failing': + return { tone: 'bad', text: `${event.app} on ${event.server}: health check failing (${event.streak} consecutive probes)` }; + case 'health-recovered': + return { tone: 'ok', text: `${event.app} on ${event.server}: health check recovered` }; + case 'server-unreachable': + return { tone: 'bad', text: `${event.server} unreachable: ${event.message}` }; + case 'server-recovered': + return { tone: 'ok', text: `${event.server} reachable again` }; + case 'app-error': + return { tone: 'bad', text: `${event.app} on ${event.server}: ${event.message}` }; + case 'notice': + return { tone: 'info', text: event.message }; + } +} diff --git a/src/cli/monitor/hooks/use-fleet.ts b/src/cli/monitor/hooks/use-fleet.ts new file mode 100644 index 0000000..9b05a55 --- /dev/null +++ b/src/cli/monitor/hooks/use-fleet.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef, useState } from 'react'; +import { ObserveSession, type ObserveState, type ObserveTarget } from '../../../services/observe/session.js'; + +export interface FleetData { + state: ObserveState; + session: ObserveSession; + refresh: () => Promise; +} + +/** + * Own one `ObserveSession` for the life of the dashboard and mirror its state + * into React. The session does the scheduling, history and event detection; this + * hook only subscribes and renders what it publishes. + */ +export function useFleet(targets: ObserveTarget[], intervalSeconds: number): FleetData { + const sessionRef = useRef(null); + if (sessionRef.current === null) { + sessionRef.current = new ObserveSession({ targets, intervalSeconds }); + } + const session = sessionRef.current; + const [state, setState] = useState(() => session.getState()); + + useEffect(() => { + const unsubscribe = session.subscribe(setState); + session.start(); + return () => { + unsubscribe(); + session.stop(); + }; + }, [session]); + + return { state, session, refresh: () => session.tick() }; +} diff --git a/src/cli/monitor/hooks/use-live-logs.ts b/src/cli/monitor/hooks/use-live-logs.ts deleted file mode 100644 index 0d0d20c..0000000 --- a/src/cli/monitor/hooks/use-live-logs.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import type { RemoteExecutor } from '../../../domain/remote/executor.js'; -import type { ShipnodeApp } from '../../../shared/types.js'; -import { collectLogs, collectCaddyLogs } from '../poller.js'; -import { isWatt } from '../../../domain/runtime/watt.js'; - -const MAX_BUFFER_LINES = 500; - -export interface LiveLogsState { - logBuffer: string; - clearLogs: () => void; -} - -export function useLiveLogs( - executor: RemoteExecutor, - app: ShipnodeApp, - liveMode: boolean, - interval: number, - /** Exact process (pm2 name or systemd unit) to tail, or null for the whole app namespace. */ - filter: string | null = null, -): LiveLogsState { - const [logBuffer, setLogBuffer] = useState(''); - const lastChunkRef = useRef(''); - const inFlightRef = useRef(false); - - const clearLogs = (): void => { - lastChunkRef.current = ''; - setLogBuffer(''); - }; - - const fetchLogs = async (): Promise => { - if (app.appType === 'frontend') { - return collectCaddyLogs(executor, app.name, 50); - } - const target = filter ?? app.pm2?.apps[0]?.name; - if (target === undefined) return ''; - return collectLogs(executor, target, 20, isWatt(app) ? 'systemd' : undefined); - }; - - const pollLogs = async (): Promise => { - if (inFlightRef.current) return; - inFlightRef.current = true; - try { - const freshLogs = await fetchLogs(); - if (!freshLogs || freshLogs === '(no logs)' || freshLogs === lastChunkRef.current) return; - lastChunkRef.current = freshLogs; - setLogBuffer((prev) => { - const combined = prev ? `${prev}\n${freshLogs}` : freshLogs; - return combined.split('\n').slice(-MAX_BUFFER_LINES).join('\n'); - }); - } finally { - inFlightRef.current = false; - } - }; - - useEffect(() => { - clearLogs(); - }, [app, filter]); - - useEffect(() => { - if (!liveMode) return; - void pollLogs(); - const timer = setInterval(() => { - void pollLogs(); - }, interval * 1000); - return () => clearInterval(timer); - }, [liveMode, app, interval, filter]); - - return { logBuffer, clearLogs }; -} diff --git a/src/cli/monitor/hooks/use-log-stream.ts b/src/cli/monitor/hooks/use-log-stream.ts new file mode 100644 index 0000000..dc1cc3f --- /dev/null +++ b/src/cli/monitor/hooks/use-log-stream.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react'; +import { planLogSources } from '../../../domain/observe/log-source.js'; +import type { LogLine } from '../../../domain/observe/log-line.js'; +import { LogStream, type LogBinding, type SourceHealth } from '../../../services/observe/log-stream.js'; +import type { FleetHost } from '../../observe.js'; + +export interface LogStreamData { + lines: readonly LogLine[]; + health: readonly SourceHealth[]; + /** Total sources being followed; 0 once planning finished with nothing to follow. */ + sources: number; + /** Servers whose sources could not be planned (host unreachable, units unresolved). */ + skipped: string[]; + clear: () => void; +} + +/** + * Follow every server's logs from the moment they are first wanted. + * + * Streaming starts lazily - a dashboard that never opens logs never opens the + * channels - and then stays on for the rest of the session, so toggling the + * view off and on does not drop the buffer or replay the backlog. + */ +export function useLogStream(hosts: readonly FleetHost[], wanted: boolean): LogStreamData { + const streamRef = useRef(null); + const startedRef = useRef(false); + const [lines, setLines] = useState([]); + const [health, setHealth] = useState([]); + const [skipped, setSkipped] = useState([]); + + useEffect(() => { + if (!wanted || startedRef.current) return; + startedRef.current = true; + let cancelled = false; + + void (async () => { + const bindings: LogBinding[] = []; + const failed: string[] = []; + await Promise.all( + hosts.map(async (host) => { + if (host.executor === null) { + failed.push(host.name); + return; + } + for (const app of host.apps) { + try { + const sources = await planLogSources(host.executor, host.name, host.config.remotePath, app); + for (const source of sources) bindings.push({ executor: host.executor, source }); + } catch { + failed.push(host.name); + } + } + }), + ); + if (cancelled) return; + + // Promise.all finished in completion order; sort so colours and cycling are stable. + bindings.sort((a, b) => a.source.server.localeCompare(b.source.server) || a.source.app.localeCompare(b.source.app)); + const stream = new LogStream({ bindings }); + streamRef.current = stream; + stream.subscribe(() => { + setLines(stream.lines()); + setHealth(stream.health()); + }); + setSkipped([...new Set(failed)]); + setHealth(stream.health()); + stream.start(); + })(); + + return () => { + cancelled = true; + // Cancelled while still planning: let a later run plan again rather than never stream. + if (streamRef.current === null) startedRef.current = false; + }; + }, [wanted, hosts]); + + // Stop only when the dashboard itself goes away. + useEffect( + () => () => { + void streamRef.current?.stop(); + }, + [], + ); + + return { + lines, + health, + sources: health.length, + skipped, + clear: () => streamRef.current?.clear(), + }; +} diff --git a/src/cli/monitor/hooks/use-monitor-data.ts b/src/cli/monitor/hooks/use-monitor-data.ts deleted file mode 100644 index 9c68137..0000000 --- a/src/cli/monitor/hooks/use-monitor-data.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useStdout } from 'ink'; -import chalk from 'chalk'; -import type { RemoteExecutor } from '../../../domain/remote/executor.js'; -import type { ShipnodeApp, ShipnodeConfig } from '../../../shared/types.js'; -import { collectMetrics } from '../poller.js'; -import { MetricsHistory, nextHealthFailStreak, type MetricsSnapshot } from '../state.js'; - -/** Sample docker accessory state roughly every this many seconds, not every poll. */ -const ACCESSORY_SAMPLE_SECONDS = 10; - -/** Consecutive failed health probes before the monitor raises an alert. */ -export const HEALTH_ALERT_THRESHOLD = 3; - -export interface MonitorDataState { - snapshot: MetricsSnapshot | null; - history: MetricsHistory; - events: string[]; - error: string | null; - lastUpdate: string; - polling: boolean; - /** Consecutive failed health probes; >= HEALTH_ALERT_THRESHOLD means alerting. */ - healthFailStreak: number; - refresh: () => Promise; - appendEvent: (message: string) => void; - reset: () => void; -} - -export function useMonitorData( - executor: RemoteExecutor, - config: ShipnodeConfig, - app: ShipnodeApp, - interval: number, - accessoryNames: string[] = [], -): MonitorDataState { - const [snapshot, setSnapshot] = useState(null); - const [events, setEvents] = useState([]); - const [error, setError] = useState(null); - const [lastUpdate, setLastUpdate] = useState(''); - const [polling, setPolling] = useState(false); - const [healthFailStreak, setHealthFailStreak] = useState(0); - const { stdout } = useStdout(); - const historyRef = useRef(new MetricsHistory()); - const inFlightRef = useRef(false); - const pollCountRef = useRef(0); - const healthStreakRef = useRef(0); - - const appendEvent = (message: string): void => { - setEvents((prev) => [...prev.slice(-100), message]); - }; - - const refresh = async (): Promise => { - if (inFlightRef.current) return; - inFlightRef.current = true; - setPolling(true); - try { - const cadence = Math.max(1, Math.ceil(ACCESSORY_SAMPLE_SECONDS / interval)); - const sampleAccessories = pollCountRef.current % cadence === 0; - pollCountRef.current += 1; - - const nextSnapshot = await collectMetrics(executor, app, config, { - intervalSeconds: interval, - accessoryNames: sampleAccessories ? accessoryNames : undefined, - }); - setSnapshot((prev) => - nextSnapshot.accessories === undefined && prev !== null - ? { ...nextSnapshot, accessories: prev.accessories } - : nextSnapshot, - ); - historyRef.current.push(nextSnapshot); - - const streak = nextHealthFailStreak(healthStreakRef.current, nextSnapshot.health); - if (streak === HEALTH_ALERT_THRESHOLD && healthStreakRef.current < HEALTH_ALERT_THRESHOLD) { - appendEvent(chalk.bold.red(`Health check failing (${streak} consecutive probes)`)); - stdout?.write('\x07'); - } - if (streak === 0 && healthStreakRef.current >= HEALTH_ALERT_THRESHOLD) { - appendEvent(chalk.green('Health check recovered')); - } - healthStreakRef.current = streak; - setHealthFailStreak(streak); - - setLastUpdate(new Date().toLocaleTimeString()); - setError(nextSnapshot.error ?? null); - if (nextSnapshot.error) appendEvent(chalk.red(nextSnapshot.error)); - } catch (cause: unknown) { - const message = cause instanceof Error ? cause.message : String(cause); - setError(message); - appendEvent(chalk.red(`Poll failed: ${message}`)); - } finally { - inFlightRef.current = false; - setPolling(false); - } - }; - - const reset = (): void => { - historyRef.current.clear(); - pollCountRef.current = 0; - healthStreakRef.current = 0; - setHealthFailStreak(0); - setSnapshot(null); - setError(null); - setLastUpdate(''); - }; - - useEffect(() => { - void refresh(); - const timer = setInterval(() => { - void refresh(); - }, interval * 1000); - return () => clearInterval(timer); - }, [app, interval]); - - return { - snapshot, - history: historyRef.current, - events, - error, - lastUpdate, - polling, - healthFailStreak, - refresh, - appendEvent, - reset, - }; -} diff --git a/src/cli/monitor/hooks/use-now.ts b/src/cli/monitor/hooks/use-now.ts new file mode 100644 index 0000000..d2fabdc --- /dev/null +++ b/src/cli/monitor/hooks/use-now.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react'; + +/** The current time, re-rendering every `ms` - for "updated 3s ago" and flash expiry. */ +export function useNow(ms: number = 1000): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), ms); + return () => clearInterval(timer); + }, [ms]); + return now; +} diff --git a/src/cli/monitor/index.tsx b/src/cli/monitor/index.tsx index 3d95be9..9aff5e1 100644 --- a/src/cli/monitor/index.tsx +++ b/src/cli/monitor/index.tsx @@ -1,17 +1,12 @@ import { render } from 'ink'; -import type { RemoteExecutor } from '../../domain/remote/executor.js'; -import type { ShipnodeConfig, ShipnodeApp } from '../../shared/types.js'; +import type { FleetConnection } from '../observe.js'; import { App } from './App.js'; interface MonitorOptions { - executor: RemoteExecutor; - config: ShipnodeConfig; - app: ShipnodeApp; - apps: ShipnodeApp[]; - accessoryNames: string[]; - targetName: string; - host: string; + fleet: FleetConnection; interval: number; + /** Start with this app selected in the overview. */ + focusApp?: string; } export async function runMonitor(options: MonitorOptions): Promise { diff --git a/src/cli/monitor/layout/HeaderBar.tsx b/src/cli/monitor/layout/HeaderBar.tsx deleted file mode 100644 index 7c95187..0000000 --- a/src/cli/monitor/layout/HeaderBar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Box, Text } from 'ink'; -import type { ShipnodeApp } from '../../../shared/types.js'; -import type { DeployLockInfo } from '../state.js'; - -const ACCENT = '#d6a85d'; -const BG_HEADER = '#161b22'; - -interface HeaderBarProps { - app: ShipnodeApp; - targetName: string; - host: string; - interval: number; - liveMode: boolean; - deployLock?: DeployLockInfo | null; - /** Consecutive failed health probes; alerting once it reaches the threshold. */ - healthFailStreak?: number; -} - -export function HeaderBar({ app, targetName, host, interval, liveMode, deployLock, healthFailStreak = 0 }: HeaderBarProps) { - return ( - - ShipNode Monitor - — {app.name} ({app.appType}) - — {targetName} {host} - {' │'} interval: {interval}s - {liveMode ? ( - {' │'} logs: ON - ) : ( - │ logs: off - )} - {deployLock != null && ( - {' │ '}DEPLOY LOCK ({deployLock.ageSeconds}s) - )} - {healthFailStreak > 0 && ( - {' │ '}HEALTH FAILING ×{healthFailStreak} - )} - {' │'} [?] help - - ); -} diff --git a/src/cli/monitor/layout/MonitorFrame.tsx b/src/cli/monitor/layout/MonitorFrame.tsx index cf9fa17..ae99bec 100644 --- a/src/cli/monitor/layout/MonitorFrame.tsx +++ b/src/cli/monitor/layout/MonitorFrame.tsx @@ -1,67 +1,109 @@ import { Box, Text } from 'ink'; -import type { ReactNode } from 'react'; -import type { ShipnodeApp } from '../../../shared/types.js'; -import type { MetricsSnapshot } from '../state.js'; -import { HeaderBar } from './HeaderBar.js'; -import { StatusBar } from './StatusBar.js'; +import { Fragment, type ReactNode } from 'react'; +import { KeyHints, type Hint } from '../components/KeyHints.js'; +import { color, glyph, toneColor, type Tone } from '../theme.js'; +import { useNow } from '../hooks/use-now.js'; -const BG = '#0d1117'; -const BORDER = '#30363d'; +export interface HeaderAlert { + text: string; +} + +export interface Flash { + tone: Tone; + text: string; +} interface MonitorFrameProps { - app: ShipnodeApp; - targetName: string; - host: string; + /** Where the user is: ['fleet'], ['fleet', 'api', 'b'], ['logs', 'api']. */ + crumbs: readonly string[]; interval: number; - liveMode: boolean; - lastUpdate: string; - snapshot: MetricsSnapshot | null; polling: boolean; - error: string | null; - /** Streak to surface in the header; pass 0 until it crosses the alert threshold. */ - healthFailStreak?: number; + lastUpdate: string | null; + /** Live log streaming state, shown only once streaming has started. */ + streaming?: { live: number; total: number } | null; + alerts: readonly HeaderAlert[]; + hints: readonly Hint[]; + /** A short-lived message from the last action, shown in the footer. */ + flash?: Flash | null; children: ReactNode; } -export function MonitorFrame({ - app, - targetName, - host, - interval, - liveMode, - lastUpdate, - snapshot, - polling, - error, - healthFailStreak = 0, - children, -}: MonitorFrameProps) { +function ago(iso: string | null, now: number): string { + if (iso === null) return 'waiting'; + const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1000)); + return seconds < 2 ? 'just now' : `${seconds}s ago`; +} + +function Header({ crumbs, interval, polling, lastUpdate, streaming }: Pick) { + const now = useNow(); + const stale = lastUpdate !== null && now - Date.parse(lastUpdate) > interval * 3000; + const pulse = lastUpdate === null || polling ? glyph.unknown : glyph.ok; + const pulseColor = stale ? color.bad : lastUpdate === null ? undefined : color.ok; + return ( + + + {glyph.brand} shipnode + {' '} + {crumbs.map((crumb, index) => ( + + {index > 0 && {glyph.crumb} } + {crumb} + + ))} + + + {streaming != null && ( + + ≋ + {streaming.live}/{streaming.total} streams + + )} + {pulse} + {stale ? 'stale' : 'live'} {glyph.sep} every {interval}s {glyph.sep} {ago(lastUpdate, now)} + + + ); +} + +function AlertBar({ alerts }: { alerts: readonly HeaderAlert[] }) { + return ( + + + {alerts.map((alert, index) => ( + + {index > 0 ? ' ' : ''}{glyph.alert} {alert.text} + + ))} + + + ); +} + +function Footer({ hints, flash }: { hints: readonly Hint[]; flash?: Flash | null }) { return ( - - - {children} - + + + + + + {flash != null ? ( + {flash.text} + ) : ( + + )} + ); } -export function WaitingPanel() { +/** Header, optional alert bar, the view, and the key-hint footer. */ +export function MonitorFrame({ alerts, hints, flash, children, ...header }: MonitorFrameProps) { return ( - - Waiting for data... + +
+ {alerts.length > 0 && } + {children} +