matrix-accounts dashboard: live status + homeserver from daemon snapshot (be-4)
This commit is contained in:
parent
06b2bd8b72
commit
f38bc13293
5 changed files with 286 additions and 14 deletions
|
|
@ -9,8 +9,15 @@
|
|||
//! path-watcher glob). It does not read the nix config, so it lists what is
|
||||
//! *provisioned*, not the full configured set: a config-declared account
|
||||
//! without a token yet appears once it is provisioned via the login form.
|
||||
//! Homeserver + live up/down status arrive in v2 from the daemon's account
|
||||
//! registry; here `homeserver` is always `null`.
|
||||
//! Homeserver + live up/down status (v2) come from the daemon's
|
||||
//! `matrix-accounts.json` snapshot (written at startup after restores): the
|
||||
//! response backfills `homeserver`, `user_id`, and `live` per account from
|
||||
//! it, and carries the snapshot's `as_of_unix` (file mtime) so a reader can
|
||||
//! judge freshness. An account with a token but absent from the snapshot
|
||||
//! reports `live: false` (provisioned but not restored / daemon down).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use axum::extract::{Form, Query};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
|
@ -27,15 +34,58 @@ pub(super) struct MatrixAccountsQuery {
|
|||
#[derive(Serialize)]
|
||||
struct MatrixAccount {
|
||||
name: String,
|
||||
/// Always `None` in v1 (derived from the token filename, which carries no
|
||||
/// homeserver); populated in v2 from the daemon's account registry.
|
||||
/// Effective homeserver, backfilled from the daemon snapshot; `None` when
|
||||
/// the account isn't in the snapshot (token present but not restored).
|
||||
homeserver: Option<String>,
|
||||
/// A token file for this account exists in the agent state dir.
|
||||
token_present: bool,
|
||||
/// The account restored a live client per the daemon snapshot. `false`
|
||||
/// when provisioned but absent from the snapshot (not up / daemon down).
|
||||
live: bool,
|
||||
/// The account's matrix user id, from the snapshot when known.
|
||||
user_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MatrixAccountsResponse {
|
||||
accounts: Vec<MatrixAccount>,
|
||||
/// Unix mtime of the daemon's `matrix-accounts.json` snapshot (when the
|
||||
/// live data was last published), or `None` when no snapshot exists yet.
|
||||
/// Lets the dashboard show "live as of N ago" without treating a stale
|
||||
/// snapshot as definitely down.
|
||||
as_of_unix: Option<i64>,
|
||||
}
|
||||
|
||||
/// One entry of the daemon's `matrix-accounts.json` snapshot
|
||||
/// (`hive-matrix-mcp::accounts::AccountStatus`). Only the fields the
|
||||
/// dashboard backfills are read; extra fields (e.g. `is_primary`) are
|
||||
/// ignored by serde.
|
||||
#[derive(Deserialize)]
|
||||
struct SnapshotAccount {
|
||||
name: String,
|
||||
homeserver: String,
|
||||
#[serde(default)]
|
||||
user_id: Option<String>,
|
||||
live: bool,
|
||||
}
|
||||
|
||||
/// Read the daemon's live-account snapshot from the agent state dir. Returns
|
||||
/// the per-name entries plus the file's unix mtime (`as_of`). A missing or
|
||||
/// unparseable file yields an empty map (every provisioned account then
|
||||
/// reports `live: false`); `as_of` is `None` only when the file is absent.
|
||||
fn read_accounts_snapshot(dir: &Path) -> (HashMap<String, SnapshotAccount>, Option<i64>) {
|
||||
let path = dir.join("matrix-accounts.json");
|
||||
let as_of = std::fs::metadata(&path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
|
||||
let map = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Vec<SnapshotAccount>>(&s).ok())
|
||||
.map(|v| v.into_iter().map(|a| (a.name.clone(), a)).collect())
|
||||
.unwrap_or_default();
|
||||
(map, as_of)
|
||||
}
|
||||
|
||||
/// Map a state-dir filename to the matrix account name it provisions, or
|
||||
|
|
@ -65,6 +115,7 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
|
|||
}
|
||||
|
||||
let dir = Coordinator::agent_notes_dir(agent);
|
||||
let (snapshot, as_of_unix) = read_accounts_snapshot(&dir);
|
||||
let mut accounts = Vec::new();
|
||||
match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => {
|
||||
|
|
@ -77,10 +128,15 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
|
|||
continue;
|
||||
};
|
||||
if let Some(name) = account_name_from_filename(fname) {
|
||||
// Backfill live status + homeserver + user id from the
|
||||
// daemon snapshot; absent => provisioned but not restored.
|
||||
let snap = snapshot.get(&name);
|
||||
accounts.push(MatrixAccount {
|
||||
name,
|
||||
homeserver: None,
|
||||
homeserver: snap.map(|s| s.homeserver.clone()),
|
||||
token_present: true,
|
||||
live: snap.is_some_and(|s| s.live),
|
||||
user_id: snap.and_then(|s| s.user_id.clone()),
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -92,7 +148,11 @@ pub(super) async fn get_matrix_accounts(Query(q): Query<MatrixAccountsQuery>) ->
|
|||
}
|
||||
}
|
||||
accounts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
axum::Json(MatrixAccountsResponse { accounts }).into_response()
|
||||
axum::Json(MatrixAccountsResponse {
|
||||
accounts,
|
||||
as_of_unix,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Form body for `POST /matrix-account-login` (urlencoded, the dashboard's
|
||||
|
|
@ -273,7 +333,64 @@ async fn matrix_whoami(homeserver: &str, token: &str) -> Result<String, String>
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{account_name_from_filename, is_plain_ident};
|
||||
use super::{account_name_from_filename, is_plain_ident, read_accounts_snapshot};
|
||||
|
||||
fn unique_dir(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!(
|
||||
"hh-c0re-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_parses_entries_and_reports_as_of() {
|
||||
let dir = unique_dir("snap");
|
||||
std::fs::write(
|
||||
dir.join("matrix-accounts.json"),
|
||||
r#"[
|
||||
{"name":"main","homeserver":"http://hs","user_id":"@a:hs","live":true,"is_primary":true},
|
||||
{"name":"pub","homeserver":"https://matrix.org","user_id":null,"live":true,"is_primary":false}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (map, as_of) = read_accounts_snapshot(&dir);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(map["main"].homeserver, "http://hs");
|
||||
assert!(map["main"].live);
|
||||
assert_eq!(map["main"].user_id.as_deref(), Some("@a:hs"));
|
||||
assert_eq!(map["pub"].user_id, None);
|
||||
// is_primary is present in the file but ignored — no panic on the
|
||||
// extra field.
|
||||
assert!(as_of.is_some());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_absent_yields_empty_map_and_no_as_of() {
|
||||
let dir = unique_dir("nosnap");
|
||||
let (map, as_of) = read_accounts_snapshot(&dir);
|
||||
assert!(map.is_empty());
|
||||
assert!(as_of.is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_unparseable_is_empty_but_as_of_set() {
|
||||
let dir = unique_dir("badsnap");
|
||||
std::fs::write(dir.join("matrix-accounts.json"), "not json").unwrap();
|
||||
let (map, as_of) = read_accounts_snapshot(&dir);
|
||||
assert!(map.is_empty());
|
||||
// File exists, so freshness is still reported even though it can't
|
||||
// be parsed (every account then reports live: false).
|
||||
assert!(as_of.is_some());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_account_from_bare_token() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue