graphvault-studio 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/assets/graphvault-logo.png +0 -0
- package/assets/studio-screenshot.png +0 -0
- package/dist/admin-cli.d.ts +12 -0
- package/dist/admin-cli.js +132 -0
- package/dist/admin-cli.js.map +1 -0
- package/dist/admin-client.d.ts +49 -0
- package/dist/admin-client.js +352 -0
- package/dist/admin-client.js.map +1 -0
- package/dist/admin-inspection.d.ts +4 -0
- package/dist/admin-inspection.js +70 -0
- package/dist/admin-inspection.js.map +1 -0
- package/dist/admin-mutation.d.ts +4 -0
- package/dist/admin-mutation.js +83 -0
- package/dist/admin-mutation.js.map +1 -0
- package/dist/admin-parent-index.d.ts +4 -0
- package/dist/admin-parent-index.js +72 -0
- package/dist/admin-parent-index.js.map +1 -0
- package/dist/admin-server.d.ts +14 -0
- package/dist/admin-server.js +132 -0
- package/dist/admin-server.js.map +1 -0
- package/dist/admin-types.d.ts +97 -0
- package/dist/admin-types.js +2 -0
- package/dist/admin-types.js.map +1 -0
- package/dist/admin-ui.d.ts +1 -0
- package/dist/admin-ui.js +886 -0
- package/dist/admin-ui.js.map +1 -0
- package/dist/admin.d.ts +4 -0
- package/dist/admin.js +3 -0
- package/dist/admin.js.map +1 -0
- package/dist/gvql-examples.d.ts +6 -0
- package/dist/gvql-examples.js +45 -0
- package/dist/gvql-examples.js.map +1 -0
- package/docs/GVQL.md +217 -0
- package/docs/PUBLISHING.md +46 -0
- package/docs/RELEASE_NOTES_0.1.0.md +41 -0
- package/docs/REMOTE_STORAGE.md +56 -0
- package/examples/create-demo-store.mjs +85 -0
- package/package.json +67 -0
package/dist/admin-ui.js
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import { STUDIO_GVQL_EXAMPLES } from "./gvql-examples.js";
|
|
2
|
+
export const ADMIN_HTML = `<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<title>GraphVault Studio</title>
|
|
8
|
+
<link rel="icon" type="image/png" href="/assets/graphvault-logo.png" />
|
|
9
|
+
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
font-family: "Avenir Next", "Aptos", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", ui-sans-serif, system-ui, sans-serif;
|
|
13
|
+
color: #142026;
|
|
14
|
+
background: #edf2f4;
|
|
15
|
+
--panel: #ffffff;
|
|
16
|
+
--line: #d6dee2;
|
|
17
|
+
--muted: #65757d;
|
|
18
|
+
--ink: #142026;
|
|
19
|
+
--nav: #101c24;
|
|
20
|
+
--nav-soft: #1d303a;
|
|
21
|
+
--accent: #0f8b8d;
|
|
22
|
+
--accent-soft: #d9f1ef;
|
|
23
|
+
--amber: #e4b955;
|
|
24
|
+
--danger: #bf4342;
|
|
25
|
+
}
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
body { margin: 0; min-height: 100vh; }
|
|
28
|
+
strong, b, .status, .panel-title, .mutation summary, .object-id, .tag, .field-name, .kpi strong { text-shadow: none; }
|
|
29
|
+
button, input, select, textarea { font: inherit; }
|
|
30
|
+
button { border: 1px solid var(--line); background: #fff; border-radius: 7px; cursor: pointer; }
|
|
31
|
+
button:hover { border-color: #98aab3; background: #f6fafb; }
|
|
32
|
+
input, select, textarea { width: 100%; padding: 10px 11px; border: 1px solid #b8c7ce; border-radius: 7px; background: #fff; }
|
|
33
|
+
textarea { min-height: 170px; resize: vertical; font-family: "SFMono-Regular", "Cascadia Code", "Roboto Mono", ui-monospace, monospace; line-height: 1.45; }
|
|
34
|
+
.shell { display: grid; grid-template-columns: 268px minmax(0, 1fr); min-height: 100vh; }
|
|
35
|
+
nav { padding: 18px 14px; background: radial-gradient(circle at 0 0, #23444f 0, var(--nav) 42%); color: #eaf1f3; display: grid; grid-template-rows: auto auto auto 1fr; gap: 18px; }
|
|
36
|
+
.brand { display: grid; grid-template-columns: 54px 1fr; gap: 12px; align-items: center; padding: 4px 8px 10px; }
|
|
37
|
+
.brand img { width: 54px; height: 54px; border-radius: 14px; box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28); }
|
|
38
|
+
.brand strong { display: block; font-family: "Avenir Next", "SF Pro Display", "Aptos Display", "Inter", ui-sans-serif, system-ui, sans-serif; font-size: 20px; line-height: 1.05; letter-spacing: 0; font-weight: 600; }
|
|
39
|
+
.brand span, .status, .hint { color: var(--muted); font-size: 13px; }
|
|
40
|
+
nav .brand span, nav .hint { color: #9fb1b9; }
|
|
41
|
+
.status { justify-self: end; padding: 6px 10px; border-radius: 999px; background: #edf6f4; color: #146767; font-weight: 600; }
|
|
42
|
+
.nav-group { display: grid; gap: 7px; }
|
|
43
|
+
.nav-btn { color: #eaf1f3; border-color: transparent; background: transparent; padding: 10px 12px; text-align: left; }
|
|
44
|
+
.nav-btn:hover, .nav-btn.active { background: var(--nav-soft); border-color: #36505c; }
|
|
45
|
+
.sidebar-card { display: grid; align-content: start; gap: 8px; padding: 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.06); }
|
|
46
|
+
nav .hint { align-self: end; }
|
|
47
|
+
.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
|
48
|
+
.topbar { min-height: 74px; padding: 16px 24px; display: grid; grid-template-columns: 1fr auto auto; gap: 14px; align-items: center; background: linear-gradient(180deg, #ffffff 0%, #fbfdfd 100%); border-bottom: 1px solid var(--line); }
|
|
49
|
+
.topbar h1 { margin: 0; font-family: "Avenir Next", "SF Pro Display", "Aptos Display", "Inter", ui-sans-serif, system-ui, sans-serif; font-size: 24px; letter-spacing: 0; font-weight: 600; }
|
|
50
|
+
.auth { display: none; grid-template-columns: minmax(160px, 280px) auto; gap: 8px; }
|
|
51
|
+
.workspace { padding: 18px; display: grid; gap: 14px; }
|
|
52
|
+
.kpis { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 12px; }
|
|
53
|
+
.kpi, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 10px 28px rgba(15, 35, 45, 0.05); }
|
|
54
|
+
.kpi { padding: 14px; display: grid; gap: 8px; min-height: 94px; }
|
|
55
|
+
.kpi span { color: var(--muted); font-size: 13px; }
|
|
56
|
+
.kpi strong { font-size: 28px; line-height: 1; overflow-wrap: anywhere; font-weight: 600; }
|
|
57
|
+
.workgrid { display: grid; grid-template-columns: minmax(330px, 500px) minmax(560px, 1fr); gap: 14px; min-height: 0; }
|
|
58
|
+
.panel { min-width: 0; overflow: hidden; }
|
|
59
|
+
.panel-head { padding: 13px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); background: #fbfdfd; }
|
|
60
|
+
.panel-title { font-weight: 600; }
|
|
61
|
+
.mutation { padding: 12px 14px; border-bottom: 1px solid var(--line); background: #f8fafb; }
|
|
62
|
+
.mutation summary { cursor: pointer; font-weight: 600; }
|
|
63
|
+
.mutation[open] { display: grid; gap: 8px; }
|
|
64
|
+
.mutation-grid { display: grid; grid-template-columns: 90px 1fr 1fr 1fr; gap: 8px; }
|
|
65
|
+
.gvql-box { display: none; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #f8fafb; gap: 8px; }
|
|
66
|
+
.gvql-box.active { display: grid; }
|
|
67
|
+
.gvql-examples { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
68
|
+
.gvql-example { padding: 7px 9px; font-size: 12px; color: #0d6264; background: #eef7f6; border-color: #bddeda; }
|
|
69
|
+
.gvql-example:hover { background: #dff0ee; border-color: #8fc7c0; }
|
|
70
|
+
.gvql-parameters { display: grid; gap: 7px; }
|
|
71
|
+
.gvql-parameter-row { display: grid; grid-template-columns: minmax(96px, 150px) minmax(150px, 1fr) minmax(96px, 120px); gap: 8px; align-items: center; }
|
|
72
|
+
.gvql-param-name { color: #0d6264; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
73
|
+
.gvql-param-empty { color: var(--muted); padding: 8px 0; }
|
|
74
|
+
.gvql-actions { display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; }
|
|
75
|
+
.actions { display: flex; gap: 8px; justify-content: flex-end; }
|
|
76
|
+
.primary { color: #fff; background: var(--accent); border-color: var(--accent); }
|
|
77
|
+
.primary:hover { color: #fff; background: #0b7375; }
|
|
78
|
+
.danger { color: #fff; background: var(--danger); border-color: var(--danger); }
|
|
79
|
+
.danger:hover { color: #fff; background: #9f3030; }
|
|
80
|
+
.list { padding: 10px; overflow: auto; max-height: calc(100vh - 300px); }
|
|
81
|
+
.item { width: 100%; display: grid; grid-template-columns: 78px minmax(90px, 130px) minmax(0, 1fr) 68px; gap: 10px; align-items: center; margin: 0 0 8px; padding: 10px; text-align: left; transition: border-color .15s ease, transform .15s ease, background .15s ease; }
|
|
82
|
+
.item:hover { transform: translateY(-1px); }
|
|
83
|
+
.item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
84
|
+
.object-id { font-weight: 600; color: #12343b; padding-left: calc(var(--depth, 0) * 16px); }
|
|
85
|
+
.tag { display: inline-flex; min-width: 0; width: max-content; max-width: 100%; padding: 4px 7px; border-radius: 999px; background: var(--accent-soft); color: #0d6264; font-size: 12px; font-weight: 600; }
|
|
86
|
+
#viz { display: none; min-height: 320px; border-bottom: 1px solid var(--line); background: #f7fbfb; overflow: hidden; }
|
|
87
|
+
#viz svg { width: 100%; height: 320px; display: block; }
|
|
88
|
+
#viz text { font-size: 12px; fill: var(--ink); pointer-events: none; }
|
|
89
|
+
.fields { display: grid; gap: 8px; padding: 12px; border-bottom: 1px solid var(--line); background: #fbfcfd; }
|
|
90
|
+
.fields-head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(160px, 240px); gap: 8px; align-items: center; }
|
|
91
|
+
.field-row { display: grid; grid-template-columns: minmax(110px, 180px) minmax(240px, 1fr) minmax(70px, 90px) max-content; gap: 8px; align-items: center; }
|
|
92
|
+
.field-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
93
|
+
.field-row > input, .field-row > select, .field-ref { min-width: 0; }
|
|
94
|
+
.field-ref { width: 100%; text-align: left; padding: 10px 11px; background: #eef7f6; color: #0d6264; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
95
|
+
.field-readonly { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
96
|
+
.field-kind { color: var(--muted); font-size: 12px; }
|
|
97
|
+
.field-actions { display: flex; gap: 6px; justify-content: flex-end; min-width: max-content; }
|
|
98
|
+
.pager { display: flex; gap: 8px; align-items: center; justify-content: space-between; padding: 10px; border-top: 1px solid var(--line); }
|
|
99
|
+
pre { margin: 0; min-height: 390px; max-height: calc(100vh - 250px); overflow: auto; padding: 15px; background: #1e272e; color: #edf4f8; line-height: 1.45; font-size: 13px; }
|
|
100
|
+
@media (max-width: 1280px) {
|
|
101
|
+
.workgrid { grid-template-columns: 1fr; }
|
|
102
|
+
.list { max-height: 420px; }
|
|
103
|
+
}
|
|
104
|
+
@media (max-width: 720px) {
|
|
105
|
+
.field-row {
|
|
106
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
107
|
+
grid-template-areas:
|
|
108
|
+
"name kind"
|
|
109
|
+
"editor editor"
|
|
110
|
+
"actions actions";
|
|
111
|
+
}
|
|
112
|
+
.field-name { grid-area: name; }
|
|
113
|
+
.field-row > input, .field-row > select, .field-ref { grid-area: editor; }
|
|
114
|
+
.field-kind { grid-area: kind; justify-self: end; }
|
|
115
|
+
.field-actions { grid-area: actions; justify-content: flex-start; }
|
|
116
|
+
}
|
|
117
|
+
@media (max-width: 980px) {
|
|
118
|
+
.shell, .kpis, .mutation-grid, .topbar, .fields-head { grid-template-columns: 1fr; }
|
|
119
|
+
.gvql-actions, .gvql-parameter-row { grid-template-columns: 1fr; }
|
|
120
|
+
nav { grid-template-rows: auto; gap: 10px; padding: 12px; }
|
|
121
|
+
.brand { grid-template-columns: 42px 1fr; padding-bottom: 2px; }
|
|
122
|
+
.brand img { width: 42px; height: 42px; border-radius: 11px; }
|
|
123
|
+
.nav-group { grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 6px; }
|
|
124
|
+
.nav-btn { padding: 8px 10px; }
|
|
125
|
+
.sidebar-card { padding: 8px; }
|
|
126
|
+
nav .hint { display: none; }
|
|
127
|
+
.workspace { padding: 12px; }
|
|
128
|
+
.status { justify-self: start; }
|
|
129
|
+
.auth { grid-template-columns: 1fr; }
|
|
130
|
+
.list { max-height: none; }
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<div class="shell">
|
|
136
|
+
<nav>
|
|
137
|
+
<div class="brand"><img src="/assets/graphvault-logo.png" alt="" /><div><strong>GraphVault Studio</strong><span>Object Graph Admin</span></div></div>
|
|
138
|
+
<div class="nav-group">
|
|
139
|
+
<button class="nav-btn active" data-view="hierarchy" onclick="showHierarchy()">Hierarchy</button>
|
|
140
|
+
<button class="nav-btn" data-view="overview" onclick="showOverview()">Overview</button>
|
|
141
|
+
<button class="nav-btn" data-view="objects" onclick="showObjects()">Objects</button>
|
|
142
|
+
<button class="nav-btn" data-view="graph" onclick="showGraph()">Graph</button>
|
|
143
|
+
<button class="nav-btn" data-view="gvql" onclick="showGvql()">GVQL</button>
|
|
144
|
+
<button class="nav-btn" data-view="types" onclick="showTypes()">Type Dictionary</button>
|
|
145
|
+
<button class="nav-btn" data-view="transactions" onclick="showTransactions()">Transactions</button>
|
|
146
|
+
<button class="nav-btn" data-view="journal" onclick="showJournal()">Journal</button>
|
|
147
|
+
<button class="nav-btn" data-view="verify" onclick="showVerify()">Verify</button>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="sidebar-card">
|
|
150
|
+
<div class="row"><input id="q" placeholder="Search paths, values, types, IDs" /><button onclick="showSearch()">Search</button></div>
|
|
151
|
+
<div class="row"><input id="backupPath" placeholder="Backup destination" /><button onclick="runBackup()">Backup</button></div>
|
|
152
|
+
<button onclick="runMaintenance()">Run Maintenance</button>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="hint">Mutations only run when the server was started with mutation support.</div>
|
|
155
|
+
</nav>
|
|
156
|
+
<section>
|
|
157
|
+
<header class="topbar">
|
|
158
|
+
<div><h1 id="title">Object Hierarchy</h1><div class="hint" id="subtitle">Root-first graph view with references you can follow.</div></div>
|
|
159
|
+
<div class="auth" id="authPanel"><input id="authToken" type="password" placeholder="Bearer token" /><button onclick="saveAuth()">Unlock</button></div>
|
|
160
|
+
<span class="status" id="status">loading</span>
|
|
161
|
+
</header>
|
|
162
|
+
<main class="workspace">
|
|
163
|
+
<div class="kpis" id="kpis"></div>
|
|
164
|
+
<div class="workgrid">
|
|
165
|
+
<div class="panel">
|
|
166
|
+
<div class="panel-head"><span class="panel-title" id="listTitle">Objects</span><span class="hint" id="listHint">Select a record</span></div>
|
|
167
|
+
<details class="mutation" id="editPanel">
|
|
168
|
+
<summary>Advanced direct edit</summary>
|
|
169
|
+
<div class="mutation-grid">
|
|
170
|
+
<input id="mid" placeholder="Selected object ID" />
|
|
171
|
+
<input id="mpath" placeholder="Field path, e.g. status" />
|
|
172
|
+
<input id="mvalue" placeholder='New JSON value, e.g. "active"' />
|
|
173
|
+
<input id="confirmToken" placeholder="Confirm token" />
|
|
174
|
+
</div>
|
|
175
|
+
<div class="actions"><button onclick="preview()">Preview</button><button class="danger" onclick="mutate()">Commit Change</button></div>
|
|
176
|
+
</details>
|
|
177
|
+
<div class="gvql-box" id="gvqlPanel">
|
|
178
|
+
<textarea id="gvqlQuery" spellcheck="false">MATCH (node)
|
|
179
|
+
RETURN node.$id AS objectId, node.$type AS type, node.$kind AS kind
|
|
180
|
+
ORDER BY node.$id ASC
|
|
181
|
+
LIMIT 25
|
|
182
|
+
OFFSET 0</textarea>
|
|
183
|
+
<div class="gvql-examples" id="gvqlExamples"></div>
|
|
184
|
+
<div class="gvql-parameters" id="gvqlParameterEditor"></div>
|
|
185
|
+
<input id="gvqlParams" type="hidden" />
|
|
186
|
+
<div class="gvql-actions">
|
|
187
|
+
<input id="gvqlConfirmToken" placeholder="Confirm token" />
|
|
188
|
+
<button onclick="runGvql(true)">Run / Preview</button>
|
|
189
|
+
<button class="danger" onclick="runGvql(false)">Commit GVQL</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="list" id="list"></div>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="panel">
|
|
195
|
+
<div class="panel-head"><span class="panel-title">Details</span><span class="hint" id="detailHint">JSON view</span></div>
|
|
196
|
+
<div id="viz"></div>
|
|
197
|
+
<div class="fields" id="fields"></div>
|
|
198
|
+
<pre id="out"></pre>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</main>
|
|
202
|
+
</section>
|
|
203
|
+
</div>
|
|
204
|
+
<script>
|
|
205
|
+
const out = document.getElementById('out');
|
|
206
|
+
const viz = document.getElementById('viz');
|
|
207
|
+
const fields = document.getElementById('fields');
|
|
208
|
+
const list = document.getElementById('list');
|
|
209
|
+
const kpis = document.getElementById('kpis');
|
|
210
|
+
const status = document.getElementById('status');
|
|
211
|
+
const title = document.getElementById('title');
|
|
212
|
+
const subtitle = document.getElementById('subtitle');
|
|
213
|
+
const listTitle = document.getElementById('listTitle');
|
|
214
|
+
const listHint = document.getElementById('listHint');
|
|
215
|
+
const gvqlExamples = ${JSON.stringify(STUDIO_GVQL_EXAMPLES)};
|
|
216
|
+
const authRequired = __AUTH_REQUIRED__;
|
|
217
|
+
const authTokenInput = document.getElementById('authToken');
|
|
218
|
+
const objectPageSize = 100;
|
|
219
|
+
let objectPageOffset = 0;
|
|
220
|
+
let hierarchyPath = [];
|
|
221
|
+
let gvqlEditorWired = false;
|
|
222
|
+
const viewText = {
|
|
223
|
+
hierarchy: ['Object Hierarchy', 'Root-first graph view with references you can follow.'],
|
|
224
|
+
overview: ['Storage Overview', 'Health, object count, latest transaction, and current snapshot.'],
|
|
225
|
+
objects: ['Objects', 'Browse graph records with type, preview, and transaction metadata.'],
|
|
226
|
+
graph: ['Object Graph', 'Click nodes or edges to inspect referenced records.'],
|
|
227
|
+
gvql: ['GVQL Query', 'Run graph pattern queries and preview batch updates.'],
|
|
228
|
+
types: ['Type Dictionary', 'Registered runtime types and schema metadata.'],
|
|
229
|
+
transactions: ['Transactions', 'Newest commits first.'],
|
|
230
|
+
journal: ['Journal', 'Append-only storage activity log.'],
|
|
231
|
+
verify: ['Verification', 'Integrity checks across manifest, transactions, and object records.'],
|
|
232
|
+
search: ['Search', 'Matches across object paths and encoded values.']
|
|
233
|
+
};
|
|
234
|
+
if (authRequired) {
|
|
235
|
+
document.getElementById('authPanel').style.display = 'grid';
|
|
236
|
+
authTokenInput.value = sessionStorage.getItem('graphvault-admin-token') || '';
|
|
237
|
+
}
|
|
238
|
+
function saveAuth() {
|
|
239
|
+
sessionStorage.setItem('graphvault-admin-token', authTokenInput.value);
|
|
240
|
+
showOverview().then(showObjects);
|
|
241
|
+
}
|
|
242
|
+
function setView(name) {
|
|
243
|
+
document.querySelectorAll('.nav-btn').forEach(button => button.classList.toggle('active', button.dataset.view === name));
|
|
244
|
+
title.textContent = viewText[name][0];
|
|
245
|
+
subtitle.textContent = viewText[name][1];
|
|
246
|
+
const gvqlPanel = document.getElementById('gvqlPanel');
|
|
247
|
+
if (gvqlPanel) gvqlPanel.classList.toggle('active', name === 'gvql');
|
|
248
|
+
}
|
|
249
|
+
const setStatus = text => status.textContent = text;
|
|
250
|
+
const esc = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
251
|
+
const show = value => {
|
|
252
|
+
viz.style.display = 'none';
|
|
253
|
+
viz.replaceChildren();
|
|
254
|
+
fields.replaceChildren();
|
|
255
|
+
out.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
256
|
+
};
|
|
257
|
+
const apiHeaders = extra => {
|
|
258
|
+
const headers = Object.assign({}, extra || {});
|
|
259
|
+
const token = authRequired ? authTokenInput.value : '';
|
|
260
|
+
if (token) headers.authorization = 'Bearer ' + token;
|
|
261
|
+
return headers;
|
|
262
|
+
};
|
|
263
|
+
const requestJson = async (url, options) => {
|
|
264
|
+
const response = await fetch(url, Object.assign({}, options || {}, { headers: apiHeaders(options && options.headers) }));
|
|
265
|
+
const value = await response.json();
|
|
266
|
+
setStatus(response.ok ? 'ready' : response.status === 401 ? 'locked' : 'error');
|
|
267
|
+
show(value);
|
|
268
|
+
return value;
|
|
269
|
+
};
|
|
270
|
+
const requestText = async url => {
|
|
271
|
+
const response = await fetch(url, { headers: apiHeaders() });
|
|
272
|
+
const value = await response.text();
|
|
273
|
+
setStatus(response.ok ? 'ready' : response.status === 401 ? 'locked' : 'error');
|
|
274
|
+
show(value);
|
|
275
|
+
return value;
|
|
276
|
+
};
|
|
277
|
+
const apiJson = async url => {
|
|
278
|
+
const response = await fetch(url, { headers: apiHeaders() });
|
|
279
|
+
const value = await response.json();
|
|
280
|
+
setStatus(response.ok ? 'ready' : response.status === 401 ? 'locked' : 'error');
|
|
281
|
+
return value;
|
|
282
|
+
};
|
|
283
|
+
const postJson = (url, body) => requestJson(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
284
|
+
const kpi = (label, value) => '<div class="kpi"><span>' + esc(label) + '</span><strong>' + esc(value) + '</strong></div>';
|
|
285
|
+
function setRows(rows, emptyText) {
|
|
286
|
+
if (!rows.length) {
|
|
287
|
+
const empty = document.createElement('div');
|
|
288
|
+
empty.className = 'hint';
|
|
289
|
+
empty.style.padding = '16px';
|
|
290
|
+
empty.textContent = emptyText || 'No records';
|
|
291
|
+
list.replaceChildren(empty);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
list.replaceChildren(...rows.map(row => {
|
|
295
|
+
const button = document.createElement('button');
|
|
296
|
+
button.className = 'item';
|
|
297
|
+
button.style.setProperty('--depth', row.depth || 0);
|
|
298
|
+
row.columns.forEach((column, index) => {
|
|
299
|
+
const span = document.createElement('span');
|
|
300
|
+
span.textContent = column;
|
|
301
|
+
if (index === 0) span.className = 'object-id';
|
|
302
|
+
if (index === 1) span.className = 'tag';
|
|
303
|
+
button.appendChild(span);
|
|
304
|
+
});
|
|
305
|
+
button.onclick = row.onclick;
|
|
306
|
+
return button;
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
async function refreshKpis() {
|
|
310
|
+
const summary = await requestJson('/api/summary?verify=false');
|
|
311
|
+
kpis.innerHTML = kpi('Objects', summary.objectCount) + kpi('Transaction', summary.transactionId) + kpi('Verify', summary.verification ? (summary.verification.ok ? 'OK' : 'FAIL') : 'manual') + kpi('Snapshot', summary.currentSnapshot || '-');
|
|
312
|
+
return summary;
|
|
313
|
+
}
|
|
314
|
+
async function showHierarchy() {
|
|
315
|
+
setView('hierarchy');
|
|
316
|
+
listTitle.textContent = 'Object hierarchy';
|
|
317
|
+
listHint.textContent = 'Lazy loaded';
|
|
318
|
+
await refreshKpis();
|
|
319
|
+
const root = await apiJson('/api/root');
|
|
320
|
+
if (!root.rootObjectId) {
|
|
321
|
+
setRows([], 'Primitive root value');
|
|
322
|
+
show(root);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await showObjectWithChildren(root.rootObjectId, 'root', []);
|
|
326
|
+
show({ root: root.rootObjectId, note: 'Only the visible branch is loaded. Click a row to drill into its children.' });
|
|
327
|
+
}
|
|
328
|
+
async function showObjectWithChildren(id, label, parentPath) {
|
|
329
|
+
const record = await apiJson('/api/objects/' + encodeURIComponent(id));
|
|
330
|
+
hierarchyPath = parentPath.concat([{ id, label, record }]);
|
|
331
|
+
renderEditableFields(record);
|
|
332
|
+
document.getElementById('mid').value = id;
|
|
333
|
+
document.getElementById('detailHint').textContent = 'Object #' + id;
|
|
334
|
+
out.textContent = JSON.stringify(record, null, 2);
|
|
335
|
+
const children = await apiJson('/api/objects/' + encodeURIComponent(id) + '/children');
|
|
336
|
+
const rows = hierarchyPath.map((entry, index) => ({
|
|
337
|
+
depth: index,
|
|
338
|
+
columns: ['#' + entry.record.objectId, entry.record.node.type || entry.record.node.kind, entry.label + ' -> ' + summarizeRecord(entry.record), index === hierarchyPath.length - 1 ? 'current' : 'parent'],
|
|
339
|
+
onclick: () => showObjectWithChildren(entry.id, entry.label, hierarchyPath.slice(0, index))
|
|
340
|
+
})).concat(children.map(child => ({
|
|
341
|
+
depth: hierarchyPath.length,
|
|
342
|
+
columns: ['#' + child.to, child.type || child.kind, child.path + ' -> ' + child.preview, 'open'],
|
|
343
|
+
onclick: () => showObjectWithChildren(child.to, child.path, hierarchyPath)
|
|
344
|
+
})));
|
|
345
|
+
setRows(rows, 'No children');
|
|
346
|
+
}
|
|
347
|
+
async function showObjectInHierarchyPath(id) {
|
|
348
|
+
setView('hierarchy');
|
|
349
|
+
listTitle.textContent = 'Object hierarchy';
|
|
350
|
+
listHint.textContent = 'Search context';
|
|
351
|
+
await refreshKpis();
|
|
352
|
+
const path = await apiJson('/api/objects/' + encodeURIComponent(id) + '/path');
|
|
353
|
+
const record = await apiJson('/api/objects/' + encodeURIComponent(id));
|
|
354
|
+
renderEditableFields(record);
|
|
355
|
+
document.getElementById('mid').value = id;
|
|
356
|
+
document.getElementById('detailHint').textContent = 'Object #' + id;
|
|
357
|
+
out.textContent = JSON.stringify(record, null, 2);
|
|
358
|
+
if (!path.found) {
|
|
359
|
+
setRows([{ depth: 0, columns: ['#' + id, record.node.type || record.node.kind, 'unreachable from root -> ' + summarizeRecord(record), 'current'], onclick: () => showObject(id) }], 'No path from root');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const children = await apiJson('/api/objects/' + encodeURIComponent(id) + '/children');
|
|
363
|
+
const pathParentIds = new Set(path.items.slice(0, -1).map(item => item.objectId));
|
|
364
|
+
const alternateParents = path.directParents.filter(parent => !pathParentIds.has(parent.objectId));
|
|
365
|
+
const rows = path.items.map((item, index) => ({
|
|
366
|
+
depth: index,
|
|
367
|
+
columns: ['#' + item.objectId, item.type || item.kind, item.label + ' -> ' + item.preview, index === path.items.length - 1 ? 'current' : 'parent'],
|
|
368
|
+
onclick: () => showObjectInHierarchyPath(item.objectId)
|
|
369
|
+
})).concat(alternateParents.map(parent => ({
|
|
370
|
+
depth: Math.max(0, path.items.length - 1),
|
|
371
|
+
columns: ['#' + parent.objectId, parent.type || parent.kind, parent.path + ' -> alternate direct parent: ' + parent.preview, 'parent'],
|
|
372
|
+
onclick: () => showObjectInHierarchyPath(parent.objectId)
|
|
373
|
+
}))).concat(children.map(child => ({
|
|
374
|
+
depth: path.items.length,
|
|
375
|
+
columns: ['#' + child.to, child.type || child.kind, child.path + ' -> ' + child.preview, 'open'],
|
|
376
|
+
onclick: () => showObjectInHierarchyPath(child.to)
|
|
377
|
+
})));
|
|
378
|
+
setRows(rows, 'No children');
|
|
379
|
+
}
|
|
380
|
+
function summarizeRecord(record) {
|
|
381
|
+
if (record.node.kind === 'object') {
|
|
382
|
+
return Object.entries(record.node.props).slice(0, 4).map(([key, value]) => key + ': ' + displayValue(value)).join(', ');
|
|
383
|
+
}
|
|
384
|
+
if (record.node.kind === 'array' || record.node.kind === 'set') return record.node.kind + '(' + record.node.items.length + ')';
|
|
385
|
+
if (record.node.kind === 'map') return 'map(' + record.node.entries.length + ')';
|
|
386
|
+
return record.node.kind;
|
|
387
|
+
}
|
|
388
|
+
function displayValue(value) {
|
|
389
|
+
if (value && typeof value === 'object' && '$ref' in value) return '#' + value.$ref;
|
|
390
|
+
if (value && typeof value === 'object' && '$type' in value) return value.value || value.$type;
|
|
391
|
+
return String(value);
|
|
392
|
+
}
|
|
393
|
+
async function showOverview() {
|
|
394
|
+
setView('overview');
|
|
395
|
+
listTitle.textContent = 'Latest transaction';
|
|
396
|
+
listHint.textContent = 'Overview';
|
|
397
|
+
const summary = await refreshKpis();
|
|
398
|
+
show(summary);
|
|
399
|
+
setRows(summary.latestTransaction ? [{ columns: ['#' + summary.latestTransaction.transactionId, summary.latestTransaction.mode, summary.latestTransaction.snapshotFile, summary.latestTransaction.objectIds.length + ' ids'], onclick: () => show(summary.latestTransaction) }] : [], 'No transactions yet');
|
|
400
|
+
}
|
|
401
|
+
async function showObjects() {
|
|
402
|
+
setView('objects');
|
|
403
|
+
listTitle.textContent = 'Persisted objects';
|
|
404
|
+
listHint.textContent = 'Click to inspect';
|
|
405
|
+
const page = await requestJson('/api/object-page?offset=' + objectPageOffset + '&limit=' + objectPageSize);
|
|
406
|
+
setRows(page.items.map(o => ({ columns: ['#' + o.objectId, o.type || o.kind, o.preview || '-', 'tx ' + o.transactionId], onclick: () => showObject(o.objectId) })), 'No objects found');
|
|
407
|
+
renderPager(page);
|
|
408
|
+
}
|
|
409
|
+
function renderPager(page) {
|
|
410
|
+
const pager = document.createElement('div');
|
|
411
|
+
pager.className = 'pager';
|
|
412
|
+
const label = document.createElement('span');
|
|
413
|
+
label.className = 'hint';
|
|
414
|
+
label.textContent = 'Showing ' + (page.offset + 1) + '-' + Math.min(page.total, page.offset + page.items.length) + ' of ' + page.total;
|
|
415
|
+
const controls = document.createElement('div');
|
|
416
|
+
controls.className = 'actions';
|
|
417
|
+
const prev = document.createElement('button');
|
|
418
|
+
prev.textContent = 'Previous';
|
|
419
|
+
prev.disabled = page.offset === 0;
|
|
420
|
+
prev.onclick = () => { objectPageOffset = Math.max(0, objectPageOffset - objectPageSize); return showObjects(); };
|
|
421
|
+
const next = document.createElement('button');
|
|
422
|
+
next.textContent = 'Next';
|
|
423
|
+
next.disabled = page.offset + page.items.length >= page.total;
|
|
424
|
+
next.onclick = () => { objectPageOffset += objectPageSize; return showObjects(); };
|
|
425
|
+
controls.append(prev, next);
|
|
426
|
+
pager.append(label, controls);
|
|
427
|
+
list.appendChild(pager);
|
|
428
|
+
}
|
|
429
|
+
async function showObject(id) {
|
|
430
|
+
document.getElementById('mid').value = id;
|
|
431
|
+
document.getElementById('detailHint').textContent = 'Object #' + id;
|
|
432
|
+
const record = await requestJson('/api/objects/' + encodeURIComponent(id));
|
|
433
|
+
renderEditableFields(record);
|
|
434
|
+
out.textContent = JSON.stringify(record, null, 2);
|
|
435
|
+
}
|
|
436
|
+
function renderEditableFields(record) {
|
|
437
|
+
fields.replaceChildren();
|
|
438
|
+
const rows = editableFieldRows(record);
|
|
439
|
+
const head = document.createElement('div');
|
|
440
|
+
head.className = 'fields-head';
|
|
441
|
+
const title = document.createElement('div');
|
|
442
|
+
title.className = 'hint';
|
|
443
|
+
title.textContent = rows.length ? 'Editable fields' : 'No direct primitive fields on this record';
|
|
444
|
+
head.appendChild(title);
|
|
445
|
+
if (confirmRequired && rows.some(row => isEditableValue(row.value))) {
|
|
446
|
+
const token = document.createElement('input');
|
|
447
|
+
token.id = 'fieldConfirmToken';
|
|
448
|
+
token.placeholder = 'Confirm token for saving';
|
|
449
|
+
token.value = document.getElementById('confirmToken').value;
|
|
450
|
+
token.oninput = () => (document.getElementById('confirmToken').value = token.value);
|
|
451
|
+
head.appendChild(token);
|
|
452
|
+
}
|
|
453
|
+
fields.appendChild(head);
|
|
454
|
+
rows.forEach(field => fields.appendChild(createFieldRow(record.objectId, field)));
|
|
455
|
+
}
|
|
456
|
+
function editableFieldRows(record) {
|
|
457
|
+
const node = record.node;
|
|
458
|
+
if (node.kind === 'object') {
|
|
459
|
+
return Object.entries(node.props).map(([path, value]) => ({ path, value }));
|
|
460
|
+
}
|
|
461
|
+
if (node.kind === 'array' || node.kind === 'set') {
|
|
462
|
+
return node.items.map((value, index) => ({ path: '[' + index + ']', value }));
|
|
463
|
+
}
|
|
464
|
+
if (node.kind === 'map') {
|
|
465
|
+
return node.entries.flatMap((entry, index) => [
|
|
466
|
+
{ path: 'entries[' + index + '].key', value: entry[0] },
|
|
467
|
+
{ path: 'entries[' + index + '].value', value: entry[1] }
|
|
468
|
+
]);
|
|
469
|
+
}
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
function createFieldRow(objectId, field) {
|
|
473
|
+
const row = document.createElement('div');
|
|
474
|
+
row.className = 'field-row';
|
|
475
|
+
const name = document.createElement('div');
|
|
476
|
+
name.className = 'field-name';
|
|
477
|
+
name.textContent = field.path;
|
|
478
|
+
row.appendChild(name);
|
|
479
|
+
const editor = createFieldEditor(field.value);
|
|
480
|
+
if (!editor.editable && editor.ref) {
|
|
481
|
+
editor.element.onclick = () => showObjectInHierarchyPath(editor.ref);
|
|
482
|
+
}
|
|
483
|
+
row.appendChild(editor.element);
|
|
484
|
+
const kind = document.createElement('div');
|
|
485
|
+
kind.className = 'field-kind';
|
|
486
|
+
kind.textContent = editor.kind;
|
|
487
|
+
row.appendChild(kind);
|
|
488
|
+
const save = document.createElement('button');
|
|
489
|
+
const actions = document.createElement('div');
|
|
490
|
+
actions.className = 'field-actions';
|
|
491
|
+
save.textContent = editor.editable ? 'Preview' : 'Open';
|
|
492
|
+
save.onclick = () => {
|
|
493
|
+
if (!editor.editable && editor.ref) return showObjectInHierarchyPath(editor.ref);
|
|
494
|
+
fillMutation(objectId, field.path, editor.read());
|
|
495
|
+
return preview();
|
|
496
|
+
};
|
|
497
|
+
actions.appendChild(save);
|
|
498
|
+
if (editor.editable) {
|
|
499
|
+
const commit = document.createElement('button');
|
|
500
|
+
commit.className = 'primary';
|
|
501
|
+
commit.textContent = 'Save';
|
|
502
|
+
commit.onclick = () => {
|
|
503
|
+
const token = document.getElementById('fieldConfirmToken');
|
|
504
|
+
if (token) document.getElementById('confirmToken').value = token.value;
|
|
505
|
+
fillMutation(objectId, field.path, editor.read());
|
|
506
|
+
return mutate();
|
|
507
|
+
};
|
|
508
|
+
actions.appendChild(commit);
|
|
509
|
+
}
|
|
510
|
+
row.appendChild(actions);
|
|
511
|
+
return row;
|
|
512
|
+
}
|
|
513
|
+
function isEditableValue(value) {
|
|
514
|
+
return !(value && typeof value === 'object' && '$ref' in value);
|
|
515
|
+
}
|
|
516
|
+
function createFieldEditor(value) {
|
|
517
|
+
if (value && typeof value === 'object' && '$ref' in value) {
|
|
518
|
+
const button = document.createElement('button');
|
|
519
|
+
button.className = 'field-ref';
|
|
520
|
+
button.textContent = 'Open object #' + value.$ref;
|
|
521
|
+
return { element: button, kind: 'reference', editable: false, ref: value.$ref };
|
|
522
|
+
}
|
|
523
|
+
if (value && typeof value === 'object' && '$type' in value) {
|
|
524
|
+
const input = document.createElement('input');
|
|
525
|
+
input.value = typedValueToInput(value);
|
|
526
|
+
const editable = value.$type === 'date' || value.$type === 'bigint' || value.$type === 'undefined';
|
|
527
|
+
if (!editable) input.disabled = true;
|
|
528
|
+
return { element: input, kind: value.$type, editable, read: () => typedInputToJson(value.$type, input.value) };
|
|
529
|
+
}
|
|
530
|
+
if (typeof value === 'boolean') {
|
|
531
|
+
const select = document.createElement('select');
|
|
532
|
+
['true', 'false'].forEach(optionValue => {
|
|
533
|
+
const option = document.createElement('option');
|
|
534
|
+
option.value = optionValue;
|
|
535
|
+
option.textContent = optionValue;
|
|
536
|
+
select.appendChild(option);
|
|
537
|
+
});
|
|
538
|
+
select.value = String(value);
|
|
539
|
+
return { element: select, kind: 'boolean', editable: true, read: () => select.value === 'true' ? 'true' : 'false' };
|
|
540
|
+
}
|
|
541
|
+
const input = document.createElement('input');
|
|
542
|
+
input.value = value === null ? 'null' : String(value);
|
|
543
|
+
return { element: input, kind: value === null ? 'null' : typeof value, editable: true, read: () => primitiveInputToJson(value, input.value) };
|
|
544
|
+
}
|
|
545
|
+
function typedValueToInput(value) {
|
|
546
|
+
if (value.$type === 'undefined') return 'undefined';
|
|
547
|
+
return value.value || '';
|
|
548
|
+
}
|
|
549
|
+
function typedInputToJson(type, value) {
|
|
550
|
+
if (type === 'undefined') return 'undefined';
|
|
551
|
+
if (type === 'bigint') return value;
|
|
552
|
+
return JSON.stringify(value);
|
|
553
|
+
}
|
|
554
|
+
function primitiveInputToJson(original, value) {
|
|
555
|
+
if (original === null) return value === 'null' ? 'null' : JSON.stringify(value);
|
|
556
|
+
if (typeof original === 'number') return String(Number(value));
|
|
557
|
+
if (typeof original === 'boolean') return value === 'true' ? 'true' : 'false';
|
|
558
|
+
return JSON.stringify(value);
|
|
559
|
+
}
|
|
560
|
+
function fillMutation(objectId, path, jsonValue) {
|
|
561
|
+
document.getElementById('mid').value = objectId;
|
|
562
|
+
document.getElementById('mpath').value = path;
|
|
563
|
+
document.getElementById('mvalue').value = jsonValue;
|
|
564
|
+
document.getElementById('editPanel').open = true;
|
|
565
|
+
}
|
|
566
|
+
async function showGraph() {
|
|
567
|
+
setView('graph');
|
|
568
|
+
listTitle.textContent = 'Graph edges';
|
|
569
|
+
listHint.textContent = 'Follow references';
|
|
570
|
+
const graph = await requestJson('/api/graph');
|
|
571
|
+
renderGraph(graph);
|
|
572
|
+
setRows(graph.edges.map(e => ({ columns: [e.from + ' -> ' + e.to, 'edge', e.path, 'ref'], onclick: () => showObject(e.to) })), 'No edges found');
|
|
573
|
+
}
|
|
574
|
+
function renderGraph(graph) {
|
|
575
|
+
const width = 900;
|
|
576
|
+
const height = 320;
|
|
577
|
+
const radius = Math.max(80, Math.min(135, 40 + graph.nodes.length * 4));
|
|
578
|
+
const cx = width / 2;
|
|
579
|
+
const cy = height / 2;
|
|
580
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
581
|
+
const svg = document.createElementNS(ns, 'svg');
|
|
582
|
+
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
|
583
|
+
const positions = new Map(graph.nodes.map((node, index) => {
|
|
584
|
+
const angle = graph.nodes.length === 1 ? 0 : (Math.PI * 2 * index) / graph.nodes.length - Math.PI / 2;
|
|
585
|
+
return [node.objectId, { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius }];
|
|
586
|
+
}));
|
|
587
|
+
graph.edges.forEach(edge => {
|
|
588
|
+
const from = positions.get(edge.from);
|
|
589
|
+
const to = positions.get(edge.to);
|
|
590
|
+
if (!from || !to) return;
|
|
591
|
+
const line = document.createElementNS(ns, 'line');
|
|
592
|
+
line.setAttribute('x1', from.x);
|
|
593
|
+
line.setAttribute('y1', from.y);
|
|
594
|
+
line.setAttribute('x2', to.x);
|
|
595
|
+
line.setAttribute('y2', to.y);
|
|
596
|
+
line.setAttribute('stroke', '#9bb3ba');
|
|
597
|
+
line.setAttribute('stroke-width', '2');
|
|
598
|
+
svg.appendChild(line);
|
|
599
|
+
});
|
|
600
|
+
graph.nodes.forEach(node => {
|
|
601
|
+
const pos = positions.get(node.objectId);
|
|
602
|
+
if (!pos) return;
|
|
603
|
+
const group = document.createElementNS(ns, 'g');
|
|
604
|
+
group.style.cursor = 'pointer';
|
|
605
|
+
group.onclick = () => showObject(node.objectId);
|
|
606
|
+
const circle = document.createElementNS(ns, 'circle');
|
|
607
|
+
circle.setAttribute('cx', pos.x);
|
|
608
|
+
circle.setAttribute('cy', pos.y);
|
|
609
|
+
circle.setAttribute('r', '26');
|
|
610
|
+
circle.setAttribute('fill', node.objectId === 'root' ? '#f3c969' : '#d9f1ef');
|
|
611
|
+
circle.setAttribute('stroke', node.objectId === 'root' ? '#ab7622' : '#0f8b8d');
|
|
612
|
+
circle.setAttribute('stroke-width', '2');
|
|
613
|
+
const label = document.createElementNS(ns, 'text');
|
|
614
|
+
label.setAttribute('x', pos.x);
|
|
615
|
+
label.setAttribute('y', pos.y + 4);
|
|
616
|
+
label.setAttribute('text-anchor', 'middle');
|
|
617
|
+
label.setAttribute('font-weight', '600');
|
|
618
|
+
label.textContent = '#' + node.objectId;
|
|
619
|
+
group.append(circle, label);
|
|
620
|
+
svg.appendChild(group);
|
|
621
|
+
});
|
|
622
|
+
viz.replaceChildren(svg);
|
|
623
|
+
viz.style.display = 'block';
|
|
624
|
+
}
|
|
625
|
+
const showTypes = async () => { setView('types'); listTitle.textContent = 'Types'; listHint.textContent = 'Dictionary'; const value = await requestJson('/api/types'); setRows((value && value.entries || []).map(e => ({ columns: [String(e.id || '-'), e.name || 'type', e.handler || e.name || '-', 'type'], onclick: () => show(e) })), 'No type dictionary found'); };
|
|
626
|
+
const showTransactions = async () => { setView('transactions'); listTitle.textContent = 'Transactions'; listHint.textContent = 'Newest first'; const rows = await requestJson('/api/transactions'); setRows(rows.map(t => ({ columns: ['#' + t.transactionId, t.mode, t.snapshotFile, t.objectIds.length + ' ids'], onclick: () => show(t) })), 'No transactions found'); };
|
|
627
|
+
const showJournal = async () => {
|
|
628
|
+
setView('journal');
|
|
629
|
+
listTitle.textContent = 'Journal';
|
|
630
|
+
listHint.textContent = 'Raw log';
|
|
631
|
+
setRows([], 'Journal entries are shown in the detail pane');
|
|
632
|
+
return requestText('/api/journal');
|
|
633
|
+
};
|
|
634
|
+
const showVerify = async () => {
|
|
635
|
+
setView('verify');
|
|
636
|
+
listTitle.textContent = 'Verification';
|
|
637
|
+
listHint.textContent = 'Integrity';
|
|
638
|
+
const result = await requestJson('/api/verify');
|
|
639
|
+
setRows([
|
|
640
|
+
{ columns: [result.ok ? 'OK' : 'FAIL', 'health', result.errors.length ? result.errors.join('; ') : 'No errors', result.checkedObjects + ' objects'], onclick: () => show(result) }
|
|
641
|
+
], 'Verification has not run');
|
|
642
|
+
return result;
|
|
643
|
+
};
|
|
644
|
+
const runMaintenance = () => postJson('/api/maintenance', { keepSnapshots: 2 });
|
|
645
|
+
const runBackup = () => postJson('/api/backup', { storageDirectory: document.getElementById('backupPath').value });
|
|
646
|
+
async function showGvql() {
|
|
647
|
+
setView('gvql');
|
|
648
|
+
listTitle.textContent = 'GVQL results';
|
|
649
|
+
listHint.textContent = 'MATCH / WHERE / RETURN / GROUP BY / HAVING / SET';
|
|
650
|
+
document.getElementById('gvqlPanel').classList.add('active');
|
|
651
|
+
wireGvqlEditor();
|
|
652
|
+
renderGvqlExamples();
|
|
653
|
+
renderGvqlParameterEditor();
|
|
654
|
+
setRows([], 'Run a GVQL query');
|
|
655
|
+
show({
|
|
656
|
+
examples: gvqlExamples.map(example => ({ name: example.name, query: example.query, parameters: example.parameters || {} }))
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
function wireGvqlEditor() {
|
|
660
|
+
if (gvqlEditorWired) return;
|
|
661
|
+
document.getElementById('gvqlQuery').addEventListener('input', () => renderGvqlParameterEditor(readGvqlParametersFromHidden()));
|
|
662
|
+
gvqlEditorWired = true;
|
|
663
|
+
}
|
|
664
|
+
function renderGvqlExamples() {
|
|
665
|
+
const target = document.getElementById('gvqlExamples');
|
|
666
|
+
if (target.dataset.rendered === 'true') return;
|
|
667
|
+
target.replaceChildren(...gvqlExamples.map((example, index) => {
|
|
668
|
+
const button = document.createElement('button');
|
|
669
|
+
button.className = 'gvql-example';
|
|
670
|
+
button.type = 'button';
|
|
671
|
+
button.textContent = example.name;
|
|
672
|
+
button.onclick = () => applyGvqlExample(index);
|
|
673
|
+
return button;
|
|
674
|
+
}));
|
|
675
|
+
target.dataset.rendered = 'true';
|
|
676
|
+
}
|
|
677
|
+
function applyGvqlExample(index) {
|
|
678
|
+
const example = gvqlExamples[index];
|
|
679
|
+
document.getElementById('gvqlQuery').value = example.query;
|
|
680
|
+
document.getElementById('gvqlParams').value = example.parameters ? JSON.stringify(example.parameters) : '';
|
|
681
|
+
renderGvqlParameterEditor(example.parameters || {});
|
|
682
|
+
setRows([], 'Run a GVQL query');
|
|
683
|
+
show({ selectedExample: example.name, query: example.query, parameters: example.parameters || {} });
|
|
684
|
+
}
|
|
685
|
+
function renderGvqlParameterEditor(seed) {
|
|
686
|
+
const target = document.getElementById('gvqlParameterEditor');
|
|
687
|
+
const names = extractGvqlParameterNames(document.getElementById('gvqlQuery').value);
|
|
688
|
+
const values = Object.assign({}, readGvqlParametersFromHidden(), seed || {});
|
|
689
|
+
if (!names.length) {
|
|
690
|
+
document.getElementById('gvqlParams').value = '{}';
|
|
691
|
+
const empty = document.createElement('div');
|
|
692
|
+
empty.className = 'gvql-param-empty';
|
|
693
|
+
empty.textContent = 'No query parameters';
|
|
694
|
+
target.replaceChildren(empty);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
target.replaceChildren(...names.map(name => {
|
|
698
|
+
const row = document.createElement('div');
|
|
699
|
+
row.className = 'gvql-parameter-row';
|
|
700
|
+
row.dataset.name = name;
|
|
701
|
+
const label = document.createElement('span');
|
|
702
|
+
label.className = 'gvql-param-name';
|
|
703
|
+
label.textContent = '$' + name;
|
|
704
|
+
const input = document.createElement('input');
|
|
705
|
+
input.placeholder = 'Value';
|
|
706
|
+
const type = document.createElement('select');
|
|
707
|
+
['string', 'number', 'boolean', 'null', 'json'].forEach(kind => {
|
|
708
|
+
const option = document.createElement('option');
|
|
709
|
+
option.value = kind;
|
|
710
|
+
option.textContent = kind;
|
|
711
|
+
type.appendChild(option);
|
|
712
|
+
});
|
|
713
|
+
type.value = inferGvqlParameterType(values[name]);
|
|
714
|
+
input.value = formatGvqlParameterInput(values[name], type.value);
|
|
715
|
+
input.disabled = type.value === 'null';
|
|
716
|
+
input.oninput = syncGvqlParameters;
|
|
717
|
+
type.onchange = () => {
|
|
718
|
+
input.disabled = type.value === 'null';
|
|
719
|
+
if (type.value === 'null') input.value = '';
|
|
720
|
+
syncGvqlParameters();
|
|
721
|
+
};
|
|
722
|
+
row.append(label, input, type);
|
|
723
|
+
return row;
|
|
724
|
+
}));
|
|
725
|
+
syncGvqlParameters();
|
|
726
|
+
}
|
|
727
|
+
function extractGvqlParameterNames(query) {
|
|
728
|
+
const names = [];
|
|
729
|
+
const seen = new Set();
|
|
730
|
+
let quote = '';
|
|
731
|
+
for (let index = 0; index < query.length; index += 1) {
|
|
732
|
+
const char = query[index];
|
|
733
|
+
if (quote) {
|
|
734
|
+
if (char === '\\\\') index += 1;
|
|
735
|
+
else if (char === quote) quote = '';
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (char === '"' || char === "'") {
|
|
739
|
+
quote = char;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (char !== '$' || query[index - 1] === '.') continue;
|
|
743
|
+
const match = query.slice(index + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
744
|
+
if (!match || seen.has(match[0])) continue;
|
|
745
|
+
seen.add(match[0]);
|
|
746
|
+
names.push(match[0]);
|
|
747
|
+
index += match[0].length;
|
|
748
|
+
}
|
|
749
|
+
return names;
|
|
750
|
+
}
|
|
751
|
+
function readGvqlParametersFromHidden() {
|
|
752
|
+
try {
|
|
753
|
+
return JSON.parse(document.getElementById('gvqlParams').value || '{}');
|
|
754
|
+
} catch (_error) {
|
|
755
|
+
return {};
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function syncGvqlParameters() {
|
|
759
|
+
try {
|
|
760
|
+
document.getElementById('gvqlParams').value = JSON.stringify(readGvqlParametersFromEditor());
|
|
761
|
+
} catch (_error) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
function readGvqlParametersFromEditor() {
|
|
766
|
+
const parameters = {};
|
|
767
|
+
document.querySelectorAll('.gvql-parameter-row').forEach(row => {
|
|
768
|
+
const name = row.dataset.name;
|
|
769
|
+
const input = row.querySelector('input');
|
|
770
|
+
const type = row.querySelector('select').value;
|
|
771
|
+
parameters[name] = parseGvqlParameterInput(input.value, type, name);
|
|
772
|
+
});
|
|
773
|
+
return parameters;
|
|
774
|
+
}
|
|
775
|
+
function parseGvqlParameterInput(value, type, name) {
|
|
776
|
+
if (type === 'string') return value;
|
|
777
|
+
if (type === 'number') {
|
|
778
|
+
const number = Number(value);
|
|
779
|
+
if (!Number.isFinite(number)) throw new Error('$' + name + ' must be a finite number');
|
|
780
|
+
return number;
|
|
781
|
+
}
|
|
782
|
+
if (type === 'boolean') {
|
|
783
|
+
if (value === 'true') return true;
|
|
784
|
+
if (value === 'false') return false;
|
|
785
|
+
throw new Error('$' + name + ' must be true or false');
|
|
786
|
+
}
|
|
787
|
+
if (type === 'null') return null;
|
|
788
|
+
try {
|
|
789
|
+
return JSON.parse(value);
|
|
790
|
+
} catch (_error) {
|
|
791
|
+
throw new Error('$' + name + ' must contain valid JSON');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function inferGvqlParameterType(value) {
|
|
795
|
+
if (value === null) return 'null';
|
|
796
|
+
if (typeof value === 'number') return 'number';
|
|
797
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
798
|
+
if (typeof value === 'object' && typeof value !== 'undefined') return 'json';
|
|
799
|
+
return 'string';
|
|
800
|
+
}
|
|
801
|
+
function formatGvqlParameterInput(value, type) {
|
|
802
|
+
if (typeof value === 'undefined' || value === null) return '';
|
|
803
|
+
return type === 'json' ? JSON.stringify(value) : String(value);
|
|
804
|
+
}
|
|
805
|
+
async function runGvql(dryRun) {
|
|
806
|
+
setView('gvql');
|
|
807
|
+
document.getElementById('gvqlPanel').classList.add('active');
|
|
808
|
+
let parameters;
|
|
809
|
+
try {
|
|
810
|
+
parameters = readGvqlParametersFromEditor();
|
|
811
|
+
document.getElementById('gvqlParams').value = JSON.stringify(parameters);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
setStatus('error');
|
|
814
|
+
setRows([], error.message || 'Invalid GVQL parameters');
|
|
815
|
+
show({ error: error.message || String(error) });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const payload = {
|
|
819
|
+
query: document.getElementById('gvqlQuery').value,
|
|
820
|
+
parameters,
|
|
821
|
+
dryRun
|
|
822
|
+
};
|
|
823
|
+
if (!dryRun && confirmRequired) payload.confirmToken = document.getElementById('gvqlConfirmToken').value;
|
|
824
|
+
const result = await postJson('/api/gvql', payload);
|
|
825
|
+
const rows = [];
|
|
826
|
+
if (result.plan) {
|
|
827
|
+
rows.push({
|
|
828
|
+
columns: ['plan', result.plan.candidateSource || 'scan', summarizeGvqlPlan(result.plan), result.plan.indexUsed ? 'indexed' : 'scan'],
|
|
829
|
+
onclick: () => show({ plan: result.plan, statement: result.statement })
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
rows.push(...(result.rows || []).map((row, index) => ({
|
|
833
|
+
columns: [String(index + 1), result.kind || 'row', summarizeGvqlRow(row), result.dryRun ? 'preview' : 'result'],
|
|
834
|
+
onclick: () => show(row)
|
|
835
|
+
})));
|
|
836
|
+
if (result.kind === 'update' && result.changes) {
|
|
837
|
+
rows.push(...result.changes.slice(0, 200).map(change => ({
|
|
838
|
+
columns: ['#' + change.objectId, change.operation || 'change', summarizeGvqlChange(change), result.dryRun ? 'preview' : 'changed'],
|
|
839
|
+
onclick: () => show(change)
|
|
840
|
+
})));
|
|
841
|
+
}
|
|
842
|
+
setRows(rows, 'No GVQL results');
|
|
843
|
+
await refreshKpis();
|
|
844
|
+
}
|
|
845
|
+
function summarizeGvqlRow(row) {
|
|
846
|
+
return Object.entries(row).map(([key, value]) => key + ': ' + (typeof value === 'object' ? JSON.stringify(value) : String(value))).join(', ').slice(0, 220);
|
|
847
|
+
}
|
|
848
|
+
function summarizeGvqlChange(change) {
|
|
849
|
+
const path = change.path || '(object)';
|
|
850
|
+
return (path + ': ' + summarizeGvqlValue(change.before) + ' -> ' + summarizeGvqlValue(change.after)).slice(0, 220);
|
|
851
|
+
}
|
|
852
|
+
function summarizeGvqlValue(value) {
|
|
853
|
+
if (typeof value === 'undefined') return 'undefined';
|
|
854
|
+
if (value === null) return 'null';
|
|
855
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
856
|
+
return String(value);
|
|
857
|
+
}
|
|
858
|
+
function summarizeGvqlPlan(plan) {
|
|
859
|
+
return [
|
|
860
|
+
plan.startCandidates + ' candidates',
|
|
861
|
+
plan.filteredBindings + ' matched',
|
|
862
|
+
plan.returnedRows + ' rows',
|
|
863
|
+
(plan.operations || []).join(' -> ')
|
|
864
|
+
].filter(Boolean).join(' | ').slice(0, 220);
|
|
865
|
+
}
|
|
866
|
+
async function showSearch() {
|
|
867
|
+
setView('search');
|
|
868
|
+
listTitle.textContent = 'Search results';
|
|
869
|
+
listHint.textContent = 'Path and value matches';
|
|
870
|
+
const rows = await requestJson('/api/search?q=' + encodeURIComponent(document.getElementById('q').value));
|
|
871
|
+
setRows(rows.map(r => ({ columns: ['#' + r.objectId, 'match', r.path + ' = ' + r.preview, 'context'], onclick: () => showObjectInHierarchyPath(r.objectId) })), 'No matches');
|
|
872
|
+
}
|
|
873
|
+
const confirmRequired = __CONFIRM_REQUIRED__;
|
|
874
|
+
if (!confirmRequired) document.getElementById('confirmToken').style.display = 'none';
|
|
875
|
+
function mutationPayload(includeConfirm) {
|
|
876
|
+
const payload = { objectId: document.getElementById('mid').value, path: document.getElementById('mpath').value, value: JSON.parse(document.getElementById('mvalue').value) };
|
|
877
|
+
if (includeConfirm) payload.confirmToken = document.getElementById('confirmToken').value;
|
|
878
|
+
return payload;
|
|
879
|
+
}
|
|
880
|
+
const preview = () => postJson('/api/preview-mutation', mutationPayload(false));
|
|
881
|
+
const mutate = () => postJson('/api/mutate', mutationPayload(confirmRequired)).then(refreshKpis);
|
|
882
|
+
showHierarchy();
|
|
883
|
+
</script>
|
|
884
|
+
</body>
|
|
885
|
+
</html>`;
|
|
886
|
+
//# sourceMappingURL=admin-ui.js.map
|