Wire all dashboard buttons: auth downloads, get_log feedback, action tests.

This commit is contained in:
drjones
2026-05-28 19:56:11 -07:00
parent 9e26c4babb
commit fda72041f0
8 changed files with 143 additions and 8 deletions

View File

@@ -254,6 +254,11 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
"lines": tailLines, "lines": tailLines,
}) })
_ = c.write(Message{Type: "log_tail", Payload: payload}) _ = 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": case "exec":
if command == "" { if command == "" {
c.sendCommandResult(action, false, "no command provided") c.sendCommandResult(action, false, "no command provided")

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"time"
"crypto-miner-server/internal/alerts" "crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db" "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) http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
return return
} }
content := f.ws.GetAgentLog(id)
if r.URL.Query().Get("refresh") == "1" { if r.URL.Query().Get("refresh") == "1" {
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300}) _ = 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{}{ writeJSON(w, map[string]interface{}{
"agent_id": id, "agent_id": id,
"content": f.ws.GetAgentLog(id), "content": content,
}) })
} }

View File

@@ -92,9 +92,9 @@ export const api = {
sendAgentCommand: ( sendAgentCommand: (
id: string, id: string,
action: string, action: string,
payload?: { tail_lines?: number; command?: string; path?: string; data?: string } payload?: Record<string, unknown>
) => ) =>
fetchJSON<{ success: boolean }>(`/agents/${id}/command`, { fetchJSON<{ success: boolean; error?: string }>(`/agents/${id}/command`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ action, ...payload }), body: JSON.stringify({ action, ...payload }),
}), }),

View File

@@ -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<void> {
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);
}

View File

@@ -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 (
<button type="button" className={className} onClick={handleClick} disabled={busy}>
{busy ? '…' : children}
</button>
);
}

View File

@@ -74,7 +74,12 @@ export default function AgentRemoteActions({
setBusy(action); setBusy(action);
try { try {
if (!compact) addLog(`> Executing ${action}...`); 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); onCommandSent?.(action);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Command failed'; const msg = err instanceof Error ? err.message : 'Command failed';

View File

@@ -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);
}
});
});

View File

@@ -13,6 +13,7 @@ import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '.
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints'; import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR'; import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
import AuthDownloadButton from '../components/AuthDownloadButton';
import '../components/Fleet/FleetPanels.css'; import '../components/Fleet/FleetPanels.css';
import './Pages.css'; import './Pages.css';
@@ -1007,9 +1008,13 @@ export default function BuilderPage() {
{!lastBuild.uninstall_export_path && ( {!lastBuild.uninstall_export_path && (
<code className="path-display">{lastBuild.uninstall_path}</code> <code className="path-display">{lastBuild.uninstall_path}</code>
)} )}
<a className="btn btn-outline" href={lastBuild.uninstall_download_url} download> <AuthDownloadButton
apiPath={lastBuild.uninstall_download_url}
filename={lastBuild.uninstall_file_name || 'uninstall.ps1'}
className="btn btn-outline"
>
Download uninstall script Download uninstall script
</a> </AuthDownloadButton>
</> </>
)} )}
</div> </div>
@@ -1053,8 +1058,16 @@ export default function BuilderPage() {
</div> </div>
</div> </div>
<LanDownloadQR url={downloadUrl} /> <LanDownloadQR url={downloadUrl} />
<a className="btn btn-outline" href={api.buildDownloadUrl(build.id)}>Download</a> <a className="btn btn-outline" href={api.buildDownloadUrl(build.id)} download>
<a className="btn btn-outline" href={api.buildUninstallUrl(build.id)}>Uninstall script</a> Download
</a>
<AuthDownloadButton
apiPath={api.buildUninstallUrl(build.id)}
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
className="btn btn-outline"
>
Uninstall script
</AuthDownloadButton>
<button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}> <button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}>
Re-forge Re-forge
</button> </button>