Implement robust auto-update system for GuruConnect agent

Features:
- Agent checks for updates periodically (hourly) during idle
- Admin can trigger immediate updates via dashboard "Update Agent" button
- Silent updates with in-place binary replacement (no reboot required)
- SHA-256 checksum verification before installation
- Semantic version comparison

Server changes:
- New releases table for tracking available versions
- GET /api/version endpoint for agent polling (unauthenticated)
- POST /api/machines/:id/update endpoint for admin push updates
- Release management API (/api/releases CRUD)
- Track agent_version in machine status

Agent changes:
- New update.rs module with download/verify/install/restart logic
- Handle ADMIN_UPDATE WebSocket command for push updates
- --post-update flag for cleanup after successful update
- Periodic update check in idle loop (persistent agents only)
- agent_version included in AgentStatus messages

Dashboard changes:
- Version display in machine detail panel
- "Update Agent" button for each connected machine

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 09:31:23 -07:00
parent 7df824c2ca
commit 4e5328fe4a
15 changed files with 1399 additions and 18 deletions

View File

@@ -0,0 +1,35 @@
-- Migration: 003_auto_update.sql
-- Purpose: Add auto-update infrastructure (releases table and machine version tracking)
-- ============================================================================
-- Releases Table
-- ============================================================================
-- Track available agent releases
CREATE TABLE IF NOT EXISTS releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version VARCHAR(32) NOT NULL UNIQUE,
download_url TEXT NOT NULL,
checksum_sha256 VARCHAR(64) NOT NULL,
release_notes TEXT,
is_stable BOOLEAN NOT NULL DEFAULT false,
is_mandatory BOOLEAN NOT NULL DEFAULT false,
min_version VARCHAR(32), -- Minimum version that can update to this
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for finding latest stable release
CREATE INDEX IF NOT EXISTS idx_releases_stable ON releases(is_stable, created_at DESC);
-- ============================================================================
-- Machine Version Tracking
-- ============================================================================
-- Add version tracking columns to existing machines table
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS agent_version VARCHAR(32);
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS update_status VARCHAR(32);
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS last_update_check TIMESTAMPTZ;
-- Index for finding machines needing updates
CREATE INDEX IF NOT EXISTS idx_machines_version ON connect_machines(agent_version);
CREATE INDEX IF NOT EXISTS idx_machines_update_status ON connect_machines(update_status);

View File

@@ -2,6 +2,7 @@
pub mod auth;
pub mod users;
pub mod releases;
use axum::{
extract::{Path, State, Query},
@@ -48,6 +49,7 @@ pub struct SessionInfo {
pub is_elevated: bool,
pub uptime_secs: i64,
pub display_count: i32,
pub agent_version: Option<String>,
}
impl From<crate::session::Session> for SessionInfo {
@@ -67,6 +69,7 @@ impl From<crate::session::Session> for SessionInfo {
is_elevated: s.is_elevated,
uptime_secs: s.uptime_secs,
display_count: s.display_count,
agent_version: s.agent_version,
}
}
}

375
server/src/api/releases.rs Normal file
View File

@@ -0,0 +1,375 @@
//! Release management API endpoints (admin only)
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use crate::auth::AdminUser;
use crate::db;
use crate::AppState;
use super::auth::ErrorResponse;
/// Release info response
#[derive(Debug, Serialize)]
pub struct ReleaseInfo {
pub id: String,
pub version: String,
pub download_url: String,
pub checksum_sha256: String,
pub release_notes: Option<String>,
pub is_stable: bool,
pub is_mandatory: bool,
pub min_version: Option<String>,
pub created_at: String,
}
impl From<db::Release> for ReleaseInfo {
fn from(r: db::Release) -> Self {
Self {
id: r.id.to_string(),
version: r.version,
download_url: r.download_url,
checksum_sha256: r.checksum_sha256,
release_notes: r.release_notes,
is_stable: r.is_stable,
is_mandatory: r.is_mandatory,
min_version: r.min_version,
created_at: r.created_at.to_rfc3339(),
}
}
}
/// Version info for unauthenticated endpoint
#[derive(Debug, Serialize)]
pub struct VersionInfo {
pub latest_version: String,
pub download_url: String,
pub checksum_sha256: String,
pub is_mandatory: bool,
pub release_notes: Option<String>,
}
/// Create release request
#[derive(Debug, Deserialize)]
pub struct CreateReleaseRequest {
pub version: String,
pub download_url: String,
pub checksum_sha256: String,
pub release_notes: Option<String>,
pub is_stable: bool,
pub is_mandatory: bool,
pub min_version: Option<String>,
}
/// Update release request
#[derive(Debug, Deserialize)]
pub struct UpdateReleaseRequest {
pub release_notes: Option<String>,
pub is_stable: bool,
pub is_mandatory: bool,
}
/// GET /api/version - Get latest version info (no auth required)
pub async fn get_version(
State(state): State<AppState>,
) -> Result<Json<VersionInfo>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let release = db::get_latest_stable_release(db.pool())
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to fetch version".to_string(),
}),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "No stable release available".to_string(),
}),
)
})?;
Ok(Json(VersionInfo {
latest_version: release.version,
download_url: release.download_url,
checksum_sha256: release.checksum_sha256,
is_mandatory: release.is_mandatory,
release_notes: release.release_notes,
}))
}
/// GET /api/releases - List all releases (admin only)
pub async fn list_releases(
State(state): State<AppState>,
_admin: AdminUser,
) -> Result<Json<Vec<ReleaseInfo>>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let releases = db::get_all_releases(db.pool())
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to fetch releases".to_string(),
}),
)
})?;
Ok(Json(releases.into_iter().map(ReleaseInfo::from).collect()))
}
/// POST /api/releases - Create new release (admin only)
pub async fn create_release(
State(state): State<AppState>,
_admin: AdminUser,
Json(request): Json<CreateReleaseRequest>,
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
// Validate version format (basic check)
if request.version.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Version cannot be empty".to_string(),
}),
));
}
// Validate checksum format (64 hex chars for SHA-256)
if request.checksum_sha256.len() != 64
|| !request.checksum_sha256.chars().all(|c| c.is_ascii_hexdigit())
{
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Invalid SHA-256 checksum format (expected 64 hex characters)".to_string(),
}),
));
}
// Validate URL
if !request.download_url.starts_with("https://") {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Download URL must use HTTPS".to_string(),
}),
));
}
// Check if version already exists
if db::get_release_by_version(db.pool(), &request.version)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Database error".to_string(),
}),
)
})?
.is_some()
{
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: "Version already exists".to_string(),
}),
));
}
let release = db::create_release(
db.pool(),
&request.version,
&request.download_url,
&request.checksum_sha256,
request.release_notes.as_deref(),
request.is_stable,
request.is_mandatory,
request.min_version.as_deref(),
)
.await
.map_err(|e| {
tracing::error!("Failed to create release: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to create release".to_string(),
}),
)
})?;
tracing::info!(
"Created release: {} (stable={}, mandatory={})",
release.version,
release.is_stable,
release.is_mandatory
);
Ok(Json(ReleaseInfo::from(release)))
}
/// GET /api/releases/:version - Get release by version (admin only)
pub async fn get_release(
State(state): State<AppState>,
_admin: AdminUser,
Path(version): Path<String>,
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let release = db::get_release_by_version(db.pool(), &version)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Database error".to_string(),
}),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "Release not found".to_string(),
}),
)
})?;
Ok(Json(ReleaseInfo::from(release)))
}
/// PUT /api/releases/:version - Update release (admin only)
pub async fn update_release(
State(state): State<AppState>,
_admin: AdminUser,
Path(version): Path<String>,
Json(request): Json<UpdateReleaseRequest>,
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let release = db::update_release(
db.pool(),
&version,
request.release_notes.as_deref(),
request.is_stable,
request.is_mandatory,
)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to update release".to_string(),
}),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "Release not found".to_string(),
}),
)
})?;
tracing::info!(
"Updated release: {} (stable={}, mandatory={})",
release.version,
release.is_stable,
release.is_mandatory
);
Ok(Json(ReleaseInfo::from(release)))
}
/// DELETE /api/releases/:version - Delete release (admin only)
pub async fn delete_release(
State(state): State<AppState>,
_admin: AdminUser,
Path(version): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let db = state.db.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "Database not available".to_string(),
}),
)
})?;
let deleted = db::delete_release(db.pool(), &version)
.await
.map_err(|e| {
tracing::error!("Database error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "Failed to delete release".to_string(),
}),
)
})?;
if deleted {
tracing::info!("Deleted release: {}", version);
Ok(StatusCode::NO_CONTENT)
} else {
Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "Release not found".to_string(),
}),
))
}
}

View File

@@ -8,6 +8,7 @@ pub mod sessions;
pub mod events;
pub mod support_codes;
pub mod users;
pub mod releases;
use anyhow::Result;
use sqlx::postgres::PgPoolOptions;
@@ -19,6 +20,7 @@ pub use sessions::*;
pub use events::*;
pub use support_codes::*;
pub use users::*;
pub use releases::*;
/// Database connection pool wrapper
#[derive(Clone)]

179
server/src/db/releases.rs Normal file
View File

@@ -0,0 +1,179 @@
//! Release management database operations
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Release record from database
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Release {
pub id: Uuid,
pub version: String,
pub download_url: String,
pub checksum_sha256: String,
pub release_notes: Option<String>,
pub is_stable: bool,
pub is_mandatory: bool,
pub min_version: Option<String>,
pub created_at: DateTime<Utc>,
}
/// Create a new release
pub async fn create_release(
pool: &PgPool,
version: &str,
download_url: &str,
checksum_sha256: &str,
release_notes: Option<&str>,
is_stable: bool,
is_mandatory: bool,
min_version: Option<&str>,
) -> Result<Release, sqlx::Error> {
sqlx::query_as::<_, Release>(
r#"
INSERT INTO releases (version, download_url, checksum_sha256, release_notes, is_stable, is_mandatory, min_version)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
)
.bind(version)
.bind(download_url)
.bind(checksum_sha256)
.bind(release_notes)
.bind(is_stable)
.bind(is_mandatory)
.bind(min_version)
.fetch_one(pool)
.await
}
/// Get the latest stable release
pub async fn get_latest_stable_release(pool: &PgPool) -> Result<Option<Release>, sqlx::Error> {
sqlx::query_as::<_, Release>(
r#"
SELECT * FROM releases
WHERE is_stable = true
ORDER BY created_at DESC
LIMIT 1
"#,
)
.fetch_optional(pool)
.await
}
/// Get a release by version
pub async fn get_release_by_version(
pool: &PgPool,
version: &str,
) -> Result<Option<Release>, sqlx::Error> {
sqlx::query_as::<_, Release>("SELECT * FROM releases WHERE version = $1")
.bind(version)
.fetch_optional(pool)
.await
}
/// Get all releases (ordered by creation date, newest first)
pub async fn get_all_releases(pool: &PgPool) -> Result<Vec<Release>, sqlx::Error> {
sqlx::query_as::<_, Release>("SELECT * FROM releases ORDER BY created_at DESC")
.fetch_all(pool)
.await
}
/// Update a release
pub async fn update_release(
pool: &PgPool,
version: &str,
release_notes: Option<&str>,
is_stable: bool,
is_mandatory: bool,
) -> Result<Option<Release>, sqlx::Error> {
sqlx::query_as::<_, Release>(
r#"
UPDATE releases SET
release_notes = COALESCE($2, release_notes),
is_stable = $3,
is_mandatory = $4
WHERE version = $1
RETURNING *
"#,
)
.bind(version)
.bind(release_notes)
.bind(is_stable)
.bind(is_mandatory)
.fetch_optional(pool)
.await
}
/// Delete a release
pub async fn delete_release(pool: &PgPool, version: &str) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM releases WHERE version = $1")
.bind(version)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Update machine version info
pub async fn update_machine_version(
pool: &PgPool,
agent_id: &str,
agent_version: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE connect_machines SET
agent_version = $1,
last_update_check = NOW()
WHERE agent_id = $2
"#,
)
.bind(agent_version)
.bind(agent_id)
.execute(pool)
.await?;
Ok(())
}
/// Update machine update status
pub async fn update_machine_update_status(
pool: &PgPool,
agent_id: &str,
update_status: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE connect_machines SET
update_status = $1
WHERE agent_id = $2
"#,
)
.bind(update_status)
.bind(agent_id)
.execute(pool)
.await?;
Ok(())
}
/// Get machines that need updates (version < latest stable)
pub async fn get_machines_needing_update(
pool: &PgPool,
latest_version: &str,
) -> Result<Vec<String>, sqlx::Error> {
// Note: This does simple string comparison which works for semver if formatted consistently
// For production, you might want a more robust version comparison
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT agent_id FROM connect_machines
WHERE status = 'online'
AND is_persistent = true
AND (agent_version IS NULL OR agent_version < $1)
"#,
)
.bind(latest_version)
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}

View File

@@ -223,6 +223,15 @@ async fn main() -> Result<()> {
.route("/api/machines/:agent_id", get(get_machine))
.route("/api/machines/:agent_id", delete(delete_machine))
.route("/api/machines/:agent_id/history", get(get_machine_history))
.route("/api/machines/:agent_id/update", post(trigger_machine_update))
// REST API - Releases and Version
.route("/api/version", get(api::releases::get_version)) // No auth - for agent polling
.route("/api/releases", get(api::releases::list_releases))
.route("/api/releases", post(api::releases::create_release))
.route("/api/releases/:version", get(api::releases::get_release))
.route("/api/releases/:version", put(api::releases::update_release))
.route("/api/releases/:version", delete(api::releases::delete_release))
// HTML page routes (clean URLs)
.route("/login", get(serve_login))
@@ -472,6 +481,62 @@ async fn delete_machine(
}))
}
// Update trigger request
#[derive(Deserialize)]
struct TriggerUpdateRequest {
/// Target version (optional, defaults to latest stable)
version: Option<String>,
}
/// Trigger update on a specific machine
async fn trigger_machine_update(
_user: AuthenticatedUser, // Require authentication
State(state): State<AppState>,
Path(agent_id): Path<String>,
Json(request): Json<TriggerUpdateRequest>,
) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
let db = state.db.as_ref()
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
// Get the target release (either specified or latest stable)
let release = if let Some(version) = request.version {
db::releases::get_release_by_version(db.pool(), &version).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
.ok_or((StatusCode::NOT_FOUND, "Release version not found"))?
} else {
db::releases::get_latest_stable_release(db.pool()).await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
.ok_or((StatusCode::NOT_FOUND, "No stable release available"))?
};
// Find session for this agent
let session = state.sessions.get_session_by_agent(&agent_id).await
.ok_or((StatusCode::NOT_FOUND, "Agent not found or offline"))?;
if !session.is_online {
return Err((StatusCode::BAD_REQUEST, "Agent is offline"));
}
// Send update command via WebSocket
// For now, we send admin command - later we'll include UpdateInfo in the message
let sent = state.sessions.send_admin_command(
session.id,
proto::AdminCommandType::AdminUpdate,
&format!("Update to version {}", release.version),
).await;
if sent {
info!("Sent update command to agent {} (version {})", agent_id, release.version);
// Update machine update status in database
let _ = db::releases::update_machine_update_status(db.pool(), &agent_id, "downloading").await;
Ok((StatusCode::OK, "Update command sent"))
} else {
Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to send update command"))
}
}
// Static page handlers
async fn serve_login() -> impl IntoResponse {
match tokio::fs::read_to_string("static/login.html").await {

View File

@@ -308,6 +308,11 @@ async fn handle_agent_connection(
}
Some(proto::message::Payload::AgentStatus(status)) => {
// Update session with agent status
let agent_version = if status.agent_version.is_empty() {
None
} else {
Some(status.agent_version.clone())
};
sessions_status.update_agent_status(
session_id,
Some(status.os_version.clone()),
@@ -315,9 +320,16 @@ async fn handle_agent_connection(
status.uptime_secs,
status.display_count,
status.is_streaming,
agent_version.clone(),
).await;
info!("Agent status update: {} - streaming={}, uptime={}s",
status.hostname, status.is_streaming, status.uptime_secs);
// Update version in database if present
if let (Some(ref db), Some(ref version)) = (&db, &agent_version) {
let _ = crate::db::releases::update_machine_version(db.pool(), &agent_id, version).await;
}
info!("Agent status update: {} - streaming={}, uptime={}s, version={:?}",
status.hostname, status.is_streaming, status.uptime_secs, agent_version);
}
Some(proto::message::Payload::Heartbeat(_)) => {
// Update heartbeat timestamp

View File

@@ -47,6 +47,7 @@ pub struct Session {
pub is_elevated: bool,
pub uptime_secs: i64,
pub display_count: i32,
pub agent_version: Option<String>, // Agent software version
}
/// Channel for sending frames from agent to viewers
@@ -138,6 +139,7 @@ impl SessionManager {
is_elevated: false,
uptime_secs: 0,
display_count: 1,
agent_version: None,
};
let session_data = SessionData {
@@ -167,6 +169,7 @@ impl SessionManager {
uptime_secs: i64,
display_count: i32,
is_streaming: bool,
agent_version: Option<String>,
) {
let mut sessions = self.sessions.write().await;
if let Some(session_data) = sessions.get_mut(&session_id) {
@@ -179,6 +182,9 @@ impl SessionManager {
session_data.info.is_elevated = is_elevated;
session_data.info.uptime_secs = uptime_secs;
session_data.info.display_count = display_count;
if let Some(version) = agent_version {
session_data.info.agent_version = Some(version);
}
}
}
@@ -454,6 +460,7 @@ impl SessionManager {
is_elevated: false,
uptime_secs: 0,
display_count: 1,
agent_version: None,
};
// Create placeholder channels (will be replaced on reconnect)

View File

@@ -908,6 +908,7 @@
const statusText = m.is_online ? 'Online' : 'Offline';
const connectDisabled = m.is_online ? '' : 'disabled';
const connectTitle = m.is_online ? '' : 'title="Agent is offline"';
const versionText = m.agent_version || 'Unknown';
container.innerHTML =
'<div class="detail-section">' +
@@ -915,6 +916,7 @@
'<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value" style="color: ' + statusColor + ';">' + statusText + '</span></div>' +
'<div class="detail-row"><span class="detail-label">Agent ID</span><span class="detail-value">' + m.agent_id.slice(0,8) + '...</span></div>' +
'<div class="detail-row"><span class="detail-label">Session ID</span><span class="detail-value">' + m.id.slice(0,8) + '...</span></div>' +
'<div class="detail-row"><span class="detail-label">Version</span><span class="detail-value">' + escapeHtml(versionText) + '</span></div>' +
'<div class="detail-row"><span class="detail-label">Connected</span><span class="detail-value">' + started + '</span></div>' +
'<div class="detail-row"><span class="detail-label">Viewers</span><span class="detail-value">' + m.viewer_count + '</span></div>' +
'</div>' +
@@ -923,6 +925,7 @@
'<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')" ' + connectDisabled + ' ' + connectTitle + '>Connect</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="openChat(\'' + m.id + '\', \'' + (m.agent_name || 'Client').replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Chat</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" disabled>Transfer Files</button>' +
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="triggerUpdate(\'' + m.agent_id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Update Agent</button>' +
'<button class="btn btn-outline" style="width: 100%; color: hsl(0, 62.8%, 50%);" onclick="disconnectMachine(\'' + m.id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')">Disconnect</button>' +
'</div>';
}
@@ -1043,6 +1046,25 @@
}
}
async function triggerUpdate(agentId, machineName) {
if (!confirm("Send update command to " + machineName + "?\n\nThe agent will download and install the latest version, then restart.")) return;
try {
const response = await fetch("/api/machines/" + agentId + "/update", {
method: "POST",
headers: { "Content-Type": "application/json" }
});
if (response.ok) {
const result = await response.json();
alert("Update command sent to " + machineName + ".\n\n" + (result.message || "Agent will update shortly."));
} else {
const errorText = await response.text();
alert("Failed to trigger update: " + errorText);
}
} catch (err) {
alert("Error triggering update: " + err.message);
}
}
// Refresh machines every 5 seconds
loadMachines();
setInterval(loadMachines, 5000);