pgkiln 0.37.0 → 0.38.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.
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { APP_COLORS, BASE, csrf, developer, input, region, select, send, shell, type Req } from './ui.ts';
6
+ import { crossAppLinks } from './cross-app.ts';
6
7
  import type { Session } from '../session.ts';
7
8
  import { isAdmin } from './locks.ts';
8
9
  import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
@@ -98,15 +99,22 @@ export async function homeRoutes(app: FastifyInstance) {
98
99
  if (typeof req.query.sort === 'string' && req.query.sort in SORTS) s.state.__BSORT = req.query.sort;
99
100
  const view: View = s.state.__BVIEW === 'grid' ? 'grid' : 'report';
100
101
  const sort: Sort = (typeof s.state.__BSORT === 'string' && s.state.__BSORT in SORTS ? s.state.__BSORT : 'name') as Sort;
102
+ // (092) application groups of the workspace: a filter, remembered for the session
103
+ const groups = (await owner.query<{ id: number; name: string }>('select id, name from meta.app_group where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
104
+ if (typeof req.query.group === 'string') s.state.__BGROUP = req.query.group;
105
+ const group = groups.find((g) => String(g.id) === s.state.__BGROUP) ?? null;
101
106
  const [apps, total, recent] = await Promise.all([
102
107
  owner.query(
103
- `select a.id, a.name, a.alias, a.authentication, a.db_role, a.updated_at,
108
+ `select a.id, a.name, a.alias, a.authentication, a.db_role, a.updated_at, g.name as group_name,
104
109
  (select count(*) from meta.page p where p.app_id = a.id)::int as pages,
105
110
  (select count(*) from meta.activity_log l where l.app_id = a.id and l.event = 'page_view' and l.at > now() - interval '1 day')::int as views
106
111
  from meta.app a
112
+ left join meta.app_group_member gm on gm.app_id = a.id
113
+ left join meta.app_group g on g.id = gm.group_id
107
114
  where ($1 = '' or a.name ilike '%' || $1 || '%' or a.alias ilike '%' || $1 || '%' or a.id::text = $1) and ${inWorkspaceSql('a', 2)}
115
+ and ($3::int is null or gm.group_id = $3)
108
116
  order by ${SORTS[sort]}`,
109
- [q.replace(/[\\%_]/g, (c) => `\\${c}`), currentWorkspace(s)],
117
+ [q.replace(/[\\%_]/g, (c) => `\\${c}`), currentWorkspace(s), group?.id ?? null],
110
118
  ),
111
119
  owner.one(`select count(*)::int as n from meta.app a where ${inWorkspaceSql('a', 1)}`, [currentWorkspace(s)]),
112
120
  recentApps(s),
@@ -122,11 +130,11 @@ export async function homeRoutes(app: FastifyInstance) {
122
130
  html`<th scope="col"${cls ? raw(` class="${cls}"`) : ''}${sort === key ? raw(' aria-sort="' + (key === 'pages' || key === 'updated' ? 'descending' : 'ascending') + '"') : ''}><a href="${link({ sort: key })}">${label}${sort === key ? html`<span class="ab-sorted" aria-hidden="true">${key === 'pages' || key === 'updated' ? '↓' : '↑'}</span>` : ''}</a></th>`;
123
131
  const rows = apps.rows;
124
132
  const list = rows.length === 0
125
- ? html`<p class="ab-empty">${total.n ? html`No application matches “${q}”. <a href="${BASE}?q=">Show all</a>` : html`No applications yet. <a href="${BASE}/create">Create one</a> or <a href="${BASE}/import">import one</a>.`}</p>`
133
+ ? html`<p class="ab-empty">${total.n ? html`No application ${group ? html`in the group ${group.name}${q ? html` matches “${q}”` : ''}` : html`matches “${q}”`}. <a href="${BASE}?q=&amp;group=">Show all</a>` : html`No applications yet. <a href="${BASE}/create">Create one</a> or <a href="${BASE}/import">import one</a>.`}</p>`
126
134
  : view === 'grid'
127
135
  ? html`<div class="ab-cards">${rows.map((a) => html`<article class="ab-card" data-filter-row>
128
136
  <a class="ab-card-main" href="${BASE}/apps/${a.id}">${appIcon(a)}<span><strong>${a.name}</strong><small>${a.id} · /a/${a.alias}</small></span></a>
129
- <div class="ab-card-meta"><span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}</span></div>
137
+ <div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}</span></div>
130
138
  <div class="ab-card-actions">
131
139
  <a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
132
140
  <a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
@@ -137,7 +145,7 @@ export async function homeRoutes(app: FastifyInstance) {
137
145
  <tbody>${rows.map((a) => html`<tr data-filter-row>
138
146
  <td class="num">${a.id}</td>
139
147
  <td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a></td>
140
- <td>${a.alias}</td>
148
+ <td>${a.alias}${a.group_name ? html` <span class="tag">${a.group_name}</span>` : ''}</td>
141
149
  <td class="num">${a.pages}</td>
142
150
  <td class="num">${a.views}</td>
143
151
  <td>${a.authentication === 'none' ? 'Public' : a.authentication === 'header' ? 'HTTP header' : a.authentication === 'database' ? 'Database accounts' : a.authentication === 'custom' ? 'Custom' : 'App users'}</td>
@@ -151,6 +159,7 @@ export async function homeRoutes(app: FastifyInstance) {
151
159
  const main = html`<div class="ab-home">
152
160
  <div class="ab-main">
153
161
  <h1 class="sr-only">App Builder</h1>
162
+ ${s.workspace?.message ? html`<div class="alert alert-info ab-message" role="status">${icon('bell')}<div><strong>${s.workspace.name}</strong><p>${s.workspace.message}</p></div></div>` : ''}
154
163
  ${tiles()}
155
164
  <div class="ab-toolbar">
156
165
  <form class="ab-search" method="get" action="${BASE}" role="search">
@@ -158,6 +167,9 @@ export async function homeRoutes(app: FastifyInstance) {
158
167
  <input id="ab-q" name="q" type="search" value="${q}" placeholder="Search" autocomplete="off" data-filter-list=".ab-home [data-filter-row]">
159
168
  </form>
160
169
  <div class="ab-views" role="group" aria-label="View">${viewButton('grid', 'grid', 'Cards')}${viewButton('report', 'list', 'Report')}</div>
170
+ ${groups.length ? html`<form method="get" action="${BASE}" class="ab-group"><label class="sr-only" for="ab-group">Application group</label>
171
+ <select id="ab-group" name="group" data-autosubmit><option value="">All groups</option>${groups.map((g) => html`<option value="${g.id}"${group?.id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select>
172
+ <noscript><button class="btn btn-sm">Show</button></noscript></form>` : ''}
161
173
  <details class="menu ab-menu"><summary class="btn">Actions ${icon('chevron')}</summary>
162
174
  <div class="menu-panel" role="menu">
163
175
  <div class="menu-section"><span class="small muted">Sort by</span>
@@ -195,7 +207,7 @@ export async function homeRoutes(app: FastifyInstance) {
195
207
  ${input('name', 'Name', '', { required: true })}
196
208
  ${input('alias', 'Alias (URL)', '', { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
197
209
  ${boilerplates.length
198
- ? select('boilerplate', 'Start from', '', [['', '- a blank application (a Home page) -'], ...boilerplates.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
210
+ ? select('boilerplate', 'Start from', /^\d{1,9}$/.test(String(req.query?.boilerplate ?? '')) ? String(req.query!.boilerplate) : '', [['', '- a blank application (a Home page) -'], ...boilerplates.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
199
211
  'A boilerplate application\'s pages, shared components and settings are copied; the new application keeps its own name, alias, role and authentication.')
200
212
  : ''}
201
213
  ${select('schema', 'Parsing schema', '', [['', '- new schema named after the alias -'], ...schemas.rows.map((r): [string, string] => [r.nspname, r.nspname])],
@@ -275,9 +287,11 @@ export async function homeRoutes(app: FastifyInstance) {
275
287
  if (!s) return;
276
288
  const card = (href: string, ic: Raw | '', title: string, text: string) =>
277
289
  html`<a class="ab-tile" href="${href}"><span class="ab-tile-icon">${ic}</span><strong>${title}</strong><span>${text}</span></a>`;
278
- const main = html`<h1 class="u-mb1">Workspace utilities</h1>
290
+ const main = html`<div class="ab-home"><div class="ab-main"><h1 class="u-mb1">Workspace utilities</h1>
279
291
  <div class="ab-tiles ab-tiles-wrap">
280
292
  ${card(`${BASE}/users`, icon('user'), 'Users', 'Accounts of the people who use the applications, and their access.')}
293
+ ${card(`${BASE}/app-groups`, icon('folder'), 'Application groups', 'Organise the applications of this workspace into groups.')}
294
+ ${card(`${BASE}/gallery`, icon('package'), 'App Gallery', 'Sample and starter applications to install, and this workspace\'s boilerplates.')}
281
295
  ${card(`${BASE}/users/providers`, icon('key'), 'Identity providers', 'OpenID Connect and SAML sign-in.')}
282
296
  ${card(`${BASE}/users/directories`, icon('org'), 'LDAP directories', 'Sign in with directory accounts.')}
283
297
  ${card(`${BASE}/users#account-settings`, icon('shield'), 'Password policy', 'Length, complexity and lifetime of passwords.')}
@@ -293,7 +307,12 @@ export async function homeRoutes(app: FastifyInstance) {
293
307
  ${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
294
308
  ${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
295
309
  ${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), 'Installation', 'Version, install and upgrade runs, applied migrations (administrators).') : ''}
296
- </div>`;
310
+ </div></div>
311
+ <aside class="ab-side" aria-label="About and reports">
312
+ <section><h2>About</h2><p>Workspace utilities hold what the applications of this workspace share: users and sign-in, groups, the gallery, and reports across the applications.</p>
313
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">Learn more …</a></section>
314
+ <section><h2>Cross Application Reports</h2><ul class="ab-links ab-tasks">${crossAppLinks()}</ul></section>
315
+ </aside></div>`;
297
316
  return send(reply, s, shell(s, 'Workspace utilities', [['App Builder', BASE], ['Workspace utilities']], main));
298
317
  });
299
318
  }
@@ -31,23 +31,61 @@ import { clearDevbar } from '../runtime/devbar.ts';
31
31
 
32
32
  export async function builderRoutes(app: FastifyInstance) {
33
33
  // ---------------------------------------------------------------- login
34
+ // (sprint 61, APEX: the workspace sign-in page) workspace (optional), username and password;
35
+ // "Remember workspace and username" keeps both in a cookie of the builder's path
36
+ const REMEMBER_COOKIE = 'pgkiln_bsignin';
37
+ const remembered = (req: Req): { w: string; u: string } | null => {
38
+ try {
39
+ const v = JSON.parse(Buffer.from(String(req.cookies?.[REMEMBER_COOKIE] ?? ''), 'base64url').toString('utf8'));
40
+ return typeof v?.w === 'string' && typeof v?.u === 'string' ? { w: v.w.slice(0, 60), u: v.u.slice(0, 100) } : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+ /** Where to go after signing in: a builder page only. */
46
+ const safeNext = (v: unknown) => (typeof v === 'string' && /^\/builder(\/[A-Za-z0-9_/-]{0,200})?$/.test(v) ? v : BASE);
47
+
34
48
  app.get(`${BASE}/login`, async (req: Req, reply) => {
35
49
  const s = await getSession(req, reply, null, BASE);
36
50
  if (s.username) return reply.redirect(BASE);
37
51
  const error = takeFlash(s, '__ERROR');
38
52
  await saveState(s);
53
+ const r = remembered(req);
54
+ const next = safeNext(req.query?.next);
55
+ const field = (name: string, label: string, ic: string, value: string, opts: { type?: string; required?: boolean; auto?: string; placeholder?: string; reveal?: boolean }) =>
56
+ html`<div class="field signin-field"><label class="sr-only" for="f_${name}">${label}</label>
57
+ <div class="signin-input">${icon(ic)}<input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value}" placeholder="${opts.placeholder ?? label}"${opts.required ? raw(' required') : ''}${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
58
+ ${opts.reveal ? html`<button type="button" class="signin-reveal" data-reveal="f_${name}" aria-controls="f_${name}" aria-pressed="false" data-hide="Hide" hidden>Show</button>` : ''}</div></div>`;
39
59
  return reply.type('text/html').send(
40
60
  documentShell(
41
61
  'Sign in · Builder',
42
- html`<main class="login"><form method="post" class="card login-card">
43
- <h1>pgkiln Builder</h1>
44
- ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
45
- ${csrf(s)}
46
- ${input('username', 'Username', '', { required: true, auto: 'username' })}
47
- ${input('password', 'Password', '', { type: 'password', required: true, auto: 'current-password' })}
48
- <button class="btn btn-hot">Sign in</button>
49
- </form></main>`,
50
- 'login-body ide-login',
62
+ html`<main class="signin">
63
+ <section class="signin-hero" aria-label="pgkiln">
64
+ <span class="signin-logo" aria-hidden="true">pg</span>
65
+ <h1 class="signin-title">pgkiln</h1>
66
+ <p class="signin-tagline">Build data applications on PostgreSQL, in SQL you can trust</p>
67
+ <p class="signin-foot">Open source under the Apache-2.0 license</p>
68
+ </section>
69
+ <section class="signin-panel">
70
+ <form method="post" class="signin-form">
71
+ <h2>Welcome</h2>
72
+ <p class="muted u-mt0">Sign in to your workspace to continue</p>
73
+ ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
74
+ ${csrf(s)}<input type="hidden" name="next" value="${next}">
75
+ ${field('workspace', 'Workspace', 'database', r?.w ?? '', { placeholder: 'Workspace (optional)', auto: 'organization' })}
76
+ ${field('username', 'Username', 'user', r?.u ?? '', { required: true, auto: 'username' })}
77
+ ${field('password', 'Password', 'key', '', { type: 'password', required: true, auto: 'current-password', reveal: true })}
78
+ <label class="check signin-remember"><input type="checkbox" name="remember" value="1"${r ? raw(' checked') : ''}> Remember workspace and username</label>
79
+ <button class="btn btn-hot signin-submit">Sign in</button>
80
+ <p class="muted small signin-hint">Leave the workspace empty to open your last one; you can switch later in your account menu.</p>
81
+ <nav class="signin-actions" aria-label="Quick actions">
82
+ <a href="${BASE}/login?next=${encodeURIComponent(`${BASE}/utilities`)}">${icon('settings')} Administration</a>
83
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">${icon('help')} About workspaces</a>
84
+ </nav>
85
+ </form>
86
+ </section>
87
+ </main>`,
88
+ 'login-body ide-login signin-body',
51
89
  {},
52
90
  builderHead(),
53
91
  { theme: validTheme(req.cookies?.[THEME_COOKIE]) ?? 'dark' },
@@ -58,15 +96,18 @@ export async function builderRoutes(app: FastifyInstance) {
58
96
  app.post(`${BASE}/login`, async (req: Req, reply) => {
59
97
  const s = await getSession(req, reply, null, BASE);
60
98
  const { username = '', password = '' } = req.body ?? {};
99
+ const workspace = String(req.body?.workspace ?? '').trim().slice(0, 60);
100
+ const next = safeNext(req.body?.next);
101
+ const again = next === BASE ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
61
102
  const ip = clientIp(req);
62
103
  if (req.body?.__csrf !== s.csrf_token) {
63
104
  flash(s, 'Your session expired. Please try again.', 'error');
64
- return back(reply, s, `${BASE}/login`);
105
+ return back(reply, s, again);
65
106
  }
66
107
  if (await loginThrottled(null, username, ip)) {
67
108
  logActivity({ username, event: 'login_locked', ip, detail: 'builder' });
68
109
  flash(s, 'Too many failed sign-in attempts. Try again later.', 'error');
69
- return back(reply, s, `${BASE}/login`);
110
+ return back(reply, s, again);
70
111
  }
71
112
  // bcrypt always runs (dummy salt for unknown users) so timing does not reveal accounts
72
113
  const dev = await owner.one(
@@ -76,25 +117,36 @@ export async function builderRoutes(app: FastifyInstance) {
76
117
  where crypt($2, coalesce(d.password_hash, gen_salt('bf', 10))) = d.password_hash`,
77
118
  [username, password],
78
119
  );
79
- if (!dev) {
80
- await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: 'builder' });
81
- flash(s, 'Invalid username or password.', 'error');
82
- return back(reply, s, `${BASE}/login`);
120
+ // a workspace, when given, must be one of the developer's (administrators: any); the same answer otherwise
121
+ const ws = dev && workspace
122
+ ? await owner.one<{ id: number }>(
123
+ `select w.id from meta.workspace w
124
+ where lower(w.name) = lower($1)
125
+ and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
126
+ or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
127
+ [workspace, dev.username],
128
+ )
129
+ : undefined;
130
+ if (!dev || (workspace && !ws)) {
131
+ await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: workspace ? 'builder (workspace)' : 'builder' });
132
+ flash(s, workspace ? 'Invalid workspace, username or password.' : 'Invalid username or password.', 'error');
133
+ return back(reply, s, again);
83
134
  }
84
135
  await destroySession(reply, s, BASE);
85
136
  const ns = await createSession(reply, null, BASE, dev.username);
86
137
  // the builder theme chosen earlier on this device
87
138
  const theme = validTheme(req.cookies?.[THEME_COOKIE]);
88
- if (theme) {
89
- ns.state.__BTHEME = theme;
90
- await saveState(ns);
91
- }
139
+ if (theme) ns.state.__BTHEME = theme;
140
+ if (ws) ns.state.__WS = String(ws.id);
141
+ if (password === 'admin' || password === dev.username) ns.state.__WEAK = '1';
142
+ await saveState(ns);
143
+ if (req.body?.remember === '1')
144
+ reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
145
+ path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
146
+ });
147
+ else reply.clearCookie(REMEMBER_COOKIE, { path: BASE });
92
148
  logActivity({ username: dev.username, event: 'login', ip, detail: 'builder' });
93
- if (password === 'admin' || password === dev.username) {
94
- ns.state.__WEAK = '1';
95
- await saveState(ns);
96
- }
97
- return reply.redirect(BASE, 303);
149
+ return reply.redirect(next, 303);
98
150
  });
99
151
 
100
152
  app.post(`${BASE}/logout`, async (req: Req, reply) => {
@@ -7,7 +7,7 @@ import { clientIp, logActivity, type Session } from '../session.ts';
7
7
  import { runScript, splitScript, type ScriptRun, type StatementResult } from '../sqlscript.ts';
8
8
  import { clearCompletions } from './code-editor.ts';
9
9
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
10
- import { currentWorkspace, workshopDb } from './workspaces.ts';
10
+ import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
11
11
 
12
12
  // SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
13
13
  // delete), run with a result per statement (stop at the first error or go on,
@@ -32,9 +32,13 @@ export interface RunRequest {
32
32
  /** Run a script, record the run (history + activity log) and return the run's id. */
33
33
  export async function runAndRecord(s: Session, ip: string, r: RunRequest): Promise<{ id: string; run: ScriptRun }> {
34
34
  const statements = splitScript(r.content);
35
- const c = await (await workshopDb(s)).pool.connect();
35
+ const db = await workshopDb(s);
36
+ const schema = await sessionSchema(s, db);
37
+ const c = await db.pool.connect();
36
38
  let run: ScriptRun;
37
39
  try {
40
+ // (sprint 61) the session's schema first in the search path (the SQL Workshop home)
41
+ if (schema) await c.query(`select set_config('search_path', $1, false)`, [`"${schema.replace(/"/g, '""')}", public`]);
38
42
  run = await runScript(c, statements, { stopOnError: r.stopOnError, transaction: r.transaction });
39
43
  } finally {
40
44
  // never hand a connection back to the pool after arbitrary SQL (SET ROLE, search_path, temp tables)
@@ -2,19 +2,94 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import pg from 'pg';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
6
- import { clientIp, logActivity } from '../session.ts';
5
+ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
6
+ import { owner } from '../db.ts';
7
+ import { clientIp, logActivity, type Session } from '../session.ts';
7
8
  import { clearCompletions } from './code-editor.ts';
8
9
  import { scriptRoutes } from './scripts.ts';
9
10
  import { quickSqlRoutes } from './quicksql.ts';
10
11
  import { queryBuilderRoutes } from './querybuilder.ts';
11
12
  import { unloadRoutes } from './unload.ts';
12
13
  import { ddlToolRoutes } from './ddl-tools.ts';
13
- import { workshopDb, workshopRunsAs } from './workspaces.ts';
14
+ import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
14
15
 
15
16
  // SQL Workshop: SQL commands and the object browser (owner connection; an isolated
16
17
  // workspace's developer login for its developers, see workshopDb).
17
18
 
19
+ const ago = (t: string | Date) => {
20
+ const sec = Math.max(0, (Date.now() - new Date(t).getTime()) / 1000);
21
+ const [n, unit] = sec < 60 ? [sec, 'second'] : sec < 3600 ? [sec / 60, 'minute'] : sec < 86400 ? [sec / 3600, 'hour'] : sec < 86400 * 30 ? [sec / 86400, 'day'] : sec < 86400 * 365 ? [sec / 86400 / 30, 'month'] : [sec / 86400 / 365, 'year'];
22
+ const k = Math.floor(n);
23
+ return `${k} ${unit}${k === 1 ? '' : 's'} ago`;
24
+ };
25
+
26
+ /** (sprint 61, APEX: the SQL Workshop home) tiles, the newest tables, recent commands and scripts, the session's schema. */
27
+ async function workshopHome(req: Req, reply: FastifyReply) {
28
+ const s = await developer(req, reply);
29
+ if (!s) return;
30
+ const db = await workshopDb(s);
31
+ const [schemas, schema, tables, commands, scripts] = await Promise.all([
32
+ usableSchemas(db),
33
+ sessionSchema(s, db),
34
+ // PostgreSQL keeps no creation time: object ids grow, so the highest are the newest
35
+ db.query<{ qname: string; schema: string; name: string; rows: number }>(
36
+ `select c.oid::regclass::text as qname, n.nspname as schema, c.relname as name, greatest(c.reltuples, 0)::bigint as rows
37
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
38
+ where c.relkind in ('r', 'p') and not c.relispartition and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
39
+ and has_schema_privilege(n.oid, 'USAGE') and has_table_privilege(c.oid, 'SELECT')
40
+ and not (n.nspname = 'public' and c.relname like 'pgkiln\\_%')
41
+ order by c.oid desc limit 15`,
42
+ ),
43
+ owner.query<{ id: string; detail: string; at: string }>(
44
+ `select id, detail, at from meta.activity_log where username = $1 and event = 'sql_command' order by id desc limit 15`,
45
+ [s.username],
46
+ ),
47
+ owner.query<{ id: number; name: string; updated_at: string }>(
48
+ `select id, name, updated_at from meta.sql_script where workspace_id = $1 order by updated_at desc limit 15`,
49
+ [currentWorkspace(s)],
50
+ ),
51
+ ]);
52
+ const tile = (href: string, ic: string, title: string, text: string) =>
53
+ html`<a class="ab-tile sw-tile" href="${BASE}${href}"><span class="ab-tile-icon sw-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
54
+ const list = (title: string, rows: Raw[], empty: string) =>
55
+ html`<section class="sw-recent"><h2>${title}</h2>${rows.length ? html`<ul>${rows}</ul>` : html`<p class="muted">${empty}</p>`}</section>`;
56
+ const main = html`<div class="ab-home">
57
+ <div class="ab-main">
58
+ <h1 class="sr-only">SQL Workshop</h1>
59
+ ${workshopTabs('home')}
60
+ <div class="ab-tiles sw-tiles">
61
+ ${tile('/sql/objects', 'search', 'Object Browser', 'Browse tables, views and functions, their policies, grants and data.')}
62
+ ${tile('/sql/commands', 'code', 'SQL Commands', 'Run SQL statements against your schemas.')}
63
+ ${tile('/sql/scripts', 'file', 'SQL Scripts', 'Create, upload and run stored SQL scripts.')}
64
+ ${tile('/sql/load', 'upload', 'Utilities', 'Load and unload data, sample data, DDL and schema comparison.')}
65
+ ${tile('/sql/quick', 'bolt', 'Quick SQL', 'Write a data model in shorthand and get the DDL.')}
66
+ </div>
67
+ <div class="sw-recents">
68
+ ${list('Recently created tables', tables.rows.map((t) => html`<li><a href="${BASE}/sql/objects?o=${encodeURIComponent(t.qname)}">${t.qname}</a><span class="muted">~${Number(t.rows).toLocaleString('en')} rows</span></li>`), 'No tables you can read.')}
69
+ ${list('Recent SQL commands', commands.rows.map((c) => html`<li><a href="${BASE}/sql/commands?recent=${c.id}" title="${c.detail.slice(0, 500)}">${c.detail.replace(/\s+/g, ' ').slice(0, 60)}</a><span class="muted">${ago(c.at)}</span></li>`), 'You haven\'t run SQL commands yet.')}
70
+ ${list('Recent SQL scripts', scripts.rows.map((x) => html`<li><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a><span class="muted">${ago(x.updated_at)}</span></li>`), 'No scripts in this workspace yet.')}
71
+ </div>
72
+ </div>
73
+ <aside class="ab-side" aria-label="About, schema and tasks">
74
+ <section><h2>About</h2><p>The SQL Workshop is a collection of tools to manage database objects. It runs as ${workshopRunsAs(db)}.</p>
75
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener">Learn more …</a></section>
76
+ <section><h2>Schema</h2><p>The default schema of this session: first in the search path of SQL Commands and SQL Scripts.</p>
77
+ <form method="post" action="${BASE}/sql/schema" class="sw-schema">${csrf(s)}
78
+ <label class="sr-only" for="f_schema">Schema</label>
79
+ <select id="f_schema" name="schema" data-autosubmit><option value="">(the default search path)</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
80
+ <button class="btn btn-sm">Set</button></form></section>
81
+ <section><h2>Tasks</h2><ul class="ab-links ab-tasks">
82
+ <li><a href="${BASE}/sql/quick">Create a data model with Quick SQL${icon('chevron')}</a></li>
83
+ <li><a href="${BASE}/blueprints">Create an application from a blueprint${icon('chevron')}</a></li>
84
+ <li><a href="${BASE}/sql/ai/describe">Describe tables${icon('chevron')}</a></li>
85
+ <li><a href="${BASE}/sql/sample-data">Generate sample data${icon('chevron')}</a></li>
86
+ <li><a href="${BASE}/sql/ddl">Generate DDL${icon('chevron')}</a></li>
87
+ </ul></section>
88
+ </aside>
89
+ </div>`;
90
+ return send(reply, s, shell(s, 'SQL Workshop', [['SQL Workshop']], main, 'sql'));
91
+ }
92
+
18
93
  export async function sqlRoutes(app: FastifyInstance) {
19
94
  // SQL Scripts, Quick SQL and the query builder (sprint 31)
20
95
  await app.register(scriptRoutes);
@@ -37,6 +112,13 @@ export async function sqlRoutes(app: FastifyInstance) {
37
112
  const sql = req.body?.sql ?? '';
38
113
  let result: Raw | '' = '';
39
114
  const db = await workshopDb(s);
115
+ // (sprint 61) a command from the home page's Recent SQL Commands (the developer's own)
116
+ let text = sql;
117
+ if (req.method === 'GET' && /^\d{1,18}$/.test(String(req.query?.recent ?? ''))) {
118
+ const r = await owner.one<{ detail: string }>(`select detail from meta.activity_log where id = $1 and username = $2 and event = 'sql_command'`, [req.query!.recent, s.username]);
119
+ if (r) text = r.detail;
120
+ }
121
+ const schema = await sessionSchema(s, db);
40
122
  if (req.method === 'POST' && sql.trim()) {
41
123
  const started = performance.now();
42
124
  try {
@@ -45,6 +127,7 @@ export async function sqlRoutes(app: FastifyInstance) {
45
127
  const c = await db.pool.connect();
46
128
  let out;
47
129
  try {
130
+ if (schema) await c.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(schema)}, public`]);
48
131
  out = await c.query({ text: sql, rowMode: 'array' });
49
132
  } finally {
50
133
  c.release(true);
@@ -61,16 +144,32 @@ export async function sqlRoutes(app: FastifyInstance) {
61
144
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: sql.length > 2000 ? `${sql.slice(0, 2000)}…` : sql });
62
145
  }
63
146
  const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('sql')}
64
- <p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role). Multiple statements are allowed; results of the last one are shown.</p>
65
- <form method="post">${csrf(s)}
66
- <textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${sql || 'select current_user, version();'}</textarea>
147
+ <p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role)${schema ? html`, schema <code>${schema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. Multiple statements are allowed; results of the last one are shown.</p>
148
+ <form method="post" action="${BASE}/sql/commands">${csrf(s)}
149
+ <textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${text || 'select current_user, version();'}</textarea>
67
150
  <div class="buttons"><button class="btn btn-hot">${icon('play')} Run (Ctrl+Enter)</button></div>
68
151
  </form>
69
152
  <div class="u-mt1">${result}</div>`;
70
153
  return send(reply, s, shell(s, 'SQL Workshop', [['SQL Workshop']], main, 'sql'));
71
154
  };
72
- app.get(`${BASE}/sql`, sqlWorkshop);
155
+ app.get(`${BASE}/sql/commands`, sqlWorkshop);
156
+ app.post(`${BASE}/sql/commands`, sqlWorkshop);
157
+ // (before sprint 61 SQL Commands was /builder/sql: a post there still runs)
73
158
  app.post(`${BASE}/sql`, sqlWorkshop);
159
+ app.get(`${BASE}/sql`, workshopHome);
160
+
161
+ // (sprint 61) the session's default schema: first in the search path of SQL Commands and SQL Scripts
162
+ app.post(`${BASE}/sql/schema`, async (req: Req, reply) => {
163
+ const s = await developer(req, reply);
164
+ if (!s) return;
165
+ const wanted = String(req.body?.schema ?? '');
166
+ const usable = await usableSchemas(await workshopDb(s));
167
+ if (wanted && !usable.includes(wanted)) return reply.code(404).send('Not found');
168
+ if (wanted) s.state.__SQLSCHEMA = wanted;
169
+ else delete s.state.__SQLSCHEMA;
170
+ flash(s, wanted ? `Schema ${wanted} first in the search path of SQL Commands and SQL Scripts.` : 'The default search path again.');
171
+ return back(reply, s, `${BASE}/sql`);
172
+ });
74
173
 
75
174
  app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
76
175
  const s = await developer(req, reply);
package/src/builder/ui.ts CHANGED
@@ -43,7 +43,7 @@ export async function send(reply: FastifyReply, s: Session, body: string) {
43
43
  return reply.type('text/html').send(body);
44
44
  }
45
45
 
46
- export type Section = 'apps' | 'users' | 'sql' | 'developers';
46
+ export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
47
47
 
48
48
  // ------------------------------------------------------------------ builder head
49
49
  // Builder-only stylesheets and scripts, loaded on every builder page after
@@ -87,8 +87,8 @@ export interface ShellOpts {
87
87
  const initials = (name: string) => name.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase() || '?';
88
88
 
89
89
  /**
90
- * A builder page: the icon rail on the left (App Builder, SQL Workshop, Users,
91
- * Developers, Search, Help, the developer's menu), a compact toolbar with
90
+ * A builder page: the icon rail on the left (App Builder, SQL Workshop, Search;
91
+ * Administration, Help, the developer's menu), an environment banner (091), a compact toolbar with
92
92
  * back button and breadcrumb, then the page itself.
93
93
  */
94
94
  export function shell(s: Session, title: string, crumbs: [string, string?][], main: Raw, section: Section = 'apps', opts: ShellOpts = {}) {
@@ -110,11 +110,30 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
110
110
  <ul class="rail-list">
111
111
  ${rail('apps', BASE, 'builder', 'App Builder')}
112
112
  ${rail('sql', `${BASE}/sql`, 'database', 'SQL Workshop')}
113
- ${rail('users', `${BASE}/users`, 'user', 'Users')}
114
- ${rail('developers', `${BASE}/developers`, 'users', 'Developers')}
113
+ ${rail('gallery', `${BASE}/gallery`, 'package', 'App Gallery')}
115
114
  ${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', 'Search this application') : ''}
116
115
  </ul>
117
116
  <ul class="rail-list rail-bottom">
117
+ <li><details class="menu rail-user rail-admin">
118
+ <summary class="rail-link${section === 'users' || section === 'developers' ? ' rail-open' : ''}" title="Administration"${section === 'users' || section === 'developers' ? raw(' aria-current="page"') : ''}>${icon('settings')}<span class="rail-label">Administration</span></summary>
119
+ <div class="menu-panel">
120
+ <div class="menu-section menu-links">
121
+ <strong>Administration</strong>
122
+ <a href="${BASE}/utilities">${icon('grid')} Workspace utilities</a>
123
+ </div>
124
+ ${s.workspace?.admin ? html`<div class="menu-section menu-links">
125
+ <span class="small muted">Manage service: ${s.workspace.name}</span>
126
+ <a href="${BASE}/workspaces/${s.workspace.id}#service">${icon('bell')} Workspace message and environment banner</a>
127
+ <a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} Workspace utilization</a>
128
+ <a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} Workspace and developers</a>
129
+ </div>` : ''}
130
+ <div class="menu-section menu-links">
131
+ <a href="${BASE}/users">${icon('user')} Manage users and groups</a>
132
+ <a href="${BASE}/developers">${icon('users')} Developers</a>
133
+ <a href="${BASE}/dashboard">${icon('activity')} Monitor activity</a>
134
+ <a href="${BASE}/developers">${icon('key')} Change my password</a>
135
+ </div>
136
+ </div></details></li>
118
137
  ${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', 'Help', raw(' target="_blank" rel="noopener"'))}
119
138
  <li><details class="menu rail-user">
120
139
  <summary class="rail-avatar" title="${s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">Account: ${s.username}</span></summary>
@@ -139,6 +158,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
139
158
  html`<li>${href && i < crumbs.length - 1 ? html`<a href="${href}">${label}</a>` : html`<span aria-current="page">${label}</span>`}</li>`)}</ol></nav>
140
159
  <div class="ide-tools">${opts.toolbar ?? ''}</div>
141
160
  </header>
161
+ ${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="Environment">${s.workspace.banner_label}</div>` : ''}
142
162
  ${s.state.__WEAK || ok || err ? html`<div class="ide-alerts">
143
163
  ${s.state.__WEAK ? html`<div class="alert alert-error" role="alert">You signed in with the default password. <a href="${BASE}/developers">Change it now.</a></div>` : ''}
144
164
  ${ok ? html`<div class="alert alert-success" role="status">${ok}</div>` : ''}
@@ -162,6 +182,9 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
162
182
  );
163
183
  }
164
184
 
185
+ /** (091) the colours of a workspace's environment banner (builder.css draws them) */
186
+ export const BANNER_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'gray'];
187
+
165
188
  export const csrf = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
166
189
 
167
190
  export function input(name: string, label: string, value: unknown, opts: { type?: string; help?: string; required?: boolean; placeholder?: string; auto?: string } = {}) {
@@ -209,12 +232,12 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
209
232
  };
210
233
 
211
234
  /** Tabs of the SQL Workshop. */
212
- export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
235
+ export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
213
236
  export const workshopTabs = (active: WorkshopTab) => {
214
237
  const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
215
238
  html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
216
239
  return html`<nav class="ide-tabs u-mb1" aria-label="SQL Workshop">
217
- ${tab('sql', '/sql', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
240
+ ${tab('home', '/sql', 'home', 'Home')}${tab('sql', '/sql/commands', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
218
241
  ${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
219
242
  };
220
243