Skip to content

Commit a3dd90f

Browse files
authored
feature: database stats (#208)
1 parent edcde37 commit a3dd90f

21 files changed

Lines changed: 4231 additions & 261 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<template>
2+
<pev2 :plan-source="plan" :plan-query="query"></pev2>
3+
</template>
4+
5+
<script>
6+
import { Plan } from "pev2";
7+
import "pev2/dist/pev2.css";
8+
9+
export default {
10+
components: {
11+
pev2: Plan,
12+
},
13+
props: {
14+
plan: {
15+
type: String,
16+
required: true,
17+
},
18+
query: {
19+
type: String,
20+
required: true,
21+
},
22+
},
23+
};
24+
</script>
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
<template>
2+
<div class="space-y-6">
3+
<!-- Connection Stats Cards -->
4+
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
5+
<Card>
6+
<CardHeader class="pb-2">
7+
<CardTitle class="text-sm font-medium text-muted-foreground"
8+
>Total</CardTitle
9+
>
10+
</CardHeader>
11+
<CardContent>
12+
<div class="text-2xl font-bold">
13+
{{ connectionStats?.total || 0 }}
14+
</div>
15+
</CardContent>
16+
</Card>
17+
<Card>
18+
<CardHeader class="pb-2">
19+
<CardTitle class="text-sm font-medium text-muted-foreground"
20+
>Active</CardTitle
21+
>
22+
</CardHeader>
23+
<CardContent>
24+
<div class="text-2xl font-bold">
25+
{{ connectionStats?.active || 0 }}
26+
</div>
27+
</CardContent>
28+
</Card>
29+
<Card>
30+
<CardHeader class="pb-2">
31+
<CardTitle class="text-sm font-medium text-muted-foreground"
32+
>Idle</CardTitle
33+
>
34+
</CardHeader>
35+
<CardContent>
36+
<div class="text-2xl font-bold">{{ connectionStats?.idle || 0 }}</div>
37+
</CardContent>
38+
</Card>
39+
<Card>
40+
<CardHeader class="pb-2">
41+
<CardTitle class="text-sm font-medium text-muted-foreground"
42+
>Idle in Txn</CardTitle
43+
>
44+
</CardHeader>
45+
<CardContent>
46+
<div class="text-2xl font-bold">
47+
{{ connectionStats?.idle_in_transaction || 0 }}
48+
</div>
49+
</CardContent>
50+
</Card>
51+
<Card>
52+
<CardHeader class="pb-2">
53+
<CardTitle class="text-sm font-medium text-muted-foreground"
54+
>Waiting</CardTitle
55+
>
56+
</CardHeader>
57+
<CardContent>
58+
<div class="text-2xl font-bold">
59+
{{ connectionStats?.waiting || 0 }}
60+
</div>
61+
</CardContent>
62+
</Card>
63+
</div>
64+
65+
<!-- Active Queries Section -->
66+
<div>
67+
<h3 class="text-lg font-semibold mb-3">Active Queries</h3>
68+
<Card>
69+
<Table>
70+
<TableHeader>
71+
<TableRow>
72+
<TableHead>PID</TableHead>
73+
<TableHead>User</TableHead>
74+
<TableHead>App</TableHead>
75+
<TableHead>State</TableHead>
76+
<TableHead>Wait Event</TableHead>
77+
<TableHead>Duration</TableHead>
78+
<TableHead>Query</TableHead>
79+
</TableRow>
80+
</TableHeader>
81+
<TableBody>
82+
<TableRow v-for="query in activeQueries" :key="query.pid">
83+
<TableCell>{{ query.pid }}</TableCell>
84+
<TableCell>{{ query.usename }}</TableCell>
85+
<TableCell class="text-xs">{{
86+
query.application_name || "N/A"
87+
}}</TableCell>
88+
<TableCell>
89+
<Badge :variant="getStateVariant(query.state)">
90+
{{ query.state }}
91+
</Badge>
92+
</TableCell>
93+
<TableCell class="text-xs">
94+
<span v-if="query.wait_event_type">
95+
{{ query.wait_event_type }}
96+
<span v-if="query.wait_event" class="text-muted-foreground">
97+
({{ query.wait_event }})
98+
</span>
99+
</span>
100+
<span v-else class="text-muted-foreground">None</span>
101+
</TableCell>
102+
<TableCell>
103+
<Badge :variant="getDurationVariant(query.duration_seconds)">
104+
{{ formatDuration(query.duration_seconds) }}
105+
</Badge>
106+
</TableCell>
107+
<TableCell class="max-w-md">
108+
<div class="flex items-center gap-2">
109+
<div
110+
class="overflow-x-auto max-h-20 font-mono text-xs whitespace-nowrap flex-1"
111+
>
112+
{{ query.query }}
113+
</div>
114+
<Button
115+
variant="ghost"
116+
size="sm"
117+
class="shrink-0 h-6 w-6 p-0"
118+
@click.stop="copyQuery(query.query)"
119+
>
120+
<CopyIcon class="w-3 h-3" />
121+
</Button>
122+
</div>
123+
</TableCell>
124+
</TableRow>
125+
</TableBody>
126+
</Table>
127+
<Empty v-if="activeQueries.length === 0">
128+
<p class="text-muted-foreground">
129+
{{ $t("pages.database.connections.no_active_queries") }}
130+
</p>
131+
</Empty>
132+
</Card>
133+
</div>
134+
135+
<!-- All Connections -->
136+
<div>
137+
<h3 class="text-lg font-semibold mb-3">All Connections</h3>
138+
<Card>
139+
<Table>
140+
<TableHeader>
141+
<TableRow>
142+
<TableHead>PID</TableHead>
143+
<TableHead>User</TableHead>
144+
<TableHead>App</TableHead>
145+
<TableHead>Client</TableHead>
146+
<TableHead>State</TableHead>
147+
<TableHead>Query</TableHead>
148+
</TableRow>
149+
</TableHeader>
150+
<TableBody>
151+
<TableRow v-for="conn in activeConnections" :key="conn.pid">
152+
<TableCell>{{ conn.pid }}</TableCell>
153+
<TableCell>{{ conn.usename || "System" }}</TableCell>
154+
<TableCell class="text-xs">{{
155+
conn.application_name || "N/A"
156+
}}</TableCell>
157+
<TableCell class="text-xs">{{
158+
conn.client_addr || "local"
159+
}}</TableCell>
160+
<TableCell>
161+
<Badge v-if="conn.state" :variant="getStateVariant(conn.state)">
162+
{{ conn.state }}
163+
</Badge>
164+
<span v-else class="text-muted-foreground text-xs">N/A</span>
165+
</TableCell>
166+
<TableCell class="max-w-md">
167+
<div v-if="conn.query" class="flex items-center gap-2">
168+
<div
169+
class="overflow-x-auto max-h-20 font-mono text-xs whitespace-nowrap flex-1"
170+
>
171+
{{ conn.query }}
172+
</div>
173+
<Button
174+
variant="ghost"
175+
size="sm"
176+
class="shrink-0 h-6 w-6 p-0"
177+
@click.stop="copyQuery(conn.query)"
178+
>
179+
<CopyIcon class="w-3 h-3" />
180+
</Button>
181+
</div>
182+
<span v-else class="text-muted-foreground text-xs">N/A</span>
183+
</TableCell>
184+
</TableRow>
185+
</TableBody>
186+
</Table>
187+
<Empty v-if="activeConnections.length === 0">
188+
<p class="text-muted-foreground">
189+
{{ $t("pages.database.connections.no_connections") }}
190+
</p>
191+
</Empty>
192+
</Card>
193+
</div>
194+
</div>
195+
</template>
196+
197+
<script lang="ts">
198+
import { CopyIcon } from "lucide-vue-next";
199+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
200+
import {
201+
Table,
202+
TableBody,
203+
TableCell,
204+
TableHead,
205+
TableHeader,
206+
TableRow,
207+
} from "@/components/ui/table";
208+
import { Empty } from "@/components/ui/empty";
209+
import { Badge } from "@/components/ui/badge";
210+
import { Button } from "@/components/ui/button";
211+
import { generateQuery } from "~/graphql/graphqlGen";
212+
213+
export default {
214+
components: {
215+
Card,
216+
CardContent,
217+
CardHeader,
218+
CardTitle,
219+
Table,
220+
TableBody,
221+
TableCell,
222+
TableHead,
223+
TableHeader,
224+
TableRow,
225+
Empty,
226+
Badge,
227+
Button,
228+
CopyIcon,
229+
},
230+
inject: ["pollInterval"],
231+
data() {
232+
return {
233+
connectionStats: null as any,
234+
activeQueries: [] as any[],
235+
activeConnections: [] as any[],
236+
};
237+
},
238+
methods: {
239+
getStateVariant(state: string) {
240+
if (state === "active") return "default";
241+
if (state === "idle") return "secondary";
242+
if (state === "idle in transaction") return "outline";
243+
return "secondary";
244+
},
245+
getDurationVariant(seconds: number) {
246+
if (seconds > 60) return "destructive";
247+
if (seconds > 10) return "secondary";
248+
return "default";
249+
},
250+
formatDuration(seconds: number) {
251+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
252+
const minutes = Math.floor(seconds / 60);
253+
const secs = Math.floor(seconds % 60);
254+
return `${minutes}m ${secs}s`;
255+
},
256+
async copyQuery(query: string) {
257+
try {
258+
await navigator.clipboard.writeText(query);
259+
this.$toast?.success(this.$t("pages.database.query_copied"));
260+
} catch (error) {
261+
console.error("Failed to copy:", error);
262+
this.$toast?.error(this.$t("pages.database.copy_failed"));
263+
}
264+
},
265+
},
266+
apollo: {
267+
connectionStats: {
268+
query: generateQuery({
269+
getConnectionStats: [
270+
{},
271+
{
272+
total: true,
273+
active: true,
274+
idle: true,
275+
idle_in_transaction: true,
276+
waiting: true,
277+
by_state: {
278+
state: true,
279+
count: true,
280+
wait_event_type: true,
281+
waiting_count: true,
282+
},
283+
},
284+
],
285+
}),
286+
update: (data: any) => data.getConnectionStats,
287+
pollInterval() {
288+
return this.pollInterval();
289+
},
290+
},
291+
activeQueries: {
292+
query: generateQuery({
293+
getActiveQueries: [
294+
{},
295+
{
296+
pid: true,
297+
usename: true,
298+
application_name: true,
299+
client_addr: true,
300+
state: true,
301+
wait_event_type: true,
302+
wait_event: true,
303+
query: true,
304+
query_start: true,
305+
duration_seconds: true,
306+
},
307+
],
308+
}),
309+
update: (data: any) => data.getActiveQueries,
310+
pollInterval() {
311+
return this.pollInterval();
312+
},
313+
},
314+
activeConnections: {
315+
query: generateQuery({
316+
getActiveConnections: [
317+
{},
318+
{
319+
pid: true,
320+
usename: true,
321+
application_name: true,
322+
client_addr: true,
323+
state: true,
324+
query: true,
325+
query_start: true,
326+
},
327+
],
328+
}),
329+
update: (data: any) => data.getActiveConnections,
330+
pollInterval() {
331+
return this.pollInterval();
332+
},
333+
},
334+
},
335+
};
336+
</script>

0 commit comments

Comments
 (0)