feat: add column sort, SQL formatter, table stats, insert dialog, saved queries & sessions monitor
- 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>
This commit is contained in:
171
src/components/management/SessionsView.tsx
Normal file
171
src/components/management/SessionsView.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user