@kazurolabs/psql-explorer 0.1.0 → 0.3.1

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 (77) hide show
  1. package/README.md +229 -18
  2. package/RELEASES.md +95 -0
  3. package/dist/cli.js +57 -13
  4. package/dist/commands/configgen.js +25 -25
  5. package/dist/commands/explore.js +86 -4
  6. package/dist/config/load.js +11 -73
  7. package/dist/config/resolve-environment.js +10 -62
  8. package/dist/config/resolvers/index.js +1 -1
  9. package/dist/config/resolvers/prompt.js +2 -2
  10. package/dist/config/resolvers/vault.js +1 -83
  11. package/dist/db/store.js +57 -0
  12. package/dist/environments.js +5 -2
  13. package/dist/explorer/alter.js +493 -0
  14. package/dist/explorer/catalog.js +31 -0
  15. package/dist/explorer/connect-entry.js +29 -18
  16. package/dist/explorer/cypher-parse.js +478 -0
  17. package/dist/explorer/cypher.js +963 -0
  18. package/dist/explorer/ddl.js +502 -0
  19. package/dist/explorer/dependents.js +222 -0
  20. package/dist/explorer/design-load.js +254 -0
  21. package/dist/explorer/design.js +45 -0
  22. package/dist/explorer/edit.js +108 -0
  23. package/dist/explorer/explain.js +44 -3
  24. package/dist/explorer/filter.js +121 -0
  25. package/dist/explorer/graph.js +679 -0
  26. package/dist/explorer/gucs.js +31 -0
  27. package/dist/explorer/profile.js +102 -0
  28. package/dist/explorer/references.js +95 -0
  29. package/dist/explorer/rows.js +100 -13
  30. package/dist/explorer/schema-diff.js +360 -0
  31. package/dist/explorer/search.js +309 -0
  32. package/dist/explorer/security.js +152 -0
  33. package/dist/explorer/seed.js +1376 -0
  34. package/dist/explorer/session.js +25 -1
  35. package/dist/explorer/stats.js +135 -0
  36. package/dist/explorer/timeout.js +13 -0
  37. package/dist/screens/environments.js +6 -3
  38. package/dist/screens/explorer.js +0 -3
  39. package/dist/screens/splash.js +4 -2
  40. package/dist/web/server.js +655 -22
  41. package/package.json +4 -1
  42. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  43. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  44. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  45. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  46. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  47. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  48. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  49. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  50. package/web/dist/assets/button-BiFx4Lyw.js +1 -0
  51. package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
  52. package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
  53. package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
  54. package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
  55. package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
  56. package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
  57. package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
  58. package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
  59. package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
  60. package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
  61. package/web/dist/assets/index-CpHnjRLv.js +10 -0
  62. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  63. package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
  64. package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
  65. package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
  66. package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
  67. package/web/dist/assets/route-7eu-iAqD.js +15 -0
  68. package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
  69. package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
  70. package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
  71. package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
  72. package/web/dist/assets/table-Drc_n1gF.js +1 -0
  73. package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
  74. package/web/dist/assets/zone-CHDOrDBF.js +5 -0
  75. package/web/dist/index.html +9 -2
  76. package/web/dist/assets/index-BMLFCtFY.js +0 -17
  77. package/web/dist/assets/index-CMXJRL9O.css +0 -2
@@ -99,7 +99,31 @@ export const openSession = async (params, options = {}) => {
99
99
  durationMs,
100
100
  };
101
101
  }),
102
- rows: (sql, values = []) => serial(async () => (await client.query(sql, [...values])).rows),
102
+ // Always the extended protocol, which takes one statement: without values node-postgres
103
+ // would use the simple one, which runs every statement in the text, COMMIT and BEGIN
104
+ // READ WRITE included, and so gets around a read only session.
105
+ rows: (sql, values = []) => serial(async () => (await client.query({
106
+ text: sql,
107
+ values: [...values],
108
+ queryMode: 'extended',
109
+ })).rows),
110
+ // From a connection of its own, since this one is busy with the statement to stop.
111
+ cancel: async () => {
112
+ const pid = client.processID;
113
+ if (!pid)
114
+ return;
115
+ const other = createClient(params, { ...options, statementTimeoutMs: 5000, readOnly: true });
116
+ try {
117
+ await other.connect();
118
+ await other.query('SELECT pg_catalog.pg_cancel_backend($1)', [pid]);
119
+ }
120
+ catch {
121
+ // The statement may have finished meanwhile, or the server cannot be reached.
122
+ }
123
+ finally {
124
+ await other.end().catch(() => undefined);
125
+ }
126
+ },
103
127
  close: () => serial(() => client.end().catch(() => undefined)),
104
128
  };
105
129
  };
@@ -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
+ }
@@ -38,6 +38,11 @@ function Status({ entry, outcome }) {
38
38
  return _jsx(Text, { color: "red", children: "\u2717" });
39
39
  return _jsx(Text, { dimColor: true, children: "untested" });
40
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
+ }
41
46
  function Banner({ services }) {
42
47
  const error = services.configError;
43
48
  if (!error)
@@ -121,8 +126,6 @@ export default function Environments({ version, navigate, params, services, isIn
121
126
  }
122
127
  finally {
123
128
  running.current.delete(entry.key);
124
- // Credentials issued for the test are not needed any more.
125
- void services.groups?.release(entry.name);
126
129
  }
127
130
  setOutcome(entry.key, outcome);
128
131
  };
@@ -219,7 +222,7 @@ export default function Environments({ version, navigate, params, services, isIn
219
222
  const below = entries.length - first - shown.length;
220
223
  const current = entries[selected];
221
224
  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) => {
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) => {
223
226
  const isSelected = first + index === selected;
224
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));
225
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))) })] }));
@@ -68,9 +68,6 @@ export default function Explorer({ version, navigate, params, services, isInputA
68
68
  // eslint-disable-next-line react-hooks/exhaustive-deps
69
69
  generation.current++;
70
70
  void session.current?.close();
71
- // Credentials issued for this visit end with it.
72
- if (entry)
73
- void services.groups?.release(entry.name);
74
71
  };
75
72
  // Connects once, when the screen opens.
76
73
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -69,8 +69,10 @@ export default function Splash({ version, navigate, services, isInputActive, })
69
69
  const stepsWidth = Math.max(20, Math.min(columns - 4, maxStepsWidth));
70
70
  useInput((input) => {
71
71
  // `q` is the global quit key, handled by <App>.
72
- if (input !== 'q' && finished)
72
+ if (input === 'o' && services.webUrl)
73
+ services.openUrl?.(services.webUrl);
74
+ else if (input !== 'q' && finished)
73
75
  navigate('environments');
74
76
  }, { isActive: isInputActive });
75
- return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", minHeight: Math.max(1, rows - 1), paddingY: 1, children: [_jsx(Box, { "aria-label": "psql-explorer", marginBottom: columns >= tinyMinColumns ? 0 : 1, children: _jsx(Gradient, { name: "cristal", children: _jsx(Title, { columns: columns }) }) }), _jsx(Gradient, { name: "cristal", children: _jsx(Text, { children: rule(columns) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: tagline }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "by " }), _jsx(Gradient, { name: "vice", children: _jsx(Text, { bold: true, children: company }) })] }), columns >= highlightsMinColumns ? (_jsx(Box, { marginTop: 1, children: highlights.map((item, index) => (_jsxs(Text, { color: "gray", children: [index > 0 ? ' ' : '', _jsx(Text, { color: "cyan", children: "\u25C6" }), " ", item] }, item))) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["v", version] }) }), steps.length > 0 ? (_jsx(Box, { flexDirection: "column", marginTop: 1, width: stepsWidth, children: steps.map((step) => (_jsx(StepLine, { step: step, now: now }, step.id))) })) : null, _jsx(Box, { marginTop: 1, children: !finished ? (_jsx(Text, { color: "gray", children: "Starting\u2026 \u00B7 q to quit" })) : columns >= tinyMinColumns ? (_jsx(Text, { color: "gray", children: "Press any key to continue \u00B7 q to quit" })) : (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: "gray", children: "Press any key to continue" }), _jsx(Text, { color: "gray", children: "q to quit" })] })) })] }));
77
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", minHeight: Math.max(1, rows - 1), paddingY: 1, children: [_jsx(Box, { "aria-label": "psql-explorer", marginBottom: columns >= tinyMinColumns ? 0 : 1, children: _jsx(Gradient, { name: "cristal", children: _jsx(Title, { columns: columns }) }) }), _jsx(Gradient, { name: "cristal", children: _jsx(Text, { children: rule(columns) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: tagline }) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "by " }), _jsx(Gradient, { name: "vice", children: _jsx(Text, { bold: true, children: company }) })] }), columns >= highlightsMinColumns ? (_jsx(Box, { marginTop: 1, children: highlights.map((item, index) => (_jsxs(Text, { color: "gray", children: [index > 0 ? ' ' : '', _jsx(Text, { color: "cyan", children: "\u25C6" }), " ", item] }, item))) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["v", version] }) }), services.webUrl ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Web UI " }), _jsx(Text, { color: "cyan", children: services.webUrl }), _jsx(Text, { dimColor: true, children: " \u00B7 press " }), _jsx(Text, { color: "cyan", children: "o" }), _jsx(Text, { dimColor: true, children: " to open" })] })) : null, steps.length > 0 ? (_jsx(Box, { flexDirection: "column", marginTop: 1, width: stepsWidth, children: steps.map((step) => (_jsx(StepLine, { step: step, now: now }, step.id))) })) : null, _jsx(Box, { marginTop: 1, children: !finished ? (_jsx(Text, { color: "gray", children: "Starting\u2026 \u00B7 q to quit" })) : columns >= tinyMinColumns ? (_jsx(Text, { color: "gray", children: "Press any key to continue \u00B7 q to quit" })) : (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: "gray", children: "Press any key to continue" }), _jsx(Text, { color: "gray", children: "q to quit" })] })) })] }));
76
78
  }