Wire all dashboard buttons: auth downloads, get_log feedback, action tests.
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -92,9 +92,9 @@ export const api = {
|
||||
sendAgentCommand: (
|
||||
id: 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',
|
||||
body: JSON.stringify({ action, ...payload }),
|
||||
}),
|
||||
|
||||
20
server/web/src/api/download.ts
Normal file
20
server/web/src/api/download.ts
Normal 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);
|
||||
}
|
||||
32
server/web/src/components/AuthDownloadButton.tsx
Normal file
32
server/web/src/components/AuthDownloadButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
51
server/web/src/help/remoteActions.test.ts
Normal file
51
server/web/src/help/remoteActions.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
<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
|
||||
</a>
|
||||
</AuthDownloadButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1053,8 +1058,16 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
<LanDownloadQR url={downloadUrl} />
|
||||
<a className="btn btn-outline" href={api.buildDownloadUrl(build.id)}>Download</a>
|
||||
<a className="btn btn-outline" href={api.buildUninstallUrl(build.id)}>Uninstall script</a>
|
||||
<a className="btn btn-outline" href={api.buildDownloadUrl(build.id)} download>
|
||||
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)}>
|
||||
Re-forge
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user