pgserve 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +105 -1
  3. package/bin/autopg-wrapper.cjs +16 -0
  4. package/bin/pgserve-wrapper.cjs +31 -6
  5. package/bin/postgres-server.js +80 -7
  6. package/console/README.md +131 -0
  7. package/console/api.js +173 -0
  8. package/console/app.jsx +483 -0
  9. package/console/colors_and_type.css +227 -0
  10. package/console/components.jsx +167 -0
  11. package/console/console.css +1666 -0
  12. package/console/data.jsx +350 -0
  13. package/console/index.html +31 -0
  14. package/console/screens/databases.jsx +5 -0
  15. package/console/screens/health.jsx +5 -0
  16. package/console/screens/ingress.jsx +5 -0
  17. package/console/screens/optimizer.jsx +5 -0
  18. package/console/screens/rlm-sim.jsx +5 -0
  19. package/console/screens/rlm-trace.jsx +5 -0
  20. package/console/screens/security.jsx +5 -0
  21. package/console/screens/settings.jsx +611 -0
  22. package/console/screens/sql.jsx +5 -0
  23. package/console/screens/sync.jsx +5 -0
  24. package/console/screens/tables.jsx +5 -0
  25. package/console/tweaks-panel.jsx +425 -0
  26. package/package.json +11 -1
  27. package/src/cli-config.cjs +310 -0
  28. package/src/cli-install.cjs +98 -11
  29. package/src/cli-restart.cjs +228 -0
  30. package/src/cli-ui.cjs +580 -0
  31. package/src/cluster.js +43 -38
  32. package/src/postgres.js +141 -19
  33. package/src/settings-loader.cjs +235 -0
  34. package/src/settings-migrate.cjs +212 -0
  35. package/src/settings-pg-args.cjs +146 -0
  36. package/src/settings-schema.cjs +422 -0
  37. package/src/settings-validator.cjs +416 -0
  38. package/src/settings-writer.cjs +288 -0
  39. package/.claude/context/windows-debug.md +0 -119
  40. package/.genie/AGENTS.md +0 -15
  41. package/.genie/agents/README.md +0 -110
  42. package/.genie/agents/analyze.md +0 -176
  43. package/.genie/agents/forge.md +0 -290
  44. package/.genie/agents/garbage-cleaner.md +0 -324
  45. package/.genie/agents/garbage-collector.md +0 -596
  46. package/.genie/agents/github-issue-gc.md +0 -618
  47. package/.genie/agents/review.md +0 -380
  48. package/.genie/agents/semantic-analyzer/find-duplicates.md +0 -90
  49. package/.genie/agents/semantic-analyzer/find-orphans.md +0 -99
  50. package/.genie/agents/semantic-analyzer.md +0 -101
  51. package/.genie/agents/update.md +0 -182
  52. package/.genie/agents/wish.md +0 -357
  53. package/.genie/brainstorms/pgserve-v2/DESIGN.md +0 -174
  54. package/.genie/code/AGENTS.md +0 -694
  55. package/.genie/code/agents/audit/risk.md +0 -173
  56. package/.genie/code/agents/audit/security.md +0 -189
  57. package/.genie/code/agents/audit.md +0 -145
  58. package/.genie/code/agents/challenge.md +0 -230
  59. package/.genie/code/agents/change-reviewer.md +0 -295
  60. package/.genie/code/agents/code-garbage-collector.md +0 -425
  61. package/.genie/code/agents/code-quality.md +0 -410
  62. package/.genie/code/agents/commit-suggester.md +0 -255
  63. package/.genie/code/agents/commit.md +0 -124
  64. package/.genie/code/agents/consensus.md +0 -204
  65. package/.genie/code/agents/daily-standup.md +0 -722
  66. package/.genie/code/agents/docgen.md +0 -48
  67. package/.genie/code/agents/explore.md +0 -79
  68. package/.genie/code/agents/fix.md +0 -100
  69. package/.genie/code/agents/git/commit-advisory.md +0 -219
  70. package/.genie/code/agents/git/workflows/issue.md +0 -244
  71. package/.genie/code/agents/git/workflows/pr.md +0 -179
  72. package/.genie/code/agents/git/workflows/release.md +0 -460
  73. package/.genie/code/agents/git/workflows/report.md +0 -342
  74. package/.genie/code/agents/git.md +0 -432
  75. package/.genie/code/agents/implementor.md +0 -161
  76. package/.genie/code/agents/install.md +0 -515
  77. package/.genie/code/agents/issue-creator.md +0 -344
  78. package/.genie/code/agents/polish.md +0 -116
  79. package/.genie/code/agents/qa.md +0 -653
  80. package/.genie/code/agents/refactor.md +0 -294
  81. package/.genie/code/agents/release.md +0 -1129
  82. package/.genie/code/agents/roadmap.md +0 -885
  83. package/.genie/code/agents/tests.md +0 -557
  84. package/.genie/code/agents/tracer.md +0 -50
  85. package/.genie/code/agents/update/upstream-update.md +0 -85
  86. package/.genie/code/agents/update/versions/generic-update.md +0 -305
  87. package/.genie/code/agents/vibe.md +0 -1317
  88. package/.genie/code/spells/agent-configuration.md +0 -58
  89. package/.genie/code/spells/automated-rc-publishing.md +0 -106
  90. package/.genie/code/spells/branch-tracker-guidance.md +0 -28
  91. package/.genie/code/spells/debug.md +0 -320
  92. package/.genie/code/spells/emoji-naming-convention.md +0 -303
  93. package/.genie/code/spells/evidence-storage.md +0 -26
  94. package/.genie/code/spells/file-naming-rules.md +0 -35
  95. package/.genie/code/spells/forge-code-blueprints.md +0 -195
  96. package/.genie/code/spells/genie-integration.md +0 -153
  97. package/.genie/code/spells/publishing-protocol.md +0 -61
  98. package/.genie/code/spells/team-consultation-protocol.md +0 -284
  99. package/.genie/code/spells/tool-requirements.md +0 -20
  100. package/.genie/code/spells/triad-maintenance-protocol.md +0 -154
  101. package/.genie/code/teams/tech-council/council.md +0 -328
  102. package/.genie/code/teams/tech-council/jt.md +0 -352
  103. package/.genie/code/teams/tech-council/nayr.md +0 -305
  104. package/.genie/code/teams/tech-council/oettam.md +0 -375
  105. package/.genie/neurons/README.md +0 -193
  106. package/.genie/neurons/forge.md +0 -106
  107. package/.genie/neurons/genie.md +0 -63
  108. package/.genie/neurons/review.md +0 -106
  109. package/.genie/neurons/wish.md +0 -104
  110. package/.genie/product/README.md +0 -20
  111. package/.genie/product/cli-automation.md +0 -359
  112. package/.genie/product/environment.md +0 -60
  113. package/.genie/product/mission.md +0 -60
  114. package/.genie/product/roadmap.md +0 -44
  115. package/.genie/product/tech-stack.md +0 -34
  116. package/.genie/product/templates/context-template.md +0 -218
  117. package/.genie/product/templates/qa-done-report-template.md +0 -68
  118. package/.genie/product/templates/review-report-template.md +0 -89
  119. package/.genie/product/templates/wish-template.md +0 -120
  120. package/.genie/scripts/helpers/analyze-commit.js +0 -195
  121. package/.genie/scripts/helpers/bullet-counter.js +0 -194
  122. package/.genie/scripts/helpers/bullet-find.js +0 -289
  123. package/.genie/scripts/helpers/bullet-id.js +0 -244
  124. package/.genie/scripts/helpers/check-secrets.js +0 -237
  125. package/.genie/scripts/helpers/count-tokens.js +0 -200
  126. package/.genie/scripts/helpers/create-frontmatter.js +0 -456
  127. package/.genie/scripts/helpers/detect-markers.js +0 -293
  128. package/.genie/scripts/helpers/detect-todos.js +0 -267
  129. package/.genie/scripts/helpers/detect-unlabeled-blocks.js +0 -135
  130. package/.genie/scripts/helpers/embeddings.js +0 -344
  131. package/.genie/scripts/helpers/find-empty-sections.js +0 -158
  132. package/.genie/scripts/helpers/index.js +0 -319
  133. package/.genie/scripts/helpers/validate-frontmatter.js +0 -578
  134. package/.genie/scripts/helpers/validate-links.js +0 -207
  135. package/.genie/scripts/helpers/validate-paths.js +0 -373
  136. package/.genie/spells/README.md +0 -9
  137. package/.genie/spells/ace-protocol.md +0 -118
  138. package/.genie/spells/ask-one-at-a-time.md +0 -175
  139. package/.genie/spells/backup-analyzer.md +0 -542
  140. package/.genie/spells/blocker.md +0 -12
  141. package/.genie/spells/break-things-move-fast.md +0 -56
  142. package/.genie/spells/context-candidates.md +0 -72
  143. package/.genie/spells/context-critic.md +0 -51
  144. package/.genie/spells/defer-to-expertise.md +0 -278
  145. package/.genie/spells/delegate-dont-do.md +0 -292
  146. package/.genie/spells/error-investigation-protocol.md +0 -328
  147. package/.genie/spells/evidence-based-completion.md +0 -273
  148. package/.genie/spells/experiment.md +0 -65
  149. package/.genie/spells/file-creation-protocol.md +0 -229
  150. package/.genie/spells/forge-integration.md +0 -281
  151. package/.genie/spells/forge-orchestration.md +0 -514
  152. package/.genie/spells/gather-context.md +0 -18
  153. package/.genie/spells/global-health-check.md +0 -34
  154. package/.genie/spells/global-noop-roundtrip.md +0 -25
  155. package/.genie/spells/install-genie.md +0 -1232
  156. package/.genie/spells/install.md +0 -82
  157. package/.genie/spells/investigate-before-commit.md +0 -112
  158. package/.genie/spells/know-yourself.md +0 -288
  159. package/.genie/spells/learn.md +0 -828
  160. package/.genie/spells/mcp-diagnostic-protocol.md +0 -246
  161. package/.genie/spells/mcp-first.md +0 -124
  162. package/.genie/spells/multi-step-execution.md +0 -67
  163. package/.genie/spells/orchestration-boundary-protocol.md +0 -256
  164. package/.genie/spells/orchestrator-not-implementor.md +0 -189
  165. package/.genie/spells/prompt.md +0 -746
  166. package/.genie/spells/reflect.md +0 -404
  167. package/.genie/spells/routing-decision-matrix.md +0 -368
  168. package/.genie/spells/run-in-parallel.md +0 -12
  169. package/.genie/spells/session-state-updater-example.md +0 -196
  170. package/.genie/spells/session-state-updater.md +0 -220
  171. package/.genie/spells/track-long-running-tasks.md +0 -133
  172. package/.genie/spells/troubleshoot-infrastructure.md +0 -176
  173. package/.genie/spells/upgrade-genie.md +0 -415
  174. package/.genie/spells/url-presentation-protocol.md +0 -301
  175. package/.genie/spells/wish-initiation.md +0 -158
  176. package/.genie/spells/wish-issue-linkage.md +0 -410
  177. package/.genie/spells/wish-lifecycle.md +0 -100
  178. package/.genie/state/provider-status.json +0 -3
  179. package/.genie/state/version.json +0 -16
  180. package/.genie/wishes/canonical-pgserve-pm2-supervision/WISH.md +0 -290
  181. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +0 -99
  182. package/.genie/wishes/pgserve-v2/WISH.md +0 -442
  183. package/.genie/wishes/release-system-genie-pattern/WISH.md +0 -268
  184. package/.genie/wishes/release-system-genie-pattern/validation.md +0 -205
  185. package/.gitguardian.yaml +0 -29
  186. package/.gitguardianignore +0 -16
  187. package/.github/workflows/ci.yml +0 -122
  188. package/.github/workflows/release.yml +0 -289
  189. package/.github/workflows/version.yml +0 -228
  190. package/.husky/pre-commit +0 -2
  191. package/AGENTS.md +0 -433
  192. package/CLAUDE.md +0 -1
  193. package/Makefile +0 -285
  194. package/assets/icon.ico +0 -0
  195. package/bun.lock +0 -435
  196. package/bunfig.toml +0 -28
  197. package/ecosystem.config.cjs +0 -23
  198. package/eslint.config.js +0 -63
  199. package/examples/multi-tenant-demo.js +0 -104
  200. package/install.sh +0 -123
  201. package/knip.json +0 -9
  202. package/scripts/test-bun-self-heal.sh +0 -163
  203. package/scripts/test-npx.sh +0 -60
  204. package/tests/audit.test.js +0 -189
  205. package/tests/backpressure.test.js +0 -167
  206. package/tests/benchmarks/runner.js +0 -1197
  207. package/tests/benchmarks/vector-generator.js +0 -368
  208. package/tests/cli-install.test.js +0 -322
  209. package/tests/control-db.test.js +0 -285
  210. package/tests/daemon-control.test.js +0 -171
  211. package/tests/daemon-fingerprint-integration.test.js +0 -111
  212. package/tests/daemon-pr24-regression.test.js +0 -198
  213. package/tests/fingerprint.test.js +0 -263
  214. package/tests/fixtures/240-orphan-seed.sql +0 -30
  215. package/tests/multi-tenant.test.js +0 -374
  216. package/tests/orphan-cleanup.test.js +0 -390
  217. package/tests/pg-version-regex.test.js +0 -129
  218. package/tests/quick-bench.js +0 -135
  219. package/tests/router-handshake-retry.test.js +0 -119
  220. package/tests/router-handshake-watchdog.test.js +0 -110
  221. package/tests/sdk.test.js +0 -71
  222. package/tests/stale-postmaster-pid.test.js +0 -85
  223. package/tests/stress-test.js +0 -439
  224. package/tests/sync-perf-test.js +0 -150
  225. package/tests/tcp-listen.test.js +0 -368
  226. package/tests/tenancy.test.js +0 -403
  227. package/tests/wrapper-supervision.test.js +0 -107
package/console/api.js ADDED
@@ -0,0 +1,173 @@
1
+ /* autopg console · API client.
2
+ *
3
+ * Wraps the four helper endpoints exposed by `autopg ui`:
4
+ * GET /api/settings → { settings, sources, etag, path }
5
+ * PUT /api/settings → { ok, etag } | { error: { code, message, field? } }
6
+ * POST /api/restart → { ok } | { error }
7
+ * GET /api/status → whatever `pgserve status --json` returns
8
+ *
9
+ * The latest etag from a successful GET is cached on the module so PUTs can
10
+ * send `If-Match` without the caller threading it through manually. PUT
11
+ * replies update the cached etag too so successive saves chain cleanly.
12
+ *
13
+ * Errors from the server come back as `{ error: { code, message, field? } }`.
14
+ * The wrapper raises a structured `ApiError` (with `.code`, `.field`,
15
+ * `.message`, `.status`, `.currentEtag?`) so screens can branch on the code
16
+ * without parsing strings. ETAG_MISMATCH is surfaced as a normal rejection
17
+ * with `error.code === 'ETAG_MISMATCH'` plus `error.currentEtag` so the
18
+ * Settings screen can show a "settings changed, reload?" banner.
19
+ */
20
+ (function (root) {
21
+ 'use strict';
22
+
23
+ const STATE = { etag: null };
24
+
25
+ class ApiError extends Error {
26
+ constructor({ code, message, field, status, currentEtag }) {
27
+ super(message || code || 'api error');
28
+ this.name = 'ApiError';
29
+ this.code = code || 'UNKNOWN';
30
+ if (field) this.field = field;
31
+ if (typeof status === 'number') this.status = status;
32
+ if (currentEtag) this.currentEtag = currentEtag;
33
+ }
34
+ }
35
+
36
+ async function parseJson(res) {
37
+ const text = await res.text();
38
+ if (!text) return {};
39
+ try {
40
+ return JSON.parse(text);
41
+ } catch {
42
+ return { _raw: text };
43
+ }
44
+ }
45
+
46
+ async function getSettings() {
47
+ const res = await fetch('/api/settings', {
48
+ method: 'GET',
49
+ headers: { accept: 'application/json' },
50
+ cache: 'no-store',
51
+ });
52
+ const body = await parseJson(res);
53
+ if (!res.ok) {
54
+ throw new ApiError({
55
+ code: body?.error?.code,
56
+ message: body?.error?.message,
57
+ field: body?.error?.field,
58
+ status: res.status,
59
+ });
60
+ }
61
+ if (body && body.etag) STATE.etag = body.etag;
62
+ return body;
63
+ }
64
+
65
+ async function putSettings(patch, { ifMatch } = {}) {
66
+ const etag = ifMatch ?? STATE.etag;
67
+ if (!etag) {
68
+ throw new ApiError({
69
+ code: 'PRECONDITION_REQUIRED',
70
+ message: 'no etag cached — call getSettings() before putSettings()',
71
+ status: 428,
72
+ });
73
+ }
74
+ const res = await fetch('/api/settings', {
75
+ method: 'PUT',
76
+ headers: {
77
+ 'content-type': 'application/json',
78
+ 'if-match': etag,
79
+ accept: 'application/json',
80
+ },
81
+ body: JSON.stringify(patch ?? {}),
82
+ });
83
+ const body = await parseJson(res);
84
+ if (res.status === 409) {
85
+ // Update the cached etag so the next reload has the latest.
86
+ if (body && body.currentEtag) STATE.etag = body.currentEtag;
87
+ throw new ApiError({
88
+ code: body?.error?.code || 'ETAG_MISMATCH',
89
+ message: body?.error?.message || 'settings changed on disk',
90
+ status: 409,
91
+ currentEtag: body?.currentEtag,
92
+ });
93
+ }
94
+ if (!res.ok) {
95
+ throw new ApiError({
96
+ code: body?.error?.code,
97
+ message: body?.error?.message,
98
+ field: body?.error?.field,
99
+ status: res.status,
100
+ });
101
+ }
102
+ if (body && body.etag) STATE.etag = body.etag;
103
+ return body;
104
+ }
105
+
106
+ async function restart() {
107
+ const res = await fetch('/api/restart', {
108
+ method: 'POST',
109
+ headers: { accept: 'application/json' },
110
+ });
111
+ const body = await parseJson(res);
112
+ if (!res.ok) {
113
+ throw new ApiError({
114
+ code: body?.error?.code,
115
+ message: body?.error?.message,
116
+ status: res.status,
117
+ });
118
+ }
119
+ return body;
120
+ }
121
+
122
+ async function getStatus() {
123
+ const res = await fetch('/api/status', {
124
+ method: 'GET',
125
+ headers: { accept: 'application/json' },
126
+ cache: 'no-store',
127
+ });
128
+ const body = await parseJson(res);
129
+ if (!res.ok) {
130
+ throw new ApiError({
131
+ code: body?.error?.code,
132
+ message: body?.error?.message,
133
+ status: res.status,
134
+ });
135
+ }
136
+ return body;
137
+ }
138
+
139
+ // Live pgserve stats — connections + databases. Always returns a body
140
+ // (status 200) even when the daemon is unreachable; check `body.ok`.
141
+ // Safe to poll from the topbar without try/catch error handling.
142
+ async function getStats() {
143
+ try {
144
+ const res = await fetch('/api/stats', {
145
+ method: 'GET',
146
+ headers: { accept: 'application/json' },
147
+ cache: 'no-store',
148
+ });
149
+ return await parseJson(res);
150
+ } catch (err) {
151
+ return { ok: false, reason: 'fetch-failed', message: err.message };
152
+ }
153
+ }
154
+
155
+ function getCachedEtag() {
156
+ return STATE.etag;
157
+ }
158
+
159
+ function setCachedEtag(etag) {
160
+ STATE.etag = etag || null;
161
+ }
162
+
163
+ root.AutopgApi = {
164
+ getSettings,
165
+ putSettings,
166
+ restart,
167
+ getStatus,
168
+ getStats,
169
+ getCachedEtag,
170
+ setCachedEtag,
171
+ ApiError,
172
+ };
173
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -0,0 +1,483 @@
1
+ /* autopg · app shell · routing · theme.
2
+ *
3
+ * Adapted from the design system's pgserve-console kit. Differences vs the
4
+ * pristine design:
5
+ * - identity flips from `pgserve` to `autopg` in the topbar / sidebar.
6
+ * - SECTIONS gains two RLM screens (rlm-trace, rlm-sim) so all 11 routes
7
+ * register; the wish ships them as `[ coming soon ]` placeholders.
8
+ * - theme toggle persists into `settings.ui.theme` via the autopg helper
9
+ * API, surviving reloads. Other tweaks remain ephemeral for v1.
10
+ */
11
+
12
+ const SECTIONS = [
13
+ { id: 'databases', label: 'Databases', glyph: '◫', count: '6', group: 'data' },
14
+ { id: 'tables', label: 'Tables', glyph: '▦', count: '47', group: 'data' },
15
+ { id: 'sql', label: 'SQL Editor', glyph: '›_', count: null, group: 'data' },
16
+ { id: 'optimizer', label: 'Optimizer', glyph: '◇', count: '4', group: 'ops' },
17
+ { id: 'security', label: 'Security', glyph: '✦', count: '2', group: 'ops' },
18
+ { id: 'ingress', label: 'Ingress', glyph: '⇨', count: '23', group: 'ops' },
19
+ { id: 'health', label: 'Health', glyph: '◍', count: null, group: 'ops' },
20
+ { id: 'sync', label: 'Sync & Backups',glyph: '⇆', count: null, group: 'ops' },
21
+ { id: 'rlm-trace', label: 'RLM Trace', glyph: '⌬', count: null, group: 'rlm' },
22
+ { id: 'rlm-sim', label: 'RLM Sim', glyph: '⊙', count: null, group: 'rlm' },
23
+ { id: 'settings', label: 'Settings', glyph: '⚙', count: null, group: 'system' },
24
+ ];
25
+
26
+ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
27
+ "phosphor": "green",
28
+ "density": "standard",
29
+ "crt": "subtle"
30
+ }/*EDITMODE-END*/;
31
+
32
+ const PHOSPHOR_PRESETS = {
33
+ green: { accent: '#7DD3A4', accentHover: '#97DFB6', accentPress: '#5FB988', vector: '#B69BE0', audit: '#D6A574', label: 'P1 · GREEN' },
34
+ amber: { accent: '#E8B860', accentHover: '#F2C97A', accentPress: '#C99A45', vector: '#E89E60', audit: '#D6C474', label: 'P3 · AMBER' },
35
+ cyan: { accent: '#6EE0E0', accentHover: '#8FEAEA', accentPress: '#4FBFBF', vector: '#9BB6E0', audit: '#D6B574', label: 'IBM · CYAN' },
36
+ magenta: { accent: '#E07BB8', accentHover: '#EA97C9', accentPress: '#B8628F', vector: '#B69BE0', audit: '#E0997B', label: 'SYN · MAGENTA' },
37
+ paper: { accent: '#C8C2B0', accentHover: '#D8D2C0', accentPress: '#A8A290', vector: '#B6B0A0', audit: '#D6CCA0', label: 'PAPER · MUTED' },
38
+ };
39
+
40
+ const DENSITY_PRESETS = {
41
+ compact: { space: 0.78, row: 24, base: 12, h1: 20, gap: 14, label: 'compact' },
42
+ standard: { space: 1.00, row: 28, base: 13, h1: 22, gap: 18, label: 'standard' },
43
+ roomy: { space: 1.28, row: 36, base: 14, h1: 26, gap: 26, label: 'roomy' },
44
+ };
45
+
46
+ const CRT_PRESETS = {
47
+ off: { scanline: 0, glow: 0, vignette: 0, chroma: 0, curve: 0, label: 'flat' },
48
+ subtle: { scanline: 0.04, glow: 4, vignette: 0.18, chroma: 0, curve: 0, label: 'subtle' },
49
+ heavy: { scanline: 0.10, glow: 10, vignette: 0.42, chroma: 0.6, curve: 1, label: 'heavy' },
50
+ };
51
+
52
+ /* Format helpers — small, dependency-free, scoped to the footer. */
53
+ function fmtUptime(sec) {
54
+ if (sec == null || !Number.isFinite(sec)) return '—';
55
+ const s = Math.max(0, Math.floor(sec));
56
+ const d = Math.floor(s / 86400);
57
+ const h = Math.floor((s % 86400) / 3600);
58
+ const m = Math.floor((s % 3600) / 60);
59
+ const r = s % 60;
60
+ if (d) return `${d}d ${h}h ${m}m`;
61
+ if (h) return `${h}h ${m}m`;
62
+ if (m) return `${m}m ${r}s`;
63
+ return `${r}s`;
64
+ }
65
+ function fmtBytes(b) {
66
+ if (b == null || !Number.isFinite(b)) return '—';
67
+ const u = ['B', 'KB', 'MB', 'GB', 'TB'];
68
+ let i = 0;
69
+ let v = Number(b);
70
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
71
+ return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)} ${u[i]}`;
72
+ }
73
+ function fmtNum(n) {
74
+ if (n == null || !Number.isFinite(Number(n))) return '—';
75
+ return Number(n).toLocaleString();
76
+ }
77
+
78
+ /* LiveFooter — the only place where pgserve health is reported.
79
+ * Polls /api/stats (SQL-backed: connections, databases, pg version, uptime,
80
+ * cache hit %, total xact, total db size) and /api/status (pm2-backed:
81
+ * daemon pid, restarts, memory) on a 3 s cadence (6 s when down).
82
+ *
83
+ * Compact line shows the headline numbers; the `title` attribute holds a
84
+ * multi-line health report rendered as the native browser tooltip on hover.
85
+ * No custom popover layer is needed — keeps the footer single-purpose.
86
+ */
87
+ function LiveFooter() {
88
+ const [stats, setStats] = useState(null);
89
+ const [status, setStatus] = useState(null);
90
+ const [open, setOpen] = useState(false);
91
+ const popRef = useRef(null);
92
+ const btnRef = useRef(null);
93
+
94
+ useEffect(() => {
95
+ if (!window.AutopgApi) return;
96
+ let alive = true;
97
+ let timer;
98
+ const tick = async () => {
99
+ const [s, st] = await Promise.all([
100
+ window.AutopgApi.getStats().catch(() => null),
101
+ window.AutopgApi.getStatus().catch(() => null),
102
+ ]);
103
+ if (!alive) return;
104
+ setStats(s);
105
+ setStatus(st);
106
+ // Slow the cadence when down to avoid hammering a failed daemon.
107
+ timer = setTimeout(tick, s?.ok ? 3000 : 6000);
108
+ };
109
+ tick();
110
+ return () => { alive = false; if (timer) clearTimeout(timer); };
111
+ }, []);
112
+
113
+ // Click-outside + Escape to close the popover.
114
+ useEffect(() => {
115
+ if (!open) return undefined;
116
+ const onDown = (e) => {
117
+ if (popRef.current?.contains(e.target)) return;
118
+ if (btnRef.current?.contains(e.target)) return;
119
+ setOpen(false);
120
+ };
121
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
122
+ document.addEventListener('mousedown', onDown);
123
+ document.addEventListener('keydown', onKey);
124
+ return () => {
125
+ document.removeEventListener('mousedown', onDown);
126
+ document.removeEventListener('keydown', onKey);
127
+ };
128
+ }, [open]);
129
+
130
+ const live = !!stats?.ok;
131
+ const pid = status?.pid;
132
+ const restarts = status?.restarts;
133
+ const daemonMem = status?.memory ?? status?.mem;
134
+ const ts = stats?.ts ? new Date(stats.ts).toTimeString().slice(0, 8) : '—';
135
+
136
+ // Multiline health report — same shape used inside the popover <pre>.
137
+ const healthReport = (() => {
138
+ const lines = [];
139
+ lines.push(`autopg ${stats?.autopg?.version ?? '—'}`);
140
+ lines.push(`port ${stats?.port ?? '—'}`);
141
+ lines.push('');
142
+ if (live) {
143
+ lines.push('postgres');
144
+ lines.push(` version ${stats.pg.version}`);
145
+ lines.push(` uptime ${fmtUptime(stats.pg.uptimeSec)}`);
146
+ lines.push(` connections ${stats.connections}`);
147
+ lines.push(` databases ${stats.databases}`);
148
+ lines.push(` size ${fmtBytes(stats.pg.sizeBytes)}`);
149
+ lines.push(` cache hit ${stats.pg.cacheHitPct == null ? '—' : `${stats.pg.cacheHitPct}%`}`);
150
+ lines.push(` total xact ${fmtNum(stats.pg.txTotal)}`);
151
+ } else {
152
+ lines.push('postgres');
153
+ lines.push(` status ${stats?.reason || 'unreachable'}`);
154
+ if (stats?.message) lines.push(` error ${stats.message}`);
155
+ }
156
+ lines.push('');
157
+ if (status && !status.error) {
158
+ lines.push('daemon (pm2)');
159
+ lines.push(` pid ${pid ?? '—'}`);
160
+ lines.push(` restarts ${restarts ?? '—'}`);
161
+ if (daemonMem) lines.push(` memory ${typeof daemonMem === 'number' ? fmtBytes(daemonMem) : daemonMem}`);
162
+ } else {
163
+ lines.push('daemon (pm2)');
164
+ lines.push(' not supervised — running in foreground or unreachable');
165
+ }
166
+ lines.push('');
167
+ lines.push(`last sample ${ts}`);
168
+ return lines.join('\n');
169
+ })();
170
+
171
+ const liveLabel = live ? 'live' : 'daemon down';
172
+ const btnTitle = live ? 'click to open health report' : 'daemon unreachable — click for details';
173
+
174
+ return (
175
+ <div className="footer">
176
+ {/* meta strip — left-aligned facts in left-to-right reading order */}
177
+ <span>autopg <span style={{ color: 'var(--accent)' }}>{stats?.autopg?.version ?? '—'}</span></span>
178
+ {pid != null && (
179
+ <>
180
+ <span className="sep">/</span>
181
+ <span>pid <span style={{ color: 'var(--accent)' }}>{pid}</span></span>
182
+ </>
183
+ )}
184
+ {live && (
185
+ <>
186
+ <span className="sep">/</span>
187
+ <span>pg {stats.pg.version}</span>
188
+ <span className="sep">/</span>
189
+ <span>{stats.connections} conns · {stats.databases} dbs</span>
190
+ {stats.pg.cacheHitPct != null && (
191
+ <>
192
+ <span className="sep">/</span>
193
+ <span>cache · <span className="ok">{stats.pg.cacheHitPct}%</span></span>
194
+ </>
195
+ )}
196
+ </>
197
+ )}
198
+ {!live && stats?.reason && (
199
+ <>
200
+ <span className="sep">/</span>
201
+ <span>{stats.reason}</span>
202
+ </>
203
+ )}
204
+
205
+ {/* live-status button — clickable, right-aligned, opens health popover */}
206
+ <button
207
+ ref={btnRef}
208
+ className={`live-btn ${live ? 'on' : 'down'}`}
209
+ onClick={() => setOpen((v) => !v)}
210
+ title={btnTitle}
211
+ aria-expanded={open}
212
+ aria-haspopup="dialog"
213
+ style={{ marginLeft: 'auto' }}
214
+ >
215
+ <span className="dot" /> {liveLabel}
216
+ </button>
217
+
218
+ {open && (
219
+ <div ref={popRef} className="live-popover" role="dialog" aria-label="pgserve health report">
220
+ <div className="head">
221
+ <span>health · last sample {ts}</span>
222
+ <button className="close" onClick={() => setOpen(false)} aria-label="close">×</button>
223
+ </div>
224
+ <pre>{healthReport}</pre>
225
+ </div>
226
+ )}
227
+ </div>
228
+ );
229
+ }
230
+
231
+ function App() {
232
+ const [route, setRoute] = useState('settings');
233
+ const [theme, setThemeLocal] = useState('mdr');
234
+ const [now, setNow] = useState('00:00:00');
235
+ const [tw, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
236
+ const [bootError, setBootError] = useState(null);
237
+
238
+ /* On boot: pull current settings to seed theme + tell the Settings screen
239
+ * the path the daemon is actually reading from. The error is non-fatal —
240
+ * the rest of the console still renders so operators can navigate. */
241
+ useEffect(() => {
242
+ let cancelled = false;
243
+ if (!window.AutopgApi) return undefined;
244
+ window.AutopgApi.getSettings()
245
+ .then((data) => {
246
+ if (cancelled) return;
247
+ const t = data?.settings?.ui?.theme;
248
+ if (t === 'mdr' || t === 'lumon') setThemeLocal(t);
249
+ })
250
+ .catch((err) => {
251
+ if (cancelled) return;
252
+ // eslint-disable-next-line no-console
253
+ console.warn('autopg console: initial settings load failed', err);
254
+ setBootError(err.message || String(err));
255
+ });
256
+ return () => {
257
+ cancelled = true;
258
+ };
259
+ }, []);
260
+
261
+ const setTheme = useMemo(() => async (next) => {
262
+ setThemeLocal(next);
263
+ if (!window.AutopgApi) return;
264
+ try {
265
+ await window.AutopgApi.putSettings({ ui: { theme: next } });
266
+ } catch (err) {
267
+ // eslint-disable-next-line no-console
268
+ console.warn('autopg console: theme persist failed', err);
269
+ }
270
+ }, []);
271
+
272
+ useEffect(() => {
273
+ document.documentElement.dataset.theme = theme;
274
+ }, [theme]);
275
+
276
+ useEffect(() => {
277
+ const r = document.documentElement;
278
+ const phos = PHOSPHOR_PRESETS[tw.phosphor] || PHOSPHOR_PRESETS.green;
279
+ const den = DENSITY_PRESETS[tw.density] || DENSITY_PRESETS.standard;
280
+ const crt = CRT_PRESETS[tw.crt] || CRT_PRESETS.subtle;
281
+
282
+ if (theme === 'mdr') {
283
+ r.style.setProperty('--c-accent', phos.accent);
284
+ r.style.setProperty('--c-accent-hover', phos.accentHover);
285
+ r.style.setProperty('--c-accent-press', phos.accentPress);
286
+ r.style.setProperty('--c-vector', phos.vector);
287
+ r.style.setProperty('--c-audit', phos.audit);
288
+ } else {
289
+ ['--c-accent', '--c-accent-hover', '--c-accent-press', '--c-vector', '--c-audit']
290
+ .forEach(p => r.style.removeProperty(p));
291
+ }
292
+
293
+ r.style.setProperty('--space-1', `${4 * den.space}px`);
294
+ r.style.setProperty('--space-2', `${8 * den.space}px`);
295
+ r.style.setProperty('--space-3', `${12 * den.space}px`);
296
+ r.style.setProperty('--space-4', `${16 * den.space}px`);
297
+ r.style.setProperty('--space-5', `${24 * den.space}px`);
298
+ r.style.setProperty('--space-6', `${32 * den.space}px`);
299
+ r.style.setProperty('--row-control', `${den.row}px`);
300
+ r.style.setProperty('--row-table', `${den.row + 4}px`);
301
+ r.style.setProperty('--t-md', `${den.base}px`);
302
+ r.style.setProperty('--t-lg', `${den.base + 2}px`);
303
+ r.style.setProperty('--t-2xl', `${den.h1}px`);
304
+
305
+ r.style.setProperty('--scanline-opacity', String(crt.scanline));
306
+ r.style.setProperty('--phosphor-glow', `${crt.glow}px`);
307
+ r.style.setProperty('--crt-vignette', String(crt.vignette));
308
+ r.style.setProperty('--crt-chroma', `${crt.chroma}px`);
309
+ r.dataset.crt = tw.crt;
310
+ r.dataset.density = tw.density;
311
+ r.dataset.phosphor = tw.phosphor;
312
+ }, [tw.phosphor, tw.density, tw.crt, theme]);
313
+
314
+ useEffect(() => {
315
+ const tick = () => {
316
+ const d = new Date();
317
+ setNow(d.toTimeString().slice(0, 8));
318
+ };
319
+ tick();
320
+ const t = setInterval(tick, 1000);
321
+ return () => clearInterval(t);
322
+ }, []);
323
+
324
+ const groups = useMemo(() => ([
325
+ { id: 'data', label: 'data', items: SECTIONS.filter(s => s.group === 'data') },
326
+ { id: 'ops', label: 'ops', items: SECTIONS.filter(s => s.group === 'ops') },
327
+ { id: 'rlm', label: 'rlm', items: SECTIONS.filter(s => s.group === 'rlm') },
328
+ { id: 'system', label: 'system', items: SECTIONS.filter(s => s.group === 'system') },
329
+ ]), []);
330
+
331
+ // Every entry MUST be a stable component reference. The previous
332
+ // `settings: () => window.ScreenSettings({ theme, setTheme })` wrapper was
333
+ // recreated on every App re-render (the 1Hz clock alone re-renders the
334
+ // whole tree), making React treat each tick as a new component type and
335
+ // unmount/remount ScreenSettings — losing scroll, focus, and the
336
+ // /api/settings fetch state. Direct refs only; pass props in JSX below.
337
+ const Screen = {
338
+ databases: window.ScreenDatabases,
339
+ tables: window.ScreenTables,
340
+ sql: window.ScreenSQL,
341
+ optimizer: window.ScreenOptimizer,
342
+ security: window.ScreenSecurity,
343
+ ingress: window.ScreenIngress,
344
+ health: window.ScreenHealth,
345
+ sync: window.ScreenSync,
346
+ 'rlm-trace': window.ScreenRlmTrace,
347
+ 'rlm-sim': window.ScreenRlmSim,
348
+ settings: window.ScreenSettings,
349
+ }[route];
350
+
351
+ return (
352
+ <div className="app">
353
+ {/* topbar */}
354
+ <div className="topbar">
355
+ <div className="wm">
356
+ <span className="cur">▌</span><span>autopg</span>
357
+ </div>
358
+ <div className="meta">
359
+ <span className="sep">/</span>
360
+ <span>console · v1</span>
361
+ <span className="sep">·</span>
362
+ <span style={{ color: 'var(--accent)' }}>{route}</span>
363
+ <span className="sep">·</span>
364
+ <span>{now}</span>
365
+ </div>
366
+ <div className="right">
367
+ {bootError && <span className="pill" style={{ color: 'var(--err, #d66)' }}>api · {bootError}</span>}
368
+ <span className="pill"><span className="dot"></span> ~/.autopg</span>
369
+ <div className="theme-switch">
370
+ <button
371
+ onClick={() => setTheme(theme === 'mdr' ? 'lumon' : 'mdr')}
372
+ title={theme === 'mdr' ? 'switch to light' : 'switch to dark'}
373
+ aria-label={theme === 'mdr' ? 'switch to light theme' : 'switch to dark theme'}
374
+ >
375
+ {theme === 'mdr' ? (
376
+ /* moon — currently dark */
377
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
378
+ <path d="M14 9.5A6 6 0 1 1 6.5 2a5 5 0 0 0 7.5 7.5z" />
379
+ </svg>
380
+ ) : (
381
+ /* sun — currently light */
382
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
383
+ <circle cx="8" cy="8" r="3" />
384
+ <path d="M8 1.5v2 M8 12.5v2 M14.5 8h-2 M3.5 8h-2 M12.6 3.4l-1.4 1.4 M4.8 11.2l-1.4 1.4 M12.6 12.6l-1.4-1.4 M4.8 4.8l-1.4-1.4" />
385
+ </svg>
386
+ )}
387
+ </button>
388
+ </div>
389
+ </div>
390
+ </div>
391
+
392
+ {/* sidebar */}
393
+ <div className="sidebar">
394
+ <div style={{ padding: '0 18px 12px', fontSize: 10, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--text-dim)' }}>
395
+ ~/.autopg
396
+ </div>
397
+ {groups.map(g => (
398
+ <div className="group" key={g.id}>
399
+ <div className="group-label">[ {g.label} ]</div>
400
+ {g.items.map(s => (
401
+ <div
402
+ key={s.id}
403
+ className={cx('nav-item', route === s.id && 'on')}
404
+ onClick={() => setRoute(s.id)}
405
+ data-screen-label={s.label}
406
+ >
407
+ <span className="glyph">{s.glyph}</span>
408
+ <span>{s.label}</span>
409
+ {s.count && <span className="count">{s.count}</span>}
410
+ </div>
411
+ ))}
412
+ </div>
413
+ ))}
414
+ <div style={{ padding: '14px 18px', fontSize: 10, color: 'var(--text-dim)', borderTop: '1px solid var(--line)', marginTop: 14 }}>
415
+ <div style={{ marginBottom: 4 }}>autopg · settings vertical</div>
416
+ <div>health vertical · next wish</div>
417
+ </div>
418
+ </div>
419
+
420
+ {/* main */}
421
+ <div className="main">
422
+ {Screen
423
+ ? <Screen theme={theme} setTheme={setTheme} />
424
+ : <div className="page">loading…</div>}
425
+ </div>
426
+
427
+ {/* footer — live pgserve health line; full report on hover */}
428
+ <LiveFooter />
429
+
430
+ {/* CRT overlay */}
431
+ <div className="crt-overlay" aria-hidden="true">
432
+ <div className="crt-scan"></div>
433
+ <div className="crt-vig"></div>
434
+ <div className="crt-chroma"></div>
435
+ </div>
436
+
437
+ <window.TweaksPanel title="Tweaks">
438
+ <window.TweakSection label="Phosphor" />
439
+ <window.TweakSelect
440
+ label="Tube color"
441
+ value={tw.phosphor}
442
+ options={[
443
+ { value: 'green', label: 'P1 · green (default)' },
444
+ { value: 'amber', label: 'P3 · amber' },
445
+ { value: 'cyan', label: 'IBM · cyan' },
446
+ { value: 'magenta', label: 'Synthwave · magenta' },
447
+ { value: 'paper', label: 'Paper · muted' },
448
+ ]}
449
+ onChange={(v) => setTweak('phosphor', v)}
450
+ />
451
+ <div style={{ fontSize: 10, color: 'rgba(41,38,27,.55)', marginTop: -4, lineHeight: 1.4 }}>
452
+ Recolors accent, vector, and audit families across every screen. Lumon (light) keeps its institutional blue.
453
+ </div>
454
+
455
+ <window.TweakSection label="Density" />
456
+ <window.TweakRadio
457
+ label="Layout"
458
+ value={tw.density}
459
+ options={[
460
+ { value: 'compact', label: 'Compact' },
461
+ { value: 'standard', label: 'Standard' },
462
+ { value: 'roomy', label: 'Roomy' },
463
+ ]}
464
+ onChange={(v) => setTweak('density', v)}
465
+ />
466
+
467
+ <window.TweakSection label="CRT intensity" />
468
+ <window.TweakRadio
469
+ label="Tube"
470
+ value={tw.crt}
471
+ options={[
472
+ { value: 'off', label: 'Flat' },
473
+ { value: 'subtle', label: 'Subtle' },
474
+ { value: 'heavy', label: 'Heavy' },
475
+ ]}
476
+ onChange={(v) => setTweak('crt', v)}
477
+ />
478
+ </window.TweaksPanel>
479
+ </div>
480
+ );
481
+ }
482
+
483
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);