@kazurolabs/psql-explorer 0.0.0-stage → 0.1.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 +294 -2
- package/dist/app.js +39 -0
- package/dist/cli.js +263 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +306 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +386 -0
- package/dist/config/resolve-environment.js +164 -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 +365 -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 +266 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +137 -0
- package/dist/explorer/catalog.js +383 -0
- package/dist/explorer/connect-entry.js +31 -0
- package/dist/explorer/explain.js +13 -0
- package/dist/explorer/format.js +134 -0
- package/dist/explorer/rows.js +50 -0
- package/dist/explorer/session.js +105 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +226 -0
- package/dist/screens/explorer.js +148 -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 +76 -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 +355 -0
- package/package.json +67 -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/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/index-BMLFCtFY.js +17 -0
- package/web/dist/assets/index-CMXJRL9O.css +2 -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/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/index.html +19 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// How results are written out: on one line in a grid cell, as an aligned table like psql's, as CSV
|
|
2
|
+
// and as JSON. Values arrive as the text Postgres sent (see Session).
|
|
3
|
+
import stringWidth from 'string-width';
|
|
4
|
+
export const outputFormats = ['table', 'csv', 'json'];
|
|
5
|
+
export const nullText = 'NULL';
|
|
6
|
+
/** A value on one line: line breaks and tabs become visible marks, so a row stays one row. */
|
|
7
|
+
export const cellText = (value) => value === null
|
|
8
|
+
? nullText
|
|
9
|
+
: value
|
|
10
|
+
.replaceAll('\r\n', '↵')
|
|
11
|
+
.replace(/[\n\r]/g, '↵')
|
|
12
|
+
.replaceAll('\t', ' ');
|
|
13
|
+
/** Text cut to `width` columns, ending in … when it was longer. */
|
|
14
|
+
export function truncate(text, width) {
|
|
15
|
+
if (width <= 0)
|
|
16
|
+
return '';
|
|
17
|
+
if (stringWidth(text) <= width)
|
|
18
|
+
return text;
|
|
19
|
+
let out = '';
|
|
20
|
+
for (const char of text) {
|
|
21
|
+
if (stringWidth(out + char) > width - 1)
|
|
22
|
+
break;
|
|
23
|
+
out += char;
|
|
24
|
+
}
|
|
25
|
+
return `${out}…`;
|
|
26
|
+
}
|
|
27
|
+
export const pad = (text, width, right = false) => {
|
|
28
|
+
const gap = ' '.repeat(Math.max(0, width - stringWidth(text)));
|
|
29
|
+
return right ? gap + text : text + gap;
|
|
30
|
+
};
|
|
31
|
+
const numericTypes = new Set([
|
|
32
|
+
'smallint',
|
|
33
|
+
'integer',
|
|
34
|
+
'bigint',
|
|
35
|
+
'numeric',
|
|
36
|
+
'real',
|
|
37
|
+
'double precision',
|
|
38
|
+
'oid',
|
|
39
|
+
'money',
|
|
40
|
+
]);
|
|
41
|
+
/** Whether a column holds numbers, which read best aligned to the right. */
|
|
42
|
+
export const isNumeric = (column) => numericTypes.has(column.type) || column.type.startsWith('numeric');
|
|
43
|
+
export const widthOf = stringWidth;
|
|
44
|
+
/** An aligned table with a header row and a "(n rows)" footer, like psql prints. */
|
|
45
|
+
export function textTable(result, { maxColumnWidth = 60 } = {}) {
|
|
46
|
+
const { columns, rows } = result;
|
|
47
|
+
const texts = rows.map((row) => row.map((value) => truncate(cellText(value), maxColumnWidth)));
|
|
48
|
+
const widths = columns.map((column, index) => Math.max(stringWidth(truncate(column.name, maxColumnWidth)), ...texts.map((row) => stringWidth(row[index] ?? ''))));
|
|
49
|
+
const line = (cells, right) => cells
|
|
50
|
+
.map((cell, index) => pad(cell, widths[index] ?? 0, right(index)))
|
|
51
|
+
.join(' | ')
|
|
52
|
+
.trimEnd();
|
|
53
|
+
const header = line(columns.map((column) => truncate(column.name, maxColumnWidth)), () => false);
|
|
54
|
+
const rule = widths.map((width) => '-'.repeat(width)).join('-+-');
|
|
55
|
+
const body = texts.map((row) => line(row, (index) => isNumeric(columns[index])));
|
|
56
|
+
const count = `(${rows.length} ${rows.length === 1 ? 'row' : 'rows'})`;
|
|
57
|
+
return [header, rule, ...body, count].join('\n') + '\n';
|
|
58
|
+
}
|
|
59
|
+
const csvField = (value) => /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
|
60
|
+
/** RFC 4180 CSV with a header row; NULL is an empty field. */
|
|
61
|
+
export function csv(result) {
|
|
62
|
+
const lines = [
|
|
63
|
+
result.columns.map((column) => csvField(column.name)).join(','),
|
|
64
|
+
...result.rows.map((row) => row.map((value) => (value === null ? '' : csvField(value))).join(',')),
|
|
65
|
+
];
|
|
66
|
+
return lines.map((line) => `${line}\r\n`).join('');
|
|
67
|
+
}
|
|
68
|
+
const jsonNumberTypes = new Set(['smallint', 'integer', 'real', 'double precision', 'oid']);
|
|
69
|
+
/**
|
|
70
|
+
* A value as JSON: numbers that fit a JS number, booleans and json columns as themselves, and
|
|
71
|
+
* everything else (bigint and numeric included, to keep every digit) as its text.
|
|
72
|
+
*/
|
|
73
|
+
export function jsonValue(value, column) {
|
|
74
|
+
if (value === null)
|
|
75
|
+
return null;
|
|
76
|
+
if (jsonNumberTypes.has(column.type)) {
|
|
77
|
+
const number = Number(value);
|
|
78
|
+
return Number.isFinite(number) ? number : value;
|
|
79
|
+
}
|
|
80
|
+
if (column.type === 'boolean')
|
|
81
|
+
return value === 't';
|
|
82
|
+
if (column.type === 'json' || column.type === 'jsonb') {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(value);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
/** The rows as an array of objects keyed by column name (a repeated name keeps its last value). */
|
|
93
|
+
export function jsonRows(result) {
|
|
94
|
+
return result.rows.map((row) => Object.fromEntries(result.columns.map((column, index) => [column.name, jsonValue(row[index] ?? null, column)])));
|
|
95
|
+
}
|
|
96
|
+
export function formatResult(result, format) {
|
|
97
|
+
if (format === 'csv')
|
|
98
|
+
return csv(result);
|
|
99
|
+
if (format === 'json')
|
|
100
|
+
return `${JSON.stringify(jsonRows(result), null, 2)}\n`;
|
|
101
|
+
return textTable(result);
|
|
102
|
+
}
|
|
103
|
+
/** "SELECT 3", "UPDATE 1": what a statement did, in psql's words. */
|
|
104
|
+
export function commandSummary(result) {
|
|
105
|
+
if (result.truncated)
|
|
106
|
+
return `${result.command} (first ${result.rows.length} rows)`;
|
|
107
|
+
return result.rowCount === null ? result.command : `${result.command} ${result.rowCount}`;
|
|
108
|
+
}
|
|
109
|
+
const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
|
|
110
|
+
/** 1536 → "1.5 kB", as pg_size_pretty but with one decimal. */
|
|
111
|
+
export function formatBytes(bytes) {
|
|
112
|
+
if (bytes === null)
|
|
113
|
+
return '';
|
|
114
|
+
let value = bytes;
|
|
115
|
+
let unit = 0;
|
|
116
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
117
|
+
value /= 1024;
|
|
118
|
+
unit++;
|
|
119
|
+
}
|
|
120
|
+
return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${units[unit]}`;
|
|
121
|
+
}
|
|
122
|
+
/** 1234567 → "1.2M": a row estimate is only ever approximate. */
|
|
123
|
+
export function formatEstimate(rows) {
|
|
124
|
+
if (rows === null)
|
|
125
|
+
return '';
|
|
126
|
+
if (rows < 1000)
|
|
127
|
+
return `~${rows}`;
|
|
128
|
+
if (rows < 1_000_000)
|
|
129
|
+
return `~${(rows / 1000).toFixed(1)}k`;
|
|
130
|
+
if (rows < 1_000_000_000)
|
|
131
|
+
return `~${(rows / 1_000_000).toFixed(1)}M`;
|
|
132
|
+
return `~${(rows / 1_000_000_000).toFixed(1)}B`;
|
|
133
|
+
}
|
|
134
|
+
export const formatDuration = (ms) => ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Paging through a table's rows, as the Rows tab and the rows command show them.
|
|
2
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
3
|
+
import { qualified } from './catalog.js';
|
|
4
|
+
export const DEFAULT_PAGE_SIZE = 100;
|
|
5
|
+
export function browseSql(table, { orderBy = [] }) {
|
|
6
|
+
const order = orderBy
|
|
7
|
+
.map(({ column, descending }) => `${quoteIdentifier(column)} ${descending ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`)
|
|
8
|
+
.join(', ');
|
|
9
|
+
return `SELECT * FROM ${qualified(table)}${order ? ` ORDER BY ${order}` : ''} LIMIT $1 OFFSET $2`;
|
|
10
|
+
}
|
|
11
|
+
/** One page of a table's rows. */
|
|
12
|
+
export function browseRows(session, table, options) {
|
|
13
|
+
return session.run(browseSql(table, options), [options.limit, options.offset ?? 0], {
|
|
14
|
+
maxRows: options.limit,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** The exact number of rows; this reads the whole table, so the statement timeout bounds it. */
|
|
18
|
+
export async function countRows(session, table) {
|
|
19
|
+
const [row] = await session.rows(`SELECT count(*) AS count FROM ${qualified(table)}`);
|
|
20
|
+
return Number(row?.count ?? 0);
|
|
21
|
+
}
|
|
22
|
+
// Statements that return rows and can stand in a FROM clause.
|
|
23
|
+
const readsRows = /^(?:select|with|values|table)\b/i;
|
|
24
|
+
// SQLSTATE 42601 (syntax error) and 0A000 (not supported): the statement cannot be wrapped.
|
|
25
|
+
const cannotWrap = (error) => ['42601', '0A000'].includes(error?.code ?? '');
|
|
26
|
+
/** The statement without its leading comments and trailing semicolons. */
|
|
27
|
+
const trimStatement = (sql) => sql
|
|
28
|
+
.replace(/^(?:\s+|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/, '')
|
|
29
|
+
.replace(/[\s;]+$/, '');
|
|
30
|
+
/**
|
|
31
|
+
* One page of any statement's rows, without the statement needing a LIMIT of its own. A query is
|
|
32
|
+
* wrapped in `SELECT * FROM (...) LIMIT OFFSET`, so the planner knows that few rows are wanted and
|
|
33
|
+
* starts returning them early, as it does for an explicit LIMIT. Other statements, and queries that
|
|
34
|
+
* cannot be wrapped, run as they are and give their first rows.
|
|
35
|
+
*/
|
|
36
|
+
export async function runPage(session, sql, { limit, offset = 0 }) {
|
|
37
|
+
const statement = trimStatement(sql);
|
|
38
|
+
if (!readsRows.test(statement))
|
|
39
|
+
return session.run(sql, [], { maxRows: limit });
|
|
40
|
+
// The newline keeps a trailing -- comment from swallowing the closing parenthesis.
|
|
41
|
+
const wrapped = `SELECT * FROM (\n${statement}\n) AS page LIMIT ${limit + 1} OFFSET ${offset}`;
|
|
42
|
+
try {
|
|
43
|
+
return await session.run(wrapped, [], { maxRows: limit });
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (!cannotWrap(error))
|
|
47
|
+
throw error;
|
|
48
|
+
return session.run(sql, [], { maxRows: limit });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
close: () => serial(() => client.end().catch(() => undefined)),
|
|
104
|
+
};
|
|
105
|
+
};
|
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,226 @@
|
|
|
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 Banner({ services }) {
|
|
42
|
+
const error = services.configError;
|
|
43
|
+
if (!error)
|
|
44
|
+
return null;
|
|
45
|
+
const shown = error.problems.slice(0, maxProblems);
|
|
46
|
+
const more = error.problems.length - shown.length;
|
|
47
|
+
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] }));
|
|
48
|
+
}
|
|
49
|
+
const readEntries = (services) => {
|
|
50
|
+
try {
|
|
51
|
+
return { entries: listEnvironments(services) };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
export default function Environments({ version, navigate, params, services, isInputActive, }) {
|
|
58
|
+
const { columns, rows } = useTerminalSize();
|
|
59
|
+
const [loaded, setLoaded] = useState(() => readEntries(services));
|
|
60
|
+
const entries = 'entries' in loaded ? loaded.entries : [];
|
|
61
|
+
const [position, setPosition] = useState(() => ({
|
|
62
|
+
selected: Math.max(0, entries.findIndex((entry) => entry.key === params?.selectedKey)),
|
|
63
|
+
top: 0,
|
|
64
|
+
}));
|
|
65
|
+
const { selected } = position;
|
|
66
|
+
const [outcomes, setOutcomes] = useState(new Map());
|
|
67
|
+
const running = useRef(new Set());
|
|
68
|
+
// Shared environments whose Vault secrets this user may not read.
|
|
69
|
+
const [locked, setLocked] = useState(new Set());
|
|
70
|
+
// A local connection waiting to be confirmed for deletion, and why something cannot be done.
|
|
71
|
+
const [deleting, setDeleting] = useState();
|
|
72
|
+
const [note, setNote] = useState();
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.config)
|
|
77
|
+
continue;
|
|
78
|
+
void credentialsReadable(entry, services, controller.signal).then((readable) => {
|
|
79
|
+
if (!readable && !controller.signal.aborted) {
|
|
80
|
+
setLocked((previous) => new Set(previous).add(entry.key));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return () => {
|
|
85
|
+
controller.abort();
|
|
86
|
+
};
|
|
87
|
+
// Looked at once, when the screen opens.
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, []);
|
|
90
|
+
const bannerRows = services.configError
|
|
91
|
+
? 2 +
|
|
92
|
+
Math.min(services.configError.problems.length, maxProblems) +
|
|
93
|
+
(services.configError.problems.length > maxProblems ? 1 : 0)
|
|
94
|
+
: 0;
|
|
95
|
+
const visible = Math.max(1, rows - chromeRows - bannerRows);
|
|
96
|
+
const first = clampTop(position.top, selected, visible, entries.length);
|
|
97
|
+
const setOutcome = (key, outcome) => {
|
|
98
|
+
setOutcomes((previous) => new Map(previous).set(key, outcome));
|
|
99
|
+
};
|
|
100
|
+
const test = async (entry) => {
|
|
101
|
+
if (running.current.has(entry.key))
|
|
102
|
+
return;
|
|
103
|
+
running.current.add(entry.key);
|
|
104
|
+
setOutcome(entry.key, { state: 'running' });
|
|
105
|
+
let outcome;
|
|
106
|
+
try {
|
|
107
|
+
const resolved = await resolveEntry(entry, services);
|
|
108
|
+
const result = await services.tester(resolved);
|
|
109
|
+
outcome = result.ok
|
|
110
|
+
? { state: 'ok', message: `Connected · PostgreSQL ${result.serverVersion}` }
|
|
111
|
+
: { state: 'failed', message: describeFailure(result) };
|
|
112
|
+
if (entry.connection) {
|
|
113
|
+
services.store.recordTest(entry.connection.id, {
|
|
114
|
+
status: result.ok ? 'ok' : 'failed',
|
|
115
|
+
message: result.ok ? `PostgreSQL ${result.serverVersion}` : result.reason,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
outcome = { state: 'failed', message: error instanceof Error ? error.message : String(error) };
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
running.current.delete(entry.key);
|
|
124
|
+
// Credentials issued for the test are not needed any more.
|
|
125
|
+
void services.groups?.release(entry.name);
|
|
126
|
+
}
|
|
127
|
+
setOutcome(entry.key, outcome);
|
|
128
|
+
};
|
|
129
|
+
// Shared environments come from the config (a directory or Vault), which is managed outside the
|
|
130
|
+
// app: only local connections can be changed here.
|
|
131
|
+
const fromConfig = (entry, what) => {
|
|
132
|
+
setNote(`${entry.name} is managed in the shared config: ${what} it there${services.config ? ` (${services.config.base})` : ''}`);
|
|
133
|
+
};
|
|
134
|
+
const deleteConnection = (entry) => {
|
|
135
|
+
if (!entry.connection)
|
|
136
|
+
return;
|
|
137
|
+
services.store.delete(entry.connection.id);
|
|
138
|
+
const next = readEntries(services);
|
|
139
|
+
setLoaded(next);
|
|
140
|
+
const remaining = 'entries' in next ? next.entries.length : 0;
|
|
141
|
+
setPosition((previous) => moveTo(previous, (at) => at, visible, remaining));
|
|
142
|
+
setOutcomes((previous) => {
|
|
143
|
+
const rest = new Map(previous);
|
|
144
|
+
rest.delete(entry.key);
|
|
145
|
+
return rest;
|
|
146
|
+
});
|
|
147
|
+
setNote(`Deleted ${entry.name}`);
|
|
148
|
+
};
|
|
149
|
+
useInput((input, key) => {
|
|
150
|
+
if (deleting) {
|
|
151
|
+
const entry = deleting;
|
|
152
|
+
setDeleting(undefined);
|
|
153
|
+
if (input === 'y')
|
|
154
|
+
deleteConnection(entry);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
setNote(undefined);
|
|
158
|
+
if (input === 'n') {
|
|
159
|
+
navigate('newConnection');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const entry = entries[selected];
|
|
163
|
+
if (!entry)
|
|
164
|
+
return;
|
|
165
|
+
if (key.return) {
|
|
166
|
+
navigate('explorer', { envKey: entry.key });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (input === 't') {
|
|
170
|
+
void test(entry);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (input === 'e') {
|
|
174
|
+
if (entry.connection)
|
|
175
|
+
navigate('newConnection', { connectionId: entry.connection.id });
|
|
176
|
+
else
|
|
177
|
+
fromConfig(entry, 'change');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (input === 'd') {
|
|
181
|
+
if (entry.connection)
|
|
182
|
+
setDeleting(entry);
|
|
183
|
+
else
|
|
184
|
+
fromConfig(entry, 'remove');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const move = key.upArrow ? -1 : key.downArrow ? 1 : 0;
|
|
188
|
+
if (move === 0)
|
|
189
|
+
return;
|
|
190
|
+
setPosition((previous) => moveTo(previous, (at) => at + move, visible, entries.length));
|
|
191
|
+
}, { isActive: isInputActive });
|
|
192
|
+
const width = Math.max(20, columns - 4);
|
|
193
|
+
// The list sits inside paddingX={1}, so rows get two columns less than the header.
|
|
194
|
+
const rowWidth = width - 2;
|
|
195
|
+
const showTier = rowWidth >= tierMinWidth;
|
|
196
|
+
const showSource = rowWidth >= sourceMinWidth;
|
|
197
|
+
const showTls = rowWidth >= tlsMinWidth;
|
|
198
|
+
const showAccess = rowWidth >= accessMinWidth;
|
|
199
|
+
// What the name and address share: the row minus marker (2), the gap after the name (2), the
|
|
200
|
+
// badge, the status and any optional columns. The name takes at most half, so the address
|
|
201
|
+
// always keeps some room.
|
|
202
|
+
const textWidth = rowWidth -
|
|
203
|
+
2 -
|
|
204
|
+
2 -
|
|
205
|
+
(showTier ? tierWidth : 0) -
|
|
206
|
+
protectedWidth -
|
|
207
|
+
statusWidth -
|
|
208
|
+
(showSource ? sourceWidth : 0) -
|
|
209
|
+
(showTls ? tlsWidth : 0) -
|
|
210
|
+
(showAccess ? accessWidth : 0);
|
|
211
|
+
const nameWidth = Math.min(maxNameWidth, Math.max(4, Math.floor(textWidth / 2)), Math.max(4, ...entries.map((entry) => entry.name.length)));
|
|
212
|
+
const hints = [
|
|
213
|
+
...(entries.length > 0 ? ['↑↓ move', 'Enter explore', 'e edit', 'd delete', 't test'] : []),
|
|
214
|
+
'n new',
|
|
215
|
+
'q quit',
|
|
216
|
+
];
|
|
217
|
+
const shown = entries.slice(first, first + visible);
|
|
218
|
+
const above = first;
|
|
219
|
+
const below = entries.length - first - shown.length;
|
|
220
|
+
const current = entries[selected];
|
|
221
|
+
const currentOutcome = current ? outcomes.get(current.key) : undefined;
|
|
222
|
+
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(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) => {
|
|
223
|
+
const isSelected = first + index === selected;
|
|
224
|
+
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));
|
|
225
|
+
}), _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))) })] }));
|
|
226
|
+
}
|