pgkiln 0.70.0 → 0.72.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/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/db/migrations/139_opentelemetry.sql +36 -0
- package/docs/apex-feature-parity.md +9 -7
- package/docs/guide/01-installation.md +108 -0
- package/docs/guide/03-builder.md +116 -4
- package/docs/guide/04-pages-and-regions.md +3 -1
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +18 -1
- package/docs/guide/12-development.md +35 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/app.js +50 -0
- package/public/builder.css +48 -0
- package/src/ai/service.ts +14 -0
- package/src/app.ts +20 -0
- package/src/builder/advisor.ts +2 -2
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +3 -1
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/routes.ts +13 -2
- package/src/builder/telemetry.ts +206 -0
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/wizards.ts +211 -26
- package/src/builder/workspaces.ts +2 -1
- package/src/cli/replace.ts +9 -1
- package/src/history.ts +155 -0
- package/src/instance-checks.ts +11 -4
- package/src/layout.ts +2 -1
- package/src/mail/mail.ts +14 -0
- package/src/runtime/ai-agents.ts +9 -0
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- package/src/runtime/routes.ts +3 -0
- package/src/session.ts +26 -0
- package/src/telemetry/browser.ts +91 -0
- package/src/telemetry/config.ts +342 -0
- package/src/telemetry/export.ts +484 -0
- package/src/telemetry/http.ts +126 -0
- package/src/telemetry/trace.ts +456 -0
- package/src/webclient.ts +25 -0
- package/src/workflow.ts +14 -7
package/src/history.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// (sprint 138, AP-5) Change history per component (APEX: Application Utilities ›
|
|
2
|
+
// Change History) and application checksums (APEX: Application Checksum).
|
|
3
|
+
//
|
|
4
|
+
// meta.app_snapshot keeps an application's export after its last recorded
|
|
5
|
+
// change. After a builder change (app-updates.ts, onSend) and inside an import
|
|
6
|
+
// --replace (cli/replace.ts: restores, merges, the command line and the MCP
|
|
7
|
+
// server), recordHistory() exports the application again, splits both exports
|
|
8
|
+
// into components the way working copies compare them (workingcopy.ts
|
|
9
|
+
// components(): a region with its SQL, a page's settings, a list of values, …)
|
|
10
|
+
// and stores each added, changed or deleted component with its files before and
|
|
11
|
+
// after in meta.component_change (migration 138). The first change of an
|
|
12
|
+
// application without a snapshot has nothing to compare with: ensureSnapshot()
|
|
13
|
+
// takes one before a builder POST runs (a preHandler hook).
|
|
14
|
+
//
|
|
15
|
+
// appChecksum(): a SHA-256 over the export's files (the text layout, without the
|
|
16
|
+
// format marker and with the alias blanked), so two installations, or an
|
|
17
|
+
// application and its working copy, compare at a glance; pageChecksums() per page.
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import type pg from 'pg';
|
|
20
|
+
import { docToFiles, MARKER, type Doc, type FileMap } from './appfiles.ts';
|
|
21
|
+
import { owner } from './db.ts';
|
|
22
|
+
import { components, describe } from './workingcopy.ts';
|
|
23
|
+
|
|
24
|
+
type Db = { query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult<any>> };
|
|
25
|
+
|
|
26
|
+
/** Days a change stays in the history (CHANGE_HISTORY_DAYS, default 365; 0 keeps everything). */
|
|
27
|
+
export const historyDays = () => {
|
|
28
|
+
const n = Number(process.env.CHANGE_HISTORY_DAYS ?? 365);
|
|
29
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 365;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The export as files of the text layout (SQL inline in YAML: readable diffs), the alias blanked. */
|
|
33
|
+
export function appFiles(doc: Doc): FileMap {
|
|
34
|
+
const d = { ...doc, app: { ...(doc.app ?? {}), alias: '' } };
|
|
35
|
+
const files = docToFiles(d, 'text');
|
|
36
|
+
files.delete(MARKER);
|
|
37
|
+
return files;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isText = (b: Buffer) => !b.subarray(0, 8000).includes(0);
|
|
41
|
+
const asText = (files: Map<string, Buffer> | undefined) =>
|
|
42
|
+
files ? Object.fromEntries([...files].map(([p, b]) => [p, isText(b) ? b.toString('utf8') : `(binary, ${b.length} bytes, sha256 ${createHash('sha256').update(b).digest('hex').slice(0, 16)})`])) : null;
|
|
43
|
+
|
|
44
|
+
const sameFiles = (a?: Map<string, Buffer>, b?: Map<string, Buffer>) => {
|
|
45
|
+
if (!a || !b) return !a && !b;
|
|
46
|
+
if (a.size !== b.size) return false;
|
|
47
|
+
for (const [p, buf] of a) if (!b.get(p)?.equals(buf)) return false;
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface ComponentDiff {
|
|
52
|
+
component: string;
|
|
53
|
+
label: string;
|
|
54
|
+
page: number | null;
|
|
55
|
+
op: 'added' | 'changed' | 'deleted';
|
|
56
|
+
before: Record<string, string> | null;
|
|
57
|
+
after: Record<string, string> | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The components that differ between two exports. */
|
|
61
|
+
export function diffDocs(before: Doc, after: Doc): ComponentDiff[] {
|
|
62
|
+
const a = components(appFiles(before)), b = components(appFiles(after));
|
|
63
|
+
const out: ComponentDiff[] = [];
|
|
64
|
+
for (const id of [...new Set([...a.keys(), ...b.keys()])].sort()) {
|
|
65
|
+
const x = a.get(id), y = b.get(id);
|
|
66
|
+
if (sameFiles(x, y)) continue;
|
|
67
|
+
const { label, page } = describe(id);
|
|
68
|
+
out.push({ component: id, label, page, op: !x ? 'added' : !y ? 'deleted' : 'changed', before: asText(x), after: asText(y) });
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const exportDoc = async (db: Db, appId: number): Promise<Doc | null> =>
|
|
74
|
+
(await db.query('select meta.export_app(alias) as d from meta.app where id = $1', [appId])).rows[0]?.d ?? null;
|
|
75
|
+
|
|
76
|
+
/** Take a first snapshot of an application that has none (before its first recorded change); with `db`, inside its transaction. */
|
|
77
|
+
export async function ensureSnapshot(appId: number, db?: pg.ClientBase) {
|
|
78
|
+
const sql = `insert into meta.app_snapshot (app_id, doc) select id, meta.export_app(alias) from meta.app
|
|
79
|
+
where id = $1 and not exists (select 1 from meta.app_snapshot where app_id = $1)
|
|
80
|
+
on conflict (app_id) do nothing`;
|
|
81
|
+
if (!db) return void (await owner.query(sql, [appId]));
|
|
82
|
+
await db.query('savepoint history_snapshot');
|
|
83
|
+
try {
|
|
84
|
+
await db.query(sql, [appId]);
|
|
85
|
+
await db.query('release savepoint history_snapshot');
|
|
86
|
+
} catch {
|
|
87
|
+
await db.query('rollback to savepoint history_snapshot');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compare the application with its snapshot, record what changed and keep the new snapshot.
|
|
93
|
+
* With `db` a client in the caller's transaction (an import --replace), else a transaction of its own.
|
|
94
|
+
*/
|
|
95
|
+
export async function recordHistory(appId: number, changedBy: string | null, source: 'builder' | 'import' | 'upgrade' = 'builder', db?: pg.ClientBase): Promise<number> {
|
|
96
|
+
const run = async (c: Db) => {
|
|
97
|
+
// one change at a time per application: the snapshot row is the lock
|
|
98
|
+
const snap = (await c.query('select doc from meta.app_snapshot where app_id = $1 for update', [appId])).rows[0];
|
|
99
|
+
const now = await exportDoc(c, appId);
|
|
100
|
+
if (!now) return 0;
|
|
101
|
+
if (!snap) {
|
|
102
|
+
await c.query('insert into meta.app_snapshot (app_id, doc) values ($1, $2) on conflict (app_id) do update set doc = excluded.doc, taken_at = now()', [appId, now]);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
const changes = diffDocs(snap.doc, now);
|
|
106
|
+
if (!changes.length) return 0;
|
|
107
|
+
await c.query(
|
|
108
|
+
`insert into meta.component_change (app_id, component, label, page_no, op, before, after, changed_by, source)
|
|
109
|
+
select $1, x.component, x.label, x.page, x.op, x.before, x.after, $2, $3
|
|
110
|
+
from jsonb_to_recordset($4::jsonb) as x(component text, label text, page int, op text, before jsonb, after jsonb)`,
|
|
111
|
+
[appId, changedBy?.slice(0, 200) ?? null, source, JSON.stringify(changes)],
|
|
112
|
+
);
|
|
113
|
+
await c.query('update meta.app_snapshot set doc = $2, taken_at = now() where app_id = $1', [appId, now]);
|
|
114
|
+
const days = historyDays();
|
|
115
|
+
if (days) await c.query(`delete from meta.component_change where app_id = $1 and changed_at < now() - make_interval(days => $2)`, [appId, days]);
|
|
116
|
+
return changes.length;
|
|
117
|
+
};
|
|
118
|
+
if (db) {
|
|
119
|
+
await db.query('savepoint history');
|
|
120
|
+
try {
|
|
121
|
+
const n = await run(db);
|
|
122
|
+
await db.query('release savepoint history');
|
|
123
|
+
return n;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
await db.query('rollback to savepoint history');
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return owner.tx(run);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** SHA-256 of a set of files (paths and contents, in path order). */
|
|
133
|
+
function hashFiles(files: [string, Buffer][]) {
|
|
134
|
+
const h = createHash('sha256');
|
|
135
|
+
for (const [p, b] of files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(p).update('\0').update(b).update('\0');
|
|
136
|
+
return h.digest('hex');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The application checksum of an export (the same definition gives the same checksum on any installation). */
|
|
140
|
+
export const appChecksum = (doc: Doc) => hashFiles([...appFiles(doc)]);
|
|
141
|
+
|
|
142
|
+
/** A checksum per page: page number → the hash of the page's files. */
|
|
143
|
+
export function pageChecksums(doc: Doc): Map<number, string> {
|
|
144
|
+
const byPage = new Map<number, [string, Buffer][]>();
|
|
145
|
+
for (const [id, files] of components(appFiles(doc))) {
|
|
146
|
+
const page = id.startsWith('pages/') ? describe(id).page : null;
|
|
147
|
+
if (page === null) continue;
|
|
148
|
+
if (!byPage.has(page)) byPage.set(page, []);
|
|
149
|
+
byPage.get(page)!.push(...files);
|
|
150
|
+
}
|
|
151
|
+
return new Map([...byPage].sort(([a], [b]) => a - b).map(([p, f]) => [p, hashFiles(f)]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A short form for display (the first 16 hex digits). */
|
|
155
|
+
export const shortSum = (sum: string) => sum.slice(0, 16);
|
package/src/instance-checks.ts
CHANGED
|
@@ -36,16 +36,23 @@ export interface SecretColumn {
|
|
|
36
36
|
schema: string;
|
|
37
37
|
table: string;
|
|
38
38
|
column: string;
|
|
39
|
+
/** (sprint 139) only the rows that match (meta.setting: the settings named *_enc) */
|
|
40
|
+
where?: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** (sprint 139) instance settings that hold a secret: meta.instance_setting rows named *_enc (otel_headers_enc) */
|
|
44
|
+
const SETTING_SECRETS: SecretColumn = { schema: 'meta', table: 'instance_setting', column: 'value', where: `name like '%\\_enc'` };
|
|
45
|
+
|
|
41
46
|
export async function encryptedColumns(): Promise<SecretColumn[]> {
|
|
42
|
-
return (await owner.query<SecretColumn>(
|
|
47
|
+
return [...(await owner.query<SecretColumn>(
|
|
43
48
|
`select table_schema as schema, table_name as table, column_name as column from information_schema.columns
|
|
44
49
|
where table_schema = 'meta' and data_type = 'text'
|
|
45
50
|
and (column_name like '%\\_enc' or (table_name = 'push_key' and column_name = 'private_key'))
|
|
46
|
-
order by 2, 3`)).rows;
|
|
51
|
+
order by 2, 3`)).rows, SETTING_SECRETS];
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
const rowsOf = (c: SecretColumn) => `${quoteIdent(c.column)} is not null${c.where ? ` and (${c.where})` : ''}`;
|
|
55
|
+
|
|
49
56
|
const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
|
|
50
57
|
const tableOf = (c: SecretColumn) => `${quoteIdent(c.schema)}.${quoteIdent(c.table)}`;
|
|
51
58
|
|
|
@@ -54,7 +61,7 @@ export async function secretStatus(withKey?: string) {
|
|
|
54
61
|
const out: (SecretColumn & { stored: number; unreadable: number })[] = [];
|
|
55
62
|
const usable = (withKey ?? process.env.PGKILN_SECRET_KEY ?? '').length >= 32;
|
|
56
63
|
for (const c of await encryptedColumns()) {
|
|
57
|
-
const rows = (await owner.query<{ v: string }>(`select ${quoteIdent(c.column)} as v from ${tableOf(c)} where ${
|
|
64
|
+
const rows = (await owner.query<{ v: string }>(`select ${quoteIdent(c.column)} as v from ${tableOf(c)} where ${rowsOf(c)}`)).rows;
|
|
58
65
|
let unreadable = 0;
|
|
59
66
|
for (const r of rows) {
|
|
60
67
|
try {
|
|
@@ -228,7 +235,7 @@ export async function rotateKey(oldKey: string, newKey: string, { dryRun = false
|
|
|
228
235
|
const failed: string[] = [];
|
|
229
236
|
for (const col of cols) {
|
|
230
237
|
const rows = (await c.query<{ ctid: string; v: string }>(
|
|
231
|
-
`select ctid::text as ctid, ${quoteIdent(col.column)} as v from ${tableOf(col)} where ${
|
|
238
|
+
`select ctid::text as ctid, ${quoteIdent(col.column)} as v from ${tableOf(col)} where ${rowsOf(col)} for update`)).rows;
|
|
232
239
|
let rotated = 0;
|
|
233
240
|
let already = 0;
|
|
234
241
|
for (const r of rows) {
|
package/src/layout.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { html, type Raw } from './html.ts';
|
|
2
2
|
import { staticUrl } from './static-version.ts';
|
|
3
|
+
import { browserTraceMeta } from './telemetry/browser.ts';
|
|
3
4
|
|
|
4
5
|
/** Attributes of <html>: language, text direction and a fixed theme (light/dark; absent = follow the OS). */
|
|
5
6
|
export interface RootAttrs {
|
|
@@ -20,7 +21,7 @@ export function documentShell(title: string, body: Raw, bodyClass = '', data: Re
|
|
|
20
21
|
<title>${title}</title>
|
|
21
22
|
<link rel="stylesheet" href="${staticUrl('app.css')}">
|
|
22
23
|
<script src="${staticUrl('app.js')}" defer></script>
|
|
23
|
-
${head}
|
|
24
|
+
${browserTraceMeta()}${head}
|
|
24
25
|
</head>
|
|
25
26
|
<body class="${bodyClass}"${attrs}>
|
|
26
27
|
${body}
|
package/src/mail/mail.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { owner } from '../db.ts';
|
|
|
14
14
|
import { decryptSecret } from '../secrets.ts';
|
|
15
15
|
import { mailHostAddress, scopeForWorkspace } from '../network-acl.ts';
|
|
16
16
|
import { deliver, MailError, verifyConnection, type OutgoingMail, type Provider, type ServiceSettings } from './providers.ts';
|
|
17
|
+
import { count, markError, rootSpan, setAttrs, traceApp } from '../telemetry/trace.ts';
|
|
17
18
|
|
|
18
19
|
export interface MailService {
|
|
19
20
|
id: number;
|
|
@@ -95,6 +96,14 @@ interface Claimed {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
async function sendOne(m: Claimed) {
|
|
99
|
+
// (sprint 139, AP-6) each attempt a trace of its own (or a span of the request that sends at once)
|
|
100
|
+
return rootSpan('mail send', { 'pgkiln.mail.id': String(m.id), 'pgkiln.mail.attempt': m.attempts }, async () => {
|
|
101
|
+
traceApp({ appId: m.app_id });
|
|
102
|
+
await sendOneTraced(m);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function sendOneTraced(m: Claimed) {
|
|
98
107
|
// the application's service, else (sprint 118) its workspace's own
|
|
99
108
|
const service = await owner.one<MailService>(`select s.* from meta.mail_service s where s.id = meta.mail_service_for_app($1)`, [m.app_id]);
|
|
100
109
|
const t0 = performance.now();
|
|
@@ -112,10 +121,15 @@ async function sendOne(m: Claimed) {
|
|
|
112
121
|
await owner.query(`update meta.mail_queue set status = 'sent', sent_at = now(), provider_id = $2, service = $3, message = null,
|
|
113
122
|
last_attempt_at = now(), duration_ms = $4, provider = $5 where id = $1`,
|
|
114
123
|
[m.id, id ? String(id).slice(0, 500) : null, service.name, ms, provider]);
|
|
124
|
+
setAttrs({ 'pgkiln.mail.provider': provider, 'pgkiln.mail.service': service.name });
|
|
125
|
+
count('pgkiln.mail.sent', { status: 'sent' });
|
|
115
126
|
} catch (e) {
|
|
116
127
|
const retry = e instanceof MailError ? e.retry : true;
|
|
117
128
|
const message = (e as Error).message.slice(0, 2000);
|
|
118
129
|
const [ms, provider] = timing();
|
|
130
|
+
setAttrs({ 'pgkiln.mail.provider': provider, 'pgkiln.mail.service': service?.name ?? null, 'pgkiln.mail.retry': retry && m.attempts < ATTEMPTS });
|
|
131
|
+
markError(message);
|
|
132
|
+
count('pgkiln.mail.sent', { status: retry && m.attempts < ATTEMPTS ? 'retry' : 'error' });
|
|
119
133
|
if (retry && m.attempts < ATTEMPTS)
|
|
120
134
|
await owner.query(`update meta.mail_queue set status = 'queued', not_before = now() + make_interval(mins => $2), message = $3, service = $4,
|
|
121
135
|
last_attempt_at = now(), duration_ms = $5, provider = $6 where id = $1`,
|
package/src/runtime/ai-agents.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { isAuthorized, pageAllowed } from './authz.ts';
|
|
|
8
8
|
import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
|
|
9
9
|
import { txContext } from './routes.ts';
|
|
10
10
|
import { retrieve } from './search.ts';
|
|
11
|
+
import { recordSpan } from '../telemetry/trace.ts';
|
|
11
12
|
|
|
12
13
|
// AI agents and AI tools (migration 121; APEX 26.1 / 26.2: Shared Components
|
|
13
14
|
// → AI Agents). Both are lists in meta.app (ai_agents, ai_tools), exported
|
|
@@ -397,6 +398,14 @@ export async function logToolCall(
|
|
|
397
398
|
ctx: PageContext, regionId: number, agent: string | null, tool: string, type: string, input: Record<string, unknown>, out: AgentOutcome, ms: number,
|
|
398
399
|
) {
|
|
399
400
|
const status = out.refused ? 'refused' : out.isError ? 'error' : 'ok';
|
|
401
|
+
// (sprint 139, AP-6) the tool call as a span of the page's trace (its arguments only where values are included)
|
|
402
|
+
recordSpan(`AI tool ${tool}`.slice(0, 120), {
|
|
403
|
+
ms,
|
|
404
|
+
kind: 1,
|
|
405
|
+
attrs: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': tool, 'gen_ai.tool.type': type, 'pgkiln.ai.agent': agent, 'pgkiln.ai.tool.status': status },
|
|
406
|
+
vals: { 'gen_ai.tool.call.arguments': JSON.stringify(input ?? {}).slice(0, 2000) },
|
|
407
|
+
error: status === 'ok' ? null : status,
|
|
408
|
+
});
|
|
400
409
|
let args = JSON.stringify(input ?? {});
|
|
401
410
|
if (args.length > MAX_ARG_CHARS) args = JSON.stringify({ cut_off: args.slice(0, MAX_ARG_CHARS) });
|
|
402
411
|
dbg(ctx, 4, 'ai', () => `AI tool ${tool} (${type}${agent ? `, agent ${agent}` : ''}) ${args} → ${status} in ${Math.round(ms)} ms${out.isError ? `: ${out.content.slice(0, 300)}` : ''}`);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { TemplateComponent } from './template-components.ts';
|
|
2
2
|
|
|
3
3
|
// Built-in template components (APEX: the Universal Theme's template
|
|
4
|
-
// components: Avatar, Badge, Comments,
|
|
5
|
-
// Metric Card). Every application can use them in a template_component
|
|
4
|
+
// components: Avatar, Badge, Comments, Content Row (137), Media List,
|
|
5
|
+
// Timeline, and 26.1's Metric Card). Every application can use them in a template_component
|
|
6
6
|
// region or as a report column template without importing anything; a
|
|
7
7
|
// component of the application with the same static id takes its place
|
|
8
8
|
// (Shared Components → Template components → Copy into this application).
|
|
@@ -53,6 +53,27 @@ export const BUILTIN_COMPONENTS: readonly TemplateComponent[] = [
|
|
|
53
53
|
css_classes: [],
|
|
54
54
|
attributes: [text('USER', 'User'), text('INITIALS', 'Initials'), text('DATE', 'Date'), text('COMMENT', 'Comment'), text('ACTIONS', 'Extra text', 'Optional, e.g. "edited".')],
|
|
55
55
|
},
|
|
56
|
+
{
|
|
57
|
+
// (137) APEX: Content Row: an avatar, a title and description, and on the right a secondary text and a badge
|
|
58
|
+
static_id: 'ut_content_row',
|
|
59
|
+
name: 'Content row',
|
|
60
|
+
description: 'Rows with initials or a picture, a title (linked when the region has a link) and a description, and on the right a secondary text and a badge. Show the region as "multiple".',
|
|
61
|
+
template:
|
|
62
|
+
'<li class="tc-content-row">{if ?IMAGE/}<img class="tc-avatar tc-avatar-md" src="#IMAGE#" alt="" loading="lazy">{else/}<span class="tc-avatar tc-avatar-md" aria-hidden="true">#INITIALS#</span>{endif/}' +
|
|
63
|
+
'<div class="tc-stack"><p class="tc-title">{if ?LINK/}<a href="#LINK#">#TITLE#</a>{else/}#TITLE#{endif/}</p>{if ?DESCRIPTION/}<p class="tc-meta">#DESCRIPTION#</p>{endif/}</div>' +
|
|
64
|
+
'<div class="tc-content-row-end">{if ?MISC/}<span class="tc-meta">#MISC#</span>{endif/}{if ?BADGE/}<span class="tc-badge tc-badge-' + STATE + '">#BADGE#</span>{endif/}</div></li>',
|
|
65
|
+
wrapper: '<ul class="tc-content-rows">#APEX$ROWS#</ul>',
|
|
66
|
+
css_classes: [],
|
|
67
|
+
attributes: [
|
|
68
|
+
text('TITLE', 'Title'),
|
|
69
|
+
text('DESCRIPTION', 'Description'),
|
|
70
|
+
text('MISC', 'Secondary text', 'Shown on the right, e.g. a date or an amount.'),
|
|
71
|
+
text('INITIALS', 'Initials', 'Shown without a picture.'),
|
|
72
|
+
text('IMAGE', 'Picture URL', 'Optional: a relative or https URL.'),
|
|
73
|
+
text('BADGE', 'Badge'),
|
|
74
|
+
text('STATE', 'Badge state', 'success, warning, danger, info or neutral.'),
|
|
75
|
+
],
|
|
76
|
+
},
|
|
56
77
|
{
|
|
57
78
|
static_id: 'ut_media_list',
|
|
58
79
|
name: 'Media list',
|
package/src/runtime/items.ts
CHANGED
|
@@ -309,9 +309,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
309
309
|
case 'checkbox_group': {
|
|
310
310
|
useLegend = true;
|
|
311
311
|
const selected = new Set(splitValues(value));
|
|
312
|
-
|
|
312
|
+
// (137) {"switches": true}: each value as a switch (the Configuration page's build options)
|
|
313
|
+
const switches = item.config?.switches === true;
|
|
314
|
+
control = html`<div class="radio-group${switches ? ' switch-group' : ''}">${options.map(
|
|
313
315
|
(o, i) =>
|
|
314
|
-
html`<label class="check"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
|
|
316
|
+
html`<label class="check${switches ? ' switch' : ''}"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${switches ? raw(' role="switch"') : ''}${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
|
|
315
317
|
)}</div>`;
|
|
316
318
|
break;
|
|
317
319
|
}
|
package/src/runtime/routes.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { availabilityRefusal } from './availability.ts';
|
|
|
55
55
|
import { feedbackRoutes } from './feedback.ts';
|
|
56
56
|
import { dataItemRoutes, loadPreferences, savePreferences } from './data-items.ts';
|
|
57
57
|
import { ajaxRoutes } from './ajax.ts';
|
|
58
|
+
import { traceApp } from '../telemetry/trace.ts';
|
|
58
59
|
|
|
59
60
|
const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
60
61
|
|
|
@@ -266,6 +267,8 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
|
|
|
266
267
|
if (!page) return simplePage(reply, 404, locale.t('error.not_found'), locale.t('error.page_not_found', { page: req.params.page, app: app.name }), base, locale), null;
|
|
267
268
|
if (locale.lang !== app.language) translatePage(page, locale.tr);
|
|
268
269
|
const user = app.authentication === 'none' ? 'nobody' : (session.username ?? 'nobody');
|
|
270
|
+
// (sprint 139, AP-6) the request's trace: which application (its workspace decides where it goes), page and user
|
|
271
|
+
await traceApp({ appId: app.id, alias: app.alias, page: page.page_no, user });
|
|
269
272
|
|
|
270
273
|
if (app.authentication !== 'none' && (page.requires_auth || fixedPage) && !session.username) {
|
|
271
274
|
if (json) reply.code(401).send({ error: locale.t('error.session_ended') });
|
package/src/session.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
import { count, emitLog } from './telemetry/trace.ts';
|
|
3
|
+
import { appOf } from './telemetry/config.ts';
|
|
2
4
|
import { runtime, sharedRuntime } from './db.ts';
|
|
3
5
|
import { runtimeForApp } from './workspace-db.ts';
|
|
4
6
|
import { hashToken, loginMaxFailuresPerIp, loginMaxFailuresPerUser, loginWindowMinutes, newToken } from './security.ts';
|
|
@@ -121,6 +123,7 @@ export async function logActivity(entry: {
|
|
|
121
123
|
elapsedMs?: number;
|
|
122
124
|
detail?: string;
|
|
123
125
|
}) {
|
|
126
|
+
activityTelemetry(entry);
|
|
124
127
|
try {
|
|
125
128
|
// (090) on the connection of the entry's application (an isolated workspace's own); without one, the shared one
|
|
126
129
|
const db = entry.appId != null ? await runtimeForApp(entry.appId) : sharedRuntime;
|
|
@@ -152,3 +155,26 @@ export async function loginThrottled(appId: number | null, username: string, ip:
|
|
|
152
155
|
);
|
|
153
156
|
return !!counts && (counts.user_fails >= loginMaxFailuresPerUser() || counts.ip_fails >= loginMaxFailuresPerIp());
|
|
154
157
|
}
|
|
158
|
+
|
|
159
|
+
// (sprint 139, AP-6) errors, refusals and failed sign-ins as OpenTelemetry logs and metrics
|
|
160
|
+
const OTEL_EVENTS: Record<string, ['ERROR' | 'WARN', string]> = {
|
|
161
|
+
error: ['ERROR', 'pgkiln.errors'],
|
|
162
|
+
forbidden: ['WARN', 'pgkiln.access_denied'],
|
|
163
|
+
login_failed: ['WARN', 'pgkiln.signin_failures'],
|
|
164
|
+
login_locked: ['WARN', 'pgkiln.signin_failures'],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
function activityTelemetry(e: { appId?: number | null; pageNo?: number | null; username?: string | null; event: string; detail?: string }) {
|
|
168
|
+
const known = OTEL_EVENTS[e.event];
|
|
169
|
+
if (!known) return;
|
|
170
|
+
const app = e.appId == null ? 'builder' : appOf(e.appId)?.[0] ?? 'unknown';
|
|
171
|
+
count(known[1], e.event === 'login_locked' ? { app, reason: 'locked' } : { app });
|
|
172
|
+
emitLog({
|
|
173
|
+
severity: known[0],
|
|
174
|
+
body: e.event,
|
|
175
|
+
detail: e.detail,
|
|
176
|
+
attrs: { 'pgkiln.event': e.event, 'pgkiln.app.id': e.appId ?? null, 'pgkiln.app.alias': e.appId == null ? null : app, 'pgkiln.page': e.pageNo == null ? null : String(e.pageNo) },
|
|
177
|
+
appId: e.appId ?? null,
|
|
178
|
+
user: e.username ?? null,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { html, type Raw } from '../html.ts';
|
|
2
|
+
import { checksumValid, signText } from '../security.ts';
|
|
3
|
+
import { browserSpans } from './export.ts';
|
|
4
|
+
import { currentCtx, now, SPAN_KIND, type Span } from './trace.ts';
|
|
5
|
+
|
|
6
|
+
// (sprint 139, AP-6) Browser spans: a page whose trace is sent carries a signed
|
|
7
|
+
// <meta name="pgkiln-trace"> (layout.ts); app.js reports the page load and the
|
|
8
|
+
// page's Ajax calls to POST /a/:alias/telemetry (src/telemetry/http.ts), and
|
|
9
|
+
// relay() turns them into spans of the page's trace. The token is bound to the
|
|
10
|
+
// trace, the page's span, the time and the application, and is good for 30
|
|
11
|
+
// minutes and 200 spans. No collector address or token reaches the browser.
|
|
12
|
+
|
|
13
|
+
const TOKEN_MINUTES = 30;
|
|
14
|
+
const MAX_PER_TOKEN = 200;
|
|
15
|
+
const used = new Map<string, number>();
|
|
16
|
+
let usedSince = Date.now();
|
|
17
|
+
|
|
18
|
+
/** The page's <meta> for browser spans: its trace, span, time and a signature (empty when not wanted). */
|
|
19
|
+
export function browserTraceMeta(): Raw | '' {
|
|
20
|
+
const ctx = currentCtx();
|
|
21
|
+
if (!ctx?.trace.browser || !ctx.trace.sampled || !ctx.trace.alias) return '';
|
|
22
|
+
const root = ctx.trace.spans[0];
|
|
23
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
24
|
+
const payload = `${root.traceId}.${root.spanId}.${ts}`;
|
|
25
|
+
return html`<meta name="pgkiln-trace" content="${payload}.${signText('otel-browser', `${payload}.${ctx.trace.alias.toLowerCase()}`)}">`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface BrowserSpan {
|
|
29
|
+
k?: unknown;
|
|
30
|
+
path?: unknown;
|
|
31
|
+
method?: unknown;
|
|
32
|
+
status?: unknown;
|
|
33
|
+
start?: unknown;
|
|
34
|
+
dur?: unknown;
|
|
35
|
+
t?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const num = (v: unknown, max: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(Math.max(v, 0), max) : null);
|
|
39
|
+
|
|
40
|
+
/** Check a page's token and turn its reports into spans. Returns the number kept, or null for a bad token. */
|
|
41
|
+
export function relay(alias: string, token: unknown, spans: unknown): number | null {
|
|
42
|
+
const m = typeof token === 'string' ? /^([0-9a-f]{32})\.([0-9a-f]{16})\.(\d{1,12})\.([\w-]{22})$/.exec(token) : null;
|
|
43
|
+
if (!m) return null;
|
|
44
|
+
const [, traceId, spanId, ts, sig] = m;
|
|
45
|
+
if (!checksumValid(signText('otel-browser', `${traceId}.${spanId}.${ts}.${alias.toLowerCase()}`), sig)) return null;
|
|
46
|
+
if (Date.now() / 1000 - Number(ts) > TOKEN_MINUTES * 60) return null;
|
|
47
|
+
if (Date.now() - usedSince > 3_600_000) {
|
|
48
|
+
used.clear();
|
|
49
|
+
usedSince = Date.now();
|
|
50
|
+
}
|
|
51
|
+
const list = Array.isArray(spans) ? (spans as BrowserSpan[]).slice(0, 50) : [];
|
|
52
|
+
const room = MAX_PER_TOKEN - (used.get(`${traceId}.${spanId}`) ?? 0);
|
|
53
|
+
if (room <= 0 || !list.length) return 0;
|
|
54
|
+
const prefix = `/a/${alias}/`.toLowerCase();
|
|
55
|
+
const out: Span[] = [];
|
|
56
|
+
const t = now();
|
|
57
|
+
for (const b of list.slice(0, room)) {
|
|
58
|
+
const dur = num(b.dur, 600_000);
|
|
59
|
+
const start = num(b.start, 8.64e15);
|
|
60
|
+
if (dur === null || start === null || Math.abs(start - t) > 3_600_000) continue;
|
|
61
|
+
const path = typeof b.path === 'string' ? b.path.split('#')[0] : '';
|
|
62
|
+
const [p, query] = path.split('?');
|
|
63
|
+
const s: Span = {
|
|
64
|
+
traceId, spanId: '', parentId: spanId, kind: SPAN_KIND.client, start, end: start + dur, name: '', attrs: { 'pgkiln.browser': true },
|
|
65
|
+
};
|
|
66
|
+
if (b.k === 'load') {
|
|
67
|
+
s.kind = SPAN_KIND.internal;
|
|
68
|
+
s.name = 'browser page load';
|
|
69
|
+
for (const k of ['ttfb', 'dom_interactive', 'dom_complete', 'load']) {
|
|
70
|
+
const v = num(b.t?.[k], 600_000);
|
|
71
|
+
if (v !== null) s.attrs[`pgkiln.browser.${k}_ms`] = Math.round(v);
|
|
72
|
+
}
|
|
73
|
+
} else if (b.k === 'ajax' && p.toLowerCase().startsWith(prefix) && p.length <= 500) {
|
|
74
|
+
const method = typeof b.method === 'string' && /^(GET|POST|PUT|PATCH|DELETE)$/.test(b.method) ? b.method : 'GET';
|
|
75
|
+
s.name = `browser ${method}`;
|
|
76
|
+
s.attrs['http.request.method'] = method;
|
|
77
|
+
s.attrs['url.path'] = p;
|
|
78
|
+
const st = num(b.status, 599);
|
|
79
|
+
if (st !== null) {
|
|
80
|
+
s.attrs['http.response.status_code'] = Math.round(st);
|
|
81
|
+
if (st >= 500 || st === 0) s.error = st ? `HTTP ${Math.round(st)}` : 'network error';
|
|
82
|
+
}
|
|
83
|
+
if (query) s.vals = { 'url.query': query.slice(0, 2000) };
|
|
84
|
+
} else continue;
|
|
85
|
+
out.push(s);
|
|
86
|
+
}
|
|
87
|
+
used.set(`${traceId}.${spanId}`, MAX_PER_TOKEN - room + out.length);
|
|
88
|
+
if (out.length) browserSpans(traceId, alias, out);
|
|
89
|
+
return out.length;
|
|
90
|
+
}
|
|
91
|
+
|