diff --git a/.github/workflows/phase3-multihost-cancel.yml b/.github/workflows/phase3-multihost-cancel.yml new file mode 100644 index 0000000..09efe36 --- /dev/null +++ b/.github/workflows/phase3-multihost-cancel.yml @@ -0,0 +1,138 @@ +name: Phase3 Multi-host Cancellation + +on: + push: + branches: [agent/operator-experience] + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.97.1 + components: rustfmt + - name: Add cooperative cancellation registry + run: | + python3 - <<'PY' + from pathlib import Path + + p = Path('src-tauri/src/multi_host.rs') + s = p.read_text() + s = s.replace( + 'use std::thread;\n', + 'use std::collections::HashMap;\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::{Arc, Mutex};\nuse std::thread;\n\nuse once_cell::sync::Lazy;\n' + ) + if 'static CANCELLATIONS' not in s: + s = s.replace( + 'const MAX_COMMAND_BYTES: usize = 16 * 1024;\n', + 'const MAX_COMMAND_BYTES: usize = 16 * 1024;\n\nstatic CANCELLATIONS: Lazy>>> =\n Lazy::new(|| Mutex::new(HashMap::new()));\n' + ) + s = s.replace( + 'pub struct MultiHostRequest {\n pub server_ids: Vec,', + 'pub struct MultiHostRequest {\n pub run_id: Option,\n pub server_ids: Vec,' + ) + validation_marker = ''' if request.server_ids.is_empty() || servers.is_empty() {\n return Err(anyhow!("select at least one server"));\n }\n''' + validation_add = validation_marker + ''' if let Some(run_id) = request.run_id.as_deref() {\n let valid = !run_id.is_empty()\n && run_id.len() <= 64\n && run_id\n .chars()\n .all(|character| character.is_ascii_alphanumeric() || character == '-');\n if !valid {\n return Err(anyhow!("run_id must be a UUID-like identifier up to 64 characters"));\n }\n }\n''' + if 'run_id must be a UUID-like identifier' not in s: + if validation_marker not in s: + raise SystemExit('multi-host validation marker missing') + s = s.replace(validation_marker, validation_add, 1) + if 'pub fn request_cancel' not in s: + marker = 'fn failed_output(message: impl ToString) -> CommandOutput {' + functions = '''pub fn request_cancel(run_id: &str) -> bool {\n CANCELLATIONS\n .lock()\n .ok()\n .and_then(|registry| registry.get(run_id).cloned())\n .map(|flag| {\n flag.store(true, Ordering::SeqCst);\n true\n })\n .unwrap_or(false)\n}\n\n''' + s = s.replace(marker, functions + marker) + + old_start = '''pub fn execute(request: &MultiHostRequest, servers: Vec) -> Result {\n validate(request, &servers)?;\n let started_at = Utc::now().to_rfc3339();\n let mut results = Vec::with_capacity(servers.len());\n\n for batch in servers.chunks(request.concurrency) {''' + new_start = '''pub fn execute(request: &MultiHostRequest, servers: Vec) -> Result {\n validate(request, &servers)?;\n let run_id = request\n .run_id\n .clone()\n .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());\n let cancellation = Arc::new(AtomicBool::new(false));\n CANCELLATIONS\n .lock()\n .map_err(|_| anyhow!("multi-host cancellation registry lock poisoned"))?\n .insert(run_id.clone(), cancellation.clone());\n let started_at = Utc::now().to_rfc3339();\n let mut results = Vec::with_capacity(servers.len());\n\n for batch in servers.chunks(request.concurrency) {\n if cancellation.load(Ordering::SeqCst) {\n break;\n }''' + if old_start not in s: + raise SystemExit('multi-host execute start marker missing') + s = s.replace(old_start, new_start, 1) + old_status = ''' let successes = results.iter().filter(|result| result.output.success).count();\n let status = if successes == results.len() {\n "success"\n } else if successes == 0 {\n "failed"\n } else {\n "partial"\n };\n Ok(MultiHostRun {\n id: uuid::Uuid::new_v4().to_string(),''' + new_status = ''' let cancelled = cancellation.load(Ordering::SeqCst);\n if let Ok(mut registry) = CANCELLATIONS.lock() {\n registry.remove(&run_id);\n }\n let successes = results.iter().filter(|result| result.output.success).count();\n let status = if cancelled {\n "cancelled"\n } else if successes == results.len() {\n "success"\n } else if successes == 0 {\n "failed"\n } else {\n "partial"\n };\n Ok(MultiHostRun {\n id: run_id,''' + if old_status not in s: + raise SystemExit('multi-host status marker missing') + s = s.replace(old_status, new_status, 1) + # Test request literals need run_id. + s = s.replace( + ' let mut request = MultiHostRequest {\n server_ids:', + ' let mut request = MultiHostRequest {\n run_id: None,\n server_ids:' + ) + p.write_text(s) + + p = Path('src-tauri/src/operator_commands.rs') + s = p.read_text() + old = '''#[tauri::command]\npub fn multi_host_run(\n state: State,\n request: MultiHostRequest,\n) -> CommandResult {''' + new = '''#[tauri::command]\npub async fn multi_host_run(\n state: State<'_, AppState>,\n request: MultiHostRequest,\n) -> CommandResult {''' + if old not in s: + raise SystemExit('multi_host_run command marker missing') + s = s.replace(old, new, 1) + old_exec = ''' let run = remote(multi_host::execute(&request, servers))?;\n {\n let conn = state.db.lock().unwrap();\n internal(operator_data::save_multi_host_run(&conn, &run))?;\n }\n Ok(run)\n}\n''' + new_exec = ''' let run = tauri::async_runtime::spawn_blocking(move || multi_host::execute(&request, servers))\n .await\n .map_err(|error| DomainError::internal(format!("multi-host worker failed: {error}")))?\n .map_err(|error| DomainError::remote(error.to_string()))?;\n {\n let conn = state.db.lock().unwrap();\n internal(operator_data::save_multi_host_run(&conn, &run))?;\n }\n Ok(run)\n}\n\n#[tauri::command]\npub fn multi_host_cancel(run_id: String) -> CommandResult<()> {\n if multi_host::request_cancel(&run_id) {\n Ok(())\n } else {\n Err(DomainError::validation(\n "run_id",\n "multi-host run is not active or already completed",\n ))\n }\n}\n''' + if old_exec not in s: + raise SystemExit('multi-host execute command marker missing') + s = s.replace(old_exec, new_exec, 1) + p.write_text(s) + + p = Path('src-tauri/src/lib.rs') + s = p.read_text() + marker = ' operator_commands::multi_host_run,\n' + if 'operator_commands::multi_host_cancel' not in s: + if marker not in s: + raise SystemExit('lib multi-host command marker missing') + s = s.replace(marker, marker + ' operator_commands::multi_host_cancel,\n') + p.write_text(s) + + p = Path('src/operatorTypes.ts') + s = p.read_text() + s = s.replace( + 'export interface MultiHostRequest {\n server_ids: string[];', + 'export interface MultiHostRequest {\n run_id?: string | null;\n server_ids: string[];' + ) + p.write_text(s) + + p = Path('src/operatorApi.ts') + s = p.read_text() + marker = '''export const multiHostRun = (request: MultiHostRequest) =>\n invoke("multi_host_run", { request });\n''' + addition = marker + '''export const multiHostCancel = (runId: string) =>\n invoke("multi_host_cancel", { runId });\n''' + if 'multiHostCancel' not in s: + if marker not in s: + raise SystemExit('operatorApi multiHostRun marker missing') + s = s.replace(marker, addition) + p.write_text(s) + + p = Path('src/components/OperatorCenter.tsx') + s = p.read_text() + if 'const [runningId' not in s: + s = s.replace( + ' const [run, setRun] = useState(null);', + ' const [run, setRun] = useState(null);\n const [runningId, setRunningId] = useState(null);\n const [cancelRequested, setCancelRequested] = useState(false);' + ) + old_execute = ''' async function execute() {\n try {\n setRun(await operatorApi.multiHostRun({\n server_ids: selected,\n command,\n concurrency,\n production_confirmed: productionConfirmed,\n destructive_confirmed: destructiveConfirmed,\n }));\n } catch (reason) { reportError(reason); }\n }''' + new_execute = ''' async function execute() {\n const runId = crypto.randomUUID();\n setRunningId(runId);\n setCancelRequested(false);\n setRun(null);\n try {\n setRun(await operatorApi.multiHostRun({\n run_id: runId,\n server_ids: selected,\n command,\n concurrency,\n production_confirmed: productionConfirmed,\n destructive_confirmed: destructiveConfirmed,\n }));\n } catch (reason) {\n reportError(reason);\n } finally {\n setRunningId(null);\n setCancelRequested(false);\n }\n }\n\n async function cancelRun() {\n if (!runningId) return;\n setCancelRequested(true);\n try {\n await operatorApi.multiHostCancel(runningId);\n } catch (reason) {\n reportError(reason);\n setCancelRequested(false);\n }\n }''' + if old_execute not in s: + raise SystemExit('OperatorCenter multi-host execute marker missing') + s = s.replace(old_execute, new_execute, 1) + old_button = '' + new_button = '''
\n \n {runningId ? : null}\n
\n {runningId ?
Cancellation stops new batches; SSH commands already in flight are allowed to finish and are still audited.
: null}''' + if old_button not in s: + raise SystemExit('OperatorCenter multi-host button marker missing') + s = s.replace(old_button, new_button, 1) + p.write_text(s) + PY + cargo fmt --manifest-path src-tauri/Cargo.toml + - name: Remove helper and commit + run: | + git rm .github/workflows/phase3-multihost-cancel.yml + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add src-tauri/src src/operatorTypes.ts src/operatorApi.ts src/components/OperatorCenter.tsx + git commit -m 'feat: add cancellable multi-host batches' + git push origin HEAD:agent/operator-experience diff --git a/README.md b/README.md index dc8e98f..4129502 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ RemoteOpsX combines SSH/SFTP/FTP/RDP/VNC access, agentless server-health monitor ## Project status -RemoteOpsX is a validated MVP undergoing release verification. The core operator workflows and the P0 implementation for explicit SSH host trust, runtime dependency preflight, restart reconciliation, and known-secret redaction are implemented. The remaining public-release blockers are the live SSH integration fixture, packaged-app E2E/security coverage, signed distribution, and repository-side enforcement of the documented branch-protection policy. +RemoteOpsX is a validated MVP undergoing release verification. The core operator workflows and the P0 implementation for explicit SSH host trust, runtime dependency preflight, restart reconciliation, known-secret redaction, bastion routing, and the live ephemeral-SSH integration fixture are implemented. Operator depth now includes historical health/alerts, persistent transfer queues, Runbook Studio, tunnel resilience, encrypted workspace backup, guarded multi-host operations, a live operations dashboard, and a universal command palette. The remaining public-release blockers are packaged-app E2E/security coverage, signed distribution, and repository-side enforcement of the documented branch-protection policy. Current automated checks: @@ -57,6 +57,14 @@ Health collection runs over a separate SSH exec path and does not interfere with - **Logs / diagnostic bundles** — remote log and journal collection with local export. - **Runbooks** — YAML steps, variables, confirmation boundaries, output capture, and persisted history. - **SSH tunnels** — local, remote, and dynamic SOCKS forwards with persisted records and live-process reconciliation. +- **Bastion routing** — key-auth jump hosts with explicit trust on both hops and shared routing across terminal, exec, health, runbooks, SFTP/SCP and tunnels. +- **Health history + alerts** — bounded local history, threshold/consecutive/cooldown rules, persisted alert acknowledgement and dashboard rollups. +- **Persistent transfers** — ControlMaster-backed SFTP/SCP queue with cancellation, recursive transfer, byte progress for single files, chmod and drag/drop. +- **Runbook Studio** — YAML validation/import/export, variable-aware dry-run preview and retry from the first failed step. +- **Operator Center + Dashboard** — fleet health, alerts, transfers, multi-host operations, tunnel policies and backup/restore in one local-first workspace. +- **Multi-host operations** — bounded fan-out with production/destructive safety gates and per-host audit results. +- **Encrypted workspace backup** — versioned encrypted export/import without keyring secret export; restored password profiles require credential re-entry. +- **Universal command palette** — fuzzy actions across servers, protocols, health, diagnostics, runbooks, snippets and application workflows. - **Startup recovery** — stale sessions/runbooks are marked interrupted and stale persisted tunnel state is reconciled after an unclean restart. - **Known-secret redaction** — keyring secrets are centrally registered and masked from buffered remote output, backend errors/logging, runbook results, and exported text; known credentials are rejected from persisted profile metadata, snippets, and runbooks. - **Session history** and **command snippets**. @@ -200,7 +208,6 @@ Current protections: Remaining release blockers: -- Live ephemeral-SSH integration tests must verify key/password auth, PTY/exec/SCP/tunnel behavior, and host-key mismatch rejection against a real SSH server. - Packaged desktop E2E/security tests must cover fresh install, upgrade, keyring/dependency failures, destructive confirmation boundaries, and diagnostic-export leakage. - Release artifacts have checksums but are not yet signed/notarized. - The repository-side `main` branch protection/ruleset must enforce the required CI checks documented in `.github/BRANCH_PROTECTION.md`. diff --git a/TODO.md b/TODO.md index 821b01e..1b0de88 100644 --- a/TODO.md +++ b/TODO.md @@ -30,19 +30,23 @@ Status legend: ✅ done · 🚧 partial · ⬜ planned - ⬜ **Signed releases** — code-sign/notarize macOS artifacts and sign Linux release artifacts. Checksums are published, but signatures are still required for a strong distribution trust chain. ## P1 — operator depth -- ⬜ **Persistent SFTP subsystem** — replace per-operation `ssh`/`scp`; add progress, cancellation, recursive transfers, chmod and drag/drop. -- ⬜ **Runbook editor** — validated form/YAML editor, variables, dry-run, import/export and partial retry from a failed step. -- ⬜ **Tunnel resilience** — health checks, auto-reconnect, autostart-on-connect and explicit failure state. -- ⬜ **Health history** — bounded per-server time-series retention, custom thresholds and desktop/webhook alert routing. +- ✅ **Persistent SFTP transfer subsystem** — strict per-server OpenSSH ControlMaster reuse, cancellable background upload/download jobs, recursive transfers, single-file byte progress, chmod and Tauri-native drag/drop queueing. +- ✅ **Runbook Studio** — bounded YAML import/export, validation, variable rendering, non-executing dry-run preview, destructive/confirmation markers and retry from the first failed step. +- ✅ **Tunnel resilience** — persisted autostart/auto-reconnect desired-state policies, reconciliation, explicit failed state and explicit Stop precedence. +- 🚧 **Health history + alerts** — bounded 30-second/7-day per-server history, custom threshold/consecutive/cooldown rules, persisted acknowledgement and dashboard visualization are implemented. Desktop/webhook delivery remains planned. - ⬜ **App lock** — optional local lock/master credential with keyring-aware unlock behavior. -- ⬜ **Import/export** — encrypted, versioned backup/restore of profiles, settings, runbooks and snippets without exporting keyring secrets by default. +- ✅ **Import/export** — encrypted versioned workspace backup/restore for profiles, settings, runbooks, snippets, bastions, alert rules and tunnels; keyring secrets are never exported and password profiles require credential re-entry. - ✅ **Jump hosts / bastion routing** — first-class key-auth bastion configuration with strict app-owned SSH config, explicit bastion fingerprint trust, destination fingerprint inspection through the trusted bastion, and shared routing across terminal/exec/health/runbooks/SCP/tunnels. +## Product experience +- ✅ **Operations Dashboard** — fleet rollup from persisted health, alerts, tunnel state and recent automation, with direct server/SSH drill-down. +- ✅ **Universal Command Palette** — fuzzy index across application actions, protocol-specific server actions, health/diagnostics, runbooks and snippets; snippet execution requires a focused target and confirmation. + ## P2 — transport and desktop expansion - ⬜ Native SSH transport (for example `russh`/`libssh2`) if it materially improves host-key UX, multiplexing, passphrase prompts and portability over the hardened system-OpenSSH backend. - ⬜ Embedded RDP and VNC sessions instead of external viewers. - ⬜ Flatpak and additional distro packaging targets after the signed release path is stable. -- ⬜ Broadcast-to-many commands with strong environment/risk safeguards and per-host result tracking. +- ✅ Broadcast-to-many commands with a 50-host cap, bounded concurrency, independent production/destructive confirmations, known-secret rejection and persisted per-host result tracking. ## Quality gates - ✅ Frontend regression tests diff --git a/src-tauri/src/experience_commands.rs b/src-tauri/src/experience_commands.rs new file mode 100644 index 0000000..15add66 --- /dev/null +++ b/src-tauri/src/experience_commands.rs @@ -0,0 +1,397 @@ +//! Product-experience commands for the operations dashboard and Runbook Studio. + +use std::collections::{BTreeSet, HashMap}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::error::{CommandResult, DomainError}; +use crate::models::{RunbookRun, RunbookSpec, Tunnel}; +use crate::operator_data::{MultiHostRun, OperatorAlert}; +use crate::{database, multi_host, operator_data, redaction, runbook_runner, AppState}; + +const MAX_RUNBOOK_IMPORT_BYTES: u64 = 256 * 1024; + +fn internal(result: Result) -> CommandResult { + result.map_err(DomainError::internal) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunbookPreviewStep { + pub index: usize, + pub name: String, + pub command: String, + pub requires_confirmation: bool, + pub destructive: bool, + pub unresolved_variables: Vec, + pub success_pattern: Option, + pub failure_pattern: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunbookPreview { + pub spec: RunbookSpec, + pub steps: Vec, + pub unresolved_variables: Vec, + pub valid: bool, +} + +fn valid_variable_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') +} + +/// Render every `{{ variable }}` span in one pass. This deliberately parses the +/// exact source spans rather than replacing a couple of whitespace variants, so +/// preview and execution cannot disagree on `{{name}}`, `{{ name }}` or wider +/// spacing. Malformed template syntax is rejected instead of being treated as a +/// valid dry-run. +fn substitute( + command: &str, + variables: &HashMap, +) -> CommandResult<(String, Vec)> { + let mut rendered = String::with_capacity(command.len()); + let mut unresolved = BTreeSet::new(); + let mut remaining = command; + + while let Some(start) = remaining.find("{{") { + rendered.push_str(&remaining[..start]); + let after_open = &remaining[start + 2..]; + let Some(relative_end) = after_open.find("}}") else { + return Err(DomainError::validation( + "command", + "runbook variable placeholder is missing a closing }}", + )); + }; + let raw = &after_open[..relative_end]; + let name = raw.trim(); + if !valid_variable_name(name) { + return Err(DomainError::validation( + "command", + "runbook variables must use letters, numbers, or underscore inside {{...}}", + )); + } + match variables.get(name) { + Some(value) if !value.is_empty() => rendered.push_str(value), + _ => { + unresolved.insert(name.to_string()); + rendered.push_str(&remaining[start..start + 2 + relative_end + 2]); + } + } + remaining = &after_open[relative_end + 2..]; + } + rendered.push_str(remaining); + + Ok((rendered, unresolved.into_iter().collect())) +} + +fn preview_yaml( + content_yaml: &str, + variables: Option>, +) -> CommandResult { + if content_yaml.len() > MAX_RUNBOOK_IMPORT_BYTES as usize { + return Err(DomainError::validation( + "content_yaml", + "runbook YAML is limited to 256 KiB", + )); + } + if redaction::contains_known_secret(content_yaml) { + return Err(DomainError::validation( + "content_yaml", + "runbook YAML contains a stored credential", + )); + } + let spec = runbook_runner::parse(content_yaml) + .map_err(|error| DomainError::validation("content_yaml", error.to_string()))?; + if spec.name.trim().is_empty() { + return Err(DomainError::validation("name", "runbook name is required")); + } + if spec.steps.is_empty() { + return Err(DomainError::validation( + "steps", + "runbook must contain at least one step", + )); + } + + let mut resolved_variables = spec.variables.clone(); + if let Some(overrides) = variables { + resolved_variables.extend(overrides); + } + let mut all_unresolved = BTreeSet::new(); + let steps = spec + .steps + .iter() + .enumerate() + .map(|(index, step)| { + if step.name.trim().is_empty() || step.command.trim().is_empty() { + return Err(DomainError::validation( + "steps", + format!("step {} requires a name and command", index + 1), + )); + } + let (command, unresolved_variables) = substitute(&step.command, &resolved_variables)?; + all_unresolved.extend(unresolved_variables.iter().cloned()); + let destructive = multi_host::looks_destructive(&command); + Ok(RunbookPreviewStep { + index, + name: step.name.clone(), + command, + // A rendered variable can turn a benign template into a + // destructive operation. Confirmation is therefore derived + // after rendering and cannot be disabled by the YAML author. + requires_confirmation: step.requires_confirmation || destructive, + destructive, + unresolved_variables, + success_pattern: step.success_pattern.clone(), + failure_pattern: step.failure_pattern.clone(), + }) + }) + .collect::>>()?; + let unresolved_variables = all_unresolved.into_iter().collect::>(); + Ok(RunbookPreview { + spec, + steps, + valid: unresolved_variables.is_empty(), + unresolved_variables, + }) +} + +#[tauri::command] +pub fn runbook_preview_yaml( + content_yaml: String, + variables: Option>, +) -> CommandResult { + preview_yaml(&content_yaml, variables) +} + +/// Prepare a saved runbook immediately before execution. The frontend executes +/// only these server-rendered commands, keeping variable resolution and +/// destructive confirmation policy identical to Studio's dry-run preview. +#[tauri::command] +pub fn runbook_preview_saved( + state: State, + runbook_id: String, + variables: Option>, +) -> CommandResult { + let runbook = { + let conn = state + .db + .lock() + .map_err(|_| DomainError::internal("database lock poisoned"))?; + internal(database::get_runbook(&conn, &runbook_id))? + }; + preview_yaml(&runbook.content_yaml, variables) +} + +#[tauri::command] +pub fn runbook_import_yaml(path: String) -> CommandResult { + let path_ref = Path::new(&path); + let extension = path_ref + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !matches!(extension.as_str(), "yaml" | "yml") { + return Err(DomainError::validation( + "path", + "Runbook Studio imports only .yaml or .yml files", + )); + } + let metadata = internal(std::fs::metadata(path_ref))?; + if !metadata.is_file() || metadata.len() > MAX_RUNBOOK_IMPORT_BYTES { + return Err(DomainError::validation( + "path", + "runbook import must be a file no larger than 256 KiB", + )); + } + let content = internal(std::fs::read_to_string(path_ref))?; + let _ = preview_yaml(&content, None)?; + Ok(content) +} + +#[tauri::command] +pub fn runbook_export_yaml(path: String, content_yaml: String) -> CommandResult<()> { + let _ = preview_yaml(&content_yaml, None)?; + let extension = Path::new(&path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !matches!(extension.as_str(), "yaml" | "yml") { + return Err(DomainError::validation( + "path", + "runbook export path must end in .yaml or .yml", + )); + } + internal(std::fs::write(path, content_yaml)) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardServer { + pub server_id: String, + pub name: String, + pub environment: String, + pub status: String, + pub sampled_at: Option, + pub cpu_percent: Option, + pub mem_percent: Option, + pub max_disk_percent: Option, + pub failed_services: Option, + pub unacknowledged_alerts: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSummary { + pub servers_total: usize, + pub healthy: usize, + pub warning: usize, + pub critical: usize, + pub unknown: usize, + pub active_tunnels: usize, + pub failed_tunnels: usize, + pub unacknowledged_alerts: usize, + pub servers: Vec, + pub recent_alerts: Vec, + pub recent_runbooks: Vec, + pub recent_multi_host: Vec, +} + +fn quick_status(cpu: f64, memory: f64, disk: f64, failed_services: u32) -> &'static str { + if failed_services > 0 || cpu >= 95.0 || memory >= 95.0 || disk >= 95.0 { + "critical" + } else if cpu >= 80.0 || memory >= 85.0 || disk >= 85.0 { + "warning" + } else { + "healthy" + } +} + +#[tauri::command] +pub fn operator_dashboard_summary(state: State) -> CommandResult { + let conn = state + .db + .lock() + .map_err(|_| DomainError::internal("database lock poisoned"))?; + internal(operator_data::ensure_schema(&conn))?; + let servers = internal(database::list_servers(&conn))?; + let alerts = internal(operator_data::alerts(&conn, 200))?; + let recent_runbooks = internal(database::list_runbook_runs(&conn, 12))?; + let recent_multi_host = internal(operator_data::multi_host_runs(&conn, 12))?; + let tunnels: Vec = internal(database::list_tunnels(&conn))?; + + let unacknowledged_alerts = alerts + .iter() + .filter(|alert| alert.acknowledged_at.is_none()) + .count(); + let mut rollups = Vec::with_capacity(servers.len()); + for server in servers { + let point = internal(operator_data::health_history(&conn, &server.id, 1))? + .into_iter() + .next(); + let server_alerts = alerts + .iter() + .filter(|alert| alert.server_id == server.id && alert.acknowledged_at.is_none()) + .count(); + let status = point + .as_ref() + .map(|point| { + quick_status( + point.cpu_percent, + point.mem_percent, + point.max_disk_percent, + point.failed_services, + ) + .to_string() + }) + .unwrap_or_else(|| "unknown".into()); + rollups.push(DashboardServer { + server_id: server.id, + name: server.name, + environment: server.environment, + status, + sampled_at: point.as_ref().map(|point| point.sampled_at.clone()), + cpu_percent: point.as_ref().map(|point| point.cpu_percent), + mem_percent: point.as_ref().map(|point| point.mem_percent), + max_disk_percent: point.as_ref().map(|point| point.max_disk_percent), + failed_services: point.as_ref().map(|point| point.failed_services), + unacknowledged_alerts: server_alerts, + }); + } + rollups.sort_by_key(|server| match server.status.as_str() { + "critical" => 0, + "warning" => 1, + "unknown" => 2, + _ => 3, + }); + Ok(DashboardSummary { + servers_total: rollups.len(), + healthy: rollups.iter().filter(|server| server.status == "healthy").count(), + warning: rollups.iter().filter(|server| server.status == "warning").count(), + critical: rollups.iter().filter(|server| server.status == "critical").count(), + unknown: rollups.iter().filter(|server| server.status == "unknown").count(), + active_tunnels: tunnels.iter().filter(|tunnel| tunnel.status == "active").count(), + failed_tunnels: tunnels.iter().filter(|tunnel| tunnel.status == "failed").count(), + unacknowledged_alerts, + servers: rollups, + recent_alerts: alerts.into_iter().take(20).collect(), + recent_runbooks, + recent_multi_host, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_renders_variable_whitespace_and_marks_destructive_steps() { + let yaml = r#"name: Deploy +description: Preview +target_os: linux +variables: + service: nginx +steps: + - name: Status + command: systemctl status {{ service }} + - name: Restart + command: sudo systemctl {{action}} {{service}} +"#; + let mut overrides = HashMap::new(); + overrides.insert("action".into(), "restart".into()); + let preview = preview_yaml(yaml, Some(overrides)).unwrap(); + assert!(preview.valid); + assert_eq!(preview.steps[0].command, "systemctl status nginx"); + assert_eq!(preview.steps[1].command, "sudo systemctl restart nginx"); + assert!(preview.steps[1].destructive); + assert!(preview.steps[1].requires_confirmation); + } + + #[test] + fn preview_reports_unresolved_variables() { + let yaml = r#"name: Check +description: Missing variable +variables: {} +steps: + - name: Check + command: echo {{missing}} +"#; + let preview = preview_yaml(yaml, None).unwrap(); + assert!(!preview.valid); + assert_eq!(preview.unresolved_variables, vec!["missing"]); + } + + #[test] + fn malformed_variable_placeholders_are_rejected() { + let yaml = r#"name: Check +description: Bad variable +variables: {} +steps: + - name: Check + command: echo {{bad-name}} +"#; + assert!(preview_yaml(yaml, None).is_err()); + } +} diff --git a/src/App.tsx b/src/App.tsx index 6d33c1c..af2c3e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { TabContent } from "./components/TabContent"; import { RightPanel } from "./components/RightPanel"; import { BottomPanel } from "./components/BottomPanel"; import { RunbookLauncher } from "./components/RunbookLauncher"; +import { RunbookStudio } from "./components/RunbookStudio"; import { TunnelManager } from "./components/TunnelManager"; import { JumpHostManager } from "./components/JumpHostManager"; import { OperatorCenter } from "./components/OperatorCenter"; @@ -28,6 +29,7 @@ export default function App() { const [editing, setEditing] = useState(undefined); const [initialFolder, setInitialFolder] = useState(undefined); const [showRunbooks, setShowRunbooks] = useState(false); + const [showRunbookStudio, setShowRunbookStudio] = useState(false); const [showTunnels, setShowTunnels] = useState(false); const [showJumpHosts, setShowJumpHosts] = useState(false); const [showOperatorCenter, setShowOperatorCenter] = useState(false); @@ -94,7 +96,7 @@ export default function App() {
RemoteOpsX
{!settingsInitialized ? Loading settings… : null} @@ -105,6 +107,7 @@ export default function App() {
+ @@ -114,7 +117,12 @@ export default function App() {
- setShowRunbooks(true)} onOpenTunnels={() => setShowTunnels(true)} /> + setShowRunbooks(true)} + onOpenTunnels={() => setShowTunnels(true)} + onOpenOperations={() => setShowOperatorCenter(true)} + />
{!rightCollapsed && } @@ -125,7 +133,9 @@ export default function App() { onClose={() => setPaletteOpen(false)} onNewServer={() => openNewServer()} onOpenRunbooks={() => setShowRunbooks(true)} + onOpenRunbookStudio={() => setShowRunbookStudio(true)} onOpenTunnels={() => setShowTunnels(true)} + onOpenOperations={() => setShowOperatorCenter(true)} onOpenSettings={() => openSettings(commandTriggerRef.current)} /> @@ -133,6 +143,7 @@ export default function App() { { setEditing(undefined); setInitialFolder(undefined); }} /> )} {showRunbooks && setShowRunbooks(false)} />} + {showRunbookStudio && setShowRunbookStudio(false)} />} {showTunnels && setShowTunnels(false)} />} {showJumpHosts && setShowJumpHosts(false)} />} {showOperatorCenter && setShowOperatorCenter(false)} />} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index a873169..59a989e 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,13 +1,22 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import * as api from "../api"; import { useStore } from "../store"; -import type { RightPanelView, Server, TabKind } from "../types"; +import type { + CommandSnippet, + RightPanelView, + Runbook, + Server, + TabKind, +} from "../types"; interface Props { open: boolean; onClose: () => void; onNewServer: () => void; onOpenRunbooks: () => void; + onOpenRunbookStudio: () => void; onOpenTunnels: () => void; + onOpenOperations: () => void; onOpenSettings: () => void; } @@ -17,10 +26,14 @@ interface PaletteAction { eyebrow: string; detail?: string; keywords: string; - run: () => void; + run: () => void | Promise; } -const SERVER_ACTIONS: { kind: TabKind; label: string; requiresProtocol?: "ssh" | "sftp" | "ftp" | "rdp" | "vnc" }[] = [ +const SERVER_ACTIONS: { + kind: TabKind; + label: string; + requiresProtocol?: "ssh" | "sftp" | "ftp" | "rdp" | "vnc"; +}[] = [ { kind: "ssh", label: "Open SSH", requiresProtocol: "ssh" }, { kind: "sftp", label: "Open SFTP", requiresProtocol: "sftp" }, { kind: "ftp", label: "Open FTP", requiresProtocol: "ftp" }, @@ -29,28 +42,103 @@ const SERVER_ACTIONS: { kind: TabKind; label: string; requiresProtocol?: "ssh" | { kind: "vnc", label: "Launch VNC", requiresProtocol: "vnc" }, ]; -/** Keyboard-first command palette for jumping across servers and common actions. */ -export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onOpenTunnels, onOpenSettings }: Props) { +/** Keyboard-first universal operator launcher. It indexes application actions, + * servers, protocol actions, panels, runbooks, snippets and open tabs. */ +export function CommandPalette({ + open, + onClose, + onNewServer, + onOpenRunbooks, + onOpenRunbookStudio, + onOpenTunnels, + onOpenOperations, + onOpenSettings, +}: Props) { const servers = useStore((s) => s.servers); const tabs = useStore((s) => s.tabs); const activeTabId = useStore((s) => s.activeTabId); + const focusedServerId = useStore((s) => s.focusedServerId); const openTab = useStore((s) => s.openTab); const setActiveTab = useStore((s) => s.setActiveTab); const setFocusedServer = useStore((s) => s.setFocusedServer); const setRightPanel = useStore((s) => s.setRightPanel); const setBottomPanel = useStore((s) => s.setBottomPanel); const toggleBottomPanel = useStore((s) => s.toggleBottomPanel); + const pushAlert = useStore((s) => s.pushAlert); + const pushOutput = useStore((s) => s.pushOutput); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(0); + const [runbooks, setRunbooks] = useState([]); + const [snippets, setSnippets] = useState([]); const inputRef = useRef(null); + const focusedServer = useMemo( + () => servers.find((server) => server.id === focusedServerId) ?? null, + [focusedServerId, servers], + ); + + useEffect(() => { + if (!open) return; + let cancelled = false; + void Promise.all([api.runbooksList(), api.commandSnippetsList()]) + .then(([nextRunbooks, nextSnippets]) => { + if (cancelled) return; + setRunbooks(nextRunbooks); + setSnippets(nextSnippets); + }) + .catch((error) => { + if (!cancelled) pushAlert("warn", `Palette index refresh failed: ${error}`); + }); + return () => { + cancelled = true; + }; + }, [open, pushAlert]); + const actions = useMemo(() => { const closeThen = (action: () => void) => () => { action(); onClose(); }; + const focusPanel = (server: Server, view: RightPanelView) => { + setFocusedServer(server.id); + setRightPanel(view); + onClose(); + }; + const globalActions: PaletteAction[] = [ + { + id: "operations-dashboard", + title: "Open operations dashboard", + eyebrow: "Operations", + detail: "Fleet health, alerts, tunnels and recent automation", + keywords: "operations dashboard fleet unhealthy critical warning noc overview health", + run: closeThen(onOpenOperations), + }, + { + id: "operator-center", + title: "Open Operator Center", + eyebrow: "Operations", + detail: "Alerts, transfers, multi-host commands, tunnels and backup", + keywords: "operator center alerts transfers multi host broadcast backup restore tunnel", + run: closeThen(onOpenOperations), + }, + { + id: "runbook-studio", + title: "Open Runbook Studio", + eyebrow: "Automation", + detail: "Author, validate, dry-run, import and export runbooks", + keywords: "runbook studio yaml editor dry run automation import export", + run: closeThen(onOpenRunbookStudio), + }, + { + id: "runbooks", + title: "Open runbook launcher", + eyebrow: "Automation", + detail: "Pick a runbook and target server", + keywords: "runbook automation diagnose health execute", + run: closeThen(onOpenRunbooks), + }, { id: "settings", title: "Open application settings", @@ -67,14 +155,6 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO keywords: "add new server profile host", run: closeThen(onNewServer), }, - { - id: "runbooks", - title: "Open runbook launcher", - eyebrow: "Automation", - detail: "Pick a built-in runbook and target server", - keywords: "runbook automation diagnose health", - run: closeThen(onOpenRunbooks), - }, { id: "tunnels", title: "Manage SSH tunnels", @@ -117,16 +197,18 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO }, ]; - const panelActions: PaletteAction[] = (["health", "services", "notes", "snippets"] as RightPanelView[]).map((view) => ({ - id: `panel-${view}`, - title: `Focus ${view} panel`, - eyebrow: "Right panel", - detail: "Switch the operations side panel", - keywords: `${view} right panel metrics services notes snippets`, - run: closeThen(() => setRightPanel(view)), - })); + const panelActions: PaletteAction[] = focusedServer + ? (["health", "diagnostics", "services", "notes", "snippets"] as RightPanelView[]).map((view) => ({ + id: `panel-${view}-${focusedServer.id}`, + title: `${capitalize(view)} · ${focusedServer.name}`, + eyebrow: "Focused server", + detail: `Open the ${view} panel for ${focusedServer.host}`, + keywords: `${view} right panel metrics health diagnostics services notes snippets ${serverKeywords(focusedServer)}`, + run: () => focusPanel(focusedServer, view), + })) + : []; - const tabActions = tabs.map((tab) => ({ + const tabActions: PaletteAction[] = tabs.map((tab) => ({ id: `tab-${tab.id}`, title: tab.title, eyebrow: tab.id === activeTabId ? "Active tab" : "Open tab", @@ -135,7 +217,7 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO run: closeThen(() => setActiveTab(tab.id)), })); - const serverActions = servers.flatMap((server) => [ + const serverActions: PaletteAction[] = servers.flatMap((server) => [ { id: `focus-${server.id}`, title: `Focus ${server.name}`, @@ -144,7 +226,25 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO keywords: serverKeywords(server), run: closeThen(() => setFocusedServer(server.id)), }, - ...SERVER_ACTIONS.filter((action) => !action.requiresProtocol || server.protocols.includes(action.requiresProtocol)).map((action) => ({ + { + id: `health-${server.id}`, + title: `Health · ${server.name}`, + eyebrow: server.environment, + detail: "Focus host and open live/persisted health", + keywords: `${serverKeywords(server)} health cpu ram disk load unhealthy metrics`, + run: () => focusPanel(server, "health"), + }, + { + id: `diagnostics-${server.id}`, + title: `Diagnostics · ${server.name}`, + eyebrow: server.environment, + detail: "SSH trust, runtime readiness and authenticated probe", + keywords: `${serverKeywords(server)} diagnostics ssh trust fingerprint probe connectivity`, + run: () => focusPanel(server, "diagnostics"), + }, + ...SERVER_ACTIONS.filter( + (action) => !action.requiresProtocol || server.protocols.includes(action.requiresProtocol), + ).map((action) => ({ id: `${action.kind}-${server.id}`, title: `${action.label} · ${server.name}`, eyebrow: server.group_name || "Server action", @@ -154,40 +254,109 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO })), ]); - return [...globalActions, ...panelActions, ...tabActions, ...serverActions]; + const runbookActions: PaletteAction[] = runbooks.map((runbook) => { + const target = focusedServer; + return { + id: `runbook-${runbook.id}`, + title: target ? `Run ${runbook.name} · ${target.name}` : runbook.name, + eyebrow: runbook.builtin ? "Built-in runbook" : "Runbook", + detail: target + ? `Open controlled execution on ${target.name}` + : "Focus a server first, or open the runbook launcher", + keywords: `runbook automation ${runbook.name} ${runbook.description} ${target ? serverKeywords(target) : ""}`, + run: target + ? closeThen(() => openTab("runbook", target, { runbookId: runbook.id, title: `Runbook · ${runbook.name} · ${target.name}` })) + : closeThen(onOpenRunbooks), + }; + }); + + const snippetActions: PaletteAction[] = snippets + .filter((snippet) => !focusedServer || snippet.tags.length === 0 || snippet.tags.some((tag) => focusedServer.tags.includes(tag))) + .map((snippet) => ({ + id: `snippet-${snippet.id}`, + title: focusedServer ? `${snippet.label} · ${focusedServer.name}` : snippet.label, + eyebrow: "Command snippet", + detail: focusedServer + ? `Requires confirmation before execution on ${focusedServer.environment}` + : "Focus an SSH server before executing this snippet", + keywords: `snippet command ${snippet.label} ${snippet.tags.join(" ")} ${focusedServer ? serverKeywords(focusedServer) : ""}`, + run: async () => { + const target = focusedServer; + if (!target) { + pushAlert("warn", "Focus a server before executing a command snippet."); + return; + } + if (!target.protocols.includes("ssh")) { + pushAlert("warn", `${target.name} does not expose SSH.` , target.id); + return; + } + const production = target.environment === "production"; + const confirmed = window.confirm( + `${production ? "PRODUCTION TARGET\n\n" : ""}Execute snippet “${snippet.label}” on ${target.name} (${target.host})?\n\n${snippet.command}`, + ); + if (!confirmed) return; + onClose(); + try { + const output = await api.runRemote(target.id, snippet.command); + pushOutput(`$ ${snippet.label} · ${target.name}\n${output.stdout}${output.stderr}`); + setBottomPanel("output"); + pushAlert(output.success ? "info" : "error", `${snippet.label} ${output.success ? "completed" : "failed"} on ${target.name}`, target.id); + } catch (error) { + pushAlert("error", `${snippet.label} failed on ${target.name}: ${error}`, target.id); + } + }, + })); + + return [ + ...globalActions, + ...panelActions, + ...runbookActions, + ...snippetActions, + ...tabActions, + ...serverActions, + ]; }, [ activeTabId, + focusedServer, onClose, onNewServer, + onOpenOperations, + onOpenRunbookStudio, onOpenRunbooks, - onOpenTunnels, onOpenSettings, + onOpenTunnels, openTab, + pushAlert, + pushOutput, + runbooks, servers, setActiveTab, setBottomPanel, setFocusedServer, setRightPanel, + snippets, tabs, toggleBottomPanel, ]); const filtered = useMemo(() => { const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - if (terms.length === 0) return actions.slice(0, 18); + if (terms.length === 0) return actions.slice(0, 24); return actions .map((action) => ({ action, score: terms.reduce((score, term) => { + const title = action.title.toLowerCase(); const haystack = `${action.title} ${action.eyebrow} ${action.detail ?? ""} ${action.keywords}`.toLowerCase(); - if (action.title.toLowerCase().includes(term)) return score + 4; + if (title.startsWith(term)) return score + 8; + if (title.includes(term)) return score + 4; if (haystack.includes(term)) return score + 1; return score - 20; }, 0), })) .filter((item) => item.score >= terms.length) .sort((a, b) => b.score - a.score || a.action.title.localeCompare(b.action.title)) - .slice(0, 18) + .slice(0, 24) .map((item) => item.action); }, [actions, query]); @@ -213,7 +382,7 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO } if (event.key === "ArrowDown") { event.preventDefault(); - setSelected((current) => Math.min(filtered.length - 1, current + 1)); + setSelected((current) => Math.min(Math.max(0, filtered.length - 1), current + 1)); return; } if (event.key === "ArrowUp") { @@ -223,7 +392,7 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO } if (event.key === "Enter") { event.preventDefault(); - filtered[selected]?.run(); + void filtered[selected]?.run(); } } @@ -237,7 +406,7 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={onKeyDown} - placeholder="Search servers, tabs, panels or actions" + placeholder="Search servers, health, runbooks, snippets or actions" /> Esc @@ -250,7 +419,7 @@ export function CommandPalette({ open, onClose, onNewServer, onOpenRunbooks, onO key={action.id} className={`palette-item${selected === index ? " active" : ""}`} onMouseEnter={() => setSelected(index)} - onClick={action.run} + onClick={() => void action.run()} > {iconFor(action)} @@ -279,15 +448,22 @@ function serverKeywords(server: Server): string { ].filter(Boolean).join(" "); } +function capitalize(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + function iconFor(action: PaletteAction): string { if (action.id.startsWith("ssh-")) return "▰"; - if (action.id.startsWith("sftp-")) return "⇅"; - if (action.id.startsWith("ftp-")) return "⇅"; + if (action.id.startsWith("sftp-") || action.id.startsWith("ftp-")) return "⇅"; if (action.id.startsWith("rdp-") || action.id.startsWith("vnc-")) return "▣"; if (action.id.startsWith("focus-")) return "◉"; + if (action.id.startsWith("health-") || action.id.startsWith("diagnostics-")) return "◌"; if (action.id.startsWith("panel-")) return "◧"; if (action.id.startsWith("tab-")) return "▱"; - if (action.id === "runbooks") return "▶"; + if (action.id.startsWith("runbook-")) return "▶"; + if (action.id.startsWith("snippet-")) return ">_"; + if (action.id.includes("operations")) return "◎"; + if (action.id.includes("runbook")) return "▶"; if (action.id === "tunnels") return "⇄"; return "⌁"; } diff --git a/src/components/OperationsDashboard.tsx b/src/components/OperationsDashboard.tsx new file mode 100644 index 0000000..ef75e28 --- /dev/null +++ b/src/components/OperationsDashboard.tsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useState } from "react"; +import * as experienceApi from "../experienceApi"; +import { useStore } from "../store"; +import type { DashboardSummary } from "../experienceTypes"; +import type { Server, TabKind } from "../types"; + +export function OperationsDashboard({ + servers, + focusedServer, + onFocusServer, + onOpenTab, + onNewServer, + onOpenRunbooks, + onOpenTunnels, + onOpenOperations, +}: { + servers: Server[]; + focusedServer: Server | null; + onFocusServer: (id: string | null) => void; + onOpenTab: (kind: TabKind, server: Server) => string; + onNewServer: () => void; + onOpenRunbooks: () => void; + onOpenTunnels: () => void; + onOpenOperations: () => void; +}) { + const pushAlert = useStore((state) => state.pushAlert); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + setSummary(await experienceApi.dashboardSummary()); + } catch (reason) { + pushAlert("error", `Dashboard refresh failed: ${reason}`); + } finally { + setLoading(false); + } + }, [pushAlert]); + + useEffect(() => { + void refresh(); + const timer = window.setInterval(() => void refresh(), 15_000); + return () => window.clearInterval(timer); + }, [refresh]); + + const serverById = (id: string) => servers.find((server) => server.id === id) ?? null; + + return ( +
+
+
+ Operations overview +

{summary?.critical ? `${summary.critical} critical target${summary.critical === 1 ? "" : "s"}` : "Infrastructure at a glance."}

+

+ Persisted health, alert events, tunnels and recent automation are summarized here without deploying an agent. +

+
+ + + + + +
+
+
+ +
+ + + + +
+ +
+
+
+
Fleet

Server state

+ 30s persisted samples +
+ {!summary || summary.servers.length === 0 ? ( +
{servers.length ? "Health samples appear after polling succeeds." : "Add a server to start."}
+ ) : ( +
+ {summary.servers.slice(0, 18).map((rollup) => { + const server = serverById(rollup.server_id); + return ( +
+ +
+ CPU {formatPercent(rollup.cpu_percent)} + RAM {formatPercent(rollup.mem_percent)} + Disk {formatPercent(rollup.max_disk_percent)} + {rollup.failed_services ? {rollup.failed_services} failed svc : null} +
+ {server?.protocols.includes("ssh") ? : null} +
+ ); + })} +
+ )} +
+ +
+ Alerts +

Recent events

+ {!summary?.recent_alerts.length ?
No persisted alert events.
: summary.recent_alerts.slice(0, 8).map((alert) => ( + + ))} + +
+ +
+ Automation +

Recent runs

+ {!summary?.recent_runbooks.length && !summary?.recent_multi_host.length ? ( +
No recent automation runs.
+ ) : ( +
+ {summary?.recent_runbooks.slice(0, 5).map((run) => ( +
{run.status === "success" ? "✓" : "●"}Runbook · {run.status}{new Date(run.started_at).toLocaleTimeString()}
+ ))} + {summary?.recent_multi_host.slice(0, 5).map((run) => ( +
{run.status === "success" ? "✓" : "●"}Multi-host · {run.status}{run.results.length} hosts
+ ))} +
+ )} +
+
+
+
+ ); +} + +function MetricCard({ value, label, detail, tone }: { value: number; label: string; detail: string; tone?: "ok" | "critical" }) { + return
{value}{label}{detail}
; +} + +function formatPercent(value?: number | null) { + return value == null ? "—" : `${value.toFixed(0)}%`; +} + +function statusDot(status: string) { + if (status === "healthy") return "connected"; + if (status === "critical") return "closed"; + return "connecting"; +} diff --git a/src/components/RunbookRunner.tsx b/src/components/RunbookRunner.tsx index 4224599..cea067d 100644 --- a/src/components/RunbookRunner.tsx +++ b/src/components/RunbookRunner.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import * as api from "../api"; +import * as experienceApi from "../experienceApi"; import { useStore } from "../store"; import { confirmStep, @@ -9,14 +10,20 @@ import { skipStep, type RunState, } from "../runbookMachine"; -import type { RunbookSpec, Server, StepResult } from "../types"; +import type { RunbookPreview } from "../experienceTypes"; +import type { RunbookSpec, RunbookStep, Server, StepResult } from "../types"; -/** Executes one durable frontend run state across confirmation boundaries. */ +/** Executes one durable run state across confirmation boundaries. Every actual + * execution is prepared by the Rust backend first, so variable rendering and + * destructive-command confirmation use the same policy as Studio dry-run. */ export function RunbookRunner({ runbookId, server }: { runbookId: string; server: Server }) { const pushAlert = useStore((state) => state.pushAlert); const [spec, setSpec] = useState(null); const [vars, setVars] = useState>({}); + const [prepared, setPrepared] = useState(null); + const [preparing, setPreparing] = useState(false); const [run, setRun] = useState(null); + const [runOriginIndex, setRunOriginIndex] = useState(0); const [expanded, setExpanded] = useState>(() => new Set()); const recordedRun = useRef(null); @@ -26,7 +33,9 @@ export function RunbookRunner({ runbookId, server }: { runbookId: string; server if (cancelled) return; setSpec(loaded); setVars(loaded.variables ?? {}); + setPrepared(null); setRun(null); + setRunOriginIndex(0); }).catch((error) => pushAlert("error", `load runbook: ${error}`)); return () => { cancelled = true; }; }, [pushAlert, runbookId]); @@ -71,20 +80,61 @@ export function RunbookRunner({ runbookId, server }: { runbookId: string; server .catch((error) => pushAlert("error", `record run: ${error}`)); }, [pushAlert, run, runbookId, server.id, spec?.name]); - const previewSteps = useMemo( - () => spec ? createRun(spec.steps, vars, "preview").steps : [], - [spec, vars], - ); + const previewSteps = useMemo(() => { + if (prepared) { + return createRun(preparedSteps(prepared), {}, "preview").steps; + } + return spec ? createRun(spec.steps, vars, "preview").steps : []; + }, [prepared, spec, vars]); const steps = run?.steps ?? previewSteps; const completedSteps = steps.filter((step) => ["success", "failure", "skipped"].includes(step.state)).length; const progressPct = steps.length ? (completedSteps / steps.length) * 100 : 0; - const active = run?.phase === "running" || run?.phase === "executing"; + const active = preparing || Boolean(run && ["running", "executing", "waiting_confirmation"].includes(run.phase)); + const firstFailedResult = run?.phase === "complete" + ? run.results.findIndex((result) => result.status === "failure") + : -1; + const retryOriginalIndex = firstFailedResult >= 0 ? runOriginIndex + firstFailedResult : -1; + + async function startFrom(index: number) { + if (!spec || active) return; + setPreparing(true); + try { + const nextPrepared = await experienceApi.runbookPreviewSaved(runbookId, vars); + if (!nextPrepared.valid) { + pushAlert( + "warn", + `Runbook has unresolved variables: ${nextPrepared.unresolved_variables.join(", ")}`, + server.id, + ); + return; + } + const executable = preparedSteps(nextPrepared); + const bounded = Math.max(0, Math.min(index, executable.length - 1)); + recordedRun.current = null; + setExpanded(new Set()); + setPrepared(nextPrepared); + setRunOriginIndex(bounded); + setRun(createRun(executable.slice(bounded), {})); + } catch (error) { + pushAlert("error", `prepare runbook: ${error}`, server.id); + } finally { + setPreparing(false); + } + } function start() { - if (!spec) return; - recordedRun.current = null; - setExpanded(new Set()); - setRun(createRun(spec.steps, vars)); + void startFrom(0); + } + + function retryFromFailure() { + if (retryOriginalIndex < 0) return; + void startFrom(retryOriginalIndex); + } + + function updateVariable(key: string, value: string) { + setVars((current) => ({ ...current, [key]: value })); + // Never imply an old server-rendered preview represents changed inputs. + setPrepared(null); } function toggleExpanded(index: number) { @@ -103,12 +153,30 @@ export function RunbookRunner({ runbookId, server }: { runbookId: string; server

{spec.name}

{spec.description} · target {server.name}

+ {runOriginIndex > 0 && run ? Retry run starts at original step {runOriginIndex + 1}. : null} +
+
+ {retryOriginalIndex >= 0 && !active ? ( + + ) : null} +
- + {prepared && !run ? ( +
✓ Commands prepared by backend policy.
+ ) : null} + + {retryOriginalIndex >= 0 && !active ? ( +
+ The previous run failed at {spec.steps[retryOriginalIndex]?.name}. Retry executes that step and every step after it, with confirmation recalculated from the current rendered commands. +
+ ) : null} +
{completedSteps}/{steps.length} @@ -124,7 +192,7 @@ export function RunbookRunner({ runbookId, server }: { runbookId: string; server {Object.entries(vars).map(([key, value]) => (
- setVars((current) => ({ ...current, [key]: event.target.value }))} /> + updateVariable(key, event.target.value)} />
))}
@@ -134,11 +202,12 @@ export function RunbookRunner({ runbookId, server }: { runbookId: string; server {steps.map((step, index) => { const isExpanded = expanded.has(index) || step.state === "running"; const needsConfirmation = run?.pendingConfirmation === index; + const originalIndex = run ? runOriginIndex + index : index; return ( -
+
); } + +function preparedSteps(preview: RunbookPreview): RunbookStep[] { + return preview.steps.map((step) => ({ + name: step.name, + command: step.command, + requires_confirmation: step.requires_confirmation, + success_pattern: step.success_pattern ?? null, + failure_pattern: step.failure_pattern ?? null, + })); +} diff --git a/src/components/RunbookStudio.tsx b/src/components/RunbookStudio.tsx new file mode 100644 index 0000000..15d0c07 --- /dev/null +++ b/src/components/RunbookStudio.tsx @@ -0,0 +1,241 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { open as openDialog, save as saveDialog } from "@tauri-apps/plugin-dialog"; +import * as api from "../api"; +import * as experienceApi from "../experienceApi"; +import { useStore } from "../store"; +import type { Runbook } from "../types"; +import type { RunbookPreview } from "../experienceTypes"; + +const NEW_RUNBOOK = `name: New Runbook +description: Describe the operator outcome. +target_os: linux +variables: + service: nginx +steps: + - name: Inspect service + command: systemctl status {{service}} --no-pager || true + - name: Example guarded action + command: sudo systemctl restart {{service}} + requires_confirmation: true + - name: Verify + command: systemctl is-active {{service}} + success_pattern: active +`; + +export function RunbookStudio({ onClose }: { onClose: () => void }) { + const pushAlert = useStore((state) => state.pushAlert); + const [runbooks, setRunbooks] = useState([]); + const [selectedId, setSelectedId] = useState(""); + const [content, setContent] = useState(NEW_RUNBOOK); + const [preview, setPreview] = useState(null); + const [variables, setVariables] = useState>({}); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const selected = useMemo( + () => runbooks.find((runbook) => runbook.id === selectedId) ?? null, + [runbooks, selectedId], + ); + + const load = useCallback(async () => { + try { + setRunbooks(await api.runbooksList()); + } catch (reason) { + setError(String(reason)); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + useEffect(() => { + if (!selected) return; + setContent(selected.content_yaml); + setPreview(null); + setVariables({}); + setError(null); + }, [selected]); + + async function validate(overrides = variables): Promise { + setBusy(true); + setError(null); + try { + const next = await experienceApi.runbookPreviewYaml(content, overrides); + setPreview(next); + const merged = { ...next.spec.variables, ...overrides }; + setVariables(merged); + return next; + } catch (reason) { + setPreview(null); + setError(String(reason)); + return null; + } finally { + setBusy(false); + } + } + + async function saveRunbook() { + const next = await validate(); + if (!next || !next.valid) return; + setBusy(true); + try { + const id = selected && !selected.builtin ? selected.id : undefined; + const savedId = await api.runbookSave( + next.spec.name, + next.spec.description, + content, + id, + ); + await load(); + setSelectedId(savedId); + pushAlert("info", selected?.builtin + ? `Saved a custom copy of ${next.spec.name}` + : `Saved runbook ${next.spec.name}`); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + } + + async function importYaml() { + const picked = await openDialog({ + multiple: false, + directory: false, + filters: [{ name: "Runbook YAML", extensions: ["yaml", "yml"] }], + }); + if (!picked || Array.isArray(picked)) return; + setBusy(true); + try { + const yaml = await experienceApi.runbookImportYaml(picked); + setSelectedId(""); + setContent(yaml); + setVariables({}); + setPreview(await experienceApi.runbookPreviewYaml(yaml)); + } catch (reason) { + setError(String(reason)); + } finally { + setBusy(false); + } + } + + async function exportYaml() { + const next = preview ?? await validate(); + if (!next) return; + const suggested = `${next.spec.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "runbook"}.yaml`; + const path = await saveDialog({ + defaultPath: suggested, + filters: [{ name: "Runbook YAML", extensions: ["yaml", "yml"] }], + }); + if (!path) return; + try { + await experienceApi.runbookExportYaml(path, content); + pushAlert("info", `Exported runbook YAML to ${path}`); + } catch (reason) { + setError(String(reason)); + } + } + + function createNew() { + setSelectedId(""); + setContent(NEW_RUNBOOK); + setPreview(null); + setVariables({ service: "nginx" }); + setError(null); + } + + return ( +
event.target === event.currentTarget && onClose()}> +
+
+
Automation authoringRunbook Studio
+ +
+
+
+
+ + +
+
+
+ + +
+
+ + {selected?.builtin ? ( +
Built-ins are immutable templates in Studio. Saving creates a user-owned copy instead of replacing the shipped runbook.
+ ) : null} + +
+
+ +