Wire all dashboard buttons: auth downloads, get_log feedback, action tests.
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
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);
|
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';
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user