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:
@@ -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 = {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user