datagrok-tools 6.7.8 → 6.7.9
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/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +12 -0
- package/CLAUDE.md +12 -0
- package/GROK_S.md +70 -6
- package/bin/commands/add.js +4 -4
- package/bin/commands/api.js +20 -12
- package/bin/commands/help.js +2 -2
- package/bin/commands/server-domains.js +24 -7
- package/bin/commands/server-observe.js +526 -0
- package/bin/commands/server.js +8 -1
- package/bin/utils/ent-helpers.js +1 -1
- package/bin/utils/migrate/walker.js +2 -1
- package/bin/utils/migrate/walker.ts +1 -1
- package/bin/utils/node-dapi.js +27 -10
- package/domain-schema.schema.json +50 -1
- package/entity-template/domain-app-imports.js +2 -2
- package/package.json +5 -2
package/.turbo/turbo-build.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Datagrok-tools changelog
|
|
2
2
|
|
|
3
|
+
## v.next
|
|
4
|
+
|
|
5
|
+
* Upgraded Puppeteer to v25, which drops the vulnerable `extract-zip` and `basic-ftp` transitive dependencies; Node.js 22.12+ is now required
|
|
6
|
+
* `grok add app --domain`, `grok api --ui`: scaffold against `@datagrok-libraries/ems`
|
|
7
|
+
* GROK-20884: Added `grok s o errors get <signature|problem>`: each occurrence of an error with its time, user, function and session, so an error incident leads to its calls
|
|
8
|
+
* GROK-20884: Added `grok s o logs container [<name|id>]`: a docker container's log lines cut by `--since`/`--from`/`--to` and `--grep`; no name lists the containers
|
|
9
|
+
* GROK-20884: `grok s o problems get` prints one field per line with every detail in full, and `errors top --limit` returns that many rows instead of the top of every source
|
|
10
|
+
* GROK-20884: Added `grok s observe` (alias `grok s o`): problems and their alerts, problem rules, logger settings with expiring user and group entries, errors, the timeline and cloud logs (CloudWatch and the log archive)
|
|
11
|
+
* GROK-20884: Added `grok s o logs capture <login|group> [--for 30m]`: saves every API request of a user or a group until the entry expires (a `logger set` of `saveHttpRequests`; an entry that expires later keeps its time); `logs capture stop` removes an expiring entry that holds only the capture, `logs capture` lists the active ones
|
|
12
|
+
* GROK-20298: `grok s domains apply` sends the manifest JSON's `version` as the now-required `ifVersion` of a user-managed schema, accepts `--if-version current` to take the registered one, refuses such an apply without either, and forwards `friendlyName`, `description` and `storage` from the JSON for a user-managed schema (a package schema keeps its key set)
|
|
13
|
+
* GROK-20298: `domain-schema.schema.json` accepts the root `storage` key (`{kind: external, connection, schema?, catalog?}`) and the remote-name keys `table`/`column`; `grok api` types an external table's rows with `id` only, an empty expand map, and emits no `<Schema>TransactionOp`/`transaction` for an external schema
|
|
14
|
+
|
|
3
15
|
## 6.7.8 (2026-10-13)
|
|
4
16
|
|
|
5
17
|
* Fixed `grok api` (and every command other than `grok s`) exiting with code 1 and its usage text in 6.7.7: the new `--send-email` flag reached every command as `send-email: false`, which `grok api` rejected as an unknown option
|
package/CLAUDE.md
CHANGED
|
@@ -121,6 +121,18 @@ When a package has a `dockerfiles/` directory, `grok publish` automatically mana
|
|
|
121
121
|
|
|
122
122
|
**Practical implication:** editing any file inside `dockerfiles/<name>/` will cause `grok publish` to rebuild that container image on the next publish, without needing `--rebuild-docker`.
|
|
123
123
|
|
|
124
|
+
**Publish traps:**
|
|
125
|
+
- `grok publish` runs `npm run build` / `pnpm run build` itself (`buildPackage`); `--skip-check`
|
|
126
|
+
only skips checks, and `--rebuild` skips the local build rather than forcing one.
|
|
127
|
+
- With no local image and a transient error on the fallback lookup, CLI <= 6.5.10 published
|
|
128
|
+
`image: null` and the container went to `error`; 6.5.11+ builds instead.
|
|
129
|
+
- A republish renames a package's containers to `<name>_1`.
|
|
130
|
+
- Outside the pnpm workspace, a fresh `npm install` in a package may pull ts-loader 9.6.x /
|
|
131
|
+
webpack 5.109, which cannot resolve `datagrok-api/*` (tsc can); reuse a known-good
|
|
132
|
+
`node_modules` (ts-loader 9.5.7 / webpack 5.106) and junction `node_modules/datagrok-api` to a
|
|
133
|
+
built js-api (`cmd /c mklink /J`).
|
|
134
|
+
- Long publishes outlive a 10-minute agent tool limit; run them detached and poll a log.
|
|
135
|
+
|
|
124
136
|
### Testing Framework
|
|
125
137
|
|
|
126
138
|
Tests use Puppeteer for headless browser automation:
|
package/GROK_S.md
CHANGED
|
@@ -36,6 +36,7 @@ browser or a logged-in session.
|
|
|
36
36
|
| See what fields an entity type has | `grok s describe connections` |
|
|
37
37
|
| Hit any undocumented endpoint | `grok s raw GET /users/current` / `raw POST <path> --data '{...}'` |
|
|
38
38
|
| Check server + per-module health | `grok s healthcheck [--module <name>]` |
|
|
39
|
+
| Problems, alerts, problem rules, logger settings, errors, cloud logs | `grok s observe problems list --alert-status open` (alias `grok s o`) |
|
|
39
40
|
| Bulk operations in one round-trip | `grok s batch <entity> <verb> --json items.json` |
|
|
40
41
|
| Move entities dev to prod (bundle, or instance to instance) | `grok s pull ... --out ./bundle` / `grok s migrate ... --from dev --to prod` |
|
|
41
42
|
| Browse / query / edit domain-table rows | `grok s domains query grit.issue --filter 'status = "open"'` / `domains insert` / `domains upload` |
|
|
@@ -136,6 +137,18 @@ grok s connections test --json conn.json # test before saving
|
|
|
136
137
|
|
|
137
138
|
See `public/packages/Chembl/connections/` for connection JSON examples.
|
|
138
139
|
|
|
140
|
+
Connection tags over REST: the entity JSON carries `entityTags`
|
|
141
|
+
(`[{id, tag, owner: {id: <All users group id>}}]`, no `#type` on the owner), not `tags`.
|
|
142
|
+
`grok s connections save --json` without `entityTags` **clears** the tags (saveTags deletes rows
|
|
143
|
+
not in the list); `POST /entities/save_tags {id, entityTags}` sets them. Filter with
|
|
144
|
+
`whereTag([...])` (`?tags=x`); `whereSmart('tag = "x"')` returns nothing for connections over REST.
|
|
145
|
+
A `Web` connection's parameters must be wrapped (`{"url": {"value": "https://..."}}`); a bare
|
|
146
|
+
string makes the test route answer 400 "Invalid argument(s): value".
|
|
147
|
+
|
|
148
|
+
A connection saved over REST goes through `Project.add(uniqueName: true)` and can come back
|
|
149
|
+
renamed `<name>_1` for a package's connection — see `core/docs/PROJECTS_AND_PACKAGES.MD`
|
|
150
|
+
"Package connections on a stand".
|
|
151
|
+
|
|
139
152
|
## Group membership
|
|
140
153
|
|
|
141
154
|
`grok s groups add-members` is idempotent — it resolves each member (by login, name,
|
|
@@ -161,6 +174,12 @@ ambiguous the command prints every matching group and exits non-zero — pass a
|
|
|
161
174
|
disambiguate, or add `--user` (accepted by all four membership verbs) to restrict lookup to
|
|
162
175
|
personal groups.
|
|
163
176
|
|
|
177
|
+
> **Trap:** `grok s groups add-members <group> <users...>` without `--admin` sets `isAdmin=false`
|
|
178
|
+
> on members that are already group admins (reported as `updated`, not `noop`). Before a bulk add
|
|
179
|
+
> run `grok s groups list-members <group> --admin` and exclude those users, or re-add them with
|
|
180
|
+
> `--admin`. Pass the group UUID — `list-members Administrators` by name has returned
|
|
181
|
+
> "(no results)" while the id worked.
|
|
182
|
+
|
|
164
183
|
## User administration
|
|
165
184
|
|
|
166
185
|
```bash
|
|
@@ -411,12 +430,19 @@ grok s domains audit inventory --limit 50 # DDL and row e
|
|
|
411
430
|
grok s domains delete inventory --force # purge: data, audit, registry, grants
|
|
412
431
|
```
|
|
413
432
|
|
|
414
|
-
`schema.json` may be a full manifest or a partial apply body —
|
|
415
|
-
`propertySchemas` and `
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
433
|
+
`schema.json` may be a full manifest or a partial apply body — `tables`, `extend`,
|
|
434
|
+
`propertySchemas`, `dropTables` and `access` are sent, plus `friendlyName`, `description` and
|
|
435
|
+
`storage` when the schema is user-managed (`name` is dropped; `version` becomes `ifVersion` and
|
|
436
|
+
`incarnation` becomes `ifIncarnation` unless `--if-version` says otherwise — `current` takes
|
|
437
|
+
both from the schema registered right now, so a name deleted and re-created since the file was
|
|
438
|
+
written is a conflict, not a fresh `1`). Named tables
|
|
439
|
+
replace their definition wholesale; untouched tables stay as the registry has them. A plan
|
|
440
|
+
that drops or narrows anything is refused until `--confirm-destructive` is passed, and the
|
|
441
|
+
plan is printed with the refusal. `--if-version` fails the apply when the schema's apply
|
|
442
|
+
counter (or `ext_version` on a package schema) has moved; a user-managed schema REQUIRES the
|
|
443
|
+
token (the server answers `version-required` without it) — pass the version the edit was made
|
|
444
|
+
against, or `--if-version current` to take the one registered right now. Dropping a table
|
|
445
|
+
needs Delete on that table, dropping its columns Edit on it. Package-deployed
|
|
420
446
|
schemas cannot be applied to or deleted here — `apply` on one is the user-extension path
|
|
421
447
|
(needs `Extend`), and the manifest is owned by `grok publish`. `delete <schema>` requires
|
|
422
448
|
`--force` because it takes every row with it.
|
|
@@ -471,6 +497,44 @@ module the server does not report exits 1. Requires a valid dev key (standard `g
|
|
|
471
497
|
For an anonymous liveness probe — load balancer, k8s readiness — hit `/admin/health` directly;
|
|
472
498
|
it's on the server's unauthenticated allowlist.
|
|
473
499
|
|
|
500
|
+
## Problems, logging and errors (`grok s observe`, alias `grok s o`)
|
|
501
|
+
|
|
502
|
+
Thin commands over the server's routes; `grok s observe` with no command prints every verb and option.
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
grok s o problems list --alert-status open # the open alerts; --status, --state, --kind, --since 7d
|
|
506
|
+
grok s o problems get 'health:Jupyter@<server>' # by id or kind:key; history <id> for its records
|
|
507
|
+
grok s o problems status connection:<connection id> --data '{"status": "muted", "reason": "maintenance", "until": "2026-10-04T06:00:00Z"}'
|
|
508
|
+
grok s o problems ack 'health:Jupyter@<server>' --reason x # resolve <id> --reason ... closes the alert
|
|
509
|
+
grok s o rules get > rules.json # Settings > Alerts problem rules; rules put --json rules.json
|
|
510
|
+
grok s o rules test # evaluates the saved rules once, now
|
|
511
|
+
grok s o logger get exportSettings # the logger settings, or one path of them
|
|
512
|
+
grok s o logger set --user alice --set debugFlags='["db"]' --for 30m --reason "ticket 123" # an expiring entry, from All users
|
|
513
|
+
grok s o logger set --user alice --remove # delete that entry
|
|
514
|
+
grok s o logger history --limit 5 # log-settings-changed audit records with their diff
|
|
515
|
+
grok s o logs capture alice --for 2h --reason "ticket 123" # logger set of saveHttpRequests on an expiring entry; 30m by default
|
|
516
|
+
grok s o logs capture # the active captures; logs capture stop alice removes one
|
|
517
|
+
grok s o errors top --since 7d --by signature --limit 20 # GET /admin/metrics errors
|
|
518
|
+
grok s o errors top --since 24h --source server # ranked by distinct users: system errors (scheduler, probes) need --source
|
|
519
|
+
grok s o errors get error-incident:<key> # each occurrence of a problem's error: time, user, function, session
|
|
520
|
+
grok s o errors get <signature> --since 1d # or of a signature from errors top --by signature
|
|
521
|
+
grok s o timeline --session <id> # its events and requests of the last 15 min
|
|
522
|
+
grok s o timeline --session <id> --from 2026-10-09T10:00 # 10 min from then; --to sets the end, at most 2 h later
|
|
523
|
+
grok s o logs cloud --since 2h --filter ERROR # CloudWatch, the instance's own group; --group, --from/--to
|
|
524
|
+
grok s o logs archive list --prefix cw/ --since 1d # keys in the log archive (WORM bucket); --since filters the returned page only
|
|
525
|
+
grok s o logs archive read <key> # one archived object decoded to events
|
|
526
|
+
grok s o logs container # docker containers and their status
|
|
527
|
+
grok s o logs container scripting-python_1 --since 1h --grep 'Kernel (started|shutdown)' # one container's log lines
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Problems need `ManageAlerts`, errors, another session's timeline and `logs cloud`/`logs archive` `ViewTelemetry`,
|
|
531
|
+
`rules test` both, logger and rules changes and `logs capture` `EditPluginsSettings`. `capture` is an expiring
|
|
532
|
+
`userGroupSettings` entry with `saveHttpRequests` on (seeded from the All Users entry, with `usage` saved); on an entry
|
|
533
|
+
that already expires later it keeps that time. `capture stop` removes only an expiring entry that holds nothing but the
|
|
534
|
+
capture; for any other entry use `logger set --user <login> --remove`. Read what it recorded with
|
|
535
|
+
`timeline --session <id>`. `logs container` reads the container's latest `--limit` lines (20000) and only then cuts `--since`/`--from`/`--to` (UTC, matched across the container's own offset) and `--grep`, so a window older than those lines comes back empty: raise `--limit`. `logs archive list --since` filters only the page the server returned (`--limit`), so narrow
|
|
536
|
+
the listing with `--prefix` first. `logs` read with the server's AWS role, or with `--connection <id>`. In durations `m` means minutes (in `grok s pull --since` it means months).
|
|
537
|
+
|
|
474
538
|
## Describing an entity type
|
|
475
539
|
|
|
476
540
|
```bash
|
package/bin/commands/add.js
CHANGED
|
@@ -16,8 +16,7 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
|
|
|
16
16
|
* answer key): u2 is not on npm, so the dependency is the workspace link, and `u2core` is the one
|
|
17
17
|
* external `@datagrok/build-config` does not know about. The css imports and the tsconfig need
|
|
18
18
|
* nothing — the shared bundler config carries the loaders and the tsconfig extends the base. */
|
|
19
|
-
const
|
|
20
|
-
const u2DgModule = '@datagrok-libraries/u2/src/dg/index.js';
|
|
19
|
+
const emsDgModule = '@datagrok-libraries/ems/src/dg';
|
|
21
20
|
const u2RspackExternal = `'datagrok-api/u2core': 'DG.U2'`;
|
|
22
21
|
const u2RspackConfig = [`const {bundler} = require('@datagrok/build-config');`, '', `module.exports = bundler({externals: {${u2RspackExternal}}});`, ''].join('\n');
|
|
23
22
|
function add(args) {
|
|
@@ -110,7 +109,7 @@ function add(args) {
|
|
|
110
109
|
* existing imports (which webpack's externals depend on). */
|
|
111
110
|
function addU2Imports() {
|
|
112
111
|
const contents = _fs.default.readFileSync(packageEntry, 'utf8');
|
|
113
|
-
if (contents.includes(
|
|
112
|
+
if (contents.includes(emsDgModule)) return;
|
|
114
113
|
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
|
|
115
114
|
const imports = _fs.default.readFileSync(_path.default.join(templateDir, 'entity-template', 'domain-app-imports.js'), 'utf8').trimEnd().split(/\r?\n/);
|
|
116
115
|
const lines = contents.split(/\r?\n/);
|
|
@@ -125,7 +124,8 @@ function add(args) {
|
|
|
125
124
|
function addU2Wiring() {
|
|
126
125
|
const packageObj = JSON.parse(_fs.default.readFileSync(packagePath, 'utf8'));
|
|
127
126
|
packageObj.dependencies = packageObj.dependencies ?? {};
|
|
128
|
-
packageObj.dependencies['@datagrok-libraries/
|
|
127
|
+
packageObj.dependencies['@datagrok-libraries/ems'] ??= 'workspace:^';
|
|
128
|
+
packageObj.dependencies['@datagrok-libraries/u2'] ??= 'workspace:^';
|
|
129
129
|
_fs.default.writeFileSync(packagePath, JSON.stringify(packageObj, null, 2), 'utf8');
|
|
130
130
|
if (!_fs.default.existsSync(rspackConfigPath)) _fs.default.writeFileSync(rspackConfigPath, u2RspackConfig, 'utf8');else if (!_fs.default.readFileSync(rspackConfigPath, 'utf8').includes('datagrok-api/u2core')) color.warn(`rspack.config.js is already there — add \`externals: {${u2RspackExternal}}\` to it by hand`);
|
|
131
131
|
}
|
package/bin/commands/api.js
CHANGED
|
@@ -24,7 +24,7 @@ const dbFile = 'db.ts';
|
|
|
24
24
|
const dbUiFile = 'db-ui.ts';
|
|
25
25
|
/** Where `db-ui.ts` takes the u2 domain stack from — the same specifier Grit and Stockroom use
|
|
26
26
|
* (u2 is a relative-path dependency without a `dg` subpath export). */
|
|
27
|
-
const
|
|
27
|
+
const emsDgModule = '@datagrok-libraries/ems/src/dg';
|
|
28
28
|
|
|
29
29
|
/** Naive English plural of a (snake_case) table name — `issue` → `issues`, `box` →
|
|
30
30
|
* `boxes`, `category` → `categories`. */
|
|
@@ -229,7 +229,7 @@ function generateDomainClients(packageDir = curDir, options) {
|
|
|
229
229
|
_fs.default.writeFileSync(_path.default.join(genDir, dbFile), normEol(content).replace(/\n/g, '\r\n'), 'utf8');
|
|
230
230
|
color.log(`Successfully generated file src/generated/${dbFile}${sep}`, 'success');
|
|
231
231
|
if (ui) {
|
|
232
|
-
const uiContent = annotationForDbUiFile + `import {domains, DomainTable} from '${
|
|
232
|
+
const uiContent = annotationForDbUiFile + `import {domains, DomainTable} from '${emsDgModule}';${sep}` + wrapTokens(`import {`, dbImports, ', ', `} from './${dbFile.replace(/\.ts$/, '')}';`) + sep + sep + uiParts.join(sep);
|
|
233
233
|
_fs.default.writeFileSync(_path.default.join(genDir, dbUiFile), normEol(uiContent).replace(/\n/g, '\r\n'), 'utf8');
|
|
234
234
|
color.log(`Successfully generated file src/generated/${dbUiFile}${sep}`, 'success');
|
|
235
235
|
}
|
|
@@ -265,6 +265,10 @@ function findDependencyManifest(packageDir, schemaName) {
|
|
|
265
265
|
function generateDomainSchemaCode(manifest, manifestPath, emittedTypes, packageDir) {
|
|
266
266
|
const decls = [];
|
|
267
267
|
const systemColumnNames = new Set(domainSystemColumns.map(([name]) => name));
|
|
268
|
+
// An external table's rows carry `id` (the encoded business key) and nothing else
|
|
269
|
+
// system-side; nothing is expanded and nothing is written (external-bindings §3).
|
|
270
|
+
const external = manifest.storage?.kind === 'external';
|
|
271
|
+
const systemColumns = external ? domainSystemColumns.filter(([name]) => name === 'id') : domainSystemColumns;
|
|
268
272
|
const tableNames = Object.keys(manifest.tables);
|
|
269
273
|
// Keyed by table name; a qualified ref target ('Core.queries', 'grit.issue') lands here
|
|
270
274
|
// under its qualified key so the expand map can list its columns — it gets no accessor.
|
|
@@ -427,7 +431,7 @@ function generateDomainSchemaCode(manifest, manifestPath, emittedTypes, packageD
|
|
|
427
431
|
const columns = tableColumns[tableName];
|
|
428
432
|
const relations = tableRelations[tableName];
|
|
429
433
|
const rowLines = [`/** Row of \`${manifest.name}.${tableName}\`. */`, `export interface ${typeName}Row {`];
|
|
430
|
-
for (const [name, tsType] of
|
|
434
|
+
for (const [name, tsType] of systemColumns) rowLines.push(` ${name}: ${tsType};`);
|
|
431
435
|
for (const c of columns) rowLines.push(` ${c.name}${c.required || c.autoNumber ? '' : '?'}: ${c.tsType};`);
|
|
432
436
|
rowLines.push('}');
|
|
433
437
|
decls.push(rowLines.join(sep));
|
|
@@ -447,13 +451,13 @@ function generateDomainSchemaCode(manifest, manifestPath, emittedTypes, packageD
|
|
|
447
451
|
updateLines.push('};');
|
|
448
452
|
decls.push(updateLines.join(sep));
|
|
449
453
|
}
|
|
450
|
-
decls.push(formatColumnUnion(`${typeName}Column`, [...
|
|
454
|
+
decls.push(formatColumnUnion(`${typeName}Column`, [...systemColumns.map(([name]) => name), ...columns.map(c => c.name)]));
|
|
451
455
|
|
|
452
456
|
// Expand map: MUST stay a `type` alias — object-type literals carry the implicit
|
|
453
457
|
// index signature the `TExpand extends {[key: string]: {}}` constraint needs
|
|
454
458
|
// (an interface would fail it).
|
|
455
459
|
const expandEntries = [];
|
|
456
|
-
for (const c of columns) {
|
|
460
|
+
for (const c of external ? [] : columns) {
|
|
457
461
|
if (c.ref == null) continue;
|
|
458
462
|
const fields = tableColumns[c.ref].map(tc => `'${c.name}.${tc.name}'?: ${tc.tsType}`);
|
|
459
463
|
expandEntries.push(wrapTokens(` '${c.name}': {`, fields, '; ', '};', ' '));
|
|
@@ -463,15 +467,15 @@ function generateDomainSchemaCode(manifest, manifestPath, emittedTypes, packageD
|
|
|
463
467
|
// The link shape stays INLINE (structurally DG.DomainRelationLink): generated
|
|
464
468
|
// files must compile against the datagrok-api the package depends on, which may
|
|
465
469
|
// predate that interface.
|
|
466
|
-
for (const r of relations) expandEntries.push(` '${r.name}': {${r.name}?: {id: string; name: string}[]};`);
|
|
467
|
-
for (const childName of tableNames) {
|
|
470
|
+
for (const r of external ? [] : relations) expandEntries.push(` '${r.name}': {${r.name}?: {id: string; name: string}[]};`);
|
|
471
|
+
for (const childName of external ? [] : tableNames) {
|
|
468
472
|
const fks = tableColumns[childName].filter(c => c.ref === tableName);
|
|
469
473
|
if (fks.length === 0) continue;
|
|
470
474
|
const childType = utils.snakeToCamelCase(childName);
|
|
471
475
|
if (fks.length === 1) expandEntries.push(` 'details:${childName}': {${childName}?: ${childType}Row[]};`);else for (const fk of fks) expandEntries.push(` 'details:${childName}.${fk.name}': {${childName}?: ${childType}Row[]};`);
|
|
472
476
|
}
|
|
473
477
|
decls.push(expandEntries.length === 0 ? `export type ${typeName}Expand = {};` : [`/** Expand keys of \`${manifest.name}.${tableName}\` → fields each adds to the row ` + `(consumed by query()/builder). */`, `export type ${typeName}Expand = {`, ...expandEntries, '};'].join(sep));
|
|
474
|
-
txArms.push(` {op: 'insert'; table: '${tableName}'; ref?: string; values: DG.DomainTxValues<${typeName}Insert>; ` + `onDuplicate?: 'error'} |`, ` {op: 'update'; table: '${tableName}'; id: string; ` + `values: DG.DomainTxValues<${relations.length > 0 ? `${typeName}Update` : `Partial<${typeName}Row>`}>; ` + `expectedVersion?: number} |`, ` {op: 'delete'; table: '${tableName}'; id: string} |`);
|
|
478
|
+
if (!external) txArms.push(` {op: 'insert'; table: '${tableName}'; ref?: string; values: DG.DomainTxValues<${typeName}Insert>; ` + `onDuplicate?: 'error'} |`, ` {op: 'update'; table: '${tableName}'; id: string; ` + `values: DG.DomainTxValues<${relations.length > 0 ? `${typeName}Update` : `Partial<${typeName}Row>`}>; ` + `expectedVersion?: number} |`, ` {op: 'delete'; table: '${tableName}'; id: string} |`);
|
|
475
479
|
|
|
476
480
|
// No datetime config: the client resolves datetime columns from the domain
|
|
477
481
|
// registry itself (the Dayjs typing in <Table>Row stays true for every client).
|
|
@@ -480,10 +484,14 @@ function generateDomainSchemaCode(manifest, manifestPath, emittedTypes, packageD
|
|
|
480
484
|
clients.push(` get ${tableProp(tableName)}() {`, ` return grok.dapi.domains.table<${typeName}Row, ${typeName}Insert, ` + `${typeName}Column, ${typeName}Expand` + `${relations.length > 0 ? `, ${typeName}Update` : ''}>('${manifest.name}.${tableName}');`, ' },');
|
|
481
485
|
}
|
|
482
486
|
const schemaType = utils.snakeToCamelCase(manifest.name);
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
+
const transaction = [];
|
|
488
|
+
if (!external) {
|
|
489
|
+
const lastArm = txArms.pop();
|
|
490
|
+
txArms.push(lastArm.replace(/ \|$/, ';'));
|
|
491
|
+
decls.push([`export type ${schemaType}TransactionOp =`, ...txArms].join(sep));
|
|
492
|
+
transaction.push(` transaction<T extends ${schemaType}TransactionOp[]>(ops: [...T]):`, ` Promise<{[K in keyof T]: DG.DomainOpResultFor<T[K]>}> {`, ` return grok.dapi.domains.transaction('${manifest.name}', ops) as`, ` Promise<{[K in keyof T]: DG.DomainOpResultFor<T[K]>}>;`, ' },');
|
|
493
|
+
}
|
|
494
|
+
decls.push([`/** Typed clients for the \`${manifest.name}\` domain schema tables ` + `(lazy — no import-time side effects). */`, `export const ${utils.snakeToCamelCase(manifest.name, false)}Db = {`, ...clients, ...transaction, '};'].join(sep));
|
|
487
495
|
return decls.join(sep.repeat(2)) + sep;
|
|
488
496
|
}
|
|
489
497
|
|
package/bin/commands/help.js
CHANGED
|
@@ -96,7 +96,7 @@ grok add tests
|
|
|
96
96
|
Please note that entity names may only include letters and numbers
|
|
97
97
|
|
|
98
98
|
--domain scaffolds a working browse/CRUD app over an entity-mapped domain table
|
|
99
|
-
(\`grok.dapi.domains\`) from the
|
|
99
|
+
(\`grok.dapi.domains\`) from the EMS defaults (\`@datagrok-libraries/ems\`): a three-line
|
|
100
100
|
app function, a \`src/app.spec.json\` the designer edits, and — for a schema the package
|
|
101
101
|
does not declare yet — a one-table starter \`databases/<schema>/schema.json\`. Give it
|
|
102
102
|
one table (\`--domain tracker.issue\`), a whole schema the package declares in
|
|
@@ -140,7 +140,7 @@ Options:
|
|
|
140
140
|
--verbose Print detailed output
|
|
141
141
|
--ui Also generate \`src/generated/db-ui.ts\` — typed u2 handles
|
|
142
142
|
(\`get<Schema>Db()\` → a \`DomainTable<Row>\` per table, opened in
|
|
143
|
-
parallel behind one await) over \`@datagrok-libraries/
|
|
143
|
+
parallel behind one await) over \`@datagrok-libraries/ems\`. Once the
|
|
144
144
|
file exists, plain \`grok api\` keeps it up to date; delete it to
|
|
145
145
|
opt out again
|
|
146
146
|
`;
|
|
@@ -21,7 +21,9 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
|
|
|
21
21
|
/// `grok s domains ...` — entity-mapped domain schemas and their rows (DomainsRouter, `/domains/`).
|
|
22
22
|
/// Every verb addresses a schema as `<schema>` or a table as `<schema>.<table>`.
|
|
23
23
|
|
|
24
|
-
const APPLY_KEYS = ['tables', 'extend', 'propertySchemas', 'dropTables', 'ifVersion', 'confirmDestructive'];
|
|
24
|
+
const APPLY_KEYS = ['tables', 'extend', 'propertySchemas', 'dropTables', 'access', 'ifVersion', 'ifIncarnation', 'confirmDestructive'];
|
|
25
|
+
/** Accepted by a user-managed schema's apply only; a package schema refuses them as unknown keys. */
|
|
26
|
+
const USER_APPLY_KEYS = ['friendlyName', 'description', 'storage'];
|
|
25
27
|
const PERMISSIONS = ['View', 'Edit', 'Delete', 'Share', 'Extend'];
|
|
26
28
|
const USAGE = `Usage: grok s domains <verb> [args]
|
|
27
29
|
list [<schema>] [--filter <text>] Schemas, or the tables of one schema
|
|
@@ -210,11 +212,23 @@ async function handleDomains(dapi, verb, rest, argv, output) {
|
|
|
210
212
|
}
|
|
211
213
|
case 'apply':
|
|
212
214
|
{
|
|
213
|
-
if (!args[0] || !argv.json) return usage('apply <schema> --json manifest.json [--dry-run] [--confirm-destructive] [--if-version <v
|
|
215
|
+
if (!args[0] || !argv.json) return usage('apply <schema> --json manifest.json [--dry-run] [--confirm-destructive] [--if-version <v>|current]');
|
|
214
216
|
const a = (0, _nodeDapi.parseDomainAddress)(args[0], {
|
|
215
217
|
table: false
|
|
216
218
|
});
|
|
217
|
-
const
|
|
219
|
+
const json = readJson(argv.json);
|
|
220
|
+
// One registry read, only when the token or the metadata keys depend on who manages the schema.
|
|
221
|
+
const s = argv['if-version'] === undefined || argv['if-version'] === 'current' || USER_APPLY_KEYS.some(k => json?.[k] !== undefined) ? await domains.schema(a.schema) : null;
|
|
222
|
+
const body = applyBody(json, argv, {
|
|
223
|
+
userManaged: s?.managedBy === 'user'
|
|
224
|
+
});
|
|
225
|
+
if (body.ifVersion === 'current') {
|
|
226
|
+
// Both halves of the token as registered now: a name deleted and re-created
|
|
227
|
+
// since the file was written must conflict, not pass as a fresh `1`.
|
|
228
|
+
const m = await domains.manifest(a.schema);
|
|
229
|
+
body.ifVersion = String(m?.version ?? s.version ?? '1');
|
|
230
|
+
if (m?.incarnation !== undefined) body.ifIncarnation = String(m.incarnation);
|
|
231
|
+
} else if (body.ifVersion === undefined && s.managedBy === 'user') return usage(`apply ${a.schema} --json manifest.json --if-version <v>|current — a user-managed schema ` + 'requires the version the edit was made against (the "version" of the manifest JSON is used when present)');
|
|
218
232
|
printJson(await domains.applySchema(a.schema, body, argv['dry-run'] === true), output, r => r?.version);
|
|
219
233
|
return true;
|
|
220
234
|
}
|
|
@@ -305,12 +319,15 @@ function aggregateSpec(argv, filter, limit) {
|
|
|
305
319
|
return spec;
|
|
306
320
|
}
|
|
307
321
|
|
|
308
|
-
/** The apply payload from a schema.json (or a partial body): only the keys the server accepts
|
|
309
|
-
|
|
322
|
+
/** The apply payload from a schema.json (or a partial body): only the keys the server accepts
|
|
323
|
+
* (the schema's metadata and storage keys for a user-managed schema only), flags folded in. */
|
|
324
|
+
function applyBody(json, argv, options = {}) {
|
|
310
325
|
const body = {};
|
|
311
|
-
for (const k of APPLY_KEYS) if (json?.[k] !== undefined) body[k] = json[k];
|
|
326
|
+
for (const k of options.userManaged ? [...APPLY_KEYS, ...USER_APPLY_KEYS] : APPLY_KEYS) if (json?.[k] !== undefined) body[k] = json[k];
|
|
312
327
|
if (argv['confirm-destructive'] === true) body.confirmDestructive = true;
|
|
313
|
-
if (argv['if-version'] !== undefined) body.ifVersion = String(argv['if-version']);
|
|
328
|
+
if (argv['if-version'] !== undefined) body.ifVersion = String(argv['if-version']);else if (json?.version !== undefined) body.ifVersion = String(json.version);
|
|
329
|
+
// The manifest's `incarnation` rides with its `version` (the server keeps it optional).
|
|
330
|
+
if (json?.incarnation !== undefined) body.ifIncarnation = String(json.incarnation);
|
|
314
331
|
return body;
|
|
315
332
|
}
|
|
316
333
|
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.OBSERVE_USAGE = void 0;
|
|
7
|
+
exports.fieldRows = fieldRows;
|
|
8
|
+
exports.handleObserve = handleObserve;
|
|
9
|
+
exports.parseDuration = parseDuration;
|
|
10
|
+
exports.parseTime = parseTime;
|
|
11
|
+
exports.setPath = setPath;
|
|
12
|
+
var fs = _interopRequireWildcard(require("fs"));
|
|
13
|
+
var _nodeDapi = require("../utils/node-dapi");
|
|
14
|
+
var _serverOutput = require("../utils/server-output");
|
|
15
|
+
var _walker = require("../utils/migrate/walker");
|
|
16
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
17
|
+
/// `grok s observe` (alias `grok s o`): problems, problem rules, logger settings, errors, the timeline and cloud logs,
|
|
18
|
+
/// over `/problems`, `/admin/plugins/<key>/settings`, `/alerts/Test_rules`, `/log`, `/admin/metrics`, `/log/timeline`,
|
|
19
|
+
/// `/log/cloud/*`, `/log/archive/*` and `/docker/containers`.
|
|
20
|
+
|
|
21
|
+
const OBSERVE_USAGE = exports.OBSERVE_USAGE = `Usage: grok s observe <command> <verb> [args] (alias: grok s o)
|
|
22
|
+
|
|
23
|
+
problems list [--status <s>] [--state <s>] [--alert-status <s>] [--kind <k>] [--key <k>] [--since 7d] [--limit 100]
|
|
24
|
+
Comma lists or all; --alert-status open lists the open alerts
|
|
25
|
+
problems get <id|kind:key> One field per line, details by path
|
|
26
|
+
problems history <id|kind:key> [--limit 50]
|
|
27
|
+
problems status <id|kind:key> --data '{"status": "muted", "reason": "...", "until": "<ISO>"}' | --json body.json
|
|
28
|
+
problems ack <id|kind:key> --reason <text>
|
|
29
|
+
problems resolve <id|kind:key> --reason <text>
|
|
30
|
+
rules get The problem rules (Settings > Alerts), as JSON
|
|
31
|
+
rules put --json rules.json Replace them; the server checks them first
|
|
32
|
+
rules test Evaluate the saved rules once, now
|
|
33
|
+
logger get [<path>] The logger settings, or one value (exportSettings.0.types)
|
|
34
|
+
logger set --set <path>=<json> ... Change values; with --user <login> or --group <name|id>, that
|
|
35
|
+
[--for 30m | --until <ISO>] [--reason <text>] entry of userGroupSettings (a new one starts from
|
|
36
|
+
[--remove] All users), removed when it expires; --remove deletes it
|
|
37
|
+
logger history [--limit 20] The log-settings-changed audit records, with each change's diff
|
|
38
|
+
errors top [--since 24h | --date "this week" | --from <ISO> [--to <ISO>]] [--by message|signature]
|
|
39
|
+
[--package <name>] [--user <login>] [--source <s>] [--limit 10]
|
|
40
|
+
Ranked by distinct users, so errors nobody saw (scheduler, health
|
|
41
|
+
probes) come last: narrow those with --source server
|
|
42
|
+
errors get <signature|problem> [--since 7d] [--limit 50]
|
|
43
|
+
Each occurrence of an error (a signature from errors top, or the
|
|
44
|
+
id or kind:key of its problem): time, user, function, session
|
|
45
|
+
logs container [<name|id> [--since 1h | --from <ISO> [--to <ISO>]] [--grep <regex>] [--limit 20000]]
|
|
46
|
+
A docker container's log lines (its latest --limit, then the
|
|
47
|
+
window and --grep); no name, the containers and their status
|
|
48
|
+
timeline --session <id> [--from <ISO|-15m>] [--to <ISO>] [--limit 500]
|
|
49
|
+
A session's events and requests in a window: no --from, the last
|
|
50
|
+
15 min; --to defaults to --from + 10 min, at most 2 h
|
|
51
|
+
logs cloud [--group <g>] [--since 1h | --from <ISO> [--to <ISO>]] [--filter <pattern>] [--limit 1000]
|
|
52
|
+
CloudWatch events; no --group, the instance's own group;
|
|
53
|
+
--filter is a CloudWatch filter pattern
|
|
54
|
+
logs archive list [--prefix <p>] [--since 7d] [--limit n]
|
|
55
|
+
Objects in the log archive (WORM bucket), by key; --since filters
|
|
56
|
+
only the page the server returned, so narrow it with --prefix
|
|
57
|
+
logs archive read <key> One archived object decoded to events
|
|
58
|
+
logs cloud and logs archive take [--connection <id>]; without it,
|
|
59
|
+
the instance's AWS role
|
|
60
|
+
logs capture [<login|group> [--for 30m] [--reason <text>]]
|
|
61
|
+
Save every API request of a user or a group for a while: logger set
|
|
62
|
+
of saveHttpRequests on their expiring entry (an entry that already
|
|
63
|
+
expires later keeps its time); no argument, the active captures;
|
|
64
|
+
read them with timeline --session <id>
|
|
65
|
+
logs capture stop <login|group> Remove that expiring entry; one that holds other settings is left
|
|
66
|
+
to logger set --remove
|
|
67
|
+
|
|
68
|
+
Durations are 30m (minutes), 24h, 7d, 2w; times are ISO 8601 (UTC without an offset) or
|
|
69
|
+
relative, written with = (--from=-2h).`;
|
|
70
|
+
const UNIT_MS = {
|
|
71
|
+
m: 60000,
|
|
72
|
+
h: 3600000,
|
|
73
|
+
d: 86400000,
|
|
74
|
+
w: 604800000
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** `30m`, `2h`, `7d`, `1w` in milliseconds; `m` is minutes, unlike `grok s pull --since`, where it is months. */
|
|
78
|
+
function parseDuration(value, flag) {
|
|
79
|
+
const m = /^(\d+)([mhdw])$/.exec(String(value ?? '').trim());
|
|
80
|
+
if (!m || Number(m[1]) <= 0) throw new Error(`${flag} expects a duration such as 30m, 2h, 7d or 1w, got '${value}'`);
|
|
81
|
+
return Number(m[1]) * UNIT_MS[m[2]];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** ISO 8601 (UTC without an offset), or relative to [now] (`-2h`). */
|
|
85
|
+
function parseTime(value, flag, now = new Date()) {
|
|
86
|
+
let s = String(value ?? '').trim();
|
|
87
|
+
if (s.startsWith('-')) return new Date(now.getTime() - parseDuration(s.substring(1), flag));
|
|
88
|
+
if (/[T ].*[+-]\d{2}$/.test(s)) s = `${s}:00`;
|
|
89
|
+
const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(s) || /(Z|[+-]\d{2}:?\d{2})$/i.test(s) ? s : `${s}Z`);
|
|
90
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(s) || isNaN(d.getTime())) throw new Error(`${flag} expects an ISO time (2026-10-04T06:00) or a relative one (-2h), got '${value}'`);
|
|
91
|
+
return d;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `a.b.0.c=<json>` applied to [target]; a value that is not JSON is a string. */
|
|
95
|
+
function setPath(target, assignment) {
|
|
96
|
+
const at = assignment.indexOf('=');
|
|
97
|
+
if (at <= 0) throw new Error(`--set expects <path>=<json>, got '${assignment}'`);
|
|
98
|
+
const keys = assignment.slice(0, at).split('.');
|
|
99
|
+
const text = assignment.slice(at + 1);
|
|
100
|
+
let value;
|
|
101
|
+
try {
|
|
102
|
+
value = JSON.parse(text);
|
|
103
|
+
} catch {
|
|
104
|
+
value = text;
|
|
105
|
+
}
|
|
106
|
+
let node = target;
|
|
107
|
+
for (const key of keys.slice(0, -1)) node = node[key] ??= {};
|
|
108
|
+
node[keys[keys.length - 1]] = value;
|
|
109
|
+
return keys[0];
|
|
110
|
+
}
|
|
111
|
+
function readBody(argv) {
|
|
112
|
+
if (argv.json) return JSON.parse(fs.readFileSync(argv.json, 'utf8'));
|
|
113
|
+
if (argv.data !== undefined) return JSON.parse(String(argv.data));
|
|
114
|
+
throw new Error('Expected a body: --data \'<json>\' or --json <file>');
|
|
115
|
+
}
|
|
116
|
+
const iso = (value, flag) => value === undefined ? undefined : parseTime(value, flag).toISOString();
|
|
117
|
+
|
|
118
|
+
/** A record as `field, value` rows, nested objects by dotted path, so a table shows every value in full. */
|
|
119
|
+
function fieldRows(record, prefix = '') {
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (const [key, value] of Object.entries(record ?? {})) if (value !== null && typeof value === 'object' && !Array.isArray(value)) rows.push(...fieldRows(value, `${prefix}${key}.`));else rows.push({
|
|
122
|
+
field: `${prefix}${key}`,
|
|
123
|
+
value
|
|
124
|
+
});
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
127
|
+
async function handleObserve(dapi, command, verb, rest, argv, output) {
|
|
128
|
+
// A `:` cannot pass through a route parameter behind the proxy, so kind:key is looked up by the list's filters.
|
|
129
|
+
let id;
|
|
130
|
+
const needId = async () => {
|
|
131
|
+
const given = rest[0] === undefined ? '' : String(rest[0]);
|
|
132
|
+
const colon = given.indexOf(':');
|
|
133
|
+
if (!given || colon === 0 || colon === given.length - 1) throw new Error(`Usage: grok s observe ${command} ${verb} <id|kind:key>`);
|
|
134
|
+
if (colon < 0) {
|
|
135
|
+
id = given;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const found = await dapi.problems.list({
|
|
139
|
+
kind: given.substring(0, colon),
|
|
140
|
+
key: given.substring(colon + 1),
|
|
141
|
+
limit: 1
|
|
142
|
+
});
|
|
143
|
+
if (!found?.length) throw new Error(`No problem '${given}'`);
|
|
144
|
+
id = found[0].id;
|
|
145
|
+
};
|
|
146
|
+
const problemPath = () => `/problems/${encodeURIComponent(id)}`;
|
|
147
|
+
// The route answers JSON as text/plain.
|
|
148
|
+
const settings = async key => {
|
|
149
|
+
const r = await dapi.raw('GET', `/admin/plugins/${key}/settings`);
|
|
150
|
+
return (typeof r === 'string' ? JSON.parse(r) : r)?.settings ?? {};
|
|
151
|
+
};
|
|
152
|
+
// A save within 5 s of the previous one is refused (429) until that one is applied.
|
|
153
|
+
const saveSettings = async (key, body) => {
|
|
154
|
+
for (let attempt = 0;; attempt++) {
|
|
155
|
+
try {
|
|
156
|
+
return await dapi.raw('POST', `/admin/plugins/${key}/settings`, body);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
if (attempt >= 2 || e?.apiError?.errorCode !== 429) throw e;
|
|
159
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const saveLogger = body => saveSettings('logger', body);
|
|
164
|
+
switch (`${command} ${verb}`) {
|
|
165
|
+
case 'problems list':
|
|
166
|
+
(0, _serverOutput.printOutput)(await dapi.problems.list({
|
|
167
|
+
status: argv.status,
|
|
168
|
+
state: argv.state,
|
|
169
|
+
alertStatus: argv['alert-status'],
|
|
170
|
+
kind: argv.kind,
|
|
171
|
+
key: argv.key,
|
|
172
|
+
since: argv.since,
|
|
173
|
+
limit: argv.limit
|
|
174
|
+
}), output);
|
|
175
|
+
return true;
|
|
176
|
+
case 'problems get':
|
|
177
|
+
{
|
|
178
|
+
await needId();
|
|
179
|
+
const problem = await dapi.problems.find(id);
|
|
180
|
+
if (!problem) throw new Error(`No problem '${rest[0]}'`);
|
|
181
|
+
(0, _serverOutput.printOutput)(output === 'table' ? fieldRows(problem) : problem, output);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
case 'problems history':
|
|
185
|
+
await needId();
|
|
186
|
+
(0, _serverOutput.printOutput)(await dapi.raw('GET', `${problemPath()}/history${(0, _nodeDapi.buildQuery)({
|
|
187
|
+
limit: argv.limit
|
|
188
|
+
})}`), output);
|
|
189
|
+
return true;
|
|
190
|
+
case 'problems status':
|
|
191
|
+
await needId();
|
|
192
|
+
(0, _serverOutput.printOutput)(await dapi.raw('POST', `${problemPath()}/status`, readBody(argv)), output);
|
|
193
|
+
return true;
|
|
194
|
+
case 'problems ack':
|
|
195
|
+
case 'problems resolve':
|
|
196
|
+
await needId();
|
|
197
|
+
(0, _serverOutput.printOutput)(await dapi.raw('POST', `${problemPath()}/${verb}`, {
|
|
198
|
+
reason: argv.reason
|
|
199
|
+
}), output);
|
|
200
|
+
return true;
|
|
201
|
+
case 'rules get':
|
|
202
|
+
{
|
|
203
|
+
const rules = (await settings('alerts'))?.problemRules;
|
|
204
|
+
(0, _serverOutput.printOutput)(rules ? JSON.parse(rules) : [], output === 'table' ? 'json' : output);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
case 'rules put':
|
|
208
|
+
{
|
|
209
|
+
const rules = readBody(argv);
|
|
210
|
+
await saveSettings('alerts', {
|
|
211
|
+
problemRules: typeof rules === 'string' ? rules : JSON.stringify(rules)
|
|
212
|
+
});
|
|
213
|
+
if (output !== 'quiet') console.log('Saved the problem rules');
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
case 'rules test':
|
|
217
|
+
console.log(await dapi.raw('POST', '/alerts/Test_rules'));
|
|
218
|
+
return true;
|
|
219
|
+
case 'logger get':
|
|
220
|
+
{
|
|
221
|
+
let value = await settings('logger');
|
|
222
|
+
for (const key of rest[0] ? String(rest[0]).split('.') : []) value = value?.[key];
|
|
223
|
+
(0, _serverOutput.printOutput)(value, output === 'table' ? 'json' : output);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
case 'logger set':
|
|
227
|
+
return loggerSet(dapi, argv, await settings('logger'), saveLogger, output);
|
|
228
|
+
case 'logger history':
|
|
229
|
+
{
|
|
230
|
+
const text = encodeURIComponent('eventType.name = "log-settings-changed"');
|
|
231
|
+
const events = (await dapi.raw('GET', `/log?text=${text}&limit=${argv.limit ?? 20}&page=1`)) ?? [];
|
|
232
|
+
(0, _serverOutput.printOutput)(events.map(e => {
|
|
233
|
+
const params = {};
|
|
234
|
+
for (const p of e.parameters ?? []) params[p.parameter?.name] = p.value;
|
|
235
|
+
const row = {
|
|
236
|
+
time: e.eventTime,
|
|
237
|
+
user: params.user,
|
|
238
|
+
changed: params.changed
|
|
239
|
+
};
|
|
240
|
+
return output === 'table' ? row : {
|
|
241
|
+
...row,
|
|
242
|
+
diff: params.diff ? (0, _nodeDapi.tryParseJson)(params.diff) ?? params.diff : null
|
|
243
|
+
};
|
|
244
|
+
}), output);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
case 'errors top':
|
|
248
|
+
{
|
|
249
|
+
if (argv.since !== undefined && (argv.from !== undefined || argv.date !== undefined)) throw new Error('--since is not used with --from or --date');
|
|
250
|
+
if (argv.date !== undefined && argv.from !== undefined) throw new Error('--date is not used with --from');
|
|
251
|
+
const metrics = await dapi.raw('GET', `/admin/metrics${(0, _nodeDapi.buildQuery)({
|
|
252
|
+
date_start: argv.since !== undefined ? new Date(Date.now() - parseDuration(argv.since, '--since')).toISOString() : iso(argv.from, '--from'),
|
|
253
|
+
date_end: iso(argv.to, '--to'),
|
|
254
|
+
date: argv.date,
|
|
255
|
+
limit: argv.limit,
|
|
256
|
+
errors_by: argv.by,
|
|
257
|
+
package: argv.package,
|
|
258
|
+
user: argv.user,
|
|
259
|
+
source: argv.source
|
|
260
|
+
})}`);
|
|
261
|
+
// The server answers the top `limit` of every source, in the global order.
|
|
262
|
+
const top = (metrics?.errors?.top ?? []).slice(0, Number(argv.limit ?? 10));
|
|
263
|
+
(0, _serverOutput.printOutput)(output === 'json' ? {
|
|
264
|
+
...metrics?.errors,
|
|
265
|
+
top
|
|
266
|
+
} : top, output);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
case 'errors get':
|
|
270
|
+
{
|
|
271
|
+
if (rest[0] === undefined) throw new Error('Usage: grok s observe errors get <signature|problem id|kind:key> [--since 7d] [--limit 50]');
|
|
272
|
+
const given = String(rest[0]);
|
|
273
|
+
if (given.includes(':')) await needId();
|
|
274
|
+
const problem = await dapi.problems.find(id ?? given);
|
|
275
|
+
const signature = problem ? problem.details?.signatureId : given;
|
|
276
|
+
if (!signature) throw new Error(`Problem '${given}' is not about an error: it has no signatureId`);
|
|
277
|
+
// An error with a stack is grouped by its stack hash, one without by its event type (ErrorIncidentProblem).
|
|
278
|
+
const text = `(eventType.id = "${signature}" or eventType.errorStackTraceHash = "${signature}") and ` + `eventTime > "${new Date(Date.now() - parseDuration(argv.since ?? '7d', '--since')).toISOString()}"`;
|
|
279
|
+
const events = (await dapi.raw('GET', `/log${(0, _nodeDapi.buildQuery)({
|
|
280
|
+
text,
|
|
281
|
+
limit: argv.limit ?? 50,
|
|
282
|
+
page: 1
|
|
283
|
+
})}`)) ?? [];
|
|
284
|
+
const logins = new Map();
|
|
285
|
+
for (const e of events) for (const p of e.parameters ?? []) if (p.parameter?.name === 'user' && p.value && !logins.has(p.value)) logins.set(p.value, (await dapi.users.find(p.value))?.login ?? p.value);
|
|
286
|
+
(0, _serverOutput.printOutput)(events.map(e => {
|
|
287
|
+
const params = {};
|
|
288
|
+
for (const p of e.parameters ?? []) params[p.parameter?.name] = p.value;
|
|
289
|
+
const row = {
|
|
290
|
+
time: e.eventTime,
|
|
291
|
+
source: params.source,
|
|
292
|
+
user: logins.get(params.user) ?? null,
|
|
293
|
+
function: params.function ?? null,
|
|
294
|
+
session: e.session?.id ?? null,
|
|
295
|
+
message: e.description
|
|
296
|
+
};
|
|
297
|
+
return output === 'table' ? row : {
|
|
298
|
+
...row,
|
|
299
|
+
eventId: e.id,
|
|
300
|
+
params
|
|
301
|
+
};
|
|
302
|
+
}), output);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (command === 'logs' && verb === 'capture') return capture(dapi, rest, argv, await settings('logger'), saveLogger, output);
|
|
307
|
+
if (command === 'logs') return logs(dapi, verb, rest, argv, output);
|
|
308
|
+
if (command === 'timeline') {
|
|
309
|
+
if (argv.session === undefined) throw new Error('Usage: grok s observe timeline --session <id> [--from <ISO|-15m>] [--to <ISO>] [--limit 500]');
|
|
310
|
+
needFrom(argv);
|
|
311
|
+
const window = argv.from === undefined ? {
|
|
312
|
+
from: iso('-15m', '--from'),
|
|
313
|
+
to: new Date().toISOString()
|
|
314
|
+
} : {
|
|
315
|
+
from: iso(argv.from, '--from'),
|
|
316
|
+
to: iso(argv.to, '--to')
|
|
317
|
+
};
|
|
318
|
+
(0, _serverOutput.printOutput)(await dapi.raw('GET', `/log/timeline${(0, _nodeDapi.buildQuery)({
|
|
319
|
+
session: argv.session,
|
|
320
|
+
...window,
|
|
321
|
+
limit: argv.limit
|
|
322
|
+
})}`), output);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`Unknown command '${[command, verb].filter(Boolean).join(' ')}'\n${OBSERVE_USAGE}`);
|
|
326
|
+
}
|
|
327
|
+
const needFrom = argv => {
|
|
328
|
+
if (argv.to !== undefined && argv.from === undefined) throw new Error('--to needs --from');
|
|
329
|
+
};
|
|
330
|
+
async function logs(dapi, verb, rest, argv, output) {
|
|
331
|
+
const connection = argv.connection;
|
|
332
|
+
if (verb === 'cloud') {
|
|
333
|
+
if (argv.since !== undefined && argv.from !== undefined) throw new Error('--since is not used with --from');
|
|
334
|
+
needFrom(argv);
|
|
335
|
+
const group = argv.group ?? (await dapi.raw('GET', `/log/cloud/groups${(0, _nodeDapi.buildQuery)({
|
|
336
|
+
connection
|
|
337
|
+
})}`))?.[0];
|
|
338
|
+
if (!group) throw new Error('No log group to read: pass --group');
|
|
339
|
+
const window = argv.from !== undefined ? {
|
|
340
|
+
start: iso(argv.from, '--from'),
|
|
341
|
+
end: iso(argv.to, '--to')
|
|
342
|
+
} : {
|
|
343
|
+
start: new Date(Date.now() - parseDuration(argv.since ?? '1h', '--since')).toISOString()
|
|
344
|
+
};
|
|
345
|
+
(0, _serverOutput.printOutput)(await dapi.raw('GET', `/log/cloud/events${(0, _nodeDapi.buildQuery)({
|
|
346
|
+
connection,
|
|
347
|
+
group,
|
|
348
|
+
...window,
|
|
349
|
+
filter: argv.filter,
|
|
350
|
+
limit: argv.limit,
|
|
351
|
+
format: 'json'
|
|
352
|
+
})}`), output);
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
if (verb === 'container') return containerLogs(dapi, rest[0], argv, output);
|
|
356
|
+
if (verb === 'archive' && rest[0] === 'list') {
|
|
357
|
+
let objects = (await dapi.raw('GET', `/log/archive/objects${(0, _nodeDapi.buildQuery)({
|
|
358
|
+
connection,
|
|
359
|
+
prefix: argv.prefix,
|
|
360
|
+
limit: argv.limit,
|
|
361
|
+
format: 'json'
|
|
362
|
+
})}`)) ?? [];
|
|
363
|
+
if (argv.since !== undefined) {
|
|
364
|
+
const from = Date.now() - parseDuration(argv.since, '--since');
|
|
365
|
+
objects = objects.filter(o => Date.parse(o.modified) >= from);
|
|
366
|
+
}
|
|
367
|
+
(0, _serverOutput.printOutput)(objects, output);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
if (verb === 'archive' && rest[0] === 'read' && rest[1] !== undefined) {
|
|
371
|
+
(0, _serverOutput.printOutput)(await dapi.raw('GET', `/log/archive/events${(0, _nodeDapi.buildQuery)({
|
|
372
|
+
connection,
|
|
373
|
+
key: rest[1],
|
|
374
|
+
format: 'json'
|
|
375
|
+
})}`), output);
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
throw new Error('Usage: grok s observe logs cloud [--group <g>] ... | logs container [<name|id>] | ' + 'logs archive list [--prefix <p>] | logs archive read <key> | logs capture [<login|group>] | ' + 'logs capture stop <login|group>');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// `logs container`: the server only cuts a container's log by line count, so the window and --grep apply here.
|
|
382
|
+
async function containerLogs(dapi, name, argv, output) {
|
|
383
|
+
if (name === undefined) {
|
|
384
|
+
const all = (await dapi.raw('GET', `/docker/containers${(0, _nodeDapi.buildQuery)({
|
|
385
|
+
limit: 1000
|
|
386
|
+
})}`)) ?? [];
|
|
387
|
+
(0, _serverOutput.printOutput)(all.map(c => ({
|
|
388
|
+
id: c.id,
|
|
389
|
+
name: c.name,
|
|
390
|
+
status: c.status
|
|
391
|
+
})), output);
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
if (argv.since !== undefined && argv.from !== undefined) throw new Error('--since is not used with --from');
|
|
395
|
+
needFrom(argv);
|
|
396
|
+
let id = String(name);
|
|
397
|
+
if (!_nodeDapi.UUID_RE.test(id)) {
|
|
398
|
+
const found = (await dapi.raw('GET', `/docker/containers${(0, _nodeDapi.buildQuery)({
|
|
399
|
+
text: id,
|
|
400
|
+
limit: 100
|
|
401
|
+
})}`)) ?? [];
|
|
402
|
+
const container = found.find(c => c.name === id);
|
|
403
|
+
if (!container) throw new Error(`No container '${id}'${found.length ? `; similar: ${found.map(c => c.name).join(', ')}` : ''}`);
|
|
404
|
+
id = container.id;
|
|
405
|
+
}
|
|
406
|
+
const from = argv.from !== undefined ? parseTime(argv.from, '--from').getTime() : argv.since !== undefined ? Date.now() - parseDuration(argv.since, '--since') : -Infinity;
|
|
407
|
+
const to = argv.to !== undefined ? parseTime(argv.to, '--to').getTime() : Infinity;
|
|
408
|
+
const grep = argv.grep === undefined ? null : new RegExp(String(argv.grep), 'i');
|
|
409
|
+
const text = await dapi.raw('GET', `/docker/containers/${id}/logs${(0, _nodeDapi.buildQuery)({
|
|
410
|
+
limit: argv.limit ?? 20000
|
|
411
|
+
})}`);
|
|
412
|
+
// A line without its own timestamp (a stack frame) belongs to the window of the line before it.
|
|
413
|
+
let inWindow = from === -Infinity && to === Infinity;
|
|
414
|
+
const lines = [];
|
|
415
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
416
|
+
const stamp = /^(\d{4}-\d{2}-\d{2}T[\d:]{8})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})/.exec(line);
|
|
417
|
+
if (stamp) {
|
|
418
|
+
const t = Date.parse(`${stamp[1]}${stamp[2]}`);
|
|
419
|
+
inWindow = t >= from && t <= to;
|
|
420
|
+
}
|
|
421
|
+
if (inWindow && line && (!grep || grep.test(line))) lines.push(line);
|
|
422
|
+
}
|
|
423
|
+
if (output === 'json') (0, _serverOutput.printOutput)(lines, output);else for (const line of lines) console.log(line);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
async function loggerSet(dapi, argv, current, save, output) {
|
|
427
|
+
const sets = argv.set === undefined ? [] : (Array.isArray(argv.set) ? argv.set : [argv.set]).map(String);
|
|
428
|
+
const entry = argv.user !== undefined || argv.group !== undefined || argv.target !== undefined;
|
|
429
|
+
if (!sets.length && !entry) throw new Error('Usage: grok s observe logger set --set <path>=<json> ... [--user <login> | --group <name|id>]');
|
|
430
|
+
if (!entry && (argv.for !== undefined || argv.until !== undefined || argv.reason !== undefined || argv.remove)) throw new Error('--for, --until, --reason and --remove apply to a --user or --group entry');
|
|
431
|
+
const name = argv.user ?? argv.group ?? argv.name;
|
|
432
|
+
if (!entry) {
|
|
433
|
+
const body = {};
|
|
434
|
+
for (const s of sets) {
|
|
435
|
+
const key = setPath(current, s);
|
|
436
|
+
body[key] = current[key];
|
|
437
|
+
}
|
|
438
|
+
await save(body);
|
|
439
|
+
} else {
|
|
440
|
+
const group = argv.target ?? (argv.user !== undefined ? await dapi.groups.resolve(String(argv.user), {
|
|
441
|
+
personalOnly: true
|
|
442
|
+
}) : await dapi.groups.resolve(String(argv.group)));
|
|
443
|
+
const groups = {
|
|
444
|
+
...current.userGroupSettings
|
|
445
|
+
};
|
|
446
|
+
const existing = groups[group.id];
|
|
447
|
+
if (argv.remove) {
|
|
448
|
+
if (!existing) throw new Error(`${name} has no logger entry`);
|
|
449
|
+
delete groups[group.id];
|
|
450
|
+
} else {
|
|
451
|
+
if (existing && !existing.expiresAt && (argv.for !== undefined || argv.until !== undefined)) throw new Error(`${name} has a logger entry that does not expire; change it without --for or --until`);
|
|
452
|
+
// A user's settings come from one entry, so a new one starts from All users instead of the defaults.
|
|
453
|
+
const settings = existing ? {
|
|
454
|
+
...existing
|
|
455
|
+
} : {
|
|
456
|
+
...groups[_walker.ALL_USERS]
|
|
457
|
+
};
|
|
458
|
+
if (!existing) delete settings.reason;
|
|
459
|
+
for (const s of sets) setPath(settings, s);
|
|
460
|
+
if (argv.for !== undefined) settings.expiresAt = new Date(Date.now() + parseDuration(argv.for, '--for')).toISOString();else if (argv.until !== undefined) {
|
|
461
|
+
settings.expiresAt = iso(argv.until, '--until');
|
|
462
|
+
if (Date.parse(settings.expiresAt) <= Date.now()) throw new Error(`--until ${argv.until} is in the past`);
|
|
463
|
+
}
|
|
464
|
+
if (argv.reason !== undefined) settings.reason = String(argv.reason);
|
|
465
|
+
groups[group.id] = settings;
|
|
466
|
+
}
|
|
467
|
+
await save({
|
|
468
|
+
userGroupSettings: groups
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (output !== 'quiet') console.log(!entry ? 'Saved the logger settings' : argv.remove ? `Removed the logger entry of ${name}` : `Saved the logger settings of ${name}`);
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/// `logs capture`: logger set of saveHttpRequests, and the usage level it is saved at, on an expiring entry.
|
|
476
|
+
async function capture(dapi, rest, argv, current, save, output) {
|
|
477
|
+
const groups = current.userGroupSettings ?? {};
|
|
478
|
+
const stop = rest[0] === 'stop';
|
|
479
|
+
const name = stop ? rest[1] : rest[0];
|
|
480
|
+
if (name === undefined) {
|
|
481
|
+
if (stop) throw new Error('Usage: grok s observe logs capture stop <login|group>');
|
|
482
|
+
const rows = [];
|
|
483
|
+
for (const [id, s] of Object.entries(groups)) if (s?.saveHttpRequests === true && s.expiresAt) {
|
|
484
|
+
const group = await dapi.groups.find(id);
|
|
485
|
+
rows.push({
|
|
486
|
+
id,
|
|
487
|
+
name: group?.friendlyName ?? group?.name ?? id,
|
|
488
|
+
expiresAt: s.expiresAt,
|
|
489
|
+
reason: s.reason ?? ''
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
(0, _serverOutput.printOutput)(rows, output);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
let group;
|
|
496
|
+
try {
|
|
497
|
+
group = await dapi.groups.resolve(String(name), {
|
|
498
|
+
personalOnly: true
|
|
499
|
+
});
|
|
500
|
+
} catch {
|
|
501
|
+
group = await dapi.groups.resolve(String(name));
|
|
502
|
+
}
|
|
503
|
+
const existing = groups[group.id];
|
|
504
|
+
if (stop) {
|
|
505
|
+
if (!existing?.saveHttpRequests) throw new Error(`No capture for ${name}`);
|
|
506
|
+
if (!existing.expiresAt) throw new Error(`The logger entry of ${name} does not expire; change it with logger set`);
|
|
507
|
+
const own = ['saveHttpRequests', 'postLevels', 'saveLevels', 'expiresAt', 'reason'];
|
|
508
|
+
const others = Object.keys(existing).filter(k => !own.includes(k) && JSON.stringify(existing[k]) !== JSON.stringify(groups[_walker.ALL_USERS]?.[k]));
|
|
509
|
+
if (others.length) throw new Error(`The logger entry of ${name} also holds ${others.join(', ')}; remove it with ` + `grok s o logger set --${group.personal ? 'user' : 'group'} ${name} --remove`);
|
|
510
|
+
return loggerSet(dapi, {
|
|
511
|
+
target: group,
|
|
512
|
+
name,
|
|
513
|
+
remove: true
|
|
514
|
+
}, current, save, output);
|
|
515
|
+
}
|
|
516
|
+
const base = existing ?? groups[_walker.ALL_USERS] ?? {};
|
|
517
|
+
const sets = ['saveHttpRequests=true', ...['postLevels', 'saveLevels'].filter(l => Array.isArray(base[l]) && !base[l].includes('usage')).map(l => `${l}=${JSON.stringify([...base[l], 'usage'])}`)];
|
|
518
|
+
const end = Math.max(Date.now() + parseDuration(argv.for ?? '30m', '--for'), Date.parse(existing?.expiresAt) || 0);
|
|
519
|
+
return loggerSet(dapi, {
|
|
520
|
+
target: group,
|
|
521
|
+
name,
|
|
522
|
+
set: sets,
|
|
523
|
+
reason: argv.reason,
|
|
524
|
+
until: new Date(end).toISOString()
|
|
525
|
+
}, current, save, output);
|
|
526
|
+
}
|
package/bin/commands/server.js
CHANGED
|
@@ -15,6 +15,7 @@ var _serverClient = require("../utils/server-client");
|
|
|
15
15
|
var _serverOutput = require("../utils/server-output");
|
|
16
16
|
var _serverMigrate = require("./server-migrate");
|
|
17
17
|
var _serverDomains = require("./server-domains");
|
|
18
|
+
var _serverObserve = require("./server-observe");
|
|
18
19
|
var _registry = require("../utils/migrate/registry");
|
|
19
20
|
var _walker = require("../utils/migrate/walker");
|
|
20
21
|
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
@@ -27,7 +28,7 @@ const ENTITY_TYPES = {
|
|
|
27
28
|
reports: 'UserReport'
|
|
28
29
|
};
|
|
29
30
|
const ENTITIES = ['users', 'groups', 'functions', 'connections', 'queries', 'scripts', 'packages', 'reports', 'files', 'tables'];
|
|
30
|
-
const COMMANDS = ['shares', 'domains', 'raw', 'batch', 'describe', 'healthcheck', 'sync', 'pull', 'push', 'migrate', 'diff', 'bundle', 'token'];
|
|
31
|
+
const COMMANDS = ['shares', 'domains', 'raw', 'batch', 'describe', 'healthcheck', 'sync', 'pull', 'push', 'migrate', 'diff', 'bundle', 'token', 'observe', 'o'];
|
|
31
32
|
const VERBS = ['list', 'count', 'get', 'delete'];
|
|
32
33
|
async function server(argv) {
|
|
33
34
|
const args = argv['_'].slice(1);
|
|
@@ -45,6 +46,10 @@ async function server(argv) {
|
|
|
45
46
|
console.log(HELP_SERVER);
|
|
46
47
|
return true;
|
|
47
48
|
}
|
|
49
|
+
if ((entity === 'observe' || entity === 'o') && !verb) {
|
|
50
|
+
console.log(_serverObserve.OBSERVE_USAGE);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
48
53
|
let client;
|
|
49
54
|
try {
|
|
50
55
|
client = await (0, _serverClient.createClient)(host, !!argv.admin);
|
|
@@ -60,6 +65,7 @@ async function server(argv) {
|
|
|
60
65
|
// escaping to grok.js as an unhandled rejection (stack trace + exit 255)
|
|
61
66
|
if (['pull', 'push', 'migrate', 'diff', 'bundle'].includes(entity)) return await (0, _serverMigrate.handleMigrate)(dapi, entity, [verb, ...rest].filter(Boolean), argv, output);
|
|
62
67
|
if (entity === 'domains') return await (0, _serverDomains.handleDomains)(dapi, verb, rest, argv, output);
|
|
68
|
+
if (entity === 'observe' || entity === 'o') return await (0, _serverObserve.handleObserve)(dapi, verb, rest[0], rest.slice(1), argv, output);
|
|
63
69
|
if (entity === 'batch') return await handleBatch(dapi, argv, verb, rest, output);
|
|
64
70
|
// Shell scripts that used to curl /users/login/dev get a token the same way
|
|
65
71
|
// every other command does, whatever credential the config holds.
|
|
@@ -927,6 +933,7 @@ Manage a Datagrok server from the command line.
|
|
|
927
933
|
Entities:
|
|
928
934
|
users, groups, functions, connections, queries, scripts, packages, reports, files, tables
|
|
929
935
|
(plus domains, shares, batch, raw, describe, healthcheck, sync, pull/push/migrate/diff/bundle below)
|
|
936
|
+
observe (alias o): problems, problem rules, logger settings, errors, timeline, cloud logs; grok s observe prints its verbs
|
|
930
937
|
|
|
931
938
|
Verbs:
|
|
932
939
|
list List entities (--filter, --limit, --offset)
|
package/bin/utils/ent-helpers.js
CHANGED
|
@@ -12,7 +12,7 @@ const domainApp = (appNames, tables) => `
|
|
|
12
12
|
The application${appNames.length > 1 ? 's' : ''} ${appNames.join(', ')} ` + `(over ${tables.join(', ')}) ${appNames.length > 1 ? 'have' : 'has'} been added successfully
|
|
13
13
|
|
|
14
14
|
Next steps:
|
|
15
|
-
pnpm install link @datagrok-libraries/
|
|
15
|
+
pnpm install link @datagrok-libraries/ems (a workspace dependency)
|
|
16
16
|
grok build && grok publish publish the package and open the app from the browse tree
|
|
17
17
|
|
|
18
18
|
The app is the u2 defaults alone — list, search, filters, entity page with children
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
+
exports.ALL_USERS = void 0;
|
|
6
7
|
exports.collectExternals = collectExternals;
|
|
7
8
|
exports.compileFilter = compileFilter;
|
|
8
9
|
exports.expand = expand;
|
|
@@ -28,7 +29,7 @@ const noProgress = () => {};
|
|
|
28
29
|
|
|
29
30
|
/** What the bundle points at but does not carry: resolvable on the source, or dead there too. */
|
|
30
31
|
|
|
31
|
-
const ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
32
|
+
const ALL_USERS = exports.ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
32
33
|
|
|
33
34
|
/** One round trip per entity, tens of thousands per stand — modest, since the server is shared. */
|
|
34
35
|
const WALK_CONCURRENCY = 12;
|
|
@@ -30,7 +30,7 @@ export interface External {id: string; type: string; nqName: string}
|
|
|
30
30
|
/** What the bundle points at but does not carry: resolvable on the source, or dead there too. */
|
|
31
31
|
export interface Outside {externals: External[]; dangling: string[]}
|
|
32
32
|
|
|
33
|
-
const ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
33
|
+
export const ALL_USERS = 'a4b45840-9a50-11e6-9cc9-8546b8bf62e6';
|
|
34
34
|
|
|
35
35
|
/** One round trip per entity, tens of thousands per stand — modest, since the server is shared. */
|
|
36
36
|
const WALK_CONCURRENCY = 12;
|
package/bin/utils/node-dapi.js
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.NodeUsersDataSource = exports.NodeTablesDataSource = exports.NodeSharesDataSource = exports.NodePackagesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDomainsDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = exports.InternalDataSource = void 0;
|
|
6
|
+
exports.UUID_RE = exports.NodeUsersDataSource = exports.NodeTablesDataSource = exports.NodeSharesDataSource = exports.NodePackagesDataSource = exports.NodeHttpDataSource = exports.NodeGroupsDataSource = exports.NodeFuncsDataSource = exports.NodeFilesDataSource = exports.NodeDomainsDataSource = exports.NodeDapi = exports.NodeConnectionsDataSource = exports.NodeApiClient = exports.InternalDataSource = void 0;
|
|
7
7
|
exports.apiPath = apiPath;
|
|
8
8
|
exports.buildQuery = buildQuery;
|
|
9
9
|
exports.ensureBodyId = ensureBodyId;
|
|
10
10
|
exports.mapPositionalParams = mapPositionalParams;
|
|
11
11
|
exports.parseDomainAddress = parseDomainAddress;
|
|
12
12
|
exports.throwIfApiError = throwIfApiError;
|
|
13
|
+
exports.tryParseJson = tryParseJson;
|
|
13
14
|
var _crypto = require("crypto");
|
|
14
15
|
var _keypair = require("./keypair");
|
|
15
16
|
/// Docs: [Grok Dapi](/docs/plans/grok-dapi/)
|
|
@@ -142,7 +143,7 @@ class NodeApiClient {
|
|
|
142
143
|
return null;
|
|
143
144
|
}
|
|
144
145
|
const ct = res.headers.get('content-type') ?? '';
|
|
145
|
-
return throwIfApiError(ct.includes('application/json') ? await res.json() : await res.text());
|
|
146
|
+
return throwIfApiError(ct.includes('application/json') ? await res.json() : await res.text(), res.headers);
|
|
146
147
|
}
|
|
147
148
|
get(path) {
|
|
148
149
|
return this.request('GET', path);
|
|
@@ -170,7 +171,7 @@ class NodeApiClient {
|
|
|
170
171
|
}, false, BYTES_TIMEOUT_MS);
|
|
171
172
|
if (!res.ok) await throwHttpError(res);
|
|
172
173
|
const ct = res.headers.get('content-type') ?? '';
|
|
173
|
-
return throwIfApiError(ct.includes('application/json') ? await res.json() : await res.text());
|
|
174
|
+
return throwIfApiError(ct.includes('application/json') ? await res.json() : await res.text(), res.headers);
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
/** POST a JSON body and read the response as raw bytes (d42 query results). */
|
|
@@ -236,13 +237,15 @@ async function throwHttpError(res) {
|
|
|
236
237
|
* a JSON string when the handler returned text. Every response passes through here so a
|
|
237
238
|
* missing entity, a rejected save or an unknown function throws instead of printing as data.
|
|
238
239
|
*/
|
|
239
|
-
|
|
240
|
+
/** A user error answers HTTP 200 with the ApiError in the body and its status in the `api-error-code` header. */
|
|
241
|
+
function throwIfApiError(payload, headers) {
|
|
240
242
|
const parsed = typeof payload === 'string' ? tryParseJson(payload) : payload;
|
|
241
243
|
if (parsed?.['#type'] === 'ApiError') {
|
|
244
|
+
const code = headers?.get('api-error-code');
|
|
242
245
|
const err = {
|
|
243
246
|
error: parsed.message ?? 'Request failed',
|
|
244
247
|
source: 'Server',
|
|
245
|
-
errorCode: parsed.errorCode,
|
|
248
|
+
errorCode: parsed.errorCode ?? (code ? Number(code) : undefined),
|
|
246
249
|
stackTrace: parsed.stackTrace,
|
|
247
250
|
body: parsed
|
|
248
251
|
};
|
|
@@ -388,7 +391,7 @@ class InternalDataSource {
|
|
|
388
391
|
}
|
|
389
392
|
}
|
|
390
393
|
exports.InternalDataSource = InternalDataSource;
|
|
391
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
394
|
+
const UUID_RE = exports.UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
392
395
|
class NodeGroupsDataSource extends NodeHttpDataSource {
|
|
393
396
|
constructor(client) {
|
|
394
397
|
super(client, 'groups', '/groups');
|
|
@@ -936,6 +939,10 @@ function parseDomainAddress(s, opts = {}) {
|
|
|
936
939
|
if (opts.table === false && address.table) throw new Error(`'${str}' names a table; this command needs a schema`);
|
|
937
940
|
return address;
|
|
938
941
|
}
|
|
942
|
+
/** Every literal a `/domains/{schema}/{table}/{id}` route registers at the id position, the dot
|
|
943
|
+
* segments a proxy folds away, and the u2 draft sentinel — the Dart `DomainRowKey.reserved` list. */
|
|
944
|
+
const RESERVED_ROW_KEYS = ['access', 'aggregate', 'audit', 'batch', 'delete', 'facets', 'filters', 'query', 'update', 'version', 'watch', 'new', '.', '..'];
|
|
945
|
+
|
|
939
946
|
/**
|
|
940
947
|
* Client for entity-mapped domain tables (`/domains/...`, DomainsRouter). Rows are plain
|
|
941
948
|
* JSON objects; the server validates, permission-checks and audits every write. Errors
|
|
@@ -950,6 +957,13 @@ class NodeDomainsDataSource {
|
|
|
950
957
|
return `/domains/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`;
|
|
951
958
|
}
|
|
952
959
|
|
|
960
|
+
/** The row-id segment as the Dart `DomainRowKey` spells it (grok_shared domain_manifest_rules.dart):
|
|
961
|
+
* escaped, then percent-encoded once more so an external key's own `%2C` survives the proxy. */
|
|
962
|
+
row(schema, table, id) {
|
|
963
|
+
const escaped = RESERVED_ROW_KEYS.includes(id) || id.startsWith('~') ? `~${id}` : id;
|
|
964
|
+
return `${this.rows(schema, table)}/${encodeURIComponent(escaped)}`;
|
|
965
|
+
}
|
|
966
|
+
|
|
953
967
|
/** Registered schemas with their tables (`GET /domains/schemas`); [text] is a smart filter. */
|
|
954
968
|
schemas(text) {
|
|
955
969
|
return this.client.get(`/domains/schemas${buildQuery({
|
|
@@ -1001,7 +1015,7 @@ class NodeDomainsDataSource {
|
|
|
1001
1015
|
})}`);
|
|
1002
1016
|
}
|
|
1003
1017
|
rowAudit(schema, table, id) {
|
|
1004
|
-
return this.client.get(`${this.
|
|
1018
|
+
return this.client.get(`${this.row(schema, table, id)}/audit`);
|
|
1005
1019
|
}
|
|
1006
1020
|
grants(entityId) {
|
|
1007
1021
|
return this.client.get(`/domains/grants/${encodeURIComponent(entityId)}`);
|
|
@@ -1053,7 +1067,7 @@ class NodeDomainsDataSource {
|
|
|
1053
1067
|
/** One row, or null when it does not exist or is not visible (the server answers 404). */
|
|
1054
1068
|
async getRow(schema, table, id) {
|
|
1055
1069
|
try {
|
|
1056
|
-
return await this.client.get(
|
|
1070
|
+
return await this.client.get(this.row(schema, table, id));
|
|
1057
1071
|
} catch (err) {
|
|
1058
1072
|
if (err?.apiError?.errorCode === 404) return null;
|
|
1059
1073
|
throw err;
|
|
@@ -1073,10 +1087,10 @@ class NodeDomainsDataSource {
|
|
|
1073
1087
|
values
|
|
1074
1088
|
};
|
|
1075
1089
|
if (version !== undefined) body.version = version;
|
|
1076
|
-
return this.client.request('PATCH',
|
|
1090
|
+
return this.client.request('PATCH', this.row(schema, table, id), body);
|
|
1077
1091
|
}
|
|
1078
1092
|
deleteRow(schema, table, id) {
|
|
1079
|
-
return this.client.del(
|
|
1093
|
+
return this.client.del(this.row(schema, table, id));
|
|
1080
1094
|
}
|
|
1081
1095
|
|
|
1082
1096
|
/** Soft-deletes up to [limit] (≤1000) matching rows in one transaction; `{deleted, hasMore}`. */
|
|
@@ -1138,6 +1152,9 @@ class NodeDapi {
|
|
|
1138
1152
|
get reports() {
|
|
1139
1153
|
return this.internal('/reports');
|
|
1140
1154
|
}
|
|
1155
|
+
get problems() {
|
|
1156
|
+
return this.internal('/problems');
|
|
1157
|
+
}
|
|
1141
1158
|
get files() {
|
|
1142
1159
|
return new NodeFilesDataSource(this.client);
|
|
1143
1160
|
}
|
|
@@ -16,6 +16,36 @@
|
|
|
16
16
|
"type": "string",
|
|
17
17
|
"description": "Manifest version (semver recommended). Recorded in the registry on deploy."
|
|
18
18
|
},
|
|
19
|
+
"storage": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"description": "Where the rows live. Absent = domain: the platform creates and owns the tables. external: the tables already exist in the database of the named platform connection and are read through it (no row security, soft delete, audit, relations, constraints, property schemas or extensions; read-only unless 'writable'); every table then declares its remote primary key as businessKey, and 'table'/'column' carry the remote names when they differ from the declared ones.",
|
|
22
|
+
"required": ["kind"],
|
|
23
|
+
"additionalProperties": false,
|
|
24
|
+
"properties": {
|
|
25
|
+
"kind": {"enum": ["domain", "external"]},
|
|
26
|
+
"connection": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 1,
|
|
29
|
+
"description": "external: the qualified name of the platform connection to read through ('Package:Connection'); required."
|
|
30
|
+
},
|
|
31
|
+
"schema": {
|
|
32
|
+
"$ref": "#/definitions/remoteIdentifier",
|
|
33
|
+
"description": "external: the schema inside the remote database (e.g. 'public', 'dbo'); required."
|
|
34
|
+
},
|
|
35
|
+
"catalog": {
|
|
36
|
+
"$ref": "#/definitions/remoteIdentifier",
|
|
37
|
+
"description": "external: the catalog/database, for targets that have one."
|
|
38
|
+
},
|
|
39
|
+
"writable": {
|
|
40
|
+
"type": "boolean",
|
|
41
|
+
"default": false,
|
|
42
|
+
"description": "external: rows may be inserted, patched and deleted through the connection (one remote transaction per request, keyed by businessKey; on ClickHouse, which commits every statement, a request is one multi-row INSERT and patch/delete/upsert are refused). The publisher must hold Query, AddRows, ChangeValues and RemoveRows on the connection; a table opts out with its own 'writable': false."
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"if": {"properties": {"kind": {"const": "external"}}},
|
|
46
|
+
"then": {"required": ["connection", "schema"]},
|
|
47
|
+
"else": {"properties": {"connection": false, "schema": false, "catalog": false, "writable": false}}
|
|
48
|
+
},
|
|
19
49
|
"extensible": {
|
|
20
50
|
"type": "object",
|
|
21
51
|
"description": "Opt-in for user extensions of this schema. Users holding the Extend permission on the schema entity may then add their own tables; per-table column extension is opted in with the table's own 'extensible' flag. User objects are retained across plugin upgrades and never appear in generated typed clients.",
|
|
@@ -64,11 +94,26 @@
|
|
|
64
94
|
{"not": {"type": "string", "pattern": "^x_"}}
|
|
65
95
|
]
|
|
66
96
|
},
|
|
97
|
+
"remoteIdentifier": {
|
|
98
|
+
"description": "A name inside the remote database, verbatim (spaces allowed, the connector quotes it); it may not contain brackets, quotes, backslashes or dots.",
|
|
99
|
+
"type": "string",
|
|
100
|
+
"minLength": 1,
|
|
101
|
+
"maxLength": 256,
|
|
102
|
+
"pattern": "^[^\\[\\]\"'`\\\\.]+$"
|
|
103
|
+
},
|
|
67
104
|
"table": {
|
|
68
105
|
"type": "object",
|
|
69
106
|
"required": ["columns"],
|
|
70
107
|
"additionalProperties": false,
|
|
71
108
|
"properties": {
|
|
109
|
+
"table": {
|
|
110
|
+
"$ref": "#/definitions/remoteIdentifier",
|
|
111
|
+
"description": "External storage only: the remote table's name when it differs from the declared one (e.g. 'Order Details')."
|
|
112
|
+
},
|
|
113
|
+
"writable": {
|
|
114
|
+
"type": "boolean",
|
|
115
|
+
"description": "Writable external storage only: false keeps this table read-only while the rest of the binding accepts writes."
|
|
116
|
+
},
|
|
72
117
|
"securityMode": {
|
|
73
118
|
"enum": ["table", "master", "row"],
|
|
74
119
|
"default": "table",
|
|
@@ -263,7 +308,11 @@
|
|
|
263
308
|
"properties": {
|
|
264
309
|
"type": {
|
|
265
310
|
"enum": ["string", "int", "float", "bool", "datetime", "string_list", "ref", "file", "json", "user", "group"],
|
|
266
|
-
"description": "Property type; ref = FK to another table in this manifest; user/group = aliases of ref Core.users / ref Core.groups (semantic type User / Group); file = a file://<connection>/<path> string into platform file storage (semantic type File); json = a jsonb-backed opaque object (filters, sorts, facets and aggregates refuse it)."
|
|
311
|
+
"description": "Property type; ref = FK to another table in this manifest; user/group = aliases of ref Core.users / ref Core.groups (semantic type User / Group); file = a file://<connection>/<path> string into platform file storage (semantic type File); json = a jsonb-backed opaque object (filters, sorts, facets and aggregates refuse it). An external storage admits string, int, float, bool, datetime and same-manifest ref only."
|
|
312
|
+
},
|
|
313
|
+
"column": {
|
|
314
|
+
"$ref": "#/definitions/remoteIdentifier",
|
|
315
|
+
"description": "External storage only: the remote column's name when it differs from the declared one (e.g. 'OrderID')."
|
|
267
316
|
},
|
|
268
317
|
"required": {"type": "boolean", "default": false},
|
|
269
318
|
"unique": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {domains} from '@datagrok-libraries/
|
|
1
|
+
import {domains} from '@datagrok-libraries/ems/src/dg';
|
|
2
2
|
|
|
3
3
|
// the skins of everything the u2 domain stack renders
|
|
4
|
-
import '@datagrok-libraries/
|
|
4
|
+
import '@datagrok-libraries/ems/src/dg/styles.js';
|
package/package.json
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "https://github.com/datagrok-ai/public.git"
|
|
6
6
|
},
|
|
7
|
-
"version": "6.7.
|
|
7
|
+
"version": "6.7.9",
|
|
8
8
|
"description": "Utility to upload and publish packages to Datagrok",
|
|
9
9
|
"homepage": "https://github.com/datagrok-ai/public/tree/master/tools#readme",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22.12.0"
|
|
12
|
+
},
|
|
10
13
|
"dependencies": {
|
|
11
14
|
"@babel/parser": "^7.29.7",
|
|
12
15
|
"@babel/runtime": "^7.29.7",
|
|
@@ -24,7 +27,7 @@
|
|
|
24
27
|
"minimist": "^1.2.8",
|
|
25
28
|
"node-fetch": "^2.7.0",
|
|
26
29
|
"papaparse": "^5.5.3",
|
|
27
|
-
"puppeteer": "^
|
|
30
|
+
"puppeteer": "^25.14.0",
|
|
28
31
|
"ts-morph": "^28.0.0",
|
|
29
32
|
"@babel/generator": "^7.29.0",
|
|
30
33
|
"datagrok-api": "^1.27.12"
|