0 ? "text-hue-orange" : "text-fg"}`}>
+
{waitingCount}
Wait
diff --git a/src/components/monitoring/tabs/OverviewTab.tsx b/src/components/monitoring/tabs/OverviewTab.tsx
index dcd95f1e92..dbad6af4f9 100644
--- a/src/components/monitoring/tabs/OverviewTab.tsx
+++ b/src/components/monitoring/tabs/OverviewTab.tsx
@@ -11,6 +11,7 @@ import type { TimeSeriesPoint } from "@/lib/time-series-buffer";
import { evaluateThreshold, getThresholdColor, thresholdFor } from "@/lib/monitoring-thresholds";
import { storage } from "@/lib/storage";
import { CACHE_HIT_RATIO_UNAVAILABLE } from "@/lib/monitoring-cache-ratio";
+import { sessionActivityFigure } from "@/lib/session-activity";
import { MetricChart } from "./MetricChart";
import { PanelUnavailable } from "../PanelUnavailable";
@@ -393,13 +394,13 @@ function QuickStatsCard({ data }: Readonly<{ data: MonitoringData | null }>) {
Active of listed sessions
- {quickStat(sessions, () => (sessions ?? []).filter((s) => s.state === "active").length)}
+ {sessionActivityFigure(sessions ?? [], "active", sessions === undefined)}
Idle of listed sessions
- {quickStat(sessions, () => (sessions ?? []).filter((s) => s.state === "idle").length)}
+ {sessionActivityFigure(sessions ?? [], "idle", sessions === undefined)}
diff --git a/src/components/monitoring/tabs/SessionsTab.tsx b/src/components/monitoring/tabs/SessionsTab.tsx
index 03476f7478..3272b10be9 100644
--- a/src/components/monitoring/tabs/SessionsTab.tsx
+++ b/src/components/monitoring/tabs/SessionsTab.tsx
@@ -26,6 +26,7 @@ import {
type ProviderCapabilities,
type ProviderLabels,
} from "@/lib/db/types";
+import { sessionActivityFigure } from "@/lib/session-activity";
import { PanelUnavailable } from "../PanelUnavailable";
interface SessionsTabProps {
@@ -68,13 +69,13 @@ export function SessionsTab({ data, loading, onKillSession, isAdmin = true, labe
// The four summary cards and the card title count the same absent panel, so they read
// N/A rather than 0 when the engine refused the question. Zero sessions IS a
// measurement and still reads 0 - the two must not look alike.
- const count = (of: (session: ActiveSessionDetails) => unknown): string =>
- sessionsUnavailable ? "N/A" : String(sessions.filter(of).length);
+ const count = (activity: "active" | "idle" | "idle-in-transaction" | "waiting"): string =>
+ sessionActivityFigure(sessions, activity, sessionsUnavailable !== undefined);
- const activeCount = count((s) => s.state === "active");
- const idleCount = count((s) => s.state === "idle");
- const idleInTxCount = count((s) => s.state?.includes("idle in transaction"));
- const waitingCount = count((s) => s.waitEventType);
+ const activeCount = count("active");
+ const idleCount = count("idle");
+ const idleInTxCount = count("idle-in-transaction");
+ const waitingCount = count("waiting");
const handleKillClick = (session: ActiveSessionDetails) => {
setConfirmKill(session);
diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts
index 5767fea741..8b4a6b0769 100644
--- a/src/lib/db/types.ts
+++ b/src/lib/db/types.ts
@@ -2853,6 +2853,12 @@ export interface SlowQueryStats {
sharedBlksRead?: number;
}
+/**
+ * Which summary card a session belongs on. The engine's own word stays in `state` for the row.
+ * `waiting` is a session that is blocked, not one that is merely sitting ready for the next command.
+ */
+export type SessionActivity = "active" | "idle" | "idle-in-transaction" | "waiting" | "other";
+
/**
* Active session with detailed information
*/
@@ -2862,7 +2868,13 @@ export interface ActiveSessionDetails {
database: string;
applicationName?: string;
clientAddr?: string;
+ /** The engine's own status word, shown on the row. */
state: string;
+ /**
+ * The card this session counts toward. Absent when the engine does not classify its own
+ * vocabulary, which the cards render as N/A rather than a measured 0 (#1426).
+ */
+ activity?: SessionActivity;
query: string;
queryStart?: Date;
duration: string;
diff --git a/src/lib/session-activity.ts b/src/lib/session-activity.ts
new file mode 100644
index 0000000000..06c753abfd
--- /dev/null
+++ b/src/lib/session-activity.ts
@@ -0,0 +1,17 @@
+import type { ActiveSessionDetails, SessionActivity } from "@/lib/db/types";
+
+/**
+ * One summary card, counted from `activity`.
+ *
+ * A refused read and a list the engine did not classify are both N/A: either one printed as 0
+ * would claim a measurement that was not made. An empty list is a measurement and stays 0.
+ * One unclassified row makes every card N/A, because a partial classification is not a count.
+ */
+export function sessionActivityFigure(
+ sessions: readonly Pick
[],
+ activity: SessionActivity,
+ unavailable: boolean,
+): string {
+ if (unavailable || sessions.some((session) => session.activity === undefined)) return "N/A";
+ return String(sessions.filter((session) => session.activity === activity).length);
+}
diff --git a/tests/components/admin/OperationsTab.test.tsx b/tests/components/admin/OperationsTab.test.tsx
index ecfca5bad8..83f837ee87 100644
--- a/tests/components/admin/OperationsTab.test.tsx
+++ b/tests/components/admin/OperationsTab.test.tsx
@@ -41,6 +41,7 @@ const defaultSessions = [
pid: 1234,
user: "admin",
state: "active",
+ activity: "active",
query: "SELECT 1",
duration: "00:01:00",
durationMs: 60000,
@@ -161,16 +162,27 @@ const multiSessions = [
pid: 100,
user: "admin",
state: "active",
+ activity: "active",
query: "SELECT 1",
duration: "00:00:05",
durationMs: 5000,
database: "dev",
},
- { pid: 101, user: "user1", state: "idle", query: "", duration: "00:00:10", durationMs: 10000, database: "dev" },
+ {
+ pid: 101,
+ user: "user1",
+ state: "idle",
+ activity: "idle",
+ query: "",
+ duration: "00:00:10",
+ durationMs: 10000,
+ database: "dev",
+ },
{
pid: 102,
user: "user2",
state: "idle in transaction",
+ activity: "idle-in-transaction",
query: "UPDATE users SET x=1",
duration: "00:02:00",
durationMs: 120000,
@@ -180,6 +192,7 @@ const multiSessions = [
pid: 103,
user: "user3",
state: "idle in transaction (aborted)",
+ activity: "idle-in-transaction",
query: "INSERT INTO t",
duration: "00:00:30",
durationMs: 30000,
@@ -189,6 +202,7 @@ const multiSessions = [
pid: 104,
user: "user4",
state: "fastpath function call",
+ activity: "other",
query: "",
duration: "00:00:01",
durationMs: 1000,
@@ -744,8 +758,89 @@ describe("OperationsTab", () => {
const { container } = renderResult!;
const text = container.textContent || "";
- // multiSessions: 1 active, 1 idle, 2 idle in tx (one normal, one aborted), 1 waiting
+ // multiSessions: 1 active, 1 idle, 2 idle in tx, and the fastpath row is other, so Wait stays 0.
+ // Its Lock wait is not a wait: the session is not active (#1426).
expect(text).toContain("Sessions (5)");
+ expect(renderResult!.getByTestId("operations-session-stat-active").textContent).toBe("1");
+ expect(renderResult!.getByTestId("operations-session-stat-idle").textContent).toBe("1");
+ expect(renderResult!.getByTestId("operations-session-stat-in-tx").textContent).toBe("2");
+ expect(renderResult!.getByTestId("operations-session-stat-wait").textContent).toBe("0");
+ });
+
+ test("MySQL, Oracle, SQL Server and Redis shaped rows count from activity", async () => {
+ monitoringOverride = {
+ data: {
+ activeSessions: [
+ {
+ pid: 1,
+ user: "u",
+ database: "db",
+ state: "Query",
+ activity: "active",
+ query: "",
+ duration: "1s",
+ durationMs: 1,
+ },
+ {
+ pid: 2,
+ user: "u",
+ database: "db",
+ state: "Sleep",
+ activity: "idle",
+ query: "",
+ duration: "1s",
+ durationMs: 1,
+ },
+ {
+ pid: 3,
+ user: "u",
+ database: "db",
+ state: "ACTIVE",
+ activity: "waiting",
+ query: "",
+ duration: "1s",
+ durationMs: 1,
+ },
+ {
+ pid: 4,
+ user: "u",
+ database: "db",
+ state: "running",
+ activity: "active",
+ query: "",
+ duration: "1s",
+ durationMs: 1,
+ },
+ { pid: 5, user: "u", database: "db", state: "N", activity: "idle", query: "", duration: "1s", durationMs: 1 },
+ ],
+ tables: defaultTables,
+ },
+ };
+ let renderResult: ReturnType;
+ await act(async () => {
+ renderResult = render();
+ });
+ expect(renderResult!.getByTestId("operations-session-stat-active").textContent).toBe("2");
+ expect(renderResult!.getByTestId("operations-session-stat-idle").textContent).toBe("2");
+ expect(renderResult!.getByTestId("operations-session-stat-in-tx").textContent).toBe("0");
+ expect(renderResult!.getByTestId("operations-session-stat-wait").textContent).toBe("1");
+ });
+
+ test("a session list the engine did not classify reads N/A", async () => {
+ monitoringOverride = {
+ data: {
+ activeSessions: [
+ { pid: 1, user: "u", database: "db", state: "Sleep", query: "", duration: "1s", durationMs: 1 },
+ ],
+ tables: defaultTables,
+ },
+ };
+ let renderResult: ReturnType;
+ await act(async () => {
+ renderResult = render();
+ });
+ expect(renderResult!.getByTestId("operations-session-stat-active").textContent).toBe("N/A");
+ expect(renderResult!.getByTestId("operations-session-stat-wait").textContent).toBe("N/A");
});
// =========================================================================
diff --git a/tests/components/monitoring/OverviewTab.test.tsx b/tests/components/monitoring/OverviewTab.test.tsx
index a26b85959b..13f5a32a73 100644
--- a/tests/components/monitoring/OverviewTab.test.tsx
+++ b/tests/components/monitoring/OverviewTab.test.tsx
@@ -36,8 +36,26 @@ function makeData(): MonitoringData {
},
slowQueries: [{ query: "SELECT 1", calls: 10, totalTime: 100, avgTime: 10, rows: 10 }],
activeSessions: [
- { pid: 1, user: "admin", database: "db", state: "active", query: "SELECT 1", duration: "1s", durationMs: 1000 },
- { pid: 2, user: "app", database: "db", state: "idle", query: "", duration: "2s", durationMs: 2000 },
+ {
+ pid: 1,
+ user: "admin",
+ database: "db",
+ state: "active",
+ activity: "active",
+ query: "SELECT 1",
+ duration: "1s",
+ durationMs: 1000,
+ },
+ {
+ pid: 2,
+ user: "app",
+ database: "db",
+ state: "idle",
+ activity: "idle",
+ query: "",
+ duration: "2s",
+ durationMs: 2000,
+ },
],
} as unknown as MonitoringData;
}
@@ -419,6 +437,7 @@ function atCap(): MonitoringData {
user: "app",
database: "db",
state: i < 29 ? "active" : "idle",
+ activity: i < 29 ? "active" : "idle",
query: "SELECT 1",
duration: "1s",
durationMs: 1000,
@@ -550,3 +569,50 @@ describe("the Tables card of a count that is a floor", () => {
expect(queryByTestId("overview-table-count-scope")).toBeNull();
});
});
+
+describe("Quick Stats counts classified sessions", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("MySQL-shaped rows count from activity, and the raw command stays off the cards", () => {
+ const data = {
+ ...makeData(),
+ activeSessions: [
+ {
+ pid: 1,
+ user: "u",
+ database: "db",
+ state: "Query",
+ activity: "active",
+ query: "",
+ duration: "1s",
+ durationMs: 1,
+ },
+ {
+ pid: 2,
+ user: "u",
+ database: "db",
+ state: "Sleep",
+ activity: "idle",
+ query: "",
+ duration: "1s",
+ durationMs: 1,
+ },
+ ],
+ } as MonitoringData;
+ const { getByTestId } = render();
+ expect(getByTestId("quick-stat-active").textContent).toBe("1");
+ expect(getByTestId("quick-stat-idle").textContent).toBe("1");
+ });
+
+ test("a list the engine did not classify reads N/A rather than 0", () => {
+ const data = {
+ ...makeData(),
+ activeSessions: [{ pid: 1, user: "u", database: "db", state: "Sleep", query: "", duration: "1s", durationMs: 1 }],
+ } as MonitoringData;
+ const { getByTestId } = render();
+ expect(getByTestId("quick-stat-active").textContent).toBe("N/A");
+ expect(getByTestId("quick-stat-idle").textContent).toBe("N/A");
+ });
+});
diff --git a/tests/components/monitoring/SessionsTab.test.tsx b/tests/components/monitoring/SessionsTab.test.tsx
index 7dd379ae45..957740d60d 100644
--- a/tests/components/monitoring/SessionsTab.test.tsx
+++ b/tests/components/monitoring/SessionsTab.test.tsx
@@ -418,3 +418,79 @@ describe("the session summary never counts a refused read as zero", () => {
expect(getByText("Sessions (0)")).not.toBeNull();
});
});
+
+function listedSession(state: string, activity?: string): MonitoringData["activeSessions"] {
+ return [
+ {
+ pid: 1,
+ user: "u",
+ database: "db",
+ state,
+ ...(activity === undefined ? {} : { activity }),
+ query: "",
+ duration: "1s",
+ durationMs: 1000,
+ },
+ ] as MonitoringData["activeSessions"];
+}
+
+// The cards count `activity`, which each provider sets from its own vocabulary. The State column
+// keeps the engine's word. A list that never sets `activity` is an engine that cannot classify (#1426).
+describe("session cards count the classified activity, not the engine's own word", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ function cards(sessions: MonitoringData["activeSessions"]) {
+ const data = { ...makeData(), activeSessions: sessions } as MonitoringData;
+ const view = render( true} />);
+ return {
+ active: view.getByTestId("session-stat-active").textContent,
+ idle: view.getByTestId("session-stat-idle").textContent,
+ inTx: view.getByTestId("session-stat-in-tx").textContent,
+ wait: view.getByTestId("session-stat-wait").textContent,
+ text: view.container.textContent ?? "",
+ };
+ }
+
+ test("MySQL Query, Sleep and Daemon count as active, idle and neither", () => {
+ const result = cards([
+ ...listedSession("Query", "active")!,
+ ...listedSession("Sleep", "idle")!,
+ ...listedSession("Daemon", "other")!,
+ ]);
+ expect(result).toMatchObject({ active: "1", idle: "1", inTx: "0", wait: "0" });
+ expect(result.text).toContain("Query");
+ expect(result.text).toContain("Sleep");
+ expect(result.text).toContain("Daemon");
+ });
+
+ test("Oracle ACTIVE and INACTIVE count, and a wait class on an idle session does not", () => {
+ const result = cards([
+ ...listedSession("ACTIVE", "active")!,
+ { ...listedSession("INACTIVE", "idle")![0], waitEventType: "Idle" },
+ { ...listedSession("ACTIVE", "waiting")![0], waitEventType: "Application" },
+ ]);
+ expect(result).toMatchObject({ active: "1", idle: "1", inTx: "0", wait: "1" });
+ });
+
+ test("SQL Server running and sleeping count, and a finished wait on a sleeping session does not", () => {
+ const result = cards([
+ ...listedSession("running", "active")!,
+ { ...listedSession("sleeping", "idle")![0], waitEventType: "ASYNC_NETWORK_IO" },
+ ...listedSession("running", "waiting")!,
+ ]);
+ expect(result).toMatchObject({ active: "1", idle: "1", inTx: "0", wait: "1" });
+ });
+
+ test("Redis flag N counts as idle and a blocked client as waiting", () => {
+ const result = cards([...listedSession("N", "idle")!, ...listedSession("b", "waiting")!]);
+ expect(result).toMatchObject({ active: "0", idle: "1", inTx: "0", wait: "1" });
+ });
+
+ test("a list the engine did not classify reads N/A on every card, above the rows it did return", () => {
+ const result = cards(listedSession("Sleep"));
+ expect(result).toMatchObject({ active: "N/A", idle: "N/A", inTx: "N/A", wait: "N/A" });
+ expect(result.text).toContain("Sleep");
+ });
+});
diff --git a/tests/unit/session-activity.test.ts b/tests/unit/session-activity.test.ts
new file mode 100644
index 0000000000..6698d3b74f
--- /dev/null
+++ b/tests/unit/session-activity.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from "bun:test";
+import { sessionActivityFigure } from "@/lib/session-activity";
+import type { SessionActivity } from "@/lib/db/types";
+
+const row = (activity?: SessionActivity) => ({ activity });
+
+describe("sessionActivityFigure", () => {
+ test("a refused read is N/A", () => {
+ expect(sessionActivityFigure([], "active", true)).toBe("N/A");
+ });
+
+ test("an empty classified list is 0", () => {
+ expect(sessionActivityFigure([], "idle", false)).toBe("0");
+ });
+
+ test("one unclassified row makes every card N/A", () => {
+ const sessions = [row("active"), row()];
+ expect(sessionActivityFigure(sessions, "active", false)).toBe("N/A");
+ expect(sessionActivityFigure(sessions, "waiting", false)).toBe("N/A");
+ });
+
+ test("counts only the requested card", () => {
+ const sessions = [row("active"), row("idle"), row("idle-in-transaction"), row("waiting"), row("other")];
+ expect(sessionActivityFigure(sessions, "active", false)).toBe("1");
+ expect(sessionActivityFigure(sessions, "idle", false)).toBe("1");
+ expect(sessionActivityFigure(sessions, "idle-in-transaction", false)).toBe("1");
+ expect(sessionActivityFigure(sessions, "waiting", false)).toBe("1");
+ expect(sessionActivityFigure(sessions, "other", false)).toBe("1");
+ });
+});
From c20500356e441caf556b518c19e3a12f75c220ab Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 16 Oct 2026 20:37:12 -0700
Subject: [PATCH 2/9] fix(mysql): classify processlist commands onto session
cards
Query is active, Sleep is idle, and every other command stays other, with the raw command left on the row.
---
docs/providers/mysql.md | 2 +-
src/lib/db/providers/sql/mysql.ts | 14 +++++++
tests/integration/db/mysql-provider.test.ts | 41 +++++++++++++++++++++
3 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/docs/providers/mysql.md b/docs/providers/mysql.md
index c1c59d8720..bbb93f359e 100644
--- a/docs/providers/mysql.md
+++ b/docs/providers/mysql.md
@@ -1927,7 +1927,7 @@ All monitoring reads from `SHOW STATUS`/`SHOW VARIABLES`, `information_schema`,
| `getOverview()` | `VERSION()`, one bare `SHOW STATUS` (`Uptime` and `Threads_connected` out of the same result), `SHOW VARIABLES LIKE 'max_connections'`, `information_schema` | version, uptime, conns, max_conns, size, table/index counts |
| `getPerformanceMetrics()` | `performance_schema.global_status`, one bare `SHOW STATUS` (`Innodb_deadlocks` picked client-side) | cache-hit %, **queries/sec** (`Queries`/`Uptime`), buffer-pool %, deadlocks. Every field optional — see the degradation note below |
| `getSlowQueries()` | `performance_schema.events_statements_summary_by_digest` | per-digest stats |
-| `getActiveSessions()` | `information_schema.PROCESSLIST` | pid, user, db, host, command, duration |
+| `getActiveSessions()` | `information_schema.PROCESSLIST` | pid, user, db, host, command, duration. `activity` classifies `COMMAND`: `Query` is `active`, `Sleep` is `idle`, and every other command (`Daemon` included) is `other`. The raw command stays in `state` |
| `getTableStats()` | `information_schema.TABLES` | sizes; bloat **estimated from `DATA_FREE`** (no live/dead tuples, no last-vacuum/analyze) |
| `getIndexStats()` | `information_schema.STATISTICS` + `mysql.innodb_index_stats` | columns, unique/primary; **`scans` = `CARDINALITY`** (a proxy, not a real scan counter); per-index size, or **absent** — see the index-size note below |
| `getStorageStats()` | `information_schema.TABLES`, `SHOW BINARY LOGS` | Data size, Binary Logs (if enabled), InnoDB data file (size `N/A`) |
diff --git a/src/lib/db/providers/sql/mysql.ts b/src/lib/db/providers/sql/mysql.ts
index bbd2cc2d58..b603f068c9 100644
--- a/src/lib/db/providers/sql/mysql.ts
+++ b/src/lib/db/providers/sql/mysql.ts
@@ -52,6 +52,7 @@ import {
type PerformanceMetrics,
type SlowQueryStats,
type ActiveSessionDetails,
+ type SessionActivity,
type TableStats,
type IndexStats,
type StorageStats,
@@ -1347,6 +1348,18 @@ const ACTIVE_SESSIONS_BODY_SQL = `
WHERE DB = ? OR DB IS NULL
ORDER BY TIME DESC`;
+/** PROCESSLIST `COMMAND`: `Query` is a statement in flight, `Sleep` is a connection waiting for the next one. */
+function mysqlSessionActivity(command: string): SessionActivity {
+ switch (command.toLowerCase()) {
+ case "query":
+ return "active";
+ case "sleep":
+ return "idle";
+ default:
+ return "other";
+ }
+}
+
const TABLE_STATS_SQL = `
SELECT
TABLE_SCHEMA as schema_name,
@@ -5039,6 +5052,7 @@ export class MySQLProvider extends SQLBaseProvider {
database: r.database_name || "",
clientAddr: r.client_addr?.split(":")[0] || undefined,
state: r.state || "unknown",
+ activity: mysqlSessionActivity(String(r.state || "unknown")),
query: r.query || "",
duration: this.formatDurationString(durationSeconds * 1000),
durationMs: durationSeconds * 1000,
diff --git a/tests/integration/db/mysql-provider.test.ts b/tests/integration/db/mysql-provider.test.ts
index c4feee8222..b240411710 100644
--- a/tests/integration/db/mysql-provider.test.ts
+++ b/tests/integration/db/mysql-provider.test.ts
@@ -2769,10 +2769,51 @@ describe("MySQLProvider", () => {
expect(typeof first.user).toBe("string");
expect(first.user).toBe("root");
expect(typeof first.state).toBe("string");
+ expect(first.state).toBe("Query");
+ expect(first.activity).toBe("active");
+ expect(sessions[1].state).toBe("Sleep");
+ expect(sessions[1].activity).toBe("idle");
expect(typeof first.query).toBe("string");
expect(typeof first.duration).toBe("string");
expect(typeof first.durationMs).toBe("number");
});
+
+ test("Daemon and any other command are other, and the match is case-insensitive", async () => {
+ mockExecuteFn = (sql: string) => {
+ if (sql.toLowerCase().includes("processlist")) {
+ return Promise.resolve([
+ [
+ {
+ pid: 1,
+ user: "root",
+ database_name: "testdb",
+ state: "Daemon",
+ query: "",
+ client_addr: "localhost",
+ duration_seconds: "1",
+ },
+ {
+ pid: 2,
+ user: "root",
+ database_name: "testdb",
+ state: "query",
+ query: "SELECT 1",
+ client_addr: "localhost",
+ duration_seconds: "1",
+ },
+ { pid: 3, user: "root", database_name: "testdb", state: null, query: "", duration_seconds: "0" },
+ ],
+ [],
+ ]);
+ }
+ return defaultMockExecute(sql);
+ };
+ provider = new MySQLProvider(makeMySQLConfig());
+ await provider.connect();
+ const sessions = await provider.getActiveSessions();
+ expect(sessions.map((session) => session.activity)).toEqual(["other", "active", "other"]);
+ expect(sessions[2].state).toBe("unknown");
+ });
});
// --------------------------------------------------------------------------
From 46b9c42b122ce03da9a5a5dad70f230c488d3ece Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 16 Oct 2026 20:37:16 -0700
Subject: [PATCH 3/9] fix(oracle): classify session status onto session cards
INACTIVE is idle, and ACTIVE counts as waiting only for a wait class other than Idle or CPU.
---
docs/providers/oracle.md | 2 +-
src/lib/db/providers/sql/oracle.ts | 21 ++++++++++
tests/integration/db/oracle-provider.test.ts | 40 ++++++++++++++++++++
3 files changed, 62 insertions(+), 1 deletion(-)
diff --git a/docs/providers/oracle.md b/docs/providers/oracle.md
index e09167646f..2105a583cf 100644
--- a/docs/providers/oracle.md
+++ b/docs/providers/oracle.md
@@ -1969,7 +1969,7 @@ are not ([§3.7](#37-privilege-resilient-monitoring)).
| `getOverview()` | `V$VERSION`, `V$INSTANCE`, `V$SESSION`, `V$PARAMETER`, `USER_SEGMENTS`, `USER_TABLES`/`USER_INDEXES` | each guarded; `activeConnections` is **omitted**, never `0` ([§7.2](#72-when-the-connection-count-is-not-measurable)), while `maxConnections` stays `0` because `0` there means "no limit published"; `databaseSizeBytes` is **omitted** and `databaseSize` stays `N/A`, never a `0`, when `USER_SEGMENTS` does not answer ([§7.3](#73-when-the-database-size-is-not-measurable)) |
| `getPerformanceMetrics()` | `V$SYSSTAT` | **only** `cacheHitRatio`, and it is **omitted** when `V$SYSSTAT` cannot be read (no QPS/deadlocks/buffer-pool) — [§7.1](#71-when-the-cache-hit-ratio-is-not-measurable) |
| `getSlowQueries()` | `V$SQL` (top-N by `ELAPSED_TIME`) | `sharedBlksHit`=`BUFFER_GETS`, `sharedBlksRead`=`DISK_READS`; `[]` on failure |
-| `getActiveSessions()` | `V$SESSION` ⋈ `V$SQL` | `pid` = `"SID,SERIAL#"`; wait class/event; `[]` on failure |
+| `getActiveSessions()` | `V$SESSION` ⋈ `V$SQL` | `pid` = `"SID,SERIAL#"`; wait class/event; `[]` on failure. `activity` classifies `STATUS`: `INACTIVE` is `idle` even when `WAIT_CLASS` is set, `ACTIVE` is `waiting` only for a wait class other than `Idle` or `CPU`, and any other status is `other`. The raw status stays in `state` |
| `getTableStats()` | `USER_TABLES` + `USER_LOBS` + `USER_INDEXES` + `USER_SEGMENTS` | one row per table the connected user owns; sizes + `lastAnalyze` ([§7.4](#74-what-a-tables-size-counts)); no live/dead tuples, no bloat; **rejects** with the engine's sentence on failure ([§3.7](#37-privilege-resilient-monitoring)) |
| `getIndexStats()` | `ALL_INDEXES` + `USER_SEGMENTS` + `ALL_IND_COLUMNS` | size summed over every segment of the index, partitions included; **`scans` always `0`** (no usage counter exposed); `isPrimary` always `false`; **rejects** on failure |
| `getStorageStats()` | `DBA_DATA_FILES` → fallback `USER_SEGMENTS` | per-tablespace size; DBA view falls back to user segments without privilege |
diff --git a/src/lib/db/providers/sql/oracle.ts b/src/lib/db/providers/sql/oracle.ts
index 86d017d731..2368e6d5f1 100644
--- a/src/lib/db/providers/sql/oracle.ts
+++ b/src/lib/db/providers/sql/oracle.ts
@@ -22,6 +22,7 @@ import {
type PerformanceMetrics,
type SlowQueryStats,
type ActiveSessionDetails,
+ type SessionActivity,
type TableStats,
type IndexStats,
type StorageStats,
@@ -178,6 +179,22 @@ const ACTIVE_SESSIONS_BODY_SQL = `SELECT * FROM (
ORDER BY CASE s.STATUS WHEN 'ACTIVE' THEN 0 ELSE 1 END, s.LOGON_TIME DESC
)`;
+/**
+ * `V$SESSION.STATUS`, with a wait counted only while the session is `ACTIVE` and the wait class is
+ * a block. `INACTIVE` sits on an idle wait class (`SQL*Net message from client`), and `CPU` is the
+ * session doing work. Neither is a wait.
+ */
+function oracleSessionActivity(status: string, waitClass: string | undefined): SessionActivity {
+ const word = status.toUpperCase();
+ if (word === "INACTIVE") return "idle";
+ if (word === "ACTIVE") {
+ const wait = (waitClass ?? "").toUpperCase();
+ if (wait !== "" && wait !== "IDLE" && wait !== "CPU") return "waiting";
+ return "active";
+ }
+ return "other";
+}
+
/**
* One row per table the connected user owns, with its rows, its storage and its indexes.
*
@@ -3192,6 +3209,10 @@ export class OracleProvider extends SQLBaseProvider {
applicationName: String(r.PROGRAM || ""),
clientAddr: String(r.MACHINE || ""),
state: String(r.STATUS || "unknown"),
+ activity: oracleSessionActivity(
+ String(r.STATUS || "unknown"),
+ r.WAIT_CLASS ? String(r.WAIT_CLASS) : undefined,
+ ),
query: String(r.QUERY || r.SQL_ID || ""),
queryStart: r.LOGON_TIME ? new Date(String(r.LOGON_TIME)) : undefined,
duration: durationStr,
diff --git a/tests/integration/db/oracle-provider.test.ts b/tests/integration/db/oracle-provider.test.ts
index 7fceedf60c..9afca83d9a 100644
--- a/tests/integration/db/oracle-provider.test.ts
+++ b/tests/integration/db/oracle-provider.test.ts
@@ -2917,6 +2917,43 @@ describe("OracleProvider", () => {
expect(typeof first.query).toBe("string");
expect(typeof first.duration).toBe("string");
expect(typeof first.durationMs).toBe("number");
+ expect(first.activity).toBe("active");
+ });
+
+ test("INACTIVE is idle even with a wait class, and an ACTIVE block is waiting", async () => {
+ mockExecuteFn = async (sql: string) => {
+ if (sql.toUpperCase().includes("V$SESSION") && sql.toUpperCase().includes("SERIAL#")) {
+ return {
+ rows: [
+ {
+ SID: 1,
+ "SERIAL#": 1,
+ USERNAME: "APP",
+ STATUS: "INACTIVE",
+ WAIT_CLASS: "Idle",
+ EVENT: "SQL*Net message from client",
+ DURATION_SECS: 1,
+ },
+ {
+ SID: 2,
+ "SERIAL#": 2,
+ USERNAME: "APP",
+ STATUS: "ACTIVE",
+ WAIT_CLASS: "Application",
+ EVENT: "enq: TX",
+ DURATION_SECS: 1,
+ },
+ { SID: 3, "SERIAL#": 3, USERNAME: "APP", STATUS: "KILLED", DURATION_SECS: 1 },
+ { SID: 4, "SERIAL#": 4, USERNAME: "APP", STATUS: "active", WAIT_CLASS: "idle", DURATION_SECS: 1 },
+ ],
+ metaData: [{ name: "SID" }],
+ };
+ }
+ return defaultExecute(sql);
+ };
+ await provider.connect();
+ const sessions = await provider.getActiveSessions();
+ expect(sessions.map((session) => session.activity)).toEqual(["idle", "waiting", "other", "active"]);
});
test("formats hour, minute, and second durations and defaults missing fields", async () => {
@@ -2975,6 +3012,9 @@ describe("OracleProvider", () => {
expect(sessions[1].queryStart).toBeUndefined();
expect(sessions[1].waitEventType).toBeUndefined();
expect(sessions[1].waitEvent).toBeUndefined();
+ expect(sessions[0].activity).toBe("active");
+ expect(sessions[1].activity).toBe("other");
+ expect(sessions[2].activity).toBe("active");
});
test("returns empty array when V$SESSION is not accessible", async () => {
From e48a306b8e7f4ade70fd7cce71ce5ed82d1f1c00 Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 16 Oct 2026 20:37:20 -0700
Subject: [PATCH 4/9] fix(mssql): classify session status onto session cards
Sleeping and dormant are idle, suspended is waiting, and running counts as waiting only while wait_type is set.
---
docs/providers/mssql.md | 2 +-
src/lib/db/providers/sql/mssql.ts | 14 +++++
tests/integration/db/mssql-provider.test.ts | 67 +++++++++++++++++++++
3 files changed, 82 insertions(+), 1 deletion(-)
diff --git a/docs/providers/mssql.md b/docs/providers/mssql.md
index 32fecfd0a4..39f192f54a 100644
--- a/docs/providers/mssql.md
+++ b/docs/providers/mssql.md
@@ -1199,7 +1199,7 @@ in parallel. Each sub-query is independently privilege-guarded (DMVs need `VIEW
| `getOverview()` | `@@VERSION`, `dm_os_sys_info`, `dm_exec_sessions`, `sys.configurations`, `database_files`, `sys.tables`/`indexes` | `user connections = 0` → reported as 32767 (unlimited); `databaseSizeBytes` is **omitted** and `databaseSize` stays `N/A`, never a `0`, when the size statement fails — [§8.3](#83-when-the-database-size-is-not-measurable) |
| `getPerformanceMetrics()` | `dm_os_performance_counters` | **only** the cache-hit ratio, and it is **omitted** when the DMV cannot be read (no QPS/deadlocks/buffer-pool) — [§8.1](#81-when-the-cache-hit-ratio-is-not-measurable) |
| `getSlowQueries()` | `dm_exec_query_stats` ⋈ `dm_exec_sql_text` | `sharedBlksHit`=logical reads, `sharedBlksRead`=physical reads; `[]` on failure |
-| `getActiveSessions()` | `dm_exec_sessions` ⋈ `dm_exec_requests` ⋈ `dm_exec_sql_text` | **`blocked` is real** (`blocking_session_id > 0`); wait types; `[]` on failure |
+| `getActiveSessions()` | `dm_exec_sessions` ⋈ `dm_exec_requests` ⋈ `dm_exec_sql_text` | **`blocked` is real** (`blocking_session_id > 0`); wait types; `[]` on failure. `activity` classifies `status`: `sleeping` and `dormant` are `idle`, `suspended` is `waiting`, `running` or `runnable` is `waiting` only while `wait_type` is set, and any other status is `other`. The raw status stays in `state` |
| `getTableStats()` | `sys.tables`/`partitions`/`allocation_units` | sizes + `lastAnalyze` (`STATS_DATE`); no live/dead tuples; `[]` on failure |
| `getIndexStats()` | `sys.indexes`/`allocation_units` + `dm_db_index_usage_stats` | **`scans` is real** (seeks+scans+lookups); `[]` on failure |
| `getStorageStats()` | `sys.database_files` | per-file name/path/size; `[]` on failure |
diff --git a/src/lib/db/providers/sql/mssql.ts b/src/lib/db/providers/sql/mssql.ts
index e703a579a5..131c13df9e 100644
--- a/src/lib/db/providers/sql/mssql.ts
+++ b/src/lib/db/providers/sql/mssql.ts
@@ -25,6 +25,7 @@ import {
type PerformanceMetrics,
type SlowQueryStats,
type ActiveSessionDetails,
+ type SessionActivity,
type TableStats,
type IndexStats,
type StorageStats,
@@ -294,6 +295,18 @@ const ACTIVE_SESSIONS_BODY_SQL = `
s.last_request_start_time DESC
`;
+/**
+ * `dm_exec_sessions.status`. `running` with a current `wait_type`, and `suspended`, are blocked.
+ * `sleeping` is idle even when `last_wait_type` still names the wait the request already finished.
+ */
+function mssqlSessionActivity(status: string, waitType: string | undefined): SessionActivity {
+ const word = status.toLowerCase();
+ if (word === "sleeping" || word === "dormant") return "idle";
+ if (word === "suspended") return "waiting";
+ if (word === "running" || word === "runnable") return waitType ? "waiting" : "active";
+ return "other";
+}
+
const TABLE_STATS_SQL = `
SELECT
s.name AS schema_name,
@@ -3707,6 +3720,7 @@ export class MSSQLProvider extends SQLBaseProvider {
applicationName: r.application_name ? String(r.application_name) : undefined,
clientAddr: r.client_addr ? String(r.client_addr) : undefined,
state: String(r.state || "unknown"),
+ activity: mssqlSessionActivity(String(r.state || "unknown"), r.wait_type ? String(r.wait_type) : undefined),
query: String(r.query || ""),
queryStart: r.query_start ? new Date(String(r.query_start)) : undefined,
duration: String(r.duration || "N/A"),
diff --git a/tests/integration/db/mssql-provider.test.ts b/tests/integration/db/mssql-provider.test.ts
index 2ff9c48336..b32aca7f54 100644
--- a/tests/integration/db/mssql-provider.test.ts
+++ b/tests/integration/db/mssql-provider.test.ts
@@ -2171,6 +2171,73 @@ describe("MSSQLProvider", () => {
expect(typeof first.query).toBe("string");
expect(typeof first.duration).toBe("string");
expect(typeof first.durationMs).toBe("number");
+ expect(first.state).toBe("sleeping");
+ expect(first.activity).toBe("idle");
+ });
+
+ test("running with a wait is waiting, and sleeping stays idle", async () => {
+ mockQueryFn = async (sql: string) => {
+ const upper = sql.toUpperCase();
+ if (upper.includes("SYS.DM_EXEC_SESSIONS") && upper.includes("TOP") && !upper.includes("COUNT")) {
+ return {
+ recordset: [
+ {
+ pid: 1,
+ user: "sa",
+ database: "db",
+ state: "running",
+ query: "SELECT 1",
+ duration: "1s",
+ duration_ms: 1,
+ wait_type: null,
+ },
+ {
+ pid: 2,
+ user: "sa",
+ database: "db",
+ state: "running",
+ query: "UPDATE t",
+ duration: "1s",
+ duration_ms: 1,
+ wait_type: "LCK_M_X",
+ },
+ {
+ pid: 3,
+ user: "sa",
+ database: "db",
+ state: "suspended",
+ query: "",
+ duration: "1s",
+ duration_ms: 1,
+ wait_type: "LCK_M_S",
+ },
+ { pid: 4, user: "sa", database: "db", state: "dormant", query: "", duration: "1s", duration_ms: 1 },
+ {
+ pid: 5,
+ user: "sa",
+ database: "db",
+ state: "runnable",
+ query: "SELECT 2",
+ duration: "1s",
+ duration_ms: 1,
+ },
+ { pid: 6, user: "sa", database: "db", state: "preconnect", query: "", duration: "1s", duration_ms: 1 },
+ ],
+ rowsAffected: [6],
+ };
+ }
+ return defaultQuery(sql);
+ };
+ await provider.connect();
+ const sessions = await provider.getActiveSessions();
+ expect(sessions.map((session) => session.activity)).toEqual([
+ "active",
+ "waiting",
+ "waiting",
+ "idle",
+ "active",
+ "other",
+ ]);
});
});
From f0c3b1e22a642defabc89a60eb3ad91a9467ada2 Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 16 Oct 2026 20:37:48 -0700
Subject: [PATCH 5/9] fix(postgres): count real waits and hide studio backends
An idle client wait stays idle, a lock wait counts as waiting, and pooled connections named libredb-studio are left out of the session list. Duration is measured to clock_timestamp() so it cannot read below zero.
---
docs/providers/postgres.md | 2 +-
src/lib/api/object-route.ts | 4 +-
src/lib/db/providers/sql/postgres.ts | 31 +++++-
.../integration/db/postgres-provider.test.ts | 101 ++++++++++++++++++
4 files changed, 131 insertions(+), 7 deletions(-)
diff --git a/docs/providers/postgres.md b/docs/providers/postgres.md
index 98106ba83c..b3050f52c2 100644
--- a/docs/providers/postgres.md
+++ b/docs/providers/postgres.md
@@ -1942,7 +1942,7 @@ base) fans these out in parallel.
| `getOverview()` | `version()`, `pg_postmaster_start_time()`, `pg_settings`, `pg_database_size`, `information_schema.tables`/`pg_indexes` | version, uptime, conns, max_conns, size, table/index counts |
| `getPerformanceMetrics()` | `pg_statio_user_tables`, `pg_stat_database`, `pg_stat_checkpointer` (17+) or `pg_stat_bgwriter` | cache-hit % (omitted when unmeasurable), deadlocks, checkpoint write time (`N/A` when unreadable); **no buffer-pool %** — see [§7.1](#71-when-the-cache-hit-ratio-is-not-measurable) |
| `getSlowQueries()` | `pg_stat_statements` → fallback `pg_stat_activity` | detailed per-statement stats; fallback shows live active queries |
-| `getActiveSessions()` | `pg_stat_activity` | pid, user, state, query, wait events, duration; excludes own backend |
+| `getActiveSessions()` | `pg_stat_activity` | pid, user, state, query, wait events, duration; excludes the querying backend and every connection whose `application_name` is `libredb-studio`. `activity` is `waiting` only for an `active` session whose `wait_event_type` is set and is not `Client`; an idle session waiting on `Client` stays `idle`. Duration is measured to `clock_timestamp()`, so a statement that started after the reading transaction does not read below zero |
| `getTableStats()` | `pg_stat_user_tables` + `pg_table_size`/`pg_indexes_size`/`pg_total_relation_size`, each on `relid`; `pg_class` when that view answers no rows (#1540) | live/dead tuples, sizes (absent per function the engine refuses, [§3.1.2](#312-two-kinds-of-absence-and-why-neither-is-an-empty-array)), last (auto)vacuum/analyze, bloat ratio |
| `getIndexStats()` | `pg_stat_user_indexes`, `pg_index`, `pg_am` | type, columns, unique/primary, size, scan count, usage ratio |
| `getStorageStats()` | `pg_tablespace`, WAL functions | per-tablespace size; WAL size (superuser-gated, swallowed if denied) |
diff --git a/src/lib/api/object-route.ts b/src/lib/api/object-route.ts
index 18c4b59010..b138b07b98 100644
--- a/src/lib/api/object-route.ts
+++ b/src/lib/api/object-route.ts
@@ -717,7 +717,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
* was the only engine that had landed; the day-one set is now three and the count was re-measured
* rather than the digit bumped, because what it counts is what the paragraph is for.
*
- * The day-one producers of `edit` are: `providers/sql/postgres.ts:4419`, gated on
+ * The day-one producers of `edit` are: `providers/sql/postgres.ts:4441`, gated on
* `kindAcceptsSourceEdits(capabilities, kind)`; `providers/sql/trino/index.ts:1342` and
* `providers/keyvalue/redis.ts:2234`, both gated on `spec.acceptsSourceEdits === true`, which is the
* same fact read through the same declaration. Later engines may produce it too, as etcd does for a
@@ -752,7 +752,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
*
* THE BOUND, on both sides of the same constant. `edit-plan/route.ts:74` refuses a SUBMITTED text
* longer than `EDIT_CHARACTER_LIMIT`, and all three day-one providers refuse a READ definition longer
- * than it inside `buildObjectEdit`: `providers/sql/postgres.ts:4587`, `providers/keyvalue/redis.ts:2324`
+ * than it inside `buildObjectEdit`: `providers/sql/postgres.ts:4609`, `providers/keyvalue/redis.ts:2324`
* and `providers/sql/trino/index.ts:1536`. The second is what closes the class rather than narrowing
* it: a plan is minted only from the build's own read, so a definition the pane could only have shown
* truncated never reaches a plan at all, whatever the client POSTs.
diff --git a/src/lib/db/providers/sql/postgres.ts b/src/lib/db/providers/sql/postgres.ts
index f64a455505..2c507bd9d1 100644
--- a/src/lib/db/providers/sql/postgres.ts
+++ b/src/lib/db/providers/sql/postgres.ts
@@ -39,6 +39,7 @@ import {
type PerformanceMetrics,
type SlowQueryStats,
type ActiveSessionDetails,
+ type SessionActivity,
type TableStats,
type IndexStats,
type StorageStats,
@@ -2213,6 +2214,25 @@ const SLOW_QUERIES_FALLBACK_SQL = `
`;
// getActiveSessions: detailed session list ($1 = database, $2 = limit).
+/** The name every pooled connection carries, so the session list can leave Studio's own probes out (#1426). */
+const STUDIO_APPLICATION_NAME = "libredb-studio";
+
+/**
+ * The card a PostgreSQL session belongs on.
+ *
+ * An idle backend waits on `Client` / `ClientRead`, which is the session sitting ready for the next
+ * command. That is idle, not waiting. Waiting is a session that is `active` and blocked on something
+ * else: a lock, I/O, an LWLock.
+ */
+function postgresSessionActivity(state: string, waitEventType: string | null | undefined): SessionActivity {
+ const wait = waitEventType || undefined;
+ if (state === "active" && wait !== undefined && wait !== "Client") return "waiting";
+ if (state === "active") return "active";
+ if (state === "idle") return "idle";
+ if (state.includes("idle in transaction")) return "idle-in-transaction";
+ return "other";
+}
+
const ACTIVE_SESSIONS_SQL = `
SELECT
pid,
@@ -2227,21 +2247,22 @@ const ACTIVE_SESSIONS_SQL = `
wait_event,
CASE
WHEN state = 'active' THEN
- EXTRACT(EPOCH FROM (now() - query_start))::text || 's'
+ EXTRACT(EPOCH FROM (clock_timestamp() - query_start))::text || 's'
WHEN xact_start IS NOT NULL THEN
- EXTRACT(EPOCH FROM (now() - xact_start))::text || 's'
+ EXTRACT(EPOCH FROM (clock_timestamp() - xact_start))::text || 's'
ELSE 'N/A'
END as duration,
CASE
WHEN state = 'active' THEN
- EXTRACT(EPOCH FROM (now() - query_start)) * 1000
+ EXTRACT(EPOCH FROM (clock_timestamp() - query_start)) * 1000
WHEN xact_start IS NOT NULL THEN
- EXTRACT(EPOCH FROM (now() - xact_start)) * 1000
+ EXTRACT(EPOCH FROM (clock_timestamp() - xact_start)) * 1000
ELSE 0
END as duration_ms
FROM pg_stat_activity
WHERE datname = $1
AND pid != pg_backend_pid()
+ AND application_name IS DISTINCT FROM '${STUDIO_APPLICATION_NAME}'
ORDER BY
CASE state WHEN 'active' THEN 0 ELSE 1 END,
query_start DESC NULLS LAST
@@ -3339,6 +3360,7 @@ export class PostgresProvider extends SQLBaseProvider {
idleTimeoutMillis: this.poolConfig.idleTimeout,
connectionTimeoutMillis: this.poolConfig.acquireTimeout,
statement_timeout: this.queryTimeout,
+ application_name: STUDIO_APPLICATION_NAME,
ssl: sslConfig,
// In the base so both connection forms below carry it.
types: POOL_TYPE_PARSERS,
@@ -5821,6 +5843,7 @@ export class PostgresProvider extends SQLBaseProvider {
applicationName: r.application_name || undefined,
clientAddr: r.client_addr || undefined,
state: r.state,
+ activity: postgresSessionActivity(String(r.state), r.wait_event_type),
query: r.query || "",
queryStart: r.query_start ? new Date(r.query_start) : undefined,
duration: r.duration,
diff --git a/tests/integration/db/postgres-provider.test.ts b/tests/integration/db/postgres-provider.test.ts
index 1a8b773f83..9e305322e0 100644
--- a/tests/integration/db/postgres-provider.test.ts
+++ b/tests/integration/db/postgres-provider.test.ts
@@ -3315,6 +3315,7 @@ describe("PostgresProvider", () => {
expect(session.database).toBe("testdb");
expect(session.applicationName).toBe("myapp");
expect(session.state).toBe("active");
+ expect(session.activity).toBe("active");
expect(typeof session.query).toBe("string");
expect(typeof session.duration).toBe("string");
expect(typeof session.durationMs).toBe("number");
@@ -3327,6 +3328,106 @@ describe("PostgresProvider", () => {
const sessions = await provider.getActiveSessions({ limit: 10 });
expect(Array.isArray(sessions)).toBe(true);
});
+
+ test("an idle client wait is idle, a lock wait is waiting, and Studio's own connections are filtered out", async () => {
+ let sql = "";
+ mockQueryFn = (query: string) => {
+ if (query.includes("wait_event_type") && query.includes("clock_timestamp()")) {
+ sql = query;
+ return Promise.resolve({
+ rows: [
+ {
+ pid: 1,
+ user: "a",
+ database: "testdb",
+ state: "idle",
+ wait_event_type: "Client",
+ query: "",
+ duration: "N/A",
+ duration_ms: "0",
+ },
+ {
+ pid: 2,
+ user: "a",
+ database: "testdb",
+ state: "active",
+ wait_event_type: "Lock",
+ query: "SELECT 1",
+ duration: "1s",
+ duration_ms: "1000",
+ },
+ {
+ pid: 3,
+ user: "a",
+ database: "testdb",
+ state: "active",
+ wait_event_type: "Client",
+ query: "SELECT 2",
+ duration: "1s",
+ duration_ms: "1000",
+ },
+ {
+ pid: 4,
+ user: "a",
+ database: "testdb",
+ state: "idle in transaction",
+ query: "UPDATE t",
+ duration: "1s",
+ duration_ms: "1000",
+ },
+ {
+ pid: 5,
+ user: "a",
+ database: "testdb",
+ state: "idle in transaction (aborted)",
+ query: "",
+ duration: "1s",
+ duration_ms: "1000",
+ },
+ {
+ pid: 6,
+ user: "a",
+ database: "testdb",
+ state: "fastpath function call",
+ query: "",
+ duration: "N/A",
+ duration_ms: "0",
+ },
+ {
+ pid: 7,
+ user: "a",
+ database: "testdb",
+ state: "active",
+ wait_event_type: "",
+ query: "SELECT 3",
+ duration: "1s",
+ duration_ms: "1000",
+ },
+ ],
+ fields: [],
+ rowCount: 6,
+ });
+ }
+ return defaultMockQuery(query);
+ };
+ provider = new PostgresProvider(makePgConfig());
+ await provider.connect();
+ const sessions = await provider.getActiveSessions();
+
+ expect(lastPoolConfig.application_name).toBe("libredb-studio");
+ expect(sql).toContain("application_name IS DISTINCT FROM 'libredb-studio'");
+ expect(sql).toContain("clock_timestamp()");
+ expect(sql).not.toContain("now() - query_start");
+ expect(sessions.map((session) => session.activity)).toEqual([
+ "idle",
+ "waiting",
+ "active",
+ "idle-in-transaction",
+ "idle-in-transaction",
+ "other",
+ "active",
+ ]);
+ });
});
// --------------------------------------------------------------------------
From fc5d15a1b126016ac443ad3619b006071429b2cd Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 16 Oct 2026 20:37:58 -0700
Subject: [PATCH 6/9] fix(redis): classify client flags onto session cards
A CLIENT LIST flag string containing b is waiting. Every other flag, including the N fallback, is idle.
---
docs/providers/redis.md | 4 +++-
src/lib/api/object-route.ts | 6 +++---
src/lib/db/providers/keyvalue/redis.ts | 7 +++++++
tests/integration/db/redis-provider.test.ts | 16 +++++++++++++++-
4 files changed, 28 insertions(+), 5 deletions(-)
diff --git a/docs/providers/redis.md b/docs/providers/redis.md
index 339bff7973..4814e68d53 100644
--- a/docs/providers/redis.md
+++ b/docs/providers/redis.md
@@ -89,7 +89,9 @@ is not.
`getActiveSessions()` ([`redis.ts`](../../src/lib/db/providers/keyvalue/redis.ts)) splits each
line into `key=value` pairs and substitutes a default for anything missing: the session's user is
`user` falling back to `default`, its pid `id` falling back to `0`, its state `flags` falling back
-to `N`, its command `cmd` falling back to `idle`. A relative that omits a field therefore produces a
+to `N`, its command `cmd` falling back to `idle`. `activity` reads those flags: a flag string
+containing `b` (the client is blocked) is `waiting`, and every other flag, including the fallback
+`N`, is `idle`. A relative that omits a field therefore produces a
plausible-looking row rather than an error.
Three relatives diverge, all measured on the versions above:
diff --git a/src/lib/api/object-route.ts b/src/lib/api/object-route.ts
index b138b07b98..b42f3d74b5 100644
--- a/src/lib/api/object-route.ts
+++ b/src/lib/api/object-route.ts
@@ -719,7 +719,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
*
* The day-one producers of `edit` are: `providers/sql/postgres.ts:4441`, gated on
* `kindAcceptsSourceEdits(capabilities, kind)`; `providers/sql/trino/index.ts:1342` and
- * `providers/keyvalue/redis.ts:2234`, both gated on `spec.acceptsSourceEdits === true`, which is the
+ * `providers/keyvalue/redis.ts:2240`, both gated on `spec.acceptsSourceEdits === true`, which is the
* same fact read through the same declaration. Later engines may produce it too, as etcd does for a
* key in `providers/keyvalue/etcd/objects.ts`. Every producer sits on the READABLE arm, verified rather
* than assumed: no producer attaches `edit` to a part carrying `unavailable`.
@@ -732,7 +732,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
* Rule 2's producer set GREW and its character changed, which is the part a bumped digit would have
* hidden. On PostgreSQL it is a by-product: that site spreads `truncated` and `edit` from a single
* read, so a routine over `SOURCE_CHARACTER_LIMIT` reaches it. On Redis it is a DECIDED POSITION,
- * stated at `redis.ts:2227-2233`: the affordance is offered on a truncated part deliberately, because
+ * stated at `redis.ts:2233-2239`: the affordance is offered on a truncated part deliberately, because
* the bound is the CALLER's and the same object read without one is whole, so a provider that withheld
* it there would be answering a property of the REQUEST as a property of the object. Rule 2 is what
* makes that position safe on the standalone path, and the pane's predicate and `buildObjectEdit`'s
@@ -752,7 +752,7 @@ function boundText(part: ObjectSourcePart, limit: number): ObjectSourcePart {
*
* THE BOUND, on both sides of the same constant. `edit-plan/route.ts:74` refuses a SUBMITTED text
* longer than `EDIT_CHARACTER_LIMIT`, and all three day-one providers refuse a READ definition longer
- * than it inside `buildObjectEdit`: `providers/sql/postgres.ts:4609`, `providers/keyvalue/redis.ts:2324`
+ * than it inside `buildObjectEdit`: `providers/sql/postgres.ts:4609`, `providers/keyvalue/redis.ts:2330`
* and `providers/sql/trino/index.ts:1536`. The second is what closes the class rather than narrowing
* it: a plan is minted only from the build's own read, so a definition the pane could only have shown
* truncated never reaches a plan at all, whatever the client POSTs.
diff --git a/src/lib/db/providers/keyvalue/redis.ts b/src/lib/db/providers/keyvalue/redis.ts
index 1d6357fae2..753a8de185 100644
--- a/src/lib/db/providers/keyvalue/redis.ts
+++ b/src/lib/db/providers/keyvalue/redis.ts
@@ -48,6 +48,7 @@ import {
type PerformanceMetrics,
type SlowQueryStats,
type ActiveSessionDetails,
+ type SessionActivity,
type TableStats,
type IndexStats,
type StorageStats,
@@ -1165,6 +1166,11 @@ function redisApplyFailure(payload: ObjectEditStep, error: unknown, duration: nu
};
}
+/** `CLIENT LIST` flags. `b` is a client blocked on a command; every other flag, including `N`, is idle. */
+function redisSessionActivity(flags: string): SessionActivity {
+ return flags.includes("b") ? "waiting" : "idle";
+}
+
// ============================================================================
// Redis Provider
// ============================================================================
@@ -2690,6 +2696,7 @@ export class RedisProvider extends BaseDatabaseProvider {
user: fields.user || "default",
database: fields.db || "0",
state: fields.flags || "N",
+ activity: redisSessionActivity(fields.flags || "N"),
query: fields.cmd || "idle",
duration: `${Math.round(parseInt(fields.idle || "0"))}s`,
durationMs: parseInt(fields.idle || "0") * 1000,
diff --git a/tests/integration/db/redis-provider.test.ts b/tests/integration/db/redis-provider.test.ts
index 803040cebb..b3b23dc549 100644
--- a/tests/integration/db/redis-provider.test.ts
+++ b/tests/integration/db/redis-provider.test.ts
@@ -68,6 +68,7 @@ const MOCK_PLAIN_CLUSTER_INFO = ["# Cluster", "cluster_enabled:0", "cluster_stat
// two used to be conflated in getActiveSessions(), which read `name` for the user column.
const MOCK_CLIENT_LIST =
"id=1 addr=127.0.0.1:6379 name= db=0 flags=N cmd=get idle=5 user=studio\nid=2 addr=127.0.0.1:6380 name= db=0 flags=N cmd=set idle=10 user=default";
+let mockClientList = MOCK_CLIENT_LIST;
// SLOWLOG GET entries: [id, timestamp, duration-in-microseconds, args, clientAddr, clientName]
// The second entry carries a non-array args payload to exercise the String() fallback.
@@ -690,7 +691,7 @@ mock.module("ioredis", () => {
}
async client(subcommand: string) {
- if (subcommand === "LIST") return MOCK_CLIENT_LIST;
+ if (subcommand === "LIST") return mockClientList;
return "OK";
}
@@ -777,6 +778,7 @@ describe("RedisProvider", () => {
let provider: InstanceType;
beforeEach(() => {
+ mockClientList = MOCK_CLIENT_LIST;
provider = new RedisProvider({ ...baseConfig });
});
@@ -2567,6 +2569,18 @@ describe("RedisProvider", () => {
const sessions = await provider.getActiveSessions();
expect(sessions[0].user).toBe("studio");
expect(sessions[1].user).toBe("default");
+ expect(sessions[0].activity).toBe("idle");
+ expect(sessions[1].activity).toBe("idle");
+ });
+
+ test("a blocked client is waiting, and a line with no flags is the ordinary idle client", async () => {
+ mockClientList =
+ "id=3 addr=127.0.0.1:6379 db=0 flags=bx cmd=blpop idle=0 user=app\nid=4 addr=127.0.0.1:6379 db=0 cmd=ping idle=1";
+ const sessions = await provider.getActiveSessions();
+ expect(sessions.map((session) => [session.state, session.activity])).toEqual([
+ ["bx", "waiting"],
+ ["N", "idle"],
+ ]);
});
});
From b23390f296c00fb9b79c11e279386bebb8d1dac0 Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 23 Oct 2026 21:16:27 -0700
Subject: [PATCH 7/9] fix(oracle): count a wait only while V.STATE is WAITING
---
docs/providers/oracle.md | 2 +-
src/lib/db/providers/sql/oracle.ts | 19 +++++---
tests/integration/db/oracle-provider.test.ts | 48 +++++++++++++++++++-
3 files changed, 61 insertions(+), 8 deletions(-)
diff --git a/docs/providers/oracle.md b/docs/providers/oracle.md
index 2105a583cf..338ff1ce0d 100644
--- a/docs/providers/oracle.md
+++ b/docs/providers/oracle.md
@@ -1969,7 +1969,7 @@ are not ([§3.7](#37-privilege-resilient-monitoring)).
| `getOverview()` | `V$VERSION`, `V$INSTANCE`, `V$SESSION`, `V$PARAMETER`, `USER_SEGMENTS`, `USER_TABLES`/`USER_INDEXES` | each guarded; `activeConnections` is **omitted**, never `0` ([§7.2](#72-when-the-connection-count-is-not-measurable)), while `maxConnections` stays `0` because `0` there means "no limit published"; `databaseSizeBytes` is **omitted** and `databaseSize` stays `N/A`, never a `0`, when `USER_SEGMENTS` does not answer ([§7.3](#73-when-the-database-size-is-not-measurable)) |
| `getPerformanceMetrics()` | `V$SYSSTAT` | **only** `cacheHitRatio`, and it is **omitted** when `V$SYSSTAT` cannot be read (no QPS/deadlocks/buffer-pool) — [§7.1](#71-when-the-cache-hit-ratio-is-not-measurable) |
| `getSlowQueries()` | `V$SQL` (top-N by `ELAPSED_TIME`) | `sharedBlksHit`=`BUFFER_GETS`, `sharedBlksRead`=`DISK_READS`; `[]` on failure |
-| `getActiveSessions()` | `V$SESSION` ⋈ `V$SQL` | `pid` = `"SID,SERIAL#"`; wait class/event; `[]` on failure. `activity` classifies `STATUS`: `INACTIVE` is `idle` even when `WAIT_CLASS` is set, `ACTIVE` is `waiting` only for a wait class other than `Idle` or `CPU`, and any other status is `other`. The raw status stays in `state` |
+| `getActiveSessions()` | `V$SESSION` ⋈ `V$SQL` | `pid` = `"SID,SERIAL#"`; wait class/event; `[]` on failure. `activity` classifies `STATUS` and `STATE`: `INACTIVE` is `idle` even when `WAIT_CLASS` is set, `ACTIVE` is `waiting` only when `STATE` is `WAITING` and the wait class is other than `Idle` (`EVENT` and `WAIT_CLASS` keep the last wait once the session is on CPU, and there is no CPU wait class), and any other status is `other`. The raw status stays in `state` |
| `getTableStats()` | `USER_TABLES` + `USER_LOBS` + `USER_INDEXES` + `USER_SEGMENTS` | one row per table the connected user owns; sizes + `lastAnalyze` ([§7.4](#74-what-a-tables-size-counts)); no live/dead tuples, no bloat; **rejects** with the engine's sentence on failure ([§3.7](#37-privilege-resilient-monitoring)) |
| `getIndexStats()` | `ALL_INDEXES` + `USER_SEGMENTS` + `ALL_IND_COLUMNS` | size summed over every segment of the index, partitions included; **`scans` always `0`** (no usage counter exposed); `isPrimary` always `false`; **rejects** on failure |
| `getStorageStats()` | `DBA_DATA_FILES` → fallback `USER_SEGMENTS` | per-tablespace size; DBA view falls back to user segments without privilege |
diff --git a/src/lib/db/providers/sql/oracle.ts b/src/lib/db/providers/sql/oracle.ts
index 2368e6d5f1..01042b3a51 100644
--- a/src/lib/db/providers/sql/oracle.ts
+++ b/src/lib/db/providers/sql/oracle.ts
@@ -172,7 +172,7 @@ const ACTIVE_SESSIONS_BODY_SQL = `SELECT * FROM (
SUBSTR(sq.SQL_TEXT, 1, 500) AS QUERY,
s.LOGON_TIME,
ROUND((SYSDATE - s.LOGON_TIME) * 86400) AS DURATION_SECS,
- s.WAIT_CLASS, s.EVENT
+ s.STATE, s.WAIT_CLASS, s.EVENT
FROM V$SESSION s
LEFT JOIN V$SQL sq ON s.SQL_ID = sq.SQL_ID AND s.SQL_CHILD_NUMBER = sq.CHILD_NUMBER
WHERE s.TYPE = 'USER'
@@ -180,16 +180,22 @@ const ACTIVE_SESSIONS_BODY_SQL = `SELECT * FROM (
)`;
/**
- * `V$SESSION.STATUS`, with a wait counted only while the session is `ACTIVE` and the wait class is
- * a block. `INACTIVE` sits on an idle wait class (`SQL*Net message from client`), and `CPU` is the
- * session doing work. Neither is a wait.
+ * `V$SESSION.STATUS`, with a wait counted only while the session is `ACTIVE`, `STATE` is
+ * `WAITING`, and the wait class is a block. `EVENT` and `WAIT_CLASS` keep the last wait after
+ * the session is back on CPU (`WAITED SHORT TIME`, `WAITED KNOWN TIME`, `WAITED UNKNOWN TIME`),
+ * and there is no CPU wait class. `INACTIVE` sits on an idle wait (`SQL*Net message from client`).
*/
-function oracleSessionActivity(status: string, waitClass: string | undefined): SessionActivity {
+function oracleSessionActivity(
+ status: string,
+ state: string | undefined,
+ waitClass: string | undefined,
+): SessionActivity {
const word = status.toUpperCase();
if (word === "INACTIVE") return "idle";
if (word === "ACTIVE") {
+ const waitingNow = (state ?? "").toUpperCase() === "WAITING";
const wait = (waitClass ?? "").toUpperCase();
- if (wait !== "" && wait !== "IDLE" && wait !== "CPU") return "waiting";
+ if (waitingNow && wait !== "" && wait !== "IDLE") return "waiting";
return "active";
}
return "other";
@@ -3211,6 +3217,7 @@ export class OracleProvider extends SQLBaseProvider {
state: String(r.STATUS || "unknown"),
activity: oracleSessionActivity(
String(r.STATUS || "unknown"),
+ r.STATE ? String(r.STATE) : undefined,
r.WAIT_CLASS ? String(r.WAIT_CLASS) : undefined,
),
query: String(r.QUERY || r.SQL_ID || ""),
diff --git a/tests/integration/db/oracle-provider.test.ts b/tests/integration/db/oracle-provider.test.ts
index 9afca83d9a..3655952941 100644
--- a/tests/integration/db/oracle-provider.test.ts
+++ b/tests/integration/db/oracle-provider.test.ts
@@ -2939,12 +2939,21 @@ describe("OracleProvider", () => {
"SERIAL#": 2,
USERNAME: "APP",
STATUS: "ACTIVE",
+ STATE: "WAITING",
WAIT_CLASS: "Application",
EVENT: "enq: TX",
DURATION_SECS: 1,
},
{ SID: 3, "SERIAL#": 3, USERNAME: "APP", STATUS: "KILLED", DURATION_SECS: 1 },
- { SID: 4, "SERIAL#": 4, USERNAME: "APP", STATUS: "active", WAIT_CLASS: "idle", DURATION_SECS: 1 },
+ {
+ SID: 4,
+ "SERIAL#": 4,
+ USERNAME: "APP",
+ STATUS: "active",
+ STATE: "WAITING",
+ WAIT_CLASS: "idle",
+ DURATION_SECS: 1,
+ },
],
metaData: [{ name: "SID" }],
};
@@ -2956,6 +2965,43 @@ describe("OracleProvider", () => {
expect(sessions.map((session) => session.activity)).toEqual(["idle", "waiting", "other", "active"]);
});
+ test("an on-CPU session keeps its last User I/O wait and reads active, and STATE WAITING reads waiting", async () => {
+ mockExecuteFn = async (sql: string) => {
+ if (sql.toUpperCase().includes("V$SESSION") && sql.toUpperCase().includes("SERIAL#")) {
+ return {
+ rows: [
+ {
+ SID: 1,
+ "SERIAL#": 1,
+ USERNAME: "APP",
+ STATUS: "ACTIVE",
+ STATE: "WAITED SHORT TIME",
+ WAIT_CLASS: "User I/O",
+ EVENT: "db file sequential read",
+ DURATION_SECS: 1,
+ },
+ {
+ SID: 2,
+ "SERIAL#": 2,
+ USERNAME: "APP",
+ STATUS: "ACTIVE",
+ STATE: "WAITING",
+ WAIT_CLASS: "User I/O",
+ EVENT: "db file sequential read",
+ DURATION_SECS: 1,
+ },
+ ],
+ metaData: [{ name: "SID" }],
+ };
+ }
+ return defaultExecute(sql);
+ };
+ await provider.connect();
+ const sessions = await provider.getActiveSessions();
+ expect(sessions.map((session) => session.activity)).toEqual(["active", "waiting"]);
+ expect(sessions.map((session) => session.state)).toEqual(["ACTIVE", "ACTIVE"]);
+ });
+
test("formats hour, minute, and second durations and defaults missing fields", async () => {
const sessionRow = (sid: number, secs: number, overrides: Record = {}) => ({
SID: sid,
From fc2da5a47c93ede4c7b58604098419c7469a15a0 Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 23 Oct 2026 21:31:18 -0700
Subject: [PATCH 8/9] fix(sessions): classify mongodb, sqlite, clickhouse,
databend, and neo4j onto session cards
---
docs/providers/clickhouse.md | 2 +-
docs/providers/databend.md | 2 +-
docs/providers/mongodb.md | 2 +-
docs/providers/neo4j.md | 1 +
docs/providers/sqlite.md | 2 +-
.../db/providers/document/mongodb/index.ts | 6 +-
.../db/providers/graph/neo4j/monitoring.ts | 23 ++++++--
src/lib/db/providers/sql/clickhouse/index.ts | 5 +-
.../db/providers/sql/databend/introspect.ts | 20 +++++--
src/lib/db/providers/sql/sqlite.ts | 2 +
.../db/clickhouse-provider.test.ts | 2 +
.../integration/db/databend-provider.test.ts | 57 +++++++++++++++++++
tests/integration/db/mongodb-provider.test.ts | 2 +
tests/integration/db/neo4j-provider.test.ts | 8 ++-
tests/integration/db/sqlite-provider.test.ts | 1 +
tests/unit/db/databend/introspect.test.ts | 16 +++---
tests/unit/db/databend/provider-doc.test.ts | 9 ++-
tests/unit/db/databend/provider.test.ts | 7 +--
tests/unit/db/neo4j/monitoring-reads.test.ts | 1 +
tests/unit/db/neo4j/monitoring.test.ts | 17 +++---
20 files changed, 146 insertions(+), 39 deletions(-)
diff --git a/docs/providers/clickhouse.md b/docs/providers/clickhouse.md
index f90c01ba19..feb600fabb 100644
--- a/docs/providers/clickhouse.md
+++ b/docs/providers/clickhouse.md
@@ -1436,7 +1436,7 @@ keep surfacing.
| `getOverview()` | `version()`, `uptime()`, `system.metrics`, `system.server_settings`, `system.parts`, `system.tables`, `system.data_skipping_indices` | Five separate reads on purpose: a restricted user gets `200` from `version()`/`uptime()` and `system.tables`, but `500`/497 from `system.metrics` and the two count queries — combining them into one statement would throw away the panels a restricted user CAN see |
| `getPerformanceMetrics()` | `system.events` (`MarkCacheHits`/`MarkCacheMisses`/`Query`), `system.asynchronous_metrics` (`MemoryResident`/`OSMemoryTotal`) | cache-hit ratio from the mark cache (ClickHouse's nearest buffer-cache equivalent); `queriesPerSecond` divides the lifetime `Query` counter by uptime, since neither is ever sampled twice in a single-shot read; buffer-pool usage is resident memory against total machine memory |
| `getSlowQueries()` | `system.query_log` where `type = 'QueryFinish'`, grouped by `normalized_query_hash` | Grouped the way `pg_stat_statements` groups — one row per statement *shape*, with its call count and min/max/avg duration, not one row per execution. Flushed **asynchronously**, so a statement that just ran may be absent for a few seconds |
-| `getActiveSessions()` | `system.processes` | Excludes its own read (`query NOT LIKE '%system.processes%'`), the same trick `postgres.ts` applies to `pg_stat_activity`; every row is reported with a constant `state: "active"`, because ClickHouse has no idle-in-transaction equivalent to distinguish |
+| `getActiveSessions()` | `system.processes` | Excludes its own read (`query NOT LIKE '%system.processes%'`), the same trick `postgres.ts` applies to `pg_stat_activity`; every row is reported with `state` and `activity` `active`, because ClickHouse has no idle-in-transaction equivalent to distinguish |
| `getTableStats()` / `getIndexStats()` | `system.parts` (active parts only) / `system.data_skipping_indices` | Accept an optional `{ schema }` filter; without one, every non-system database is covered. Index scan counts are always `0` — ClickHouse publishes no per-index usage counter anywhere, and a guessed number would be worse than an obvious zero |
| `getStorageStats()` | `system.disks` | Name, path, total/free bytes, and a usage percentage per configured disk |
| `getHealth()` | the four above, composed | connections, size, cache-hit ratio, top-5 slow queries, top-10 sessions |
diff --git a/docs/providers/databend.md b/docs/providers/databend.md
index cd907cd824..3dafdcd554 100644
--- a/docs/providers/databend.md
+++ b/docs/providers/databend.md
@@ -732,7 +732,7 @@ When Databend answers one of the codes 1003, 1025, 1063, 1112, 1119 or 1002 (an
- Overview: the version is `version()`; uptime is "unknown" and the connection limit 0, because Databend publishes neither; the size is the compressed data plus index size of the default catalog's base tables, "N/A" when that read degraded; the active count is the running statements of `system.processes`, Studio's own statements left out (below).
- Sessions: one row per running statement of the server or warehouse, every user's included, from `system.processes`, which needs no grant, since Databend creates a session per HTTP request.
The kill target is the row's session id.
- Its state is "active" while it runs a statement (`command` `Query`), the word the Active card and the Overview count, and "aborting" (`Aborting`) while a `KILL CONNECTION` or a server shutdown ends its session; the panel's own kill, a `KILL QUERY`, leaves it "active" until the statement stops.
+ Its state is "active" while it runs a statement (`command` `Query`) and "aborting" (`Aborting`) while a `KILL CONNECTION` or a server shutdown ends its session; the panel's own kill, a `KILL QUERY`, leaves it "active" until the statement stops. `activity` is `active` for `Query` and `other` for any other command, and the Active and Idle cards count `activity`.
Databend's query id of the statement is not shown: a session row of the panel has no field for one.
Studio's own statements are left out (below).
- Slow queries: `system_history.query_history`, the finished statements of the last 24 hours, slowest first.
diff --git a/docs/providers/mongodb.md b/docs/providers/mongodb.md
index f08b8b1bbc..61089eb032 100644
--- a/docs/providers/mongodb.md
+++ b/docs/providers/mongodb.md
@@ -947,7 +947,7 @@ Every method is wrapped in try/catch. Degradation reports the absence rather tha
| `getOverview()` | `serverStatus`, `buildInfo`, `dbStats`, `listCollections` | version, uptime, connections (**omitted**, never `0`, on the same two paths as `getHealth()`, [§7.2](#72-a-connection-count-nobody-published-is-absent-not-zero)), database size (**`dataSize + indexSize`**, the database-level sums of the per-collection `collStats.size` and `totalIndexSize` the rows are built from, so a share's numerator and denominator are the same pair; `databaseSizeBytes` **omitted**, never `0`, when either addend is unpublished, [§7.3](#73-a-database-size-nobody-published-is-absent-not-0-b)), collection/index counts. `maxConnections` is `connections.current + connections.available`, or `0` (the repo's spelling of *no limit published*) when the server publishes no headroom |
| `getPerformanceMetrics()` | `serverStatus` (WiredTiger + opcounters) | cache-hit %, **ops/sec** (`query`+`insert`+`update`+`delete` opcounters ÷ uptime — *total operations, not just queries*), buffer-pool % (cache bytes), `deadlocks: 0`. **Every field is optional**: each one is present only if its reading was, and a failed `serverStatus` reports `{}` ([§7.1](#71-what-the-panel-shows-when-the-cache-cannot-be-measured)) |
| `getSlowQueries()` | `system.profile` | per-op time/returned; **`[]` if the profiler isn't enabled** (`db.setProfilingLevel(1)`); sorted by `millis` (slowest) — note `getHealth()`'s slow-query block instead sorts by `ts` (most recent) and emits a placeholder row when disabled |
-| `getActiveSessions()` | `currentOp` | opid, ns, lock waits, duration — ⚠️ the **`user` field is populated from `op.client`** (the client `host:port`), **not** an authenticated user |
+| `getActiveSessions()` | `currentOp` | opid, ns, lock waits, duration. `activity` is `active` when the operation's `active` flag is set and `idle` when it is not — ⚠️ the **`user` field is populated from `op.client`** (the client `host:port`), **not** an authenticated user |
| `getTableStats()` | `collStats` per collection | row count + data/index/total sizes, `totalIndexSize` carried as the byte figure `indexSizeBytes` and not only as formatted text; a time series collection is **one** row, because the server's internal `system.buckets.` duplicate is skipped rather than summed beside it; every other `system.*` namespace (`system.views` included) is skipped with the same `system.` prefix the object browser uses, so Monitoring lists the same collections the tree does. `systemetrics` does not start with that prefix and stays |
| `getIndexStats()` | `$indexStats` + `indexes()` | **real `scans`** (`accesses.ops`); `indexSize` `N/A`; **`indexType` only distinguishes `text` vs `btree`** — `hashed`/`2dsphere`/`2d`/wildcard/clustered are all mislabelled `btree` |
| `getStorageStats()` | `dbStats` + WiredTiger | Data / Indexes / Storage / WiredTiger cache (with usage %) |
diff --git a/docs/providers/neo4j.md b/docs/providers/neo4j.md
index 052aaecd9c..1117640dd8 100644
--- a/docs/providers/neo4j.md
+++ b/docs/providers/neo4j.md
@@ -380,6 +380,7 @@ No kind declares `acceptsSourceEdits` or `acceptsRowWrites`: there is no source
The transactions read names its columns, never the `parameters` column, which can hold a secret, and never `YIELD *`.
Each `elapsedTime` is read as an ISO 8601 duration of days and time, a sign allowed on each component, and a negative total is shown as 0 ms (#1416): Neo4j 2026.09.0 reported a just-started transaction as `PT-0.001000000S` on about one Sessions load in three, and refusing that value dropped the whole panel. Any other text in that column still refuses the panel, naming the column.
+A transaction's `activity` is `active` while `status` is `Running` and `other` for `Blocked`, `Closing` and `Terminated`. The row shows `Running` as "active" and any other status lower-cased, and the Active and Idle cards count `activity`.
Each label in a count statement is backticked by the quoting module and checked by the read policy before it runs; a label the quoting refuses is left out, and the server log names every label left out, since the table stats have no field to say so.
The monitoring reads run through the same READ session path without the user's policy, and a unit test checks every one against the policy: every monitoring read but the transactions read passes it.
The transactions read stays out of the user allowlist (section 3.2), since a user's `SHOW TRANSACTIONS YIELD *` returns other sessions' query text and parameters, and the monitoring read keeps a fixed column list that names no `parameters` column.
diff --git a/docs/providers/sqlite.md b/docs/providers/sqlite.md
index 4244000d99..c9107e3b04 100644
--- a/docs/providers/sqlite.md
+++ b/docs/providers/sqlite.md
@@ -1055,7 +1055,7 @@ Minimal by nature — SQLite keeps almost no server-style runtime statistics.
| `getOverview()` | `sqlite_version()`, file size, `sqlite_master` counts | `uptime: N/A`, `maxConnections: 1`; `databaseSizeBytes` is **omitted** and `databaseSize` stays `N/A`, never a `0`, when the size read fails — [§7.3](#73-when-the-database-size-is-not-measurable) |
| `getPerformanceMetrics()` | — | **no cache-hit ratio, no QPS, no buffer-pool usage** — all three are omitted, so both monitoring tabs show "N/A / Not measured" for them ([§7.1](#71-there-is-no-cache-hit-ratio-and-there-cannot-be)); only `deadlocks: 0` is reported, which is a fact about the engine |
| `getSlowQueries()` | — | always `[]` (SQLite has no query stats) |
-| `getActiveSessions()` | — | the single current process session |
+| `getActiveSessions()` | — | the single current process session; `state` and `activity` are both `active` |
| `getTableStats()` | `COUNT(*)` per table, `dbstat` for the bytes | size is **measured page bytes wherever `dbstat` is compiled in, and absent where it is not** — see [§7.2](#72-per-table-size-depends-on-the-sqlite-build-behind-the-driver) |
| `getIndexStats()` | `PRAGMA index_list`/`index_info` | `scans` always `0` (no usage counter); `indexSize` is `N/A` and `indexSizeBytes` is **omitted** — SQLite publishes no per-index size, and a `0` was summed by the Storage tab as an empty index |
| `getStorageStats()` | `fs.statSync` on the DB / `-wal` / `-shm` files | per-file sizes (on disk only) |
diff --git a/src/lib/db/providers/document/mongodb/index.ts b/src/lib/db/providers/document/mongodb/index.ts
index e6806f9a5b..04c40e3f70 100644
--- a/src/lib/db/providers/document/mongodb/index.ts
+++ b/src/lib/db/providers/document/mongodb/index.ts
@@ -1979,6 +1979,9 @@ export class MongoDBProvider extends BaseDatabaseProvider {
return (currentOps.inprog || []).slice(0, limit).map((op: Document) => {
const microseconds = op.microsecs_running || 0;
const durationMs = microseconds / 1000;
+ // `currentOp.active` is the operation's own flag. The row shows that word, and the
+ // cards count it: a statement in flight is Active, and one that is not is Idle.
+ const activity = op.active ? "active" : "idle";
return {
pid: op.opid || "N/A",
@@ -1986,7 +1989,8 @@ export class MongoDBProvider extends BaseDatabaseProvider {
database: op.ns?.split(".")[0] || this.getDatabaseName(),
applicationName: op.appName || undefined,
clientAddr: op.client?.split(":")[0] || undefined,
- state: op.active ? "active" : "idle",
+ state: activity,
+ activity,
query: JSON.stringify(op.command || {}).substring(0, 500),
duration: this.formatDurationString(durationMs),
durationMs,
diff --git a/src/lib/db/providers/graph/neo4j/monitoring.ts b/src/lib/db/providers/graph/neo4j/monitoring.ts
index 1886b294b9..5cf912cae7 100644
--- a/src/lib/db/providers/graph/neo4j/monitoring.ts
+++ b/src/lib/db/providers/graph/neo4j/monitoring.ts
@@ -9,7 +9,14 @@
* A count arrives as the transport writes a 64-bit integer: a number, or its decimal string beyond 2^53.
* The overview's text keeps every digit; a numeric field takes the nearest number, as it must.
*/
-import type { ActiveSessionDetails, DatabaseOverview, HealthInfo, IndexStats, TableStats } from "@/lib/db/types";
+import type {
+ ActiveSessionDetails,
+ DatabaseOverview,
+ HealthInfo,
+ IndexStats,
+ SessionActivity,
+ TableStats,
+} from "@/lib/db/types";
import { formatDuration } from "@/lib/db/utils/pool-manager";
/** The release series this provider is verified against; any other server connects and is marked untested. */
@@ -127,14 +134,21 @@ export interface Neo4jTransaction {
}
/**
- * The panel state of a transaction status. The monitoring panels count `state === "active"`, PostgreSQL's
- * word for a statement in flight, which is what Neo4j's `Running` means; the other statuses (`Blocked`,
- * `Closing`, `Terminated`) keep their own words, lower-cased, so no panel counts them as active or idle.
+ * The row's status word. `Running` is shown as "active"; the other statuses (`Blocked`, `Closing`, `Terminated`)
+ * keep their own words, lower-cased. The cards count {@link sessionActivity}.
*/
function sessionState(status: string): string {
return status === "Running" ? "active" : status.toLowerCase();
}
+/**
+ * The card a transaction belongs on. `Running` is a statement in flight (`active`); any other status is `other`,
+ * so a blocked, closing or terminated transaction is counted as neither active nor idle.
+ */
+function sessionActivity(status: string): SessionActivity {
+ return status === "Running" ? "active" : "other";
+}
+
/** One session per transaction, its transaction id as the pid. */
export function toActiveSessions(transactions: readonly Neo4jTransaction[]): ActiveSessionDetails[] {
return transactions.map((transaction) => ({
@@ -142,6 +156,7 @@ export function toActiveSessions(transactions: readonly Neo4jTransaction[]): Act
user: transaction.username,
database: transaction.database,
state: sessionState(transaction.status),
+ activity: sessionActivity(transaction.status),
query: transaction.currentQuery,
queryStart: new Date(transaction.startTime),
duration: formatDuration(transaction.durationMs),
diff --git a/src/lib/db/providers/sql/clickhouse/index.ts b/src/lib/db/providers/sql/clickhouse/index.ts
index 1888439ffd..5b40563f15 100644
--- a/src/lib/db/providers/sql/clickhouse/index.ts
+++ b/src/lib/db/providers/sql/clickhouse/index.ts
@@ -132,8 +132,8 @@ const DEFAULT_SESSION_LIMIT = 50;
/**
* Every row of `system.processes` is a statement the server is executing right
- * now: ClickHouse has no idle-in-transaction equivalent to distinguish, so the
- * state is a constant rather than a column.
+ * now. ClickHouse has no idle session to distinguish, so `state` and `activity`
+ * are both this constant rather than a column.
*/
const RUNNING_STATE = "active";
@@ -1013,6 +1013,7 @@ export class ClickHouseProvider extends SQLBaseProvider {
database: asText(row.database),
clientAddr: asText(row.clientAddr),
state: RUNNING_STATE,
+ activity: RUNNING_STATE,
query: asText(row.query),
duration: this.formatDuration(durationMs),
durationMs,
diff --git a/src/lib/db/providers/sql/databend/introspect.ts b/src/lib/db/providers/sql/databend/introspect.ts
index 1fdb2f9f2f..2491658022 100644
--- a/src/lib/db/providers/sql/databend/introspect.ts
+++ b/src/lib/db/providers/sql/databend/introspect.ts
@@ -14,8 +14,9 @@
* Sessions [X08]: Databend creates a session per HTTP request, so `system.processes` lists one row per running
* statement of the whole warehouse, every user's included, and needs no grant (`user_grant.rs:27-51`). The kill target
* is that row's `id`, a SESSION id Databend makes for the request, never the client session id Studio sends:
- * `KILL QUERY` by the HTTP query id answers 1053, so the query id is never the target. A row's state is the word the
- * monitoring panels count, "active", for the command `Query`, and the command lower-cased otherwise (`Aborting`).
+ * `KILL QUERY` by the HTTP query id answers 1053, so the query id is never the target. A row's state is "active" for
+ * the command `Query`, and the command lower-cased otherwise (`Aborting`). The cards count `activity`: `active` for
+ * `Query`, and `other` for any other command.
* `KILL QUERY` on a session stops its current statement and needs global SUPER.
*
* Studio's own statements are not running work: `system.processes` lists every running statement, the reading one, a
@@ -37,6 +38,7 @@ import type {
HealthInfo,
IndexStats,
PerformanceMetrics,
+ SessionActivity,
SlowQuery,
SlowQueryStats,
StorageStats,
@@ -285,14 +287,21 @@ export async function getSlowQueries(
}
/**
- * The state the monitoring panels count: they count `state === "active"`, PostgreSQL's word for a statement in flight,
- * which is what the command `Query` means; another command (`Aborting`) keeps its own word, lower-cased, so no panel
- * counts it as active or idle, as Neo4j's sessions do.
+ * The row's status word. `Query` is shown as "active"; another command (`Aborting`) keeps its own word, lower-cased.
+ * The cards count {@link sessionActivity}.
*/
function sessionState(command: string): string {
return command === "Query" ? "active" : command.toLowerCase();
}
+/**
+ * The card a session belongs on. `Query` is a statement in flight (`active`); any other command is `other`,
+ * so an aborting session is counted as neither active nor idle.
+ */
+function sessionActivity(command: string): SessionActivity {
+ return command === "Query" ? "active" : "other";
+}
+
/** Every running statement of the warehouse but Studio's own, keyed by its session id [X08]. */
export async function getActiveSessions(
runner: DatabendStatementRunner,
@@ -311,6 +320,7 @@ export async function getActiveSessions(
user: readText(row.user_name),
database: readText(row.database_name),
state: sessionState(readText(row.command)),
+ activity: sessionActivity(readText(row.command)),
query: readText(row.query_text),
duration: formatDuration(durationMs),
durationMs,
diff --git a/src/lib/db/providers/sql/sqlite.ts b/src/lib/db/providers/sql/sqlite.ts
index f7e027b1f3..c5cb748bd9 100644
--- a/src/lib/db/providers/sql/sqlite.ts
+++ b/src/lib/db/providers/sql/sqlite.ts
@@ -2498,6 +2498,8 @@ export class SQLiteProvider extends SQLBaseProvider {
user: "sqlite",
database: path.basename(dbPath),
state: "active",
+ // The one session this process holds is a statement in flight. The cards count `activity`.
+ activity: "active",
query: "",
duration: "N/A",
durationMs: 0,
diff --git a/tests/integration/db/clickhouse-provider.test.ts b/tests/integration/db/clickhouse-provider.test.ts
index 8c42bcf218..757a7d29da 100644
--- a/tests/integration/db/clickhouse-provider.test.ts
+++ b/tests/integration/db/clickhouse-provider.test.ts
@@ -1594,6 +1594,8 @@ describe("ClickHouseProvider monitoring", () => {
expect(sessions[0].database).toBe(DATABASE);
expect(sessions[0].clientAddr).toBe("::ffff:172.17.0.1");
expect(sessions[0].state).toBe("active");
+ expect(sessions[0].activity).toBe("active");
+ expect(sessions[1].activity).toBe("active");
expect(sessions[0].durationMs).toBe(12500);
expect(sessions[0].duration).toBe("12.50s");
expect(sessions[1].durationMs).toBe(1);
diff --git a/tests/integration/db/databend-provider.test.ts b/tests/integration/db/databend-provider.test.ts
index e4bedde6da..41bca81d84 100644
--- a/tests/integration/db/databend-provider.test.ts
+++ b/tests/integration/db/databend-provider.test.ts
@@ -30,6 +30,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { AuthenticationError, ConnectionError, QueryCancelledError, QueryError } from "@/lib/db/errors";
import { DatabendProvider } from "@/lib/db/providers/sql/databend";
+import { DATABEND_DEFAULT_SESSION_LIMIT, databendSessionsSql } from "@/lib/db/providers/sql/databend/introspect";
import type { DatabaseProvider } from "@/lib/db/types";
import { DATABEND_ERROR_SENTENCES } from "@/lib/db/providers/sql/databend/errors";
import { TEMP_TABLES_DROPPED, TRANSACTION_ENDED } from "@/lib/db/providers/sql/databend/session";
@@ -725,6 +726,62 @@ describe("Databend v1.2.951-nightly, replayed", () => {
record();
});
+ flow("a running statement counts as active and an aborting one as other", async () => {
+ const sessionsSql = databendSessionsSql(DATABEND_DEFAULT_SESSION_LIMIT);
+ const { provider } = replay(
+ {},
+ {
+ answer: (sql) => {
+ if (sql !== sessionsSql) return connectAnswer(sql);
+ return {
+ built: {
+ schema: [
+ text("session_id"),
+ text("query_id"),
+ text("user_name"),
+ text("host", "Nullable(String)"),
+ text("database_name"),
+ text("command"),
+ text("query_text"),
+ text("created_time", "Timestamp"),
+ text("elapsed_seconds", "UInt64"),
+ ],
+ data: [
+ [
+ "5c00e52f-34c2-4cab-8ba8-c1f1121157bf",
+ "q-query",
+ "studio_reader",
+ "172.20.0.1",
+ "libredb_demo",
+ "Query",
+ "SELECT 1",
+ "2026-10-07 22:58:07.375243",
+ "3",
+ ],
+ [
+ "6d11f63a-45d3-5dbc-9cb9-d2e2232268c0",
+ "q-abort",
+ "studio_reader",
+ "172.20.0.1",
+ "libredb_demo",
+ "Aborting",
+ "SELECT 2",
+ "2026-10-07 22:58:08.375243",
+ "1",
+ ],
+ ],
+ },
+ };
+ },
+ },
+ );
+ await provider.connect();
+ expect((await provider.getActiveSessions()).map((session) => [session.state, session.activity])).toEqual([
+ ["active", "active"],
+ ["aborting", "other"],
+ ]);
+ });
+
test("every captured exchange is replayed, except the ones Studio's flow never asks for", () => {
const missing = flows.filter((title) => !finished.has(title));
if (missing.length > 0) {
diff --git a/tests/integration/db/mongodb-provider.test.ts b/tests/integration/db/mongodb-provider.test.ts
index 073087c167..8e1756d852 100644
--- a/tests/integration/db/mongodb-provider.test.ts
+++ b/tests/integration/db/mongodb-provider.test.ts
@@ -1841,6 +1841,7 @@ describe("MongoDBProvider", () => {
expect(sessions[0].applicationName).toBe("mongosh");
expect(sessions[0].clientAddr).toBe("10.0.0.1");
expect(sessions[0].state).toBe("active");
+ expect(sessions[0].activity).toBe("active");
expect(sessions[0].query).toContain("find");
expect(sessions[0].duration).toBe("500ms");
expect(sessions[0].durationMs).toBe(500);
@@ -1854,6 +1855,7 @@ describe("MongoDBProvider", () => {
expect(sessions[1].applicationName).toBeUndefined();
expect(sessions[1].clientAddr).toBeUndefined();
expect(sessions[1].state).toBe("idle");
+ expect(sessions[1].activity).toBe("idle");
expect(sessions[1].waitEventType).toBeUndefined();
expect(sessions[1].waitEvent).toBeUndefined();
diff --git a/tests/integration/db/neo4j-provider.test.ts b/tests/integration/db/neo4j-provider.test.ts
index 1861b548b2..970dfdc2d1 100644
--- a/tests/integration/db/neo4j-provider.test.ts
+++ b/tests/integration/db/neo4j-provider.test.ts
@@ -163,7 +163,13 @@ describe("the monitoring reads (spec 7)", () => {
indexCount: 2,
});
const [session] = await provider.getActiveSessions();
- expect(session).toMatchObject({ pid: "neo4j-transaction-18", user: "neo4j", state: "active", durationMs: 101 });
+ expect(session).toMatchObject({
+ pid: "neo4j-transaction-18",
+ user: "neo4j",
+ state: "active",
+ activity: "active",
+ durationMs: 101,
+ });
expect((await provider.getTableStats()).map((row) => [row.tableName, row.rowCount])).toEqual([
["Back`tick", 2],
["Marker", 2],
diff --git a/tests/integration/db/sqlite-provider.test.ts b/tests/integration/db/sqlite-provider.test.ts
index 666a248f52..fab229bcaa 100644
--- a/tests/integration/db/sqlite-provider.test.ts
+++ b/tests/integration/db/sqlite-provider.test.ts
@@ -972,6 +972,7 @@ describe("SQLiteProvider", () => {
expect(session.pid).toBe(process.pid);
expect(session.user).toBe("sqlite");
expect(session.state).toBe("active");
+ expect(session.activity).toBe("active");
});
});
diff --git a/tests/unit/db/databend/introspect.test.ts b/tests/unit/db/databend/introspect.test.ts
index 6babf596f8..d0c0f51bb1 100644
--- a/tests/unit/db/databend/introspect.test.ts
+++ b/tests/unit/db/databend/introspect.test.ts
@@ -427,7 +427,7 @@ describe("limits", () => {
});
describe("sessions and the kill [X08]", () => {
- test("a row maps id to pid, the kill target, and a running statement's state is the word the panels count", async () => {
+ test("a row maps id to pid, the kill target, and a running statement counts as active", async () => {
const { runner } = routed({ [databendSessionsSql(5)]: outcome(SESSION_SCHEMA, [SESSION_ROW]) });
expect(await getActiveSessions(runner, { limit: 5 })).toEqual([
{
@@ -436,6 +436,7 @@ describe("sessions and the kill [X08]", () => {
database: "libredb_demo",
clientAddr: "172.20.0.1",
state: "active",
+ activity: "active",
query: "SELECT 1",
queryStart: new Date("2026-10-07T22:58:07.375Z"),
duration: "3.00s",
@@ -444,18 +445,19 @@ describe("sessions and the kill [X08]", () => {
]);
});
- // The Sessions, Overview and Operations panels count `state === "active"`, PostgreSQL's word for a statement in
- // flight, which is what Databend's command `Query` means (F8); `Aborting` keeps its own word, lower-cased, so no
- // card counts it as active or idle. The sessions read leaves `Idle` out.
+ // The cards count `activity`. `Query` is a statement in flight, so it is `active`; `Aborting` keeps its own
+ // word on the row, lower-cased, and counts as `other`, neither active nor idle (F8). The sessions read leaves
+ // `Idle` out.
test.each([
- ["Query", "active"],
- ["Aborting", "aborting"],
- ])("the command %s is the panel state %s", async (command, state) => {
+ ["Query", "active", "active"],
+ ["Aborting", "aborting", "other"],
+ ] as const)("the command %s is the row state %s and the card %s", async (command, state, activity) => {
const row = [...SESSION_ROW];
row[5] = command;
const { runner } = routed({ [databendSessionsSql(5)]: outcome(SESSION_SCHEMA, [row]) });
const [session] = await getActiveSessions(runner, { limit: 5 });
expect(session.state).toBe(state);
+ expect(session.activity).toBe(activity);
});
test("a session with no host and an unreadable time keeps only what it has", async () => {
diff --git a/tests/unit/db/databend/provider-doc.test.ts b/tests/unit/db/databend/provider-doc.test.ts
index d3da095ddf..33a3a2a72a 100644
--- a/tests/unit/db/databend/provider-doc.test.ts
+++ b/tests/unit/db/databend/provider-doc.test.ts
@@ -1435,10 +1435,10 @@ describe("docs/providers/databend.md quotes what the code says", () => {
);
});
- test("the session state the monitoring section names is the word the panels count (F8)", async () => {
+ test("the session row the monitoring section names, and the card activity the panels count (F8)", async () => {
const monitoring = flat(sectionOf(DOC, "## 7. Monitoring & health"));
expect(monitoring).toContain(
- 'Its state is "active" while it runs a statement (`command` `Query`), the word the Active card and the Overview count, and "aborting" (`Aborting`) while a `KILL CONNECTION` or a server shutdown ends its session; the panel\'s own kill, a `KILL QUERY`, leaves it "active" until the statement stops.',
+ 'Its state is "active" while it runs a statement (`command` `Query`) and "aborting" (`Aborting`) while a `KILL CONNECTION` or a server shutdown ends its session; the panel\'s own kill, a `KILL QUERY`, leaves it "active" until the statement stops. `activity` is `active` for `Query` and `other` for any other command, and the Active and Idle cards count `activity`.',
);
expect(monitoring).not.toContain("(query [query id])");
const columns = ["session_id", "user_name", "host", "database_name", "command", "query_text"];
@@ -1456,7 +1456,10 @@ describe("docs/providers/databend.md quotes what the code says", () => {
affect: null,
};
};
- expect((await getActiveSessions(runner)).map((session) => session.state)).toEqual(["active", "aborting"]);
+ expect((await getActiveSessions(runner)).map((session) => [session.state, session.activity])).toEqual([
+ ["active", "active"],
+ ["aborting", "other"],
+ ]);
});
test("section 7 leaves Studio's own statements out by the query id Studio sent them under (CL-OPS-1)", async () => {
diff --git a/tests/unit/db/databend/provider.test.ts b/tests/unit/db/databend/provider.test.ts
index ce04a7fe66..1d3dfcb554 100644
--- a/tests/unit/db/databend/provider.test.ts
+++ b/tests/unit/db/databend/provider.test.ts
@@ -1552,10 +1552,9 @@ describe("Studio's own statements are left out of the Sessions panel and the act
// Settled by the disconnect after the test when an assertion fails first.
listing.catch(() => {});
await until(() => fake.sqls().includes(SQL.catalogs));
- expect((await provider.getActiveSessions()).map((session) => session.pid)).toEqual([
- USER.session,
- LITERAL_LED.session,
- ]);
+ const sessions = await provider.getActiveSessions();
+ expect(sessions.map((session) => session.pid)).toEqual([USER.session, LITERAL_LED.session]);
+ expect(sessions.map((session) => session.activity)).toEqual(["active", "active"]);
expect((await provider.getOverview()).activeConnections).toBe(2);
tree.open({ schema: [column("catalog_name")], data: [["default"]] });
expect((await listing).map((container) => container.name)).toEqual(["default"]);
diff --git a/tests/unit/db/neo4j/monitoring-reads.test.ts b/tests/unit/db/neo4j/monitoring-reads.test.ts
index 400cdb1f99..3d2c0ec2df 100644
--- a/tests/unit/db/neo4j/monitoring-reads.test.ts
+++ b/tests/unit/db/neo4j/monitoring-reads.test.ts
@@ -214,6 +214,7 @@ describe("readActiveSessions", () => {
user: "neo4j",
database: "neo4j",
state: "active",
+ activity: "active",
query: transactions,
queryStart: new Date("2026-10-03T05:18:20.881Z"),
duration: "101ms",
diff --git a/tests/unit/db/neo4j/monitoring.test.ts b/tests/unit/db/neo4j/monitoring.test.ts
index b3baa49e29..f673fb4a6d 100644
--- a/tests/unit/db/neo4j/monitoring.test.ts
+++ b/tests/unit/db/neo4j/monitoring.test.ts
@@ -116,15 +116,14 @@ describe("isoDurationMs", () => {
});
describe("toActiveSessions", () => {
- // The monitoring panels count `state === "active"` (SessionsTab, OverviewTab, OperationsTab), the word
- // PostgreSQL gives a statement in flight; Neo4j's `Running` is that state, and its other statuses keep
- // their own words, lower-cased, so they are counted as neither active nor idle.
+ // The cards count `activity`. `Running` is a statement in flight, so it is `active`; the other statuses
+ // keep their own words on the row, lower-cased, and count as `other`, neither active nor idle.
test.each([
- ["Running", "active"],
- ["Blocked", "blocked"],
- ["Closing", "closing"],
- ["Terminated", "terminated"],
- ])("maps the transaction status %s to the panel state %s", (status, state) => {
+ ["Running", "active", "active"],
+ ["Blocked", "blocked", "other"],
+ ["Closing", "closing", "other"],
+ ["Terminated", "terminated", "other"],
+ ] as const)("maps the transaction status %s to the row state %s and the card %s", (status, state, activity) => {
const [session] = toActiveSessions([
{
database: "neo4j",
@@ -137,6 +136,7 @@ describe("toActiveSessions", () => {
},
]);
expect(session?.state).toBe(state);
+ expect(session?.activity).toBe(activity);
});
test("one session per transaction, its id as the pid", () => {
@@ -158,6 +158,7 @@ describe("toActiveSessions", () => {
user: "neo4j",
database: "neo4j",
state: "active",
+ activity: "active",
query: "MATCH (n) RETURN n",
queryStart: new Date("2026-10-03T05:18:20.881Z"),
duration: "101ms",
From 8d330272a8a6403ec3e22e3147a2a93f3ba93b8c Mon Sep 17 00:00:00 2001
From: JaredAung <133064427+JaredAung@users.noreply.github.com>
Date: Fri, 23 Oct 2026 21:43:58 -0700
Subject: [PATCH 9/9] test(operations): pin the four session cards at N/A when
the session read is refused
---
tests/components/admin/OperationsTab.test.tsx | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/tests/components/admin/OperationsTab.test.tsx b/tests/components/admin/OperationsTab.test.tsx
index 83f837ee87..5f78bf5e42 100644
--- a/tests/components/admin/OperationsTab.test.tsx
+++ b/tests/components/admin/OperationsTab.test.tsx
@@ -1922,6 +1922,24 @@ describe("OperationsTab", () => {
expect(renderResult!.getByText("Sessions")).not.toBeNull();
});
+ // The sentence above reads `sessionsUnavailable` itself. The four cards read it only through the
+ // boolean passed to `sessionActivityFigure`. `activeSessions` stays off this payload so N/A depends
+ // on that argument: a list of unclassified rows would read N/A with the flag false.
+ test("a refused activeSessions read leaves the four cards at N/A", async () => {
+ monitoringOverride = {
+ data: { tables: defaultTables, errors: { activeSessions: "Unknown table 'information_schema.PROCESSLIST'." } },
+ };
+ let renderResult: ReturnType;
+ await act(async () => {
+ renderResult = render();
+ });
+
+ expect(renderResult!.getByTestId("operations-session-stat-active").textContent).toBe("N/A");
+ expect(renderResult!.getByTestId("operations-session-stat-idle").textContent).toBe("N/A");
+ expect(renderResult!.getByTestId("operations-session-stat-in-tx").textContent).toBe("N/A");
+ expect(renderResult!.getByTestId("operations-session-stat-wait").textContent).toBe("N/A");
+ });
+
test("an answered empty session list keeps the empty-state copy", async () => {
monitoringOverride = { data: { activeSessions: [], tables: defaultTables } };
let renderResult: ReturnType;