diff --git a/agent/client/client.go b/agent/client/client.go index 8d75a12..7a085d8 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -254,6 +254,11 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, "lines": tailLines, }) _ = c.write(Message{Type: "log_tail", Payload: payload}) + preview := content + if len(preview) > 12000 { + preview = preview[len(preview)-12000:] + } + c.sendCommandResult(action, true, preview) case "exec": if command == "" { c.sendCommandResult(action, false, "no command provided") diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 96a3f52..97b9c80 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "strconv" + "time" "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/db" @@ -67,12 +68,20 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) { http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable) return } + content := f.ws.GetAgentLog(id) if r.URL.Query().Get("refresh") == "1" { _ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300}) + for i := 0; i < 12; i++ { + time.Sleep(150 * time.Millisecond) + if c := f.ws.GetAgentLog(id); c != "" { + content = c + break + } + } } writeJSON(w, map[string]interface{}{ "agent_id": id, - "content": f.ws.GetAgentLog(id), + "content": content, }) } diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 110d414..5858ef2 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -92,9 +92,9 @@ export const api = { sendAgentCommand: ( id: string, action: string, - payload?: { tail_lines?: number; command?: string; path?: string; data?: string } + payload?: Record ) => - fetchJSON<{ success: boolean }>(`/agents/${id}/command`, { + fetchJSON<{ success: boolean; error?: string }>(`/agents/${id}/command`, { method: 'POST', body: JSON.stringify({ action, ...payload }), }), diff --git a/server/web/src/api/download.ts b/server/web/src/api/download.ts new file mode 100644 index 0000000..e6cf9b7 --- /dev/null +++ b/server/web/src/api/download.ts @@ -0,0 +1,20 @@ +import { authHeaders } from './auth'; + +/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */ +export async function downloadAuthedFile(apiPath: string, filename: string): Promise { + const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`; + const res = await fetch(path, { headers: authHeaders() }); + if (!res.ok) { + const err = await res.text(); + throw new Error(err || `Download failed (${res.status})`); + } + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(objectUrl); +} diff --git a/server/web/src/components/AuthDownloadButton.tsx b/server/web/src/components/AuthDownloadButton.tsx new file mode 100644 index 0000000..23674e5 --- /dev/null +++ b/server/web/src/components/AuthDownloadButton.tsx @@ -0,0 +1,32 @@ +import { useState, type ReactNode } from 'react'; +import { downloadAuthedFile } from '../api/download'; + +type Props = { + apiPath: string; + filename: string; + className?: string; + children: ReactNode; +}; + +export default function AuthDownloadButton({ apiPath, filename, className, children }: Props) { + const [busy, setBusy] = useState(false); + + const handleClick = async (e: React.MouseEvent) => { + e.preventDefault(); + if (busy) return; + setBusy(true); + try { + await downloadAuthedFile(apiPath, filename); + } catch (err) { + window.alert(err instanceof Error ? err.message : 'Download failed'); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index 1c4c2bf..83d4943 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -74,7 +74,12 @@ export default function AgentRemoteActions({ setBusy(action); try { if (!compact) addLog(`> Executing ${action}...`); - await api.sendAgentCommand(agentId, action, args); + const res = await api.sendAgentCommand(agentId, action, args); + if (res.success === false) { + addLog(`Command rejected: ${res.error ?? 'unknown error'}`); + return; + } + if (!compact) addLog(`> ${action} sent to ${agentId === 'all' ? 'fleet' : agentName}`); onCommandSent?.(action); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Command failed'; diff --git a/server/web/src/help/remoteActions.test.ts b/server/web/src/help/remoteActions.test.ts new file mode 100644 index 0000000..98ec74c --- /dev/null +++ b/server/web/src/help/remoteActions.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; + +/** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */ +const UI_REMOTE_ACTIONS = [ + 'pause', + 'resume', + 'stop', + 'uninstall', + 'restart', + 'screenshot', + 'ps', + 'sysinfo', + 'netstat', + 'users', + 'software', + 'get_log', + 'powershell', + 'upload', +] as const; + +/** Implemented in agent/client/client.go handleCommand switch. */ +const AGENT_HANDLED = new Set([ + 'pause', + 'resume', + 'restart', + 'stop', + 'kill', + 'uninstall', + 'get_log', + 'exec', + 'powershell', + 'upload', + 'download', + 'ps', + 'netstat', + 'users', + 'software', + 'screenshot', + 'sysinfo', + 'ipconfig', + 'clipboard', + 'wifi', +]); + +describe('remote action wiring', () => { + it('every UI remote button maps to an agent handler', () => { + for (const action of UI_REMOTE_ACTIONS) { + expect(AGENT_HANDLED.has(action)).toBe(true); + } + }); +}); diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 95a2c55..8295be3 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -13,6 +13,7 @@ import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '. import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints'; import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import { LanDownloadQR } from '../components/Fleet/LanDownloadQR'; +import AuthDownloadButton from '../components/AuthDownloadButton'; import '../components/Fleet/FleetPanels.css'; import './Pages.css'; @@ -1007,9 +1008,13 @@ export default function BuilderPage() { {!lastBuild.uninstall_export_path && ( {lastBuild.uninstall_path} )} - + Download uninstall script - + )} @@ -1053,8 +1058,16 @@ export default function BuilderPage() { - Download - Uninstall script + + Download + + + Uninstall script +