@deepsql/mcp 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/cli.js +64 -1
- package/src/commands/_users.js +55 -0
- package/src/commands/access.js +225 -0
- package/src/commands/admin.test.js +135 -0
- package/src/commands/permissions.js +149 -0
- package/src/commands/setup.js +220 -0
- package/src/commands/slow-queries.js +340 -0
- package/src/commands/users.js +276 -0
- package/src/ui/editor.js +61 -0
- package/src/ui/prompts.js +60 -0
- package/src/ui/sse.js +97 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql setup` — post-install admin config wizard.
|
|
5
|
+
*
|
|
6
|
+
* Org name and LLM config are set at install time (env vars / install script /
|
|
7
|
+
* bootstrap link), so the CLI wizard only covers what's left after that:
|
|
8
|
+
*
|
|
9
|
+
* 1. Optionally configure SMTP / email (PUT /admin/settings/email +
|
|
10
|
+
* POST /admin/settings/email/test).
|
|
11
|
+
* 2. Optionally configure Slack — bot token, signing secret, optional app
|
|
12
|
+
* token for Socket Mode (PUT /admin/settings/slack). The backend masks
|
|
13
|
+
* existing tokens (GET only returns *Configured booleans), so leaving a
|
|
14
|
+
* token field blank means "keep current value."
|
|
15
|
+
* 3. Mark setup complete (POST /setup/complete) so the web-UI first-run
|
|
16
|
+
* banner clears. Skipped if already complete or with --skip-complete.
|
|
17
|
+
*
|
|
18
|
+
* The wizard is idempotent — re-running picks up current values where the
|
|
19
|
+
* backend exposes them and prefills the prompts.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { ApiError, request } = require("../api/client");
|
|
23
|
+
const { resolveSession } = require("./_session");
|
|
24
|
+
const ui = require("../ui/prompts");
|
|
25
|
+
|
|
26
|
+
async function run(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
27
|
+
const session = resolveSession(opts);
|
|
28
|
+
const log = (msg) => stderr.write(`[deepsql] ${msg}\n`);
|
|
29
|
+
|
|
30
|
+
log("Checking setup status…");
|
|
31
|
+
const status = await request(session.baseUrl, "/setup/status", { token: session.token });
|
|
32
|
+
if (!status?.hasOrganizationInfo || !status?.hasLlmConfig) {
|
|
33
|
+
stderr.write(
|
|
34
|
+
"[deepsql] Note: organization or LLM config is not set. Those are configured at install time " +
|
|
35
|
+
"(env vars, install script, or bootstrap link), not by this wizard.\n",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await stepEmail(session, opts, log);
|
|
40
|
+
await stepSlack(session, opts, log);
|
|
41
|
+
|
|
42
|
+
if (!opts.skipComplete && !status?.setupComplete) {
|
|
43
|
+
await request(session.baseUrl, "/setup/complete", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
token: session.token,
|
|
46
|
+
json: {},
|
|
47
|
+
});
|
|
48
|
+
log("Setup marked complete.");
|
|
49
|
+
} else if (status?.setupComplete) {
|
|
50
|
+
log("Setup was already complete.");
|
|
51
|
+
}
|
|
52
|
+
stdout.write("Done.\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── email ─────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
async function stepEmail(session, opts, log) {
|
|
58
|
+
if (opts.skipEmail) {
|
|
59
|
+
log("Skipping SMTP setup (--skip-email).");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const want = await ui.confirm({
|
|
63
|
+
message: "Configure SMTP / email now?",
|
|
64
|
+
default: true,
|
|
65
|
+
});
|
|
66
|
+
if (!want) {
|
|
67
|
+
log("Skipped SMTP.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let current = {};
|
|
72
|
+
try {
|
|
73
|
+
current = await request(session.baseUrl, "/admin/settings/email", { token: session.token });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
76
|
+
log("Cannot configure SMTP: requires ADMIN role on the calling token.");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const host = await ui.input({ message: "SMTP host:", default: current?.host || "smtp.gmail.com" });
|
|
83
|
+
const port = Number(
|
|
84
|
+
await ui.input({ message: "SMTP port:", default: String(current?.port || 587), required: true }),
|
|
85
|
+
);
|
|
86
|
+
const username = await ui.input({ message: "SMTP username:", default: current?.username || "" });
|
|
87
|
+
const password = await ui.password({ message: "SMTP password / app password:" });
|
|
88
|
+
const fromAddress = await ui.input({
|
|
89
|
+
message: "From address:",
|
|
90
|
+
default: current?.fromAddress || username,
|
|
91
|
+
});
|
|
92
|
+
const fromName = await ui.input({
|
|
93
|
+
message: "From name:",
|
|
94
|
+
default: current?.fromName || "DeepSQL",
|
|
95
|
+
required: false,
|
|
96
|
+
});
|
|
97
|
+
const startTls = await ui.confirm({ message: "Use STARTTLS?", default: current?.startTls ?? true });
|
|
98
|
+
|
|
99
|
+
const body = { host, port, username, password, fromAddress, fromName, startTls };
|
|
100
|
+
await request(session.baseUrl, "/admin/settings/email", {
|
|
101
|
+
method: "PUT",
|
|
102
|
+
token: session.token,
|
|
103
|
+
json: body,
|
|
104
|
+
});
|
|
105
|
+
log("SMTP saved.");
|
|
106
|
+
|
|
107
|
+
const sendTest = await ui.confirm({ message: "Send a test email now?", default: true });
|
|
108
|
+
if (sendTest) {
|
|
109
|
+
const recipient = await ui.input({
|
|
110
|
+
message: "Test recipient (your email):",
|
|
111
|
+
default: fromAddress,
|
|
112
|
+
required: true,
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
await request(session.baseUrl, "/admin/settings/email/test", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
token: session.token,
|
|
118
|
+
json: { recipient },
|
|
119
|
+
});
|
|
120
|
+
log(`Test email sent to ${recipient}.`);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log(`Test send failed: ${err.message}. Settings saved anyway.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── slack ─────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
async function stepSlack(session, opts, log) {
|
|
130
|
+
if (opts.skipSlack) {
|
|
131
|
+
log("Skipping Slack setup (--skip-slack).");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const want = await ui.confirm({
|
|
135
|
+
message: "Configure Slack (digests + bot replies) now?",
|
|
136
|
+
default: false,
|
|
137
|
+
});
|
|
138
|
+
if (!want) {
|
|
139
|
+
log("Skipped Slack.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let current = {};
|
|
144
|
+
try {
|
|
145
|
+
current = await request(session.baseUrl, "/admin/settings/slack", { token: session.token });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
148
|
+
log("Cannot configure Slack: requires ADMIN role on the calling token.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const enabled = await ui.confirm({
|
|
155
|
+
message: "Enable Slack integration?",
|
|
156
|
+
default: current?.enabled ?? true,
|
|
157
|
+
});
|
|
158
|
+
const socketModeEnabled = await ui.confirm({
|
|
159
|
+
message:
|
|
160
|
+
"Use Socket Mode? (Easier for self-hosted — no public webhook needed; requires an App-level token.)",
|
|
161
|
+
default: current?.socketModeEnabled ?? true,
|
|
162
|
+
});
|
|
163
|
+
const deepsqlBotUsername = await ui.input({
|
|
164
|
+
message: "Bot display name in Slack:",
|
|
165
|
+
default: current?.deepsqlBotUsername || "DeepSQL",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// The backend only echoes *Configured booleans for tokens, so we never have
|
|
169
|
+
// the existing values to pre-fill. A blank entry means "keep current."
|
|
170
|
+
const tokenHint = (label, configuredFlag) =>
|
|
171
|
+
configuredFlag ? `${label} (leave blank to keep current):` : `${label}:`;
|
|
172
|
+
|
|
173
|
+
const botToken = await ui.password({
|
|
174
|
+
message: tokenHint("Bot token (xoxb-…)", current?.botTokenConfigured),
|
|
175
|
+
});
|
|
176
|
+
const signingSecret = await ui.password({
|
|
177
|
+
message: tokenHint("Signing secret", current?.signingSecretConfigured),
|
|
178
|
+
});
|
|
179
|
+
const appToken = socketModeEnabled
|
|
180
|
+
? await ui.password({
|
|
181
|
+
message: tokenHint("App-level token (xapp-…)", current?.appTokenConfigured),
|
|
182
|
+
})
|
|
183
|
+
: "";
|
|
184
|
+
|
|
185
|
+
// Build the PUT body. Empty token strings are intentionally omitted so the
|
|
186
|
+
// service treats them as "keep current"; non-blank values overwrite.
|
|
187
|
+
const body = {
|
|
188
|
+
enabled,
|
|
189
|
+
socketModeEnabled,
|
|
190
|
+
deepsqlBotUsername,
|
|
191
|
+
};
|
|
192
|
+
if (botToken && botToken.trim()) body.botToken = botToken.trim();
|
|
193
|
+
if (signingSecret && signingSecret.trim()) body.signingSecret = signingSecret.trim();
|
|
194
|
+
if (appToken && appToken.trim()) body.appToken = appToken.trim();
|
|
195
|
+
|
|
196
|
+
// Reject the case where the user is enabling Slack for the first time but
|
|
197
|
+
// gave us no tokens — saving would leave the integration broken.
|
|
198
|
+
const noBot = !current?.botTokenConfigured && !body.botToken;
|
|
199
|
+
const noSecret = !current?.signingSecretConfigured && !body.signingSecret;
|
|
200
|
+
const noAppToken = socketModeEnabled && !current?.appTokenConfigured && !body.appToken;
|
|
201
|
+
if (enabled && (noBot || noSecret || noAppToken)) {
|
|
202
|
+
const missing = [
|
|
203
|
+
noBot && "bot token",
|
|
204
|
+
noSecret && "signing secret",
|
|
205
|
+
noAppToken && "app-level token",
|
|
206
|
+
]
|
|
207
|
+
.filter(Boolean)
|
|
208
|
+
.join(", ");
|
|
209
|
+
throw new Error(`Slack is enabled but missing: ${missing}. Aborting before save.`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await request(session.baseUrl, "/admin/settings/slack", {
|
|
213
|
+
method: "PUT",
|
|
214
|
+
token: session.token,
|
|
215
|
+
json: body,
|
|
216
|
+
});
|
|
217
|
+
log("Slack saved.");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { run };
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql slow-queries` — read slow-query analyses, trigger new ones, stream
|
|
5
|
+
* AI optimization steps live, and clean up history.
|
|
6
|
+
*
|
|
7
|
+
* deepsql slow-queries latest --connection <name> [--json]
|
|
8
|
+
* deepsql slow-queries history --connection <name> [N] [--json]
|
|
9
|
+
* deepsql slow-queries analyze --connection <name>
|
|
10
|
+
* [--time-range LAST_24_HOURS|LAST_HOUR] [--threshold-ms <ms>] [--limit <n>]
|
|
11
|
+
* deepsql slow-queries optimize --connection <name> --query-id <id>
|
|
12
|
+
* (streams SSE; use --query-text to skip history lookup)
|
|
13
|
+
* deepsql slow-queries delete --history-id <id> [--yes]
|
|
14
|
+
*
|
|
15
|
+
* The optimize subcommand follows the SSE protocol from
|
|
16
|
+
* /slow-queries/optimize/stream — `step` events go to stderr, the final
|
|
17
|
+
* `result` event prints to stdout. Honours SIGINT.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { ApiError, request } = require("../api/client");
|
|
21
|
+
const { streamSse } = require("../ui/sse");
|
|
22
|
+
const { resolveSession } = require("./_session");
|
|
23
|
+
const { resolveConnectionId } = require("./_connections");
|
|
24
|
+
const ui = require("../ui/prompts");
|
|
25
|
+
|
|
26
|
+
const SUBCOMMANDS = {
|
|
27
|
+
latest: cmdLatest,
|
|
28
|
+
history: cmdHistory,
|
|
29
|
+
analyze: cmdAnalyze,
|
|
30
|
+
optimize: cmdOptimize,
|
|
31
|
+
delete: cmdDelete,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
async function run(opts, io = {}) {
|
|
35
|
+
const sub = opts.positional[0];
|
|
36
|
+
if (!sub) {
|
|
37
|
+
throw new Error("Usage: deepsql slow-queries <latest|history|analyze|optimize|delete> ...");
|
|
38
|
+
}
|
|
39
|
+
const handler = SUBCOMMANDS[sub];
|
|
40
|
+
if (!handler) throw new Error(`Unknown slow-queries subcommand: ${sub}.`);
|
|
41
|
+
if (!opts.connection && sub !== "delete") {
|
|
42
|
+
throw new Error("--connection <name> is required.");
|
|
43
|
+
}
|
|
44
|
+
return wrap(handler)({ ...opts, positional: opts.positional.slice(1) }, io);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function wrap(handler) {
|
|
48
|
+
return async (opts, io) => {
|
|
49
|
+
try {
|
|
50
|
+
return await handler(opts, io);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
53
|
+
throw new Error("Access denied — slow-query operations require permissions on this connection.");
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── latest ────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function cmdLatest(opts, { stdout = process.stdout } = {}) {
|
|
63
|
+
const session = resolveSession(opts);
|
|
64
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
65
|
+
const result = await request(
|
|
66
|
+
session.baseUrl,
|
|
67
|
+
`/slow-queries/latest/${encodeURIComponent(connectionId)}`,
|
|
68
|
+
{ token: session.token },
|
|
69
|
+
);
|
|
70
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── history ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async function cmdHistory(opts, { stdout = process.stdout } = {}) {
|
|
76
|
+
const session = resolveSession(opts);
|
|
77
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
78
|
+
const summaries = await request(
|
|
79
|
+
session.baseUrl,
|
|
80
|
+
`/slow-queries/history/${encodeURIComponent(connectionId)}`,
|
|
81
|
+
{ token: session.token },
|
|
82
|
+
);
|
|
83
|
+
const items = Array.isArray(summaries) ? summaries : [];
|
|
84
|
+
const limitArg = parseCount(opts.positional[0]) || parseCount(opts.count);
|
|
85
|
+
const trimmed = limitArg ? items.slice(0, limitArg) : items;
|
|
86
|
+
|
|
87
|
+
if (opts.json) {
|
|
88
|
+
stdout.write(`${JSON.stringify(trimmed, null, 2)}\n`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (trimmed.length === 0) {
|
|
92
|
+
stdout.write("No history.\n");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
printHistory(stdout, trimmed);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── analyze ───────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
async function cmdAnalyze(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
101
|
+
const session = resolveSession(opts);
|
|
102
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
103
|
+
const timeRange = (opts.timeRange || "LAST_24_HOURS").toUpperCase();
|
|
104
|
+
const thresholdMs = parseNum(opts.thresholdMs) || 100;
|
|
105
|
+
const limit = parseNum(opts.limit) || 10;
|
|
106
|
+
|
|
107
|
+
stderr.write(`Analyzing slow queries on ${opts.connection} (range=${timeRange}, threshold=${thresholdMs}ms)…\n`);
|
|
108
|
+
const result = await request(session.baseUrl, "/slow-queries/analyze", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
token: session.token,
|
|
111
|
+
timeoutMs: 240000,
|
|
112
|
+
json: { connectionId, timeRange, thresholdMs, limit },
|
|
113
|
+
});
|
|
114
|
+
if (opts.json) {
|
|
115
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const queries = (result && (result.queries || result.slowQueries)) || [];
|
|
119
|
+
if (queries.length === 0) {
|
|
120
|
+
stdout.write("No slow queries detected in the selected window.\n");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
printQueries(stdout, queries);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── optimize (SSE) ────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async function cmdOptimize(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
129
|
+
const session = resolveSession(opts);
|
|
130
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
131
|
+
|
|
132
|
+
let queryText = opts.queryText;
|
|
133
|
+
let queryId = opts.queryId;
|
|
134
|
+
|
|
135
|
+
// If --query-id is given, look up the queryText from the latest analysis so
|
|
136
|
+
// the user doesn't have to paste raw SQL into the terminal.
|
|
137
|
+
if (!queryText) {
|
|
138
|
+
if (!queryId) throw new Error("Pass --query-id <id> or --query-text \"<sql>\".");
|
|
139
|
+
queryText = await lookupQueryText(session, connectionId, queryId);
|
|
140
|
+
if (!queryText) {
|
|
141
|
+
throw new Error(`Could not find query ${queryId} in the latest analysis. Pass --query-text "<sql>" instead.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const controller = new AbortController();
|
|
146
|
+
const onSigint = () => {
|
|
147
|
+
stderr.write("\n[deepsql] cancelling optimization…\n");
|
|
148
|
+
controller.abort();
|
|
149
|
+
};
|
|
150
|
+
process.once("SIGINT", onSigint);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const stream = streamSse(session.baseUrl, "/slow-queries/optimize/stream", {
|
|
154
|
+
token: session.token,
|
|
155
|
+
query: { connectionId, queryText, queryId, sampleQuery: opts.sampleQuery },
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
for await (const message of stream) {
|
|
159
|
+
if (message.event === "step") {
|
|
160
|
+
const parsed = safeParse(message.data);
|
|
161
|
+
const status = parsed?.status || "step";
|
|
162
|
+
const text = parsed?.message || message.data;
|
|
163
|
+
stderr.write(`[${status}] ${text}\n`);
|
|
164
|
+
} else if (message.event === "result") {
|
|
165
|
+
if (opts.json) {
|
|
166
|
+
stdout.write(`${message.data}\n`);
|
|
167
|
+
} else {
|
|
168
|
+
const parsed = safeParse(message.data);
|
|
169
|
+
if (parsed && parsed.optimizedQuery) {
|
|
170
|
+
stdout.write(`\n--- Optimized query ---\n${parsed.optimizedQuery}\n`);
|
|
171
|
+
}
|
|
172
|
+
if (parsed && parsed.recommendations && parsed.recommendations.length) {
|
|
173
|
+
stdout.write("\n--- Recommendations ---\n");
|
|
174
|
+
for (const r of parsed.recommendations) {
|
|
175
|
+
stdout.write(`• ${r.title || r.summary || JSON.stringify(r)}\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!parsed || (!parsed.optimizedQuery && !(parsed.recommendations || []).length)) {
|
|
179
|
+
stdout.write(`${message.data}\n`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} else if (message.event === "error") {
|
|
183
|
+
const parsed = safeParse(message.data);
|
|
184
|
+
throw new Error(`Optimization failed: ${parsed?.message || message.data}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (controller.signal.aborted) {
|
|
189
|
+
process.exitCode = 130; // 128 + SIGINT
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
} finally {
|
|
194
|
+
process.removeListener("SIGINT", onSigint);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function lookupQueryText(session, connectionId, queryId) {
|
|
199
|
+
const latest = await request(
|
|
200
|
+
session.baseUrl,
|
|
201
|
+
`/slow-queries/latest/${encodeURIComponent(connectionId)}`,
|
|
202
|
+
{ token: session.token },
|
|
203
|
+
);
|
|
204
|
+
const data = latest?.analysisData || latest?.analysis || latest || {};
|
|
205
|
+
const queries = data.queries || data.slowQueries || [];
|
|
206
|
+
const hit = queries.find((q) => String(q.queryId || q.id) === String(queryId));
|
|
207
|
+
return hit ? (hit.queryText || hit.normalizedQuery || hit.query) : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── delete ────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
async function cmdDelete(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
213
|
+
const session = resolveSession(opts);
|
|
214
|
+
|
|
215
|
+
if (opts.historyId) {
|
|
216
|
+
if (!opts.yes) {
|
|
217
|
+
const ok = await ui.confirm({
|
|
218
|
+
message: `Delete history entry ${opts.historyId}?`,
|
|
219
|
+
default: false,
|
|
220
|
+
});
|
|
221
|
+
if (!ok) {
|
|
222
|
+
stderr.write("Aborted.\n");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await request(session.baseUrl, `/slow-queries/history/${encodeURIComponent(opts.historyId)}`, {
|
|
227
|
+
method: "DELETE",
|
|
228
|
+
token: session.token,
|
|
229
|
+
});
|
|
230
|
+
stdout.write(`Deleted history entry ${opts.historyId}.\n`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (opts.connection) {
|
|
234
|
+
if (!opts.yes) {
|
|
235
|
+
const ok = await ui.confirm({
|
|
236
|
+
message: `Delete ALL slow-query history for ${opts.connection}? This is destructive.`,
|
|
237
|
+
default: false,
|
|
238
|
+
});
|
|
239
|
+
if (!ok) {
|
|
240
|
+
stderr.write("Aborted.\n");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
245
|
+
await request(
|
|
246
|
+
session.baseUrl,
|
|
247
|
+
`/slow-queries/history/connection/${encodeURIComponent(connectionId)}`,
|
|
248
|
+
{ method: "DELETE", token: session.token },
|
|
249
|
+
);
|
|
250
|
+
stdout.write(`Deleted all history for ${opts.connection}.\n`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
throw new Error("Pass --history-id <id> or --connection <name>.");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function parseCount(v) {
|
|
259
|
+
if (v == null) return null;
|
|
260
|
+
const n = Number.parseInt(v, 10);
|
|
261
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseNum(v) {
|
|
265
|
+
if (v == null) return null;
|
|
266
|
+
const n = Number(v);
|
|
267
|
+
return Number.isFinite(n) ? n : null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function safeParse(text) {
|
|
271
|
+
if (!text) return null;
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(text);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function printHistory(stdout, items) {
|
|
280
|
+
const rows = items.map((h) => ({
|
|
281
|
+
id: String(h.id ?? h.historyId ?? ""),
|
|
282
|
+
createdAt: formatTime(h.createdAt || h.timestamp || h.analysisTime),
|
|
283
|
+
queries: String(h.queryCount ?? h.totalQueries ?? "?"),
|
|
284
|
+
summary: trim(h.summary || h.headline || "", 70),
|
|
285
|
+
}));
|
|
286
|
+
const cols = [
|
|
287
|
+
{ key: "id", label: "ID" },
|
|
288
|
+
{ key: "createdAt", label: "WHEN" },
|
|
289
|
+
{ key: "queries", label: "#" },
|
|
290
|
+
{ key: "summary", label: "SUMMARY" },
|
|
291
|
+
];
|
|
292
|
+
printTable(stdout, cols, rows);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function printQueries(stdout, queries) {
|
|
296
|
+
const rows = queries.map((q) => ({
|
|
297
|
+
id: String(q.queryId || q.id || ""),
|
|
298
|
+
severity: q.severity || "?",
|
|
299
|
+
avgMs: q.avgExecutionTimeMs != null ? Math.round(q.avgExecutionTimeMs).toString() : "?",
|
|
300
|
+
calls: String(q.callCount ?? q.executionCount ?? "?"),
|
|
301
|
+
sample: trim(q.queryText || q.normalizedQuery || q.query || "", 60),
|
|
302
|
+
}));
|
|
303
|
+
const cols = [
|
|
304
|
+
{ key: "id", label: "QUERY ID" },
|
|
305
|
+
{ key: "severity", label: "SEVERITY" },
|
|
306
|
+
{ key: "avgMs", label: "AVG MS" },
|
|
307
|
+
{ key: "calls", label: "CALLS" },
|
|
308
|
+
{ key: "sample", label: "QUERY" },
|
|
309
|
+
];
|
|
310
|
+
printTable(stdout, cols, rows);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function printTable(stdout, cols, rows) {
|
|
314
|
+
const widths = cols.map((c) => Math.max(c.label.length, ...rows.map((r) => r[c.key].length)));
|
|
315
|
+
const header = cols.map((c, i) => c.label.padEnd(widths[i])).join(" ");
|
|
316
|
+
const sep = widths.map((w) => "-".repeat(w)).join(" ");
|
|
317
|
+
stdout.write(`${header}\n${sep}\n`);
|
|
318
|
+
for (const row of rows) {
|
|
319
|
+
stdout.write(`${cols.map((c, i) => row[c.key].padEnd(widths[i])).join(" ")}\n`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatTime(value) {
|
|
324
|
+
if (!value) return "—";
|
|
325
|
+
try {
|
|
326
|
+
const d = new Date(value);
|
|
327
|
+
if (Number.isNaN(d.getTime())) return String(value);
|
|
328
|
+
return d.toISOString().replace("T", " ").slice(0, 16) + "Z";
|
|
329
|
+
} catch {
|
|
330
|
+
return String(value);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function trim(text, max) {
|
|
335
|
+
if (!text) return "";
|
|
336
|
+
const s = String(text).replace(/\s+/g, " ").trim();
|
|
337
|
+
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = { run };
|