queryapigate 0.5.0__py3-none-any.whl
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.
Potentially problematic release.
This version of queryapigate might be problematic. Click here for more details.
- queryapigate/__init__.py +6 -0
- queryapigate/__main__.py +3 -0
- queryapigate/apikeys.py +535 -0
- queryapigate/app.py +844 -0
- queryapigate/cache.py +61 -0
- queryapigate/cli.py +168 -0
- queryapigate/config.py +268 -0
- queryapigate/cors.py +51 -0
- queryapigate/engine.py +166 -0
- queryapigate/errors.py +8 -0
- queryapigate/formats.py +193 -0
- queryapigate/lib/h2-2.2.224.jar +0 -0
- queryapigate/logging_setup.py +75 -0
- queryapigate/metrics.py +215 -0
- queryapigate/openapi.py +410 -0
- queryapigate/params.py +275 -0
- queryapigate/pool.py +146 -0
- queryapigate/ratelimit.py +88 -0
- queryapigate/runners.py +795 -0
- queryapigate/schema.py +99 -0
- queryapigate/sqltools.py +184 -0
- queryapigate/store.py +433 -0
- queryapigate/ui.py +2371 -0
- queryapigate-0.5.0.dist-info/METADATA +708 -0
- queryapigate-0.5.0.dist-info/RECORD +29 -0
- queryapigate-0.5.0.dist-info/WHEEL +5 -0
- queryapigate-0.5.0.dist-info/entry_points.txt +2 -0
- queryapigate-0.5.0.dist-info/licenses/LICENSE +21 -0
- queryapigate-0.5.0.dist-info/top_level.txt +1 -0
queryapigate/ui.py
ADDED
|
@@ -0,0 +1,2371 @@
|
|
|
1
|
+
"""A small admin UI at /ui: connections, saved queries and an ad-hoc SQL runner.
|
|
2
|
+
|
|
3
|
+
Self-contained (no build step, no external script or stylesheet - unlike /docs, which needs the real
|
|
4
|
+
Swagger UI library, this page is simple enough to write by hand). It is a client of the existing JSON API
|
|
5
|
+
only; there is no server-side logic here beyond serving this one static page. Query results are always
|
|
6
|
+
rendered through DOM APIs (createElement/textContent), never innerHTML, so a value coming back from a
|
|
7
|
+
database can never execute as markup.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
UI_HTML = r"""<!doctype html>
|
|
11
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
12
|
+
<title>QueryAPIGate</title>
|
|
13
|
+
<style>
|
|
14
|
+
:root {
|
|
15
|
+
color-scheme: light dark;
|
|
16
|
+
--bg: light-dark(#f5f5f3, #131416);
|
|
17
|
+
--surface: light-dark(#ffffff, #1a1b1e);
|
|
18
|
+
--surface-2: light-dark(#f0f0ed, #212226);
|
|
19
|
+
--surface-3: light-dark(#e8e8e4, #2a2b30);
|
|
20
|
+
--line: light-dark(#e2e2de, #2b2c31);
|
|
21
|
+
--line-strong: light-dark(#cdcdc8, #3a3b41);
|
|
22
|
+
--ink: light-dark(#1b1c1f, #e7e7e4);
|
|
23
|
+
--ink-2: light-dark(#56585e, #a6a7ac);
|
|
24
|
+
--ink-3: light-dark(#85878d, #74767c);
|
|
25
|
+
--accent: light-dark(oklch(0.52 0.11 170), oklch(0.78 0.11 170));
|
|
26
|
+
--accent-ink: light-dark(#ffffff, #0c1714);
|
|
27
|
+
--accent-soft: light-dark(oklch(0.52 0.11 170 / 0.1), oklch(0.78 0.11 170 / 0.13));
|
|
28
|
+
--danger: light-dark(oklch(0.53 0.17 27), oklch(0.74 0.14 27));
|
|
29
|
+
--danger-soft: light-dark(oklch(0.53 0.17 27 / 0.08), oklch(0.74 0.14 27 / 0.12));
|
|
30
|
+
--warn: light-dark(oklch(0.6 0.13 75), oklch(0.8 0.12 80));
|
|
31
|
+
--syn-kw: light-dark(oklch(0.48 0.14 265), oklch(0.78 0.1 265));
|
|
32
|
+
--syn-str: light-dark(oklch(0.5 0.12 145), oklch(0.8 0.11 145));
|
|
33
|
+
--syn-num: light-dark(oklch(0.55 0.13 55), oklch(0.8 0.11 65));
|
|
34
|
+
--syn-param: light-dark(oklch(0.5 0.15 330), oklch(0.78 0.12 330));
|
|
35
|
+
--syn-cmt: var(--ink-3);
|
|
36
|
+
--shadow: light-dark(0 12px 40px rgb(0 0 0 / 0.12), 0 12px 40px rgb(0 0 0 / 0.5));
|
|
37
|
+
--sans: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
38
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
39
|
+
}
|
|
40
|
+
* { box-sizing: border-box; }
|
|
41
|
+
[hidden] { display: none !important; }
|
|
42
|
+
html, body { height: 100%; }
|
|
43
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 13px/1.45 var(--sans); -webkit-font-smoothing: antialiased; }
|
|
44
|
+
a { color: var(--ink-2); text-decoration: none; }
|
|
45
|
+
a:hover { color: var(--accent); }
|
|
46
|
+
code, .mono { font-family: var(--mono); font-size: 12px; }
|
|
47
|
+
h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.005em; }
|
|
48
|
+
::selection { background: var(--accent-soft); }
|
|
49
|
+
|
|
50
|
+
/* ---- top bar ---- */
|
|
51
|
+
header.top { position: sticky; top: 0; z-index: 20; display: flex; align-items: stretch; gap: 16px; height: 48px;
|
|
52
|
+
padding: 0 20px; background: var(--surface); border-bottom: 1px solid var(--line); }
|
|
53
|
+
.brand { flex: none; display: flex; align-items: center; gap: 10px; }
|
|
54
|
+
.wordmark { font: 700 13px/1 var(--mono); letter-spacing: 0.02em; }
|
|
55
|
+
.wordmark b { color: var(--accent); font-weight: 700; }
|
|
56
|
+
.health { display: inline-flex; align-items: center; gap: 6px; font: 11px/1 var(--mono); color: var(--ink-3);
|
|
57
|
+
padding: 4px 7px; border: 1px solid var(--line); border-radius: 20px; }
|
|
58
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); flex: none; display: inline-block; }
|
|
59
|
+
.dot.ok { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
60
|
+
.dot.bad { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
|
|
61
|
+
.dot.off { background: transparent; border: 1.5px solid var(--ink-3); }
|
|
62
|
+
/* When the header is tight, the tab row scrolls rather than squeezing the key bar off-screen. */
|
|
63
|
+
#tabs { display: flex; align-items: stretch; gap: 2px; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
|
64
|
+
#tabs button { position: relative; display: flex; align-items: center; gap: 7px; border: 0; background: none; padding: 0 9px;
|
|
65
|
+
font: 500 13px var(--sans); color: var(--ink-2); cursor: pointer; white-space: nowrap; }
|
|
66
|
+
#tabs button:hover { color: var(--ink); }
|
|
67
|
+
#tabs button.active { color: var(--ink); }
|
|
68
|
+
#tabs button.active::after { content: ""; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2px; background: var(--accent); border-radius: 2px 2px 0 0; }
|
|
69
|
+
.count { font: 11px/1 var(--mono); color: var(--ink-3); background: var(--surface-2); padding: 3px 5px; border-radius: 4px; min-width: 18px; text-align: center; }
|
|
70
|
+
.count:empty { display: none; }
|
|
71
|
+
.top-end { flex: none; margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
|
72
|
+
.top-end > a { font-size: 12.5px; white-space: nowrap; }
|
|
73
|
+
.chip { font: 11px/1 var(--mono); color: var(--ink-2); padding: 5px 7px; border-radius: 4px; background: var(--surface-2); white-space: nowrap; }
|
|
74
|
+
.chip.low { color: var(--danger); background: var(--danger-soft); }
|
|
75
|
+
#key-bar { flex: none; display: flex; align-items: center; gap: 0; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--bg); height: 30px; overflow: hidden; }
|
|
76
|
+
#key-bar label { font-size: 11.5px; color: var(--ink-3); padding: 0 4px 0 9px; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
|
|
77
|
+
#key-bar input { border: 0; background: transparent; width: 150px; height: 28px; padding: 0 6px; font: 12px var(--mono); color: var(--ink); outline: none; }
|
|
78
|
+
#key-bar button { border: 0; border-left: 1px solid var(--line-strong); height: 100%; padding: 0 10px; background: var(--surface); font: 500 12px var(--sans); color: var(--ink); cursor: pointer; }
|
|
79
|
+
#key-bar button:hover { background: var(--surface-2); }
|
|
80
|
+
#key-bar:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
81
|
+
|
|
82
|
+
/* ---- error banner ---- */
|
|
83
|
+
#error-banner { position: sticky; top: 48px; z-index: 19; background: var(--danger-soft); border-bottom: 1px solid var(--danger);
|
|
84
|
+
color: var(--ink); padding: 9px 20px; backdrop-filter: blur(8px); background-color: light-dark(#fbeeed, #2a1a1a); }
|
|
85
|
+
.eb-main { display: flex; align-items: baseline; gap: 10px; }
|
|
86
|
+
.eb-code { font: 600 11px/1 var(--mono); color: var(--accent-ink); background: var(--danger); padding: 3px 6px; border-radius: 4px; }
|
|
87
|
+
.eb-msg { flex: 1; font-weight: 500; overflow-wrap: anywhere; }
|
|
88
|
+
.eb-close { border: 0; background: none; color: var(--ink-2); font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px; }
|
|
89
|
+
.eb-fields { margin: 6px 0 0; padding: 0 0 0 0; list-style: none; display: flex; flex-direction: column; gap: 2px; font-size: 12.5px; color: var(--ink-2); }
|
|
90
|
+
.eb-fields code { color: var(--danger); }
|
|
91
|
+
|
|
92
|
+
/* ---- layout ---- */
|
|
93
|
+
main { padding: 20px 20px 48px; max-width: 1480px; margin: 0 auto; }
|
|
94
|
+
main > section { display: none; }
|
|
95
|
+
main > section.active { display: block; }
|
|
96
|
+
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
97
|
+
.toolbar h2 { font-size: 15px; }
|
|
98
|
+
.toolbar .sub { color: var(--ink-3); font-size: 12.5px; }
|
|
99
|
+
.spacer { flex: 1; }
|
|
100
|
+
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
|
101
|
+
.search { height: 30px; width: 220px; padding: 0 9px; }
|
|
102
|
+
.toolbar select { width: auto; }
|
|
103
|
+
#connections-table, #apikeys-table, #roles-table, #auditlog-table { overflow-x: auto; }
|
|
104
|
+
|
|
105
|
+
/* ---- controls ---- */
|
|
106
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 30px; padding: 0 12px;
|
|
107
|
+
border: 1px solid var(--line-strong); border-radius: 6px; background: var(--surface); color: var(--ink);
|
|
108
|
+
font: 500 12.5px var(--sans); cursor: pointer; white-space: nowrap; }
|
|
109
|
+
.btn:hover { background: var(--surface-2); }
|
|
110
|
+
.btn:disabled { opacity: 0.45; cursor: default; }
|
|
111
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
|
112
|
+
.btn.primary:hover { filter: brightness(1.06); }
|
|
113
|
+
.btn.danger { color: var(--danger); }
|
|
114
|
+
.btn.danger:hover { background: var(--danger-soft); border-color: var(--danger); }
|
|
115
|
+
.btn.ghost { border-color: transparent; background: transparent; }
|
|
116
|
+
.btn.ghost:hover { background: var(--surface-2); }
|
|
117
|
+
.btn.sm { height: 24px; padding: 0 8px; font-size: 12px; border-radius: 5px; }
|
|
118
|
+
.btn.icon { width: 30px; padding: 0; font-size: 17px; }
|
|
119
|
+
kbd { font: 10.5px/1 var(--mono); padding: 2px 4px; border-radius: 3px; border: 1px solid currentColor; opacity: 0.7; }
|
|
120
|
+
input, select, textarea { font: 13px var(--sans); color: var(--ink); background: var(--surface); border: 1px solid var(--line-strong);
|
|
121
|
+
border-radius: 6px; padding: 0 9px; height: 30px; width: 100%; min-width: 0; }
|
|
122
|
+
textarea { height: auto; padding: 8px 9px; font: 12.5px/1.5 var(--mono); resize: vertical; min-height: 84px; }
|
|
123
|
+
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
124
|
+
input:disabled { background: var(--surface-2); color: var(--ink-2); }
|
|
125
|
+
input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); margin: 0; }
|
|
126
|
+
input[type=number] { font-family: var(--mono); font-size: 12.5px; }
|
|
127
|
+
input::placeholder, textarea::placeholder { color: var(--ink-3); }
|
|
128
|
+
.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
|
129
|
+
.field > label { font-size: 12px; font-weight: 500; color: var(--ink-2); display: flex; gap: 6px; align-items: baseline; }
|
|
130
|
+
.field > label .req { color: var(--danger); }
|
|
131
|
+
.field > label .type { font: 11px var(--mono); color: var(--ink-3); font-weight: 400; }
|
|
132
|
+
.hint { font-size: 11.5px; color: var(--ink-3); }
|
|
133
|
+
.grid2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
|
|
134
|
+
.grid-host { display: grid; grid-template-columns: minmax(0, 1fr) 96px; gap: 12px; }
|
|
135
|
+
.password-field { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
136
|
+
.password-field input { min-width: 0; }
|
|
137
|
+
|
|
138
|
+
/* ---- tables ---- */
|
|
139
|
+
table.grid { width: 100%; border-collapse: separate; border-spacing: 0; }
|
|
140
|
+
table.grid th { position: sticky; top: 0; z-index: 1; text-align: left; font: 600 11px var(--sans); letter-spacing: 0.03em; text-transform: uppercase;
|
|
141
|
+
color: var(--ink-3); background: var(--surface); padding: 8px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
|
142
|
+
table.grid td { padding: 7px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
|
143
|
+
table.grid tbody tr:last-child td { border-bottom: 0; }
|
|
144
|
+
table.grid tbody tr:hover td { background: var(--surface-2); }
|
|
145
|
+
.actions { display: flex; gap: 2px; justify-content: flex-end; opacity: 0.55; }
|
|
146
|
+
tr:hover .actions, .actions:focus-within { opacity: 1; }
|
|
147
|
+
.tag { display: inline-block; font: 11px/1.5 var(--mono); padding: 0 6px; border-radius: 4px; background: var(--surface-2); border: 1px solid var(--line); color: var(--ink-2); white-space: nowrap; }
|
|
148
|
+
.tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
149
|
+
.name { font: 600 12.5px var(--mono); }
|
|
150
|
+
.dim { color: var(--ink-3); }
|
|
151
|
+
.empty { padding: 44px 20px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--ink-2); }
|
|
152
|
+
.empty strong { color: var(--ink); font-size: 14px; font-weight: 600; }
|
|
153
|
+
.loading { padding: 28px 16px; color: var(--ink-3); display: flex; align-items: center; gap: 10px; justify-content: center; }
|
|
154
|
+
.spin { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--line-strong); border-top-color: var(--accent); animation: spin 0.7s linear infinite; }
|
|
155
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
156
|
+
|
|
157
|
+
/* ---- saved queries ---- */
|
|
158
|
+
.split { display: grid; grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); gap: 16px; align-items: start; }
|
|
159
|
+
#queries-table { position: sticky; top: 64px; max-height: calc(100vh - 140px); overflow: auto; }
|
|
160
|
+
.qitem { display: flex; flex-direction: column; gap: 3px; width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line);
|
|
161
|
+
background: none; color: inherit; font: inherit; padding: 10px 14px; cursor: pointer; border-left: 2px solid transparent; }
|
|
162
|
+
.qitem:last-child { border-bottom: 0; }
|
|
163
|
+
.qitem:hover { background: var(--surface-2); }
|
|
164
|
+
.qitem.sel { background: var(--accent-soft); border-left-color: var(--accent); }
|
|
165
|
+
.qi-top { display: flex; align-items: center; gap: 7px; min-width: 0; }
|
|
166
|
+
.qi-top .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
167
|
+
.qi-conn { margin-left: auto; font: 11px var(--mono); color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 45%; }
|
|
168
|
+
.qi-desc { color: var(--ink-2); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
169
|
+
.detail { min-height: 360px; }
|
|
170
|
+
.d-head { padding: 16px 18px 0; display: flex; flex-direction: column; gap: 10px; }
|
|
171
|
+
.d-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
172
|
+
.d-title h3 { font: 600 16px var(--mono); }
|
|
173
|
+
.d-desc { color: var(--ink-2); margin: 0; text-wrap: pretty; }
|
|
174
|
+
.versions { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 6px; overflow: hidden; }
|
|
175
|
+
.versions button { border: 0; border-right: 1px solid var(--line-strong); background: var(--surface); color: var(--ink-2); font: 500 11.5px var(--mono);
|
|
176
|
+
padding: 0 9px; height: 24px; cursor: pointer; }
|
|
177
|
+
.versions button:last-child { border-right: 0; }
|
|
178
|
+
.versions button:hover { background: var(--surface-2); }
|
|
179
|
+
.versions button.on { background: var(--ink); color: var(--surface); }
|
|
180
|
+
.meta { display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 12px; color: var(--ink-3); margin: 0; }
|
|
181
|
+
.meta div { display: flex; gap: 6px; }
|
|
182
|
+
.meta dt { color: var(--ink-3); }
|
|
183
|
+
.meta dd { margin: 0; color: var(--ink); font-family: var(--mono); font-size: 11.5px; }
|
|
184
|
+
.subtabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 0 -18px; padding: 0 12px; }
|
|
185
|
+
.subtabs button { border: 0; background: none; font: 500 12.5px var(--sans); color: var(--ink-2); padding: 9px 8px; cursor: pointer; position: relative; display: flex; gap: 6px; align-items: center; }
|
|
186
|
+
.subtabs button.on { color: var(--ink); }
|
|
187
|
+
.subtabs button.on::after { content: ""; position: absolute; left: 6px; right: 6px; bottom: -1px; height: 2px; background: var(--ink); }
|
|
188
|
+
.d-body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 14px; }
|
|
189
|
+
.param-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
|
190
|
+
.run-row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }
|
|
191
|
+
.run-row .field { width: 150px; }
|
|
192
|
+
.sub-h { font-size: 11px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
|
|
193
|
+
.history-list { display: flex; flex-direction: column; gap: 2px; margin-top: 6px; max-height: 140px; overflow: auto; }
|
|
194
|
+
.history-item { border: 0; background: none; color: var(--ink-2); font: 11.5px var(--mono); text-align: left; padding: 4px 6px; border-radius: 4px;
|
|
195
|
+
cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
196
|
+
.history-item:hover { background: var(--surface-2); color: var(--ink); }
|
|
197
|
+
|
|
198
|
+
/* ---- SQL editor (hand-rolled highlighting: a <pre> painted behind a transparent <textarea>) ---- */
|
|
199
|
+
.editor { position: relative; min-height: 220px; height: 260px; resize: vertical; overflow: hidden; background: var(--surface); }
|
|
200
|
+
.editor .hl, .editor textarea { position: absolute; inset: 0; margin: 0; padding: 12px 14px; border: 0; border-radius: 0;
|
|
201
|
+
font: 13px/1.6 var(--mono); tab-size: 2; white-space: pre; overflow: auto; letter-spacing: 0; }
|
|
202
|
+
.editor .hl { pointer-events: none; color: var(--ink); overflow: hidden; }
|
|
203
|
+
.editor textarea { color: transparent; caret-color: var(--ink); background: transparent; resize: none; min-height: 0; height: 100%; }
|
|
204
|
+
.editor textarea:focus { box-shadow: none; }
|
|
205
|
+
.editor textarea::selection { background: light-dark(oklch(0.52 0.11 170 / 0.22), oklch(0.78 0.11 170 / 0.25)); color: transparent; }
|
|
206
|
+
.editor.boxed { border: 1px solid var(--line-strong); border-radius: 6px; height: 170px; min-height: 120px; }
|
|
207
|
+
.editor.boxed:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
208
|
+
.code { margin: 0; padding: 12px 14px; font: 12.5px/1.6 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--surface-2);
|
|
209
|
+
border: 1px solid var(--line); border-radius: 6px; max-height: 420px; overflow: auto; }
|
|
210
|
+
.k { color: var(--syn-kw); font-weight: 600; }
|
|
211
|
+
.s { color: var(--syn-str); }
|
|
212
|
+
.n { color: var(--syn-num); }
|
|
213
|
+
.p { color: var(--syn-param); font-weight: 600; }
|
|
214
|
+
.c { color: var(--syn-cmt); font-style: italic; }
|
|
215
|
+
|
|
216
|
+
/* ---- run SQL tab ---- */
|
|
217
|
+
.runner { display: grid; grid-template-columns: minmax(0, 1fr) 280px; overflow: hidden; }
|
|
218
|
+
.runner-main { display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line); }
|
|
219
|
+
.runner-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); background: var(--surface); flex-wrap: wrap; }
|
|
220
|
+
.runner-bar select { width: auto; height: 28px; font-size: 12.5px; }
|
|
221
|
+
.runner-bar .lbl { font-size: 11.5px; color: var(--ink-3); }
|
|
222
|
+
.runner-side { padding: 12px; display: flex; flex-direction: column; gap: 12px; background: var(--surface); }
|
|
223
|
+
.runner-side textarea { min-height: 96px; }
|
|
224
|
+
.runner-side .grid2 input { height: 28px; }
|
|
225
|
+
.refs { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; min-height: 18px; }
|
|
226
|
+
|
|
227
|
+
/* ---- schema browser (click a table/column to insert it into the nearest SQL editor) ---- */
|
|
228
|
+
.schema-browser { max-height: 220px; overflow: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
|
|
229
|
+
.schema-browser .hint, .schema-browser .loading { padding: 9px 10px; }
|
|
230
|
+
.schema-row { display: flex; align-items: center; gap: 0; }
|
|
231
|
+
.schema-caret { flex: none; width: 20px; height: 26px; padding: 0; border: 0; background: none; color: var(--ink-3); font-size: 9px; cursor: pointer; }
|
|
232
|
+
.schema-caret:hover { color: var(--ink); }
|
|
233
|
+
.schema-table { flex: 1; display: flex; align-items: center; gap: 6px; min-width: 0; border: 0; background: none; color: inherit; font: inherit;
|
|
234
|
+
text-align: left; padding: 4px 6px 4px 0; cursor: pointer; }
|
|
235
|
+
.schema-table:hover { background: var(--surface-2); }
|
|
236
|
+
.schema-preview { flex: none; width: 22px; height: 26px; padding: 0; border: 0; background: none; color: var(--ink-3); font-size: 9px; cursor: pointer; }
|
|
237
|
+
.schema-preview:hover { color: var(--accent); }
|
|
238
|
+
.schema-table .name { font: 600 12px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
239
|
+
.schema-cols { display: flex; flex-direction: column; padding-left: 22px; }
|
|
240
|
+
.schema-col { display: flex; justify-content: space-between; gap: 8px; border: 0; background: none; color: inherit; font: 12px var(--mono);
|
|
241
|
+
text-align: left; padding: 3px 6px; cursor: pointer; }
|
|
242
|
+
.schema-col:hover { background: var(--surface-2); }
|
|
243
|
+
|
|
244
|
+
/* ---- results ---- */
|
|
245
|
+
.results { margin-top: 14px; }
|
|
246
|
+
.results:empty { display: none; }
|
|
247
|
+
.resbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 7px 10px; border-bottom: 1px solid var(--line); font-size: 12px; color: var(--ink-2); }
|
|
248
|
+
.resbar:empty { display: none; }
|
|
249
|
+
.resbar .stat { font: 11.5px var(--mono); color: var(--ink-2); display: flex; gap: 12px; align-items: center; }
|
|
250
|
+
.resbar .stat b { color: var(--ink); font-weight: 600; }
|
|
251
|
+
.resbar .stat b.stat-ok { color: var(--accent); }
|
|
252
|
+
.pager { display: flex; align-items: center; gap: 4px; }
|
|
253
|
+
.pager input { width: 52px; height: 24px; text-align: center; padding: 0 4px; }
|
|
254
|
+
.pager select { width: auto; height: 24px; font-size: 12px; padding: 0 4px; }
|
|
255
|
+
.res-body:empty { display: none; }
|
|
256
|
+
.table-wrap { max-height: 62vh; overflow: auto; }
|
|
257
|
+
table.rs { border-collapse: separate; border-spacing: 0; font: 12.5px/1.4 var(--mono); min-width: 100%; }
|
|
258
|
+
table.rs th { position: sticky; top: 0; z-index: 1; background: var(--surface-2); text-align: left; font-weight: 600; color: var(--ink);
|
|
259
|
+
padding: 6px 12px; border-bottom: 1px solid var(--line-strong); border-right: 1px solid var(--line); white-space: nowrap; }
|
|
260
|
+
table.rs td { padding: 5px 12px; border-bottom: 1px solid var(--line); border-right: 1px solid var(--line); white-space: nowrap;
|
|
261
|
+
max-width: 380px; overflow: hidden; text-overflow: ellipsis; }
|
|
262
|
+
table.rs tr:hover td { background: var(--accent-soft); }
|
|
263
|
+
table.rs .rn { color: var(--ink-3); text-align: right; background: var(--surface); position: sticky; left: 0; font-size: 11px; padding: 5px 8px; }
|
|
264
|
+
table.rs th.rn { z-index: 2; background: var(--surface-2); }
|
|
265
|
+
table.rs .num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
266
|
+
table.rs .null { color: var(--ink-3); font-style: italic; font-size: 11px; }
|
|
267
|
+
.res-pre { margin: 0; padding: 12px 14px; font: 12.5px/1.55 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 62vh; overflow: auto; }
|
|
268
|
+
.res-note { padding: 22px 16px; color: var(--ink-2); display: flex; align-items: center; gap: 10px; justify-content: center; }
|
|
269
|
+
.res-note.err { color: var(--danger); }
|
|
270
|
+
|
|
271
|
+
/* ---- response headers panel ---- */
|
|
272
|
+
.headers-panel { margin: 0 14px 12px; overflow: auto; max-height: 200px; }
|
|
273
|
+
.headers-panel td { font-size: 11.5px; padding: 5px 10px; }
|
|
274
|
+
.headers-panel td:first-child { color: var(--ink-3); white-space: nowrap; }
|
|
275
|
+
.headers-panel td:last-child { overflow-wrap: anywhere; }
|
|
276
|
+
|
|
277
|
+
/* ---- quick chart (current page only) ---- */
|
|
278
|
+
.chart-panel { margin: 0 14px 12px; padding: 10px 12px; }
|
|
279
|
+
.chart-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
280
|
+
.chart-toolbar select { font: 11.5px var(--mono); background: var(--surface); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; }
|
|
281
|
+
.chart-svg-slot svg { width: 100%; height: auto; display: block; }
|
|
282
|
+
.chart-bar { fill: var(--accent); }
|
|
283
|
+
.chart-bar:hover { opacity: 0.75; }
|
|
284
|
+
.chart-axis { stroke: var(--line); stroke-width: 1; }
|
|
285
|
+
.chart-label { font: 10px var(--mono); fill: var(--ink-3); }
|
|
286
|
+
.chart-label-x { text-anchor: middle; }
|
|
287
|
+
.chart-label-y { text-anchor: start; }
|
|
288
|
+
|
|
289
|
+
/* ---- metrics tab ---- */
|
|
290
|
+
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
|
291
|
+
.stat-tile { padding: 12px 14px; }
|
|
292
|
+
.stat-tile .label { font-size: 11px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 4px; }
|
|
293
|
+
.stat-tile .value { font: 600 22px var(--mono); color: var(--ink); }
|
|
294
|
+
.stat-tile .value.warn { color: var(--danger); }
|
|
295
|
+
.metrics-charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-bottom: 14px; }
|
|
296
|
+
.metrics-charts .chart-panel { margin: 0; }
|
|
297
|
+
|
|
298
|
+
/* ---- collapsible JSON tree (non-tabular responses) ---- */
|
|
299
|
+
.jt-root { padding: 12px 14px; max-height: 62vh; overflow: auto; }
|
|
300
|
+
.jt-head { display: inline-flex; align-items: center; gap: 2px; }
|
|
301
|
+
.jt-toggle { border: 0; background: none; color: var(--ink-3); font-size: 9px; cursor: pointer; padding: 0 3px; width: 16px; flex: none; }
|
|
302
|
+
.jt-toggle:hover { color: var(--ink); }
|
|
303
|
+
.jt-children { padding-left: 18px; border-left: 1px solid var(--line); margin-left: 6px; }
|
|
304
|
+
.jt-item { display: flex; gap: 4px; align-items: flex-start; }
|
|
305
|
+
.jt-key { color: var(--syn-param); flex: none; }
|
|
306
|
+
.jt-punct { color: var(--ink-3); }
|
|
307
|
+
.jt-str { color: var(--syn-str); overflow-wrap: anywhere; }
|
|
308
|
+
.jt-num { color: var(--syn-num); }
|
|
309
|
+
.jt-null { color: var(--ink-3); font-style: italic; }
|
|
310
|
+
|
|
311
|
+
/* ---- drawer ---- */
|
|
312
|
+
.backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / 0.28); z-index: 40; }
|
|
313
|
+
.drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(500px, 100%); z-index: 41; background: var(--surface); border-left: 1px solid var(--line);
|
|
314
|
+
box-shadow: var(--shadow); display: flex; flex-direction: column; transform: translateX(102%); transition: transform 0.18s ease; visibility: hidden; }
|
|
315
|
+
.drawer.open { transform: none; visibility: visible; }
|
|
316
|
+
.drawer-head { display: flex; align-items: flex-start; gap: 10px; padding: 16px 18px 14px; border-bottom: 1px solid var(--line); }
|
|
317
|
+
.drawer-head h3 { font-size: 15px; }
|
|
318
|
+
.kicker { font: 11px var(--mono); color: var(--ink-3); margin-bottom: 3px; }
|
|
319
|
+
.kicker:empty { display: none; }
|
|
320
|
+
.drawer-body { flex: 1; overflow: auto; }
|
|
321
|
+
.form { display: flex; flex-direction: column; gap: 14px; padding: 18px; min-height: 100%; }
|
|
322
|
+
.form-actions { position: sticky; bottom: 0; margin: auto -18px -18px; padding: 12px 18px; background: var(--surface); border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; }
|
|
323
|
+
.switch { display: flex; align-items: center; gap: 8px; font-weight: 500; font-size: 12.5px; cursor: pointer; }
|
|
324
|
+
|
|
325
|
+
/* ---- toast ---- */
|
|
326
|
+
#toasts { position: fixed; right: 20px; bottom: 20px; z-index: 60; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
|
|
327
|
+
.toast { background: var(--ink); color: var(--surface); padding: 8px 12px; border-radius: 6px; font-size: 12.5px; box-shadow: var(--shadow); transition: opacity 0.3s; }
|
|
328
|
+
|
|
329
|
+
footer { padding: 0 20px 24px; color: var(--ink-3); font-size: 11.5px; max-width: 1480px; margin: 0 auto; }
|
|
330
|
+
|
|
331
|
+
/* The header needs ~1210px on one row with every tab; below that it drops the tabs onto a second row. */
|
|
332
|
+
@media (max-width: 1240px) {
|
|
333
|
+
header.top { height: auto; flex-wrap: wrap; gap: 8px 16px; padding: 8px 14px; }
|
|
334
|
+
#tabs { order: 3; width: 100%; height: 36px; margin: 0 -12px; }
|
|
335
|
+
.top-end { flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
|
|
336
|
+
#error-banner { top: 0; position: relative; }
|
|
337
|
+
}
|
|
338
|
+
@media (max-width: 980px) {
|
|
339
|
+
main { padding: 14px; }
|
|
340
|
+
.split, .runner { grid-template-columns: minmax(0, 1fr); }
|
|
341
|
+
#queries-table { position: static; max-height: 280px; }
|
|
342
|
+
.runner-main { border-right: 0; border-bottom: 1px solid var(--line); }
|
|
343
|
+
.hide-sm { display: none; }
|
|
344
|
+
}
|
|
345
|
+
</style></head>
|
|
346
|
+
<body>
|
|
347
|
+
<header class="top">
|
|
348
|
+
<div class="brand">
|
|
349
|
+
<span class="wordmark">Query<b>API</b>Gate</span>
|
|
350
|
+
<span class="health" id="health" title="GET /health"><span class="dot" id="health-dot"></span><span id="version">…</span></span>
|
|
351
|
+
</div>
|
|
352
|
+
<nav id="tabs" role="tablist">
|
|
353
|
+
<button type="button" role="tab" data-tab="connections" class="active">Connections <span class="count" id="count-connections"></span></button>
|
|
354
|
+
<button type="button" role="tab" data-tab="queries">Saved Queries <span class="count" id="count-queries"></span></button>
|
|
355
|
+
<button type="button" role="tab" data-tab="apikeys">API Keys <span class="count" id="count-apikeys"></span></button>
|
|
356
|
+
<button type="button" role="tab" data-tab="roles">Roles <span class="count" id="count-roles"></span></button>
|
|
357
|
+
<button type="button" role="tab" data-tab="auditlog">Audit Log</button>
|
|
358
|
+
<button type="button" role="tab" data-tab="metrics">Metrics</button>
|
|
359
|
+
<button type="button" role="tab" data-tab="run">Run SQL</button>
|
|
360
|
+
</nav>
|
|
361
|
+
<div class="top-end">
|
|
362
|
+
<span class="chip" id="rate" hidden title="X-RateLimit-Remaining / X-RateLimit-Limit"></span>
|
|
363
|
+
<a href="docs">API docs</a>
|
|
364
|
+
<a href="openapi.json">OpenAPI</a>
|
|
365
|
+
<form id="key-bar" autocomplete="off">
|
|
366
|
+
<label for="key"><span class="dot off" id="key-dot"></span>API key</label>
|
|
367
|
+
<input id="key" type="password" autocomplete="off" placeholder="X-API-Key · this tab only">
|
|
368
|
+
<button id="save-key" type="submit">Apply</button>
|
|
369
|
+
</form>
|
|
370
|
+
</div>
|
|
371
|
+
</header>
|
|
372
|
+
<div id="error-banner" role="alert" hidden></div>
|
|
373
|
+
|
|
374
|
+
<main>
|
|
375
|
+
<section id="tab-connections" class="active">
|
|
376
|
+
<div class="toolbar">
|
|
377
|
+
<h2>Connections</h2>
|
|
378
|
+
<span class="sub" id="connections-sub"></span>
|
|
379
|
+
<span class="spacer"></span>
|
|
380
|
+
<input id="conn-filter" class="search" type="search" placeholder="Filter by name, type, host…">
|
|
381
|
+
<button id="new-connection" type="button" class="btn primary">New connection</button>
|
|
382
|
+
</div>
|
|
383
|
+
<div id="connections-table" class="panel"><div class="loading"><span class="spin"></span>Loading connections…</div></div>
|
|
384
|
+
</section>
|
|
385
|
+
|
|
386
|
+
<section id="tab-queries">
|
|
387
|
+
<div class="toolbar">
|
|
388
|
+
<h2>Saved queries</h2>
|
|
389
|
+
<span class="sub" id="queries-sub"></span>
|
|
390
|
+
<span class="spacer"></span>
|
|
391
|
+
<input id="query-filter" class="search" type="search" placeholder="Filter by name, description, tag…">
|
|
392
|
+
<button id="new-query" type="button" class="btn primary">New saved query</button>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="split">
|
|
395
|
+
<div id="queries-table" class="panel"><div class="loading"><span class="spin"></span>Loading…</div></div>
|
|
396
|
+
<div id="query-detail" class="panel detail"><div class="empty"><strong>No query selected</strong><span>Pick a saved query to run it, read its SQL or see its history.</span></div></div>
|
|
397
|
+
</div>
|
|
398
|
+
</section>
|
|
399
|
+
|
|
400
|
+
<section id="tab-apikeys">
|
|
401
|
+
<div class="toolbar">
|
|
402
|
+
<h2>API keys</h2>
|
|
403
|
+
<span class="sub" id="apikeys-sub"></span>
|
|
404
|
+
<span class="spacer"></span>
|
|
405
|
+
<button id="new-apikey" type="button" class="btn primary">New API key</button>
|
|
406
|
+
</div>
|
|
407
|
+
<div id="apikeys-table" class="panel"><div class="loading"><span class="spin"></span>Loading API keys…</div></div>
|
|
408
|
+
</section>
|
|
409
|
+
|
|
410
|
+
<section id="tab-roles">
|
|
411
|
+
<div class="toolbar">
|
|
412
|
+
<h2>Roles</h2>
|
|
413
|
+
<span class="sub" id="roles-sub"></span>
|
|
414
|
+
<span class="spacer"></span>
|
|
415
|
+
<button id="new-role" type="button" class="btn primary">New role</button>
|
|
416
|
+
</div>
|
|
417
|
+
<div id="roles-table" class="panel"><div class="loading"><span class="spin"></span>Loading roles…</div></div>
|
|
418
|
+
</section>
|
|
419
|
+
|
|
420
|
+
<section id="tab-auditlog">
|
|
421
|
+
<div class="toolbar">
|
|
422
|
+
<h2>Audit log</h2>
|
|
423
|
+
<span class="sub" id="auditlog-sub"></span>
|
|
424
|
+
<span class="spacer"></span>
|
|
425
|
+
<select id="auditlog-action-filter"><option value="">All actions</option></select>
|
|
426
|
+
<input id="auditlog-filter" class="search" type="search" placeholder="Filter by actor, target, time…">
|
|
427
|
+
<button id="refresh-auditlog" type="button" class="btn ghost">Refresh</button>
|
|
428
|
+
</div>
|
|
429
|
+
<div id="auditlog-table" class="panel"><div class="loading"><span class="spin"></span>Loading audit log…</div></div>
|
|
430
|
+
</section>
|
|
431
|
+
|
|
432
|
+
<section id="tab-metrics">
|
|
433
|
+
<div class="toolbar">
|
|
434
|
+
<h2>Metrics</h2>
|
|
435
|
+
<span class="sub">Live totals since this process started - no history, no trends. For that, scrape
|
|
436
|
+
<code>/metrics</code> with Prometheus and import the bundled Grafana dashboard.</span>
|
|
437
|
+
<span class="spacer"></span>
|
|
438
|
+
<button id="refresh-metrics" type="button" class="btn ghost">Refresh</button>
|
|
439
|
+
</div>
|
|
440
|
+
<div id="metrics-body"><div class="loading"><span class="spin"></span>Loading metrics…</div></div>
|
|
441
|
+
</section>
|
|
442
|
+
|
|
443
|
+
<section id="tab-run">
|
|
444
|
+
<form id="run-form" class="panel runner" novalidate>
|
|
445
|
+
<div class="runner-main">
|
|
446
|
+
<div class="runner-bar">
|
|
447
|
+
<span class="lbl">Connection</span>
|
|
448
|
+
<select id="run-connection" required aria-label="Connection"></select>
|
|
449
|
+
<span class="lbl">Format</span>
|
|
450
|
+
<select id="run-format" aria-label="Format">
|
|
451
|
+
<option value="json">json</option><option value="ndjson">ndjson</option>
|
|
452
|
+
<option value="csv">csv</option><option value="tsv">tsv</option>
|
|
453
|
+
<option value="xml">xml</option><option value="yaml">yaml</option><option value="xlsx">xlsx</option>
|
|
454
|
+
</select>
|
|
455
|
+
<span class="spacer"></span>
|
|
456
|
+
<span class="hint hide-sm"><kbd>Ctrl</kbd> <kbd>Enter</kbd></span>
|
|
457
|
+
<button type="button" class="btn ghost" id="run-explain-button" title="Run EXPLAIN on this query">Explain</button>
|
|
458
|
+
<button type="submit" class="btn primary" id="run-button">Run</button>
|
|
459
|
+
</div>
|
|
460
|
+
<div class="editor" id="run-editor">
|
|
461
|
+
<pre class="hl" id="run-sql-hl" aria-hidden="true"></pre>
|
|
462
|
+
<textarea id="run-sql" required spellcheck="false" wrap="off" autocomplete="off" aria-label="SQL" placeholder="SELECT * FROM t WHERE id = :id"></textarea>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
<div class="runner-side">
|
|
466
|
+
<div id="run-history-slot"></div>
|
|
467
|
+
<div class="field">
|
|
468
|
+
<label for="run-params">Bound parameters <span class="type">JSON</span></label>
|
|
469
|
+
<textarea id="run-params" spellcheck="false" placeholder='{"id": 1}'></textarea>
|
|
470
|
+
<div class="refs" id="run-refs"></div>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="grid2">
|
|
473
|
+
<div class="field"><label for="run-page">Page</label><input id="run-page" type="number" min="1" value="1"></div>
|
|
474
|
+
<div class="field"><label for="run-page-size">Page size</label><input id="run-page-size" type="number" min="1" value="10"></div>
|
|
475
|
+
</div>
|
|
476
|
+
<div class="field"><label for="run-timeout">Timeout <span class="type">seconds, optional</span></label><input id="run-timeout" type="number" min="0" step="any" placeholder="server default"></div>
|
|
477
|
+
<div id="run-schema-slot"></div>
|
|
478
|
+
</div>
|
|
479
|
+
</form>
|
|
480
|
+
<div class="panel results" id="run-results-panel">
|
|
481
|
+
<div class="resbar" id="run-status"></div>
|
|
482
|
+
<div class="res-body" id="run-results"></div>
|
|
483
|
+
</div>
|
|
484
|
+
</section>
|
|
485
|
+
</main>
|
|
486
|
+
<footer><span id="footer-note">Every request is sent to this server’s JSON API with the key above.</span></footer>
|
|
487
|
+
|
|
488
|
+
<div class="backdrop" id="drawer-backdrop" hidden></div>
|
|
489
|
+
<aside class="drawer" id="drawer" aria-hidden="true" role="dialog" aria-labelledby="drawer-title">
|
|
490
|
+
<div class="drawer-head">
|
|
491
|
+
<div><div class="kicker" id="drawer-kicker"></div><h3 id="drawer-title"></h3></div>
|
|
492
|
+
<span class="spacer"></span>
|
|
493
|
+
<button type="button" class="btn ghost icon" id="drawer-close" aria-label="Close">×</button>
|
|
494
|
+
</div>
|
|
495
|
+
<div class="drawer-body">
|
|
496
|
+
<div id="connection-form-slot"></div>
|
|
497
|
+
<div id="query-form-slot"></div>
|
|
498
|
+
<div id="apikey-form-slot"></div>
|
|
499
|
+
<div id="role-form-slot"></div>
|
|
500
|
+
</div>
|
|
501
|
+
</aside>
|
|
502
|
+
<div id="toasts" aria-live="polite"></div>
|
|
503
|
+
|
|
504
|
+
<script>
|
|
505
|
+
'use strict';
|
|
506
|
+
|
|
507
|
+
// ---- DOM builder: every value goes through textContent / createTextNode / setAttribute, never as markup. ----
|
|
508
|
+
function h(tag, attrs) {
|
|
509
|
+
var node = document.createElement(tag);
|
|
510
|
+
attrs = attrs || {};
|
|
511
|
+
Object.keys(attrs).forEach(function (k) {
|
|
512
|
+
var v = attrs[k];
|
|
513
|
+
if (v === null || v === undefined || v === false) return;
|
|
514
|
+
if (k === 'text') node.textContent = v;
|
|
515
|
+
else if (k.slice(0, 2) === 'on') node[k] = v;
|
|
516
|
+
else if (k === 'className') node.className = v;
|
|
517
|
+
else if (k === 'value') node.value = v;
|
|
518
|
+
else node.setAttribute(k, v === true ? '' : v);
|
|
519
|
+
});
|
|
520
|
+
function append(child) {
|
|
521
|
+
if (child === null || child === undefined || child === false) return;
|
|
522
|
+
if (Array.isArray(child)) { child.forEach(append); return; }
|
|
523
|
+
node.appendChild(typeof child === 'string' || typeof child === 'number' ? document.createTextNode(String(child)) : child);
|
|
524
|
+
}
|
|
525
|
+
for (var i = 2; i < arguments.length; i++) append(arguments[i]);
|
|
526
|
+
return node;
|
|
527
|
+
}
|
|
528
|
+
function clear(node) { node.textContent = ''; return node; }
|
|
529
|
+
function $(id) { return document.getElementById(id); }
|
|
530
|
+
function enc(s) { return encodeURIComponent(s); }
|
|
531
|
+
function loadingNode(text) { return h('div', { className: 'loading' }, h('span', { className: 'spin' }), text || 'Loading…'); }
|
|
532
|
+
|
|
533
|
+
// ---- API key: per-tab, same sessionStorage key /docs uses ----
|
|
534
|
+
function getKey() { try { return sessionStorage.getItem('queryapigate-key') || ''; } catch (e) { return ''; } }
|
|
535
|
+
function setKey(v) { try { sessionStorage.setItem('queryapigate-key', v); } catch (e) {} }
|
|
536
|
+
function paintKeyState() { $('key-dot').className = 'dot ' + (getKey() ? 'ok' : 'off'); }
|
|
537
|
+
|
|
538
|
+
// ---- feedback ----
|
|
539
|
+
function showError(message, detail) {
|
|
540
|
+
var banner = clear($('error-banner'));
|
|
541
|
+
if (!message) { banner.hidden = true; return; }
|
|
542
|
+
detail = detail || {};
|
|
543
|
+
var fields = detail.errors && typeof detail.errors === 'object' ? Object.keys(detail.errors) : [];
|
|
544
|
+
banner.appendChild(h('div', { className: 'eb-main' },
|
|
545
|
+
detail.status ? h('span', { className: 'eb-code', text: String(detail.status) }) : null,
|
|
546
|
+
h('span', { className: 'eb-msg', text: String(message) }),
|
|
547
|
+
h('button', { type: 'button', className: 'eb-close', 'aria-label': 'Dismiss', text: '×', onclick: function () { showError(''); } })));
|
|
548
|
+
if (fields.length) {
|
|
549
|
+
banner.appendChild(h('ul', { className: 'eb-fields' }, fields.map(function (f) {
|
|
550
|
+
return h('li', {}, h('code', { text: f }), ' — ', String(detail.errors[f]));
|
|
551
|
+
})));
|
|
552
|
+
}
|
|
553
|
+
banner.hidden = false;
|
|
554
|
+
}
|
|
555
|
+
function errorMessage(status, body, text) {
|
|
556
|
+
var msg = (body && body.error) || text || ('HTTP ' + status);
|
|
557
|
+
if (status === 401 && !getKey()) msg += ' — enter an API key in the top bar.';
|
|
558
|
+
return msg;
|
|
559
|
+
}
|
|
560
|
+
function toast(msg) {
|
|
561
|
+
var t = h('div', { className: 'toast', text: msg });
|
|
562
|
+
$('toasts').appendChild(t);
|
|
563
|
+
setTimeout(function () { t.style.opacity = '0'; }, 2200);
|
|
564
|
+
setTimeout(function () { t.remove(); }, 2600);
|
|
565
|
+
}
|
|
566
|
+
function noteRate(res) {
|
|
567
|
+
var limit = res.headers.get('x-ratelimit-limit'), left = res.headers.get('x-ratelimit-remaining');
|
|
568
|
+
if (limit === null || left === null) return;
|
|
569
|
+
var chip = $('rate');
|
|
570
|
+
chip.textContent = 'rate ' + left + '/' + limit;
|
|
571
|
+
chip.className = 'chip' + (Number(left) <= Math.max(1, Number(limit) * 0.1) ? ' low' : '');
|
|
572
|
+
chip.hidden = false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---- API ----
|
|
576
|
+
async function apiFetch(path, opts) {
|
|
577
|
+
opts = opts || {};
|
|
578
|
+
var headers = Object.assign({}, opts.headers || {});
|
|
579
|
+
var key = getKey();
|
|
580
|
+
if (key) headers['X-API-Key'] = key;
|
|
581
|
+
var body = opts.body;
|
|
582
|
+
if (opts.json !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(opts.json); }
|
|
583
|
+
var res = await fetch(path, { method: opts.method || 'GET', headers: headers, body: body });
|
|
584
|
+
noteRate(res);
|
|
585
|
+
return res;
|
|
586
|
+
}
|
|
587
|
+
/** Call a JSON endpoint; on failure show the server's error and return null. */
|
|
588
|
+
async function apiJson(path, opts) {
|
|
589
|
+
var res;
|
|
590
|
+
try { res = await apiFetch(path, opts); }
|
|
591
|
+
catch (e) { showError('Network error: ' + e.message); return null; }
|
|
592
|
+
var text = await res.text(), body = null;
|
|
593
|
+
try { body = text ? JSON.parse(text) : null; } catch (e) { /* not JSON */ }
|
|
594
|
+
if (!res.ok) { showError(errorMessage(res.status, body, ''), { status: res.status, errors: body && body.errors }); return null; }
|
|
595
|
+
return body;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ---- tabs ----
|
|
599
|
+
function showTab(name) {
|
|
600
|
+
document.querySelectorAll('#tabs button').forEach(function (b) {
|
|
601
|
+
var on = b.dataset.tab === name;
|
|
602
|
+
b.classList.toggle('active', on);
|
|
603
|
+
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
604
|
+
});
|
|
605
|
+
document.querySelectorAll('main > section').forEach(function (s) { s.classList.toggle('active', s.id === 'tab-' + name); });
|
|
606
|
+
try { sessionStorage.setItem('queryapigate-ui-tab', name); } catch (e) {}
|
|
607
|
+
}
|
|
608
|
+
document.querySelectorAll('#tabs button').forEach(function (btn) { btn.onclick = function () { showTab(btn.dataset.tab); }; });
|
|
609
|
+
|
|
610
|
+
$('key').value = getKey();
|
|
611
|
+
paintKeyState();
|
|
612
|
+
$('key-bar').onsubmit = function (e) {
|
|
613
|
+
e.preventDefault();
|
|
614
|
+
setKey($('key').value);
|
|
615
|
+
paintKeyState();
|
|
616
|
+
showError('');
|
|
617
|
+
toast($('key').value ? 'API key applied to this tab' : 'API key cleared');
|
|
618
|
+
refreshAll();
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
// ---- drawer (hosts the connection and saved-query forms) ----
|
|
622
|
+
function openDrawer(slotId, title, kicker) {
|
|
623
|
+
clear($('connection-form-slot')); clear($('query-form-slot')); clear($('apikey-form-slot')); clear($('role-form-slot'));
|
|
624
|
+
$('drawer-title').textContent = title;
|
|
625
|
+
$('drawer-kicker').textContent = kicker || '';
|
|
626
|
+
$('drawer').classList.add('open');
|
|
627
|
+
$('drawer').setAttribute('aria-hidden', 'false');
|
|
628
|
+
$('drawer-backdrop').hidden = false;
|
|
629
|
+
return $(slotId);
|
|
630
|
+
}
|
|
631
|
+
function closeDrawer() {
|
|
632
|
+
$('drawer').classList.remove('open');
|
|
633
|
+
$('drawer').setAttribute('aria-hidden', 'true');
|
|
634
|
+
$('drawer-backdrop').hidden = true;
|
|
635
|
+
clear($('connection-form-slot')); clear($('query-form-slot')); clear($('apikey-form-slot')); clear($('role-form-slot'));
|
|
636
|
+
}
|
|
637
|
+
$('drawer-close').onclick = closeDrawer;
|
|
638
|
+
$('drawer-backdrop').onclick = closeDrawer;
|
|
639
|
+
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && $('drawer').classList.contains('open')) closeDrawer(); });
|
|
640
|
+
function field(id, label, input, hint, extra) {
|
|
641
|
+
return h('div', { className: 'field' }, h('label', { for: id }, label, extra || null), input,
|
|
642
|
+
hint ? h('div', { className: 'hint', text: hint }) : null);
|
|
643
|
+
}
|
|
644
|
+
function formActions(submitText, onCancel) {
|
|
645
|
+
var submit = h('button', { type: 'submit', className: 'btn primary', text: submitText });
|
|
646
|
+
return { node: h('div', { className: 'form-actions' }, h('button', { type: 'button', className: 'btn', text: 'Cancel', onclick: onCancel }), submit), submit: submit };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---- SQL highlighting (tokens become spans via textContent) ----
|
|
650
|
+
var SQL_KW = {};
|
|
651
|
+
('select from where and or not in is null as join left right inner outer full cross on using group by order having limit offset union ' +
|
|
652
|
+
'all distinct insert into values update set delete create table view index drop alter with recursive case when then else end asc desc ' +
|
|
653
|
+
'like ilike between exists returning primary key default top fetch next rows only over partition window count sum avg min max ' +
|
|
654
|
+
'coalesce cast true false interval date timestamp extract filter lateral intersect except nulls first last')
|
|
655
|
+
.split(' ').forEach(function (w) { SQL_KW[w] = true; });
|
|
656
|
+
var SQL_TOKEN = /(--[^\n]*|\/\*[\s\S]*?(?:\*\/|$))|('(?:[^']|'')*'?)|("(?:[^"]|"")*"?|`[^`]*`?)|(::)|(:[A-Za-z_]\w*)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_]\w*)/g;
|
|
657
|
+
function highlightInto(target, sql) {
|
|
658
|
+
var frag = document.createDocumentFragment(), last = 0, m;
|
|
659
|
+
SQL_TOKEN.lastIndex = 0;
|
|
660
|
+
function span(cls, text) { var s = document.createElement('span'); s.className = cls; s.textContent = text; frag.appendChild(s); }
|
|
661
|
+
while ((m = SQL_TOKEN.exec(sql))) {
|
|
662
|
+
if (m.index > last) frag.appendChild(document.createTextNode(sql.slice(last, m.index)));
|
|
663
|
+
if (m[1]) span('c', m[1]);
|
|
664
|
+
else if (m[2]) span('s', m[2]);
|
|
665
|
+
else if (m[5]) span('p', m[5]);
|
|
666
|
+
else if (m[6]) span('n', m[6]);
|
|
667
|
+
else if (m[7] && SQL_KW[m[7].toLowerCase()]) span('k', m[7]);
|
|
668
|
+
else frag.appendChild(document.createTextNode(m[0]));
|
|
669
|
+
last = SQL_TOKEN.lastIndex;
|
|
670
|
+
}
|
|
671
|
+
if (last < sql.length) frag.appendChild(document.createTextNode(sql.slice(last)));
|
|
672
|
+
clear(target).appendChild(frag);
|
|
673
|
+
return target;
|
|
674
|
+
}
|
|
675
|
+
function sqlParams(sql) {
|
|
676
|
+
var seen = {}, out = [], m;
|
|
677
|
+
SQL_TOKEN.lastIndex = 0;
|
|
678
|
+
while ((m = SQL_TOKEN.exec(sql))) if (m[5] && !seen[m[5]]) { seen[m[5]] = true; out.push(m[5].slice(1)); }
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
function bindEditor(textarea, pre, onChange) {
|
|
682
|
+
function paint() {
|
|
683
|
+
highlightInto(pre, textarea.value + '\n');
|
|
684
|
+
pre.scrollTop = textarea.scrollTop; pre.scrollLeft = textarea.scrollLeft;
|
|
685
|
+
if (onChange) onChange(textarea.value);
|
|
686
|
+
}
|
|
687
|
+
textarea.addEventListener('input', paint);
|
|
688
|
+
textarea.addEventListener('scroll', function () { pre.scrollTop = textarea.scrollTop; pre.scrollLeft = textarea.scrollLeft; });
|
|
689
|
+
textarea.addEventListener('keydown', function (e) {
|
|
690
|
+
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
691
|
+
e.preventDefault();
|
|
692
|
+
var s = textarea.selectionStart, en = textarea.selectionEnd;
|
|
693
|
+
textarea.value = textarea.value.slice(0, s) + ' ' + textarea.value.slice(en);
|
|
694
|
+
textarea.selectionStart = textarea.selectionEnd = s + 2;
|
|
695
|
+
paint();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
textarea.repaint = paint;
|
|
699
|
+
paint();
|
|
700
|
+
}
|
|
701
|
+
function makeEditor(attrs, value) {
|
|
702
|
+
var ta = h('textarea', Object.assign({ spellcheck: 'false', wrap: 'off', autocomplete: 'off' }, attrs));
|
|
703
|
+
ta.value = value || '';
|
|
704
|
+
var pre = h('pre', { className: 'hl', 'aria-hidden': 'true' });
|
|
705
|
+
var wrap = h('div', { className: 'editor boxed' }, pre, ta);
|
|
706
|
+
bindEditor(ta, pre);
|
|
707
|
+
return wrap;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ---- schema browser: click a table or column to insert its name into a SQL editor ----
|
|
711
|
+
function insertAtCursor(textarea, text) {
|
|
712
|
+
var start = textarea.selectionStart, end = textarea.selectionEnd;
|
|
713
|
+
textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(end);
|
|
714
|
+
var pos = start + text.length;
|
|
715
|
+
textarea.selectionStart = textarea.selectionEnd = pos;
|
|
716
|
+
textarea.focus();
|
|
717
|
+
if (textarea.repaint) textarea.repaint();
|
|
718
|
+
}
|
|
719
|
+
var schemaCache = {}; // connection name -> {status: 'loading'|'ready'|'error', tables, truncated, message}
|
|
720
|
+
async function loadSchema(name, onDone) {
|
|
721
|
+
schemaCache[name] = { status: 'loading' };
|
|
722
|
+
var res;
|
|
723
|
+
try { res = await apiFetch('connections/' + enc(name) + '/schema'); }
|
|
724
|
+
catch (e) { schemaCache[name] = { status: 'error', message: 'Network error: ' + e.message }; onDone(); return; }
|
|
725
|
+
if (!res.ok) {
|
|
726
|
+
var body = null;
|
|
727
|
+
try { body = await res.json(); } catch (e) {}
|
|
728
|
+
schemaCache[name] = { status: 'error', message: (body && body.error) || ('HTTP ' + res.status) };
|
|
729
|
+
onDone();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
var data = await res.json();
|
|
733
|
+
schemaCache[name] = { status: 'ready', tables: data.tables || [], truncated: !!data.truncated };
|
|
734
|
+
onDone();
|
|
735
|
+
}
|
|
736
|
+
/** A self-contained, connection-aware schema tree. `insertFn(text)` is called with a table or column name
|
|
737
|
+
* when the caller clicks one - errors (an unsupported connection type, no permission, ...) render inline
|
|
738
|
+
* rather than through the page-wide error banner, since browsing the schema is optional, not the action the
|
|
739
|
+
* user took. */
|
|
740
|
+
function schemaBrowser(insertFn, previewFn) {
|
|
741
|
+
var box = h('div', { className: 'schema-browser' });
|
|
742
|
+
var current = null;
|
|
743
|
+
function paint() {
|
|
744
|
+
clear(box);
|
|
745
|
+
if (!current) { box.appendChild(h('div', { className: 'hint', text: 'Pick a connection to browse its schema.' })); return; }
|
|
746
|
+
var entry = schemaCache[current];
|
|
747
|
+
if (!entry) { loadSchema(current, paint); entry = { status: 'loading' }; }
|
|
748
|
+
if (entry.status === 'loading') { box.appendChild(loadingNode('Loading schema…')); return; }
|
|
749
|
+
if (entry.status === 'error') { box.appendChild(h('div', { className: 'hint', text: entry.message })); return; }
|
|
750
|
+
if (!entry.tables.length) { box.appendChild(h('div', { className: 'hint', text: 'No tables found.' })); return; }
|
|
751
|
+
entry.tables.forEach(function (t) {
|
|
752
|
+
var colsBox = h('div', { className: 'schema-cols', hidden: true });
|
|
753
|
+
var caret = h('button', { type: 'button', className: 'schema-caret', text: '▸', 'aria-label': 'Expand ' + t.name });
|
|
754
|
+
caret.onclick = function () {
|
|
755
|
+
var open = colsBox.hidden;
|
|
756
|
+
colsBox.hidden = !open;
|
|
757
|
+
caret.textContent = open ? '▾' : '▸';
|
|
758
|
+
if (open && !colsBox.childNodes.length) {
|
|
759
|
+
t.columns.forEach(function (c) {
|
|
760
|
+
colsBox.appendChild(h('button', {
|
|
761
|
+
type: 'button', className: 'schema-col', title: c.type + (c.nullable ? ' · nullable' : ' · not null'),
|
|
762
|
+
onclick: function () { insertFn(c.name); }
|
|
763
|
+
}, h('span', { text: c.name }), h('span', { className: 'dim', text: c.type })));
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
box.appendChild(h('div', { className: 'schema-row' }, caret,
|
|
768
|
+
h('button', { type: 'button', className: 'schema-table', title: 'Insert ' + t.name, onclick: function () { insertFn(t.name); } },
|
|
769
|
+
h('span', { className: 'name', text: t.name }), h('span', { className: 'tag', text: t.type })),
|
|
770
|
+
previewFn ? h('button', { type: 'button', className: 'schema-preview', title: 'Preview ' + t.name + ' in Run SQL', 'aria-label': 'Preview ' + t.name,
|
|
771
|
+
onclick: function () { previewFn(t.name); } }, '▶') : null));
|
|
772
|
+
box.appendChild(colsBox);
|
|
773
|
+
});
|
|
774
|
+
if (entry.truncated) box.appendChild(h('div', { className: 'hint', text: 'Showing the first 5000 columns.' }));
|
|
775
|
+
}
|
|
776
|
+
paint();
|
|
777
|
+
return {
|
|
778
|
+
node: box,
|
|
779
|
+
setConnection: function (name) { current = name || null; paint(); },
|
|
780
|
+
refresh: function () { if (current) delete schemaCache[current]; paint(); }
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
function schemaField(browser) {
|
|
784
|
+
return field(null, 'Schema', browser.node, null,
|
|
785
|
+
h('button', { type: 'button', className: 'btn ghost sm', text: '↻', title: 'Refresh', onclick: function () { browser.refresh(); } }));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ---- connections ----
|
|
789
|
+
var DB_TYPES = ['mysql', 'postgres', 'clickhouse', 'sqlite', 'h2', 'duckdb']; // mirrors config.SUPPORTED_DB_TYPES
|
|
790
|
+
var PASSWORD_MASK = '********'; // mirrors config.PASSWORD_MASK; sending it back unchanged keeps the stored password
|
|
791
|
+
var connectionsCache = {};
|
|
792
|
+
|
|
793
|
+
function openConnectionForm(name, existing) {
|
|
794
|
+
existing = existing || {};
|
|
795
|
+
var isEdit = !!name;
|
|
796
|
+
var slot = openDrawer('connection-form-slot', isEdit ? 'Edit connection' : 'New connection', isEdit ? name : 'PATCH /connections');
|
|
797
|
+
function inp(id, type, value, ph) {
|
|
798
|
+
return h('input', { id: id, type: type || 'text', value: value === undefined || value === null ? '' : String(value), placeholder: ph || null, autocomplete: 'off', spellcheck: 'false' });
|
|
799
|
+
}
|
|
800
|
+
function withRevealToggle(input) {
|
|
801
|
+
var btn = h('button', { type: 'button', className: 'btn ghost sm', 'aria-label': 'Show password', text: 'Show' });
|
|
802
|
+
btn.onclick = function () {
|
|
803
|
+
var showing = input.type === 'text';
|
|
804
|
+
input.type = showing ? 'password' : 'text';
|
|
805
|
+
btn.textContent = showing ? 'Show' : 'Hide';
|
|
806
|
+
btn.setAttribute('aria-label', (showing ? 'Show' : 'Hide') + ' password');
|
|
807
|
+
};
|
|
808
|
+
return h('div', { className: 'password-field' }, input, btn);
|
|
809
|
+
}
|
|
810
|
+
var nameInput = inp('c-name', 'text', name || '', 'reporting-db');
|
|
811
|
+
if (isEdit) nameInput.disabled = true;
|
|
812
|
+
var dbSelect = h('select', { id: 'c-db' }, DB_TYPES.map(function (t) { return h('option', { value: t, text: t }); }));
|
|
813
|
+
if (existing.db) dbSelect.value = existing.db;
|
|
814
|
+
var active = h('input', { id: 'c-active', type: 'checkbox' });
|
|
815
|
+
active.checked = existing.active !== false;
|
|
816
|
+
var actions = formActions(isEdit ? 'Save' : 'Create', closeDrawer);
|
|
817
|
+
var form = h('form', { className: 'form', novalidate: true, onsubmit: function (e) {
|
|
818
|
+
e.preventDefault();
|
|
819
|
+
var port = $('c-port').value;
|
|
820
|
+
saveConnection(name, {
|
|
821
|
+
db: dbSelect.value,
|
|
822
|
+
host: $('c-host').value || undefined,
|
|
823
|
+
port: port ? Number(port) : undefined,
|
|
824
|
+
user: $('c-user').value || undefined,
|
|
825
|
+
password: $('c-password').value,
|
|
826
|
+
database: $('c-database').value || undefined,
|
|
827
|
+
active: active.checked
|
|
828
|
+
}, nameInput.value.trim(), actions.submit);
|
|
829
|
+
} },
|
|
830
|
+
field('c-name', 'Name', nameInput, isEdit ? 'Renaming isn’t supported — create a new connection instead.' : 'Referenced as connection_name by queries and the API.'),
|
|
831
|
+
field('c-db', 'Database type', dbSelect),
|
|
832
|
+
h('div', { className: 'grid-host' }, field('c-host', 'Host', inp('c-host', 'text', existing.host, 'localhost')),
|
|
833
|
+
field('c-port', 'Port', inp('c-port', 'number', existing.port, ''))),
|
|
834
|
+
h('div', { className: 'grid2' }, field('c-user', 'User', inp('c-user', 'text', existing.user, '')),
|
|
835
|
+
field('c-password', 'Password', withRevealToggle(inp('c-password', 'password', isEdit ? (existing.password === undefined ? '' : existing.password) : '', '')),
|
|
836
|
+
isEdit ? 'Leave the mask to keep the stored password.' : '${ENV_VAR} references are resolved on the server.')),
|
|
837
|
+
field('c-database', 'Database / file path', inp('c-database', 'text', existing.database, 'SQLite, DuckDB and H2 take a file path'), null),
|
|
838
|
+
h('label', { className: 'switch' }, active, 'Active', h('span', { className: 'hint', text: '— inactive connections refuse queries' })),
|
|
839
|
+
actions.node);
|
|
840
|
+
slot.appendChild(form);
|
|
841
|
+
(isEdit ? $('c-host') : nameInput).focus();
|
|
842
|
+
}
|
|
843
|
+
$('new-connection').onclick = function () { openConnectionForm(null, {}); };
|
|
844
|
+
|
|
845
|
+
async function saveConnection(originalName, details, newName, btn) {
|
|
846
|
+
var name = originalName || newName;
|
|
847
|
+
if (!name) { showError('A connection name is required.', { errors: { name: 'This field is required' } }); $('c-name').focus(); return; }
|
|
848
|
+
var body = {};
|
|
849
|
+
body[name] = details;
|
|
850
|
+
btn.disabled = true;
|
|
851
|
+
var res = await apiJson('connections', { method: 'PATCH', json: { connections: body } });
|
|
852
|
+
btn.disabled = false;
|
|
853
|
+
if (res) { showError(''); closeDrawer(); toast((originalName ? 'Saved ' : 'Created ') + name); loadConnections(); }
|
|
854
|
+
}
|
|
855
|
+
async function deleteConnection(name) {
|
|
856
|
+
if (!confirm("Delete connection '" + name + "'?")) return;
|
|
857
|
+
var res = await apiJson('connections/' + enc(name), { method: 'DELETE' });
|
|
858
|
+
if (res) { toast('Deleted ' + name); loadConnections(); }
|
|
859
|
+
}
|
|
860
|
+
async function loadConnections() {
|
|
861
|
+
var data = await apiJson('connections');
|
|
862
|
+
var box = $('connections-table');
|
|
863
|
+
if (!data) {
|
|
864
|
+
clear(box).appendChild(h('div', { className: 'empty' }, h('strong', { text: 'Couldn’t load connections' }),
|
|
865
|
+
h('button', { type: 'button', className: 'btn sm', text: 'Retry', onclick: loadConnections })));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
connectionsCache = data.connections || {};
|
|
869
|
+
populateConnectionSelect();
|
|
870
|
+
renderConnections();
|
|
871
|
+
}
|
|
872
|
+
// A key's usage has no avg_duration_ms (request/query latency isn't split by key - see metrics.py); a
|
|
873
|
+
// connection's does, since a connection has exactly one dialect and its latency histogram is keyed by it.
|
|
874
|
+
function usageCell(usage) {
|
|
875
|
+
if (!usage || !usage.queries) return h('td', { className: 'dim', text: 'No activity yet' });
|
|
876
|
+
var parts = [usage.queries + (usage.queries === 1 ? ' query' : ' queries')];
|
|
877
|
+
if (usage.errors) parts.push(usage.errors + ' failed');
|
|
878
|
+
if (usage.avg_duration_ms !== undefined && usage.avg_duration_ms !== null) parts.push(usage.avg_duration_ms + 'ms avg');
|
|
879
|
+
var title = usage.rows + (usage.rows === 1 ? ' row' : ' rows') + ' returned in total · since this process started';
|
|
880
|
+
return h('td', { title: title, text: parts.join(' · ') });
|
|
881
|
+
}
|
|
882
|
+
function renderConnections() {
|
|
883
|
+
var box = clear($('connections-table'));
|
|
884
|
+
var all = Object.keys(connectionsCache).sort();
|
|
885
|
+
var activeCount = all.filter(function (n) { return connectionsCache[n].active; }).length;
|
|
886
|
+
$('count-connections').textContent = all.length ? String(all.length) : '';
|
|
887
|
+
$('connections-sub').textContent = all.length ? activeCount + ' active of ' + all.length : '';
|
|
888
|
+
if (!all.length) {
|
|
889
|
+
box.appendChild(h('div', { className: 'empty' }, h('strong', { text: 'No connections yet' }),
|
|
890
|
+
h('span', { text: 'Add a MySQL, PostgreSQL, ClickHouse, SQLite or H2 database to start running SQL.' }),
|
|
891
|
+
h('button', { type: 'button', className: 'btn primary', text: 'New connection', onclick: function () { openConnectionForm(null, {}); } })));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
var q = $('conn-filter').value.trim().toLowerCase();
|
|
895
|
+
var names = all.filter(function (n) {
|
|
896
|
+
var c = connectionsCache[n];
|
|
897
|
+
return !q || [n, c.db, c.host, c.database, c.user].join(' ').toLowerCase().indexOf(q) !== -1;
|
|
898
|
+
});
|
|
899
|
+
if (!names.length) { box.appendChild(h('div', { className: 'empty' }, h('span', { text: 'No connections match “' + q + '”.' }))); return; }
|
|
900
|
+
var rows = names.map(function (name) {
|
|
901
|
+
var c = connectionsCache[name];
|
|
902
|
+
var endpoint = c.host ? c.host + (c.port ? ':' + c.port : '') : '';
|
|
903
|
+
return h('tr', {},
|
|
904
|
+
h('td', { style: 'width:28px;padding-right:0' }, h('span', { className: 'dot ' + (c.active ? 'ok' : 'off'), title: c.active ? 'Active' : 'Inactive' })),
|
|
905
|
+
h('td', {}, h('span', { className: 'name', text: name })),
|
|
906
|
+
h('td', {}, h('span', { className: 'tag', text: c.db || '?' })),
|
|
907
|
+
h('td', { className: 'mono' }, endpoint || h('span', { className: 'dim', text: '—' })),
|
|
908
|
+
h('td', { className: 'mono', text: c.database || '' }),
|
|
909
|
+
h('td', { className: 'mono', text: c.user || '' }),
|
|
910
|
+
h('td', { className: c.active ? '' : 'dim', text: c.active ? 'Active' : 'Inactive' }),
|
|
911
|
+
usageCell(c.usage),
|
|
912
|
+
h('td', {}, h('div', { className: 'actions' },
|
|
913
|
+
h('button', { type: 'button', className: 'btn ghost sm', text: 'Query', disabled: !c.active, title: 'Open in Run SQL', onclick: function () {
|
|
914
|
+
$('run-connection').value = name; showTab('run'); $('run-sql').focus(); } }),
|
|
915
|
+
h('button', { type: 'button', className: 'btn ghost sm', text: 'Edit', onclick: function () { openConnectionForm(name, c); } }),
|
|
916
|
+
h('button', { type: 'button', className: 'btn ghost sm danger', text: 'Delete', onclick: function () { deleteConnection(name); } }))));
|
|
917
|
+
});
|
|
918
|
+
box.appendChild(h('table', { className: 'grid' },
|
|
919
|
+
h('thead', {}, h('tr', {}, ['', 'Name', 'Type', 'Host', 'Database', 'User', 'Status', 'Usage', ''].map(function (t) { return h('th', { text: t }); }))),
|
|
920
|
+
h('tbody', {}, rows)));
|
|
921
|
+
}
|
|
922
|
+
$('conn-filter').oninput = renderConnections;
|
|
923
|
+
function connectionOptions(select, includeBlank, blankText) {
|
|
924
|
+
var current = select.value;
|
|
925
|
+
clear(select);
|
|
926
|
+
if (includeBlank) select.appendChild(h('option', { value: '', text: blankText }));
|
|
927
|
+
Object.keys(connectionsCache).sort().forEach(function (name) {
|
|
928
|
+
var c = connectionsCache[name];
|
|
929
|
+
select.appendChild(h('option', { value: name, text: name + (c.active ? '' : ' (inactive)') }));
|
|
930
|
+
});
|
|
931
|
+
if (current && connectionsCache[current]) select.value = current;
|
|
932
|
+
}
|
|
933
|
+
function populateConnectionSelect() {
|
|
934
|
+
var select = $('run-connection');
|
|
935
|
+
var had = select.value;
|
|
936
|
+
connectionOptions(select, false);
|
|
937
|
+
if (!had) {
|
|
938
|
+
var firstActive = Object.keys(connectionsCache).sort().filter(function (n) { return connectionsCache[n].active; })[0];
|
|
939
|
+
if (firstActive) select.value = firstActive;
|
|
940
|
+
}
|
|
941
|
+
runSchema.setConnection(select.value);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ---- API keys ----
|
|
945
|
+
// QUERYAPIGATE_API_KEY is a full-access admin key, unaffected by anything here. A scoped key created below is
|
|
946
|
+
// limited to the connections it's given (or every connection) and can be denied write access even when the
|
|
947
|
+
// server otherwise allows it - but only the admin key can reach this tab's endpoints at all, so a scoped
|
|
948
|
+
// key visiting /ui sees the same "couldn't load" state here as it does on Connections and Saved Queries.
|
|
949
|
+
var apiKeysCache = {};
|
|
950
|
+
|
|
951
|
+
async function loadApiKeys() {
|
|
952
|
+
var data = await apiJson('api_keys');
|
|
953
|
+
var box = $('apikeys-table');
|
|
954
|
+
if (!data) {
|
|
955
|
+
apiKeysCache = {};
|
|
956
|
+
$('count-apikeys').textContent = '';
|
|
957
|
+
clear(box).appendChild(h('div', { className: 'empty' }, h('strong', { text: 'Couldn’t load API keys' }),
|
|
958
|
+
h('span', { text: 'Only the admin key (QUERYAPIGATE_API_KEY) can manage API keys.' }),
|
|
959
|
+
h('button', { type: 'button', className: 'btn sm', text: 'Retry', onclick: loadApiKeys })));
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
apiKeysCache = data.keys || {};
|
|
963
|
+
renderApiKeys();
|
|
964
|
+
}
|
|
965
|
+
function renderApiKeys() {
|
|
966
|
+
var box = clear($('apikeys-table'));
|
|
967
|
+
var names = Object.keys(apiKeysCache).sort();
|
|
968
|
+
$('count-apikeys').textContent = names.length ? String(names.length) : '';
|
|
969
|
+
$('apikeys-sub').textContent = names.length ? names.length + (names.length === 1 ? ' key' : ' keys') : '';
|
|
970
|
+
if (!names.length) {
|
|
971
|
+
box.appendChild(h('div', { className: 'empty' }, h('strong', { text: 'No scoped API keys yet' }),
|
|
972
|
+
h('span', { text: 'QUERYAPIGATE_API_KEY is a full-access admin key. Create a scoped one to limit a caller to specific connections, read-only.' }),
|
|
973
|
+
h('button', { type: 'button', className: 'btn primary', text: 'New API key', onclick: function () { openApiKeyForm(); } })));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
var today = new Date().toISOString().slice(0, 10);
|
|
977
|
+
var rows = names.map(function (name) {
|
|
978
|
+
var k = apiKeysCache[name];
|
|
979
|
+
var conns = k.connections === '*'
|
|
980
|
+
? h('span', { className: 'tag', text: 'all connections' })
|
|
981
|
+
: h('div', { className: 'tags' }, (k.connections || []).length
|
|
982
|
+
? k.connections.map(function (c) { return h('span', { className: 'tag', text: c }); })
|
|
983
|
+
: [h('span', { className: 'dim', text: 'none' })]);
|
|
984
|
+
var queries = k.queries === '*'
|
|
985
|
+
? h('span', { className: 'tag', text: 'all queries' })
|
|
986
|
+
: h('div', { className: 'tags' }, (k.queries || []).length
|
|
987
|
+
? k.queries.map(function (q) {
|
|
988
|
+
var writable = q && typeof q === 'object' && q.allow_writes;
|
|
989
|
+
var qname = writable ? q.name : q;
|
|
990
|
+
return h('span', { className: 'tag', title: writable ? qname + ' — write access' : null,
|
|
991
|
+
text: qname + (writable ? ' (write)' : '') });
|
|
992
|
+
})
|
|
993
|
+
: [h('span', { className: 'dim', text: '—' })]);
|
|
994
|
+
var expired = k.expires_at && k.expires_at < today;
|
|
995
|
+
var expiry = k.expires_at
|
|
996
|
+
? h('span', { className: expired ? 'dim' : '', style: expired ? 'color:var(--danger)' : '', text: k.expires_at + (expired ? ' (expired)' : '') })
|
|
997
|
+
: h('span', { className: 'dim', text: 'never' });
|
|
998
|
+
var ips = (k.allowed_ips || []).length
|
|
999
|
+
? h('span', { title: k.allowed_ips.join(', '), text: k.allowed_ips.length + (k.allowed_ips.length === 1 ? ' IP' : ' IPs') })
|
|
1000
|
+
: h('span', { className: 'dim', text: 'any' });
|
|
1001
|
+
var writeOps = k.allow_writes && (k.allowed_write_ops || []).length;
|
|
1002
|
+
var access = !k.allow_writes
|
|
1003
|
+
? h('td', { className: 'dim', text: 'Read-only' })
|
|
1004
|
+
: writeOps
|
|
1005
|
+
? h('td', { title: k.allowed_write_ops.join(', '),
|
|
1006
|
+
text: 'Read/write (' + k.allowed_write_ops.length + (k.allowed_write_ops.length === 1 ? ' op' : ' ops') + ')' })
|
|
1007
|
+
: h('td', { text: 'Read/write' });
|
|
1008
|
+
return h('tr', {},
|
|
1009
|
+
h('td', { style: 'width:28px;padding-right:0' }, h('span', { className: 'dot ' + (k.active && !expired ? 'ok' : 'off'), title: !k.active ? 'Revoked' : (expired ? 'Expired' : 'Active') })),
|
|
1010
|
+
h('td', {}, h('span', { className: 'name', text: name })),
|
|
1011
|
+
h('td', {}, conns),
|
|
1012
|
+
h('td', {}, queries),
|
|
1013
|
+
access,
|
|
1014
|
+
h('td', {}, expiry),
|
|
1015
|
+
h('td', { className: k.rate_limit ? 'mono' : 'mono dim', text: k.rate_limit || 'server default' }),
|
|
1016
|
+
h('td', {}, ips),
|
|
1017
|
+
h('td', { className: 'mono dim', text: k.created_at || '' }),
|
|
1018
|
+
h('td', { className: 'mono dim', text: k.last_used_at || 'never' }),
|
|
1019
|
+
usageCell(k.usage),
|
|
1020
|
+
h('td', {}, h('div', { className: 'actions' },
|
|
1021
|
+
h('button', { type: 'button', className: 'btn ghost sm', text: 'Edit', onclick: function () { openApiKeyForm(name, k); } }),
|
|
1022
|
+
h('button', { type: 'button', className: 'btn ghost sm danger', text: 'Revoke', onclick: function () { deleteApiKey(name); } }))));
|
|
1023
|
+
});
|
|
1024
|
+
box.appendChild(h('table', { className: 'grid' },
|
|
1025
|
+
h('thead', {}, h('tr', {}, ['', 'Name', 'Connections', 'Queries', 'Access', 'Expires', 'Rate limit', 'IPs', 'Created', 'Last used', 'Usage', ''].map(function (t) { return h('th', { text: t }); }))),
|
|
1026
|
+
h('tbody', {}, rows)));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ---- roles ----
|
|
1030
|
+
// A role is a reusable *template* for the grant fields below - copied onto a key once, at
|
|
1031
|
+
// "create key from role" time. Editing or deleting a role afterward never touches a key already
|
|
1032
|
+
// created from it; see the "Create from" picker in openApiKeyForm.
|
|
1033
|
+
var rolesCache = {};
|
|
1034
|
+
|
|
1035
|
+
async function loadRoles() {
|
|
1036
|
+
var data = await apiJson('roles');
|
|
1037
|
+
var box = $('roles-table');
|
|
1038
|
+
if (!data) {
|
|
1039
|
+
rolesCache = {};
|
|
1040
|
+
$('count-roles').textContent = '';
|
|
1041
|
+
clear(box).appendChild(h('div', { className: 'empty' }, h('strong', { text: 'Couldn’t load roles' }),
|
|
1042
|
+
h('span', { text: 'Only the admin key (QUERYAPIGATE_API_KEY) can manage roles.' }),
|
|
1043
|
+
h('button', { type: 'button', className: 'btn sm', text: 'Retry', onclick: loadRoles })));
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
rolesCache = data.roles || {};
|
|
1047
|
+
renderRoles();
|
|
1048
|
+
}
|
|
1049
|
+
function renderRoles() {
|
|
1050
|
+
var box = clear($('roles-table'));
|
|
1051
|
+
var names = Object.keys(rolesCache).sort();
|
|
1052
|
+
$('count-roles').textContent = names.length ? String(names.length) : '';
|
|
1053
|
+
$('roles-sub').textContent = names.length ? names.length + (names.length === 1 ? ' role' : ' roles') : '';
|
|
1054
|
+
if (!names.length) {
|
|
1055
|
+
box.appendChild(h('div', { className: 'empty' }, h('strong', { text: 'No roles yet' }),
|
|
1056
|
+
h('span', { text: 'A role is a reusable template of connections, queries, write access, rate limit and allowed IPs — create an API key "from" a role instead of filling in every field by hand each time.' }),
|
|
1057
|
+
h('button', { type: 'button', className: 'btn primary', text: 'New role', onclick: function () { openRoleForm(); } })));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
var rows = names.map(function (name) {
|
|
1061
|
+
var r = rolesCache[name];
|
|
1062
|
+
var conns = r.connections === '*'
|
|
1063
|
+
? h('span', { className: 'tag', text: 'all connections' })
|
|
1064
|
+
: h('div', { className: 'tags' }, (r.connections || []).length
|
|
1065
|
+
? r.connections.map(function (c) { return h('span', { className: 'tag', text: c }); })
|
|
1066
|
+
: [h('span', { className: 'dim', text: 'none' })]);
|
|
1067
|
+
var queries = r.queries === '*'
|
|
1068
|
+
? h('span', { className: 'tag', text: 'all queries' })
|
|
1069
|
+
: h('div', { className: 'tags' }, (r.queries || []).length
|
|
1070
|
+
? r.queries.map(function (q) {
|
|
1071
|
+
var writable = q && typeof q === 'object' && q.allow_writes;
|
|
1072
|
+
var qname = writable ? q.name : q;
|
|
1073
|
+
return h('span', { className: 'tag', title: writable ? qname + ' — write access' : null,
|
|
1074
|
+
text: qname + (writable ? ' (write)' : '') });
|
|
1075
|
+
})
|
|
1076
|
+
: [h('span', { className: 'dim', text: '—' })]);
|
|
1077
|
+
var writeOps = r.allow_writes && (r.allowed_write_ops || []).length;
|
|
1078
|
+
var access = !r.allow_writes
|
|
1079
|
+
? h('td', { className: 'dim', text: 'Read-only' })
|
|
1080
|
+
: writeOps
|
|
1081
|
+
? h('td', { title: r.allowed_write_ops.join(', '),
|
|
1082
|
+
text: 'Read/write (' + r.allowed_write_ops.length + (r.allowed_write_ops.length === 1 ? ' op' : ' ops') + ')' })
|
|
1083
|
+
: h('td', { text: 'Read/write' });
|
|
1084
|
+
var ips = (r.allowed_ips || []).length
|
|
1085
|
+
? h('span', { title: r.allowed_ips.join(', '), text: r.allowed_ips.length + (r.allowed_ips.length === 1 ? ' IP' : ' IPs') })
|
|
1086
|
+
: h('span', { className: 'dim', text: 'any' });
|
|
1087
|
+
return h('tr', {},
|
|
1088
|
+
h('td', {}, h('span', { className: 'name', text: name })),
|
|
1089
|
+
h('td', {}, conns),
|
|
1090
|
+
h('td', {}, queries),
|
|
1091
|
+
access,
|
|
1092
|
+
h('td', { className: r.rate_limit ? 'mono' : 'mono dim', text: r.rate_limit || 'server default' }),
|
|
1093
|
+
h('td', {}, ips),
|
|
1094
|
+
h('td', {}, h('div', { className: 'actions' },
|
|
1095
|
+
h('button', { type: 'button', className: 'btn ghost sm', text: 'New key from this', onclick: function () { openApiKeyForm(null, {}, name); } }),
|
|
1096
|
+
h('button', { type: 'button', className: 'btn ghost sm', text: 'Edit', onclick: function () { openRoleForm(name, r); } }),
|
|
1097
|
+
h('button', { type: 'button', className: 'btn ghost sm danger', text: 'Delete', onclick: function () { deleteRole(name); } }))));
|
|
1098
|
+
});
|
|
1099
|
+
box.appendChild(h('table', { className: 'grid' },
|
|
1100
|
+
h('thead', {}, h('tr', {}, ['Name', 'Connections', 'Queries', 'Access', 'Rate limit', 'IPs', ''].map(function (t) { return h('th', { text: t }); }))),
|
|
1101
|
+
h('tbody', {}, rows)));
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function openRoleForm(name, existing) {
|
|
1105
|
+
existing = existing || {};
|
|
1106
|
+
var isEdit = !!name;
|
|
1107
|
+
var slot = openDrawer('role-form-slot', isEdit ? 'Edit role' : 'New role', isEdit ? name : 'POST /roles');
|
|
1108
|
+
var nameInput = h('input', { id: 'r-name', value: name || '', placeholder: 'reporting', autocomplete: 'off', spellcheck: 'false', required: isEdit ? null : true });
|
|
1109
|
+
if (isEdit) nameInput.disabled = true;
|
|
1110
|
+
var connNames = Object.keys(connectionsCache).sort();
|
|
1111
|
+
var wildcard = existing.connections === undefined || existing.connections === '*';
|
|
1112
|
+
var allCheckbox = h('input', { id: 'r-all', type: 'checkbox' });
|
|
1113
|
+
allCheckbox.checked = wildcard;
|
|
1114
|
+
var connChecks = {};
|
|
1115
|
+
var connBox = h('div', { className: 'tags' }, connNames.length ? connNames.map(function (cname) {
|
|
1116
|
+
var cb = h('input', { type: 'checkbox' });
|
|
1117
|
+
cb.checked = !wildcard && (existing.connections || []).indexOf(cname) !== -1;
|
|
1118
|
+
connChecks[cname] = cb;
|
|
1119
|
+
return h('label', { className: 'switch', style: 'font-weight:400' }, cb, cname);
|
|
1120
|
+
}) : h('span', { className: 'hint', text: 'No connections exist yet — add one on the Connections tab first.' }));
|
|
1121
|
+
function paintConnMode() { connBox.style.opacity = allCheckbox.checked ? '0.4' : '1'; connBox.style.pointerEvents = allCheckbox.checked ? 'none' : 'auto'; }
|
|
1122
|
+
allCheckbox.onchange = paintConnMode;
|
|
1123
|
+
paintConnMode();
|
|
1124
|
+
var queryNames = filesCache.map(function (f) { return f.filename; }).sort();
|
|
1125
|
+
var queryWildcard = existing.queries === '*';
|
|
1126
|
+
var existingQueryEntries = queryWildcard ? [] : (existing.queries || []);
|
|
1127
|
+
var existingQueryNames = existingQueryEntries.map(function (e) { return typeof e === 'string' ? e : e.name; });
|
|
1128
|
+
var existingWriteQueries = {};
|
|
1129
|
+
existingQueryEntries.forEach(function (e) { if (e && typeof e === 'object' && e.allow_writes) existingWriteQueries[e.name] = true; });
|
|
1130
|
+
var allQueriesCheckbox = h('input', { id: 'r-all-queries', type: 'checkbox' });
|
|
1131
|
+
allQueriesCheckbox.checked = queryWildcard;
|
|
1132
|
+
var queryChecks = {}, queryWriteChecks = {};
|
|
1133
|
+
var queryBox = h('div', { className: 'tags' }, queryNames.length ? queryNames.map(function (qname) {
|
|
1134
|
+
var cb = h('input', { type: 'checkbox' });
|
|
1135
|
+
cb.checked = !queryWildcard && existingQueryNames.indexOf(qname) !== -1;
|
|
1136
|
+
queryChecks[qname] = cb;
|
|
1137
|
+
var writeCb = h('input', { type: 'checkbox', title: 'Allow a key created from this role to write through ' + qname + ' specifically' });
|
|
1138
|
+
writeCb.checked = !!existingWriteQueries[qname];
|
|
1139
|
+
writeCb.disabled = !cb.checked;
|
|
1140
|
+
cb.onchange = function () { writeCb.disabled = !cb.checked; if (!cb.checked) writeCb.checked = false; };
|
|
1141
|
+
queryWriteChecks[qname] = writeCb;
|
|
1142
|
+
return h('label', { className: 'switch', style: 'font-weight:400' }, cb, qname,
|
|
1143
|
+
h('span', { className: 'switch', style: 'font-weight:400;margin-left:6px' }, writeCb, 'write'));
|
|
1144
|
+
}) : h('span', { className: 'hint', text: 'No saved queries exist yet — add one on the Saved Queries tab first.' }));
|
|
1145
|
+
function paintQueryMode() { queryBox.style.opacity = allQueriesCheckbox.checked ? '0.4' : '1'; queryBox.style.pointerEvents = allQueriesCheckbox.checked ? 'none' : 'auto'; }
|
|
1146
|
+
allQueriesCheckbox.onchange = paintQueryMode;
|
|
1147
|
+
paintQueryMode();
|
|
1148
|
+
var writesCheckbox = h('input', { id: 'r-writes', type: 'checkbox' });
|
|
1149
|
+
writesCheckbox.checked = !!existing.allow_writes;
|
|
1150
|
+
var rateLimitInput = h('input', { id: 'r-rate-limit', value: existing.rate_limit || '', placeholder: 'e.g. 100/minute', autocomplete: 'off', spellcheck: 'false' });
|
|
1151
|
+
var allowedIpsInput = h('input', { id: 'r-allowed-ips', value: (existing.allowed_ips || []).join(', '), placeholder: 'e.g. 203.0.113.5, 10.0.0.0/8', autocomplete: 'off', spellcheck: 'false' });
|
|
1152
|
+
var allowedWriteOpsInput = h('input', { id: 'r-allowed-write-ops', value: (existing.allowed_write_ops || []).join(', '), placeholder: 'e.g. insert, update', autocomplete: 'off', spellcheck: 'false' });
|
|
1153
|
+
var actions = formActions(isEdit ? 'Save' : 'Create role', closeDrawer);
|
|
1154
|
+
var form = h('form', { className: 'form', novalidate: true, onsubmit: function (e) {
|
|
1155
|
+
e.preventDefault();
|
|
1156
|
+
var connections = allCheckbox.checked ? '*' : Object.keys(connChecks).filter(function (c) { return connChecks[c].checked; });
|
|
1157
|
+
var queries = allQueriesCheckbox.checked ? '*' : Object.keys(queryChecks).filter(function (q) { return queryChecks[q].checked; })
|
|
1158
|
+
.map(function (q) { return queryWriteChecks[q].checked ? { name: q, allow_writes: true } : q; });
|
|
1159
|
+
var rateLimit = rateLimitInput.value.trim() || null;
|
|
1160
|
+
var allowedIps = allowedIpsInput.value.split(',').map(function (v) { return v.trim(); }).filter(Boolean);
|
|
1161
|
+
if (!allowedIps.length) allowedIps = null;
|
|
1162
|
+
var allowedWriteOps = allowedWriteOpsInput.value.split(',').map(function (v) { return v.trim().toLowerCase(); }).filter(Boolean);
|
|
1163
|
+
if (!allowedWriteOps.length) allowedWriteOps = null;
|
|
1164
|
+
var payload = { connections: connections, allow_writes: writesCheckbox.checked, queries: queries, rate_limit: rateLimit, allowed_ips: allowedIps, allowed_write_ops: allowedWriteOps };
|
|
1165
|
+
if (isEdit) updateRole(name, payload, actions.submit);
|
|
1166
|
+
else createRole(nameInput.value.trim(), payload, actions.submit);
|
|
1167
|
+
} },
|
|
1168
|
+
field('r-name', 'Name', nameInput, isEdit ? null : 'Letters, digits, spaces, “.”, “_” and “-”.'),
|
|
1169
|
+
h('label', { className: 'switch' }, allCheckbox, 'All connections'),
|
|
1170
|
+
h('div', { className: 'field' }, h('label', {}, 'Allowed connections'), connBox),
|
|
1171
|
+
h('label', { className: 'switch' }, allQueriesCheckbox, 'All saved queries'),
|
|
1172
|
+
h('div', { className: 'field' }, h('label', {}, 'Additional saved-query access'), queryBox),
|
|
1173
|
+
h('label', { className: 'switch' }, writesCheckbox, 'Allow writes', h('span', { className: 'hint', text: '— still capped by QUERYAPIGATE_ALLOW_WRITES' })),
|
|
1174
|
+
field('r-allowed-write-ops', 'Allowed write operations', allowedWriteOpsInput, 'Optional, comma-separated SQL keywords — e.g. "insert, update". Only takes effect when Allow writes is on.'),
|
|
1175
|
+
field('r-rate-limit', 'Rate limit', rateLimitInput, 'Optional — e.g. "100/minute". Leave blank for no limit of its own.'),
|
|
1176
|
+
field('r-allowed-ips', 'Allowed IPs', allowedIpsInput, 'Optional, comma-separated IP addresses or CIDR ranges. Leave blank to allow any address.'),
|
|
1177
|
+
h('div', { className: 'hint', text: 'A role is a template: it’s copied onto a key once, when the key is created "from" it. Editing or deleting this role afterward never changes a key already created from it.' }),
|
|
1178
|
+
actions.node);
|
|
1179
|
+
slot.appendChild(form);
|
|
1180
|
+
nameInput.focus();
|
|
1181
|
+
}
|
|
1182
|
+
$('new-role').onclick = function () { openRoleForm(); };
|
|
1183
|
+
|
|
1184
|
+
async function createRole(name, payload, btn) {
|
|
1185
|
+
if (!name) { showError('A role name is required.', { errors: { name: 'This field is required' } }); $('r-name').focus(); return; }
|
|
1186
|
+
btn.disabled = true;
|
|
1187
|
+
var res = await apiJson('roles', { method: 'POST', json: Object.assign({ name: name }, payload) });
|
|
1188
|
+
btn.disabled = false;
|
|
1189
|
+
if (res) { showError(''); closeDrawer(); toast('Created role ' + name); loadRoles(); }
|
|
1190
|
+
}
|
|
1191
|
+
async function updateRole(name, payload, btn) {
|
|
1192
|
+
btn.disabled = true;
|
|
1193
|
+
var res = await apiJson('roles/' + enc(name), { method: 'PATCH', json: payload });
|
|
1194
|
+
btn.disabled = false;
|
|
1195
|
+
if (res) { showError(''); closeDrawer(); toast('Updated ' + name); loadRoles(); }
|
|
1196
|
+
}
|
|
1197
|
+
async function deleteRole(name) {
|
|
1198
|
+
if (!confirm("Delete role '" + name + "'? Keys already created from it are unaffected — this only removes the template.")) return;
|
|
1199
|
+
var res = await apiJson('roles/' + enc(name), { method: 'DELETE' });
|
|
1200
|
+
if (res) { toast('Deleted role ' + name); loadRoles(); }
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// ---- audit log ----
|
|
1204
|
+
var auditLogCache = [];
|
|
1205
|
+
|
|
1206
|
+
async function loadAuditLog() {
|
|
1207
|
+
var box = $('auditlog-table');
|
|
1208
|
+
var data = await apiJson('audit_log');
|
|
1209
|
+
if (!data) {
|
|
1210
|
+
$('auditlog-sub').textContent = '';
|
|
1211
|
+
clear(box).appendChild(h('div', { className: 'empty' }, h('strong', { text: 'Couldn’t load the audit log' }),
|
|
1212
|
+
h('span', { text: 'Only the admin key (QUERYAPIGATE_API_KEY) can view the audit log.' }),
|
|
1213
|
+
h('button', { type: 'button', className: 'btn sm', text: 'Retry', onclick: loadAuditLog })));
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
auditLogCache = data.entries || [];
|
|
1217
|
+
var actionSelect = $('auditlog-action-filter');
|
|
1218
|
+
var currentAction = actionSelect.value;
|
|
1219
|
+
var actions = Array.from(new Set(auditLogCache.map(function (e) { return e.action; }).filter(Boolean))).sort();
|
|
1220
|
+
clear(actionSelect);
|
|
1221
|
+
actionSelect.appendChild(h('option', { value: '', text: 'All actions' }));
|
|
1222
|
+
actions.forEach(function (a) { actionSelect.appendChild(h('option', { value: a, text: a })); });
|
|
1223
|
+
if (actions.indexOf(currentAction) !== -1) actionSelect.value = currentAction;
|
|
1224
|
+
renderAuditLog();
|
|
1225
|
+
}
|
|
1226
|
+
$('refresh-auditlog').onclick = loadAuditLog;
|
|
1227
|
+
$('auditlog-action-filter').onchange = renderAuditLog;
|
|
1228
|
+
$('auditlog-filter').oninput = renderAuditLog;
|
|
1229
|
+
|
|
1230
|
+
function renderAuditLog() {
|
|
1231
|
+
var box = clear($('auditlog-table'));
|
|
1232
|
+
if (!auditLogCache.length) {
|
|
1233
|
+
$('auditlog-sub').textContent = '';
|
|
1234
|
+
box.appendChild(h('div', { className: 'empty' }, h('strong', { text: 'No administrative changes recorded yet' }),
|
|
1235
|
+
h('span', { text: 'Creating or changing a connection, saved query or API key will appear here.' })));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
var action = $('auditlog-action-filter').value;
|
|
1239
|
+
var q = $('auditlog-filter').value.trim().toLowerCase();
|
|
1240
|
+
var entries = auditLogCache.filter(function (e) {
|
|
1241
|
+
if (action && e.action !== action) return false;
|
|
1242
|
+
if (!q) return true;
|
|
1243
|
+
return [e.timestamp, e.actor, e.target].join(' ').toLowerCase().indexOf(q) !== -1;
|
|
1244
|
+
});
|
|
1245
|
+
$('auditlog-sub').textContent = entries.length === auditLogCache.length
|
|
1246
|
+
? auditLogCache.length + (auditLogCache.length === 1 ? ' entry' : ' entries')
|
|
1247
|
+
: entries.length + ' of ' + auditLogCache.length + ' entries';
|
|
1248
|
+
if (!entries.length) {
|
|
1249
|
+
box.appendChild(h('div', { className: 'empty' }, h('span', { text: 'No entries match this filter.' })));
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
var rows = entries.map(function (e) {
|
|
1253
|
+
return h('tr', {},
|
|
1254
|
+
h('td', { className: 'mono dim', text: e.timestamp || '' }),
|
|
1255
|
+
h('td', {}, h('span', { className: 'name', text: e.actor || '-' })),
|
|
1256
|
+
h('td', {}, h('span', { className: 'tag', text: e.action || '' })),
|
|
1257
|
+
h('td', {}, h('span', { className: 'name', text: e.target || '' })),
|
|
1258
|
+
h('td', {}, renderAuditChanges(e.changes)));
|
|
1259
|
+
});
|
|
1260
|
+
box.appendChild(h('table', { className: 'grid' },
|
|
1261
|
+
h('thead', {}, h('tr', {}, ['Time', 'Actor', 'Action', 'Target', 'Changes'].map(function (t) { return h('th', { text: t }); }))),
|
|
1262
|
+
h('tbody', {}, rows)));
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function auditValueText(v) {
|
|
1266
|
+
if (v === null || v === undefined || v === '') return 'none';
|
|
1267
|
+
if (Array.isArray(v)) return v.length ? v.join(', ') : 'none';
|
|
1268
|
+
return String(v);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** true for a value renderAuditChanges() should skip in a full snapshot (create/delete) - unset/empty, not
|
|
1272
|
+
* a real value like false or 0. A diff entry (an update) never has these in the first place - _dict_diff()
|
|
1273
|
+
* only ever includes a field that actually changed - so this only thins out snapshots. */
|
|
1274
|
+
function auditValueIsEmpty(v) {
|
|
1275
|
+
return v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function renderAuditChanges(changes) {
|
|
1279
|
+
var keys = changes && typeof changes === 'object' ? Object.keys(changes).sort() : [];
|
|
1280
|
+
var shown = keys.filter(function (key) {
|
|
1281
|
+
var v = changes[key];
|
|
1282
|
+
var isDiff = v && typeof v === 'object' && !Array.isArray(v) && ('from' in v || 'to' in v);
|
|
1283
|
+
return isDiff || !auditValueIsEmpty(v);
|
|
1284
|
+
});
|
|
1285
|
+
if (!shown.length) return h('span', { className: 'dim', text: keys.length ? 'no other fields set' : '—' });
|
|
1286
|
+
return h('div', {}, shown.map(function (key) {
|
|
1287
|
+
var v = changes[key];
|
|
1288
|
+
var text = (v && typeof v === 'object' && !Array.isArray(v) && ('from' in v || 'to' in v))
|
|
1289
|
+
? key + ': ' + auditValueText(v.from) + ' → ' + auditValueText(v.to)
|
|
1290
|
+
: key + ': ' + auditValueText(v);
|
|
1291
|
+
return h('div', { className: 'mono', style: 'font-size:12px', text: text });
|
|
1292
|
+
}));
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ---- metrics (a live snapshot of /metrics, parsed client-side - counters and gauges only, this process's
|
|
1296
|
+
// own in-memory numbers since it started, deliberately no history or trends; see the tab's own subtitle
|
|
1297
|
+
// and documentation/API.md#observability for why that stays Prometheus/Grafana's job, not this page's) ----
|
|
1298
|
+
function parseMetricsText(text) {
|
|
1299
|
+
var series = [];
|
|
1300
|
+
text.split('\n').forEach(function (line) {
|
|
1301
|
+
if (!line || line.charAt(0) === '#') return;
|
|
1302
|
+
var m = line.match(/^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{([^}]*)\})?\s+([0-9eE+\-.]+)\s*$/);
|
|
1303
|
+
if (!m) return;
|
|
1304
|
+
var labels = {};
|
|
1305
|
+
(m[3] || '').replace(/([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g, function (_, k, v) {
|
|
1306
|
+
labels[k] = v.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
1307
|
+
return '';
|
|
1308
|
+
});
|
|
1309
|
+
series.push({ name: m[1], labels: labels, value: Number(m[4]) });
|
|
1310
|
+
});
|
|
1311
|
+
return series;
|
|
1312
|
+
}
|
|
1313
|
+
function metricSum(series, name, filter) {
|
|
1314
|
+
return series.filter(function (s) { return s.name === name && (!filter || filter(s.labels)); })
|
|
1315
|
+
.reduce(function (a, s) { return a + s.value; }, 0);
|
|
1316
|
+
}
|
|
1317
|
+
function metricGauge(series, name) {
|
|
1318
|
+
var s = series.filter(function (s2) { return s2.name === name; })[0];
|
|
1319
|
+
return s ? s.value : 0;
|
|
1320
|
+
}
|
|
1321
|
+
function metricGroupSum(series, name, labelKey) {
|
|
1322
|
+
var totals = {};
|
|
1323
|
+
series.filter(function (s) { return s.name === name; }).forEach(function (s) {
|
|
1324
|
+
var key = s.labels[labelKey] || '(none)';
|
|
1325
|
+
totals[key] = (totals[key] || 0) + s.value;
|
|
1326
|
+
});
|
|
1327
|
+
return totals;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function loadMetrics() {
|
|
1331
|
+
var box = $('metrics-body');
|
|
1332
|
+
var res;
|
|
1333
|
+
try { res = await apiFetch('metrics'); } catch (e) { res = null; }
|
|
1334
|
+
if (!res || !res.ok) {
|
|
1335
|
+
clear(box).appendChild(h('div', { className: 'empty' }, h('strong', { text: 'Couldn’t load metrics' }),
|
|
1336
|
+
h('button', { type: 'button', className: 'btn sm', text: 'Retry', onclick: loadMetrics })));
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
renderMetrics(parseMetricsText(await res.text()));
|
|
1340
|
+
}
|
|
1341
|
+
$('refresh-metrics').onclick = loadMetrics;
|
|
1342
|
+
|
|
1343
|
+
function statTile(label, value, warn) {
|
|
1344
|
+
return h('div', { className: 'panel stat-tile' },
|
|
1345
|
+
h('div', { className: 'label', text: label }),
|
|
1346
|
+
h('div', { className: 'value' + (warn ? ' warn' : ''), text: String(value) }));
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function renderMetrics(series) {
|
|
1350
|
+
var box = clear($('metrics-body'));
|
|
1351
|
+
var totalRequests = metricSum(series, 'queryapigate_requests_total');
|
|
1352
|
+
var byStatusClass = {};
|
|
1353
|
+
series.filter(function (s) { return s.name === 'queryapigate_requests_total'; }).forEach(function (s) {
|
|
1354
|
+
var cls = (s.labels.status || '?').charAt(0) + 'xx';
|
|
1355
|
+
byStatusClass[cls] = (byStatusClass[cls] || 0) + s.value;
|
|
1356
|
+
});
|
|
1357
|
+
var errorCount = Object.keys(byStatusClass).filter(function (c) { return c === '4xx' || c === '5xx'; })
|
|
1358
|
+
.reduce(function (a, c) { return a + byStatusClass[c]; }, 0);
|
|
1359
|
+
var errorRate = totalRequests ? (100 * errorCount / totalRequests) : 0;
|
|
1360
|
+
|
|
1361
|
+
box.appendChild(h('div', { className: 'stat-tiles' },
|
|
1362
|
+
statTile('Requests', totalRequests),
|
|
1363
|
+
statTile('Error rate', errorRate.toFixed(1) + '%', errorRate >= 5),
|
|
1364
|
+
statTile('Active queries', metricGauge(series, 'queryapigate_active_queries')),
|
|
1365
|
+
statTile('Pool idle connections', metricGauge(series, 'queryapigate_pool_idle_connections')),
|
|
1366
|
+
statTile('Rate limit rejections', metricGauge(series, 'queryapigate_rate_limit_rejections_total')),
|
|
1367
|
+
statTile('Rows returned', metricSum(series, 'queryapigate_rows_returned_total'))));
|
|
1368
|
+
|
|
1369
|
+
var statusRows = ['2xx', '3xx', '4xx', '5xx'].map(function (c) { return { label: c, value: byStatusClass[c] || 0 }; })
|
|
1370
|
+
.filter(function (r) { return r.value > 0; });
|
|
1371
|
+
var byConnection = metricGroupSum(series, 'queryapigate_queries_total', 'connection');
|
|
1372
|
+
var connRows = Object.keys(byConnection).sort().map(function (c) { return { label: c, value: byConnection[c] }; });
|
|
1373
|
+
|
|
1374
|
+
box.appendChild(h('div', { className: 'metrics-charts' },
|
|
1375
|
+
statusRows.length ? h('div', { className: 'panel chart-panel' }, h('h3', { style: 'font-size:13px;margin-bottom:8px', text: 'Requests by status' }),
|
|
1376
|
+
barChartSvg(statusRows, 'label', 'value')) : null,
|
|
1377
|
+
connRows.length ? h('div', { className: 'panel chart-panel' }, h('h3', { style: 'font-size:13px;margin-bottom:8px', text: 'Queries by connection' }),
|
|
1378
|
+
barChartSvg(connRows, 'label', 'value')) : null));
|
|
1379
|
+
|
|
1380
|
+
var errorsByConnection = {};
|
|
1381
|
+
series.filter(function (s) { return s.name === 'queryapigate_queries_total' && s.labels.status === 'error'; })
|
|
1382
|
+
.forEach(function (s) { var c = s.labels.connection || '?'; errorsByConnection[c] = (errorsByConnection[c] || 0) + s.value; });
|
|
1383
|
+
var rowsByConnection = metricGroupSum(series, 'queryapigate_rows_returned_total', 'connection');
|
|
1384
|
+
var sumByConn = {}, countByConn = {};
|
|
1385
|
+
series.filter(function (s) { return s.name === 'queryapigate_query_duration_seconds_sum'; })
|
|
1386
|
+
.forEach(function (s) { sumByConn[s.labels.connection] = (sumByConn[s.labels.connection] || 0) + s.value; });
|
|
1387
|
+
series.filter(function (s) { return s.name === 'queryapigate_query_duration_seconds_count'; })
|
|
1388
|
+
.forEach(function (s) { countByConn[s.labels.connection] = (countByConn[s.labels.connection] || 0) + s.value; });
|
|
1389
|
+
var connNames = Object.keys(byConnection).sort();
|
|
1390
|
+
if (connNames.length) {
|
|
1391
|
+
var tRows = connNames.map(function (c) {
|
|
1392
|
+
var count = countByConn[c] || 0;
|
|
1393
|
+
var avgMs = count ? Math.round(1000 * (sumByConn[c] || 0) / count) : null;
|
|
1394
|
+
return h('tr', {},
|
|
1395
|
+
h('td', {}, h('span', { className: 'name', text: c })),
|
|
1396
|
+
h('td', { className: 'mono', style: 'text-align:right', text: String(byConnection[c] || 0) }),
|
|
1397
|
+
h('td', { className: 'mono', style: 'text-align:right', text: String(errorsByConnection[c] || 0) }),
|
|
1398
|
+
h('td', { className: 'mono', style: 'text-align:right', text: avgMs === null ? '—' : avgMs + ' ms' }),
|
|
1399
|
+
h('td', { className: 'mono', style: 'text-align:right', text: String(rowsByConnection[c] || 0) }));
|
|
1400
|
+
});
|
|
1401
|
+
box.appendChild(h('div', { className: 'panel' }, h('table', { className: 'grid' },
|
|
1402
|
+
h('thead', {}, h('tr', {}, ['Connection', 'Queries', 'Errors', 'Avg latency', 'Rows returned'].map(function (t) { return h('th', { text: t }); }))),
|
|
1403
|
+
h('tbody', {}, tRows))));
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function openApiKeyForm(name, existing, fromRole) {
|
|
1408
|
+
existing = existing || {};
|
|
1409
|
+
var isEdit = !!name;
|
|
1410
|
+
var slot = openDrawer('apikey-form-slot', isEdit ? 'Edit API key' : 'New API key', isEdit ? name : 'POST /api_keys');
|
|
1411
|
+
var nameInput = h('input', { id: 'k-name', value: name || '', placeholder: 'reporting', autocomplete: 'off', spellcheck: 'false', required: isEdit ? null : true });
|
|
1412
|
+
if (isEdit) nameInput.disabled = true;
|
|
1413
|
+
var roleNames = Object.keys(rolesCache).sort();
|
|
1414
|
+
var roleSelect = null, grantFields = null;
|
|
1415
|
+
if (!isEdit && roleNames.length) {
|
|
1416
|
+
roleSelect = h('select', { id: 'k-role' }, h('option', { value: '', text: 'Custom (choose grants below)' }),
|
|
1417
|
+
roleNames.map(function (r) { return h('option', { value: r, text: r }); }));
|
|
1418
|
+
if (fromRole) roleSelect.value = fromRole;
|
|
1419
|
+
}
|
|
1420
|
+
var connNames = Object.keys(connectionsCache).sort();
|
|
1421
|
+
var wildcard = existing.connections === undefined || existing.connections === '*';
|
|
1422
|
+
var allCheckbox = h('input', { id: 'k-all', type: 'checkbox' });
|
|
1423
|
+
allCheckbox.checked = wildcard;
|
|
1424
|
+
var connChecks = {};
|
|
1425
|
+
var connBox = h('div', { className: 'tags' }, connNames.length ? connNames.map(function (cname) {
|
|
1426
|
+
var cb = h('input', { type: 'checkbox' });
|
|
1427
|
+
cb.checked = !wildcard && (existing.connections || []).indexOf(cname) !== -1;
|
|
1428
|
+
connChecks[cname] = cb;
|
|
1429
|
+
return h('label', { className: 'switch', style: 'font-weight:400' }, cb, cname);
|
|
1430
|
+
}) : h('span', { className: 'hint', text: 'No connections exist yet — add one on the Connections tab first.' }));
|
|
1431
|
+
function paintConnMode() { connBox.style.opacity = allCheckbox.checked ? '0.4' : '1'; connBox.style.pointerEvents = allCheckbox.checked ? 'none' : 'auto'; }
|
|
1432
|
+
allCheckbox.onchange = paintConnMode;
|
|
1433
|
+
paintConnMode();
|
|
1434
|
+
var queryNames = filesCache.map(function (f) { return f.filename; }).sort();
|
|
1435
|
+
var queryWildcard = existing.queries === '*';
|
|
1436
|
+
var existingQueryEntries = queryWildcard ? [] : (existing.queries || []);
|
|
1437
|
+
var existingQueryNames = existingQueryEntries.map(function (e) { return typeof e === 'string' ? e : e.name; });
|
|
1438
|
+
var existingWriteQueries = {};
|
|
1439
|
+
existingQueryEntries.forEach(function (e) { if (e && typeof e === 'object' && e.allow_writes) existingWriteQueries[e.name] = true; });
|
|
1440
|
+
var allQueriesCheckbox = h('input', { id: 'k-all-queries', type: 'checkbox' });
|
|
1441
|
+
allQueriesCheckbox.checked = queryWildcard;
|
|
1442
|
+
var queryChecks = {}, queryWriteChecks = {};
|
|
1443
|
+
var queryBox = h('div', { className: 'tags' }, queryNames.length ? queryNames.map(function (qname) {
|
|
1444
|
+
var cb = h('input', { type: 'checkbox' });
|
|
1445
|
+
cb.checked = !queryWildcard && existingQueryNames.indexOf(qname) !== -1;
|
|
1446
|
+
queryChecks[qname] = cb;
|
|
1447
|
+
var writeCb = h('input', { type: 'checkbox', title: 'Allow this key to write through ' + qname + ' specifically' });
|
|
1448
|
+
writeCb.checked = !!existingWriteQueries[qname];
|
|
1449
|
+
writeCb.disabled = !cb.checked;
|
|
1450
|
+
cb.onchange = function () { writeCb.disabled = !cb.checked; if (!cb.checked) writeCb.checked = false; };
|
|
1451
|
+
queryWriteChecks[qname] = writeCb;
|
|
1452
|
+
return h('label', { className: 'switch', style: 'font-weight:400' }, cb, qname,
|
|
1453
|
+
h('span', { className: 'switch', style: 'font-weight:400;margin-left:6px' }, writeCb, 'write'));
|
|
1454
|
+
}) : h('span', { className: 'hint', text: 'No saved queries exist yet — add one on the Saved Queries tab first.' }));
|
|
1455
|
+
function paintQueryMode() { queryBox.style.opacity = allQueriesCheckbox.checked ? '0.4' : '1'; queryBox.style.pointerEvents = allQueriesCheckbox.checked ? 'none' : 'auto'; }
|
|
1456
|
+
allQueriesCheckbox.onchange = paintQueryMode;
|
|
1457
|
+
paintQueryMode();
|
|
1458
|
+
var writesCheckbox = h('input', { id: 'k-writes', type: 'checkbox' });
|
|
1459
|
+
writesCheckbox.checked = !!existing.allow_writes;
|
|
1460
|
+
var expiresInput = h('input', { id: 'k-expires', type: 'date', value: existing.expires_at || '' });
|
|
1461
|
+
var rateLimitInput = h('input', { id: 'k-rate-limit', value: existing.rate_limit || '', placeholder: 'e.g. 100/minute', autocomplete: 'off', spellcheck: 'false' });
|
|
1462
|
+
var allowedIpsInput = h('input', { id: 'k-allowed-ips', value: (existing.allowed_ips || []).join(', '), placeholder: 'e.g. 203.0.113.5, 10.0.0.0/8', autocomplete: 'off', spellcheck: 'false' });
|
|
1463
|
+
var allowedWriteOpsInput = h('input', { id: 'k-allowed-write-ops', value: (existing.allowed_write_ops || []).join(', '), placeholder: 'e.g. insert, update', autocomplete: 'off', spellcheck: 'false' });
|
|
1464
|
+
var activeCheckbox = isEdit ? h('input', { id: 'k-active', type: 'checkbox' }) : null;
|
|
1465
|
+
if (activeCheckbox) activeCheckbox.checked = existing.active !== false;
|
|
1466
|
+
grantFields = h('div', {},
|
|
1467
|
+
h('label', { className: 'switch' }, allCheckbox, 'All connections'),
|
|
1468
|
+
h('div', { className: 'field' }, h('label', {}, 'Allowed connections'), connBox),
|
|
1469
|
+
h('label', { className: 'switch' }, allQueriesCheckbox, 'All saved queries'),
|
|
1470
|
+
h('div', { className: 'field' }, h('label', {}, 'Additional saved-query access'), queryBox,
|
|
1471
|
+
h('span', { className: 'hint', text: '— independent of connections above: runnable by name even without connection access, for an external client that should only see its own approved queries. Check "write" on a query to let this key write through it specifically, even with no blanket write access — "All saved queries" above can never carry write access, only a specific enumerated list can.' })),
|
|
1472
|
+
h('label', { className: 'switch' }, writesCheckbox, 'Allow writes', h('span', { className: 'hint', text: '— still capped by QUERYAPIGATE_ALLOW_WRITES' })),
|
|
1473
|
+
field('k-allowed-write-ops', 'Allowed write operations', allowedWriteOpsInput, 'Optional, comma-separated SQL keywords — e.g. "insert, update". Only takes effect when Allow writes is on; narrows which write statements this key may perform. Leave blank to allow any write.'),
|
|
1474
|
+
field('k-rate-limit', 'Rate limit', rateLimitInput, 'Optional, this key only — e.g. "100/minute". Checked in addition to QUERYAPIGATE_RATE_LIMIT, not instead of it. Leave blank for no limit of its own.'),
|
|
1475
|
+
field('k-allowed-ips', 'Allowed IPs', allowedIpsInput, 'Optional, comma-separated IP addresses or CIDR ranges — e.g. "203.0.113.5, 10.0.0.0/8". Leave blank to allow any address.'));
|
|
1476
|
+
function paintRoleMode() {
|
|
1477
|
+
var picked = roleSelect && roleSelect.value;
|
|
1478
|
+
grantFields.style.opacity = picked ? '0.4' : '1';
|
|
1479
|
+
grantFields.style.pointerEvents = picked ? 'none' : 'auto';
|
|
1480
|
+
}
|
|
1481
|
+
if (roleSelect) { roleSelect.onchange = paintRoleMode; paintRoleMode(); }
|
|
1482
|
+
var actions = formActions(isEdit ? 'Save' : 'Create key', closeDrawer);
|
|
1483
|
+
var form = h('form', { className: 'form', novalidate: true, onsubmit: function (e) {
|
|
1484
|
+
e.preventDefault();
|
|
1485
|
+
var expiresAt = expiresInput.value || null;
|
|
1486
|
+
var pickedRole = roleSelect && roleSelect.value;
|
|
1487
|
+
if (!isEdit && pickedRole) { createApiKey({ name: nameInput.value.trim(), role: pickedRole, expires_at: expiresAt }, actions.submit); return; }
|
|
1488
|
+
var connections = allCheckbox.checked ? '*' : Object.keys(connChecks).filter(function (c) { return connChecks[c].checked; });
|
|
1489
|
+
var queries = allQueriesCheckbox.checked ? '*' : Object.keys(queryChecks).filter(function (q) { return queryChecks[q].checked; })
|
|
1490
|
+
.map(function (q) { return queryWriteChecks[q].checked ? { name: q, allow_writes: true } : q; });
|
|
1491
|
+
var rateLimit = rateLimitInput.value.trim() || null;
|
|
1492
|
+
var allowedIps = allowedIpsInput.value.split(',').map(function (v) { return v.trim(); }).filter(Boolean);
|
|
1493
|
+
if (!allowedIps.length) allowedIps = null;
|
|
1494
|
+
var allowedWriteOps = allowedWriteOpsInput.value.split(',').map(function (v) { return v.trim().toLowerCase(); }).filter(Boolean);
|
|
1495
|
+
if (!allowedWriteOps.length) allowedWriteOps = null;
|
|
1496
|
+
if (isEdit) updateApiKey(name, { connections: connections, allow_writes: writesCheckbox.checked, active: activeCheckbox.checked, queries: queries, expires_at: expiresAt, rate_limit: rateLimit, allowed_ips: allowedIps, allowed_write_ops: allowedWriteOps }, actions.submit);
|
|
1497
|
+
else createApiKey({ name: nameInput.value.trim(), connections: connections, allow_writes: writesCheckbox.checked, queries: queries, expires_at: expiresAt, rate_limit: rateLimit, allowed_ips: allowedIps, allowed_write_ops: allowedWriteOps }, actions.submit);
|
|
1498
|
+
} },
|
|
1499
|
+
field('k-name', 'Name', nameInput, isEdit ? null : 'Letters, digits, spaces, “.”, “_” and “-”.'),
|
|
1500
|
+
roleSelect ? field('k-role', 'Create from', roleSelect, 'Optional — copies that role’s connections, queries, write access, rate limit and allowed IPs onto this key once, at creation. Editing or deleting the role afterward never changes this key.') : null,
|
|
1501
|
+
grantFields,
|
|
1502
|
+
field('k-expires', 'Expires', expiresInput, 'Optional — valid through the end of this date. Leave blank for no expiry.'),
|
|
1503
|
+
activeCheckbox ? h('label', { className: 'switch' }, activeCheckbox, 'Active', h('span', { className: 'hint', text: '— unchecking revokes it immediately' })) : null,
|
|
1504
|
+
actions.node);
|
|
1505
|
+
slot.appendChild(form);
|
|
1506
|
+
nameInput.focus();
|
|
1507
|
+
}
|
|
1508
|
+
$('new-apikey').onclick = function () { openApiKeyForm(); };
|
|
1509
|
+
|
|
1510
|
+
async function createApiKey(payload, btn) {
|
|
1511
|
+
if (!payload.name) { showError('An API key name is required.', { errors: { name: 'This field is required' } }); $('k-name').focus(); return; }
|
|
1512
|
+
btn.disabled = true;
|
|
1513
|
+
var res = await apiJson('api_keys', { method: 'POST', json: payload });
|
|
1514
|
+
btn.disabled = false;
|
|
1515
|
+
if (res) { showError(''); revealApiKey(res.name, res.key); loadApiKeys(); }
|
|
1516
|
+
}
|
|
1517
|
+
async function updateApiKey(name, patch, btn) {
|
|
1518
|
+
btn.disabled = true;
|
|
1519
|
+
var res = await apiJson('api_keys/' + enc(name), { method: 'PATCH', json: patch });
|
|
1520
|
+
btn.disabled = false;
|
|
1521
|
+
if (res) { showError(''); closeDrawer(); toast('Updated ' + name); loadApiKeys(); }
|
|
1522
|
+
}
|
|
1523
|
+
async function deleteApiKey(name) {
|
|
1524
|
+
if (!confirm("Revoke API key '" + name + "'? Anything still using it will stop working immediately.")) return;
|
|
1525
|
+
var res = await apiJson('api_keys/' + enc(name), { method: 'DELETE' });
|
|
1526
|
+
if (res) { toast('Revoked ' + name); loadApiKeys(); }
|
|
1527
|
+
}
|
|
1528
|
+
/** Shown once, right after creation - the secret is never retrievable again after this. */
|
|
1529
|
+
function revealApiKey(name, secret) {
|
|
1530
|
+
clear($('apikey-form-slot'));
|
|
1531
|
+
$('drawer-title').textContent = 'API key created';
|
|
1532
|
+
$('drawer-kicker').textContent = name;
|
|
1533
|
+
var input = h('input', { readonly: true, className: 'mono', onclick: function () { input.select(); } });
|
|
1534
|
+
input.value = secret;
|
|
1535
|
+
$('apikey-form-slot').appendChild(h('div', { className: 'form' },
|
|
1536
|
+
h('div', { className: 'field' }, h('label', {}, 'Secret key'), input,
|
|
1537
|
+
h('div', { className: 'hint', text: 'Store this now — it cannot be shown again. To rotate it, revoke this key and create a new one.' })),
|
|
1538
|
+
h('div', { className: 'form-actions' },
|
|
1539
|
+
h('button', { type: 'button', className: 'btn ghost', text: 'Copy', onclick: function () { copyText(secret); } }),
|
|
1540
|
+
h('button', { type: 'button', className: 'btn primary', text: 'Done', onclick: closeDrawer }))));
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// ---- saved queries ----
|
|
1544
|
+
var filesCache = [];
|
|
1545
|
+
var selected = { name: null, version: null, tab: 'run' };
|
|
1546
|
+
var contentCache = {};
|
|
1547
|
+
|
|
1548
|
+
function latestOf(f) { return f.versions[f.versions.length - 1] || {}; }
|
|
1549
|
+
function findFile(name) { return filesCache.filter(function (f) { return f.filename === name; })[0] || null; }
|
|
1550
|
+
function lastRun(v) { var hs = v.execution_history || []; return hs[hs.length - 1] || null; }
|
|
1551
|
+
|
|
1552
|
+
async function loadQueries(selectName) {
|
|
1553
|
+
var data = await apiJson('list_files');
|
|
1554
|
+
if (!data) {
|
|
1555
|
+
clear($('queries-table')).appendChild(h('div', { className: 'empty' }, h('strong', { text: 'Couldn’t load saved queries' }),
|
|
1556
|
+
h('button', { type: 'button', className: 'btn sm', text: 'Retry', onclick: function () { loadQueries(); } })));
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
filesCache = (data.files || []).slice().sort(function (a, b) { return a.filename < b.filename ? -1 : 1; });
|
|
1560
|
+
contentCache = {};
|
|
1561
|
+
$('count-queries').textContent = filesCache.length ? String(filesCache.length) : '';
|
|
1562
|
+
$('queries-sub').textContent = filesCache.length ? filesCache.reduce(function (n, f) { return n + f.versions.length; }, 0) + ' versions' : '';
|
|
1563
|
+
if (selectName !== undefined) selected.name = selectName;
|
|
1564
|
+
var f = selected.name && findFile(selected.name);
|
|
1565
|
+
if (!f) { selected.name = null; selected.version = null; }
|
|
1566
|
+
else if (!f.versions.some(function (v) { return v.version === selected.version; })) selected.version = latestOf(f).version;
|
|
1567
|
+
renderQueryList();
|
|
1568
|
+
renderDetail();
|
|
1569
|
+
}
|
|
1570
|
+
function renderQueryList() {
|
|
1571
|
+
var box = clear($('queries-table'));
|
|
1572
|
+
if (!filesCache.length) {
|
|
1573
|
+
box.appendChild(h('div', { className: 'empty' }, h('strong', { text: 'No saved queries yet' }),
|
|
1574
|
+
h('span', { text: 'Saved queries become GET /q/<name> endpoints with typed parameters.' }),
|
|
1575
|
+
h('button', { type: 'button', className: 'btn primary', text: 'New saved query', onclick: function () { openQueryForm(); } })));
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
var q = $('query-filter').value.trim().toLowerCase();
|
|
1579
|
+
var list = filesCache.filter(function (f) {
|
|
1580
|
+
var l = latestOf(f);
|
|
1581
|
+
return !q || [f.filename, l.description, (l.tags || []).join(' '), l.connection_name, l.author].join(' ').toLowerCase().indexOf(q) !== -1;
|
|
1582
|
+
});
|
|
1583
|
+
if (!list.length) { box.appendChild(h('div', { className: 'empty' }, h('span', { text: 'Nothing matches “' + q + '”.' }))); return; }
|
|
1584
|
+
list.forEach(function (f) {
|
|
1585
|
+
var l = latestOf(f), run = lastRun(l);
|
|
1586
|
+
box.appendChild(h('button', { type: 'button', className: 'qitem' + (f.filename === selected.name ? ' sel' : ''), onclick: function () {
|
|
1587
|
+
selected.name = f.filename; selected.version = l.version; renderQueryList(); renderDetail(); } },
|
|
1588
|
+
h('div', { className: 'qi-top' },
|
|
1589
|
+
h('span', { className: 'name', text: f.filename }),
|
|
1590
|
+
h('span', { className: 'tag', text: 'v' + l.version }),
|
|
1591
|
+
run ? h('span', { className: 'dot ' + (run.status === 'success' ? 'ok' : 'bad'), title: 'Last run ' + run.executed_at + ' · ' + run.status }) : null,
|
|
1592
|
+
h('span', { className: 'qi-conn', text: l.connection_name || '' })),
|
|
1593
|
+
l.description ? h('div', { className: 'qi-desc', text: l.description }) : null,
|
|
1594
|
+
(l.tags || []).length ? h('div', { className: 'tags' }, l.tags.map(function (t) { return h('span', { className: 'tag', text: t }); })) : null));
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
$('query-filter').oninput = renderQueryList;
|
|
1598
|
+
|
|
1599
|
+
async function getContent(name) {
|
|
1600
|
+
if (contentCache[name]) return contentCache[name];
|
|
1601
|
+
var data = await apiJson('view_file_content?filename=' + enc(name));
|
|
1602
|
+
if (!data) return null;
|
|
1603
|
+
var parsed = null;
|
|
1604
|
+
try { parsed = JSON.parse(data.content); } catch (e) { parsed = null; }
|
|
1605
|
+
contentCache[name] = { raw: data.content, parsed: parsed };
|
|
1606
|
+
return contentCache[name];
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function renderDetail() {
|
|
1610
|
+
var box = clear($('query-detail'));
|
|
1611
|
+
var f = selected.name && findFile(selected.name);
|
|
1612
|
+
if (!f) {
|
|
1613
|
+
box.appendChild(h('div', { className: 'empty' }, h('strong', { text: 'No query selected' }),
|
|
1614
|
+
h('span', { text: 'Pick a saved query to run it, read its SQL or see its history.' })));
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
var latest = latestOf(f);
|
|
1618
|
+
var v = f.versions.filter(function (x) { return x.version === selected.version; })[0] || latest;
|
|
1619
|
+
var isLatest = v.version === latest.version;
|
|
1620
|
+
var history = v.execution_history || [];
|
|
1621
|
+
|
|
1622
|
+
var versionSwitch = h('div', { className: 'versions', role: 'group', 'aria-label': 'Version' }, f.versions.map(function (x) {
|
|
1623
|
+
return h('button', { type: 'button', className: x.version === v.version ? 'on' : '', text: 'v' + x.version,
|
|
1624
|
+
title: x.version === latest.version ? 'Latest version' : 'Version ' + x.version,
|
|
1625
|
+
onclick: function () { selected.version = x.version; renderDetail(); } });
|
|
1626
|
+
}));
|
|
1627
|
+
var head = h('div', { className: 'd-head' },
|
|
1628
|
+
h('div', { className: 'd-title' },
|
|
1629
|
+
h('h3', { text: f.filename }),
|
|
1630
|
+
versionSwitch,
|
|
1631
|
+
isLatest ? h('span', { className: 'hint', text: 'latest' }) : h('span', { className: 'hint', text: 'older version' }),
|
|
1632
|
+
h('span', { className: 'spacer' }),
|
|
1633
|
+
h('button', { type: 'button', className: 'btn sm', text: 'New version', onclick: function () { openQueryForm(f.filename, v); } }),
|
|
1634
|
+
h('button', { type: 'button', className: 'btn sm danger', text: 'Delete v' + v.version, title: 'DELETE /saved_sql/' + f.filename + '?version=' + v.version,
|
|
1635
|
+
onclick: function () { deleteQueryVersion(f.filename, v.version, f.versions.length); } }),
|
|
1636
|
+
h('button', { type: 'button', className: 'btn sm danger', text: 'Delete query', onclick: function () { deleteQuery(f.filename); } })),
|
|
1637
|
+
v.description ? h('p', { className: 'd-desc', text: v.description }) : null,
|
|
1638
|
+
h('dl', { className: 'meta' },
|
|
1639
|
+
metaItem('connection', v.connection_name || '—'), metaItem('author', v.author || '—'),
|
|
1640
|
+
metaItem('modified', v.last_modified_at || v.created_at || '—'), metaItem('status', v.status || '—'),
|
|
1641
|
+
(v.tags || []).length ? h('div', {}, h('dt', { text: 'tags' }), h('dd', {}, h('span', { className: 'tags' }, v.tags.map(function (t) { return h('span', { className: 'tag', text: t }); })))) : null),
|
|
1642
|
+
h('div', { className: 'subtabs', role: 'tablist' },
|
|
1643
|
+
subtab('run', 'Run'), subtab('sql', 'SQL'),
|
|
1644
|
+
subtab('history', 'History', h('span', { className: 'count', text: history.length ? String(history.length) : '' })),
|
|
1645
|
+
subtab('curl', 'Curl')));
|
|
1646
|
+
var body = h('div', { className: 'd-body' });
|
|
1647
|
+
box.appendChild(head);
|
|
1648
|
+
box.appendChild(body);
|
|
1649
|
+
if (selected.tab === 'sql') renderSqlTab(body, f, v);
|
|
1650
|
+
else if (selected.tab === 'history') renderHistoryTab(body, v);
|
|
1651
|
+
else if (selected.tab === 'curl') renderCurlTab(body, f, v, isLatest);
|
|
1652
|
+
else renderRunTab(body, f, v, isLatest);
|
|
1653
|
+
}
|
|
1654
|
+
function metaItem(k, val) { return h('div', {}, h('dt', { text: k }), h('dd', { text: String(val) })); }
|
|
1655
|
+
function subtab(key, label, extra) {
|
|
1656
|
+
return h('button', { type: 'button', role: 'tab', 'data-subtab': key, className: selected.tab === key ? 'on' : '', onclick: function () { selected.tab = key; renderDetail(); } }, label, extra || null);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function renderSqlTab(body, f, v) {
|
|
1660
|
+
body.appendChild(loadingNode());
|
|
1661
|
+
var c = await getContent(f.filename);
|
|
1662
|
+
clear(body);
|
|
1663
|
+
if (!c) return;
|
|
1664
|
+
var data = c.parsed && c.parsed[String(v.version)];
|
|
1665
|
+
var sql = data && data.sql_query;
|
|
1666
|
+
var codeEl = h('pre', { className: 'code' });
|
|
1667
|
+
if (sql !== undefined && sql !== null) highlightInto(codeEl, String(sql)); else codeEl.textContent = c.raw;
|
|
1668
|
+
var raw = h('pre', { className: 'code', text: c.raw });
|
|
1669
|
+
raw.hidden = true;
|
|
1670
|
+
var toggle = h('button', { type: 'button', className: 'btn sm ghost', text: 'Show raw file', onclick: function () {
|
|
1671
|
+
raw.hidden = !raw.hidden; toggle.textContent = raw.hidden ? 'Show raw file' : 'Hide raw file'; } });
|
|
1672
|
+
body.appendChild(h('div', { className: 'toolbar', style: 'margin:0' }, h('p', { className: 'sub-h', text: 'SQL · v' + v.version }), h('span', { className: 'spacer' }),
|
|
1673
|
+
h('button', { type: 'button', className: 'btn sm ghost', text: 'Copy', onclick: function () { copyText(sql || c.raw); } }), toggle));
|
|
1674
|
+
body.appendChild(codeEl);
|
|
1675
|
+
body.appendChild(raw);
|
|
1676
|
+
var qp = v.query_parameters || {};
|
|
1677
|
+
var keys = Object.keys(qp);
|
|
1678
|
+
body.appendChild(h('p', { className: 'sub-h', text: 'Parameters' }));
|
|
1679
|
+
if (!keys.length) { body.appendChild(h('div', { className: 'hint', text: 'This version declares no query_parameters.' })); return; }
|
|
1680
|
+
body.appendChild(h('div', { className: 'panel', style: 'overflow:auto' }, h('table', { className: 'grid' },
|
|
1681
|
+
h('thead', {}, h('tr', {}, ['Name', 'Type', 'Constraints', 'Description'].map(function (t) { return h('th', { text: t }); }))),
|
|
1682
|
+
h('tbody', {}, keys.map(function (k) {
|
|
1683
|
+
var s = typeof qp[k] === 'string' ? { type: qp[k] } : (qp[k] || {});
|
|
1684
|
+
var cons = Object.keys(s).filter(function (x) { return x !== 'type' && x !== 'description'; })
|
|
1685
|
+
.map(function (x) { return x + '=' + JSON.stringify(s[x]); }).join(' ');
|
|
1686
|
+
return h('tr', {}, h('td', {}, h('span', { className: 'name', text: k })), h('td', { className: 'mono', text: String(s.type || '') }),
|
|
1687
|
+
h('td', { className: 'mono dim', text: cons }), h('td', { text: s.description ? String(s.description) : '' }));
|
|
1688
|
+
})))));
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function renderHistoryTab(body, v) {
|
|
1692
|
+
var hs = (v.execution_history || []).slice().reverse();
|
|
1693
|
+
if (!hs.length) { body.appendChild(h('div', { className: 'empty' }, h('strong', { text: 'No runs recorded' }), h('span', { text: 'Runs of v' + v.version + ' through /q/ appear here.' }))); return; }
|
|
1694
|
+
var ok = hs.filter(function (x) { return x.status === 'success'; });
|
|
1695
|
+
var durations = ok.map(function (x) { return Number(x.duration_ms); }).filter(function (n) { return !isNaN(n); });
|
|
1696
|
+
body.appendChild(h('div', { className: 'resbar', style: 'border:0;padding:0' }, h('span', { className: 'stat' },
|
|
1697
|
+
h('span', {}, h('b', { text: String(hs.length) }), ' runs'),
|
|
1698
|
+
h('span', {}, h('b', { text: String(ok.length) }), ' ok · ', h('b', { text: String(hs.length - ok.length) }), ' failed'),
|
|
1699
|
+
durations.length ? h('span', {}, 'avg ', h('b', { text: Math.round(durations.reduce(function (a, b) { return a + b; }, 0) / durations.length) + ' ms' })) : null)));
|
|
1700
|
+
|
|
1701
|
+
var statusSelect = h('select', {},
|
|
1702
|
+
h('option', { value: '', text: 'All statuses' }),
|
|
1703
|
+
h('option', { value: 'success', text: 'Success' }),
|
|
1704
|
+
h('option', { value: 'error', text: 'Failed' }));
|
|
1705
|
+
var searchInput = h('input', { className: 'search', type: 'search',
|
|
1706
|
+
placeholder: 'Filter by connection, caller, request id, error…' });
|
|
1707
|
+
body.appendChild(h('div', { className: 'toolbar', style: 'margin:0 0 10px' }, statusSelect, searchInput));
|
|
1708
|
+
var tableBox = h('div', { className: 'panel', style: 'overflow:auto;max-height:60vh' });
|
|
1709
|
+
body.appendChild(tableBox);
|
|
1710
|
+
|
|
1711
|
+
function paint() {
|
|
1712
|
+
var status = statusSelect.value;
|
|
1713
|
+
var q = searchInput.value.trim().toLowerCase();
|
|
1714
|
+
var rows = hs.filter(function (x) {
|
|
1715
|
+
if (status && x.status !== status) return false;
|
|
1716
|
+
if (!q) return true;
|
|
1717
|
+
return [x.connection_name, x.key_name, x.request_id, x.error].join(' ').toLowerCase().indexOf(q) !== -1;
|
|
1718
|
+
});
|
|
1719
|
+
clear(tableBox);
|
|
1720
|
+
if (!rows.length) { tableBox.appendChild(h('div', { className: 'empty' }, h('span', { text: 'No runs match this filter.' }))); return; }
|
|
1721
|
+
tableBox.appendChild(h('table', { className: 'grid' },
|
|
1722
|
+
h('thead', {}, h('tr', {}, ['', 'Executed at', 'Connection', 'Caller', 'Rows', 'Duration', 'Request ID', 'Error'].map(function (t) { return h('th', { text: t }); }))),
|
|
1723
|
+
h('tbody', {}, rows.map(function (x) {
|
|
1724
|
+
var good = x.status === 'success';
|
|
1725
|
+
return h('tr', {},
|
|
1726
|
+
h('td', { style: 'width:28px;padding-right:0' }, h('span', { className: 'dot ' + (good ? 'ok' : 'bad'), title: String(x.status || '') })),
|
|
1727
|
+
h('td', { className: 'mono', text: String(x.executed_at || '') }),
|
|
1728
|
+
h('td', { className: 'mono', text: String(x.connection_name || '') }),
|
|
1729
|
+
h('td', { className: 'mono', text: String(x.key_name || '') }),
|
|
1730
|
+
h('td', { className: 'mono', style: 'text-align:right', text: x.rows === undefined ? '' : String(x.rows) }),
|
|
1731
|
+
h('td', { className: 'mono', style: 'text-align:right', text: x.duration_ms === undefined ? '' : x.duration_ms + ' ms' }),
|
|
1732
|
+
h('td', { className: 'mono dim', text: String(x.request_id || '') }),
|
|
1733
|
+
h('td', { style: 'color:var(--danger);white-space:normal;word-break:break-word;max-width:320px',
|
|
1734
|
+
text: x.error ? String(x.error) : '' }));
|
|
1735
|
+
}))));
|
|
1736
|
+
}
|
|
1737
|
+
statusSelect.onchange = paint;
|
|
1738
|
+
searchInput.oninput = paint;
|
|
1739
|
+
paint();
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** The latest version's declared parameters come from the OpenAPI catalogue (one source of truth with the
|
|
1743
|
+
* server). Older versions aren't in the catalogue, so their own query_parameters are used instead. */
|
|
1744
|
+
var RESERVED = ['format', 'page', 'page_size', 'version', 'connection_name', 'timeout'];
|
|
1745
|
+
async function fetchQueryParameters(name) {
|
|
1746
|
+
var spec = await apiJson('openapi.json');
|
|
1747
|
+
if (!spec) return null;
|
|
1748
|
+
var op = spec.paths && spec.paths['/q/' + enc(name)];
|
|
1749
|
+
return (op && op.get && op.get.parameters || []).filter(function (p) { return RESERVED.indexOf(p.name) === -1; });
|
|
1750
|
+
}
|
|
1751
|
+
function paramsFromVersion(v) {
|
|
1752
|
+
var qp = v.query_parameters || {};
|
|
1753
|
+
return Object.keys(qp).map(function (k) {
|
|
1754
|
+
var s = typeof qp[k] === 'string' ? { type: qp[k] } : (qp[k] || {});
|
|
1755
|
+
return { name: k, required: s.default === undefined && s.required !== false, description: s.description,
|
|
1756
|
+
schema: { type: s.type, default: s.default, minimum: s.min, maximum: s.max } };
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
async function renderRunTab(body, f, v, isLatest) {
|
|
1761
|
+
body.appendChild(loadingNode('Loading parameters…'));
|
|
1762
|
+
var params = isLatest ? await fetchQueryParameters(f.filename) : null;
|
|
1763
|
+
if (!params) params = paramsFromVersion(v);
|
|
1764
|
+
if (selected.name !== f.filename || selected.tab !== 'run') return;
|
|
1765
|
+
clear(body);
|
|
1766
|
+
var inputs = {};
|
|
1767
|
+
var grid = h('div', { className: 'param-grid' }, params.map(function (p) {
|
|
1768
|
+
var sch = p.schema || {};
|
|
1769
|
+
var bounds = [sch.minimum !== undefined ? '≥ ' + sch.minimum : null, sch.maximum !== undefined ? '≤ ' + sch.maximum : null].filter(Boolean).join(' ');
|
|
1770
|
+
var input = h('input', { id: 'run-q-' + p.name, spellcheck: 'false', autocomplete: 'off',
|
|
1771
|
+
placeholder: sch.default !== undefined ? 'default: ' + sch.default : (p.required ? 'required' : ''),
|
|
1772
|
+
required: p.required ? true : null, type: sch.type === 'integer' || sch.type === 'int' ? 'number' : 'text' });
|
|
1773
|
+
inputs[p.name] = input;
|
|
1774
|
+
return field('run-q-' + p.name, h('span', { className: 'mono', text: p.name }), input,
|
|
1775
|
+
[p.description, bounds].filter(Boolean).join(' · ') || null,
|
|
1776
|
+
[sch.type ? h('span', { className: 'type', text: String(sch.type) }) : null, p.required ? h('span', { className: 'req', text: '*' }) : null]);
|
|
1777
|
+
}));
|
|
1778
|
+
var connSelect = h('select', { id: 'rq-connection' });
|
|
1779
|
+
connectionOptions(connSelect, true, 'saved: ' + (v.connection_name || 'none'));
|
|
1780
|
+
var formatSelect = h('select', { id: 'rq-format' }, ['json', 'csv', 'tsv', 'xml', 'yaml', 'ndjson', 'xlsx'].map(function (x) { return h('option', { value: x, text: x }); }));
|
|
1781
|
+
var sizeSelect = h('select', { id: 'rq-page-size' }, [10, 25, 50, 100, 500].map(function (n) { return h('option', { value: String(n), text: String(n) }); }));
|
|
1782
|
+
var statusBox = h('div', { className: 'resbar' }), resultsBox = h('div', { className: 'res-body' });
|
|
1783
|
+
var resultsPanel = h('div', { className: 'panel' }, statusBox, resultsBox);
|
|
1784
|
+
resultsPanel.hidden = true;
|
|
1785
|
+
var page = 1;
|
|
1786
|
+
var runBtn = h('button', { type: 'submit', className: 'btn primary', text: 'Run' });
|
|
1787
|
+
var url = 'q/' + enc(f.filename);
|
|
1788
|
+
async function run() {
|
|
1789
|
+
var query = params.map(function (p) {
|
|
1790
|
+
var val = inputs[p.name].value;
|
|
1791
|
+
return val !== '' ? enc(p.name) + '=' + enc(val) : null;
|
|
1792
|
+
}).filter(Boolean);
|
|
1793
|
+
query.push('format=' + formatSelect.value, 'page=' + page, 'page_size=' + sizeSelect.value);
|
|
1794
|
+
if (!isLatest) query.push('version=' + v.version);
|
|
1795
|
+
if (connSelect.value) query.push('connection_name=' + enc(connSelect.value));
|
|
1796
|
+
resultsPanel.hidden = false;
|
|
1797
|
+
await execute(function () { return apiFetch(url + '?' + query.join('&')); }, {
|
|
1798
|
+
results: resultsBox, status: statusBox, button: runBtn, page: page, pageSize: Number(sizeSelect.value),
|
|
1799
|
+
filename: f.filename, format: formatSelect.value,
|
|
1800
|
+
onPage: function (n) { page = n; run(); },
|
|
1801
|
+
onPageSize: function (n) { sizeSelect.value = String(n); page = 1; run(); }
|
|
1802
|
+
});
|
|
1803
|
+
refreshHistorySilently(f.filename);
|
|
1804
|
+
}
|
|
1805
|
+
var form = h('form', { novalidate: true, style: 'display:flex;flex-direction:column;gap:14px', onsubmit: function (e) { e.preventDefault(); page = 1; run(); } },
|
|
1806
|
+
params.length ? grid : h('div', { className: 'hint', text: 'No parameters — this query runs as-is.' }),
|
|
1807
|
+
h('div', { className: 'run-row' },
|
|
1808
|
+
field('rq-connection', 'Connection', connSelect), field('rq-format', 'Format', formatSelect), field('rq-page-size', 'Page size', sizeSelect),
|
|
1809
|
+
h('span', { className: 'spacer' }),
|
|
1810
|
+
h('code', { className: 'dim hide-sm', style: 'align-self:center', text: 'GET /' + url + (isLatest ? '' : '?version=' + v.version) }),
|
|
1811
|
+
runBtn));
|
|
1812
|
+
body.appendChild(form);
|
|
1813
|
+
body.appendChild(resultsPanel);
|
|
1814
|
+
}
|
|
1815
|
+
async function refreshHistorySilently(name) {
|
|
1816
|
+
var res;
|
|
1817
|
+
try { res = await apiFetch('list_files'); } catch (e) { return; }
|
|
1818
|
+
if (!res.ok) return;
|
|
1819
|
+
var data = await res.json();
|
|
1820
|
+
filesCache = (data.files || []).slice().sort(function (a, b) { return a.filename < b.filename ? -1 : 1; });
|
|
1821
|
+
renderQueryList();
|
|
1822
|
+
var f = findFile(name);
|
|
1823
|
+
if (!f || selected.name !== name) return;
|
|
1824
|
+
var v = f.versions.filter(function (x) { return x.version === selected.version; })[0];
|
|
1825
|
+
var badge = document.querySelector('#query-detail .subtabs button[data-subtab="history"] .count');
|
|
1826
|
+
if (v && badge) badge.textContent = String((v.execution_history || []).length || '');
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
async function renderCurlTab(body, f, v, isLatest) {
|
|
1830
|
+
body.appendChild(loadingNode('Loading parameters…'));
|
|
1831
|
+
var params = isLatest ? await fetchQueryParameters(f.filename) : null;
|
|
1832
|
+
if (!params) params = paramsFromVersion(v);
|
|
1833
|
+
if (selected.name !== f.filename || selected.tab !== 'curl') return;
|
|
1834
|
+
clear(body);
|
|
1835
|
+
var cmd = asSavedQueryCurl(f.filename, params, isLatest ? null : v.version);
|
|
1836
|
+
body.appendChild(h('div', { className: 'toolbar', style: 'margin:0' }, h('p', { className: 'sub-h', text: 'curl' }), h('span', { className: 'spacer' }),
|
|
1837
|
+
h('button', { type: 'button', className: 'btn sm ghost', text: 'Copy', onclick: function () { copyText(cmd); } })));
|
|
1838
|
+
body.appendChild(h('pre', { className: 'code', text: cmd }));
|
|
1839
|
+
if (params.length) body.appendChild(h('div', { className: 'hint', text: 'Replace the <placeholder> value(s) with real parameters before running it.' }));
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
async function openQueryForm(baseName, baseVersion) {
|
|
1843
|
+
var slot = openDrawer('query-form-slot', baseName ? 'New version' : 'New saved query',
|
|
1844
|
+
baseName ? baseName + ' · from v' + baseVersion.version : 'PATCH /save_sql_to_file');
|
|
1845
|
+
var prefill = baseVersion || {};
|
|
1846
|
+
var sql = '';
|
|
1847
|
+
if (baseName) {
|
|
1848
|
+
slot.appendChild(loadingNode());
|
|
1849
|
+
var c = await getContent(baseName);
|
|
1850
|
+
var d = c && c.parsed && c.parsed[String(baseVersion.version)];
|
|
1851
|
+
sql = (d && d.sql_query) || '';
|
|
1852
|
+
clear(slot);
|
|
1853
|
+
}
|
|
1854
|
+
function inp(id, value, ph, req) {
|
|
1855
|
+
return h('input', { id: id, value: value || '', placeholder: ph || null, required: req ? true : null, autocomplete: 'off', spellcheck: 'false' });
|
|
1856
|
+
}
|
|
1857
|
+
var connSelect = h('select', { id: 'q-connection' });
|
|
1858
|
+
connectionOptions(connSelect, true, '(none — must be given at run time)');
|
|
1859
|
+
if (prefill.connection_name) connSelect.value = prefill.connection_name;
|
|
1860
|
+
var qp = prefill.query_parameters && Object.keys(prefill.query_parameters).length ? JSON.stringify(prefill.query_parameters, null, 2) : '';
|
|
1861
|
+
var paramsTa = h('textarea', { id: 'q-params', spellcheck: 'false', placeholder: '{"id": {"type": "int", "min": 1}}' });
|
|
1862
|
+
paramsTa.value = qp;
|
|
1863
|
+
var detected = h('div', { className: 'refs' });
|
|
1864
|
+
var editor = makeEditor({ id: 'q-sql', required: true, placeholder: 'SELECT * FROM t WHERE id = :id' }, sql);
|
|
1865
|
+
var sqlTa = editor.querySelector('textarea');
|
|
1866
|
+
function paintRefs() {
|
|
1867
|
+
clear(detected);
|
|
1868
|
+
var names = sqlParams(sqlTa.value);
|
|
1869
|
+
if (names.length) detected.appendChild(h('span', { className: 'hint', text: 'Bound in SQL:' }));
|
|
1870
|
+
names.forEach(function (n) { detected.appendChild(h('span', { className: 'tag', text: ':' + n })); });
|
|
1871
|
+
}
|
|
1872
|
+
sqlTa.addEventListener('input', paintRefs);
|
|
1873
|
+
paintRefs();
|
|
1874
|
+
var querySchema = schemaBrowser(function (text) { insertAtCursor(sqlTa, text); },
|
|
1875
|
+
function (tableName) { previewTable(connSelect.value, tableName); closeDrawer(); });
|
|
1876
|
+
querySchema.setConnection(connSelect.value);
|
|
1877
|
+
connSelect.onchange = function () { querySchema.setConnection(connSelect.value); };
|
|
1878
|
+
var actions = formActions(baseName ? 'Save as v' + (latestOf(findFile(baseName) || { versions: [baseVersion] }).version + 1) : 'Save', closeDrawer);
|
|
1879
|
+
var form = h('form', { className: 'form', novalidate: true, onsubmit: function (e) { e.preventDefault(); saveQuery(actions.submit); } },
|
|
1880
|
+
field('q-filename', 'Filename', inp('q-filename', baseName, 'films_by_rating', true), 'Letters, digits, spaces, “.”, “_” and “-”. Saving an existing name adds a version.'),
|
|
1881
|
+
h('div', { className: 'grid2' }, field('q-author', 'Author', inp('q-author', prefill.author, '', true)), field('q-connection', 'Default connection', connSelect)),
|
|
1882
|
+
h('div', { className: 'field' }, h('label', { for: 'q-sql' }, 'SQL', h('span', { className: 'type', text: 'use :name for bound parameters' })), editor, detected),
|
|
1883
|
+
schemaField(querySchema),
|
|
1884
|
+
field('q-description', 'Description', inp('q-description', prefill.description, '', true)),
|
|
1885
|
+
field('q-tags', 'Tags', inp('q-tags', (prefill.tags || []).join(', '), 'comma-separated')),
|
|
1886
|
+
field('q-params', 'query_parameters', paramsTa, 'JSON object: name → type, or {type, min, max, default, description}.'),
|
|
1887
|
+
actions.node);
|
|
1888
|
+
slot.appendChild(form);
|
|
1889
|
+
(baseName ? sqlTa : $('q-filename')).focus();
|
|
1890
|
+
}
|
|
1891
|
+
$('new-query').onclick = function () { openQueryForm(); };
|
|
1892
|
+
|
|
1893
|
+
async function saveQuery(btn) {
|
|
1894
|
+
var paramsText = $('q-params').value.trim();
|
|
1895
|
+
var queryParameters = {};
|
|
1896
|
+
if (paramsText) {
|
|
1897
|
+
try { queryParameters = JSON.parse(paramsText); }
|
|
1898
|
+
catch (e) { showError('query_parameters is not valid JSON: ' + e.message, { errors: { query_parameters: e.message } }); return; }
|
|
1899
|
+
}
|
|
1900
|
+
var tags = $('q-tags').value.split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
|
1901
|
+
var filename = $('q-filename').value.trim();
|
|
1902
|
+
btn.disabled = true;
|
|
1903
|
+
var res = await apiJson('save_sql_to_file', { method: 'PATCH', json: {
|
|
1904
|
+
filename: filename,
|
|
1905
|
+
sql_query: $('q-sql').value,
|
|
1906
|
+
author: $('q-author').value,
|
|
1907
|
+
description: $('q-description').value,
|
|
1908
|
+
tags: tags,
|
|
1909
|
+
connection_name: $('q-connection').value || undefined,
|
|
1910
|
+
query_parameters: queryParameters
|
|
1911
|
+
} });
|
|
1912
|
+
btn.disabled = false;
|
|
1913
|
+
if (res) {
|
|
1914
|
+
showError(''); closeDrawer();
|
|
1915
|
+
toast('Saved ' + filename + (res.version ? ' v' + res.version : ''));
|
|
1916
|
+
selected.version = res.version || null;
|
|
1917
|
+
showTab('queries');
|
|
1918
|
+
loadQueries(filename);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
async function deleteQuery(name) {
|
|
1922
|
+
if (!confirm("Delete saved query '" + name + "' (all versions)?")) return;
|
|
1923
|
+
var res = await apiJson('saved_sql/' + enc(name), { method: 'DELETE' });
|
|
1924
|
+
if (res) { toast('Deleted ' + name); loadQueries(null); }
|
|
1925
|
+
}
|
|
1926
|
+
async function deleteQueryVersion(name, version, count) {
|
|
1927
|
+
var msg = "Delete version " + version + " of '" + name + "'?" + (count === 1 ? ' It is the only version, so the query itself will be removed.' : '');
|
|
1928
|
+
if (!confirm(msg)) return;
|
|
1929
|
+
var res = await apiJson('saved_sql/' + enc(name) + '?version=' + enc(String(version)), { method: 'DELETE' });
|
|
1930
|
+
if (res) { toast('Deleted ' + name + ' v' + version); selected.version = null; loadQueries(count === 1 ? null : name); }
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// ---- shared execution + result rendering (saved-query runs and Run SQL) ----
|
|
1934
|
+
function copyText(text) {
|
|
1935
|
+
if (navigator.clipboard) navigator.clipboard.writeText(text).then(function () { toast('Copied'); }, function () { toast('Copy failed'); });
|
|
1936
|
+
}
|
|
1937
|
+
function downloadBlob(blob, name) {
|
|
1938
|
+
var a = document.createElement('a');
|
|
1939
|
+
a.href = URL.createObjectURL(blob);
|
|
1940
|
+
a.download = name;
|
|
1941
|
+
document.body.appendChild(a);
|
|
1942
|
+
a.click();
|
|
1943
|
+
a.remove();
|
|
1944
|
+
setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
|
|
1945
|
+
}
|
|
1946
|
+
var EXT = { json: 'json', ndjson: 'ndjson', csv: 'csv', tsv: 'tsv', xml: 'xml', yaml: 'yaml', xlsx: 'xlsx' };
|
|
1947
|
+
|
|
1948
|
+
async function execute(doFetch, o) {
|
|
1949
|
+
showError('');
|
|
1950
|
+
clear(o.status);
|
|
1951
|
+
clear(o.results).appendChild(h('div', { className: 'res-note' }, h('span', { className: 'spin' }), 'Running…'));
|
|
1952
|
+
if (o.button) o.button.disabled = true;
|
|
1953
|
+
var t0 = performance.now(), res;
|
|
1954
|
+
try { res = await doFetch(); }
|
|
1955
|
+
catch (e) {
|
|
1956
|
+
if (o.button) o.button.disabled = false;
|
|
1957
|
+
clear(o.results).appendChild(h('div', { className: 'res-note err', text: 'Network error: ' + e.message }));
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
o.elapsed = Math.round(performance.now() - t0);
|
|
1961
|
+
await renderResponse(res, o);
|
|
1962
|
+
if (o.button) o.button.disabled = false;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
async function renderResponse(res, o) {
|
|
1966
|
+
var box = clear(o.results), bar = clear(o.status);
|
|
1967
|
+
var contentType = (res.headers.get('content-type') || '').split(';')[0].trim();
|
|
1968
|
+
if (!res.ok) {
|
|
1969
|
+
var text = await res.text(), body = null;
|
|
1970
|
+
try { body = JSON.parse(text); } catch (e) {}
|
|
1971
|
+
var msg = errorMessage(res.status, body, text);
|
|
1972
|
+
showError(msg, { status: res.status, errors: body && body.errors });
|
|
1973
|
+
box.appendChild(h('div', { className: 'res-note err' }, h('span', { className: 'eb-code', text: String(res.status) }), msg));
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
var page = Number(res.headers.get('x-page')) || o.page || 1;
|
|
1977
|
+
var size = Number(res.headers.get('x-page-size')) || o.pageSize || 10;
|
|
1978
|
+
var hasMore = res.headers.get('x-has-more') === 'true';
|
|
1979
|
+
var base = (o.filename || 'result') + (page > 1 ? '-p' + page : '');
|
|
1980
|
+
var payload = null, rowCount = null, format = o.format || 'json', tableData = null;
|
|
1981
|
+
if (contentType === 'application/json') {
|
|
1982
|
+
var data = await res.json();
|
|
1983
|
+
payload = JSON.stringify(data, null, 2);
|
|
1984
|
+
if (Array.isArray(data)) { rowCount = data.length; tableData = data; renderTable(box, data, (page - 1) * size); }
|
|
1985
|
+
else box.appendChild(h('div', { className: 'res-pre jt-root' }, jsonTree(data)));
|
|
1986
|
+
} else if (contentType.indexOf('text/') === 0 || contentType === 'application/xml' || contentType === 'application/x-yaml' || contentType === 'application/x-ndjson') {
|
|
1987
|
+
payload = await res.text();
|
|
1988
|
+
box.appendChild(h('pre', { className: 'res-pre', text: payload }));
|
|
1989
|
+
} else {
|
|
1990
|
+
var blob = await res.blob();
|
|
1991
|
+
var fname = base + '.' + (EXT[format] || 'bin');
|
|
1992
|
+
downloadBlob(blob, fname);
|
|
1993
|
+
box.appendChild(h('div', { className: 'res-note' }, 'Downloaded ', h('code', { text: fname }), ' (' + (blob.size < 1024 ? blob.size + ' B' : (blob.size / 1024).toFixed(1) + ' KB') + ')',
|
|
1994
|
+
h('button', { type: 'button', className: 'btn sm', text: 'Download again', onclick: function () { downloadBlob(blob, fname); } })));
|
|
1995
|
+
}
|
|
1996
|
+
var stat = h('span', { className: 'stat' },
|
|
1997
|
+
h('span', {}, h('b', { className: 'stat-ok', text: String(res.status) }), res.statusText ? ' ' + res.statusText : ''),
|
|
1998
|
+
rowCount !== null ? h('span', {}, h('b', { text: String(rowCount) }), rowCount === 1 ? ' row' : ' rows') : null,
|
|
1999
|
+
rowCount ? h('span', { text: 'rows ' + ((page - 1) * size + 1) + '–' + ((page - 1) * size + rowCount) }) : null,
|
|
2000
|
+
h('span', { text: format }),
|
|
2001
|
+
o.elapsed !== undefined ? h('span', { text: o.elapsed + ' ms' }) : null);
|
|
2002
|
+
bar.appendChild(stat);
|
|
2003
|
+
bar.appendChild(h('span', { className: 'spacer' }));
|
|
2004
|
+
if (o.onPage) bar.appendChild(pager(page, size, hasMore, o));
|
|
2005
|
+
var headersBox = headersPanel(res);
|
|
2006
|
+
headersBox.hidden = true;
|
|
2007
|
+
bar.appendChild(h('button', { type: 'button', className: 'btn sm ghost', text: 'Headers', onclick: function () { headersBox.hidden = !headersBox.hidden; } }));
|
|
2008
|
+
if (payload !== null) {
|
|
2009
|
+
bar.appendChild(h('button', { type: 'button', className: 'btn sm ghost', text: 'Copy', onclick: function () { copyText(payload); } }));
|
|
2010
|
+
bar.appendChild(h('button', { type: 'button', className: 'btn sm ghost', text: 'Download', onclick: function () {
|
|
2011
|
+
downloadBlob(new Blob([payload], { type: contentType || 'text/plain' }), base + '.' + (EXT[format] || 'txt')); } }));
|
|
2012
|
+
}
|
|
2013
|
+
if (tableData) bar.appendChild(h('button', { type: 'button', className: 'btn sm ghost', text: 'Copy as TSV', onclick: function () { copyText(rowsToTsv(tableData)); } }));
|
|
2014
|
+
if (o.sql !== undefined) bar.appendChild(h('button', { type: 'button', className: 'btn sm ghost', text: 'Copy as curl', onclick: function () { copyText(asCurl(o)); } }));
|
|
2015
|
+
var chartPanel = null;
|
|
2016
|
+
if (tableData && tableData.length && numericColumns(tableData).length) {
|
|
2017
|
+
chartPanel = buildChartPanel(tableData);
|
|
2018
|
+
chartPanel.hidden = true;
|
|
2019
|
+
bar.appendChild(h('button', { type: 'button', className: 'btn sm ghost', text: 'Chart', onclick: function () { chartPanel.hidden = !chartPanel.hidden; } }));
|
|
2020
|
+
}
|
|
2021
|
+
box.appendChild(headersBox);
|
|
2022
|
+
if (chartPanel) box.appendChild(chartPanel);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
/** A collapsible list of every header the response actually carries - X-Page, X-RateLimit-*, X-Request-Id,
|
|
2026
|
+
* X-Cache, ETag, and whatever else the server sends, not a hand-picked subset. */
|
|
2027
|
+
function headersPanel(res) {
|
|
2028
|
+
var rows = [];
|
|
2029
|
+
res.headers.forEach(function (v, k) { rows.push([k, v]); });
|
|
2030
|
+
rows.sort(function (a, b) { return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; });
|
|
2031
|
+
return h('div', { className: 'panel headers-panel' }, h('table', { className: 'grid' },
|
|
2032
|
+
h('tbody', {}, rows.map(function (r) { return h('tr', {}, h('td', { className: 'mono dim' }, r[0]), h('td', { className: 'mono' }, r[1])); }))));
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function columnsOf(rows) {
|
|
2036
|
+
var cols = [], seen = {};
|
|
2037
|
+
rows.forEach(function (r) { Object.keys(r || {}).forEach(function (c) { if (!seen[c]) { seen[c] = true; cols.push(c); } }); });
|
|
2038
|
+
return cols;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function rowsToTsv(rows) {
|
|
2042
|
+
var cols = columnsOf(rows);
|
|
2043
|
+
var lines = [cols.join('\t')];
|
|
2044
|
+
rows.forEach(function (row) {
|
|
2045
|
+
lines.push(cols.map(function (c) {
|
|
2046
|
+
var v = row[c];
|
|
2047
|
+
if (v === null || v === undefined) return '';
|
|
2048
|
+
var s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
2049
|
+
return s.replace(/[\t\n\r]/g, ' ');
|
|
2050
|
+
}).join('\t'));
|
|
2051
|
+
});
|
|
2052
|
+
return lines.join('\n');
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// ---- quick chart: a bar chart of the current page only, never implying a full-result view ----
|
|
2056
|
+
var CHART_MAX_BARS = 50;
|
|
2057
|
+
|
|
2058
|
+
function numericColumns(rows) {
|
|
2059
|
+
return columnsOf(rows).filter(function (c) {
|
|
2060
|
+
var any = false;
|
|
2061
|
+
var allNumericOrNull = rows.every(function (r) {
|
|
2062
|
+
var v = r[c];
|
|
2063
|
+
if (v === null || v === undefined) return true;
|
|
2064
|
+
if (typeof v === 'number' && isFinite(v)) { any = true; return true; }
|
|
2065
|
+
return false;
|
|
2066
|
+
});
|
|
2067
|
+
return any && allNumericOrNull;
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function buildChartPanel(tableData) {
|
|
2072
|
+
var cols = columnsOf(tableData);
|
|
2073
|
+
var numCols = numericColumns(tableData);
|
|
2074
|
+
var labelCol = cols.filter(function (c) { return numCols.indexOf(c) === -1; })[0] || cols[0];
|
|
2075
|
+
var valueCol = numCols[0];
|
|
2076
|
+
var shown = tableData.slice(0, CHART_MAX_BARS);
|
|
2077
|
+
var labelSel = h('select', { 'aria-label': 'Label column' },
|
|
2078
|
+
cols.map(function (c) { return h('option', { value: c, text: c, selected: c === labelCol ? true : null }); }));
|
|
2079
|
+
var valueSel = h('select', { 'aria-label': 'Value column' },
|
|
2080
|
+
numCols.map(function (c) { return h('option', { value: c, text: c, selected: c === valueCol ? true : null }); }));
|
|
2081
|
+
var svgSlot = h('div', { className: 'chart-svg-slot' });
|
|
2082
|
+
function repaint() { clear(svgSlot).appendChild(barChartSvg(shown, labelSel.value, valueSel.value)); }
|
|
2083
|
+
labelSel.onchange = repaint;
|
|
2084
|
+
valueSel.onchange = repaint;
|
|
2085
|
+
repaint();
|
|
2086
|
+
return h('div', { className: 'panel chart-panel' },
|
|
2087
|
+
h('div', { className: 'chart-toolbar' },
|
|
2088
|
+
h('span', { className: 'hint', text: 'Chart of this page only (' + shown.length +
|
|
2089
|
+
(shown.length < tableData.length ? ' of ' + tableData.length : '') +
|
|
2090
|
+
(shown.length === 1 ? ' row' : ' rows') + ') — not the full result.' }),
|
|
2091
|
+
h('span', { className: 'spacer' }),
|
|
2092
|
+
h('label', { className: 'switch', style: 'font-weight:400' }, 'Label', labelSel),
|
|
2093
|
+
h('label', { className: 'switch', style: 'font-weight:400' }, 'Value', valueSel)),
|
|
2094
|
+
svgSlot);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function barChartSvg(rows, labelCol, valueCol) {
|
|
2098
|
+
var NS = 'http://www.w3.org/2000/svg';
|
|
2099
|
+
var data = rows.map(function (r) {
|
|
2100
|
+
var v = r[valueCol];
|
|
2101
|
+
return { label: r[labelCol] === null || r[labelCol] === undefined ? '' : String(r[labelCol]),
|
|
2102
|
+
value: typeof v === 'number' && isFinite(v) ? v : 0 };
|
|
2103
|
+
});
|
|
2104
|
+
var width = 680, height = 220, pad = { top: 10, right: 10, bottom: 30, left: 46 };
|
|
2105
|
+
var innerW = width - pad.left - pad.right, innerH = height - pad.top - pad.bottom;
|
|
2106
|
+
var maxVal = Math.max.apply(null, data.map(function (d) { return d.value; }).concat([0]));
|
|
2107
|
+
var minVal = Math.min.apply(null, data.map(function (d) { return d.value; }).concat([0]));
|
|
2108
|
+
var range = (maxVal - minVal) || 1;
|
|
2109
|
+
var zeroY = pad.top + innerH * (maxVal / range);
|
|
2110
|
+
var gap = 4;
|
|
2111
|
+
var barW = data.length ? Math.max(2, (innerW - gap * (data.length - 1)) / data.length) : 0;
|
|
2112
|
+
|
|
2113
|
+
var svg = document.createElementNS(NS, 'svg');
|
|
2114
|
+
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
|
2115
|
+
svg.setAttribute('preserveAspectRatio', 'xMinYMin meet');
|
|
2116
|
+
|
|
2117
|
+
var axis = document.createElementNS(NS, 'line');
|
|
2118
|
+
axis.setAttribute('x1', pad.left); axis.setAttribute('x2', width - pad.right);
|
|
2119
|
+
axis.setAttribute('y1', zeroY); axis.setAttribute('y2', zeroY);
|
|
2120
|
+
axis.setAttribute('class', 'chart-axis');
|
|
2121
|
+
svg.appendChild(axis);
|
|
2122
|
+
|
|
2123
|
+
data.forEach(function (d, i) {
|
|
2124
|
+
var barH = innerH * (Math.abs(d.value) / range);
|
|
2125
|
+
var x = pad.left + i * (barW + gap);
|
|
2126
|
+
var y = d.value >= 0 ? zeroY - barH : zeroY;
|
|
2127
|
+
var rect = document.createElementNS(NS, 'rect');
|
|
2128
|
+
rect.setAttribute('x', x); rect.setAttribute('y', y);
|
|
2129
|
+
rect.setAttribute('width', barW); rect.setAttribute('height', Math.max(0, barH));
|
|
2130
|
+
rect.setAttribute('class', 'chart-bar');
|
|
2131
|
+
var title = document.createElementNS(NS, 'title');
|
|
2132
|
+
title.textContent = d.label + ': ' + d.value;
|
|
2133
|
+
rect.appendChild(title);
|
|
2134
|
+
svg.appendChild(rect);
|
|
2135
|
+
if (barW > 16) {
|
|
2136
|
+
var label = document.createElementNS(NS, 'text');
|
|
2137
|
+
label.setAttribute('x', x + barW / 2);
|
|
2138
|
+
label.setAttribute('y', height - pad.bottom + 13);
|
|
2139
|
+
label.setAttribute('class', 'chart-label chart-label-x');
|
|
2140
|
+
label.textContent = d.label.length > 9 ? d.label.slice(0, 8) + '…' : d.label;
|
|
2141
|
+
svg.appendChild(label);
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
[[maxVal, pad.top + 8], [minVal, height - pad.bottom + 3]].forEach(function (pair) {
|
|
2146
|
+
var label = document.createElementNS(NS, 'text');
|
|
2147
|
+
label.setAttribute('x', 2); label.setAttribute('y', pair[1]);
|
|
2148
|
+
label.setAttribute('class', 'chart-label chart-label-y');
|
|
2149
|
+
label.textContent = String(pair[0]);
|
|
2150
|
+
svg.appendChild(label);
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
return svg;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function shQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
|
2157
|
+
/** o.sql/o.connection/o.params come from runSql()'s call to execute() - ad-hoc runs only, not saved-query
|
|
2158
|
+
* ones. The API key, if any, is a placeholder rather than the real value: this text is meant to be copied
|
|
2159
|
+
* out of the browser (to a terminal, a ticket, a chat), and the key typed into this page shouldn't ride
|
|
2160
|
+
* along by default. */
|
|
2161
|
+
function asCurl(o) {
|
|
2162
|
+
var qs = 'format=' + enc(o.format) + '&page=' + o.page + '&page_size=' + o.pageSize + (o.timeout ? '&timeout=' + enc(o.timeout) : '');
|
|
2163
|
+
var url = new URL('execute_sql?' + qs, location.href).href;
|
|
2164
|
+
var body = JSON.stringify({ sql: o.sql, connection_name: o.connection, params: o.params || {} });
|
|
2165
|
+
var lines = ['curl -X POST ' + shQuote(url), " -H 'Content-Type: application/json'"];
|
|
2166
|
+
if (getKey()) lines.push(" -H 'X-API-Key: YOUR_KEY_HERE' # replace with your own key");
|
|
2167
|
+
lines.push(' -d ' + shQuote(body));
|
|
2168
|
+
return lines.join(' \\\n');
|
|
2169
|
+
}
|
|
2170
|
+
/** A saved query's own GET /q/<name> endpoint as a curl command - readable as a reference, not necessarily
|
|
2171
|
+
* runnable as-is: a parameter with no declared default becomes a <name> placeholder to fill in rather than
|
|
2172
|
+
* a real value, since none is on hand outside the Run tab's own form. Same API-key placeholder policy as
|
|
2173
|
+
* asCurl() above. */
|
|
2174
|
+
function asSavedQueryCurl(name, params, version) {
|
|
2175
|
+
// Built as a plain string, not new URL(...).href, since a <placeholder> value would otherwise come back
|
|
2176
|
+
// percent-encoded (%3Cid%3E) - unreadable for a command that's meant to be read and edited, not run as-is.
|
|
2177
|
+
var query = params.map(function (p) {
|
|
2178
|
+
var sch = p.schema || {};
|
|
2179
|
+
return sch.default !== undefined ? enc(p.name) + '=' + enc(String(sch.default)) : enc(p.name) + '=<' + p.name + '>';
|
|
2180
|
+
});
|
|
2181
|
+
query.push('format=json');
|
|
2182
|
+
if (version) query.push('version=' + version);
|
|
2183
|
+
var base = new URL('q/' + enc(name), location.href).href;
|
|
2184
|
+
var url = query.length ? base + '?' + query.join('&') : base;
|
|
2185
|
+
var lines = ['curl ' + shQuote(url)];
|
|
2186
|
+
if (getKey()) lines.push(" -H 'X-API-Key: YOUR_KEY_HERE' # replace with your own key");
|
|
2187
|
+
return lines.join(' \\\n');
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/** A collapsible tree for a JSON value that isn't a row array - the same idea browser devtools or `jq`
|
|
2191
|
+
* give for free, instead of a flat stringified dump. */
|
|
2192
|
+
function jsonNode(entries, bracketOpen, bracketClose) {
|
|
2193
|
+
var open = true;
|
|
2194
|
+
var toggle = h('button', { type: 'button', className: 'jt-toggle', text: '▾', 'aria-label': 'Collapse' });
|
|
2195
|
+
var items = h('div', { className: 'jt-children' }, entries.map(function (pair) {
|
|
2196
|
+
return h('div', { className: 'jt-item' }, h('span', { className: 'jt-key', text: pair[0] + ': ' }), jsonTree(pair[1]));
|
|
2197
|
+
}));
|
|
2198
|
+
toggle.onclick = function () {
|
|
2199
|
+
open = !open;
|
|
2200
|
+
items.hidden = !open;
|
|
2201
|
+
toggle.textContent = open ? '▾' : '▸';
|
|
2202
|
+
toggle.setAttribute('aria-label', open ? 'Collapse' : 'Expand');
|
|
2203
|
+
};
|
|
2204
|
+
return h('div', { className: 'jt-node' },
|
|
2205
|
+
h('div', { className: 'jt-head' }, toggle, h('span', { className: 'jt-punct', text: bracketOpen + entries.length + bracketClose })),
|
|
2206
|
+
items);
|
|
2207
|
+
}
|
|
2208
|
+
function jsonTree(value) {
|
|
2209
|
+
if (value === null || value === undefined) return h('span', { className: 'jt-null', text: 'null' });
|
|
2210
|
+
if (Array.isArray(value)) {
|
|
2211
|
+
if (!value.length) return h('span', { className: 'jt-punct', text: '[]' });
|
|
2212
|
+
return jsonNode(value.map(function (v, i) { return [i, v]; }), '[', ']');
|
|
2213
|
+
}
|
|
2214
|
+
if (typeof value === 'object') {
|
|
2215
|
+
var keys = Object.keys(value);
|
|
2216
|
+
if (!keys.length) return h('span', { className: 'jt-punct', text: '{}' });
|
|
2217
|
+
return jsonNode(keys.map(function (k) { return [k, value[k]]; }), '{', '}');
|
|
2218
|
+
}
|
|
2219
|
+
if (typeof value === 'string') return h('span', { className: 'jt-str', text: JSON.stringify(value) });
|
|
2220
|
+
return h('span', { className: 'jt-num', text: String(value) });
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function pager(page, size, hasMore, o) {
|
|
2224
|
+
var pageInput = h('input', { type: 'number', min: '1', value: String(page), 'aria-label': 'Page', onchange: function () {
|
|
2225
|
+
var n = Math.max(1, Number(pageInput.value) || 1); o.onPage(n); } });
|
|
2226
|
+
var presets = [10, 25, 50, 100, 500];
|
|
2227
|
+
if (presets.indexOf(size) === -1) presets.push(size);
|
|
2228
|
+
presets.sort(function (a, b) { return a - b; });
|
|
2229
|
+
var sizeSelect = h('select', { 'aria-label': 'Page size', onchange: function () { o.onPageSize(Number(sizeSelect.value)); } },
|
|
2230
|
+
presets.map(function (n) { return h('option', { value: String(n), text: n + ' / page' }); }));
|
|
2231
|
+
sizeSelect.value = String(size);
|
|
2232
|
+
return h('div', { className: 'pager' },
|
|
2233
|
+
h('button', { type: 'button', className: 'btn sm', text: '‹ Prev', disabled: page <= 1, onclick: function () { o.onPage(page - 1); } }),
|
|
2234
|
+
h('span', { className: 'hint', text: 'Page' }), pageInput,
|
|
2235
|
+
h('button', { type: 'button', className: 'btn sm', text: hasMore ? 'Next page ›' : 'Next ›', disabled: !hasMore, title: hasMore ? 'X-Has-More: true' : 'No more rows', onclick: function () { o.onPage(page + 1); } }),
|
|
2236
|
+
sizeSelect);
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
function renderTable(box, rows, offset) {
|
|
2240
|
+
if (!rows.length) { box.appendChild(h('div', { className: 'res-note', text: 'No rows returned.' })); return; }
|
|
2241
|
+
var cols = [], seen = {};
|
|
2242
|
+
rows.forEach(function (r) { Object.keys(r || {}).forEach(function (c) { if (!seen[c]) { seen[c] = true; cols.push(c); } }); });
|
|
2243
|
+
var numeric = {};
|
|
2244
|
+
cols.forEach(function (c) {
|
|
2245
|
+
numeric[c] = rows.every(function (r) { var v = r[c]; return v === null || v === undefined || typeof v === 'number'; })
|
|
2246
|
+
&& rows.some(function (r) { return typeof r[c] === 'number'; });
|
|
2247
|
+
});
|
|
2248
|
+
box.appendChild(h('div', { className: 'table-wrap' }, h('table', { className: 'rs' },
|
|
2249
|
+
h('thead', {}, h('tr', {}, h('th', { className: 'rn', text: '#' }), cols.map(function (c) { return h('th', { className: numeric[c] ? 'num' : null, text: c }); }))),
|
|
2250
|
+
h('tbody', {}, rows.map(function (row, i) {
|
|
2251
|
+
return h('tr', {}, h('td', { className: 'rn', text: String(offset + i + 1) }), cols.map(function (c) {
|
|
2252
|
+
var v = row[c];
|
|
2253
|
+
if (v === null || v === undefined) return h('td', {}, h('span', { className: 'null', text: 'NULL' }));
|
|
2254
|
+
var s = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
2255
|
+
return h('td', { className: numeric[c] ? 'num' : null, title: s.length > 40 ? s : null, text: s });
|
|
2256
|
+
}));
|
|
2257
|
+
})))));
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// ---- Run SQL tab ----
|
|
2261
|
+
var runPage = 1;
|
|
2262
|
+
function paintRunRefs(sql) {
|
|
2263
|
+
var box = clear($('run-refs'));
|
|
2264
|
+
var names = sqlParams(sql);
|
|
2265
|
+
if (!names.length) return;
|
|
2266
|
+
box.appendChild(h('span', { className: 'hint', text: 'In SQL:' }));
|
|
2267
|
+
names.forEach(function (n) { box.appendChild(h('span', { className: 'tag', text: ':' + n })); });
|
|
2268
|
+
}
|
|
2269
|
+
bindEditor($('run-sql'), $('run-sql-hl'), paintRunRefs);
|
|
2270
|
+
var runSchema = schemaBrowser(function (text) { insertAtCursor($('run-sql'), text); },
|
|
2271
|
+
function (tableName) { previewTable($('run-connection').value, tableName); });
|
|
2272
|
+
$('run-schema-slot').appendChild(schemaField(runSchema));
|
|
2273
|
+
$('run-connection').onchange = function () { runSchema.setConnection($('run-connection').value); };
|
|
2274
|
+
function keyRun(e) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); $('run-form').requestSubmit ? $('run-form').requestSubmit() : $('run-form').onsubmit(e); } }
|
|
2275
|
+
$('run-sql').addEventListener('keydown', keyRun);
|
|
2276
|
+
$('run-params').addEventListener('keydown', keyRun);
|
|
2277
|
+
$('run-form').onsubmit = function (e) {
|
|
2278
|
+
e.preventDefault();
|
|
2279
|
+
runPage = Number($('run-page').value) || 1;
|
|
2280
|
+
recordRunHistory();
|
|
2281
|
+
runSql({});
|
|
2282
|
+
};
|
|
2283
|
+
$('run-explain-button').onclick = function () {
|
|
2284
|
+
if (!$('run-sql').value.trim()) { showError('Write some SQL to run.', { errors: { sql: 'This field is required' } }); $('run-sql').focus(); return; }
|
|
2285
|
+
runPage = 1;
|
|
2286
|
+
runSql({ explain: true, button: $('run-explain-button') });
|
|
2287
|
+
};
|
|
2288
|
+
function runSql(opts) {
|
|
2289
|
+
var sql = $('run-sql').value;
|
|
2290
|
+
if (!sql.trim()) { showError('Write some SQL to run.', { errors: { sql: 'This field is required' } }); $('run-sql').focus(); return; }
|
|
2291
|
+
if (!$('run-connection').value) { showError('Pick a connection first — add one on the Connections tab.'); return; }
|
|
2292
|
+
var paramsText = $('run-params').value.trim();
|
|
2293
|
+
var params = {};
|
|
2294
|
+
if (paramsText) {
|
|
2295
|
+
try { params = JSON.parse(paramsText); }
|
|
2296
|
+
catch (e) { showError('Bound parameters must be valid JSON: ' + e.message, { errors: { params: e.message } }); return; }
|
|
2297
|
+
}
|
|
2298
|
+
var format = $('run-format').value;
|
|
2299
|
+
var pageSize = Number($('run-page-size').value) || 10;
|
|
2300
|
+
var timeout = $('run-timeout').value;
|
|
2301
|
+
$('run-page').value = String(runPage);
|
|
2302
|
+
var qs = 'format=' + enc(format) + '&page=' + runPage + '&page_size=' + pageSize + (timeout ? '&timeout=' + enc(timeout) : '');
|
|
2303
|
+
var connection = $('run-connection').value;
|
|
2304
|
+
var sqlToRun = opts.explain ? 'EXPLAIN ' + sql : sql;
|
|
2305
|
+
execute(function () {
|
|
2306
|
+
return apiFetch('execute_sql?' + qs, { method: 'POST', json: { sql: sqlToRun, connection_name: connection, params: params } });
|
|
2307
|
+
}, {
|
|
2308
|
+
results: $('run-results'), status: $('run-status'), button: opts.button || $('run-button'), page: runPage, pageSize: pageSize,
|
|
2309
|
+
filename: opts.explain ? 'explain' : 'query', format: format,
|
|
2310
|
+
sql: sqlToRun, connection: connection, params: params, timeout: timeout,
|
|
2311
|
+
onPage: function (n) { runPage = n; runSql(opts); },
|
|
2312
|
+
onPageSize: function (n) { $('run-page-size').value = String(n); runPage = 1; runSql(opts); }
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/** Jump to the Run SQL tab pre-filled with a preview of one table - from either schema browser. */
|
|
2317
|
+
function previewTable(connectionName, tableName) {
|
|
2318
|
+
showTab('run');
|
|
2319
|
+
if (connectionName && connectionsCache[connectionName]) {
|
|
2320
|
+
$('run-connection').value = connectionName;
|
|
2321
|
+
runSchema.setConnection(connectionName);
|
|
2322
|
+
}
|
|
2323
|
+
$('run-sql').value = 'SELECT * FROM ' + tableName;
|
|
2324
|
+
if ($('run-sql').repaint) $('run-sql').repaint();
|
|
2325
|
+
runPage = 1;
|
|
2326
|
+
recordRunHistory();
|
|
2327
|
+
runSql({});
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// ---- client-side ad-hoc query history (this browser tab only; not the saved-query execution_history) ----
|
|
2331
|
+
var RUN_HISTORY_KEY = 'queryapigate-run-history', RUN_HISTORY_MAX = 20;
|
|
2332
|
+
function loadRunHistory() {
|
|
2333
|
+
try { return JSON.parse(sessionStorage.getItem(RUN_HISTORY_KEY) || '[]'); } catch (e) { return []; }
|
|
2334
|
+
}
|
|
2335
|
+
function recordRunHistory() {
|
|
2336
|
+
var sql = $('run-sql').value.trim();
|
|
2337
|
+
if (!sql) return;
|
|
2338
|
+
var entry = { sql: sql, connection: $('run-connection').value, format: $('run-format').value };
|
|
2339
|
+
var list = loadRunHistory().filter(function (e) { return !(e.sql === entry.sql && e.connection === entry.connection); });
|
|
2340
|
+
list.unshift(entry);
|
|
2341
|
+
if (list.length > RUN_HISTORY_MAX) list = list.slice(0, RUN_HISTORY_MAX);
|
|
2342
|
+
try { sessionStorage.setItem(RUN_HISTORY_KEY, JSON.stringify(list)); } catch (e) {}
|
|
2343
|
+
renderRunHistory();
|
|
2344
|
+
}
|
|
2345
|
+
function renderRunHistory() {
|
|
2346
|
+
var box = clear($('run-history-slot'));
|
|
2347
|
+
var list = loadRunHistory();
|
|
2348
|
+
if (!list.length) return;
|
|
2349
|
+
box.appendChild(h('div', { className: 'sub-h', text: 'Recent queries' }));
|
|
2350
|
+
box.appendChild(h('div', { className: 'history-list' }, list.map(function (entry) {
|
|
2351
|
+
return h('button', { type: 'button', className: 'history-item', title: entry.sql, onclick: function () {
|
|
2352
|
+
$('run-sql').value = entry.sql;
|
|
2353
|
+
if ($('run-sql').repaint) $('run-sql').repaint();
|
|
2354
|
+
if (entry.connection && connectionsCache[entry.connection]) { $('run-connection').value = entry.connection; runSchema.setConnection(entry.connection); }
|
|
2355
|
+
if (entry.format) $('run-format').value = entry.format;
|
|
2356
|
+
} }, entry.sql.length > 64 ? entry.sql.slice(0, 64) + '…' : entry.sql);
|
|
2357
|
+
})));
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// ---- startup ----
|
|
2361
|
+
function refreshAll() { loadConnections(); loadQueries(); loadApiKeys(); loadRoles(); loadAuditLog(); loadMetrics(); }
|
|
2362
|
+
apiFetch('health').then(function (res) { return res.ok ? res.json() : null; }).then(function (info) {
|
|
2363
|
+
$('health-dot').className = 'dot ' + (info && info.status === 'ok' ? 'ok' : 'bad');
|
|
2364
|
+
$('version').textContent = info ? 'v' + info.version : 'unreachable';
|
|
2365
|
+
}, function () { $('health-dot').className = 'dot bad'; $('version').textContent = 'unreachable'; });
|
|
2366
|
+
try { var lastTab = sessionStorage.getItem('queryapigate-ui-tab'); if (lastTab && $('tab-' + lastTab)) showTab(lastTab); } catch (e) {}
|
|
2367
|
+
renderRunHistory();
|
|
2368
|
+
refreshAll();
|
|
2369
|
+
</script>
|
|
2370
|
+
</body></html>
|
|
2371
|
+
"""
|