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.
@@ -5,9 +5,9 @@ import { ownerUrl, runtimeUrl } from '../db.ts';
5
5
  import { secretKeyConfigured } from '../secrets.ts';
6
6
  import { html, raw } from '../html.ts';
7
7
  import { icon } from '../icons.ts';
8
- import { clientIp, logActivity, type Session } from '../session.ts';
8
+ import { clientIp, logActivity, type BuilderWorkspace, type Session } from '../session.ts';
9
9
  import { isAdmin } from './locks.ts';
10
- import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
10
+ import { back, BANNER_COLORS, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
11
11
 
12
12
  // Workspaces (064; APEX: workspaces). A workspace groups applications and the
13
13
  // developers who build them. developer() loads the developer's workspaces into
@@ -20,10 +20,7 @@ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Re
20
20
  // Not a security boundary between developers who write SQL (the SQL Workshop
21
21
  // and application code run with installation-wide rights): see chapter 3.
22
22
 
23
- export interface WorkspaceRef {
24
- id: number;
25
- name: string;
26
- }
23
+ export type WorkspaceRef = BuilderWorkspace;
27
24
 
28
25
  /** Workspace 1: applications without a meta.workspace_app row belong to it. */
29
26
  export const DEFAULT_WORKSPACE = 1;
@@ -32,7 +29,10 @@ export const DEFAULT_WORKSPACE = 1;
32
29
  export async function loadWorkspaces(s: Session) {
33
30
  const rows = (
34
31
  await owner.query<WorkspaceRef>(
35
- `select w.id, w.name from meta.workspace w
32
+ `select w.id, w.name, w.message, w.banner_label, w.banner_color, w.isolated,
33
+ exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
34
+ or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1 and m.is_admin) as admin
35
+ from meta.workspace w
36
36
  where exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
37
37
  or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1)
38
38
  order by w.id = 1 desc, lower(w.name)`,
@@ -81,6 +81,20 @@ export async function workshopDb(s: Session): Promise<Db> {
81
81
  return (await devDbForWorkspace(s.workspace.id)) ?? owner;
82
82
  }
83
83
 
84
+ /** Schemas the SQL Workshop's connection may use (not pgkiln's, not the system's). */
85
+ export async function usableSchemas(db: Db) {
86
+ return (await db.query<{ n: string }>(
87
+ `select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta')
88
+ and has_schema_privilege(oid, 'USAGE') order by 1`)).rows.map((r) => r.n);
89
+ }
90
+
91
+ /** (sprint 61) The session's default schema (SQL Workshop home), if the connection may still use it. */
92
+ export async function sessionSchema(s: Session, db: Db) {
93
+ const v = s.state.__SQLSCHEMA;
94
+ if (typeof v !== 'string' || !v) return null;
95
+ return (await db.one<{ ok: boolean }>(`select has_schema_privilege(oid, 'USAGE') as ok from pg_namespace where nspname = $1`, [v]))?.ok ? v : null;
96
+ }
97
+
84
98
  /** Who the SQL Workshop runs as, for its notes. */
85
99
  export const workshopRunsAs = (db: Db) => (db === owner ? 'the builder\'s owner connection' : 'this isolated workspace\'s developer login (its applications\' roles, nothing else)');
86
100
 
@@ -179,6 +193,19 @@ function isolationRegion(s: Session, w: { id: number; name: string; isolated: bo
179
193
  return region('Isolation', body);
180
194
  }
181
195
 
196
+ /** (091) Manage service: the workspace message and the environment banner. */
197
+ function serviceRegion(s: Session, w: { id: number; message: string | null; banner_label: string | null; banner_color: string }) {
198
+ return region('Manage service', html`<form method="post" action="${BASE}/workspaces/${w.id}/service">${csrf(s)}
199
+ <div class="field"><label class="label" for="f_message">Workspace message</label>
200
+ <textarea id="f_message" name="message" rows="3" maxlength="2000">${w.message ?? ''}</textarea>
201
+ <small class="help">Shown to this workspace's developers on the App Builder home, e.g. a maintenance window. Plain text.</small></div>
202
+ <div class="form-grid">
203
+ ${input('banner_label', 'Environment banner', w.banner_label ?? '', { placeholder: 'DEVELOPMENT', help: 'A short label shown at the top of every builder page while this workspace is current. Empty: no banner.' })}
204
+ ${select('banner_color', 'Banner colour', w.banner_color, BANNER_COLORS.map((c): [string, string] => [c, c[0].toUpperCase() + c.slice(1)]))}
205
+ </div>
206
+ <div class="buttons"><button class="btn">Save</button></div></form>`, '', 'service');
207
+ }
208
+
182
209
  export async function workspaceRoutes(app: FastifyInstance) {
183
210
  // switch the current workspace (any of the developer's own)
184
211
  app.post(`${BASE}/workspace`, async (req: Req, reply) => {
@@ -288,6 +315,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
288
315
  </div><small class="help">They manage this workspace's name and developers. Isolation, database roles, moving applications and deleting the workspace stay with administrators.</small></fieldset>
289
316
  <div class="buttons"><button class="btn">Save developers</button></div></form>`)}
290
317
  </div>
318
+ ${serviceRegion(s, w)}
291
319
  ${full ? isolationRegion(s, w, roles.rows) : region('Isolation', html`<p class="u-mt0">${w.isolated ? 'Isolated: its applications and SQL run on database logins of its own.' : 'Shared.'} Administrators change it.</p>`)}
292
320
  ${!full ? '' : region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
293
321
  <thead><tr><th>Application</th><th>Move to</th></tr></thead>
@@ -361,6 +389,8 @@ export async function workspaceRoutes(app: FastifyInstance) {
361
389
  await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [to.id, role]);
362
390
  }
363
391
  await placeApp(c, appId, to.id);
392
+ // (092) groups are the old workspace's
393
+ await c.query('delete from meta.app_group_member where app_id = $1', [appId]);
364
394
  });
365
395
  } catch (e) {
366
396
  flash(s, `Not moved: ${roleError(e)}`, 'error');
@@ -443,4 +473,83 @@ export async function workspaceRoutes(app: FastifyInstance) {
443
473
  }
444
474
  return back(reply, s, `${BASE}/workspaces/${id}`);
445
475
  });
476
+
477
+ // (091) the workspace message and environment banner
478
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/service`, async (req: Req, reply) => {
479
+ const s = await developer(req, reply);
480
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
481
+ const message = String(req.body?.message ?? '').replace(/\r\n/g, '\n').trim();
482
+ const label = String(req.body?.banner_label ?? '').trim();
483
+ const color = BANNER_COLORS.includes(String(req.body?.banner_color)) ? String(req.body?.banner_color) : 'blue';
484
+ try {
485
+ if (message.length > 2000) throw new Error('The message is at most 2000 characters.');
486
+ if (label && !/^\S(.{0,38}\S)?$/.test(label)) throw new Error('The banner label is at most 40 characters.');
487
+ const r = await owner.query('update meta.workspace set message = $2, banner_label = $3, banner_color = $4 where id = $1', [req.params.id, message || null, label || null, color]);
488
+ if (!r.rowCount) return reply.code(404).send('Not found');
489
+ await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${req.params.id}: message and banner` });
490
+ flash(s, 'Message and banner saved.');
491
+ } catch (e) {
492
+ flash(s, (e as Error).message, 'error');
493
+ }
494
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
495
+ });
496
+
497
+ // (091) workspace utilization: its applications, their size and use, its schemas' storage
498
+ app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)/utilization`, async (req: Req, reply) => {
499
+ const s = await developer(req, reply);
500
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
501
+ const w = await owner.one<{ id: number; name: string }>('select id, name from meta.workspace where id = $1', [req.params.id]);
502
+ if (!w) return reply.code(404).send('Not found');
503
+ const [apps, schemas, totals] = await Promise.all([
504
+ owner.query(
505
+ `select a.id, a.name, a.alias, a.db_role,
506
+ (select count(*) from meta.page p where p.app_id = a.id)::int as pages,
507
+ (select count(*) from meta.region r join meta.page p on p.id = r.page_id where p.app_id = a.id)::int as regions,
508
+ (select count(*) from meta.item i join meta.page p on p.id = i.page_id where p.app_id = a.id)::int as items,
509
+ (select count(*) from meta.activity_log l where l.app_id = a.id and l.event = 'page_view' and l.at > now() - interval '30 days')::int as views,
510
+ (select count(distinct l.username) from meta.activity_log l where l.app_id = a.id and l.event = 'page_view' and l.at > now() - interval '30 days')::int as users,
511
+ (select coalesce(sum(octet_length(f.content)), 0) from meta.static_file f where f.app_id = a.id)::bigint as files
512
+ from meta.app a where meta.app_workspace(a.id) = $1 order by lower(a.name)`,
513
+ [w.id],
514
+ ),
515
+ owner.query(
516
+ `select n.nspname as schema, n.nspowner::regrole::text as owner, count(c.oid)::int as tables,
517
+ coalesce(sum(pg_total_relation_size(c.oid)), 0)::bigint as bytes
518
+ from pg_namespace n left join pg_class c on c.relnamespace = n.oid and c.relkind in ('r', 'p', 'm')
519
+ where n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
520
+ and exists (select 1 from (select db_role as r from meta.app where meta.app_workspace(id) = $1 and db_role is not null
521
+ union select role_name from meta.workspace_role where workspace_id = $1) x
522
+ join pg_roles rl on rl.rolname = x.r
523
+ where has_schema_privilege(rl.oid, n.oid, 'USAGE'))
524
+ group by 1, 2 order by 1`,
525
+ [w.id],
526
+ ),
527
+ owner.one(
528
+ `select (select count(*) from meta.workspace_member where workspace_id = $1)::int as developers,
529
+ (select count(*) from meta.sql_script where workspace_id = $1)::int as scripts`,
530
+ [w.id],
531
+ ),
532
+ ]);
533
+ const size = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`);
534
+ const sum = (k: string) => apps.rows.reduce((t, r) => t + Number(r[k]), 0);
535
+ const main = html`<div class="title-row"><h1>Workspace utilization: ${w.name}</h1></div>
536
+ <div class="stat-grid u-mb1">
537
+ ${[['Applications', apps.rows.length], ['Pages', sum('pages')], ['Developers', totals.developers], ['Page views (30 days)', sum('views')], ['SQL scripts', totals.scripts]]
538
+ .map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
539
+ </div>
540
+ ${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
541
+ <thead><tr><th>Application</th><th class="num">Pages</th><th class="num">Regions</th><th class="num">Items</th><th class="num">Page views (30 days)</th><th class="num">Users (30 days)</th><th class="num">Static files</th></tr></thead>
542
+ <tbody>${apps.rows.length ? apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
543
+ <td class="num" data-label="Pages">${a.pages}</td><td class="num" data-label="Regions">${a.regions}</td><td class="num" data-label="Items">${a.items}</td>
544
+ <td class="num" data-label="Page views">${a.views}</td><td class="num" data-label="Users">${a.users}</td><td class="num" data-label="Static files">${size(Number(a.files))}</td></tr>`)
545
+ : html`<tr><td colspan="7" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}
546
+ <div class="u-spacer"></div>
547
+ ${region('Schemas', html`<p class="muted u-mt0">The schemas the workspace's database roles may use, with the storage of their tables, indexes and TOAST data.</p>
548
+ <div class="table-wrap"><table class="report report-reflow">
549
+ <thead><tr><th>Schema</th><th>Owner</th><th class="num">Tables</th><th class="num">Storage</th></tr></thead>
550
+ <tbody>${schemas.rows.length ? schemas.rows.map((r) => html`<tr><td data-label="Schema">${r.schema}</td><td data-label="Owner"><code>${r.owner}</code></td>
551
+ <td class="num" data-label="Tables">${r.tables}</td><td class="num" data-label="Storage">${size(Number(r.bytes))}</td></tr>`)
552
+ : html`<tr><td colspan="4" class="empty">No schemas: the workspace's applications have no database role of their own.</td></tr>`}</tbody></table></div>`)}`;
553
+ return send(reply, s, shell(s, `Utilization: ${w.name}`, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [w.name, `${BASE}/workspaces/${w.id}`], ['Utilization']], main));
554
+ });
446
555
  }
@@ -55,6 +55,8 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
55
55
  'blueprint',
56
56
  // (064) the workspace of the application
57
57
  'workspace_app',
58
+ // (092) the application's group in the App Builder
59
+ 'app_group_member',
58
60
  // (074) push notifications: the app's VAPID keys, users' devices, queued notifications
59
61
  'push_key', 'push_subscription', 'push_message',
60
62
  // (082) the application's backups, (084) users' feedback
@@ -185,9 +185,10 @@ export async function renderCalendar(ctx: PageContext, r: Region): Promise<Raw>
185
185
  const c = ctx.client!;
186
186
  const res = await savepoint(c, () =>
187
187
  c.query(
188
+ // end_date is optional: read through to_jsonb, so a query without one works
188
189
  `select * from (\n${src}\n) "__q"
189
190
  where "__q".start_date < ${literal(iso(to))}::date
190
- and coalesce("__q".end_date, "__q".start_date) >= ${literal(iso(from))}::date
191
+ and coalesce(nullif(to_jsonb("__q") ->> 'end_date', '')::timestamptz, "__q".start_date::timestamptz) >= ${literal(iso(from))}::date
191
192
  order by "__q".start_date limit 2000`,
192
193
  ),
193
194
  );
package/src/session.ts CHANGED
@@ -4,6 +4,17 @@ import { runtimeForApp } from './workspace-db.ts';
4
4
  import { hashToken, loginMaxFailuresPerIp, loginMaxFailuresPerUser, loginWindowMinutes, newToken } from './security.ts';
5
5
  import { instanceSetting } from './instance.ts';
6
6
 
7
+ /** A developer's workspace in the builder (src/builder/workspaces.ts loadWorkspaces). */
8
+ export interface BuilderWorkspace {
9
+ id: number;
10
+ name: string;
11
+ message?: string | null;
12
+ banner_label?: string | null;
13
+ banner_color?: string;
14
+ isolated?: boolean;
15
+ admin?: boolean;
16
+ }
17
+
7
18
  export interface Session {
8
19
  id: string; // internal id, exposed to SQL as pgkiln.session_id
9
20
  app_id: number | null;
@@ -14,8 +25,8 @@ export interface Session {
14
25
  roles: string[];
15
26
  isNew?: boolean;
16
27
  /** Builder only: the developer's workspaces and the current one (src/builder/workspaces.ts). */
17
- workspaces?: { id: number; name: string }[];
18
- workspace?: { id: number; name: string } | null;
28
+ workspaces?: BuilderWorkspace[];
29
+ workspace?: BuilderWorkspace | null;
19
30
  /** Builder only (079): sha256 of the developer toolbar token (src/runtime/devbar.ts). */
20
31
  devbar_hash?: string | null;
21
32
  /** (085) when the session started, in ms since the epoch (its maximum length counts from here) */