- Column sort by header click in table view (ASC/DESC/none cycle, server-side) - SQL formatter with Format button and Shift+Alt+F keybinding (sql-formatter) - Table size and row count display in schema tree via pg_class - Insert row dialog with column type hints and auto-skip for identity columns - Saved queries (bookmarks) with CRUD backend, sidebar panel, and save dialog - Active sessions monitor (pg_stat_activity) with auto-refresh, cancel & terminate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
6.6 KiB
TypeScript
172 lines
6.6 KiB
TypeScript
import {
|
|
useSessions,
|
|
useCancelQuery,
|
|
useTerminateBackend,
|
|
} from "@/hooks/use-management";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { toast } from "sonner";
|
|
import { Loader2, XCircle, Skull, RefreshCw } from "lucide-react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
interface Props {
|
|
connectionId: string;
|
|
}
|
|
|
|
function getStateBadge(state: string | null) {
|
|
if (!state) return null;
|
|
const colors: Record<string, string> = {
|
|
idle: "bg-green-500/15 text-green-600",
|
|
active: "bg-yellow-500/15 text-yellow-600",
|
|
"idle in transaction": "bg-orange-500/15 text-orange-600",
|
|
disabled: "bg-red-500/15 text-red-600",
|
|
};
|
|
return (
|
|
<Badge variant="outline" className={`text-[9px] px-1 py-0 ${colors[state] ?? ""}`}>
|
|
{state}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function formatDuration(queryStart: string | null): string {
|
|
if (!queryStart) return "-";
|
|
const start = new Date(queryStart).getTime();
|
|
const now = Date.now();
|
|
const diffSec = Math.floor((now - start) / 1000);
|
|
if (diffSec < 0) return "-";
|
|
if (diffSec < 60) return `${diffSec}s`;
|
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`;
|
|
return `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`;
|
|
}
|
|
|
|
function getDurationColor(queryStart: string | null, state: string | null): string {
|
|
if (state !== "active" || !queryStart) return "";
|
|
const diffSec = (Date.now() - new Date(queryStart).getTime()) / 1000;
|
|
if (diffSec > 30) return "text-red-500 font-semibold";
|
|
if (diffSec > 5) return "text-yellow-500 font-semibold";
|
|
return "";
|
|
}
|
|
|
|
export function SessionsView({ connectionId }: Props) {
|
|
const { data: sessions, isLoading } = useSessions(connectionId);
|
|
const cancelMutation = useCancelQuery();
|
|
const terminateMutation = useTerminateBackend();
|
|
const queryClient = useQueryClient();
|
|
|
|
const handleCancel = (pid: number) => {
|
|
cancelMutation.mutate(
|
|
{ connectionId, pid },
|
|
{
|
|
onSuccess: () => toast.success(`Cancel signal sent to PID ${pid}`),
|
|
onError: (err) => toast.error("Cancel failed", { description: String(err) }),
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleTerminate = (pid: number) => {
|
|
if (!confirm(`Terminate backend PID ${pid}? This will kill the session.`)) return;
|
|
terminateMutation.mutate(
|
|
{ connectionId, pid },
|
|
{
|
|
onSuccess: () => toast.success(`Terminate signal sent to PID ${pid}`),
|
|
onError: (err) => toast.error("Terminate failed", { description: String(err) }),
|
|
}
|
|
);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
Loading sessions...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
|
<span className="text-xs font-semibold">Active Sessions</span>
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
{sessions?.length ?? 0}
|
|
</Badge>
|
|
<span className="text-[10px] text-muted-foreground">Auto-refresh: 5s</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="ml-auto h-6 gap-1 text-xs"
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: ["sessions"] })}
|
|
>
|
|
<RefreshCw className="h-3 w-3" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="sticky top-0 bg-card border-b">
|
|
<tr>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">PID</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">User</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Database</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">State</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Duration</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Wait</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Query</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Client</th>
|
|
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sessions?.map((s) => (
|
|
<tr key={s.pid} className="border-b hover:bg-accent/50">
|
|
<td className="px-2 py-1 font-mono">{s.pid}</td>
|
|
<td className="px-2 py-1">{s.usename ?? "-"}</td>
|
|
<td className="px-2 py-1">{s.datname ?? "-"}</td>
|
|
<td className="px-2 py-1">{getStateBadge(s.state)}</td>
|
|
<td className={`px-2 py-1 ${getDurationColor(s.query_start, s.state)}`}>
|
|
{formatDuration(s.query_start)}
|
|
</td>
|
|
<td className="px-2 py-1 text-muted-foreground">
|
|
{s.wait_event ? `${s.wait_event_type}: ${s.wait_event}` : "-"}
|
|
</td>
|
|
<td className="px-2 py-1 max-w-xs truncate font-mono" title={s.query ?? ""}>
|
|
{s.query ? (s.query.length > 60 ? s.query.slice(0, 60) + "..." : s.query) : "-"}
|
|
</td>
|
|
<td className="px-2 py-1 text-muted-foreground">{s.client_addr ?? "-"}</td>
|
|
<td className="px-2 py-1">
|
|
<div className="flex gap-0.5">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
title="Cancel Query"
|
|
onClick={() => handleCancel(s.pid)}
|
|
>
|
|
<XCircle className="h-3 w-3 text-yellow-500" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
title="Terminate Backend"
|
|
onClick={() => handleTerminate(s.pid)}
|
|
>
|
|
<Skull className="h-3 w-3 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{(!sessions || sessions.length === 0) && (
|
|
<div className="py-8 text-center text-xs text-muted-foreground">
|
|
No active sessions
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|