@kazurolabs/psql-explorer 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/RELEASES.md +87 -0
- package/dist/app.js +39 -0
- package/dist/cli.js +307 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +388 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +324 -0
- package/dist/config/resolve-environment.js +112 -0
- package/dist/config/resolvers/env.js +12 -0
- package/dist/config/resolvers/file.js +24 -0
- package/dist/config/resolvers/groups.js +1 -0
- package/dist/config/resolvers/index.js +49 -0
- package/dist/config/resolvers/literal.js +7 -0
- package/dist/config/resolvers/prompt.js +159 -0
- package/dist/config/resolvers/stored.js +13 -0
- package/dist/config/resolvers/types.js +18 -0
- package/dist/config/resolvers/vault.js +283 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/source.js +105 -0
- package/dist/db/connect.js +55 -0
- package/dist/db/identifiers.js +6 -0
- package/dist/db/paths.js +14 -0
- package/dist/db/store.js +323 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +140 -0
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +414 -0
- package/dist/explorer/connect-entry.js +42 -0
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +37 -0
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/format.js +134 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +64 -0
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +122 -0
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +229 -0
- package/dist/screens/explorer.js +145 -0
- package/dist/screens/header.js +8 -0
- package/dist/screens/index.js +13 -0
- package/dist/screens/new-connection.js +178 -0
- package/dist/screens/panes/common.js +35 -0
- package/dist/screens/panes/history.js +36 -0
- package/dist/screens/panes/info.js +61 -0
- package/dist/screens/panes/query.js +113 -0
- package/dist/screens/panes/table.js +178 -0
- package/dist/screens/panes/tables.js +142 -0
- package/dist/screens/prompt-modal.js +18 -0
- package/dist/screens/record-view.js +42 -0
- package/dist/screens/result-grid.js +126 -0
- package/dist/screens/scroll.js +12 -0
- package/dist/screens/splash.js +78 -0
- package/dist/screens/terminal.js +25 -0
- package/dist/screens/types.js +1 -0
- package/dist/services.js +1 -0
- package/dist/startup.js +84 -0
- package/dist/web/server.js +988 -0
- package/package.json +69 -5
- package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
- package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-ClA70PLR.js +1 -0
- package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
- package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
- package/web/dist/assets/copy-CCXzeW-s.js +1 -0
- package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
- package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
- package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
- package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
- package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
- package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
- package/web/dist/assets/index-CDcnUacl.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
- package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
- package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
- package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
- package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
- package/web/dist/assets/route-DO6IZ7of.js +15 -0
- package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
- package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
- package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
- package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
- package/web/dist/assets/table-DfMz2f8M.js +1 -0
- package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
- package/web/dist/assets/zone-D9dXk8tn.js +5 -0
- package/web/dist/index.html +26 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import Cursor from 'pg-cursor';
|
|
2
|
+
import { createClient } from '../db/connect.js';
|
|
3
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
4
|
+
import { shortVersion } from '../db/tester.js';
|
|
5
|
+
export const DEFAULT_MAX_ROWS = 1000;
|
|
6
|
+
/** The server refused to change anything because the session is read only (SQLSTATE 25006). */
|
|
7
|
+
export const isReadOnlyError = (error) => error?.code === '25006';
|
|
8
|
+
// The extended protocol takes one statement at a time (SQLSTATE 42601 with this message).
|
|
9
|
+
const isMultipleCommands = (error) => error instanceof Error && error.message.includes('cannot insert multiple commands');
|
|
10
|
+
/** Why a statement failed, in a form ready to show. */
|
|
11
|
+
export function describeQueryError(error) {
|
|
12
|
+
if (isMultipleCommands(error))
|
|
13
|
+
return 'Run one statement at a time';
|
|
14
|
+
if (isReadOnlyError(error)) {
|
|
15
|
+
return `${error instanceof Error ? error.message : String(error)} (this environment is read only)`;
|
|
16
|
+
}
|
|
17
|
+
const pgError = error;
|
|
18
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
19
|
+
const extra = [
|
|
20
|
+
pgError?.detail ? `Detail: ${pgError.detail}` : undefined,
|
|
21
|
+
pgError?.hint ? `Hint: ${pgError.hint}` : undefined,
|
|
22
|
+
].filter(Boolean);
|
|
23
|
+
return extra.length > 0 ? `${message}. ${extra.join('. ')}` : message;
|
|
24
|
+
}
|
|
25
|
+
const readCursor = (cursor, count) => new Promise((resolve, reject) => {
|
|
26
|
+
cursor.read(count, (error, rows, result) => {
|
|
27
|
+
if (error)
|
|
28
|
+
reject(error);
|
|
29
|
+
else
|
|
30
|
+
resolve({ rows, result });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
/** Connects to the database and returns a session on it. Throws what the driver throws. */
|
|
34
|
+
export const openSession = async (params, options = {}) => {
|
|
35
|
+
const client = createClient(params, options);
|
|
36
|
+
await client.connect();
|
|
37
|
+
let serverVersion;
|
|
38
|
+
try {
|
|
39
|
+
const result = await client.query('SHOW server_version');
|
|
40
|
+
serverVersion = shortVersion(result.rows[0]?.server_version ?? '');
|
|
41
|
+
// Tables of the environment's schema can be named without it, as in the Tables tab.
|
|
42
|
+
if (params.schema !== 'public') {
|
|
43
|
+
await client.query("SELECT pg_catalog.set_config('search_path', $1, false)", [
|
|
44
|
+
`${quoteIdentifier(params.schema)}, public`,
|
|
45
|
+
]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
await client.end().catch(() => undefined);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const typeNames = new Map();
|
|
53
|
+
const namesOf = async (fields) => {
|
|
54
|
+
const missing = [...new Set(fields.map((field) => field.dataTypeID))].filter((oid) => !typeNames.has(oid));
|
|
55
|
+
if (missing.length > 0) {
|
|
56
|
+
const { rows } = await client.query('SELECT oid::int8 AS oid, pg_catalog.format_type(oid, NULL) AS name FROM pg_catalog.pg_type WHERE oid = ANY($1::oid[])', [missing]);
|
|
57
|
+
for (const row of rows)
|
|
58
|
+
typeNames.set(Number(row.oid), row.name);
|
|
59
|
+
}
|
|
60
|
+
return fields.map((field) => ({
|
|
61
|
+
name: field.name,
|
|
62
|
+
type: typeNames.get(field.dataTypeID) ?? String(field.dataTypeID),
|
|
63
|
+
}));
|
|
64
|
+
};
|
|
65
|
+
// One statement at a time: a cursor holds the connection until it is closed.
|
|
66
|
+
let queue = Promise.resolve();
|
|
67
|
+
const serial = (work) => {
|
|
68
|
+
const next = queue.then(work, work);
|
|
69
|
+
queue = next.catch(() => undefined);
|
|
70
|
+
return next;
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
params,
|
|
74
|
+
readOnly: options.readOnly ?? false,
|
|
75
|
+
serverVersion,
|
|
76
|
+
run: (sql, values = [], { maxRows = DEFAULT_MAX_ROWS } = {}) => serial(async () => {
|
|
77
|
+
const started = performance.now();
|
|
78
|
+
const cursor = client.query(new Cursor(sql, [...values], { rowMode: 'array' }));
|
|
79
|
+
let read;
|
|
80
|
+
try {
|
|
81
|
+
// One more than wanted says whether there are more.
|
|
82
|
+
read = await readCursor(cursor, maxRows + 1);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
// A cursor stopped before its end keeps the connection busy: close it before
|
|
86
|
+
// asking anything else, such as the names of the column types.
|
|
87
|
+
await cursor.close().catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
const { rows, result } = read;
|
|
90
|
+
const durationMs = performance.now() - started;
|
|
91
|
+
const truncated = rows.length > maxRows;
|
|
92
|
+
return {
|
|
93
|
+
command: result.command ?? '',
|
|
94
|
+
columns: await namesOf(result.fields ?? []),
|
|
95
|
+
rows: truncated ? rows.slice(0, maxRows) : rows,
|
|
96
|
+
// A cursor that was not read to the end has no command tag yet.
|
|
97
|
+
rowCount: truncated ? null : (result.rowCount ?? rows.length),
|
|
98
|
+
truncated,
|
|
99
|
+
durationMs,
|
|
100
|
+
};
|
|
101
|
+
}),
|
|
102
|
+
rows: (sql, values = []) => serial(async () => (await client.query(sql, [...values])).rows),
|
|
103
|
+
// From a connection of its own, since this one is busy with the statement to stop.
|
|
104
|
+
cancel: async () => {
|
|
105
|
+
const pid = client.processID;
|
|
106
|
+
if (!pid)
|
|
107
|
+
return;
|
|
108
|
+
const other = createClient(params, { ...options, statementTimeoutMs: 5000, readOnly: true });
|
|
109
|
+
try {
|
|
110
|
+
await other.connect();
|
|
111
|
+
await other.query('SELECT pg_catalog.pg_cancel_backend($1)', [pid]);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// The statement may have finished meanwhile, or the server cannot be reached.
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await other.end().catch(() => undefined);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
close: () => serial(() => client.end().catch(() => undefined)),
|
|
121
|
+
};
|
|
122
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { qualified } from './catalog.js';
|
|
2
|
+
const utc = (column) => `to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`;
|
|
3
|
+
const kinds = {
|
|
4
|
+
r: 'table',
|
|
5
|
+
p: 'partitioned',
|
|
6
|
+
m: 'materialized',
|
|
7
|
+
};
|
|
8
|
+
const count = (value) => (value === null ? null : Number(value));
|
|
9
|
+
const flag = (value) => value === 't';
|
|
10
|
+
/**
|
|
11
|
+
* Tables and materialized views with their sizes and upkeep, biggest first. The schemas of the
|
|
12
|
+
* system are left out; `table` narrows it to one.
|
|
13
|
+
*/
|
|
14
|
+
export async function listTableStats(session, table) {
|
|
15
|
+
const rows = await session.rows(`WITH rel AS (
|
|
16
|
+
SELECT c.oid, n.nspname AS schema, c.relname AS name, c.relkind AS kind, c.reltuples,
|
|
17
|
+
c.relpages,
|
|
18
|
+
pg_catalog.pg_relation_size(c.oid) AS heap_bytes,
|
|
19
|
+
pg_catalog.pg_table_size(c.oid) AS table_and_toast_bytes,
|
|
20
|
+
pg_catalog.pg_indexes_size(c.oid) AS index_bytes,
|
|
21
|
+
CASE WHEN c.reltoastrelid = 0 THEN 0
|
|
22
|
+
ELSE pg_catalog.pg_total_relation_size(c.reltoastrelid) END AS toast_bytes,
|
|
23
|
+
pg_catalog.pg_total_relation_size(c.oid) AS total_bytes,
|
|
24
|
+
-- The bytes a row needs: its header, its pointer in the page, and its columns.
|
|
25
|
+
(SELECT 24 + 4 + sum(s.avg_width) FROM pg_catalog.pg_stats s
|
|
26
|
+
WHERE s.schemaname = n.nspname AND s.tablename = c.relname
|
|
27
|
+
AND NOT s.inherited) AS row_bytes,
|
|
28
|
+
coalesce((SELECT option_value::numeric
|
|
29
|
+
FROM pg_catalog.pg_options_to_table(c.reloptions)
|
|
30
|
+
WHERE option_name = 'fillfactor'), 100) AS fillfactor
|
|
31
|
+
FROM pg_catalog.pg_class c
|
|
32
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
33
|
+
WHERE c.relkind IN ('r', 'p', 'm')
|
|
34
|
+
AND n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
|
|
35
|
+
AND ($1::text IS NULL OR c.oid = $1::regclass)
|
|
36
|
+
)
|
|
37
|
+
SELECT r.schema, r.name, r.kind,
|
|
38
|
+
CASE WHEN r.reltuples < 0 THEN NULL ELSE r.reltuples::int8 END AS estimated_rows,
|
|
39
|
+
r.table_and_toast_bytes - r.toast_bytes AS table_bytes,
|
|
40
|
+
r.index_bytes, r.toast_bytes, r.total_bytes,
|
|
41
|
+
CASE WHEN r.row_bytes IS NULL OR r.reltuples < 0 OR r.kind = 'p' THEN NULL
|
|
42
|
+
ELSE greatest(r.heap_bytes - ceil(r.reltuples * r.row_bytes
|
|
43
|
+
/ ((current_setting('block_size')::int - 24) * r.fillfactor / 100))
|
|
44
|
+
* current_setting('block_size')::int, 0)::int8
|
|
45
|
+
END AS bloat_bytes,
|
|
46
|
+
s.n_live_tup AS live_rows, s.n_dead_tup AS dead_rows,
|
|
47
|
+
s.seq_scan AS seq_scans, s.idx_scan AS index_scans,
|
|
48
|
+
${utc('s.last_vacuum')} AS last_vacuum, ${utc('s.last_autovacuum')} AS last_autovacuum,
|
|
49
|
+
${utc('s.last_analyze')} AS last_analyze, ${utc('s.last_autoanalyze')} AS last_autoanalyze
|
|
50
|
+
FROM rel r
|
|
51
|
+
LEFT JOIN pg_catalog.pg_stat_all_tables s ON s.relid = r.oid
|
|
52
|
+
ORDER BY r.total_bytes DESC, r.schema, r.name`, [table ? qualified(table) : null]);
|
|
53
|
+
return rows.map((row) => ({
|
|
54
|
+
schema: row.schema ?? '',
|
|
55
|
+
name: row.name ?? '',
|
|
56
|
+
kind: kinds[row.kind ?? ''] ?? 'table',
|
|
57
|
+
estimatedRows: count(row.estimated_rows),
|
|
58
|
+
tableBytes: Number(row.table_bytes),
|
|
59
|
+
indexBytes: Number(row.index_bytes),
|
|
60
|
+
toastBytes: Number(row.toast_bytes),
|
|
61
|
+
totalBytes: Number(row.total_bytes),
|
|
62
|
+
bloatBytes: count(row.bloat_bytes),
|
|
63
|
+
liveRows: count(row.live_rows),
|
|
64
|
+
deadRows: count(row.dead_rows),
|
|
65
|
+
seqScans: count(row.seq_scans),
|
|
66
|
+
indexScans: count(row.index_scans),
|
|
67
|
+
lastVacuum: row.last_vacuum,
|
|
68
|
+
lastAutovacuum: row.last_autovacuum,
|
|
69
|
+
lastAnalyze: row.last_analyze,
|
|
70
|
+
lastAutoanalyze: row.last_autoanalyze,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Every index of the user's tables (or of one table) with its size, how often it was used, and the
|
|
75
|
+
* other indexes it duplicates: the same table, columns, operator classes, expressions and
|
|
76
|
+
* condition. Biggest first.
|
|
77
|
+
*/
|
|
78
|
+
export async function listIndexStats(session, table) {
|
|
79
|
+
const rows = await session.rows(`SELECT n.nspname AS schema, t.relname AS "table", i.relname AS name,
|
|
80
|
+
pg_catalog.pg_get_indexdef(x.indexrelid) AS definition,
|
|
81
|
+
pg_catalog.pg_relation_size(x.indexrelid) AS bytes,
|
|
82
|
+
x.indisprimary AS "primary", x.indisunique AS "unique", x.indisvalid AS valid,
|
|
83
|
+
EXISTS (SELECT 1 FROM pg_catalog.pg_constraint k WHERE k.conindid = x.indexrelid
|
|
84
|
+
AND k.conrelid = x.indrelid AND k.contype IN ('p', 'u', 'x')) AS "constraint",
|
|
85
|
+
coalesce(s.idx_scan, 0) AS scans,
|
|
86
|
+
array_to_json(ARRAY(SELECT o.relname FROM pg_catalog.pg_index y
|
|
87
|
+
JOIN pg_catalog.pg_class o ON o.oid = y.indexrelid
|
|
88
|
+
WHERE y.indrelid = x.indrelid AND y.indexrelid <> x.indexrelid
|
|
89
|
+
AND y.indkey::text = x.indkey::text AND y.indclass::text = x.indclass::text
|
|
90
|
+
AND y.indcollation::text = x.indcollation::text
|
|
91
|
+
AND o.relam = i.relam
|
|
92
|
+
AND coalesce(pg_catalog.pg_get_expr(y.indexprs, y.indrelid), '')
|
|
93
|
+
= coalesce(pg_catalog.pg_get_expr(x.indexprs, x.indrelid), '')
|
|
94
|
+
AND coalesce(pg_catalog.pg_get_expr(y.indpred, y.indrelid), '')
|
|
95
|
+
= coalesce(pg_catalog.pg_get_expr(x.indpred, x.indrelid), '')
|
|
96
|
+
ORDER BY o.relname))::text AS duplicate_of
|
|
97
|
+
FROM pg_catalog.pg_index x
|
|
98
|
+
JOIN pg_catalog.pg_class i ON i.oid = x.indexrelid
|
|
99
|
+
JOIN pg_catalog.pg_class t ON t.oid = x.indrelid
|
|
100
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
|
|
101
|
+
LEFT JOIN pg_catalog.pg_stat_all_indexes s ON s.indexrelid = x.indexrelid
|
|
102
|
+
WHERE t.relkind IN ('r', 'p', 'm')
|
|
103
|
+
AND n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
|
|
104
|
+
AND ($1::text IS NULL OR x.indrelid = $1::regclass)
|
|
105
|
+
ORDER BY bytes DESC, n.nspname, t.relname, i.relname`, [table ? qualified(table) : null]);
|
|
106
|
+
return rows.map((row) => ({
|
|
107
|
+
schema: row.schema ?? '',
|
|
108
|
+
table: row.table ?? '',
|
|
109
|
+
name: row.name ?? '',
|
|
110
|
+
definition: row.definition ?? '',
|
|
111
|
+
bytes: Number(row.bytes),
|
|
112
|
+
primary: flag(row.primary),
|
|
113
|
+
unique: flag(row.unique),
|
|
114
|
+
constraint: flag(row.constraint),
|
|
115
|
+
valid: flag(row.valid),
|
|
116
|
+
scans: Number(row.scans),
|
|
117
|
+
duplicateOf: JSON.parse(row.duplicate_of ?? '[]'),
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
/** The sizes and upkeep of every table and index of the database, biggest first. */
|
|
121
|
+
export async function databaseStats(session) {
|
|
122
|
+
const [row] = await session.rows(`SELECT current_database() AS database,
|
|
123
|
+
pg_catalog.pg_database_size(current_database()) AS bytes,
|
|
124
|
+
(SELECT ${utc('stats_reset')} FROM pg_catalog.pg_stat_database
|
|
125
|
+
WHERE datname = current_database()) AS reset`);
|
|
126
|
+
const tables = await listTableStats(session);
|
|
127
|
+
const indexes = await listIndexStats(session);
|
|
128
|
+
return {
|
|
129
|
+
database: row?.database ?? '',
|
|
130
|
+
databaseBytes: Number(row?.bytes ?? 0),
|
|
131
|
+
statsReset: row?.reset ?? null,
|
|
132
|
+
tables,
|
|
133
|
+
indexes,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Runs `work` with the session's statement timeout set to `ms`, and puts the old one back. */
|
|
2
|
+
export async function withStatementTimeout(session, ms, work) {
|
|
3
|
+
const [before] = await session.rows("SELECT current_setting('statement_timeout') AS timeout");
|
|
4
|
+
await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [String(ms)]);
|
|
5
|
+
try {
|
|
6
|
+
return await work();
|
|
7
|
+
}
|
|
8
|
+
finally {
|
|
9
|
+
await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [
|
|
10
|
+
before?.timeout ?? '0',
|
|
11
|
+
]);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ResolveError } from './config/resolvers/types.js';
|
|
2
|
+
export function createPromptQueue() {
|
|
3
|
+
const queue = [];
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
const notify = () => {
|
|
6
|
+
for (const listener of listeners)
|
|
7
|
+
listener();
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
ask(request) {
|
|
11
|
+
const { envName, field, masked, challenge, signal } = request;
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const remove = () => {
|
|
14
|
+
const index = queue.indexOf(pending);
|
|
15
|
+
if (index >= 0)
|
|
16
|
+
queue.splice(index, 1);
|
|
17
|
+
signal?.removeEventListener('abort', cancel);
|
|
18
|
+
notify();
|
|
19
|
+
};
|
|
20
|
+
const cancel = () => {
|
|
21
|
+
remove();
|
|
22
|
+
reject(new ResolveError(envName, field, 'cancelled'));
|
|
23
|
+
};
|
|
24
|
+
const pending = {
|
|
25
|
+
envName,
|
|
26
|
+
field,
|
|
27
|
+
masked,
|
|
28
|
+
...(challenge === undefined ? {} : { challenge }),
|
|
29
|
+
submit(value) {
|
|
30
|
+
remove();
|
|
31
|
+
resolve(value);
|
|
32
|
+
},
|
|
33
|
+
cancel,
|
|
34
|
+
};
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
reject(new ResolveError(envName, field, 'cancelled'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
signal?.addEventListener('abort', cancel);
|
|
40
|
+
queue.push(pending);
|
|
41
|
+
notify();
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
current: () => queue[0],
|
|
45
|
+
subscribe(listener) {
|
|
46
|
+
listeners.add(listener);
|
|
47
|
+
return () => {
|
|
48
|
+
listeners.delete(listener);
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { describeProblem } from '../config/schema.js';
|
|
6
|
+
import { addressOf, credentialsReadable, listEnvironments, resolveEntry, shield, tierColor, } from '../environments.js';
|
|
7
|
+
import Header from './header.js';
|
|
8
|
+
import { clampTop, moveTo } from './scroll.js';
|
|
9
|
+
import { useTerminalSize } from './terminal.js';
|
|
10
|
+
// Rows the screen uses around the list: top padding, header (3), gap, column titles, the two "more" markers,
|
|
11
|
+
// gap + result line, gap, key help, and one spare so the frame never reaches the bottom row (Ink
|
|
12
|
+
// would then clear and redraw the whole screen on every update).
|
|
13
|
+
const chromeRows = 13;
|
|
14
|
+
const statusWidth = 10;
|
|
15
|
+
const tierWidth = 12;
|
|
16
|
+
const protectedWidth = 3;
|
|
17
|
+
const sourceWidth = 7;
|
|
18
|
+
const tlsWidth = 12;
|
|
19
|
+
const maxNameWidth = 28;
|
|
20
|
+
// Optional columns appear only when the row is wide enough to keep the address readable.
|
|
21
|
+
const tierMinWidth = 50;
|
|
22
|
+
const sourceMinWidth = 60;
|
|
23
|
+
const tlsMinWidth = 72;
|
|
24
|
+
const accessWidth = 11;
|
|
25
|
+
const accessMinWidth = 110;
|
|
26
|
+
const maxProblems = 5;
|
|
27
|
+
const describeFailure = (result) => result.detail && result.detail !== result.reason
|
|
28
|
+
? `${result.reason} (${result.detail})`
|
|
29
|
+
: result.reason;
|
|
30
|
+
function Status({ entry, outcome }) {
|
|
31
|
+
if (outcome?.state === 'running') {
|
|
32
|
+
return (_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }));
|
|
33
|
+
}
|
|
34
|
+
const status = outcome?.state ?? entry.connection?.lastTest?.status;
|
|
35
|
+
if (status === 'ok')
|
|
36
|
+
return _jsx(Text, { color: "green", children: "\u2713" });
|
|
37
|
+
if (status === 'failed')
|
|
38
|
+
return _jsx(Text, { color: "red", children: "\u2717" });
|
|
39
|
+
return _jsx(Text, { dimColor: true, children: "untested" });
|
|
40
|
+
}
|
|
41
|
+
function WebNote({ url }) {
|
|
42
|
+
if (!url)
|
|
43
|
+
return null;
|
|
44
|
+
return (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["Web UI is running on ", _jsx(Text, { color: "cyan", children: url })] }) }));
|
|
45
|
+
}
|
|
46
|
+
function Banner({ services }) {
|
|
47
|
+
const error = services.configError;
|
|
48
|
+
if (!error)
|
|
49
|
+
return null;
|
|
50
|
+
const shown = error.problems.slice(0, maxProblems);
|
|
51
|
+
const more = error.problems.length - shown.length;
|
|
52
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: "red", wrap: "truncate-middle", children: ["\u2717 Invalid config ", error.file] }), shown.map((problem) => (_jsxs(Text, { color: "red", wrap: "truncate-end", children: [' ', describeProblem(problem)] }, describeProblem(problem)))), more > 0 ? _jsx(Text, { color: "red", children: ` … and ${more} more` }) : null] }));
|
|
53
|
+
}
|
|
54
|
+
const readEntries = (services) => {
|
|
55
|
+
try {
|
|
56
|
+
return { entries: listEnvironments(services) };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
export default function Environments({ version, navigate, params, services, isInputActive, }) {
|
|
63
|
+
const { columns, rows } = useTerminalSize();
|
|
64
|
+
const [loaded, setLoaded] = useState(() => readEntries(services));
|
|
65
|
+
const entries = 'entries' in loaded ? loaded.entries : [];
|
|
66
|
+
const [position, setPosition] = useState(() => ({
|
|
67
|
+
selected: Math.max(0, entries.findIndex((entry) => entry.key === params?.selectedKey)),
|
|
68
|
+
top: 0,
|
|
69
|
+
}));
|
|
70
|
+
const { selected } = position;
|
|
71
|
+
const [outcomes, setOutcomes] = useState(new Map());
|
|
72
|
+
const running = useRef(new Set());
|
|
73
|
+
// Shared environments whose Vault secrets this user may not read.
|
|
74
|
+
const [locked, setLocked] = useState(new Set());
|
|
75
|
+
// A local connection waiting to be confirmed for deletion, and why something cannot be done.
|
|
76
|
+
const [deleting, setDeleting] = useState();
|
|
77
|
+
const [note, setNote] = useState();
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.config)
|
|
82
|
+
continue;
|
|
83
|
+
void credentialsReadable(entry, services, controller.signal).then((readable) => {
|
|
84
|
+
if (!readable && !controller.signal.aborted) {
|
|
85
|
+
setLocked((previous) => new Set(previous).add(entry.key));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return () => {
|
|
90
|
+
controller.abort();
|
|
91
|
+
};
|
|
92
|
+
// Looked at once, when the screen opens.
|
|
93
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
94
|
+
}, []);
|
|
95
|
+
const bannerRows = services.configError
|
|
96
|
+
? 2 +
|
|
97
|
+
Math.min(services.configError.problems.length, maxProblems) +
|
|
98
|
+
(services.configError.problems.length > maxProblems ? 1 : 0)
|
|
99
|
+
: 0;
|
|
100
|
+
const visible = Math.max(1, rows - chromeRows - bannerRows);
|
|
101
|
+
const first = clampTop(position.top, selected, visible, entries.length);
|
|
102
|
+
const setOutcome = (key, outcome) => {
|
|
103
|
+
setOutcomes((previous) => new Map(previous).set(key, outcome));
|
|
104
|
+
};
|
|
105
|
+
const test = async (entry) => {
|
|
106
|
+
if (running.current.has(entry.key))
|
|
107
|
+
return;
|
|
108
|
+
running.current.add(entry.key);
|
|
109
|
+
setOutcome(entry.key, { state: 'running' });
|
|
110
|
+
let outcome;
|
|
111
|
+
try {
|
|
112
|
+
const resolved = await resolveEntry(entry, services);
|
|
113
|
+
const result = await services.tester(resolved);
|
|
114
|
+
outcome = result.ok
|
|
115
|
+
? { state: 'ok', message: `Connected · PostgreSQL ${result.serverVersion}` }
|
|
116
|
+
: { state: 'failed', message: describeFailure(result) };
|
|
117
|
+
if (entry.connection) {
|
|
118
|
+
services.store.recordTest(entry.connection.id, {
|
|
119
|
+
status: result.ok ? 'ok' : 'failed',
|
|
120
|
+
message: result.ok ? `PostgreSQL ${result.serverVersion}` : result.reason,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
outcome = { state: 'failed', message: error instanceof Error ? error.message : String(error) };
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
running.current.delete(entry.key);
|
|
129
|
+
}
|
|
130
|
+
setOutcome(entry.key, outcome);
|
|
131
|
+
};
|
|
132
|
+
// Shared environments come from the config (a directory or Vault), which is managed outside the
|
|
133
|
+
// app: only local connections can be changed here.
|
|
134
|
+
const fromConfig = (entry, what) => {
|
|
135
|
+
setNote(`${entry.name} is managed in the shared config: ${what} it there${services.config ? ` (${services.config.base})` : ''}`);
|
|
136
|
+
};
|
|
137
|
+
const deleteConnection = (entry) => {
|
|
138
|
+
if (!entry.connection)
|
|
139
|
+
return;
|
|
140
|
+
services.store.delete(entry.connection.id);
|
|
141
|
+
const next = readEntries(services);
|
|
142
|
+
setLoaded(next);
|
|
143
|
+
const remaining = 'entries' in next ? next.entries.length : 0;
|
|
144
|
+
setPosition((previous) => moveTo(previous, (at) => at, visible, remaining));
|
|
145
|
+
setOutcomes((previous) => {
|
|
146
|
+
const rest = new Map(previous);
|
|
147
|
+
rest.delete(entry.key);
|
|
148
|
+
return rest;
|
|
149
|
+
});
|
|
150
|
+
setNote(`Deleted ${entry.name}`);
|
|
151
|
+
};
|
|
152
|
+
useInput((input, key) => {
|
|
153
|
+
if (deleting) {
|
|
154
|
+
const entry = deleting;
|
|
155
|
+
setDeleting(undefined);
|
|
156
|
+
if (input === 'y')
|
|
157
|
+
deleteConnection(entry);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
setNote(undefined);
|
|
161
|
+
if (input === 'n') {
|
|
162
|
+
navigate('newConnection');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const entry = entries[selected];
|
|
166
|
+
if (!entry)
|
|
167
|
+
return;
|
|
168
|
+
if (key.return) {
|
|
169
|
+
navigate('explorer', { envKey: entry.key });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (input === 't') {
|
|
173
|
+
void test(entry);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (input === 'e') {
|
|
177
|
+
if (entry.connection)
|
|
178
|
+
navigate('newConnection', { connectionId: entry.connection.id });
|
|
179
|
+
else
|
|
180
|
+
fromConfig(entry, 'change');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (input === 'd') {
|
|
184
|
+
if (entry.connection)
|
|
185
|
+
setDeleting(entry);
|
|
186
|
+
else
|
|
187
|
+
fromConfig(entry, 'remove');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const move = key.upArrow ? -1 : key.downArrow ? 1 : 0;
|
|
191
|
+
if (move === 0)
|
|
192
|
+
return;
|
|
193
|
+
setPosition((previous) => moveTo(previous, (at) => at + move, visible, entries.length));
|
|
194
|
+
}, { isActive: isInputActive });
|
|
195
|
+
const width = Math.max(20, columns - 4);
|
|
196
|
+
// The list sits inside paddingX={1}, so rows get two columns less than the header.
|
|
197
|
+
const rowWidth = width - 2;
|
|
198
|
+
const showTier = rowWidth >= tierMinWidth;
|
|
199
|
+
const showSource = rowWidth >= sourceMinWidth;
|
|
200
|
+
const showTls = rowWidth >= tlsMinWidth;
|
|
201
|
+
const showAccess = rowWidth >= accessMinWidth;
|
|
202
|
+
// What the name and address share: the row minus marker (2), the gap after the name (2), the
|
|
203
|
+
// badge, the status and any optional columns. The name takes at most half, so the address
|
|
204
|
+
// always keeps some room.
|
|
205
|
+
const textWidth = rowWidth -
|
|
206
|
+
2 -
|
|
207
|
+
2 -
|
|
208
|
+
(showTier ? tierWidth : 0) -
|
|
209
|
+
protectedWidth -
|
|
210
|
+
statusWidth -
|
|
211
|
+
(showSource ? sourceWidth : 0) -
|
|
212
|
+
(showTls ? tlsWidth : 0) -
|
|
213
|
+
(showAccess ? accessWidth : 0);
|
|
214
|
+
const nameWidth = Math.min(maxNameWidth, Math.max(4, Math.floor(textWidth / 2)), Math.max(4, ...entries.map((entry) => entry.name.length)));
|
|
215
|
+
const hints = [
|
|
216
|
+
...(entries.length > 0 ? ['↑↓ move', 'Enter explore', 'e edit', 'd delete', 't test'] : []),
|
|
217
|
+
'n new',
|
|
218
|
+
'q quit',
|
|
219
|
+
];
|
|
220
|
+
const shown = entries.slice(first, first + visible);
|
|
221
|
+
const above = first;
|
|
222
|
+
const below = entries.length - first - shown.length;
|
|
223
|
+
const current = entries[selected];
|
|
224
|
+
const currentOutcome = current ? outcomes.get(current.key) : undefined;
|
|
225
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingTop: 1, flexGrow: 1, children: [_jsx(Header, { version: version, title: "Environments", width: width }), _jsx(Banner, { services: services }), _jsx(WebNote, { url: services.webUrl }), _jsx(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, children: 'error' in loaded ? (_jsxs(Text, { color: "red", children: ["\u2717 Could not read connections: ", loaded.error] })) : entries.length === 0 ? (_jsxs(Text, { children: ["No environments yet. Press ", _jsx(Text, { color: "cyan", children: "n" }), " to create one."] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { width: rowWidth, children: [_jsx(Box, { width: 2, flexShrink: 0 }), _jsx(Box, { width: nameWidth, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "NAME" }) }), showTier ? (_jsx(Box, { width: tierWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "TIER" }) })) : null, _jsx(Box, { width: protectedWidth, flexShrink: 0 }), showSource ? (_jsx(Box, { width: sourceWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "SOURCE" }) })) : null, _jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0, children: _jsx(Text, { bold: true, dimColor: true, wrap: "truncate-end", children: "ADDRESS" }) }), showAccess ? (_jsx(Box, { width: accessWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "ACCESS" }) })) : null, showTls ? (_jsx(Box, { width: tlsWidth, flexShrink: 0, children: _jsx(Text, { bold: true, dimColor: true, children: "TLS" }) })) : null, _jsx(Box, { width: statusWidth, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { bold: true, dimColor: true, children: "CONNECTION" }) })] }), _jsx(Text, { dimColor: true, children: above > 0 ? ` ↑ ${above} more` : ' ' }), shown.map((entry, index) => {
|
|
226
|
+
const isSelected = first + index === selected;
|
|
227
|
+
return (_jsxs(Box, { width: rowWidth, children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: "cyan", children: isSelected ? '›' : ' ' }) }), _jsx(Box, { width: nameWidth, marginRight: 2, flexShrink: 0, children: _jsx(Text, { bold: isSelected, color: locked.has(entry.key) ? 'red' : isSelected ? 'cyan' : undefined, wrap: "truncate-end", children: entry.name }) }), showTier ? (_jsx(Box, { width: tierWidth, flexShrink: 0, children: _jsx(Text, { color: tierColor[entry.tier], children: entry.tier }) })) : null, _jsx(Box, { width: protectedWidth, flexShrink: 0, children: entry.protected ? _jsx(Text, { color: "red", children: shield }) : null }), showSource ? (_jsx(Box, { width: sourceWidth, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: entry.source }) })) : null, _jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0, children: _jsx(Text, { dimColor: !isSelected, wrap: "truncate-end", children: addressOf(entry) }) }), locked.has(entry.key) ? (_jsx(Box, { flexShrink: 0, marginRight: 2, children: _jsx(Text, { color: "red", children: "credentials can't be retrieved" }) })) : null, showAccess ? (_jsx(Box, { width: accessWidth, flexShrink: 0, children: entry.readOnly ? (_jsx(Text, { color: "yellow", children: "read only" })) : (_jsx(Text, { dimColor: true, children: "read write" })) })) : null, showTls ? (_jsx(Box, { width: tlsWidth, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: entry.tlsMode }) })) : null, _jsx(Box, { width: statusWidth, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Status, { entry: entry, outcome: outcomes.get(entry.key) }) })] }, entry.key));
|
|
228
|
+
}), _jsx(Text, { dimColor: true, children: below > 0 ? ` ↓ ${below} more` : ' ' })] })) }), _jsx(Box, { marginTop: 1, paddingX: 1, width: width, children: deleting ? (_jsxs(Text, { color: "red", wrap: "truncate-end", children: ["Delete ", deleting.name, "? y to delete \u00B7 any other key cancels"] })) : note ? (_jsx(Text, { color: "yellow", wrap: "truncate-end", children: note })) : currentOutcome?.state === 'running' ? (_jsxs(Text, { dimColor: true, children: ["Testing ", current?.name, "\u2026"] })) : currentOutcome ? (_jsxs(Text, { color: currentOutcome.state === 'ok' ? 'green' : 'red', wrap: "truncate-end", children: [currentOutcome.state === 'ok' ? '✓' : '✗', " ", currentOutcome.message] })) : (_jsx(Text, { children: " " })) }), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { marginTop: 1, paddingX: 1, width: width, flexWrap: "wrap", children: hints.map((hint, index) => (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: [hint, index < hints.length - 1 ? ' · ' : ''] }, hint))) })] }));
|
|
229
|
+
}
|