sync: Auto-sync from DESKTOP-0O8A1RL at 2026-04-02 19:20:43

Synced files:
- Session logs updated
- Latest context and credentials
- Command/directive updates

Machine: DESKTOP-0O8A1RL
Timestamp: 2026-04-02 19:20:43

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 19:20:43 -07:00
parent 6e4ebc2db9
commit bff7d9dbbf
8 changed files with 981 additions and 27 deletions

View File

@@ -113,7 +113,7 @@ export interface Command {
agent_id: string;
command_type: string;
command_text: string;
status: "pending" | "running" | "completed" | "failed";
status: "pending" | "running" | "completed" | "failed" | "cancelled";
exit_code: number | null;
stdout: string | null;
stderr: string | null;
@@ -219,6 +219,11 @@ export const commandsApi = {
api.post<Command>(`/api/agents/${agentId}/command`, command),
list: () => api.get<Command[]>("/api/commands"),
get: (id: string) => api.get<Command>(`/api/commands/${id}`),
cancelCommand: (id: string) =>
api.post<{ status: string; message: string }>(`/api/commands/${id}/cancel`),
deleteCommand: (id: string) => api.delete(`/api/commands/${id}`),
clearCommandHistory: () =>
api.delete<{ deleted: number; message: string }>("/api/commands"),
};
export const clientsApi = {

View File

@@ -1,6 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useParams, useNavigate } from "react-router-dom";
import { RefreshCw, CheckCircle, XCircle, Clock, Loader2, ArrowLeft, Terminal } from "lucide-react";
import {
RefreshCw,
CheckCircle,
XCircle,
Clock,
Loader2,
ArrowLeft,
Terminal,
Trash2,
Ban,
StopCircle,
} from "lucide-react";
import { commandsApi, Command } from "../api/client";
import { Card, CardContent } from "../components/Card";
import { Button } from "../components/Button";
@@ -28,6 +39,11 @@ function StatusBadge({ status }: { status: Command["status"] }) {
label: "Failed",
className: "bg-rose-500/10 text-rose-400 border-rose-500/30",
},
cancelled: {
icon: Ban,
label: "Cancelled",
className: "bg-amber-500/10 text-amber-500 border-amber-500/30",
},
};
const { icon: Icon, label, className, spin } = config[status] as {
@@ -62,12 +78,63 @@ function formatRelativeTime(dateString: string): string {
}
export function History() {
const queryClient = useQueryClient();
const { data: commands = [], isLoading, refetch } = useQuery({
queryKey: ["commands"],
queryFn: () => commandsApi.list().then((res) => res.data),
refetchInterval: 10000,
});
const cancelMutation = useMutation({
mutationFn: (id: string) => commandsApi.cancelCommand(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["commands"] });
},
onError: (error: Error) => {
alert(`Failed to cancel command: ${error.message}`);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => commandsApi.deleteCommand(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["commands"] });
},
onError: (error: Error) => {
alert(`Failed to delete command: ${error.message}`);
},
});
const clearHistoryMutation = useMutation({
mutationFn: () => commandsApi.clearCommandHistory(),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ["commands"] });
const data = res.data;
if (data.deleted === 0) {
alert("No finished commands to clear.");
}
},
onError: (error: Error) => {
alert(`Failed to clear history: ${error.message}`);
},
});
const handleClearHistory = () => {
const finishedCount = commands.filter(
(cmd) => cmd.status === "completed" || cmd.status === "failed" || cmd.status === "cancelled"
).length;
if (finishedCount === 0) {
alert("No finished commands to clear.");
return;
}
if (window.confirm(`Clear ${finishedCount} finished command(s) from history?`)) {
clearHistoryMutation.mutate();
}
};
return (
<div className="space-y-4">
{/* Header */}
@@ -76,15 +143,30 @@ export function History() {
<h1 className="text-2xl font-mono font-bold text-[var(--text-primary)]">History</h1>
<p className="text-[var(--text-muted)] text-sm">Command execution log</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="border-[var(--border-accent)] text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)]"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<div className="flex items-center gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleClearHistory}
disabled={clearHistoryMutation.isPending}
>
{clearHistoryMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Clear History
</Button>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="border-[var(--border-accent)] text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan-muted)]"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* History List */}
@@ -103,12 +185,14 @@ export function History() {
) : (
<div className="divide-y divide-[var(--border-secondary)]">
{commands.map((cmd: Command) => (
<Link
<div
key={cmd.id}
to={`/history/${cmd.id}`}
className="flex items-center justify-between p-3 hover:bg-[rgba(6,182,212,0.05)] transition-colors group"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Link
to={`/history/${cmd.id}`}
className="flex items-center gap-3 min-w-0 flex-1"
>
<StatusBadge status={cmd.status} />
<div className="min-w-0 flex-1">
<p className="font-mono text-sm text-[var(--text-primary)] truncate group-hover:text-[var(--accent-cyan)] transition-colors">
@@ -118,18 +202,49 @@ export function History() {
{cmd.command_type} | Agent: {cmd.agent_id.slice(0, 8)}...
</p>
</div>
</div>
<div className="text-right pl-4 shrink-0">
<p className="font-mono text-xs text-[var(--text-muted)]">
{formatRelativeTime(cmd.created_at)}
</p>
{cmd.exit_code !== null && (
<p className={`text-xs font-mono ${cmd.exit_code === 0 ? "text-emerald-500" : "text-rose-500"}`}>
exit: {cmd.exit_code}
</Link>
<div className="flex items-center gap-2 pl-4 shrink-0">
<div className="text-right">
<p className="font-mono text-xs text-[var(--text-muted)]">
{formatRelativeTime(cmd.created_at)}
</p>
)}
{cmd.exit_code !== null && (
<p className={`text-xs font-mono ${cmd.exit_code === 0 ? "text-emerald-500" : "text-rose-500"}`}>
exit: {cmd.exit_code}
</p>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{(cmd.status === "pending" || cmd.status === "running") && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
cancelMutation.mutate(cmd.id);
}}
disabled={cancelMutation.isPending}
className="p-1.5 rounded-md text-amber-400 hover:bg-amber-500/10 hover:text-amber-300 transition-colors"
title="Cancel command"
>
<StopCircle className="h-4 w-4" />
</button>
)}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteMutation.mutate(cmd.id);
}}
disabled={deleteMutation.isPending}
className="p-1.5 rounded-md text-rose-400 hover:bg-rose-500/10 hover:text-rose-300 transition-colors"
title="Delete command"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</Link>
</div>
))}
</div>
)}
@@ -142,6 +257,7 @@ export function History() {
export function HistoryDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: commands = [], isLoading } = useQuery({
queryKey: ["commands"],
@@ -150,6 +266,27 @@ export function HistoryDetail() {
const command = commands.find((cmd: Command) => cmd.id === id);
const cancelMutation = useMutation({
mutationFn: (cmdId: string) => commandsApi.cancelCommand(cmdId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["commands"] });
},
onError: (error: Error) => {
alert(`Failed to cancel command: ${error.message}`);
},
});
const deleteMutation = useMutation({
mutationFn: (cmdId: string) => commandsApi.deleteCommand(cmdId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["commands"] });
navigate("/history");
},
onError: (error: Error) => {
alert(`Failed to delete command: ${error.message}`);
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -199,6 +336,33 @@ export function HistoryDetail() {
{formatDate(command.created_at)}
</p>
</div>
<div className="flex items-center gap-2">
{(command.status === "pending" || command.status === "running") && (
<Button
variant="outline"
size="sm"
onClick={() => cancelMutation.mutate(command.id)}
disabled={cancelMutation.isPending}
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
>
<StopCircle className="h-4 w-4 mr-2" />
Cancel
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => {
if (window.confirm("Delete this command?")) {
deleteMutation.mutate(command.id);
}
}}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
{/* Command Info */}

View File

@@ -160,3 +160,100 @@ pub async fn get_command(
Ok(Json(command))
}
/// Delete a single command by ID
/// Requires authentication.
pub async fn delete_command(
State(state): State<AppState>,
_user: AuthUser,
Path(command_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let deleted = db::delete_command(&state.db, command_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if deleted {
Ok(StatusCode::NO_CONTENT)
} else {
Err((StatusCode::NOT_FOUND, "Command not found".to_string()))
}
}
/// Cancel response payload
#[derive(Debug, Serialize)]
pub struct CancelCommandResponse {
pub status: String,
pub message: String,
}
/// Cancel a pending or running command
/// Requires authentication.
pub async fn cancel_command(
State(state): State<AppState>,
_user: AuthUser,
Path(command_id): Path<Uuid>,
) -> Result<Json<CancelCommandResponse>, (StatusCode, String)> {
let command = db::get_command_by_id(&state.db, command_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Command not found".to_string()))?;
match command.status.as_str() {
"completed" | "failed" | "cancelled" => {
return Err((
StatusCode::BAD_REQUEST,
"Command already finished".to_string(),
));
}
"running" => {
// Update status in DB
db::cancel_command(&state.db, command_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Optionally try to send a cancel signal via WebSocket
let agents = state.agents.read().await;
if agents.is_connected(&command.agent_id) {
let cancel_msg = ServerMessage::Error {
code: "command_cancelled".to_string(),
message: format!("Command {} has been cancelled", command_id),
};
let _ = agents.send_to(&command.agent_id, cancel_msg).await;
}
}
_ => {
// Pending or any other status - just update DB
db::cancel_command(&state.db, command_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
}
Ok(Json(CancelCommandResponse {
status: "cancelled".to_string(),
message: "Command cancelled".to_string(),
}))
}
/// Clear history response payload
#[derive(Debug, Serialize)]
pub struct ClearHistoryResponse {
pub deleted: u64,
pub message: String,
}
/// Bulk clear finished commands (completed, failed, cancelled)
/// Requires authentication.
pub async fn clear_command_history(
State(state): State<AppState>,
_user: AuthUser,
) -> Result<Json<ClearHistoryResponse>, (StatusCode, String)> {
let count = db::delete_finished_commands(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(ClearHistoryResponse {
deleted: count,
message: format!("Cleared {} commands from history", count),
}))
}

View File

@@ -56,8 +56,9 @@ pub fn routes() -> Router<AppState> {
.route("/metrics/summary", get(metrics::get_summary))
// Commands
.route("/agents/:id/command", post(commands::send_command))
.route("/commands", get(commands::list_commands))
.route("/commands/:id", get(commands::get_command))
.route("/commands", get(commands::list_commands).delete(commands::clear_command_history))
.route("/commands/:id", get(commands::get_command).delete(commands::delete_command))
.route("/commands/:id/cancel", post(commands::cancel_command))
// Legacy Agent (PowerShell for 2008 R2)
.route("/agent/register-legacy", post(agents::register_legacy))
.route("/agent/heartbeat", post(agents::heartbeat))

View File

@@ -161,3 +161,25 @@ pub async fn delete_command(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error
.await?;
Ok(result.rows_affected() > 0)
}
/// Cancel a command - set status to 'cancelled' and set completed_at
pub async fn cancel_command(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE commands SET status = 'cancelled', completed_at = NOW() WHERE id = $1",
)
.bind(id)
.execute(pool)
.await?;
Ok(())
}
/// Delete all completed, failed, and cancelled commands (bulk clear)
/// Returns the count of deleted rows.
pub async fn delete_finished_commands(pool: &PgPool) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
"DELETE FROM commands WHERE status IN ('completed', 'failed', 'cancelled')",
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}