Fleet: per-node dev-tool version cards + nightly latest-check (Phase 1, read-only)
Each node card now shows installed versions of Docker / Claude Code / Kimi / GLM /
Ollama (conditional per node) under the ssh card, with an "update available" badge.
- daemon: probe_tools() finds docker/claude/kimi-cli/ollama across candidate bin dirs,
extracts semver from --version, reports {"t":"node_tools",...} on connect + every 15m.
- migration node_tools + tool_latest; cm-db repo node_tools (upsert/list/latest).
- cm-api: fleet.rs NodeTools uplink → upsert; tool_versions.rs spawn_latest_checker
(24h, npm/pypi/github; docker display-only); GET /api/nodes/{id}/tools (glm mirrors
claude). Spawned in clawmates-server.
- frontend: NodeTools cards on each HostCard with the ↑latest badge.
Phase 2 (one-click update execution) intentionally deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
69c64ad676
commit
9263418fcb
@@ -122,6 +122,18 @@ async fn run(ws_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Probe installed dev-tool versions (docker/claude/kimi-cli/ollama) on a
|
||||||
|
// dedicated thread — the version commands shell out (blocking) — on connect,
|
||||||
|
// then every 15 min. The server flags updates against upstream.
|
||||||
|
let tools_tx = out_tx.clone();
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
let frame = json!({ "t": "node_tools", "tools": probe_tools() }).to_string();
|
||||||
|
if tools_tx.send(frame).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(900));
|
||||||
|
});
|
||||||
|
|
||||||
// Liveness: the server pings every 15s. If nothing inbound arrives for 40s
|
// Liveness: the server pings every 15s. If nothing inbound arrives for 40s
|
||||||
// the socket is dead — return so main() reconnects.
|
// the socket is dead — return so main() reconnects.
|
||||||
let mut idle_tick = tokio::time::interval(Duration::from_secs(5));
|
let mut idle_tick = tokio::time::interval(Duration::from_secs(5));
|
||||||
@@ -192,6 +204,82 @@ fn heartbeat(sys: &mut System) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe installed dev-tool versions: for each tool, find its binary across the
|
||||||
|
/// usual bin dirs and read `--version`. Returns `{ tool: "x.y.z", … }` for the
|
||||||
|
/// ones found. Probes `kimi-cli` (the real uv tool), not the `kimi` API wrapper.
|
||||||
|
fn probe_tools() -> Value {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
let dirs = [
|
||||||
|
format!("{home}/.local/bin"),
|
||||||
|
"/opt/homebrew/bin".to_string(),
|
||||||
|
"/usr/local/bin".to_string(),
|
||||||
|
"/usr/bin".to_string(),
|
||||||
|
"/bin".to_string(),
|
||||||
|
format!("{home}/.local/share/uv/tools/kimi-cli/bin"),
|
||||||
|
];
|
||||||
|
let mut out = serde_json::Map::new();
|
||||||
|
for tool in ["docker", "claude", "kimi-cli", "ollama"] {
|
||||||
|
for d in &dirs {
|
||||||
|
let p = std::path::Path::new(d).join(tool);
|
||||||
|
if p.exists() {
|
||||||
|
if let Some(v) = tool_version(&p) {
|
||||||
|
out.insert(tool.to_string(), Value::String(v));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `<bin> --version` with a 2s cap (off-thread, so a hung binary can't wedge
|
||||||
|
/// the prober) and extract the first semver from its output.
|
||||||
|
fn tool_version(bin: &std::path::Path) -> Option<String> {
|
||||||
|
let bin = bin.to_path_buf();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let text = std::process::Command::new(&bin)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.map(|o| {
|
||||||
|
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||||
|
s.push_str(&String::from_utf8_lossy(&o.stderr));
|
||||||
|
s
|
||||||
|
});
|
||||||
|
let _ = tx.send(text);
|
||||||
|
});
|
||||||
|
let text = rx.recv_timeout(Duration::from_secs(2)).ok().flatten()?;
|
||||||
|
extract_semver(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First `\d+\.\d+(\.\d+)?` run in `s` (e.g. "29.1.3" from "Docker version
|
||||||
|
/// 29.1.3, build …"; stops before a trailing `-0ubuntu…`).
|
||||||
|
fn extract_semver(s: &str) -> Option<String> {
|
||||||
|
let c: Vec<char> = s.chars().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < c.len() {
|
||||||
|
if c[i].is_ascii_digit() {
|
||||||
|
let start = i;
|
||||||
|
while i < c.len() && (c[i].is_ascii_digit() || c[i] == '.') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let mut end = i;
|
||||||
|
while end > start && c[end - 1] == '.' {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
let cand: String = c[start..end].iter().collect();
|
||||||
|
let parts: Vec<&str> = cand.split('.').collect();
|
||||||
|
if parts.len() >= 2 && parts.iter().all(|p| !p.is_empty()) {
|
||||||
|
return Some(cand);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Total + available bytes of the filesystem backing `/` (largest disk as a
|
/// Total + available bytes of the filesystem backing `/` (largest disk as a
|
||||||
/// fallback).
|
/// fallback).
|
||||||
fn root_disk() -> (i64, i64) {
|
fn root_disk() -> (i64, i64) {
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ async fn run() -> Result<(), String> {
|
|||||||
cm_api::beszel::spawn_poller(pool.clone(), std::time::Duration::from_secs(15));
|
cm_api::beszel::spawn_poller(pool.clone(), std::time::Duration::from_secs(15));
|
||||||
// Fleet automation: evaluate metric-threshold rules → drain/undrain/alert.
|
// Fleet automation: evaluate metric-threshold rules → drain/undrain/alert.
|
||||||
cm_api::node_rules::spawn_evaluator(pool.clone(), std::time::Duration::from_secs(20));
|
cm_api::node_rules::spawn_evaluator(pool.clone(), std::time::Duration::from_secs(20));
|
||||||
|
// Nightly: check upstream for newer dev-tool releases (claude/kimi/ollama).
|
||||||
|
cm_api::tool_versions::spawn_latest_checker(pool.clone(), std::time::Duration::from_secs(86_400));
|
||||||
|
|
||||||
// Hosted identity (Clerk / OIDC): pin the issuer and load its JWKS.
|
// Hosted identity (Clerk / OIDC): pin the issuer and load its JWKS.
|
||||||
let auth_verifier = match config.auth.mode {
|
let auth_verifier = match config.auth.mode {
|
||||||
|
|||||||
@@ -326,6 +326,10 @@ enum Uplink {
|
|||||||
},
|
},
|
||||||
#[serde(rename = "webrtc_failed")]
|
#[serde(rename = "webrtc_failed")]
|
||||||
WebRtcFailed { sid: u64 },
|
WebRtcFailed { sid: u64 },
|
||||||
|
#[serde(rename = "node_tools")]
|
||||||
|
NodeTools {
|
||||||
|
tools: std::collections::HashMap<String, String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -466,6 +470,10 @@ pub async fn run_channel(pool: PgPool, hub: Arc<NodeHub>, node_id: NodeId, socke
|
|||||||
let _ = s.send(json!({ "type": "webrtc_failed" }).to_string());
|
let _ = s.send(json!({ "type": "webrtc_failed" }).to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(Uplink::NodeTools { tools }) => {
|
||||||
|
let pairs: Vec<(String, String)> = tools.into_iter().collect();
|
||||||
|
let _ = cm_db::repo::node_tools::upsert(&pool, node_id, &pairs).await;
|
||||||
|
}
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod fleet;
|
|||||||
mod mcp_door;
|
mod mcp_door;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
|
pub mod tool_versions;
|
||||||
mod recursive_exec;
|
mod recursive_exec;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod runtime_provision;
|
mod runtime_provision;
|
||||||
@@ -138,6 +139,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/nodes/{id}/metrics",
|
"/api/nodes/{id}/metrics",
|
||||||
get(routes::beszel::node_metrics_get),
|
get(routes::beszel::node_metrics_get),
|
||||||
)
|
)
|
||||||
|
.route("/api/nodes/{id}/tools", get(routes::nodes::tools))
|
||||||
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
.route("/api/nodes/{id}", delete(routes::nodes::remove))
|
||||||
.route(
|
.route(
|
||||||
"/api/fleet/beszel",
|
"/api/fleet/beszel",
|
||||||
|
|||||||
@@ -174,6 +174,51 @@ pub async fn agent_ws(
|
|||||||
upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket))
|
upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/nodes/{id}/tools` — installed dev-tool versions for a node, the
|
||||||
|
/// latest upstream version, and an update flag (Phase 1: read-only). `glm` is
|
||||||
|
/// derived from `claude` (it runs Claude Code with a z.ai config).
|
||||||
|
pub async fn tools(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let node_id = NodeId::from(id);
|
||||||
|
nodes::get(&state.pool, node_id, user.workspace_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
let installed: std::collections::HashMap<String, String> =
|
||||||
|
cm_db::repo::node_tools::list(&state.pool, node_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let latest = cm_db::repo::node_tools::all_latest(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// (probe key, display name, ui key) in display order; `glm` mirrors `claude`.
|
||||||
|
let order = [
|
||||||
|
("docker", "Docker", "docker"),
|
||||||
|
("claude", "Claude Code", "claude"),
|
||||||
|
("kimi-cli", "Kimi", "kimi"),
|
||||||
|
("glm", "GLM (Claude Code)", "glm"),
|
||||||
|
("ollama", "Ollama", "ollama"),
|
||||||
|
];
|
||||||
|
let mut out: Vec<Value> = Vec::new();
|
||||||
|
for (probe, name, key) in order {
|
||||||
|
let src = if probe == "glm" { "claude" } else { probe };
|
||||||
|
if let Some(inst) = installed.get(src) {
|
||||||
|
let lat = latest.get(src).cloned();
|
||||||
|
let update = lat.as_ref().map(|l| l != inst).unwrap_or(false);
|
||||||
|
out.push(json!({
|
||||||
|
"name": name, "key": key, "installed": inst,
|
||||||
|
"latest": lat, "updateAvailable": update,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "tools": out })))
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket
|
/// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket
|
||||||
/// (the browser WS handshake can't carry a bearer header).
|
/// (the browser WS handshake can't carry a bearer header).
|
||||||
pub async fn terminal_ticket(
|
pub async fn terminal_ticket(
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! Nightly upstream-version checker for the fleet dev-tools. Each tool's latest
|
||||||
|
//! release is global (same everywhere), so we fetch once per tick and store it in
|
||||||
|
//! `tool_latest`; per-node "update available" is then `installed != latest`.
|
||||||
|
//! Docker is display-only (OS-package managed) — no upstream check.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use cm_db::repo::node_tools;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Fetch the latest version of each checkable tool and upsert `tool_latest`.
|
||||||
|
/// Stored under the same keys the daemon reports (`claude`, `kimi-cli`, `ollama`).
|
||||||
|
async fn check(pool: &PgPool, client: &reqwest::Client) {
|
||||||
|
// Claude Code — the npm package's latest dist-tag.
|
||||||
|
if let Some(v) = fetch_field(
|
||||||
|
client,
|
||||||
|
"https://registry.npmjs.org/@anthropic-ai/claude-code/latest",
|
||||||
|
&["version"],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = node_tools::set_latest(pool, "claude", &v).await;
|
||||||
|
}
|
||||||
|
// Kimi CLI — PyPI (it's a uv/python tool).
|
||||||
|
if let Some(v) = fetch_field(client, "https://pypi.org/pypi/kimi-cli/json", &["info", "version"]).await {
|
||||||
|
let _ = node_tools::set_latest(pool, "kimi-cli", &v).await;
|
||||||
|
}
|
||||||
|
// Ollama — latest GitHub release tag (strip leading "v").
|
||||||
|
if let Some(v) = fetch_field(
|
||||||
|
client,
|
||||||
|
"https://api.github.com/repos/ollama/ollama/releases/latest",
|
||||||
|
&["tag_name"],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = node_tools::set_latest(pool, "ollama", v.trim_start_matches('v')).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET `url` as JSON and read a (possibly nested) string field by `path`.
|
||||||
|
async fn fetch_field(client: &reqwest::Client, url: &str, path: &[&str]) -> Option<String> {
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.header("User-Agent", "clawmates")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut v: Value = resp.json().await.ok()?;
|
||||||
|
for key in path {
|
||||||
|
v = v.get(key)?.clone();
|
||||||
|
}
|
||||||
|
v.as_str().map(|s| s.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the nightly latest-version checker (runs once on boot, then every
|
||||||
|
/// `interval`).
|
||||||
|
pub fn spawn_latest_checker(pool: PgPool, interval: Duration) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut tick = tokio::time::interval(interval);
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
check(&pool, &client).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod fleet_tailscale;
|
|||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod node_metrics;
|
pub mod node_metrics;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
|
pub mod node_tools;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//! Per-node installed dev-tool versions (probed by the daemon) and the latest
|
||||||
|
//! upstream version per tool (filled by a nightly checker). Drives the fleet
|
||||||
|
//! UI's "update available" badges.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use cm_domain::NodeId;
|
||||||
|
use sqlx::{PgPool, Row};
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
/// Replace a node's reported tool versions with `tools` (tool, version).
|
||||||
|
pub async fn upsert(pool: &PgPool, node_id: NodeId, tools: &[(String, String)]) -> Result<(), DbError> {
|
||||||
|
for (tool, version) in tools {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO node_tools (node_id, tool, version, updated_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (node_id, tool) DO UPDATE SET version = excluded.version, updated_at = now()",
|
||||||
|
)
|
||||||
|
.bind(node_id.as_uuid())
|
||||||
|
.bind(tool)
|
||||||
|
.bind(version)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node's installed tool versions as `(tool, version)`.
|
||||||
|
pub async fn list(pool: &PgPool, node_id: NodeId) -> Result<Vec<(String, String)>, DbError> {
|
||||||
|
let rows = sqlx::query("SELECT tool, version FROM node_tools WHERE node_id = $1")
|
||||||
|
.bind(node_id.as_uuid())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.get::<String, _>("tool"), r.get::<String, _>("version")))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the latest upstream version for a tool.
|
||||||
|
pub async fn set_latest(pool: &PgPool, tool: &str, latest: &str) -> Result<(), DbError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO tool_latest (tool, latest_version, checked_at)
|
||||||
|
VALUES ($1, $2, now())
|
||||||
|
ON CONFLICT (tool) DO UPDATE SET latest_version = excluded.latest_version, checked_at = now()",
|
||||||
|
)
|
||||||
|
.bind(tool)
|
||||||
|
.bind(latest)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All known latest versions, keyed by tool.
|
||||||
|
pub async fn all_latest(pool: &PgPool) -> Result<HashMap<String, String>, DbError> {
|
||||||
|
let rows = sqlx::query("SELECT tool, latest_version FROM tool_latest")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.get::<String, _>("tool"), r.get::<String, _>("latest_version")))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -122,6 +122,30 @@ function Mini({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NodeTool { name: string; key: string; installed: string; latest: string | null; updateAvailable: boolean }
|
||||||
|
|
||||||
|
/** Installed dev-tool versions for a node (Docker / Claude Code / Kimi / GLM /
|
||||||
|
* Ollama), with an "update available" badge when the nightly check finds a newer
|
||||||
|
* release. Read-only (Phase 1); the one-click update lands next to the badge later. */
|
||||||
|
function NodeTools({ nodeId, online }: { nodeId: string; online: boolean }) {
|
||||||
|
const { data } = useFetchJson<{ tools: NodeTool[] }>(online ? `/api/nodes/${nodeId}/tools` : null);
|
||||||
|
const tools = data?.tools ?? [];
|
||||||
|
if (!online || tools.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
{tools.map((t) => (
|
||||||
|
<div key={t.key} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 10px", borderRadius: 8, background: "#070708", border: `1px solid ${t.updateAvailable ? "rgba(232,196,106,.3)" : "rgba(255,255,255,.06)"}` }}>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#9a9aa2", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.name}</span>
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 10.5, color: "#cfcfd5" }}>{t.installed}</span>
|
||||||
|
{t.updateAvailable ? (
|
||||||
|
<span title={`update available → ${t.latest}`} style={{ fontFamily: mono, fontSize: 9, color: "#e8b465", background: "rgba(232,196,106,.12)", border: "1px solid rgba(232,196,106,.3)", borderRadius: 5, padding: "1px 6px" }}>↑ {t.latest}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HostCard({ node, onRemoved, onMonitor }: { node: FleetNode; onRemoved: () => void; onMonitor: (id: string, name: string) => void }) {
|
function HostCard({ node, onRemoved, onMonitor }: { node: FleetNode; onRemoved: () => void; onMonitor: (id: string, name: string) => void }) {
|
||||||
const [, setParams] = useQueryStates(panelParsers, { shallow: true });
|
const [, setParams] = useQueryStates(panelParsers, { shallow: true });
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -196,6 +220,8 @@ function HostCard({ node, onRemoved, onMonitor }: { node: FleetNode; onRemoved:
|
|||||||
<span style={{ fontFamily: mono, fontSize: 10, color: "#e8b465" }}>waiting for daemon to dial home…</span>
|
<span style={{ fontFamily: mono, fontSize: 10, color: "#e8b465" }}>waiting for daemon to dial home…</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<NodeTools nodeId={node.id} online={st === "online"} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Per-node installed dev-tool versions (probed by the daemon) + the latest
|
||||||
|
-- upstream version per tool (a nightly checker), so the fleet UI can flag updates.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS node_tools (
|
||||||
|
node_id UUID NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
|
||||||
|
tool TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (node_id, tool)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tool_latest (
|
||||||
|
tool TEXT PRIMARY KEY,
|
||||||
|
latest_version TEXT NOT NULL,
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user