pgkiln 0.72.0 → 0.74.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.
Files changed (79) hide show
  1. package/db/migrations/140_item_region_types.sql +27 -0
  2. package/db/migrations/141_workspace_resources.sql +115 -0
  3. package/docs/apex-feature-parity.md +91 -12
  4. package/docs/guide/01-installation.md +1 -0
  5. package/docs/guide/03-builder.md +47 -4
  6. package/docs/guide/04-pages-and-regions.md +41 -1
  7. package/docs/guide/05-items.md +43 -3
  8. package/docs/guide/11-from-apex.md +3 -0
  9. package/docs/guide/12-development.md +25 -3
  10. package/docs/guide/16-files.md +29 -0
  11. package/docs/guide/19-rest-data-sources.md +41 -3
  12. package/examples/hr/hr_75_item_region_types.sql +81 -0
  13. package/examples/hr/hr_76_remote_servers.sql +48 -0
  14. package/package.json +1 -1
  15. package/public/app.css +48 -0
  16. package/public/app.js +377 -1
  17. package/src/ai/service.ts +10 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/advisor.ts +18 -2
  20. package/src/builder/ai.ts +29 -3
  21. package/src/builder/arrange.ts +7 -0
  22. package/src/builder/components.ts +5 -5
  23. package/src/builder/designer.ts +9 -3
  24. package/src/builder/home.ts +3 -0
  25. package/src/builder/i18n/de.ts +6 -0
  26. package/src/builder/i18n/en.ts +6 -0
  27. package/src/builder/i18n/es.ts +6 -0
  28. package/src/builder/i18n/fr.ts +6 -0
  29. package/src/builder/i18n/it.ts +6 -0
  30. package/src/builder/i18n/ja.ts +6 -0
  31. package/src/builder/i18n/ko.ts +6 -0
  32. package/src/builder/i18n/nl.ts +6 -0
  33. package/src/builder/i18n/pt.ts +6 -0
  34. package/src/builder/i18n/zh-cn.ts +6 -0
  35. package/src/builder/i18n/zh-tw.ts +6 -0
  36. package/src/builder/network-access.ts +33 -0
  37. package/src/builder/region-help.ts +8 -2
  38. package/src/builder/rest-catalogs.ts +22 -14
  39. package/src/builder/routes.ts +5 -1
  40. package/src/builder/static-files.ts +14 -4
  41. package/src/builder/websources.ts +12 -6
  42. package/src/builder/workflows.ts +2 -1
  43. package/src/builder/workspace-resources.ts +528 -0
  44. package/src/i18n/ar.ts +13 -0
  45. package/src/i18n/cs.ts +13 -0
  46. package/src/i18n/da.ts +13 -0
  47. package/src/i18n/de.ts +13 -0
  48. package/src/i18n/el.ts +13 -0
  49. package/src/i18n/es.ts +13 -0
  50. package/src/i18n/fi.ts +13 -0
  51. package/src/i18n/fr.ts +13 -0
  52. package/src/i18n/he.ts +13 -0
  53. package/src/i18n/it.ts +13 -0
  54. package/src/i18n/ja.ts +13 -0
  55. package/src/i18n/ko.ts +13 -0
  56. package/src/i18n/nb.ts +13 -0
  57. package/src/i18n/pl.ts +13 -0
  58. package/src/i18n/pt.ts +13 -0
  59. package/src/i18n/ru.ts +13 -0
  60. package/src/i18n/sv.ts +13 -0
  61. package/src/i18n/tr.ts +13 -0
  62. package/src/i18n/uk.ts +13 -0
  63. package/src/i18n/zh.ts +13 -0
  64. package/src/i18n.ts +26 -0
  65. package/src/metadata.ts +22 -5
  66. package/src/remote-servers.ts +133 -0
  67. package/src/restcatalog.ts +21 -10
  68. package/src/restsync.ts +1 -1
  69. package/src/runtime/context.ts +2 -0
  70. package/src/runtime/items.ts +32 -3
  71. package/src/runtime/page-regions.ts +50 -0
  72. package/src/runtime/region-cache.ts +1 -1
  73. package/src/runtime/regions.ts +11 -0
  74. package/src/runtime/render.ts +3 -17
  75. package/src/runtime/report.ts +55 -3
  76. package/src/runtime/static-files.ts +46 -17
  77. package/src/runtime/template-components.ts +2 -1
  78. package/src/websources.ts +43 -18
  79. package/src/workspace-export.ts +80 -1
@@ -116,9 +116,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
116
116
  { name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
117
117
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
118
118
  help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
119
- { name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
119
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search', 'classic_report', 'breadcrumb', 'help_text'], group: 'Identification' },
120
120
  { name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
121
- help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
121
+ help: 'report/grid/classic_report: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
122
122
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
123
123
  help: 'Read the rows of a REST data source (Shared Components) instead of a table: the source above is then optional SQL over them, e.g. select * from rest where price > 10. Parameters: {"rest_params": {"city": "&P1_CITY."}} in the attributes.' },
124
124
  { name: 'table_name', common: true, label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
@@ -149,13 +149,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
149
149
  validate: (v) => formatSettingsProblem(v.config),
150
150
  fields: [
151
151
  { name: 'name', common: true, label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
152
- { name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
152
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin', 'shuttle', 'select_one', 'select_many'], group: 'Identification' },
153
153
  { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
154
154
  { name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
155
155
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
156
156
  { name: 'source_column', common: true, label: 'Source column (form)', kind: 'text', group: 'Source' },
157
157
  { name: 'default_value', common: true, label: 'Default value', kind: 'text', group: 'Source' },
158
- { name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect store values colon-separated: string_to_array(:P1_X, \':\')' },
158
+ { name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect/shuttle/select_many store values colon-separated: string_to_array(:P1_X, \':\')' },
159
159
  { name: 'template_options', label: 'Template options', kind: 'options', choices: ITEM_OPTIONS, group: 'Appearance',
160
160
  help: 'CSS classes from a fixed list, added to the item (APEX: Template Options).' },
161
161
  { name: 'required', common: true, label: 'Value required', kind: 'bool', group: 'Validation' },
@@ -163,7 +163,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
163
163
  { name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
164
164
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
165
165
  buildOption('Security'),
166
- { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
166
+ { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Shuttle: two lists (available, chosen) with move and order buttons; the chosen values colon-separated in their order. Select One / Select Many: a search field over the list of values (Select Many: chosen values as chips, colon-separated); {"null_label":"- None -"} or false for Select One. Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
167
167
  ],
168
168
  },
169
169
  button: {
@@ -391,7 +391,7 @@ export async function designerRoutes(app: FastifyInstance) {
391
391
  ${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['left_side_column', 'Left side column'], ['right_side_column', 'Right side column'], ['minimal', 'Minimal (no navigation)']], 'Side column: regions with position left (or right) show in a column beside the body (under it on narrow screens). Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
392
392
  ${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
393
393
  ${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
394
- ${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
394
+ ${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order; #WORKSPACE_FILES#name for a static workspace file.' })}
395
395
  <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="warn_unsaved" value="true"${p.warn_unsaved ? raw(' checked') : ''}> Warn on unsaved changes</label>
396
396
  <small class="help">Ask before the user leaves the page (a link, a redirect button, closing the tab) while an item has a changed value. Submitting doesn't ask. Leave an item out with {"warn_unsaved": false} in its attributes.</small></div>
397
397
  ${select('parent_page', 'Breadcrumb parent', p.parent_page ?? '', [['', '- none -'], ...lk.pages.filter((x) => x.page_no !== p.page_no).map((x): [string, string] => [String(x.page_no), `${x.page_no}. ${x.name}`])])}
@@ -404,6 +404,11 @@ export async function designerRoutes(app: FastifyInstance) {
404
404
  ${select('build_option', 'Build option', p.build_option ?? '', buildOptionChoices(lk, p.build_option),
405
405
  'While the option is excluded (or "Not": included) the page does not exist in the running application.')}
406
406
  </div></fieldset>
407
+ <fieldset class="prop-group"><legend>Help</legend><div class="form-grid">
408
+ <div class="field" data-wide><label class="label" for="f_help">Help text</label>
409
+ <textarea id="f_help" name="help" rows="5" maxlength="20000">${p.help ?? ''}</textarea>
410
+ <small class="help">Markdown with &amp;ITEM. substitutions. A region of type Help Text on the page shows it (APEX: Page → Help).</small></div>
411
+ </div></fieldset>
407
412
  <div class="buttons"><button class="btn btn-hot">${tx('pd.save_page')}</button></div>
408
413
  </form>`, true)])}
409
414
  ${await usedInPanel(p.app_id, 'page', p)}
@@ -505,9 +510,10 @@ export async function designerRoutes(app: FastifyInstance) {
505
510
  const before = await owner.one('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
506
511
  await owner.query(
507
512
  `update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
508
- dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
513
+ dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16, help = $17 where id = $1`,
509
514
  [req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
510
- ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', ['minimal', 'left_side_column', 'right_side_column'].includes(b.page_template ?? '') ? b.page_template : 'standard', b.page_group?.trim() || null],
515
+ ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', ['minimal', 'left_side_column', 'right_side_column'].includes(b.page_template ?? '') ? b.page_template : 'standard', b.page_group?.trim() || null,
516
+ typeof b.help === 'string' && b.help.trim() ? b.help.replace(/\r\n?/g, '\n').trim().slice(0, 20000) : null],
511
517
  );
512
518
  // the page's lock and comments follow a new page number
513
519
  if (before && before.page_no !== Number(b.page_no))
@@ -286,6 +286,9 @@ export async function homeRoutes(app: FastifyInstance) {
286
286
  ${card(`${BASE}/developers`, icon('users'), 'Developers', 'Who may use this builder.')}
287
287
  ${s.workspace?.sql_workshop === false ? '' : card(`${BASE}/sql`, icon('database'), 'SQL Workshop', 'Run SQL, browse objects and load data.')}
288
288
  ${card(`${BASE}/backups`, icon('archive'), 'Manage backups', 'The backups of every application of this workspace; back them all up at once.')}
289
+ ${card(`${BASE}/workspace-files`, icon('file'), 'Static workspace files', 'CSS, JavaScript, images and fonts every application of this workspace can load (#WORKSPACE_FILES#).')}
290
+ ${card(`${BASE}/remote-servers`, icon('server'), 'Remote servers', 'Named base URLs for REST data sources, catalogs and AI services: change one value to move them all.')}
291
+ ${card(`${BASE}/workspace-credentials`, icon('key'), 'Web credentials', 'Keys and OAuth2 clients the applications of this workspace use by name (secrets encrypted, write-only).')}
289
292
  ${card(`${BASE}/rest-catalogs`, icon('globe'), 'REST Source Catalogs', 'Web services described once for the workspace (e.g. from an OpenAPI document); applications make REST data sources from them.')}
290
293
  ${s.workspace?.admin ? card(`${BASE}/workspace-export`, icon('download'), 'Export and import', 'This workspace\'s components as one file: settings, developers, groups, links, catalogs, scripts, blueprints (workspace administrators).') : ''}
291
294
  ${card(`${BASE}/dashboard`, icon('activity'), 'Dashboard', 'Usage and problems across the workspace.')}
@@ -552,6 +552,9 @@ export const de: BuilderMessages = {
552
552
  'gallery.region.search': 'Suche',
553
553
  'gallery.region.login': 'Anmeldung',
554
554
  'gallery.region.plugin': 'Plug-in',
555
+ 'gallery.region.classic_report': 'Klassischer Bericht',
556
+ 'gallery.region.breadcrumb': 'Navigationspfad',
557
+ 'gallery.region.help_text': 'Hilfetext',
555
558
  'gallery.item.text': 'Textfeld',
556
559
  'gallery.item.textarea': 'Textbereich',
557
560
  'gallery.item.number': 'Zahlenfeld',
@@ -582,6 +585,9 @@ export const de: BuilderMessages = {
582
585
  'gallery.item.display': 'Nur anzeigen',
583
586
  'gallery.item.password': 'Kennwort',
584
587
  'gallery.item.plugin': 'Plug-in',
588
+ 'gallery.item.shuttle': 'Shuttle',
589
+ 'gallery.item.select_one': 'Einzelauswahl (Suche)',
590
+ 'gallery.item.select_many': 'Mehrfachauswahl (Suche)',
585
591
  'gallery.button.submit': 'Seite weiterleiten',
586
592
  'gallery.button.redirect': 'Zu Seite umleiten',
587
593
  'gallery.button.da': 'Dynamische Aktion',
@@ -555,6 +555,9 @@ export const en = {
555
555
  'gallery.region.search': 'Search',
556
556
  'gallery.region.login': 'Login',
557
557
  'gallery.region.plugin': 'Plug-in',
558
+ 'gallery.region.classic_report': 'Classic Report',
559
+ 'gallery.region.breadcrumb': 'Breadcrumb',
560
+ 'gallery.region.help_text': 'Help Text',
558
561
  'gallery.item.text': 'Text Field',
559
562
  'gallery.item.textarea': 'Text Area',
560
563
  'gallery.item.number': 'Number Field',
@@ -585,6 +588,9 @@ export const en = {
585
588
  'gallery.item.display': 'Display Only',
586
589
  'gallery.item.password': 'Password',
587
590
  'gallery.item.plugin': 'Plug-in',
591
+ 'gallery.item.shuttle': 'Shuttle',
592
+ 'gallery.item.select_one': 'Select One',
593
+ 'gallery.item.select_many': 'Select Many',
588
594
  'gallery.button.submit': 'Submit Page',
589
595
  'gallery.button.redirect': 'Redirect to Page',
590
596
  'gallery.button.da': 'Dynamic Action',
@@ -552,6 +552,9 @@ export const es: BuilderMessages = {
552
552
  'gallery.region.search': 'Buscar',
553
553
  'gallery.region.login': 'Inicio de sesión',
554
554
  'gallery.region.plugin': 'Plugin',
555
+ 'gallery.region.classic_report': 'Informe clásico',
556
+ 'gallery.region.breadcrumb': 'Ruta de navegación',
557
+ 'gallery.region.help_text': 'Texto de ayuda',
555
558
  'gallery.item.text': 'Campo de texto',
556
559
  'gallery.item.textarea': 'Área de texto',
557
560
  'gallery.item.number': 'Campo numérico',
@@ -582,6 +585,9 @@ export const es: BuilderMessages = {
582
585
  'gallery.item.display': 'Solo visualización',
583
586
  'gallery.item.password': 'Contraseña',
584
587
  'gallery.item.plugin': 'Plugin',
588
+ 'gallery.item.shuttle': 'Lista de transferencia',
589
+ 'gallery.item.select_one': 'Seleccionar uno',
590
+ 'gallery.item.select_many': 'Seleccionar varios',
585
591
  'gallery.button.submit': 'Enviar página',
586
592
  'gallery.button.redirect': 'Redirigir a página',
587
593
  'gallery.button.da': 'Acción dinámica',
@@ -552,6 +552,9 @@ export const fr: BuilderMessages = {
552
552
  'gallery.region.search': 'Recherche',
553
553
  'gallery.region.login': 'Connexion',
554
554
  'gallery.region.plugin': 'Plug-in',
555
+ 'gallery.region.classic_report': 'Etat classique',
556
+ 'gallery.region.breadcrumb': 'Fil d\'Ariane',
557
+ 'gallery.region.help_text': 'Texte d\'aide',
555
558
  'gallery.item.text': 'Champ de texte',
556
559
  'gallery.item.textarea': 'Zone de texte',
557
560
  'gallery.item.number': 'Champ numérique',
@@ -582,6 +585,9 @@ export const fr: BuilderMessages = {
582
585
  'gallery.item.display': 'Affichage seul',
583
586
  'gallery.item.password': 'Mot de passe',
584
587
  'gallery.item.plugin': 'Plug-in',
588
+ 'gallery.item.shuttle': 'Navette',
589
+ 'gallery.item.select_one': 'Sélectionner un',
590
+ 'gallery.item.select_many': 'Sélectionner plusieurs',
585
591
  'gallery.button.submit': 'Soumettre la page',
586
592
  'gallery.button.redirect': 'Rediriger vers une page',
587
593
  'gallery.button.da': 'Action dynamique',
@@ -552,6 +552,9 @@ export const it: BuilderMessages = {
552
552
  'gallery.region.search': 'Ricerca',
553
553
  'gallery.region.login': 'Login',
554
554
  'gallery.region.plugin': 'Plug-in',
555
+ 'gallery.region.classic_report': 'Report classico',
556
+ 'gallery.region.breadcrumb': 'Percorso di navigazione',
557
+ 'gallery.region.help_text': 'Testo della guida',
555
558
  'gallery.item.text': 'Campo di testo',
556
559
  'gallery.item.textarea': 'Area di testo',
557
560
  'gallery.item.number': 'Campo numerico',
@@ -582,6 +585,9 @@ export const it: BuilderMessages = {
582
585
  'gallery.item.display': 'Solo visualizzazione',
583
586
  'gallery.item.password': 'Password',
584
587
  'gallery.item.plugin': 'Plug-in',
588
+ 'gallery.item.shuttle': 'Shuttle',
589
+ 'gallery.item.select_one': 'Selezione singola',
590
+ 'gallery.item.select_many': 'Selezione multipla con ricerca',
585
591
  'gallery.button.submit': 'Invia pagina',
586
592
  'gallery.button.redirect': 'Reindirizza alla pagina',
587
593
  'gallery.button.da': 'Azione dinamica',
@@ -552,6 +552,9 @@ export const ja: BuilderMessages = {
552
552
  'gallery.region.search': '検索',
553
553
  'gallery.region.login': 'ログイン',
554
554
  'gallery.region.plugin': 'プラグイン',
555
+ 'gallery.region.classic_report': 'クラシック・レポート',
556
+ 'gallery.region.breadcrumb': 'ブレッドクラム',
557
+ 'gallery.region.help_text': 'ヘルプ・テキスト',
555
558
  'gallery.item.text': 'テキスト・フィールド',
556
559
  'gallery.item.textarea': 'テキスト領域',
557
560
  'gallery.item.number': '数値フィールド',
@@ -582,6 +585,9 @@ export const ja: BuilderMessages = {
582
585
  'gallery.item.display': '表示のみ',
583
586
  'gallery.item.password': 'パスワード',
584
587
  'gallery.item.plugin': 'プラグイン',
588
+ 'gallery.item.shuttle': 'シャトル',
589
+ 'gallery.item.select_one': '単一選択',
590
+ 'gallery.item.select_many': '複数選択(検索)',
585
591
  'gallery.button.submit': 'ページの送信',
586
592
  'gallery.button.redirect': 'ページへリダイレクト',
587
593
  'gallery.button.da': '動的アクション',
@@ -552,6 +552,9 @@ export const ko: BuilderMessages = {
552
552
  'gallery.region.search': '검색',
553
553
  'gallery.region.login': '로그인',
554
554
  'gallery.region.plugin': '플러그인',
555
+ 'gallery.region.classic_report': '클래식 보고서',
556
+ 'gallery.region.breadcrumb': '이동 경로',
557
+ 'gallery.region.help_text': '도움말 텍스트',
555
558
  'gallery.item.text': '텍스트 필드',
556
559
  'gallery.item.textarea': '텍스트 영역',
557
560
  'gallery.item.number': '숫자 필드',
@@ -582,6 +585,9 @@ export const ko: BuilderMessages = {
582
585
  'gallery.item.display': '표시 전용',
583
586
  'gallery.item.password': '비밀번호',
584
587
  'gallery.item.plugin': '플러그인',
588
+ 'gallery.item.shuttle': '셔틀',
589
+ 'gallery.item.select_one': '단일 선택',
590
+ 'gallery.item.select_many': '다중 선택(검색)',
585
591
  'gallery.button.submit': '페이지 제출',
586
592
  'gallery.button.redirect': '페이지로 리디렉션',
587
593
  'gallery.button.da': '동적 작업',
@@ -552,6 +552,9 @@ export const nl: BuilderMessages = {
552
552
  'gallery.region.search': 'Zoeken',
553
553
  'gallery.region.login': 'Aanmelden',
554
554
  'gallery.region.plugin': 'Plug-in',
555
+ 'gallery.region.classic_report': 'Klassiek rapport',
556
+ 'gallery.region.breadcrumb': 'Kruimelpad',
557
+ 'gallery.region.help_text': 'Helptekst',
555
558
  'gallery.item.text': 'Tekstveld',
556
559
  'gallery.item.textarea': 'Tekstgebied',
557
560
  'gallery.item.number': 'Getalveld',
@@ -582,6 +585,9 @@ export const nl: BuilderMessages = {
582
585
  'gallery.item.display': 'Alleen weergeven',
583
586
  'gallery.item.password': 'Wachtwoord',
584
587
  'gallery.item.plugin': 'Plug-in',
588
+ 'gallery.item.shuttle': 'Shuttle',
589
+ 'gallery.item.select_one': 'Eén kiezen',
590
+ 'gallery.item.select_many': 'Meerdere kiezen',
585
591
  'gallery.button.submit': 'Pagina verzenden',
586
592
  'gallery.button.redirect': 'Naar pagina omleiden',
587
593
  'gallery.button.da': 'Dynamische actie',
@@ -552,6 +552,9 @@ export const pt: BuilderMessages = {
552
552
  'gallery.region.search': 'Pesquisa',
553
553
  'gallery.region.login': 'Login',
554
554
  'gallery.region.plugin': 'Plug-in',
555
+ 'gallery.region.classic_report': 'Relatório Clássico',
556
+ 'gallery.region.breadcrumb': 'Trilha de Navegação',
557
+ 'gallery.region.help_text': 'Texto de Ajuda',
555
558
  'gallery.item.text': 'Campo de Texto',
556
559
  'gallery.item.textarea': 'Área de Texto',
557
560
  'gallery.item.number': 'Campo Numérico',
@@ -582,6 +585,9 @@ export const pt: BuilderMessages = {
582
585
  'gallery.item.display': 'Somente Exibição',
583
586
  'gallery.item.password': 'Senha',
584
587
  'gallery.item.plugin': 'Plug-in',
588
+ 'gallery.item.shuttle': 'Shuttle',
589
+ 'gallery.item.select_one': 'Selecionar Um',
590
+ 'gallery.item.select_many': 'Selecionar Vários',
585
591
  'gallery.button.submit': 'Enviar Página',
586
592
  'gallery.button.redirect': 'Redirecionar para Página',
587
593
  'gallery.button.da': 'Ação Dinâmica',
@@ -552,6 +552,9 @@ export const zhCN: BuilderMessages = {
552
552
  'gallery.region.search': '搜索',
553
553
  'gallery.region.login': '登录',
554
554
  'gallery.region.plugin': '插件',
555
+ 'gallery.region.classic_report': '经典报表',
556
+ 'gallery.region.breadcrumb': '面包屑导航',
557
+ 'gallery.region.help_text': '帮助文本',
555
558
  'gallery.item.text': '文本字段',
556
559
  'gallery.item.textarea': '文本区域',
557
560
  'gallery.item.number': '数字字段',
@@ -582,6 +585,9 @@ export const zhCN: BuilderMessages = {
582
585
  'gallery.item.display': '仅显示',
583
586
  'gallery.item.password': '密码',
584
587
  'gallery.item.plugin': '插件',
588
+ 'gallery.item.shuttle': '穿梭框',
589
+ 'gallery.item.select_one': '单选(搜索)',
590
+ 'gallery.item.select_many': '多选(搜索)',
585
591
  'gallery.button.submit': '提交页面',
586
592
  'gallery.button.redirect': '重定向到页面',
587
593
  'gallery.button.da': '动态操作',
@@ -552,6 +552,9 @@ export const zhTW: BuilderMessages = {
552
552
  'gallery.region.search': '搜尋',
553
553
  'gallery.region.login': '登入',
554
554
  'gallery.region.plugin': '外掛程式',
555
+ 'gallery.region.classic_report': '傳統報表',
556
+ 'gallery.region.breadcrumb': '導覽路徑',
557
+ 'gallery.region.help_text': '說明文字',
555
558
  'gallery.item.text': '文字欄位',
556
559
  'gallery.item.textarea': '文字區域',
557
560
  'gallery.item.number': '數字欄位',
@@ -582,6 +585,9 @@ export const zhTW: BuilderMessages = {
582
585
  'gallery.item.display': '僅顯示',
583
586
  'gallery.item.password': '密碼',
584
587
  'gallery.item.plugin': '外掛程式',
588
+ 'gallery.item.shuttle': '穿梭框',
589
+ 'gallery.item.select_one': '單選(搜尋)',
590
+ 'gallery.item.select_many': '多選(搜尋)',
585
591
  'gallery.button.submit': '提交頁面',
586
592
  'gallery.button.redirect': '重新導向至頁面',
587
593
  'gallery.button.da': '動態動作',
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
3
3
  import { html, raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { parseHostList, scopeForWorkspace } from '../network-acl.ts';
6
+ import { serverUrl, type RemoteServer } from '../remote-servers.ts';
6
7
  import { clientIp, logActivity, type Session } from '../session.ts';
7
8
  import { matches, restHosts, scopeProblem, urlProblem } from '../webclient.ts';
8
9
  import { clearResponseCache } from '../websources.ts';
@@ -53,6 +54,29 @@ export async function networkAccessRoutes(app: FastifyInstance) {
53
54
  `select r.at, r.what, r.host, r.url, r.list, r.message, a.alias from meta.network_refusal r left join meta.app a on a.id = r.app_id
54
55
  where r.workspace_id = $1 order by r.id desc limit 100`, [w.id]).then((r) => r.rows),
55
56
  ]);
57
+ // (141) the hosts the workspace's remote servers stand for now, and whether a call to them would pass
58
+ const scopeNow = await scopeForWorkspace(w.id, 'check');
59
+ const servers = (await owner.query<RemoteServer>('select * from meta.remote_server where workspace_id = $1 order by static_id', [w.id])).rows.map((x) => {
60
+ const u = serverUrl(x);
61
+ let host = '';
62
+ let problem: string | null = u.problem;
63
+ if (u.url)
64
+ try {
65
+ const url = new URL(u.url);
66
+ host = url.host;
67
+ problem = urlProblem(url) ?? scopeProblem(scopeNow, url);
68
+ } catch {
69
+ problem = 'not a valid URL';
70
+ }
71
+ return { ...x, url: u.url, from: u.from, host, problem };
72
+ });
73
+ const serversOf = (entry: string) => servers.filter((x) => x.host && (() => {
74
+ try {
75
+ return matches([entry], new URL(`https://${x.host}`));
76
+ } catch {
77
+ return false;
78
+ }
79
+ })()).map((x) => x.static_id);
56
80
  // entries the installation's list never lets through (they narrow nothing: worth a warning)
57
81
  const unreachable = w.network_hosts.filter((h) => {
58
82
  const host = h.replace(/^\*\./, 'x.');
@@ -90,11 +114,20 @@ export async function networkAccessRoutes(app: FastifyInstance) {
90
114
  <textarea id="f_hosts" name="hosts" rows="6" spellcheck="false">${w.network_hosts.join('\n')}</textarea>
91
115
  <small class="help">One per line: <code>api.example.com</code>, <code>*.example.com</code> for its subdomains, <code>api.example.com:8443</code> for one port only. No scheme or path. Restricted with no hosts: no outgoing calls at all.</small></div>
92
116
  ${unreachable.length ? html`<div class="alert alert-warning" role="status">Not on the installation's list, so never reachable: ${unreachable.join(', ')}.</div>` : ''}
117
+ ${w.network_hosts.some((h) => serversOf(h).length) ? html`<p class="muted small">Remote servers on these hosts: ${w.network_hosts.filter((h) => serversOf(h).length).map((h) => `${h} (${serversOf(h).join(', ')})`).join('; ')}.</p>` : ''}
93
118
  <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`, '', 'workspace-list')}
94
119
  ${region('Installation', html`<p class="muted u-mt0">Set in the server's environment by its administrators (<code>PGKILN_REST_ALLOWED_HOSTS</code>).</p>
95
120
  ${inst.allowed.includes('*') ? html`<p>Any public host (<code>*</code>).</p>` : entries(inst.allowed)}
96
121
  ${m.admin ? html`<h3 class="u-mt1">Private hosts</h3><p class="muted small">Hosts on private or loopback addresses that may be called (<code>PGKILN_REST_PRIVATE_HOSTS</code>).</p>${entries(inst.private)}` : ''}`)}
97
122
  </div>
123
+ ${region('Remote servers', html`<p class="muted u-mt0">The hosts this workspace's <a href="${BASE}/remote-servers">remote servers</a> stand for now: the REST data sources, catalogs and AI services on a server call this host.</p>
124
+ <div class="table-wrap"><table class="report report-reflow">
125
+ <thead><tr><th>Server</th><th>Host</th><th>From</th><th>Would be called</th></tr></thead>
126
+ <tbody>${servers.length ? servers.map((x) => html`<tr><td data-label="Server">${x.static_id}</td>
127
+ <td data-label="Host" class="u-break">${x.host ? html`<code>${x.host}</code>` : html`<span class="muted">none</span>`}</td>
128
+ <td data-label="From">${x.from === 'env' ? x.env_var : x.from === 'stored' ? 'base URL' : ''}</td>
129
+ <td data-label="Would be called">${x.problem ? html`<span class="tag tag-error">no</span> <span class="muted small">${x.problem}</span>` : html`<span class="tag tag-ok">yes</span>`}</td></tr>`)
130
+ : html`<tr><td colspan="4" class="empty">No remote servers.</td></tr>`}</tbody></table></div>`, '', 'servers')}
98
131
  ${region('Check a URL', html`<form method="get" action="${BASE}/network" class="search u-mwnone">
99
132
  <input type="hidden" name="ws" value="${w.id}">
100
133
  ${input('url', 'URL', tryUrl, { type: 'url', placeholder: 'https://api.example.com/v1/items' })}
@@ -8,7 +8,7 @@ const GUIDE = 'https://pgkiln.vargar.eu/docs';
8
8
 
9
9
  export const REGION_CONFIG_HELP: [string[], string][] = [
10
10
  [['report'], '{"page_size":15,"pagination":"range" (rows X–Y without a total),"keyset":["id"] (with range: Next/Previous seek on these unique columns),"max_rows":10000,"searchable":true,"sortable":true,"interactive":true,"mobile":"reflow"|"scroll","hidden":["col"],"headings":{"col":"Label"},"link":{"column":"id","page":3,"items":{"P3_ID":"#id#"}},"empty":"No rows","pdf":{"layout":"NAME","columns":["col"],"widths":{"col":40},"align":{"col":"right"}}} (pdf widths in mm; layouts under Shared Components → Report layouts)'],
11
- [['report', 'grid', 'cards'], '{"formats":{"sal":"FML999G990D00","hiredate":"DD-MON-YYYY"}} (number or date format masks per column)'],
11
+ [['report', 'classic_report', 'grid', 'cards'], '{"formats":{"sal":"FML999G990D00","hiredate":"DD-MON-YYYY"}} (number or date format masks per column)'],
12
12
  [['chart'], '{"kind":"bar"|"column"|"line"|"area"|"donut","max_rows":1000,"format_mask":"FML999G990"}'],
13
13
  [['cards'], '{"style":"metric","link":{...},"max_rows":500}'],
14
14
  [['dynamic'], '{"max_rows":1000}'],
@@ -19,7 +19,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
19
19
  [['display_selector'], '{"style":"tabs"|"select","show_all":true,"remember":true}'],
20
20
  [['list'], '{"list":"NAME","template":"links"|"badges"|"cards"|"tabs"} (lists under Shared Components → Lists)'],
21
21
  [['*'], '{"display_selector":true} puts it in the page\'s display selector'],
22
- [['report', 'chart', 'cards', 'dynamic', 'tree', 'template_component'], '{"lazy":true} loads it after the page shows, {"cache":{"scope":"user"|"session"|"all","seconds":300}} keeps its HTML (a submit of the page empties it)'],
22
+ [['report', 'classic_report', 'chart', 'cards', 'dynamic', 'tree', 'template_component'], '{"lazy":true} loads it after the page shows, {"cache":{"scope":"user"|"session"|"all","seconds":300}} keeps its HTML (a submit of the page empties it)'],
23
23
  [['data_reporter'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"order_v","columns":[{"name":"total","label":"Total","format":"FML999G990D00"}]}],"sharing":true,"share_authz":"SCHEME","page_size":25} (the Data Reporter settings below edit it)'],
24
24
  [['form_designer'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"orders","key":"id","columns":[{"name":"customer","label":"Customer"},{"name":"status","lov":"LOV:ORDER_STATUS"}],"insert":true,"update":true,"delete":false}],"design_authz":"SCHEME","page_size":25} (the Form Designer region\'s Settings; users that pass design_authz design forms)'],
25
25
  [['ai_assistant'], '{"service":"NAME","system":"…","welcome":"…","context":[{"name":"policy","sql":"select … :AI_PROMPT …"}],"tools":[{"name":"my_leave","description":"…","sql":"select … where status = :STATUS","parameters":{"STATUS":{"type":"string","enum":["PENDING"]}}},{"name":"dept","type":"rest","source":"DEPARTMENT","description":"…"}],"max_rounds":5,"max_turns":20}; or {"agent":"SALES_AGENT","welcome":"…"} for an AI agent of Shared Components (the AI assistant settings below edit it)'],
@@ -28,6 +28,9 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
28
28
  [['form'], '{"lost_update":false} saves without checking that the row is unchanged since it was shown'],
29
29
  [['login'], '(no attributes) the sign-in form of the application\'s login page (Application settings → Login page): providers, user name and password, "Keep me signed in", "Forgot password?"'],
30
30
  [['url'], '{"height":480,"sandbox":["allow-scripts","allow-forms","allow-popups","allow-same-origin"] (the default; false: no sandbox),"fullscreen":true}'],
31
+ [['classic_report'], '{"report_template":"standard"|"value_pairs"|"badges"|"media_list","pagination":"total" (rows X–Y of N, the default)|"range" (rows X–Y)|"none" (one page of at most max_rows, 500),"page_size":15,"max_rows":500,"sortable":true,"searchable":false,"mobile":"reflow"|"scroll","hidden":["col"],"headings":{"col":"Label"},"link":{"column":"id","page":3,"items":{"P3_ID":"#id#"}},"empty":"No rows"} (a report without the Actions menu; badges: label and value columns; media_list: title and description columns)'],
32
+ [['breadcrumb'], '{"current":false} leaves the current page out of the trail (the pages above it come from each page\'s Breadcrumb parent, else Home); put it in the Breadcrumb bar position with template plain'],
33
+ [['help_text'], '{"empty":"…"} shown when the page has no help text (Page → Help text: Markdown with &ITEM. substitutions)'],
31
34
  [['search'], '{"configs":["CUSTOMERS","ORDERS"] (Shared Components → Search configurations; empty: all),"item":"P1_SEARCH" (search with this item\'s value instead of a field of its own),"placeholder":"…","min_length":2,"empty":"No results"}'],
32
35
  ];
33
36
 
@@ -57,6 +60,9 @@ const ANCHORS: Record<string, string> = {
57
60
  tasks: 'approvals-and-the-task-list',
58
61
  workflows: 'workflows',
59
62
  login: 'the-login-page',
63
+ classic_report: 'classic_report-classic-report',
64
+ breadcrumb: 'breadcrumb-and-help_text',
65
+ help_text: 'breadcrumb-and-help_text',
60
66
  };
61
67
 
62
68
  /** The help of every type (a region whose type isn't known yet). */
@@ -144,7 +144,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
144
144
  const s = await developer(req, reply);
145
145
  if (!s) return;
146
146
  const rows = (await owner.query(
147
- `select c.id, c.name, c.description, c.base_url, c.source, c.api_version, c.updated_at,
147
+ `select c.id, c.name, c.description, c.base_url, c.server, c.source, c.api_version, c.updated_at,
148
148
  (select count(*)::int from meta.rest_catalog_service x where x.catalog_id = c.id) as services
149
149
  from meta.rest_catalog c where c.workspace_id = $1 order by lower(c.name)`, [currentWorkspace(s)])).rows;
150
150
  const main = html`<div class="title-row"><h1>REST Source Catalogs</h1></div>
@@ -154,7 +154,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
154
154
  <thead><tr><th>Catalog</th><th>Base URL</th><th class="num">Services</th><th>From</th><th>Updated (UTC)</th></tr></thead>
155
155
  <tbody>${rows.length ? rows.map((c) => html`<tr>
156
156
  <td data-label="Catalog"><a href="${root}/${c.id}">${c.name}</a>${c.description ? html`<br><span class="muted small">${String(c.description).slice(0, 160)}</span>` : ''}</td>
157
- <td data-label="Base URL"><code class="wrap-anywhere">${c.base_url}</code></td>
157
+ <td data-label="Base URL">${c.server ? html`<span class="tag">${icon('server')} ${c.server}</span> ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code></td>
158
158
  <td class="num" data-label="Services">${c.services}</td>
159
159
  <td data-label="From">${c.source === 'openapi' ? html`OpenAPI${c.api_version ? html` <span class="muted">${c.api_version}</span>` : ''}` : 'by hand'}</td>
160
160
  <td data-label="Updated">${when(c.updated_at)}</td></tr>`)
@@ -225,7 +225,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
225
225
  const sel = req.query.service ? c.services.find((x) => String(x.id) === req.query.service) : null;
226
226
  const main = html`<div class="title-row"><h1>${c.name}</h1>
227
227
  <a class="btn" href="${base}/download">${icon('download')} Download</a></div>
228
- <p class="muted u-mt0"><code class="wrap-anywhere">${c.base_url}</code>${c.source === 'openapi' ? html` · from an OpenAPI document${c.api_version ? html` (version ${c.api_version})` : ''}` : ''} · updated ${when(c.updated_at)} UTC</p>
228
+ <p class="muted u-mt0">${c.server ? html`On remote server <a href="${BASE}/remote-servers">${c.server}</a> · ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code>${c.source === 'openapi' ? html` · from an OpenAPI document${c.api_version ? html` (version ${c.api_version})` : ''}` : ''} · updated ${when(c.updated_at)} UTC</p>
229
229
  ${notesHtml(s)}
230
230
  ${region('Services', html`<div class="table-wrap"><table class="report report-reflow">
231
231
  <thead><tr><th>Service</th><th>Method</th><th>Path</th><th class="num">Parameters</th><th class="num">Columns</th><th>Write back</th></tr></thead>
@@ -241,12 +241,13 @@ export async function restCatalogRoutes(app: FastifyInstance) {
241
241
  ${req.query.service === 'new'
242
242
  ? region('New service', serviceForm(s, `${base}/services`, { method: 'GET', path: '/' }, 'Add'), '', 'service')
243
243
  : sel
244
- ? region(`Service ${sel.name}`, html`<p class="muted u-mt0">URL: <code class="wrap-anywhere">${serviceUrl(c.base_url, sel.path)}</code></p>
244
+ ? region(`Service ${sel.name}`, html`<p class="muted u-mt0">URL: <code class="wrap-anywhere">${c.server ? `${c.server} + ${sel.path}` : serviceUrl(c.base_url ?? '', sel.path)}</code></p>
245
245
  ${serviceForm(s, `${base}/services/${sel.id}`, sel, 'Save')}
246
246
  <form method="post" action="${base}/services/${sel.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the service ${sel.name}?">Delete service</button></form>`, '', 'service')
247
247
  : ''}
248
248
  ${region('Catalog', html`<form method="post" action="${base}">${csrf(s)}
249
- <div class="form-grid">${input('name', 'Name', c.name, { required: true })}${input('base_url', 'Base URL', c.base_url, { required: true, help: 'e.g. a test system\'s URL. Data sources made earlier keep the URL they were made with.' })}
249
+ <div class="form-grid">${input('name', 'Name', c.name, { required: true })}${input('base_url', 'Base URL', c.base_url ?? '', { help: 'e.g. a test system\'s URL (optional on a remote server). Data sources made earlier keep the URL they were made with.' })}
250
+ ${input('server', 'Remote server', c.server ?? '', { placeholder: 'e.g. CRM', help: 'The static id of a remote server of this workspace (Workspace utilities → Remote servers): data sources made from the catalog then name the server and the service\'s path, so they move with it.' })}
250
251
  ${input('api_version', 'API version', c.api_version ?? '')}
251
252
  <div class="field" data-wide><label class="label" for="f_cdescription">Description</label><textarea id="f_cdescription" name="description" rows="2">${c.description ?? ''}</textarea></div></div>
252
253
  <div class="buttons"><button class="btn btn-hot">${icon('save')} Save</button></div></form>`)}
@@ -262,14 +263,19 @@ export async function restCatalogRoutes(app: FastifyInstance) {
262
263
  if (!c) return reply.code(404).send('Not found');
263
264
  const v = {
264
265
  name: String(req.body?.name ?? '').trim(), base_url: trimBase(String(req.body?.base_url ?? '')),
266
+ server: String(req.body?.server ?? '').trim().toUpperCase() || null,
265
267
  description: String(req.body?.description ?? '').trim() || null, api_version: String(req.body?.api_version ?? '').trim() || null,
266
268
  };
267
269
  // the services must still make sense on the new base URL
268
- const problems = [...catalogProblems(v), ...c.services.flatMap((x) => serviceProblems(v.base_url, x))];
270
+ const problems = [...catalogProblems(v), ...c.services.flatMap((x) => serviceProblems(v.base_url || 'https://x', x))];
269
271
  if (problems.length) flash(s, problems.slice(0, 6).join(' '), 'error');
270
272
  else
271
273
  try {
272
- await owner.query('update meta.rest_catalog set name = $2, base_url = $3, description = $4, api_version = $5, updated_at = now() where id = $1', [c.id, v.name, v.base_url, v.description, v.api_version]);
274
+ await owner.query('update meta.rest_catalog set name = $2, base_url = $3, description = $4, api_version = $5, server = $6, updated_at = now() where id = $1',
275
+ [c.id, v.name, v.base_url || null, v.description, v.api_version, v.server]);
276
+ if (v.server && !(await owner.one('select 1 as ok from meta.remote_server where workspace_id = $1 and static_id = $2', [c.workspace_id, v.server])))
277
+ flash(s, `Catalog saved. This workspace has no remote server ${v.server} yet: add it under Workspace utilities → Remote servers.`, 'error');
278
+ else
273
279
  flash(s, 'Catalog saved.');
274
280
  } catch (e) {
275
281
  flash(s, uniqueError(e), 'error');
@@ -292,6 +298,8 @@ export async function restCatalogRoutes(app: FastifyInstance) {
292
298
  if (doc instanceof Error) throw doc;
293
299
  doc = await documentOf(doc, s);
294
300
  const { catalog, notes } = readCatalogText(doc.text, { baseUrl: req.body?.base_url, from: doc.from, name: c.name });
301
+ // (141) the catalog stays on its remote server
302
+ catalog.server = c.server ?? null;
295
303
  const problems = wholeCatalogProblems(catalog);
296
304
  if (problems.length) throw new Error(problems.slice(0, 8).join(' '));
297
305
  await owner.tx((db) => replaceCatalog(db, c.id, catalog));
@@ -338,7 +346,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
338
346
  const retry = `${root}/${c.id}?service=${sid ?? 'new'}#service`;
339
347
  try {
340
348
  const v = serviceFromBody(req.body ?? {});
341
- const problems = serviceProblems(c.base_url, v);
349
+ const problems = serviceProblems(c.base_url || 'https://x', v);
342
350
  if (problems.length) throw new Error(problems.join(' '));
343
351
  const vals = [v.name, v.description, v.path, v.method, JSON.stringify(v.params), JSON.stringify(v.headers), v.body, v.row_selector, JSON.stringify(v.columns), v.key_columns, JSON.stringify(v.operations)];
344
352
  let id = sid;
@@ -388,14 +396,14 @@ export async function restCatalogRoutes(app: FastifyInstance) {
388
396
  const c = (await loadCatalog(svc.catalog_id))!;
389
397
  const v = sourceFromService(c, c.services.find((x) => x.id === svc.id)!, { name: req.body?.name, credential: req.body?.credential });
390
398
  if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name)) throw new Error('The name is upper case letters, digits and _, starting with a letter, e.g. COUNTRIES.');
391
- if (v.credential && !(await owner.one('select 1 as ok from meta.web_credential where app_id = $1 and name = $2', [appId, v.credential])))
392
- throw new Error(`This application has no web credential ${v.credential}.`);
399
+ if (v.credential && !(await owner.one('select 1 as ok from meta.web_credential where app_id = $1 and name = $2 union all select 1 from meta.workspace_credential where workspace_id = meta.app_workspace($1) and name = $2', [appId, v.credential])))
400
+ throw new Error(`This application has no web credential ${v.credential} (nor does its workspace).`);
393
401
  const problems = sourceProblems(v);
394
402
  if (problems.length) throw new Error(problems.join(' '));
395
403
  const r = await owner.one<{ id: number }>(
396
- `insert into meta.rest_source (app_id, name, description, url, method, credential, params, headers, body, row_selector, columns, key_columns, operations)
397
- values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) returning id`,
398
- [appId, v.name, v.description, v.url, v.method, v.credential, JSON.stringify(v.params), JSON.stringify(v.headers), v.body, v.row_selector, JSON.stringify(v.columns), v.key_columns, JSON.stringify(v.operations)]);
404
+ `insert into meta.rest_source (app_id, name, description, url, method, credential, params, headers, body, row_selector, columns, key_columns, operations, server)
405
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) returning id`,
406
+ [appId, v.name, v.description, v.url, v.method, v.credential, JSON.stringify(v.params), JSON.stringify(v.headers), v.body, v.row_selector, JSON.stringify(v.columns), v.key_columns, JSON.stringify(v.operations), v.server]);
399
407
  flash(s, `REST data source ${v.name} made from ${c.name}. Test it below${v.credential ? '' : '; add a web credential if the service needs one'}.`);
400
408
  return back(reply, s, `${BASE}/apps/${appId}/shared?c=rest_source-${r!.id}`);
401
409
  } catch (e) {
@@ -423,7 +431,7 @@ export async function catalogPicker(appId: number, s: Session): Promise<Raw | ''
423
431
  where c.workspace_id = meta.app_workspace($1) order by lower(c.name), x.position, x.name`, [appId])).rows;
424
432
  if (!services.length)
425
433
  return html`<p class="muted u-mt0 u-mb1">${icon('globe')} Services described once for the workspace: <a href="${root}">REST Source Catalogs</a>.</p>`;
426
- const credentials = (await owner.query('select name from meta.web_credential where app_id = $1 order by name', [appId])).rows;
434
+ const credentials = (await owner.query('select name from meta.web_credential where app_id = $1 union select name from meta.workspace_credential where workspace_id = meta.app_workspace($1) order by name', [appId])).rows;
427
435
  const groups = new Map<string, typeof services>();
428
436
  for (const x of services) groups.set(x.catalog, [...(groups.get(x.catalog) ?? []), x]);
429
437
  return region('From a REST source catalog', html`<form method="post" action="${BASE}/apps/${appId}/rest-sources/from-catalog">${csrf(s)}
@@ -603,7 +603,11 @@ export async function builderRoutes(app: FastifyInstance) {
603
603
  });
604
604
  // supporting objects are never run on import: the developer reviews them and chooses
605
605
  const scripts = (await owner.one('select count(*)::int as n from meta.supporting_script where app_id = $1', [r.id])).n;
606
- flash(s, `Application imported. Check its database role and users under Settings / Shared Components.${scripts ? ` It has ${scripts} supporting object script(s): they were not run.` : ''}`);
606
+ // (141) remote servers its REST data sources name that this workspace doesn't have (yet)
607
+ const servers = (await owner.query<{ server: string }>(
608
+ `select distinct x.server from meta.rest_source x where x.app_id = $1 and x.server is not null
609
+ and not exists (select 1 from meta.remote_server r where r.workspace_id = meta.app_workspace($1) and r.static_id = x.server) order by 1`, [r.id])).rows.map((x) => x.server);
610
+ flash(s, `Application imported. Check its database role and users under Settings / Shared Components.${scripts ? ` It has ${scripts} supporting object script(s): they were not run.` : ''}${servers.length ? ` Its REST data sources use remote server${servers.length > 1 ? 's' : ''} ${servers.join(', ')}, which this workspace doesn't have: add ${servers.length > 1 ? 'them' : 'it'} under Workspace utilities → Remote servers.` : ''}`);
607
611
  return back(reply, s, scripts ? `${BASE}/apps/${r.id}/supporting-objects?imported=1` : `${BASE}/apps/${r.id}`);
608
612
  } catch (e) {
609
613
  flash(s, `Import failed: ${(e as Error).message}`, 'error');