From d15c5cd74e8eaeb71c08a5658c1863e923ecf723 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:13:06 +0000 Subject: [PATCH 1/6] Add manual incidents with MCP write access; drop manual conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual incidents are support-recorded incident records, written after the fact rather than derived from check state: title, markdown description, start/end times, optional group, and author attribution. They are created, edited, and deleted over the MCP interface (record/update/delete tools) and displayed read-only in the operator UI alongside automatic incidents. The MCP interface is no longer strictly read-only: each mount's auth gate now threads an McpIdentity (tailnet login or token name, plus write permission) into the request extensions, which the write tools use to authorise and attribute writes. Bearer tokens gain a mint-time, immutable write_access scope, default read-only, so existing tokens keep their current access; any tailnet user may write on the operator mount. Writes go to the primary pool while reads stay on the replica. The never-used manual conditions feature (operator-raised checks under the `manual` source) is deleted end to end — endpoint, UI, source plumbing, and spec — freeing the "manual" name; `canopy` is now the only reserved source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BWJTpUsXDp2Z6Ck7rnt8JL --- .workhorse/specs/monitoring/checks.md | 11 +- .workhorse/specs/monitoring/incidents.md | 11 + .workhorse/specs/private-server/mcp.md | 30 +- .workhorse/specs/public-server/statuses.md | 2 +- Cargo.lock | 1 + crates/canopy-mcp/Cargo.toml | 1 + crates/canopy-mcp/src/incidents.rs | 4 +- crates/canopy-mcp/src/lib.rs | 72 ++- crates/canopy-mcp/src/manual_incidents.rs | 293 +++++++++++ crates/canopy-mcp/src/util.rs | 19 + crates/database/src/check_policies.rs | 5 +- crates/database/src/issues.rs | 50 +- crates/database/src/lib.rs | 1 + crates/database/src/manual_incidents.rs | 167 ++++++ crates/database/src/mcp_tokens.rs | 5 + crates/database/src/schema.rs | 17 + crates/database/src/silenced_refs.rs | 14 +- crates/database/src/source_policies.rs | 2 +- crates/database/src/statuses.rs | 2 +- crates/private-server/src/fns.rs | 2 + crates/private-server/src/fns/healthchecks.rs | 11 +- crates/private-server/src/fns/issues.rs | 103 +--- .../src/fns/manual_incidents.rs | 159 ++++++ crates/private-server/src/fns/mcp_tokens.rs | 10 +- crates/private-server/src/lib.rs | 9 +- crates/private-server/src/mcp.rs | 7 +- crates/private-server/src/openapi.rs | 1 + crates/private-server/tests/it/issues.rs | 481 ++++++++---------- crates/private-server/tests/it/notes.rs | 39 +- crates/public-server/openapi.json | 2 +- crates/public-server/src/mcp.rs | 15 +- crates/public-server/src/statuses.rs | 12 +- crates/public-server/tests/it/statuses.rs | 2 +- .../down.sql | 1 + .../up.sql | 23 + .../down.sql | 1 + .../up.sql | 3 + plugins/canopy-mcp/.claude-plugin/plugin.json | 2 +- plugins/canopy-mcp/README.md | 6 +- private-web/openapi.json | 298 +++++++---- private-web/package-lock.json | 18 - private-web/src/App.tsx | 5 + private-web/src/api-types.ts | 240 ++++++--- .../src/components/ManualEventButton.tsx | 55 -- .../src/components/ManualEventForm.tsx | 114 ----- .../src/components/ManualIncidentCard.tsx | 66 +++ private-web/src/routes/Incidents.tsx | 35 +- .../src/routes/ManualIncidentDetail.tsx | 87 ++++ private-web/src/routes/McpTokens.tsx | 28 +- private-web/src/routes/ServerDetail.tsx | 52 +- private-web/src/types.ts | 3 +- 51 files changed, 1703 insertions(+), 894 deletions(-) create mode 100644 crates/canopy-mcp/src/manual_incidents.rs create mode 100644 crates/database/src/manual_incidents.rs create mode 100644 crates/private-server/src/fns/manual_incidents.rs create mode 100644 migrations/2026-07-21-014944-0000_manual_incidents/down.sql create mode 100644 migrations/2026-07-21-014944-0000_manual_incidents/up.sql create mode 100644 migrations/2026-07-21-014944-0001_mcp_tokens_write_access/down.sql create mode 100644 migrations/2026-07-21-014944-0001_mcp_tokens_write_access/up.sql delete mode 100644 private-web/src/components/ManualEventButton.tsx delete mode 100644 private-web/src/components/ManualEventForm.tsx create mode 100644 private-web/src/components/ManualIncidentCard.tsx create mode 100644 private-web/src/routes/ManualIncidentDetail.tsx diff --git a/.workhorse/specs/monitoring/checks.md b/.workhorse/specs/monitoring/checks.md index f53c241c..b1d1d1b1 100644 --- a/.workhorse/specs/monitoring/checks.md +++ b/.workhorse/specs/monitoring/checks.md @@ -21,12 +21,12 @@ Canopy-wide checks are Canopy monitoring its own operation (see [SELF](../privat A source is a named reporter of checks, identified by a short string. Multiple sources may report on the same server, each concerned with part of the system, and each source's reports are independent: a report from one source says nothing about another source's checks. -Two source names are reserved for Canopy itself: `canopy` for conditions Canopy determines on its own (reachability, backup health, key expiry, self-monitoring), and `manual` for conditions raised by operators. -Reports arriving over the device API cannot use the reserved names. +One source name is reserved for Canopy itself: `canopy`, for conditions Canopy determines on its own (reachability, backup health, key expiry, self-monitoring). +Reports arriving over the device API cannot use the reserved name. ### Source policy -Each source other than the reserved names carries two operator-set modes, global to the source and edited alongside the check catalog. +Each source other than the reserved name carries two operator-set modes, global to the source and edited alongside the check catalog. Its **reachability mode** governs how the source's silence bears on its servers' reachability (see "Reachability"): @@ -162,11 +162,6 @@ A resolved state that degrades again reopens: the resolution is cleared and the **Notes** attach free-form operator commentary to a state. -## Manual conditions - -Operators can raise a condition directly against a server, under the `manual` source, with a chosen check name, result, and message, and optionally marked as escalating. -A manual condition behaves as a reported check whose reporter is the operator: it stays active until an operator resolves it or raises it again as recovered. - ## Monitoring gate Server-targeted checks on a server that is not monitored are recorded and presented for visibility but do not contribute to incidents. diff --git a/.workhorse/specs/monitoring/incidents.md b/.workhorse/specs/monitoring/incidents.md index 337be083..95bbbd78 100644 --- a/.workhorse/specs/monitoring/incidents.md +++ b/.workhorse/specs/monitoring/incidents.md @@ -49,3 +49,14 @@ Resolution cascades to the incident's open issues — each is resolved with the Unresolving clears the resolution record; it does not reopen the incident. Notes attach free-form operator commentary to an incident. + +## Manual incidents + +Alongside the automatic incidents above, Canopy keeps manual incidents: records of incidents the support team managed, written after the fact rather than derived from check state. + +A manual incident carries a title, a markdown description, when it started, and when it ended; an ended time may be absent while the incident is ongoing. +It may name one server group as the affected target; a manual incident without a group concerns the fleet or Canopy generally. +Each records who created it and when it was created and last changed. + +Manual incidents are independent of the check-state model: no issue joins them, they never notify, and nothing opens, closes, or resolves them except the people editing them. +They are created, edited, and deleted over the MCP interface (see [MCP](../private-server/mcp.md)) and presented read-only in the operator UI alongside automatic incidents. diff --git a/.workhorse/specs/private-server/mcp.md b/.workhorse/specs/private-server/mcp.md index 56390ca5..f272b591 100644 --- a/.workhorse/specs/private-server/mcp.md +++ b/.workhorse/specs/private-server/mcp.md @@ -4,8 +4,9 @@ id: MCP # Fleet query interface -A read-only query interface to the Canopy fleet, exposed for AI agents and other automated clients that operators run. +A query interface to the Canopy fleet, exposed for AI agents and other automated clients that operators run. It lets such a client discover servers and groups, read their status and health, learn what Tamanu versions exist and which are deployed, and inspect backup state and problems — without granting any ability to change the fleet. +Its one write surface is the manual incident record (see [INC](../monitoring/incidents.md), "Manual incidents"), which suitably authorised callers can create and edit. ## Why it exists @@ -32,16 +33,22 @@ Each query a caller makes is attributable to its identity: the tailnet user on t Access tokens are minted, listed, and revoked by administrators on the operator surface. Each token has an operator-chosen name, records who minted it and when, and carries a fixed lifetime of one year from minting that cannot be extended or chosen at mint time. +A token is read-only unless it was minted with write access, a mint-time choice that cannot be changed afterwards; a token's scope is visible wherever the token is listed. The token secret is shown exactly once, at minting; the system persists only a digest of it, and never logs or re-displays the secret. A token can be revoked at any time, taking effect immediately. Each token records when it was last used, so idle tokens are visible. From fifteen days before a token's expiry until that token expires or is revoked, a [self-alert](self-alerts.md) is raised, so rotation happens on schedule rather than as an outage. -## Read-only +## Reads and writes Every query in this interface only reads. -Nothing it exposes creates, modifies, deletes, or triggers any fleet action, and no query has a side effect beyond being recorded as having happened. +Nothing it exposes triggers any fleet action, and no query has a side effect beyond being recorded as having happened. + +The sole write surface is the manual incident record: the interface can create, edit, and delete manual incidents, which describe incidents the support team managed and touch nothing in the fleet or the automatic monitoring. +On the token path, writing requires a token minted with write access; a read-only token's write attempt is refused with a message saying so. +On the operator path, any tailnet user may write. +Every write is attributed: the identity that made it — the token's name or the tailnet user — is recorded as the manual incident's author at creation. Mutating the fleet is out of scope for this interface. ## Queries @@ -94,6 +101,23 @@ A summary or ranking of incidents should count published incidents rather than r **Get issue** takes an issue identifier and returns the issue with the incidents it is or was part of. +### Manual incidents + +Manual incidents (see [INC](../monitoring/incidents.md), "Manual incidents") are both queried and written through this interface. + +**Find manual incidents** optionally narrows by group and to ongoing ones only, and returns a bounded list ordered most recently started first, each with its title, description, start and end times, target group (with its name resolved), author, and when it was created and last changed. +When the result is truncated to its bound, the result says so. + +**Get manual incident** takes an identifier and returns the full record. + +**Record manual incident** takes a title and a start time, and optionally a markdown description, an end time, and a target group; it creates the record with the caller's identity as author and returns it. + +**Update manual incident** takes an identifier and any subset of title, description, start time, and end time, and applies them; it can also explicitly clear the end time, marking the incident ongoing again. + +**Delete manual incident** takes an identifier and removes the record. + +### Check knowledge + **Get check documentation** takes a source and check name and returns the check's operator-authored markdown documentation (see [CHK](../monitoring/checks.md), "Documentation"), which by convention covers what the check observes, what each result means, and how to solve a failure. A client investigating an issue consults this before deriving a check's meaning from other sources. diff --git a/.workhorse/specs/public-server/statuses.md b/.workhorse/specs/public-server/statuses.md index e573a2fa..c9995b3c 100644 --- a/.workhorse/specs/public-server/statuses.md +++ b/.workhorse/specs/public-server/statuses.md @@ -17,7 +17,7 @@ The payload carries: - **source** — the name of the reporter pushing this status. Transitionally optional: a push without a source is attributed to `alertd`. The field will become mandatory; new reporters must send it. - The reserved source names (see [CHK](../monitoring/checks.md), "Sources") are rejected. + The reserved source name (see [CHK](../monitoring/checks.md), "Sources") is rejected. - **health** — the source's complete set of checks: for each, the check's name, exactly one result (`passed`, `warning`, `failed`, `broken`, or `skipped`), and any further detail fields, which are recorded verbatim against the check. The set may be empty, meaning the source currently has no checks — which recovers every check it previously reported. - any further top-level fields, recorded verbatim as the status's server-wide detail. diff --git a/Cargo.lock b/Cargo.lock index 5c4dca44..42b25c70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,7 @@ version = "6.6.6" dependencies = [ "commons-types", "database", + "http 1.4.2", "jiff", "rmcp", "schemars", diff --git a/crates/canopy-mcp/Cargo.toml b/crates/canopy-mcp/Cargo.toml index 345ecf1d..d58a86f6 100644 --- a/crates/canopy-mcp/Cargo.toml +++ b/crates/canopy-mcp/Cargo.toml @@ -13,6 +13,7 @@ authors = [ [dependencies] commons-types = { path = "../commons-types" } database = { path = "../database" } +http.workspace = true jiff = { version = "0.2.24", features = ["serde"] } rmcp = { version = "1.8", features = ["server", "macros", "transport-streamable-http-server"] } schemars = "1" diff --git a/crates/canopy-mcp/src/incidents.rs b/crates/canopy-mcp/src/incidents.rs index 0cb7b8c6..76ae4075 100644 --- a/crates/canopy-mcp/src/incidents.rs +++ b/crates/canopy-mcp/src/incidents.rs @@ -314,7 +314,9 @@ impl CanopyMcp { ) .await?; let ids: Vec = incidents.iter().map(|i| i.id).collect(); - let stats = Incident::stats_for(&self.db, &ids).await.map_err(mcp_err)?; + let stats = Incident::stats_for(&self.db_read, &ids) + .await + .map_err(mcp_err)?; let published = SlackOutbox::delivered_open_ids(&mut conn, &ids) .await .map_err(mcp_err)?; diff --git a/crates/canopy-mcp/src/lib.rs b/crates/canopy-mcp/src/lib.rs index 55c90775..3762a369 100644 --- a/crates/canopy-mcp/src/lib.rs +++ b/crates/canopy-mcp/src/lib.rs @@ -1,4 +1,4 @@ -//! Read-only MCP (Model Context Protocol) query interface over the fleet. +//! MCP (Model Context Protocol) query interface over the fleet. //! //! Spec: `.workhorse/specs/private-server/mcp.md` (id `MCP`). //! @@ -6,7 +6,9 @@ //! tagged-device guard and an "any tailnet user" gate (private-server's //! `mcp::require_tailnet_user`), and at `/mcp` on the internet-facing //! surface behind the bearer-token gate (public-server's `mcp` module). -//! Every tool only reads; nothing here mutates the fleet. +//! Every fleet tool only reads; nothing here mutates the fleet. The one +//! write surface is the manual incident record (`manual_incidents` +//! module), gated per caller by [`McpIdentity`]. //! //! Tools call the existing `database` read functions directly and shape lean, //! agent-legible JSON. The one piece of logic that must NOT be reimplemented is @@ -15,15 +17,17 @@ //! so the verdicts match what the operator UI and the alerting sweep present. //! //! Tools are grouped into domain modules (`servers`, `groups`, `versions`, -//! `fleet`, `backups`, `restore`, `incidents`), each contributing its own tool -//! router (via rmcp's `#[tool_router(router = ..., vis = "pub(crate)")]`) that -//! [`CanopyMcp::new`] combines into the single stored `ToolRouter`. `util` -//! holds helpers shared across more than one of those modules. +//! `fleet`, `backups`, `restore`, `incidents`, `manual_incidents`), each +//! contributing its own tool router (via rmcp's `#[tool_router(router = ..., +//! vis = "pub(crate)")]`) that [`CanopyMcp::new`] combines into the single +//! stored `ToolRouter`. `util` holds helpers shared across more than one of +//! those modules. mod backups; mod fleet; mod groups; mod incidents; +mod manual_incidents; mod restore; mod servers; mod util; @@ -40,28 +44,53 @@ use rmcp::{ }, }; +/// The authenticated caller, inserted into the HTTP request's extensions by +/// each mount's auth gate. The transport carries the request parts into the +/// tool context, where the write tools read this to authorise and attribute +/// the write; the read tools never look at it. +#[derive(Clone, Debug)] +pub struct McpIdentity { + /// The tailnet user's login (operator mount) or the token's name + /// (internet-facing mount); recorded as the author of writes. + pub who: String, + /// Whether this caller may use the write tools: always for tailnet + /// users, only for tokens minted with write access. + pub can_write: bool, +} + #[derive(Clone)] pub struct CanopyMcp { - db: database::Db, + db_read: database::Db, + db_write: database::Db, tool_router: ToolRouter, } impl CanopyMcp { - pub fn new(db: database::Db) -> Self { + pub fn new(db_write: database::Db, db_read: database::Db) -> Self { Self { - db, + db_read, + db_write, tool_router: Self::servers_router() + Self::groups_router() + Self::versions_router() + Self::fleet_router() + Self::backups_router() + Self::restore_router() - + Self::incidents_router(), + + Self::incidents_router() + + Self::manual_incidents_router(), } } async fn conn(&self) -> Result, McpError> { - self.db.get().await.map_err(util::mcp_err) + self.db_read.get().await.map_err(util::mcp_err) + } + + /// A connection on the primary pool, for the manual-incident write + /// tools; everything else reads via [`Self::conn`]. + async fn write_conn( + &self, + ) -> Result, McpError> { + self.db_write.get().await.map_err(util::mcp_err) } } @@ -70,9 +99,13 @@ impl ServerHandler for CanopyMcp { fn get_info(&self) -> ServerInfo { let mut info = ServerInfo::default(); info.instructions = Some( - "Read-only access to the Canopy fleet: servers, groups, health/status, Tamanu \ - versions, backups, and incidents/issues. All data is live. Use find_* to locate \ - entities and get_* for detail; fleet_summary and find_backup_problems for triage.\n\n\ + "Access to the Canopy fleet: servers, groups, health/status, Tamanu versions, \ + backups, and incidents/issues. All data is live. Use find_* to locate entities and \ + get_* for detail; fleet_summary and find_backup_problems for triage. Everything is \ + read-only except manual incidents: support-recorded incident records \ + (record/update/delete_manual_incident), which touch nothing else in the fleet. On \ + the token-authenticated surface those write tools need a token minted with write \ + access.\n\n\ Incidents: an incident groups the issues active for a group over a span of time. \ find_incidents returns everything open in the window, including heavy sub-grace \ flapping that was recorded but never surfaced. When summarizing or ranking, count \ @@ -93,8 +126,13 @@ impl ServerHandler for CanopyMcp { /// Build the tower service nested into an axum router (`/api/mcp` on the /// operator surface, `/mcp` on the internet-facing one). Auth is the mount's -/// business, not this service's. -pub fn service(db: database::Db) -> StreamableHttpService { +/// business, not this service's: the mount's gate must insert an +/// [`McpIdentity`] into the request extensions for the write tools to work. +/// Reads go to `db_read`; only the manual-incident writes touch `db_write`. +pub fn service( + db_write: database::Db, + db_read: database::Db, +) -> StreamableHttpService { let mut config = StreamableHttpServerConfig::default(); // Stateless: each request is self-contained, with no server-side session. // The default stateful mode keeps sessions in process memory and 404s @@ -129,7 +167,7 @@ pub fn service(db: database::Db) -> StreamableHttpService, + /// Only incidents without an end time (still ongoing). Default false. + pub ongoing_only: Option, + /// Max incidents to return (default 100). + pub limit: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ManualIncidentIdArgs { + /// The manual incident's id. + pub id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct RecordManualIncidentArgs { + /// Single-line headline for the incident. + pub title: String, + /// Markdown description: what happened, impact, resolution, links. + pub description: Option, + /// When the incident started (RFC 3339, e.g. `2026-07-01T10:00:00Z`). + pub started_at: String, + /// When the incident ended (RFC 3339). Omit while it is ongoing. + pub ended_at: Option, + /// Id of the affected server group. Omit for incidents concerning the + /// fleet or Canopy generally. + pub group_id: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UpdateManualIncidentArgs { + /// The manual incident's id. + pub id: String, + /// New headline. Omitted = unchanged. + pub title: Option, + /// New markdown description. Omitted = unchanged. + pub description: Option, + /// New start time (RFC 3339). Omitted = unchanged. + pub started_at: Option, + /// New end time (RFC 3339). Omitted = unchanged. + pub ended_at: Option, + /// Clear the end time, marking the incident ongoing again. Mutually + /// exclusive with `ended_at`. + pub clear_ended_at: Option, +} + +#[derive(Serialize)] +struct ManualIncidentOut { + id: Uuid, + title: String, + /// Markdown body; empty when nobody has written one yet. + description: String, + started_at: Timestamp, + /// `null` while the incident is ongoing. + ended_at: Option, + /// The affected server group, or `null` for an incident concerning the + /// fleet or Canopy generally. + group_id: Option, + group_name: Option, + /// Who recorded it: a tailnet login or an MCP token name. + created_by: String, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[derive(Serialize)] +struct ManualIncidentList { + count: usize, + /// True when more incidents matched than `limit` allowed. + truncated: bool, + incidents: Vec, +} + +fn parse_timestamp(s: &str, field: &str) -> Result { + s.parse::().map_err(|_| { + McpError::invalid_params( + format!("invalid {field}: {s} (want RFC 3339, e.g. 2026-07-01T10:00:00Z)"), + None, + ) + }) +} + +fn parse_opt_timestamp(v: &Option, field: &str) -> Result, McpError> { + v.as_deref().map(|s| parse_timestamp(s, field)).transpose() +} + +impl CanopyMcp { + async fn manual_incident_outs( + &self, + conn: &mut database::diesel_async::AsyncPgConnection, + incidents: Vec, + ) -> Result, McpError> { + let group_ids: Vec = incidents.iter().filter_map(|i| i.server_group_id).collect(); + let names = group_names(conn, &group_ids).await?; + Ok(incidents + .into_iter() + .map(|i| ManualIncidentOut { + id: i.id, + title: i.title, + description: i.description, + started_at: i.started_at, + ended_at: i.ended_at, + group_id: i.server_group_id, + group_name: i.server_group_id.and_then(|id| names.get(&id).cloned()), + created_by: i.created_by, + created_at: i.created_at, + updated_at: i.updated_at, + }) + .collect()) + } +} + +#[tool_router(router = manual_incidents_router, vis = "pub(crate)")] +impl CanopyMcp { + #[tool( + description = "List manual incidents: support-recorded records of incidents managed by \ + people, written after the fact — separate from the automatic incidents \ + find_incidents returns. Most recently started first; optionally narrowed \ + to one group or to ongoing ones." + )] + async fn find_manual_incidents( + &self, + Parameters(args): Parameters, + ) -> Result { + let mut conn = self.conn().await?; + let group = parse_opt_uuid(&args.group_id, "group_id")?; + let limit = args.limit.unwrap_or(100).clamp(1, 1000); + let incidents = ManualIncident::list( + &mut conn, + group, + args.ongoing_only.unwrap_or(false), + limit + 1, + ) + .await + .map_err(mcp_err)?; + let truncated = incidents.len() as i64 > limit; + let incidents = incidents.into_iter().take(limit as usize).collect(); + let incidents = self.manual_incident_outs(&mut conn, incidents).await?; + ok_json(&ManualIncidentList { + count: incidents.len(), + truncated, + incidents, + }) + } + + #[tool(description = "Fetch one manual incident by id.")] + async fn get_manual_incident( + &self, + Parameters(args): Parameters, + ) -> Result { + let mut conn = self.conn().await?; + let id = parse_uuid(&args.id, "id")?; + let Some(incident) = ManualIncident::get(&mut conn, id).await.map_err(mcp_err)? else { + return Ok(not_found(format!("no manual incident {id}"))); + }; + let out = self.manual_incident_outs(&mut conn, vec![incident]).await?; + ok_json(&out[0]) + } + + #[tool( + description = "Record a manual incident: a support-managed incident written after the \ + fact. Takes a title and a start time; optionally a markdown description, \ + an end time (omit while ongoing), and the affected group. The caller's \ + identity is recorded as the author. Requires write access." + )] + async fn record_manual_incident( + &self, + Extension(parts): Extension, + Parameters(args): Parameters, + ) -> Result { + let who = require_write(&parts)?; + let started_at = parse_timestamp(&args.started_at, "started_at")?; + let ended_at = parse_opt_timestamp(&args.ended_at, "ended_at")?; + let group = parse_opt_uuid(&args.group_id, "group_id")?; + if args.title.trim().is_empty() { + return Err(McpError::invalid_params("title is required", None)); + } + + let mut conn = self.write_conn().await?; + if let Some(group) = group { + if group_names(&mut conn, &[group]).await?.is_empty() { + return Err(McpError::invalid_params( + format!("no server group {group}"), + None, + )); + } + } + let incident = ManualIncident::create( + &mut conn, + args.title.trim(), + args.description.as_deref().unwrap_or_default(), + started_at, + ended_at, + group, + &who, + ) + .await + .map_err(mcp_err)?; + tracing::info!(id = %incident.id, author = %who, "manual incident recorded"); + let out = self.manual_incident_outs(&mut conn, vec![incident]).await?; + ok_json(&out[0]) + } + + #[tool( + description = "Update a manual incident: any subset of title, description, start and end \ + times. `clear_ended_at` removes the end time, marking it ongoing again. \ + Requires write access." + )] + async fn update_manual_incident( + &self, + Extension(parts): Extension, + Parameters(args): Parameters, + ) -> Result { + let who = require_write(&parts)?; + let id = parse_uuid(&args.id, "id")?; + let ended_at = parse_opt_timestamp(&args.ended_at, "ended_at")?; + if args.clear_ended_at == Some(true) && ended_at.is_some() { + return Err(McpError::invalid_params( + "ended_at and clear_ended_at are mutually exclusive", + None, + )); + } + if args.title.as_deref().is_some_and(|t| t.trim().is_empty()) { + return Err(McpError::invalid_params("title cannot be empty", None)); + } + + let up = ManualIncidentUpdate { + title: args.title.map(|t| t.trim().to_string()), + description: args.description, + started_at: parse_opt_timestamp(&args.started_at, "started_at")?, + ended_at: if args.clear_ended_at == Some(true) { + Some(None) + } else { + ended_at.map(Some) + }, + }; + let mut conn = self.write_conn().await?; + let Some(incident) = ManualIncident::update(&mut conn, id, up) + .await + .map_err(mcp_err)? + else { + return Ok(not_found(format!("no manual incident {id}"))); + }; + tracing::info!(id = %incident.id, author = %who, "manual incident updated"); + let out = self.manual_incident_outs(&mut conn, vec![incident]).await?; + ok_json(&out[0]) + } + + #[tool(description = "Delete a manual incident by id. Requires write access.")] + async fn delete_manual_incident( + &self, + Extension(parts): Extension, + Parameters(args): Parameters, + ) -> Result { + let who = require_write(&parts)?; + let id = parse_uuid(&args.id, "id")?; + let mut conn = self.write_conn().await?; + if !ManualIncident::delete(&mut conn, id) + .await + .map_err(mcp_err)? + { + return Ok(not_found(format!("no manual incident {id}"))); + } + tracing::info!(%id, author = %who, "manual incident deleted"); + ok_json(&serde_json::json!({ "deleted": id })) + } +} diff --git a/crates/canopy-mcp/src/util.rs b/crates/canopy-mcp/src/util.rs index 3aeb8c81..3971f091 100644 --- a/crates/canopy-mcp/src/util.rs +++ b/crates/canopy-mcp/src/util.rs @@ -34,6 +34,25 @@ pub(crate) fn mcp_err(e: impl std::fmt::Display) -> McpError { McpError::internal_error(e.to_string(), None) } +/// Authorise a write tool call: dig the mount-inserted [`crate::McpIdentity`] +/// out of the HTTP request parts and check it may write, returning the +/// caller's identity for attribution. Refused write access names the fix (a +/// token minted with write access); a missing identity is a mount wiring bug. +pub(crate) fn require_write(parts: &http::request::Parts) -> Result { + let identity = parts + .extensions + .get::() + .ok_or_else(|| McpError::internal_error("caller identity missing from request", None))?; + if !identity.can_write { + return Err(McpError::invalid_request( + "this access token is read-only; recording manual incidents needs a token minted \ + with write access", + None, + )); + } + Ok(identity.who.clone()) +} + pub(crate) fn parse_opt( v: &Option, field: &str, diff --git a/crates/database/src/check_policies.rs b/crates/database/src/check_policies.rs index a6880b6f..6ab2ddc2 100644 --- a/crates/database/src/check_policies.rs +++ b/crates/database/src/check_policies.rs @@ -111,7 +111,7 @@ pub struct GradedResult { impl CheckPolicy { /// Reconcile fleet-wide check liveness. Refreshes every catalogued /// `(source, check)`'s `last_seen` to the most recent report of that - /// check on any server (synthetic `canopy`/`manual` sources excluded), + /// check on any server (the synthetic `canopy` source excluded), /// and re-animates any decommissioned check that has been reported /// since it was retired: cleared back to the newly-registered state /// (warning ceiling, pending review) so a resurrected check never @@ -128,12 +128,11 @@ impl CheckPolicy { "UPDATE check_policies cp SET last_seen = f.max_seen FROM (\ SELECT source, check_name, max(last_seen) AS max_seen FROM issues \ WHERE server_id IS NOT NULL AND check_name IS NOT NULL \ - AND source NOT IN ('{canopy}', '{manual}') \ + AND source != '{canopy}' \ GROUP BY source, check_name) f \ WHERE cp.source = f.source AND cp.check_name = f.check_name \ AND (cp.last_seen IS NULL OR cp.last_seen < f.max_seen)", canopy = crate::statuses::CANOPY_SOURCE, - manual = crate::issues::MANUAL_SOURCE, ); sql_query(refresh).execute(db).await?; diff --git a/crates/database/src/issues.rs b/crates/database/src/issues.rs index 60653bec..dd3aeb2a 100644 --- a/crates/database/src/issues.rs +++ b/crates/database/src/issues.rs @@ -325,8 +325,8 @@ impl NewEvent { /// 2. if the server is in a group: (re)evaluate incident contribution. /// /// `server_id` is the server the issue is attached to: derived from the - /// device for public submissions, supplied by the operator for manual. - /// `device_id` is `None` for manual events. + /// device for public submissions. `device_id` is `None` for canopy's + /// own filings. /// /// Issues from an **ungrouped** server are still recorded — the issue /// row goes in just like any other push — but the incident @@ -788,17 +788,12 @@ pub enum FilingScope { Global, } -/// The source operator-raised manual conditions file under. -pub const MANUAL_SOURCE: &str = "manual"; - -/// One canopy-determined or operator-raised check result to file: -/// reachability, backup health, key expiry, self-monitoring, manual -/// conditions, and the like. +/// One canopy-determined check result to file: reachability, backup +/// health, key expiry, self-monitoring, and the like. #[derive(Debug, Clone)] pub struct CheckFiling<'a> { - /// The reserved source this filing belongs to: [`MANUAL_SOURCE`] for - /// operator-raised conditions (server scope only), `canopy` for - /// canopy's own determinations. + /// The reserved source this filing belongs to: `canopy` for canopy's + /// own determinations. pub source: &'a str, pub scope: FilingScope, /// The check's stable name (doubles as the issue ref under the @@ -823,13 +818,10 @@ pub struct CheckFiling<'a> { pub documentation: Option<&'a str>, } -/// File one canopy-determined or operator-raised check result: register -/// its catalog entry (first sight only), grade the observation through -/// the operator's policy, and upsert the check state at the right scope -/// — driving incident membership exactly like a device-reported check. -/// -/// Group- and canopy-wide scopes are canopy's own (their raise paths -/// file under the `canopy` source); manual conditions are server-scoped. +/// File one canopy-determined check result: register its catalog entry +/// (first sight only), grade the observation through the operator's +/// policy, and upsert the check state at the right scope — driving +/// incident membership exactly like a device-reported check. /// /// Until issues themselves carry results, the effective result maps to /// the issue severity the same way status ingestion does: failed → @@ -840,9 +832,8 @@ pub async fn file_check(conn: &mut AsyncPgConnection, filing: CheckFiling<'_>) - let source = filing.source; debug_assert!( - matches!(filing.scope, FilingScope::Server { .. }) - || source == crate::statuses::CANOPY_SOURCE, - "group- and canopy-wide filings are canopy's own", + source == crate::statuses::CANOPY_SOURCE, + "filings are canopy's own", ); CheckPolicy::register( conn, @@ -856,11 +847,7 @@ pub async fn file_check(conn: &mut AsyncPgConnection, filing: CheckFiling<'_>) - // Rule-evaluation context: the check's detail (with the normalised // result injected, mirroring status ingestion), no report-wide - // extras, and the server's tags where there is a server. Filings - // whose observation policy shouldn't touch (an operator explicitly - // raising a manual condition) still flow through so the catalog row - // and stamps exist, but manual entries register at a failed ceiling - // so the operator's chosen result passes through ungraded by default. + // extras, and the server's tags where there is a server. let mut check_extra = filing .detail .as_ref() @@ -2395,9 +2382,9 @@ impl Issue { /// report a source pushes re-stamps `last_seen` on the state rows of /// the checks it mentions, so the max per source is when that source /// last reported — maintained incrementally by ingestion, with no scan - /// of the statuses history. The reserved sources are excluded (canopy - /// and manual filings aren't reports), as are rows never stamped by - /// the check-state model. + /// of the statuses history. The reserved source is excluded (canopy + /// filings aren't reports), as are rows never stamped by the + /// check-state model. pub async fn source_freshness( db: &mut AsyncPgConnection, server_ids: &[Uuid], @@ -2423,10 +2410,7 @@ impl Issue { .filter( dsl::server_id .eq_any(server_ids) - .and( - dsl::source - .ne_all([crate::statuses::CANOPY_SOURCE, crate::issues::MANUAL_SOURCE]), - ) + .and(dsl::source.ne(crate::statuses::CANOPY_SOURCE)) .and(dsl::check_name.is_not_null()), ) .select(( diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs index 9b742a42..503faf55 100644 --- a/crates/database/src/lib.rs +++ b/crates/database/src/lib.rs @@ -14,6 +14,7 @@ pub mod check_policies; pub mod chrome_releases; pub mod devices; pub mod issues; +pub mod manual_incidents; pub mod mcp_tokens; pub mod notes; pub mod pg_duration; diff --git a/crates/database/src/manual_incidents.rs b/crates/database/src/manual_incidents.rs new file mode 100644 index 00000000..90f7b54f --- /dev/null +++ b/crates/database/src/manual_incidents.rs @@ -0,0 +1,167 @@ +//! Manual incidents: support-team-recorded incident records, written after +//! the fact rather than derived from check state. +//! +//! Spec: `.workhorse/specs/monitoring/incidents.md` (id `INC`), "Manual +//! incidents". Independent of the issue/incident machinery in +//! [`crate::issues`]: nothing joins these, they never notify, and only the +//! people editing them change them. Written over the MCP interface (see +//! `.workhorse/specs/private-server/mcp.md`), displayed read-only in the +//! operator UI. + +use commons_errors::{AppError, Result}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, Serialize, Deserialize, Queryable, Selectable)] +#[diesel(table_name = crate::schema::manual_incidents)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ManualIncident { + pub id: Uuid, + #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] + pub created_at: Timestamp, + #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] + pub updated_at: Timestamp, + /// Single-line headline. + pub title: String, + /// Markdown body; empty when nobody has written one yet. + pub description: String, + #[diesel(deserialize_as = jiff_diesel::Timestamp, serialize_as = jiff_diesel::Timestamp)] + pub started_at: Timestamp, + /// `None` while the incident is ongoing. + #[diesel(deserialize_as = jiff_diesel::NullableTimestamp, serialize_as = jiff_diesel::NullableTimestamp)] + pub ended_at: Option, + /// The affected server group, or `None` for an incident concerning the + /// fleet or Canopy generally. + pub server_group_id: Option, + /// Who recorded it: a tailnet login or an MCP token name. + pub created_by: String, +} + +/// Field edits for [`ManualIncident::update`]. `None` leaves a field alone; +/// `ended_at` uses a double `Option` so `Some(None)` explicitly clears the +/// end time (marking the incident ongoing again). +#[derive(Clone, Debug, Default)] +pub struct ManualIncidentUpdate { + pub title: Option, + pub description: Option, + pub started_at: Option, + pub ended_at: Option>, +} + +impl ManualIncident { + pub async fn create( + db: &mut AsyncPgConnection, + title: &str, + description: &str, + started_at: Timestamp, + ended_at: Option, + server_group_id: Option, + created_by: &str, + ) -> Result { + use crate::schema::manual_incidents::dsl; + + diesel::insert_into(dsl::manual_incidents) + .values(( + dsl::title.eq(title), + dsl::description.eq(description), + dsl::started_at.eq(jiff_diesel::Timestamp::from(started_at)), + dsl::ended_at.eq(jiff_diesel::NullableTimestamp::from(ended_at)), + dsl::server_group_id.eq(server_group_id), + dsl::created_by.eq(created_by), + )) + .returning(Self::as_select()) + .get_result(db) + .await + .map_err(AppError::from) + } + + pub async fn get(db: &mut AsyncPgConnection, id: Uuid) -> Result> { + use crate::schema::manual_incidents::dsl; + + dsl::manual_incidents + .select(Self::as_select()) + .filter(dsl::id.eq(id)) + .first(db) + .await + .optional() + .map_err(AppError::from) + } + + /// Like [`Self::get`], but an unknown id errors (404). + pub async fn get_required(db: &mut AsyncPgConnection, id: Uuid) -> Result { + Self::get(db, id) + .await? + .ok_or_else(|| diesel::result::Error::NotFound.into()) + } + + /// Most recently started first. `group_id` narrows to one group's + /// incidents; `ongoing_only` keeps only those without an end time. + pub async fn list( + db: &mut AsyncPgConnection, + group_id: Option, + ongoing_only: bool, + limit: i64, + ) -> Result> { + use crate::schema::manual_incidents::dsl; + + let mut query = dsl::manual_incidents + .select(Self::as_select()) + .order(dsl::started_at.desc()) + .limit(limit) + .into_boxed(); + if let Some(group_id) = group_id { + query = query.filter(dsl::server_group_id.eq(group_id)); + } + if ongoing_only { + query = query.filter(dsl::ended_at.is_null()); + } + query.load(db).await.map_err(AppError::from) + } + + /// Apply the given edits. `None` for an unknown id. + pub async fn update( + db: &mut AsyncPgConnection, + id: Uuid, + up: ManualIncidentUpdate, + ) -> Result> { + use crate::schema::manual_incidents::dsl; + + let Some(current) = Self::get(db, id).await? else { + return Ok(None); + }; + let title = up.title.unwrap_or(current.title); + let description = up.description.unwrap_or(current.description); + let started_at = up.started_at.unwrap_or(current.started_at); + let ended_at = match up.ended_at { + Some(ended_at) => ended_at, + None => current.ended_at, + }; + + diesel::update(dsl::manual_incidents.filter(dsl::id.eq(id))) + .set(( + dsl::title.eq(title), + dsl::description.eq(description), + dsl::started_at.eq(jiff_diesel::Timestamp::from(started_at)), + dsl::ended_at.eq(jiff_diesel::NullableTimestamp::from(ended_at)), + )) + .returning(Self::as_select()) + .get_result(db) + .await + .optional() + .map_err(AppError::from) + } + + /// Remove the record. `false` for an unknown id. + pub async fn delete(db: &mut AsyncPgConnection, id: Uuid) -> Result { + use crate::schema::manual_incidents::dsl; + + let affected = diesel::delete(dsl::manual_incidents.filter(dsl::id.eq(id))) + .execute(db) + .await + .map_err(AppError::from)?; + Ok(affected > 0) + } +} diff --git a/crates/database/src/mcp_tokens.rs b/crates/database/src/mcp_tokens.rs index 95b567da..77eb9bd9 100644 --- a/crates/database/src/mcp_tokens.rs +++ b/crates/database/src/mcp_tokens.rs @@ -41,6 +41,9 @@ pub struct McpToken { pub revoked_at: Option, #[diesel(deserialize_as = jiff_diesel::NullableTimestamp)] pub last_used_at: Option, + /// Whether the token may call the MCP write tools (manual incidents). + /// A mint-time choice, immutable afterwards. + pub write_access: bool, } /// SHA-256 of the token string. Unsalted is correct here: the token is 256 bits @@ -59,6 +62,7 @@ impl McpToken { db: &mut AsyncPgConnection, name: &str, created_by: &str, + write_access: bool, ) -> Result<(Self, String)> { use crate::schema::mcp_tokens::dsl; @@ -79,6 +83,7 @@ impl McpToken { dsl::token_hash.eq(&token_hash), dsl::created_by.eq(created_by), dsl::expires_at.eq(jiff_diesel::Timestamp::from(expires_at)), + dsl::write_access.eq(write_access), )) .returning(Self::as_select()) .get_result(db) diff --git a/crates/database/src/schema.rs b/crates/database/src/schema.rs index ae36ffda..1c965c35 100644 --- a/crates/database/src/schema.rs +++ b/crates/database/src/schema.rs @@ -344,6 +344,20 @@ diesel::table! { } } +diesel::table! { + manual_incidents (id) { + id -> Uuid, + created_at -> Timestamptz, + updated_at -> Timestamptz, + title -> Text, + description -> Text, + started_at -> Timestamptz, + ended_at -> Nullable, + server_group_id -> Nullable, + created_by -> Text, + } +} + diesel::table! { mcp_tokens (id) { id -> Uuid, @@ -354,6 +368,7 @@ diesel::table! { expires_at -> Timestamptz, revoked_at -> Nullable, last_used_at -> Nullable, + write_access -> Bool, } } @@ -641,6 +656,7 @@ diesel::joinable!(issue_notes -> issues (issue_id)); diesel::joinable!(issues -> devices (device_id)); diesel::joinable!(issues -> server_groups (server_group_id)); diesel::joinable!(issues -> servers (server_id)); +diesel::joinable!(manual_incidents -> server_groups (server_group_id)); diesel::joinable!(restore_consumer_capabilities -> devices (consumer_device_id)); diesel::joinable!(restore_replicas -> devices (consumer_device_id)); diesel::joinable!(restore_replicas -> server_groups (group_id)); @@ -686,6 +702,7 @@ diesel::allow_tables_to_appear_in_same_query!( incidents, issue_notes, issues, + manual_incidents, mcp_tokens, recovery_vault_writes, restore_consumer_capabilities, diff --git a/crates/database/src/silenced_refs.rs b/crates/database/src/silenced_refs.rs index c016b99c..935b1a7c 100644 --- a/crates/database/src/silenced_refs.rs +++ b/crates/database/src/silenced_refs.rs @@ -22,9 +22,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::check_policies::{PolicyScope, ScopedCheckPolicy}; -use crate::issues::{ - MANUAL_SOURCE, reevaluate_open_issues_for_group_ref, reevaluate_open_issues_for_server_ref, -}; +use crate::issues::{reevaluate_open_issues_for_group_ref, reevaluate_open_issues_for_server_ref}; use crate::statuses::CANOPY_SOURCE; /// The ref prefix (with trailing separator) healthcheck issues use, @@ -32,16 +30,16 @@ use crate::statuses::CANOPY_SOURCE; const HEALTH_REF_PREFIX: &str = "health/"; /// The check name a silence ref maps to: refs of source-reported checks -/// carry the `health/` namespace prefix, canopy/manual refs are already -/// bare check names. +/// carry the `health/` namespace prefix, canopy refs are already bare +/// check names. fn ref_to_check(r#ref: &str) -> &str { r#ref.strip_prefix(HEALTH_REF_PREFIX).unwrap_or(r#ref) } -/// The ref a silenced check name presents as: reserved sources file at -/// bare refs, everything else under the `health/` namespace. +/// The ref a silenced check name presents as: the reserved source files +/// at bare refs, everything else under the `health/` namespace. fn check_to_ref(source: &str, check: &str) -> String { - if source == CANOPY_SOURCE || source == MANUAL_SOURCE { + if source == CANOPY_SOURCE { check.to_string() } else { format!("{HEALTH_REF_PREFIX}{check}") diff --git a/crates/database/src/source_policies.rs b/crates/database/src/source_policies.rs index 651a3fc5..c72e2502 100644 --- a/crates/database/src/source_policies.rs +++ b/crates/database/src/source_policies.rs @@ -73,7 +73,7 @@ impl SourcePolicy { max(cp.last_seen) AS last_seen \ FROM check_policies cp \ LEFT JOIN source_policies sp ON sp.source = cp.source \ - WHERE cp.source NOT IN ('canopy', 'manual') \ + WHERE cp.source != 'canopy' \ GROUP BY cp.source, sp.reachability, sp.ingest \ ORDER BY cp.source", ) diff --git a/crates/database/src/statuses.rs b/crates/database/src/statuses.rs index 6b9bf382..8c28d33e 100644 --- a/crates/database/src/statuses.rs +++ b/crates/database/src/statuses.rs @@ -259,7 +259,7 @@ impl Status { } let server_ids: Vec = monitored.iter().map(|s| s.id).collect(); - // Per-source freshness (already excludes canopy/manual and + // Per-source freshness (already excludes canopy and // decommissioned checks), grouped by server, plus each source's // reachability and ingest modes. let freshness = Issue::source_freshness(db, &server_ids).await?; diff --git a/crates/private-server/src/fns.rs b/crates/private-server/src/fns.rs index ff049902..c9101c29 100644 --- a/crates/private-server/src/fns.rs +++ b/crates/private-server/src/fns.rs @@ -9,6 +9,7 @@ pub mod devices; pub mod healthchecks; pub mod incidents; pub mod issues; +pub mod manual_incidents; pub mod mcp_tokens; pub mod restore_replicas; pub mod self_alerts; @@ -58,6 +59,7 @@ pub fn routes() -> OpenApiRouter { .nest("/healthchecks", healthchecks::routes()) .nest("/incidents", incidents::routes()) .nest("/issues", issues::routes()) + .nest("/manual_incidents", manual_incidents::routes()) .nest("/mcp_tokens", mcp_tokens::routes()) .nest("/restore_replicas", restore_replicas::routes()) .nest("/self_alerts", self_alerts::routes()) diff --git a/crates/private-server/src/fns/healthchecks.rs b/crates/private-server/src/fns/healthchecks.rs index 210d3df6..d703fa56 100644 --- a/crates/private-server/src/fns/healthchecks.rs +++ b/crates/private-server/src/fns/healthchecks.rs @@ -200,7 +200,7 @@ pub struct SourceData { /// /// Every non-reserved source that has catalogued checks, with its /// reachability mode (defaulting to `on`) and most recent fleet-wide -/// report. The reserved `canopy`/`manual` sources are excluded. +/// report. The reserved `canopy` source is excluded. #[utoipa::path( post, path = "/sources", @@ -234,8 +234,7 @@ pub async fn sources( /// Request body for setting a source's reachability mode. #[derive(Deserialize, ToSchema)] pub struct SetSourceReachabilityArgs { - /// The source to configure. The reserved `canopy`/`manual` names are - /// rejected. + /// The source to configure. The reserved `canopy` name is rejected. pub source: String, /// The reachability mode to apply: `on`, `quiet`, or `off`. pub reachability: ReachabilityMode, @@ -245,7 +244,7 @@ pub struct SetSourceReachabilityArgs { /// /// Governs how the source's silence bears on its servers' reachability: /// `on` warns, `quiet` never warns but still counts toward unreachable, -/// `off` is excluded. The reserved `canopy`/`manual` names are rejected. +/// `off` is excluded. The reserved `canopy` name is rejected. #[utoipa::path( post, path = "/set_source_reachability", @@ -265,9 +264,9 @@ pub async fn set_source_reachability( _admin: TailscaleAdmin, Json(args): Json, ) -> Result> { - if args.source == "canopy" || args.source == "manual" { + if args.source == "canopy" { return Err(AppError::BadRequest( - "the reserved canopy/manual sources have no reachability policy".into(), + "the reserved canopy source has no reachability policy".into(), )); } let mut conn = state.db.get().await?; diff --git a/crates/private-server/src/fns/issues.rs b/crates/private-server/src/fns/issues.rs index 69931919..db01ced9 100644 --- a/crates/private-server/src/fns/issues.rs +++ b/crates/private-server/src/fns/issues.rs @@ -4,10 +4,7 @@ use canopy_utoipa_axum::{router::OpenApiRouter, routes}; use commons_errors::{AppError, ProblemDetailsSchema, Result}; use commons_servers::tailscale_auth::{TailscaleAdmin, TailscaleUser}; use commons_types::{Uuid, issue::ResolvedReason, status::CheckResult}; -use database::issues::{ - CheckFiling, FilingScope, Incident, Issue, IssueFilter, IssueIncidentRef, IssueListFilters, - MANUAL_SOURCE, file_check, -}; +use database::issues::{Incident, Issue, IssueFilter, IssueIncidentRef, IssueListFilters}; use database::notes::IssueNote; use database::servers::Server; use database::tailscale_users::TailscaleUser as CachedTailscaleUser; @@ -40,10 +37,9 @@ pub struct IssueData { /// when the server isn't in a group. pub server_group_name: Option, /// Id of the device that reported the underlying event, if the issue - /// originated from a device push rather than a manual entry. + /// originated from a device push rather than canopy's own monitoring. pub device_id: Option, - /// What raised the issue (for example, an automated health check or a - /// manually submitted event). + /// What raised the issue (for example, an automated health check). pub source: String, /// Identifier used to match new incoming events to this issue; unique /// within its source and server. @@ -280,7 +276,6 @@ pub fn routes() -> OpenApiRouter { .routes(routes!(list)) .routes(routes!(list_for_device)) .routes(routes!(list_for_server)) - .routes(routes!(submit_manual_event)) .routes(routes!(resolve)) .routes(routes!(unresolve)) .routes(routes!(snooze)) @@ -445,98 +440,6 @@ pub async fn list_for_server( Ok(Json(enrich_issues(&mut conn, issues).await?)) } -/// A manually raised condition to record against a server. -#[derive(Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SubmitManualEventArgs { - /// Id of the server the condition applies to. - pub server_id: Uuid, - /// Identifier for the underlying condition. Reports with the same - /// `ref` on the same server update the same issue rather than opening - /// a new one each time; use a fresh unique value if that deduplication - /// isn't wanted. - #[serde(rename = "ref")] - pub r#ref: String, - /// The condition's result: `failed` (can open an incident) or - /// `warning` (context only). Defaults to `failed`. `passed` records - /// the condition as cleared, same as `active: false`. - #[serde(default)] - pub result: Option, - /// Whether the condition's failures should notify immediately, - /// bypassing the incident grace period. Only consulted the first time - /// a `ref` is seen (it seeds the condition's catalog entry); adjust - /// later from the healthchecks catalog. - #[serde(default)] - pub escalates: Option, - /// Short, single-line headline for the condition. Must not contain - /// newlines — use `message` for multi-line detail. - #[serde(default)] - pub description: Option, - /// Human-readable message describing the condition. May be multi-line. - pub message: String, - /// Whether the underlying condition is currently active. Defaults to - /// `true` when omitted; `false` records it as cleared regardless of - /// `result`. - #[serde(default)] - pub active: Option, -} - -/// Manually raise (or clear) a condition against a server. -/// -/// Finds or creates an issue keyed by the server and the given `ref` -/// under the `manual` source, grades the chosen result through the -/// condition's catalog policy, and returns the resulting issue. Returns -/// 400 if `ref` is empty or if `description` contains a newline. -#[utoipa::path( - post, - path = "/submit_manual_event", - tag = "issues", - security(("tailscale-admin" = [])), - request_body = SubmitManualEventArgs, - responses( - (status = 200, body = IssueData), - (status = 400, body = ProblemDetailsSchema), - ), -)] -pub async fn submit_manual_event( - State(state): State, - _admin: TailscaleAdmin, - Json(args): Json, -) -> Result> { - if args.r#ref.trim().is_empty() { - return Err(AppError::custom("ref is required")); - } - - let observed = if args.active == Some(false) { - CheckResult::Passed - } else { - args.result.unwrap_or(CheckResult::Failed) - }; - let mut conn = state.db.get().await?; - let issue = file_check( - &mut conn, - CheckFiling { - source: MANUAL_SOURCE, - scope: FilingScope::Server { - server_id: args.server_id, - device_id: None, - }, - check: &args.r#ref, - observed, - title: args.description.as_deref(), - message: &args.message, - detail: None, - // The operator's chosen result passes through ungraded; the - // escalation choice seeds the catalog entry on first sight. - default_ceiling: CheckResult::Failed, - default_escalates: args.escalates.unwrap_or(false), - documentation: None, - }, - ) - .await?; - Ok(Json(enrich_issue(&mut conn, issue).await?)) -} - /// Identifies a single issue by id. #[derive(Deserialize, ToSchema)] pub struct IssueIdArgs { diff --git a/crates/private-server/src/fns/manual_incidents.rs b/crates/private-server/src/fns/manual_incidents.rs new file mode 100644 index 00000000..74698692 --- /dev/null +++ b/crates/private-server/src/fns/manual_incidents.rs @@ -0,0 +1,159 @@ +//! Read endpoints for manual incidents: support-recorded incident records. +//! +//! Spec: `.workhorse/specs/monitoring/incidents.md` (id `INC`), "Manual +//! incidents". Deliberately read-only — creating and editing happens over +//! the MCP interface; the operator UI only displays them. + +use axum::Json; +use axum::extract::State; +use canopy_utoipa_axum::{router::OpenApiRouter, routes}; +use commons_errors::{ProblemDetailsSchema, Result}; +use commons_servers::tailscale_auth::TailscaleUser; +use commons_types::Uuid; +use database::manual_incidents::ManualIncident; +use database::server_groups::ServerGroup; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::state::AppState; + +const DEFAULT_LIMIT: i64 = 100; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list)) + .routes(routes!(get)) +} + +/// A support-recorded incident: written after the fact by people (over the +/// MCP interface), independent of the automatic incidents derived from +/// check state. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ManualIncidentData { + /// Unique identifier for this manual incident. + pub id: Uuid, + /// Single-line headline. + pub title: String, + /// Markdown body; empty when nobody has written one yet. + pub description: String, + /// When the incident started. + pub started_at: Timestamp, + /// When the incident ended; absent while it is ongoing. + pub ended_at: Option, + /// Id of the affected server group. Absent for incidents concerning the + /// fleet or Canopy generally. + pub server_group_id: Option, + /// Display name of the affected server group, when one is set. + pub server_group_name: Option, + /// Who recorded it: a tailnet login or an MCP token name. + pub created_by: String, + /// When the record was created. + pub created_at: Timestamp, + /// When the record was last changed. + pub updated_at: Timestamp, +} + +async fn enrich( + conn: &mut database::diesel_async::AsyncPgConnection, + incidents: Vec, +) -> Result> { + let group_ids: Vec = incidents.iter().filter_map(|i| i.server_group_id).collect(); + let names: std::collections::HashMap = ServerGroup::list_by_ids(conn, &group_ids) + .await? + .into_iter() + .map(|g| (g.id, g.name)) + .collect(); + Ok(incidents + .into_iter() + .map(|i| ManualIncidentData { + id: i.id, + title: i.title, + description: i.description, + started_at: i.started_at, + ended_at: i.ended_at, + server_group_id: i.server_group_id, + server_group_name: i.server_group_id.and_then(|id| names.get(&id).cloned()), + created_by: i.created_by, + created_at: i.created_at, + updated_at: i.updated_at, + }) + .collect()) +} + +/// Arguments for listing manual incidents. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ManualIncidentListArgs { + /// Restrict to one group's incidents. + #[serde(default)] + pub group_id: Option, + /// Only incidents without an end time (still ongoing). + #[serde(default)] + pub ongoing_only: Option, + /// Max incidents to return (default 100). + #[serde(default)] + pub limit: Option, +} + +/// List manual incidents, most recently started first. +#[utoipa::path( + post, + path = "/list", + operation_id = "manual_incidents_list", + tag = "manual_incidents", + security(("tailscale-user" = [])), + request_body = ManualIncidentListArgs, + responses( + (status = 200, description = "Manual incidents, most recently started first.", body = Vec), + (status = 401, body = ProblemDetailsSchema), + ), +)] +pub async fn list( + State(state): State, + _user: TailscaleUser, + Json(args): Json, +) -> Result>> { + let mut conn = state.db_read.get().await?; + let incidents = ManualIncident::list( + &mut conn, + args.group_id, + args.ongoing_only.unwrap_or(false), + args.limit.unwrap_or(DEFAULT_LIMIT), + ) + .await?; + Ok(Json(enrich(&mut conn, incidents).await?)) +} + +/// Arguments identifying one manual incident. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ManualIncidentGetArgs { + /// Id of the manual incident to fetch. + pub id: Uuid, +} + +/// Fetch one manual incident by id. +#[utoipa::path( + post, + path = "/get", + operation_id = "manual_incidents_get", + tag = "manual_incidents", + security(("tailscale-user" = [])), + request_body = ManualIncidentGetArgs, + responses( + (status = 200, body = ManualIncidentData), + (status = 401, body = ProblemDetailsSchema), + (status = 404, body = ProblemDetailsSchema), + ), +)] +pub async fn get( + State(state): State, + _user: TailscaleUser, + Json(args): Json, +) -> Result> { + let mut conn = state.db_read.get().await?; + let incident = ManualIncident::get_required(&mut conn, args.id).await?; + let mut enriched = enrich(&mut conn, vec![incident]).await?; + Ok(Json(enriched.remove(0))) +} diff --git a/crates/private-server/src/fns/mcp_tokens.rs b/crates/private-server/src/fns/mcp_tokens.rs index b94d223c..5d493f05 100644 --- a/crates/private-server/src/fns/mcp_tokens.rs +++ b/crates/private-server/src/fns/mcp_tokens.rs @@ -45,6 +45,9 @@ pub struct McpTokenView { /// When the token was last used to authenticate, or `null` if it has /// never been used. May lag the true last use by up to a minute. pub last_used_at: Option, + /// Whether the token may call the MCP write tools (manual incidents). + /// Chosen at mint time; immutable afterwards. + pub write_access: bool, } impl From for McpTokenView { @@ -57,6 +60,7 @@ impl From for McpTokenView { expires_at: t.expires_at, revoked_at: t.revoked_at, last_used_at: t.last_used_at, + write_access: t.write_access, } } } @@ -93,6 +97,10 @@ pub struct MintArgs { /// Operator-chosen label, e.g. which agent will hold this token. Cannot /// be empty or only whitespace. pub name: String, + /// Grant the token the MCP write tools (manual incidents). Defaults to + /// false (read-only); cannot be changed after minting. + #[serde(default)] + pub write_access: bool, } /// The result of minting a new MCP access token: its metadata plus the @@ -140,7 +148,7 @@ pub async fn mint( )); } let mut conn = state.db.get().await?; - let (token, secret) = McpToken::mint(&mut conn, name, &admin.login).await?; + let (token, secret) = McpToken::mint(&mut conn, name, &admin.login, args.write_access).await?; Ok(Json(MintedToken { token: token.into(), secret, diff --git a/crates/private-server/src/lib.rs b/crates/private-server/src/lib.rs index a77be272..0bb048d0 100644 --- a/crates/private-server/src/lib.rs +++ b/crates/private-server/src/lib.rs @@ -21,11 +21,12 @@ pub fn routes(state: crate::state::AppState) -> commons_errors::Result = Router::new() - .fallback_service(canopy_mcp::service(state.db_read.clone())) + .fallback_service(canopy_mcp::service(state.db.clone(), state.db_read.clone())) .layer(middleware::from_fn(mcp::require_tailnet_user)); let non_public = Router::new() diff --git a/crates/private-server/src/mcp.rs b/crates/private-server/src/mcp.rs index a4a8992d..4a68aebd 100644 --- a/crates/private-server/src/mcp.rs +++ b/crates/private-server/src/mcp.rs @@ -6,7 +6,8 @@ /// Gate the MCP mount on an authenticated tailnet user (any user, not only /// admins). Reuses the operator surface's `TailscaleUser` extractor, so the /// debug-build dev bypass applies in local dev and tests. The caller's login is -/// logged so each query is attributable. +/// logged so each query is attributable, and inserted as the request's +/// [`canopy_mcp::McpIdentity`] — any tailnet user may use the write tools. pub async fn require_tailnet_user( req: axum::extract::Request, next: axum::middleware::Next, @@ -17,6 +18,10 @@ pub async fn require_tailnet_user( let (mut parts, body) = req.into_parts(); let user = TailscaleUser::from_request_parts(&mut parts, &()).await?; tracing::info!(login = %user.login, "mcp request"); + parts.extensions.insert(canopy_mcp::McpIdentity { + who: user.login.clone(), + can_write: true, + }); let req = axum::extract::Request::from_parts(parts, body); Ok(next.run(req).await) } diff --git a/crates/private-server/src/openapi.rs b/crates/private-server/src/openapi.rs index e8f8dc69..086a4389 100644 --- a/crates/private-server/src/openapi.rs +++ b/crates/private-server/src/openapi.rs @@ -24,6 +24,7 @@ use utoipa::{ (name = "healthchecks", description = "Healthcheck catalog: severities, conditional rules, and sample data."), (name = "incidents", description = "Operational incidents (groups of issues against a server)."), (name = "issues", description = "Per-server issues raised from device events."), + (name = "manual_incidents", description = "Support-recorded incident records, written over MCP and displayed read-only."), (name = "mcp_tokens", description = "Bearer tokens for the public MCP mount."), (name = "restore_replicas", description = "Managed restore replicas: capabilities, worklist, and health."), (name = "self_alerts", description = "Canopy's alerts about its own operation."), diff --git a/crates/private-server/tests/it/issues.rs b/crates/private-server/tests/it/issues.rs index df208bb3..dd91054a 100644 --- a/crates/private-server/tests/it/issues.rs +++ b/crates/private-server/tests/it/issues.rs @@ -1,6 +1,40 @@ use commons_tests::diesel_async::SimpleAsyncConnection; +use commons_types::status::CheckResult; +use database::issues::{CheckFiling, FilingScope}; use uuid::Uuid; +/// File one canopy-determined check observation on a server through the +/// real filing path, the way the reachability/backup sweeps do. +async fn seed_filed_check( + conn: &mut database::diesel_async::AsyncPgConnection, + server_id: Uuid, + check: &str, + observed: CheckResult, + escalates: bool, + message: &str, +) -> database::issues::Issue { + database::issues::file_check( + conn, + CheckFiling { + source: database::statuses::CANOPY_SOURCE, + scope: FilingScope::Server { + server_id, + device_id: None, + }, + check, + observed, + title: None, + message, + detail: None, + default_ceiling: CheckResult::Failed, + default_escalates: escalates, + documentation: None, + }, + ) + .await + .expect("file check") +} + #[tokio::test(flavor = "multi_thread")] async fn list_issues_for_device_and_server() { commons_tests::server::run(async |mut conn, _public, private| { @@ -43,41 +77,6 @@ async fn list_issues_for_device_and_server() { .await; } -#[tokio::test(flavor = "multi_thread")] -async fn manual_event_submit_creates_issue_without_device() { - commons_tests::server::run(async |mut conn, _public, private| { - let server_id = Uuid::new_v4(); - conn.batch_execute(&format!( - "INSERT INTO servers (id, host, kind) VALUES \ - ('{server_id}', 'https://example.com', 'central');" - )) - .await - .expect("seed"); - - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "operator-note-1", - "message": "manually opened", - })) - .await; - resp.assert_status_ok(); - let body: serde_json::Value = resp.json(); - assert_eq!(body.get("source").and_then(|v| v.as_str()), Some("manual")); - assert!(body.get("device_id").map_or(true, |v| v.is_null())); - assert_eq!( - body.get("observed_result").and_then(|v| v.as_str()), - Some("failed") - ); - assert_eq!( - body.get("effective_result").and_then(|v| v.as_str()), - Some("failed") - ); - }) - .await; -} - #[tokio::test(flavor = "multi_thread")] async fn incident_groups_at_server_group() { commons_tests::server::run(async |mut conn, _public, private| { @@ -99,17 +98,16 @@ async fn incident_groups_at_server_group() { .await .expect("seed"); - // Submit a manual event on server B with severity=error → opens incident on group. - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_b_id, - "ref": "x", - "result": "failed", - "message": "trouble in B", - })) - .await; - resp.assert_status_ok(); + // File a failing check on server B → opens an incident on the group. + seed_filed_check( + &mut conn, + server_b_id, + "x", + CheckResult::Failed, + false, + "trouble in B", + ) + .await; // Listing incidents by either member's id finds the same group-level incident. let resp = private @@ -162,16 +160,15 @@ async fn ungrouped_server_event_skips_incident() { .await .expect("seed"); - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "message": "no group yet", - })) - .await; - resp.assert_status_ok(); + seed_filed_check( + &mut conn, + server_id, + "x", + CheckResult::Failed, + false, + "no group yet", + ) + .await; let resp = private .post("/api/incidents/list_for_server") @@ -201,17 +198,16 @@ async fn assigning_group_opens_pending_incident() { .await .expect("seed"); - // File an event while ungrouped: issue exists, no incident opens. - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "stuck", - "result": "failed", - "message": "waiting to be grouped", - })) - .await; - resp.assert_status_ok(); + // File a check while ungrouped: issue exists, no incident opens. + seed_filed_check( + &mut conn, + server_id, + "stuck", + CheckResult::Failed, + false, + "waiting to be grouped", + ) + .await; let resp = private .post("/api/incidents/list_for_server") @@ -259,44 +255,23 @@ async fn issue_reopen_keeps_identity_and_joins_new_incident() { .await .expect("seed"); - // 1. Open with error. - let r1 = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "message": "trouble", - })) - .await; - r1.assert_status_ok(); - let issue_id_1 = r1 - .json::() - .get("id") - .unwrap() - .as_str() - .unwrap() - .to_string(); + // 1. Open with a failure. + let issue_id_1 = seed_filed_check( + &mut conn, + server_id, + "x", + CheckResult::Failed, + false, + "trouble", + ) + .await + .id; - // 2. Resolve. - let r2 = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "active": false, - "message": "ok", - })) - .await; - r2.assert_status_ok(); - let issue_id_2 = r2 - .json::() - .get("id") - .unwrap() - .as_str() - .unwrap() - .to_string(); + // 2. Recover — a passing observation closes the issue. + let issue_id_2 = + seed_filed_check(&mut conn, server_id, "x", CheckResult::Passed, false, "ok") + .await + .id; assert_eq!(issue_id_1, issue_id_2, "same identity through inactive"); // The recovery leaves the incident lingering; let the window @@ -313,24 +288,10 @@ async fn issue_reopen_keeps_identity_and_joins_new_incident() { .expect("linger sweep"); // 3. Reopen — same identity, severity ≥ error. - let r3 = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "escalates": true, - "message": "back", - })) - .await; - r3.assert_status_ok(); - let issue_id_3 = r3 - .json::() - .get("id") - .unwrap() - .as_str() - .unwrap() - .to_string(); + let issue_id_3 = + seed_filed_check(&mut conn, server_id, "x", CheckResult::Failed, true, "back") + .await + .id; assert_eq!(issue_id_1, issue_id_3, "reopen keeps identity"); // Two incidents on the server (first closed, second open). @@ -364,30 +325,28 @@ async fn low_severity_issue_joins_existing_open_incident() { .await .expect("seed"); - // 1. Open an incident at severity = error. - private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "a", - "result": "failed", - "message": "primary trouble", - })) - .await - .assert_status_ok(); + // 1. Open an incident with a failure. + seed_filed_check( + &mut conn, + server_id, + "a", + CheckResult::Failed, + false, + "primary trouble", + ) + .await; - // 2. A warning event would normally not open an incident on its own, + // 2. A warning would normally not open an incident on its own, // but because one is already open it should join in. - private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "b", - "result": "warning", - "message": "ride-along", - })) - .await - .assert_status_ok(); + seed_filed_check( + &mut conn, + server_id, + "b", + CheckResult::Warning, + false, + "ride-along", + ) + .await; // Still one incident, with two contributing issues. let resp = private @@ -420,17 +379,16 @@ async fn low_severity_alone_does_not_open_incident() { .await .expect("seed"); - // Warning event with no open incident: must not create one. - private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "b", - "result": "warning", - "message": "minor", - })) - .await - .assert_status_ok(); + // Warning with no open incident: must not create one. + seed_filed_check( + &mut conn, + server_id, + "b", + CheckResult::Warning, + false, + "minor", + ) + .await; let resp = private .post("/api/incidents/list_for_server") @@ -458,29 +416,27 @@ async fn severity_downgrade_keeps_issue_in_incident() { .await .expect("seed"); - // Open at error. - private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "message": "trouble", - })) - .await - .assert_status_ok(); + // Open with a failure. + seed_filed_check( + &mut conn, + server_id, + "x", + CheckResult::Failed, + false, + "trouble", + ) + .await; // Downgrade to warning — still active. - private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "warning", - "message": "less bad now", - })) - .await - .assert_status_ok(); + seed_filed_check( + &mut conn, + server_id, + "x", + CheckResult::Warning, + false, + "less bad now", + ) + .await; // Incident should still be open. let resp = private @@ -495,11 +451,7 @@ async fn severity_downgrade_keeps_issue_in_incident() { .await; } -async fn open_issue( - conn: &mut database::diesel_async::AsyncPgConnection, - private: &commons_tests::axum_test::TestServer, - server_id: Uuid, -) -> Uuid { +async fn open_issue(conn: &mut database::diesel_async::AsyncPgConnection, server_id: Uuid) -> Uuid { // Standalone server in its own group — incidents are group-keyed. let group_id = Uuid::new_v4(); conn.batch_execute(&format!( @@ -510,31 +462,16 @@ async fn open_issue( .await .expect("seed"); - let r = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "message": "trouble", - })) - .await; - r.assert_status_ok(); - Uuid::parse_str( - r.json::() - .get("id") - .unwrap() - .as_str() - .unwrap(), - ) - .unwrap() + seed_filed_check(conn, server_id, "x", CheckResult::Failed, false, "trouble") + .await + .id } #[tokio::test(flavor = "multi_thread")] async fn resolve_closes_incident_and_records_reason() { commons_tests::server::run(async |mut conn, _public, private| { let server_id = Uuid::new_v4(); - let issue_id = open_issue(&mut conn, &private, server_id).await; + let issue_id = open_issue(&mut conn, server_id).await; let r = private .post("/api/issues/resolve") @@ -568,7 +505,7 @@ async fn resolve_closes_incident_and_records_reason() { async fn unresolve_reopens_incident_if_still_active() { commons_tests::server::run(async |mut conn, _public, private| { let server_id = Uuid::new_v4(); - let issue_id = open_issue(&mut conn, &private, server_id).await; + let issue_id = open_issue(&mut conn, server_id).await; private .post("/api/issues/resolve") @@ -678,7 +615,7 @@ async fn reopen_via_device_clears_resolved_fields() { async fn snooze_leaves_incident_and_blocks_rejoin() { commons_tests::server::run(async |mut conn, _public, private| { let server_id = Uuid::new_v4(); - let issue_id = open_issue(&mut conn, &private, server_id).await; + let issue_id = open_issue(&mut conn, server_id).await; // Snooze until far future. private @@ -702,18 +639,16 @@ async fn snooze_leaves_incident_and_blocks_rejoin() { "incident should be closed" ); - // A new error event during snooze should *not* open a new incident. - private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "escalates": true, - "message": "still flapping", - })) - .await - .assert_status_ok(); + // A new failure during snooze should *not* open a new incident. + seed_filed_check( + &mut conn, + server_id, + "x", + CheckResult::Failed, + true, + "still flapping", + ) + .await; let resp = private .post("/api/incidents/list_for_server") @@ -762,19 +697,17 @@ async fn unmonitored_server_event_does_not_open_incident() { .await .expect("seed"); - // Manual event with severity=error normally opens an incident. The - // server is unmonitored, so the issue is recorded but no incident - // fires. - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "ignored", - "result": "failed", - "message": "should not open an incident", - })) - .await; - resp.assert_status_ok(); + // A failing check normally opens an incident. The server is + // unmonitored, so the issue is recorded but no incident fires. + seed_filed_check( + &mut conn, + server_id, + "ignored", + CheckResult::Failed, + false, + "should not open an incident", + ) + .await; let resp = private .post("/api/incidents/list_for_server") @@ -813,16 +746,15 @@ async fn enabling_monitoring_opens_pending_incident() { .expect("seed"); // File an issue while unmonitored: no incident. - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "stuck", - "result": "failed", - "message": "waiting to be re-enabled", - })) - .await; - resp.assert_status_ok(); + seed_filed_check( + &mut conn, + server_id, + "stuck", + CheckResult::Failed, + false, + "waiting to be re-enabled", + ) + .await; // Flip monitoring on: the open issue should be promoted. let resp = private @@ -852,7 +784,7 @@ async fn disabling_monitoring_removes_open_contribution() { .expect("seed admin"); let server_id = Uuid::new_v4(); - open_issue(&mut conn, &private, server_id).await; + open_issue(&mut conn, server_id).await; // Sanity: there's an incident before we flip. let resp = private @@ -900,22 +832,21 @@ async fn silencing_server_ref_closes_only_matching_open_incident() { .expect("seed admin"); let server_id = Uuid::new_v4(); - let _issue_id = open_issue(&mut conn, &private, server_id).await; + let _issue_id = open_issue(&mut conn, server_id).await; // File a *different* ref on the same server — also an incident-class // issue. Two incidents, or one incident with two contributors? Per // the existing semantics, the first opens a group incident and the // second joins it. We'll see one open incident with two contributors. - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "other", - "result": "failed", - "message": "second contributor", - })) - .await; - resp.assert_status_ok(); + seed_filed_check( + &mut conn, + server_id, + "other", + CheckResult::Failed, + false, + "second contributor", + ) + .await; let resp = private .post("/api/incidents/list_for_server") .json(&serde_json::json!({ "server_id": server_id })) @@ -928,7 +859,7 @@ async fn silencing_server_ref_closes_only_matching_open_incident() { .post("/api/silenced_refs/silence_server") .json(&serde_json::json!({ "server_id": server_id, - "source": "manual", + "source": "canopy", "ref": "x", })) .await; @@ -952,7 +883,7 @@ async fn unsilencing_server_ref_rejoins_open_incident() { .expect("seed admin"); let server_id = Uuid::new_v4(); - let _issue_id = open_issue(&mut conn, &private, server_id).await; + let _issue_id = open_issue(&mut conn, server_id).await; // Silence then unsilence: the (re-)evaluation should leave the issue // in the same state we started in. @@ -960,7 +891,7 @@ async fn unsilencing_server_ref_rejoins_open_incident() { .post("/api/silenced_refs/silence_server") .json(&serde_json::json!({ "server_id": server_id, - "source": "manual", + "source": "canopy", "ref": "x", })) .await; @@ -980,7 +911,7 @@ async fn unsilencing_server_ref_rejoins_open_incident() { .post("/api/silenced_refs/unsilence_server") .json(&serde_json::json!({ "server_id": server_id, - "source": "manual", + "source": "canopy", "ref": "x", })) .await; @@ -1021,7 +952,7 @@ async fn group_silence_blocks_events_from_all_members() { .post("/api/silenced_refs/silence_group") .json(&serde_json::json!({ "server_group_id": group_id, - "source": "manual", + "source": "canopy", "ref": "noisy", })) .await; @@ -1029,16 +960,15 @@ async fn group_silence_blocks_events_from_all_members() { // Either member firing the silenced ref doesn't open an incident. for sid in [server_a, server_b] { - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": sid, - "ref": "noisy", - "result": "failed", - "message": "should not fire", - })) - .await; - resp.assert_status_ok(); + seed_filed_check( + &mut conn, + sid, + "noisy", + CheckResult::Failed, + false, + "should not fire", + ) + .await; } let resp = private @@ -1048,16 +978,15 @@ async fn group_silence_blocks_events_from_all_members() { assert!(resp.json::>().is_empty()); // A different ref still opens an incident — silence is ref-specific. - let resp = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_a, - "ref": "other", - "result": "failed", - "message": "should still fire", - })) - .await; - resp.assert_status_ok(); + seed_filed_check( + &mut conn, + server_a, + "other", + CheckResult::Failed, + false, + "should still fire", + ) + .await; let resp = private .post("/api/incidents/list_for_server") .json(&serde_json::json!({ "server_id": server_a })) @@ -1088,7 +1017,7 @@ async fn list_silenced_refs_for_server_and_group() { .post("/api/silenced_refs/silence_server") .json(&serde_json::json!({ "server_id": server_id, - "source": "manual", + "source": "canopy", "ref": "srv-ref", })) .await @@ -1132,7 +1061,7 @@ async fn list_silenced_refs_for_server_and_group() { async fn incident_resolve_metadata() { commons_tests::server::run(async |mut conn, _public, private| { let server_id = Uuid::new_v4(); - open_issue(&mut conn, &private, server_id).await; + open_issue(&mut conn, server_id).await; let resp = private .post("/api/incidents/list_for_server") diff --git a/crates/private-server/tests/it/notes.rs b/crates/private-server/tests/it/notes.rs index 7fea9e5a..60db0e11 100644 --- a/crates/private-server/tests/it/notes.rs +++ b/crates/private-server/tests/it/notes.rs @@ -1,4 +1,6 @@ use commons_tests::diesel_async::SimpleAsyncConnection; +use commons_types::status::CheckResult; +use database::issues::{CheckFiling, FilingScope}; use uuid::Uuid; async fn seed_issue_and_incident( @@ -15,24 +17,27 @@ async fn seed_issue_and_incident( .await .expect("seed server"); - let r = private - .post("/api/issues/submit_manual_event") - .json(&serde_json::json!({ - "serverId": server_id, - "ref": "x", - "result": "failed", - "message": "trouble", - })) - .await; - r.assert_status_ok(); - let issue_id = Uuid::parse_str( - r.json::() - .get("id") - .unwrap() - .as_str() - .unwrap(), + let issue_id = database::issues::file_check( + conn, + CheckFiling { + source: database::statuses::CANOPY_SOURCE, + scope: FilingScope::Server { + server_id, + device_id: None, + }, + check: "x", + observed: CheckResult::Failed, + title: None, + message: "trouble", + detail: None, + default_ceiling: CheckResult::Failed, + default_escalates: false, + documentation: None, + }, ) - .unwrap(); + .await + .expect("file check") + .id; let resp = private .post("/api/incidents/list_for_server") diff --git a/crates/public-server/openapi.json b/crates/public-server/openapi.json index d1f70dab..dd55868c 100644 --- a/crates/public-server/openapi.json +++ b/crates/public-server/openapi.json @@ -1796,7 +1796,7 @@ "string", "null" ], - "description": "The name of the source pushing this status: the reporting agent, e.g.\n`alertd`. Multiple sources may report on one server, each with its own\nset of checks; a source's push only opens and recovers its own checks.\n\n**Transitionally optional: this field will become mandatory.** A push\nwithout a `source` is attributed to `alertd`; new reporters must send\ntheir own name. Must be a non-empty string; the names `canopy` and\n`manual` are reserved for canopy itself and are rejected." + "description": "The name of the source pushing this status: the reporting agent, e.g.\n`alertd`. Multiple sources may report on one server, each with its own\nset of checks; a source's push only opens and recovers its own checks.\n\n**Transitionally optional: this field will become mandatory.** A push\nwithout a `source` is attributed to `alertd`; new reporters must send\ntheir own name. Must be a non-empty string; the name `canopy` is\nreserved for canopy itself and is rejected." } } } diff --git a/crates/public-server/src/mcp.rs b/crates/public-server/src/mcp.rs index d850cfce..194554c3 100644 --- a/crates/public-server/src/mcp.rs +++ b/crates/public-server/src/mcp.rs @@ -1,4 +1,4 @@ -//! The internet-facing mount of the read-only MCP query interface, gated by +//! The internet-facing mount of the MCP query interface, gated by //! bearer tokens ([`database::mcp_tokens::McpToken`]). //! //! Spec: `.workhorse/specs/private-server/mcp.md` (id `MCP`). @@ -31,11 +31,12 @@ const RL_WINDOW: Duration = Duration::from_secs(60); const RL_PER_IP: u32 = 30; /// The `/mcp` mount: the shared MCP tower service behind the bearer gate. +/// Reads go to the replica pool; manual-incident writes to the primary. pub fn routes(state: AppState) -> Router<()> { Router::new().nest( "/mcp", Router::new() - .fallback_service(canopy_mcp::service(state.db_read.clone())) + .fallback_service(canopy_mcp::service(state.db.clone(), state.db_read.clone())) .layer(middleware::from_fn_with_state(state, require_bearer_token)), ) } @@ -43,11 +44,13 @@ pub fn routes(state: AppState) -> Router<()> { /// Gate on a usable bearer token. Missing, malformed, unknown, revoked, and /// expired tokens all yield the same opaque 401 (with a `WWW-Authenticate` /// challenge); the distinction is logged. The token's name is logged on -/// success so each query is attributable, and its `last_used_at` is bumped. +/// success so each query is attributable, its `last_used_at` is bumped, and +/// it becomes the request's [`canopy_mcp::McpIdentity`] — with write access +/// only if the token was minted with it. async fn require_bearer_token( State(state): State, ClientIp(ip): ClientIp, - req: Request, + mut req: Request, next: Next, ) -> Result { let presented = req @@ -83,6 +86,10 @@ async fn require_bearer_token( let mut conn = state.db.get().await?; McpToken::touch_last_used(&mut conn, token.id).await?; } + req.extensions_mut().insert(canopy_mcp::McpIdentity { + who: token.name.clone(), + can_write: token.write_access, + }); Ok(next.run(req).await) } diff --git a/crates/public-server/src/statuses.rs b/crates/public-server/src/statuses.rs index 10ca4f11..4b9ecd0d 100644 --- a/crates/public-server/src/statuses.rs +++ b/crates/public-server/src/statuses.rs @@ -48,8 +48,8 @@ pub struct StatusPayload { /// /// **Transitionally optional: this field will become mandatory.** A push /// without a `source` is attributed to `alertd`; new reporters must send - /// their own name. Must be a non-empty string; the names `canopy` and - /// `manual` are reserved for canopy itself and are rejected. + /// their own name. Must be a non-empty string; the name `canopy` is + /// reserved for canopy itself and is rejected. pub source: Option, /// Overall self-reported health of the server. **Absent means `true`**, @@ -131,8 +131,8 @@ const LEGACY_SOURCE: &str = "tamanu"; /// server that goes quiet trips the source-staleness net. const LEGACY_CHECK: &str = "tasks"; /// Source names a push may not claim: `canopy` is canopy's own -/// determinations (reachability sweep etc.), `manual` is operator-entered. -const RESERVED_SOURCES: &[&str] = &[database::statuses::CANOPY_SOURCE, "manual"]; +/// determinations (reachability sweep etc.). +const RESERVED_SOURCES: &[&str] = &[database::statuses::CANOPY_SOURCE]; /// Prefix for per-check refs. Each check is filed at /// `(, health/)` — one thread per check, brokenness /// included (a broken check retains the previous definite result's @@ -716,8 +716,8 @@ fn per_check_description( /// - missing or `null` body → `source = alertd`, `healthy = true`, /// `health = []`, `extra = {}` /// - `source` absent ⇒ `alertd` (transitional — the field will become -/// mandatory); present must be a non-empty string and not one of the -/// reserved names (`canopy`, `manual`) +/// mandatory); present must be a non-empty string and not the +/// reserved name (`canopy`) /// - `healthy` absent ⇒ `true` (legacy compat — non-negotiable, this is /// what stops every legacy server from false-positiving unhealthy on /// the day we deploy) diff --git a/crates/public-server/tests/it/statuses.rs b/crates/public-server/tests/it/statuses.rs index 5e1deaea..160e7914 100644 --- a/crates/public-server/tests/it/statuses.rs +++ b/crates/public-server/tests/it/statuses.rs @@ -2970,7 +2970,7 @@ async fn source_field_validation() { async |mut conn, cert, device_id, public, _| { let server_id = insert_health_test_server(&mut conn, device_id).await; - for source in ["canopy", "Manual", ""] { + for source in ["canopy", "Canopy", ""] { public .post(&format!("/status/{server_id}")) .add_header("mtls-certificate", &cert) diff --git a/migrations/2026-07-21-014944-0000_manual_incidents/down.sql b/migrations/2026-07-21-014944-0000_manual_incidents/down.sql new file mode 100644 index 00000000..ddac8f9b --- /dev/null +++ b/migrations/2026-07-21-014944-0000_manual_incidents/down.sql @@ -0,0 +1 @@ +DROP TABLE manual_incidents; diff --git a/migrations/2026-07-21-014944-0000_manual_incidents/up.sql b/migrations/2026-07-21-014944-0000_manual_incidents/up.sql new file mode 100644 index 00000000..b9815849 --- /dev/null +++ b/migrations/2026-07-21-014944-0000_manual_incidents/up.sql @@ -0,0 +1,23 @@ +-- Manual incidents: support-team-recorded incident records, written after +-- the fact rather than derived from check state. Independent of the +-- issues/incidents machinery. See INC spec, "Manual incidents". +CREATE TABLE manual_incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + started_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- NULL while the incident is ongoing. + ended_at TIMESTAMP WITH TIME ZONE, + -- The affected group; NULL for fleet/canopy-wide incidents. Kept (not + -- cascaded) when the group goes away: the record is history. + server_group_id UUID REFERENCES server_groups (id) ON DELETE SET NULL ON UPDATE CASCADE, + -- Who recorded it: a tailnet login or an MCP token name. + created_by TEXT NOT NULL +); + +SELECT diesel_manage_updated_at('manual_incidents'); + +CREATE INDEX manual_incidents_started ON manual_incidents (started_at DESC); +CREATE INDEX manual_incidents_group ON manual_incidents (server_group_id) WHERE server_group_id IS NOT NULL; diff --git a/migrations/2026-07-21-014944-0001_mcp_tokens_write_access/down.sql b/migrations/2026-07-21-014944-0001_mcp_tokens_write_access/down.sql new file mode 100644 index 00000000..eef4cb96 --- /dev/null +++ b/migrations/2026-07-21-014944-0001_mcp_tokens_write_access/down.sql @@ -0,0 +1 @@ +ALTER TABLE mcp_tokens DROP COLUMN write_access; diff --git a/migrations/2026-07-21-014944-0001_mcp_tokens_write_access/up.sql b/migrations/2026-07-21-014944-0001_mcp_tokens_write_access/up.sql new file mode 100644 index 00000000..33414368 --- /dev/null +++ b/migrations/2026-07-21-014944-0001_mcp_tokens_write_access/up.sql @@ -0,0 +1,3 @@ +-- Whether the token may call the MCP write tools (manual incidents). A +-- mint-time choice, immutable afterwards; existing tokens stay read-only. +ALTER TABLE mcp_tokens ADD COLUMN write_access BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/plugins/canopy-mcp/.claude-plugin/plugin.json b/plugins/canopy-mcp/.claude-plugin/plugin.json index f218fb5e..57b87707 100644 --- a/plugins/canopy-mcp/.claude-plugin/plugin.json +++ b/plugins/canopy-mcp/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "canopy-mcp", "version": "1.0.0", - "description": "Read-only fleet queries against Canopy: Tamanu servers, groups, versions, backups, incidents, and issues. Requires a Canopy MCP bearer token credential for meta.tamanu.app." + "description": "Fleet queries against Canopy: Tamanu servers, groups, versions, backups, incidents, and issues; read-only except support-recorded manual incidents (write-scoped tokens only). Requires a Canopy MCP bearer token credential for meta.tamanu.app." } diff --git a/plugins/canopy-mcp/README.md b/plugins/canopy-mcp/README.md index ad5c0039..ae2ec7ce 100644 --- a/plugins/canopy-mcp/README.md +++ b/plugins/canopy-mcp/README.md @@ -1,7 +1,9 @@ # canopy-mcp plugin -Points agents at the internet-facing mount of Canopy's read-only MCP fleet -query interface (`https://meta.tamanu.app/mcp`). The `.mcp.json` carries no +Points agents at the internet-facing mount of Canopy's MCP fleet query +interface (`https://meta.tamanu.app/mcp`). Queries are read-only except +manual incidents (support-recorded incident records), which tokens minted +with write access can record and edit. The `.mcp.json` carries no credentials: the endpoint requires a bearer token, which must be supplied by the agent platform (for Claude Tag, the admin Connections credential proxy). diff --git a/private-web/openapi.json b/private-web/openapi.json index 7073f539..9d952176 100644 --- a/private-web/openapi.json +++ b/private-web/openapi.json @@ -2730,7 +2730,7 @@ "healthchecks" ], "summary": "Set a source's reachability mode.", - "description": "Governs how the source's silence bears on its servers' reachability:\n`on` warns, `quiet` never warns but still counts toward unreachable,\n`off` is excluded. The reserved `canopy`/`manual` names are rejected.", + "description": "Governs how the source's silence bears on its servers' reachability:\n`on` warns, `quiet` never warns but still counts toward unreachable,\n`off` is excluded. The reserved `canopy` name is rejected.", "operationId": "healthcheck_set_source_reachability", "requestBody": { "content": { @@ -2790,7 +2790,7 @@ "healthchecks" ], "summary": "List the reporting sources and their reachability policy.", - "description": "Every non-reserved source that has catalogued checks, with its\nreachability mode (defaulting to `on`) and most recent fleet-wide\nreport. The reserved `canopy`/`manual` sources are excluded.", + "description": "Every non-reserved source that has catalogued checks, with its\nreachability mode (defaulting to `on`) and most recent fleet-wide\nreport. The reserved `canopy` source is excluded.", "operationId": "healthcheck_sources", "responses": { "200": { @@ -3734,19 +3734,19 @@ ] } }, - "/api/issues/submit_manual_event": { + "/api/issues/unresolve": { "post": { "tags": [ "issues" ], - "summary": "Manually raise (or clear) a condition against a server.", - "description": "Finds or creates an issue keyed by the server and the given `ref`\nunder the `manual` source, grades the chosen result through the\ncondition's catalog policy, and returns the resulting issue. Returns\n400 if `ref` is empty or if `description` contains a newline.", - "operationId": "submit_manual_event", + "summary": "Undo a previous resolution, marking an issue as unresolved again.", + "description": "Clears the resolution timestamp, resolver, and reason, and returns the\nupdated issue.", + "operationId": "issue_unresolve", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SubmitManualEventArgs" + "$ref": "#/components/schemas/IssueIdArgs" } } }, @@ -3762,13 +3762,40 @@ } } } + } + }, + "security": [ + { + "tailscale-admin": [] + } + ] + } + }, + "/api/issues/unsnooze": { + "post": { + "tags": [ + "issues" + ], + "summary": "Clear a snooze on an issue.", + "description": "Makes a previously snoozed issue demand attention again immediately,\nwithout waiting for its snooze time to pass. Returns the updated issue.", + "operationId": "issue_unsnooze", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueIdArgs" + } + } }, - "400": { + "required": true + }, + "responses": { + "200": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemDetailsSchema" + "$ref": "#/components/schemas/IssueData" } } } @@ -3781,19 +3808,18 @@ ] } }, - "/api/issues/unresolve": { + "/api/manual_incidents/get": { "post": { "tags": [ - "issues" + "manual_incidents" ], - "summary": "Undo a previous resolution, marking an issue as unresolved again.", - "description": "Clears the resolution timestamp, resolver, and reason, and returns the\nupdated issue.", - "operationId": "issue_unresolve", + "summary": "Fetch one manual incident by id.", + "operationId": "manual_incidents_get", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueIdArgs" + "$ref": "#/components/schemas/ManualIncidentGetArgs" } } }, @@ -3805,7 +3831,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueData" + "$ref": "#/components/schemas/ManualIncidentData" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsSchema" } } } @@ -3813,24 +3859,23 @@ }, "security": [ { - "tailscale-admin": [] + "tailscale-user": [] } ] } }, - "/api/issues/unsnooze": { + "/api/manual_incidents/list": { "post": { "tags": [ - "issues" + "manual_incidents" ], - "summary": "Clear a snooze on an issue.", - "description": "Makes a previously snoozed issue demand attention again immediately,\nwithout waiting for its snooze time to pass. Returns the updated issue.", - "operationId": "issue_unsnooze", + "summary": "List manual incidents, most recently started first.", + "operationId": "manual_incidents_list", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueIdArgs" + "$ref": "#/components/schemas/ManualIncidentListArgs" } } }, @@ -3838,11 +3883,24 @@ }, "responses": { "200": { + "description": "Manual incidents, most recently started first.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManualIncidentData" + } + } + } + } + }, + "401": { "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueData" + "$ref": "#/components/schemas/ProblemDetailsSchema" } } } @@ -3850,7 +3908,7 @@ }, "security": [ { - "tailscale-admin": [] + "tailscale-user": [] } ] } @@ -8930,7 +8988,7 @@ "null" ], "format": "uuid", - "description": "Id of the device that reported the underlying event, if the issue\noriginated from a device push rather than a manual entry." + "description": "Id of the device that reported the underlying event, if the issue\noriginated from a device push rather than canopy's own monitoring." }, "effective_result": { "type": [ @@ -9060,7 +9118,7 @@ }, "source": { "type": "string", - "description": "What raised the issue (for example, an automated health check or a\nmanually submitted event)." + "description": "What raised the issue (for example, an automated health check)." }, "updated_at": { "type": "string", @@ -9476,6 +9534,119 @@ "full" ] }, + "ManualIncidentData": { + "type": "object", + "description": "A support-recorded incident: written after the fact by people (over the\nMCP interface), independent of the automatic incidents derived from\ncheck state.", + "required": [ + "id", + "title", + "description", + "started_at", + "created_by", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the record was created." + }, + "created_by": { + "type": "string", + "description": "Who recorded it: a tailnet login or an MCP token name." + }, + "description": { + "type": "string", + "description": "Markdown body; empty when nobody has written one yet." + }, + "ended_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the incident ended; absent while it is ongoing." + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this manual incident." + }, + "server_group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Id of the affected server group. Absent for incidents concerning the\nfleet or Canopy generally." + }, + "server_group_name": { + "type": [ + "string", + "null" + ], + "description": "Display name of the affected server group, when one is set." + }, + "started_at": { + "type": "string", + "format": "date-time", + "description": "When the incident started." + }, + "title": { + "type": "string", + "description": "Single-line headline." + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the record was last changed." + } + } + }, + "ManualIncidentGetArgs": { + "type": "object", + "description": "Arguments identifying one manual incident.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Id of the manual incident to fetch." + } + } + }, + "ManualIncidentListArgs": { + "type": "object", + "description": "Arguments for listing manual incidents.", + "properties": { + "groupId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Restrict to one group's incidents." + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Max incidents to return (default 100)." + }, + "ongoingOnly": { + "type": [ + "boolean", + "null" + ], + "description": "Only incidents without an end time (still ongoing)." + } + } + }, "McpTokenView": { "type": "object", "description": "Metadata about an MCP access token. Never includes the secret value\nitself — that's only ever returned once, at minting time.", @@ -9484,7 +9655,8 @@ "name", "created_by", "created_at", - "expires_at" + "expires_at", + "write_access" ], "properties": { "created_at": { @@ -9525,6 +9697,10 @@ ], "format": "date-time", "description": "When the token was revoked, or `null` if it has not been revoked." + }, + "write_access": { + "type": "boolean", + "description": "Whether the token may call the MCP write tools (manual incidents).\nChosen at mint time; immutable afterwards." } } }, @@ -9615,6 +9791,10 @@ "name": { "type": "string", "description": "Operator-chosen label, e.g. which agent will hold this token. Cannot\nbe empty or only whitespace." + }, + "write_access": { + "type": "boolean", + "description": "Grant the token the MCP write tools (manual incidents). Defaults to\nfalse (read-only); cannot be changed after minting." } } }, @@ -12408,7 +12588,7 @@ }, "source": { "type": "string", - "description": "The source to configure. The reserved `canopy`/`manual` names are\nrejected." + "description": "The source to configure. The reserved `canopy` name is rejected." } } }, @@ -12898,62 +13078,6 @@ } } }, - "SubmitManualEventArgs": { - "type": "object", - "description": "A manually raised condition to record against a server.", - "required": [ - "serverId", - "ref", - "message" - ], - "properties": { - "active": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the underlying condition is currently active. Defaults to\n`true` when omitted; `false` records it as cleared regardless of\n`result`." - }, - "description": { - "type": [ - "string", - "null" - ], - "description": "Short, single-line headline for the condition. Must not contain\nnewlines — use `message` for multi-line detail." - }, - "escalates": { - "type": [ - "boolean", - "null" - ], - "description": "Whether the condition's failures should notify immediately,\nbypassing the incident grace period. Only consulted the first time\na `ref` is seen (it seeds the condition's catalog entry); adjust\nlater from the healthchecks catalog." - }, - "message": { - "type": "string", - "description": "Human-readable message describing the condition. May be multi-line." - }, - "ref": { - "type": "string", - "description": "Identifier for the underlying condition. Reports with the same\n`ref` on the same server update the same issue rather than opening\na new one each time; use a fresh unique value if that deduplication\nisn't wanted." - }, - "result": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/CheckResult", - "description": "The condition's result: `failed` (can open an incident) or\n`warning` (context only). Defaults to `failed`. `passed` records\nthe condition as cleared, same as `active: false`." - } - ] - }, - "serverId": { - "type": "string", - "format": "uuid", - "description": "Id of the server the condition applies to." - } - } - }, "SummaryData": { "type": "object", "description": "Fleet-wide summary of software versions currently running in production.", @@ -13550,6 +13674,10 @@ "name": "issues", "description": "Per-server issues raised from device events." }, + { + "name": "manual_incidents", + "description": "Support-recorded incident records, written over MCP and displayed read-only." + }, { "name": "mcp_tokens", "description": "Bearer tokens for the public MCP mount." diff --git a/private-web/package-lock.json b/private-web/package-lock.json index 4ca692fb..017e40f0 100644 --- a/private-web/package-lock.json +++ b/private-web/package-lock.json @@ -1182,9 +1182,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1202,9 +1199,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1222,9 +1216,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1242,9 +1233,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1262,9 +1250,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1282,9 +1267,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/private-web/src/App.tsx b/private-web/src/App.tsx index 1c0a2849..e60e7b34 100644 --- a/private-web/src/App.tsx +++ b/private-web/src/App.tsx @@ -36,6 +36,7 @@ import HealthcheckSettings from "./routes/HealthcheckSettings"; import Healthchecks from "./routes/Healthchecks"; import IncidentDetail from "./routes/IncidentDetail"; import Incidents from "./routes/Incidents"; +import ManualIncidentDetail from "./routes/ManualIncidentDetail"; import Status from "./routes/Status"; import ServerCreate from "./routes/ServerCreate"; import ServerDetail from "./routes/ServerDetail"; @@ -184,6 +185,10 @@ export default function App() { } /> } /> } /> + } + /> } /> } /> } /> diff --git a/private-web/src/api-types.ts b/private-web/src/api-types.ts index 3a6581c9..b41c1c00 100644 --- a/private-web/src/api-types.ts +++ b/private-web/src/api-types.ts @@ -1362,7 +1362,7 @@ export interface paths { * Set a source's reachability mode. * @description Governs how the source's silence bears on its servers' reachability: * `on` warns, `quiet` never warns but still counts toward unreachable, - * `off` is excluded. The reserved `canopy`/`manual` names are rejected. + * `off` is excluded. The reserved `canopy` name is rejected. */ post: operations["healthcheck_set_source_reachability"]; delete?: never; @@ -1384,7 +1384,7 @@ export interface paths { * List the reporting sources and their reachability policy. * @description Every non-reserved source that has catalogued checks, with its * reachability mode (defaulting to `on`) and most recent fleet-wide - * report. The reserved `canopy`/`manual` sources are excluded. + * report. The reserved `canopy` source is excluded. */ post: operations["healthcheck_sources"]; delete?: never; @@ -1852,7 +1852,7 @@ export interface paths { patch?: never; trace?: never; }; - "/api/issues/submit_manual_event": { + "/api/issues/unresolve": { parameters: { query?: never; header?: never; @@ -1862,20 +1862,18 @@ export interface paths { get?: never; put?: never; /** - * Manually raise (or clear) a condition against a server. - * @description Finds or creates an issue keyed by the server and the given `ref` - * under the `manual` source, grades the chosen result through the - * condition's catalog policy, and returns the resulting issue. Returns - * 400 if `ref` is empty or if `description` contains a newline. + * Undo a previous resolution, marking an issue as unresolved again. + * @description Clears the resolution timestamp, resolver, and reason, and returns the + * updated issue. */ - post: operations["submit_manual_event"]; + post: operations["issue_unresolve"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/issues/unresolve": { + "/api/issues/unsnooze": { parameters: { query?: never; header?: never; @@ -1885,18 +1883,18 @@ export interface paths { get?: never; put?: never; /** - * Undo a previous resolution, marking an issue as unresolved again. - * @description Clears the resolution timestamp, resolver, and reason, and returns the - * updated issue. + * Clear a snooze on an issue. + * @description Makes a previously snoozed issue demand attention again immediately, + * without waiting for its snooze time to pass. Returns the updated issue. */ - post: operations["issue_unresolve"]; + post: operations["issue_unsnooze"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/api/issues/unsnooze": { + "/api/manual_incidents/get": { parameters: { query?: never; header?: never; @@ -1905,12 +1903,25 @@ export interface paths { }; get?: never; put?: never; - /** - * Clear a snooze on an issue. - * @description Makes a previously snoozed issue demand attention again immediately, - * without waiting for its snooze time to pass. Returns the updated issue. - */ - post: operations["issue_unsnooze"]; + /** Fetch one manual incident by id. */ + post: operations["manual_incidents_get"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/manual_incidents/list": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** List manual incidents, most recently started first. */ + post: operations["manual_incidents_list"]; delete?: never; options?: never; head?: never; @@ -4902,7 +4913,7 @@ export interface components { /** * Format: uuid * @description Id of the device that reported the underlying event, if the issue - * originated from a device push rather than a manual entry. + * originated from a device push rather than canopy's own monitoring. */ device_id?: string | null; /** @@ -4995,10 +5006,7 @@ export interface components { * this time. */ snoozed_until?: string | null; - /** - * @description What raised the issue (for example, an automated health check or a - * manually submitted event). - */ + /** @description What raised the issue (for example, an automated health check). */ source: string; /** * Format: date-time @@ -5259,6 +5267,75 @@ export interface components { * @enum {string} */ MaintenanceKind: "quick" | "full"; + /** + * @description A support-recorded incident: written after the fact by people (over the + * MCP interface), independent of the automatic incidents derived from + * check state. + */ + ManualIncidentData: { + /** + * Format: date-time + * @description When the record was created. + */ + created_at: string; + /** @description Who recorded it: a tailnet login or an MCP token name. */ + created_by: string; + /** @description Markdown body; empty when nobody has written one yet. */ + description: string; + /** + * Format: date-time + * @description When the incident ended; absent while it is ongoing. + */ + ended_at?: string | null; + /** + * Format: uuid + * @description Unique identifier for this manual incident. + */ + id: string; + /** + * Format: uuid + * @description Id of the affected server group. Absent for incidents concerning the + * fleet or Canopy generally. + */ + server_group_id?: string | null; + /** @description Display name of the affected server group, when one is set. */ + server_group_name?: string | null; + /** + * Format: date-time + * @description When the incident started. + */ + started_at: string; + /** @description Single-line headline. */ + title: string; + /** + * Format: date-time + * @description When the record was last changed. + */ + updated_at: string; + }; + /** @description Arguments identifying one manual incident. */ + ManualIncidentGetArgs: { + /** + * Format: uuid + * @description Id of the manual incident to fetch. + */ + id: string; + }; + /** @description Arguments for listing manual incidents. */ + ManualIncidentListArgs: { + /** + * Format: uuid + * @description Restrict to one group's incidents. + */ + groupId?: string | null; + /** + * Format: int64 + * @description Max incidents to return (default 100). + */ + limit?: number | null; + /** @description Only incidents without an end time (still ongoing). */ + ongoingOnly?: boolean | null; + }; /** * @description Metadata about an MCP access token. Never includes the secret value * itself — that's only ever returned once, at minting time. @@ -5295,6 +5372,11 @@ export interface components { * @description When the token was revoked, or `null` if it has not been revoked. */ revoked_at?: string | null; + /** + * @description Whether the token may call the MCP write tools (manual incidents). + * Chosen at mint time; immutable afterwards. + */ + write_access: boolean; }; /** @description Request to merge two device records into one. */ MergeIntoArgs: { @@ -5366,6 +5448,11 @@ export interface components { * be empty or only whitespace. */ name: string; + /** + * @description Grant the token the MCP write tools (manual incidents). Defaults to + * false (read-only); cannot be changed after minting. + */ + write_access?: boolean; }; /** * @description The result of minting a new MCP access token: its metadata plus the @@ -7053,10 +7140,7 @@ export interface components { SetSourceReachabilityArgs: { /** @description The reachability mode to apply: `on`, `quiet`, or `off`. */ reachability: components["schemas"]["ReachabilityMode"]; - /** - * @description The source to configure. The reserved `canopy`/`manual` names are - * rejected. - */ + /** @description The source to configure. The reserved `canopy` name is rejected. */ source: string; }; /** @@ -7387,42 +7471,6 @@ export interface components { */ version_distance?: number | null; }; - /** @description A manually raised condition to record against a server. */ - SubmitManualEventArgs: { - /** - * @description Whether the underlying condition is currently active. Defaults to - * `true` when omitted; `false` records it as cleared regardless of - * `result`. - */ - active?: boolean | null; - /** - * @description Short, single-line headline for the condition. Must not contain - * newlines — use `message` for multi-line detail. - */ - description?: string | null; - /** - * @description Whether the condition's failures should notify immediately, - * bypassing the incident grace period. Only consulted the first time - * a `ref` is seen (it seeds the condition's catalog entry); adjust - * later from the healthchecks catalog. - */ - escalates?: boolean | null; - /** @description Human-readable message describing the condition. May be multi-line. */ - message: string; - /** - * @description Identifier for the underlying condition. Reports with the same - * `ref` on the same server update the same issue rather than opening - * a new one each time; use a fresh unique value if that deduplication - * isn't wanted. - */ - ref: string; - result?: null | components["schemas"]["CheckResult"]; - /** - * Format: uuid - * @description Id of the server the condition applies to. - */ - serverId: string; - }; /** @description Fleet-wide summary of software versions currently running in production. */ SummaryData: { /** @@ -10290,7 +10338,7 @@ export interface operations { }; }; }; - submit_manual_event: { + issue_unresolve: { parameters: { query?: never; header?: never; @@ -10299,7 +10347,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["SubmitManualEventArgs"]; + "application/json": components["schemas"]["IssueIdArgs"]; }; }; responses: { @@ -10311,17 +10359,32 @@ export interface operations { "application/json": components["schemas"]["IssueData"]; }; }; - 400: { + }; + }; + issue_unsnooze: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IssueIdArgs"]; + }; + }; + responses: { + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ProblemDetailsSchema"]; + "application/json": components["schemas"]["IssueData"]; }; }; }; }; - issue_unresolve: { + manual_incidents_get: { parameters: { query?: never; header?: never; @@ -10330,7 +10393,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["IssueIdArgs"]; + "application/json": components["schemas"]["ManualIncidentGetArgs"]; }; }; responses: { @@ -10339,12 +10402,28 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IssueData"]; + "application/json": components["schemas"]["ManualIncidentData"]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; }; }; }; }; - issue_unsnooze: { + manual_incidents_list: { parameters: { query?: never; header?: never; @@ -10353,16 +10432,25 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["IssueIdArgs"]; + "application/json": components["schemas"]["ManualIncidentListArgs"]; }; }; responses: { + /** @description Manual incidents, most recently started first. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IssueData"]; + "application/json": components["schemas"]["ManualIncidentData"][]; + }; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProblemDetailsSchema"]; }; }; }; diff --git a/private-web/src/components/ManualEventButton.tsx b/private-web/src/components/ManualEventButton.tsx deleted file mode 100644 index 7c8f0965..00000000 --- a/private-web/src/components/ManualEventButton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Button, Dialog, DialogContent, DialogTitle } from "@mui/material"; -import AddAlertIcon from "@mui/icons-material/AddAlert"; -import { useState } from "react"; -import ManualEventForm from "./ManualEventForm"; - -/** Admin-only button on the ServerDetail header: opens a dialog with the - * manual-event form. Label switches between "New incident" and "Add issue" - * depending on `hasOpenIncident` — which the parent computes once from a - * single group-level query and shares with all the buttons on the page - * (header + child rows), so they all read the same group state. */ -export default function ManualEventButton({ - serverId, - hasOpenIncident, - onSubmitted, - size = "medium", -}: { - serverId: string; - /** Whether the server group currently has an open incident. The parent - * owns this state and threads it down so every button shows the same - * answer (a child server's own incidents list is empty even when the - * group's root has an open one). */ - hasOpenIncident: boolean; - /** Called after a successful submission so the parent can refresh sibling - * panels (issues, incidents) that are now stale. */ - onSubmitted?: () => void; - size?: "small" | "medium" | "large"; -}) { - const [open, setOpen] = useState(false); - const label = hasOpenIncident ? "Add issue" : "New incident"; - - return ( - <> - - setOpen(false)} fullWidth maxWidth="sm"> - {label} - - { - setOpen(false); - onSubmitted?.(); - }} - /> - - - - ); -} diff --git a/private-web/src/components/ManualEventForm.tsx b/private-web/src/components/ManualEventForm.tsx deleted file mode 100644 index d10ca636..00000000 --- a/private-web/src/components/ManualEventForm.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Alert as MuiAlert, - Box, - Button, - FormControlLabel, - MenuItem, - Stack, - Switch, - TextField, -} from "@mui/material"; -import { useState } from "react"; -import { useApiAction } from "../api"; - -/// Results an operator can raise a manual condition at: a failure can -/// open an incident; a warning joins one for context only. -const MANUAL_RESULTS = ["failed", "warning"] as const; -type ManualResult = (typeof MANUAL_RESULTS)[number]; - -export default function ManualEventForm({ - serverId, - onSubmitted, -}: { - serverId: string; - onSubmitted?: () => void; -}) { - const [result, setResult] = useState("failed"); - const [escalates, setEscalates] = useState(false); - const [message, setMessage] = useState(""); - const [description, setDescription] = useState(""); - - const action = useApiAction("issues", "submit_manual_event"); - - const submit = async () => { - try { - await action.call({ - serverId, - // Each manual submission is its own issue. Operators that need to - // add context to an existing issue use the notes panel instead. - ref: crypto.randomUUID(), - result, - escalates, - description: description.trim() === "" ? null : description.trim(), - message, - }); - setMessage(""); - setDescription(""); - onSubmitted?.(); - } catch { - /* surfaced via action.error */ - } - }; - - const valid = message.trim() !== ""; - - return ( - - - setResult(e.target.value as ManualResult)} - sx={{ minWidth: 140 }} - > - {MANUAL_RESULTS.map((r) => ( - - {r} - - ))} - - {result === "failed" && ( - setEscalates(e.target.checked)} - size="small" - /> - } - label="Notify immediately" - /> - )} - - setDescription(e.target.value)} - /> - setMessage(e.target.value)} - required - multiline - minRows={2} - /> - {action.error && ( - {action.error.message} - )} - - - - - ); -} diff --git a/private-web/src/components/ManualIncidentCard.tsx b/private-web/src/components/ManualIncidentCard.tsx new file mode 100644 index 00000000..178dfbd6 --- /dev/null +++ b/private-web/src/components/ManualIncidentCard.tsx @@ -0,0 +1,66 @@ +import { Box, Chip, Stack, Typography } from "@mui/material"; +import { Link as RouterLink } from "react-router-dom"; +import TimeAgo from "./TimeAgo"; +import type { ManualIncidentData } from "../types"; + +/** Compact view of a manual incident (a support-recorded record, distinct + * from the automatic incidents canopy opens from check state); click-through + * goes to the manual incident detail page. Ongoing records get a + * warning-coloured border; ended ones the neutral divider. */ +export default function ManualIncidentCard({ + incident, +}: { + incident: ManualIncidentData; +}) { + const ongoing = incident.ended_at == null; + return ( + + + + {incident.title} + + + {ongoing && } + + + {incident.server_group_name ?? "Fleet-wide"} · started{" "} + + {incident.ended_at && ( + <> + {" "} + · ended + + )} + + + + {incident.id.slice(0, 8)} + + + by {incident.created_by} + + + + ); +} diff --git a/private-web/src/routes/Incidents.tsx b/private-web/src/routes/Incidents.tsx index 0c7d617c..57ec2902 100644 --- a/private-web/src/routes/Incidents.tsx +++ b/private-web/src/routes/Incidents.tsx @@ -19,6 +19,7 @@ import { useSearchParams } from "react-router-dom"; import { useApi } from "../api"; import IncidentCard from "../components/IncidentCard"; import IssueRow from "../components/IssueRow"; +import ManualIncidentCard from "../components/ManualIncidentCard"; import { usePageTitle } from "../hooks/usePageTitle"; import { CHECK_RESULT_ORDER, type CheckResult } from "../types"; @@ -28,7 +29,7 @@ export default function Incidents() { usePageTitle("Incidents"); // Page-level refresh signal: a single tick refetches incidents + issues - // together after any mutation (resolve, snooze, manual submit). + // together after any mutation (resolve, snooze). const [refreshTick, setRefreshTick] = useState(0); const bumpRefresh = () => setRefreshTick((t) => t + 1); @@ -64,6 +65,14 @@ export default function Incidents() { {}, [refreshTick], ); + // Manual incidents are support-recorded history, not live alerting; the + // section only surfaces recent and ongoing ones, marked as manual. + const manualIncidents = useApi( + "manual_incidents", + "list", + { limit: 12 }, + [refreshTick], + ); const groups = useApi("server_groups", "list", {}, []); const issues = useApi( "issues", @@ -108,6 +117,30 @@ export default function Incidents() { )} + {manualIncidents.status === "ok" && manualIncidents.data.length > 0 && ( + + + Manual incidents + + + {manualIncidents.data.map((inc) => ( + + ))} + + + )} + (); + const detail = useApi("manual_incidents", "get", { id }, [id]); + + usePageTitle(detail.status === "ok" ? detail.data.title : "Manual incident"); + + if (detail.status === "loading" || detail.status === "idle") { + return ; + } + if (detail.status === "error") { + return {detail.error.message}; + } + + const incident = detail.data; + const ongoing = incident.ended_at == null; + + return ( + + + + {incident.title} + + + {ongoing ? ( + + ) : ( + + )} + + + + {incident.server_group_id ? ( + + {incident.server_group_name ?? "(unknown group)"} + + ) : ( + "Fleet-wide" + )}{" "} + · started + {incident.ended_at && ( + <> + {" "} + · ended + + )}{" "} + · recorded by {incident.created_by} + + + + {incident.description ? ( + {incident.description} + ) : ( + + No description recorded. + + )} + + + + Manual incidents are recorded and edited through the MCP interface; + this page is display-only. Last changed{" "} + . + + + ); +} diff --git a/private-web/src/routes/McpTokens.tsx b/private-web/src/routes/McpTokens.tsx index d182d344..06b537cc 100644 --- a/private-web/src/routes/McpTokens.tsx +++ b/private-web/src/routes/McpTokens.tsx @@ -2,12 +2,14 @@ import { Alert, Box, Button, + Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, + FormControlLabel, IconButton, LinearProgress, Paper, @@ -39,12 +41,14 @@ type TokenRow = { expires_at: string; revoked_at?: string | null; last_used_at?: string | null; + write_access: boolean; }; export default function McpTokens() { usePageTitle("MCP access"); const list = useApi("mcp_tokens", "list"); const [name, setName] = useState(""); + const [writeAccess, setWriteAccess] = useState(false); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const [toast, setToast] = useState(null); @@ -60,8 +64,12 @@ export default function McpTokens() { setPending(true); setError(null); try { - const res = await callApi("mcp_tokens", "mint", { name: trimmed }); + const res = await callApi("mcp_tokens", "mint", { + name: trimmed, + write_access: writeAccess, + }); setName(""); + setWriteAccess(false); setMinted({ name: res.token.name, secret: res.secret }); list.reload(); } catch (err) { @@ -112,6 +120,16 @@ export default function McpTokens() { {pending ? "Minting…" : "Mint token"} + setWriteAccess(e.target.checked)} + disabled={pending} + /> + } + label="Allow writes (manual incidents) — cannot be changed after minting" + /> {error && ( {error} @@ -136,6 +154,7 @@ export default function McpTokens() { Name Minted by + Scope Expires Last used Status @@ -149,6 +168,13 @@ export default function McpTokens() { {token.name} {token.created_by} + + {token.write_access ? ( + + ) : ( + + )} + {formatDate(token.expires_at)} {token.last_used_at diff --git a/private-web/src/routes/ServerDetail.tsx b/private-web/src/routes/ServerDetail.tsx index e3ef9f21..4f781511 100644 --- a/private-web/src/routes/ServerDetail.tsx +++ b/private-web/src/routes/ServerDetail.tsx @@ -53,7 +53,6 @@ import ExternalUsersDetails, { import HealthChip from "../components/HealthChip"; import IncidentsLink from "../components/IncidentsLink"; import OperatorAvatars from "../components/OperatorAvatars"; -import ManualEventButton from "../components/ManualEventButton"; import SilencedRefsSection from "../components/SilencedRefsSection"; import StatusDot from "../components/StatusDot"; import TailnetIdentitySection from "../components/TailnetIdentitySection"; @@ -102,24 +101,12 @@ export default function ServerDetail() { ); const isAdmin = useApi("commons", "is_current_user_admin"); // Single refresh signal for everything on the page that talks to the - // issues/incidents APIs. Any mutation (manual-event submit, resolve/ - // snooze on a row, etc.) bumps this so all sibling panels refetch in - // lockstep — otherwise resolving an issue (which can auto-close an - // incident) leaves a stale incidents list. + // issues/incidents APIs. Any mutation (resolve/snooze on a row, etc.) + // bumps this so all sibling panels refetch in lockstep — otherwise + // resolving an issue (which can auto-close an incident) leaves a stale + // incidents list. const [refreshTick, setRefreshTick] = useState(0); const bumpRefresh = () => setRefreshTick((t) => t + 1); - // Single source of truth for the group's open-incident state. Used to - // label every ManualEventButton on the page identically — a child - // server's own incidents list is empty (incidents live at the root), - // so per-button local queries would mislabel. - const openIncidents = useApi( - "incidents", - "list_for_server", - { server_id: id, include_closed: false }, - [id, refreshTick], - ); - const hasOpenIncident = - openIncidents.status === "ok" && openIncidents.data.length > 0; // Honour a `#backups` anchor (linked from the group's backup page): once the // detail has loaded and the section is painted, scroll it into view. const location = useLocation(); @@ -156,9 +143,7 @@ export default function ServerDetail() {
detail.reload()} /> {archived ? ( @@ -212,12 +197,7 @@ export default function ServerDetail() { refresh={() => detail.reload()} /> {data.siblings.length > 0 && ( - + )} void; onArchived: () => void; }) { const archived = data.server.archived; @@ -289,11 +265,6 @@ function Header({ /> {isAdmin && ( <> - + + + + ); +} diff --git a/private-web/src/routes/Incidents.tsx b/private-web/src/routes/Incidents.tsx index 57ec2902..5eb3ba49 100644 --- a/private-web/src/routes/Incidents.tsx +++ b/private-web/src/routes/Incidents.tsx @@ -1,6 +1,7 @@ import { Alert as MuiAlert, Box, + Button, Checkbox, FormControlLabel, IconButton, @@ -20,6 +21,7 @@ import { useApi } from "../api"; import IncidentCard from "../components/IncidentCard"; import IssueRow from "../components/IssueRow"; import ManualIncidentCard from "../components/ManualIncidentCard"; +import ManualIncidentFormDialog from "../components/ManualIncidentFormDialog"; import { usePageTitle } from "../hooks/usePageTitle"; import { CHECK_RESULT_ORDER, type CheckResult } from "../types"; @@ -37,6 +39,7 @@ export default function Incidents() { // Defaults aren't written into the URL (clean address bar for the // common case). const [params, setParams] = useSearchParams(); + const [recordOpen, setRecordOpen] = useState(false); const activeOnly = params.get("showAll") !== "1"; const results = (params.get("result") ?? "") .split(",") @@ -117,29 +120,44 @@ export default function Incidents() { )} - {manualIncidents.status === "ok" && manualIncidents.data.length > 0 && ( - - + + + Manual incidents - - {manualIncidents.data.map((inc) => ( - - ))} - - - )} + + + {manualIncidents.status === "ok" && + manualIncidents.data.length > 0 && ( + + {manualIncidents.data.map((inc) => ( + + ))} + + )} + setRecordOpen(false)} + onSaved={bumpRefresh} + /> + (); + const navigate = useNavigate(); const detail = useApi("manual_incidents", "get", { id }, [id]); + const [editOpen, setEditOpen] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const deleteAction = useApiAction("manual_incidents", "delete"); usePageTitle(detail.status === "ok" ? detail.data.title : "Manual incident"); @@ -31,6 +46,15 @@ export default function ManualIncidentDetail() { const incident = detail.data; const ongoing = incident.ended_at == null; + const onDelete = async () => { + try { + await deleteAction.call({ id: incident.id }); + navigate("/incidents"); + } catch { + /* surfaced via deleteAction.error */ + } + }; + return ( @@ -43,20 +67,26 @@ export default function ManualIncidentDetail() { ) : ( )} + + - {incident.server_group_id ? ( - - {incident.server_group_name ?? "(unknown group)"} - - ) : ( - "Fleet-wide" - )}{" "} + + {incident.server_group_name} + {" "} · started {incident.ended_at && ( <> @@ -78,10 +108,50 @@ export default function ManualIncidentDetail() { - Manual incidents are recorded and edited through the MCP interface; - this page is display-only. Last changed{" "} + Manual incidents are support-recorded history, written here or over + the MCP interface. Last changed{" "} . + + setEditOpen(false)} + onSaved={detail.reload} + incident={incident} + /> + + setConfirmDelete(false)}> + Delete this manual incident? + + + + “{incident.title}” will be removed entirely; there is no + undo. + + {deleteAction.error && ( + + {deleteAction.error.message} + + )} + + + + + + + ); }