@saltcorn/server 0.8.7 → 0.8.8-beta.1

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/routes/menu.js CHANGED
@@ -69,8 +69,8 @@ const menuForm = async (req) => {
69
69
  .filter(([k, v]) => !v.requireRow && !v.disableInBuilder)
70
70
  .map(([k, v]) => k),
71
71
  ];
72
- const triggers = await Trigger.find({
73
- when_trigger: { or: ["API call", "Never"] },
72
+ const triggers = Trigger.find({
73
+ when_trigger: {or: ["API call", "Never"]},
74
74
  });
75
75
  triggers.forEach((tr) => {
76
76
  actions.push(tr.name);
@@ -148,6 +148,13 @@ const menuForm = async (req) => {
148
148
  input_type: "select",
149
149
  options: roles.map((r) => ({ label: r.role, value: r.id })),
150
150
  },
151
+ {
152
+ name: "disable_on_mobile",
153
+ label: req.__("Disable on mobile"),
154
+ type: "Bool",
155
+ class: "item-menu",
156
+ required: false,
157
+ },
151
158
  {
152
159
  name: "url",
153
160
  label: req.__("URL"),
@@ -264,18 +271,18 @@ const menuForm = async (req) => {
264
271
  },
265
272
  attributes: {
266
273
  options: [
267
- { name: "", label: "Link" },
268
- { name: "btn btn-primary", label: "Primary button" },
269
- { name: "btn btn-secondary", label: "Secondary button" },
270
- { name: "btn btn-success", label: "Success button" },
271
- { name: "btn btn-danger", label: "Danger button" },
274
+ { name: "", label: req.__("Link") },
275
+ { name: "btn btn-primary", label: req.__("Primary button") },
276
+ { name: "btn btn-secondary", label: req.__("Secondary button") },
277
+ { name: "btn btn-success", label: req.__("Success button") },
278
+ { name: "btn btn-danger", label: req.__("Danger button") },
272
279
  {
273
280
  name: "btn btn-outline-primary",
274
- label: "Primary outline button",
281
+ label: req.__("Primary outline button"),
275
282
  },
276
283
  {
277
284
  name: "btn btn-outline-secondary",
278
- label: "Secondary outline button",
285
+ label: req.__("Secondary outline button"),
279
286
  },
280
287
  ],
281
288
  },
@@ -0,0 +1,492 @@
1
+ /**
2
+ * @category server
3
+ * @module routes/models
4
+ * @subcategory routes
5
+ */
6
+
7
+ const Router = require("express-promise-router");
8
+
9
+ const { error_catcher, is_relative_url, isAdmin } = require("./utils.js");
10
+ const Table = require("@saltcorn/data/models/table");
11
+ const Form = require("@saltcorn/data/models/form");
12
+ const Model = require("@saltcorn/data/models/model");
13
+ const ModelInstance = require("@saltcorn/data/models/model_instance");
14
+ const { getState } = require("@saltcorn/data/db/state");
15
+ const db = require("@saltcorn/data/db");
16
+ const moment = require("moment");
17
+
18
+ const { mkTable, renderForm, post_delete_btn } = require("@saltcorn/markup");
19
+ const {
20
+ span,
21
+ h4,
22
+ p,
23
+ a,
24
+ div,
25
+ i,
26
+ form,
27
+ label,
28
+ input,
29
+ text,
30
+ script,
31
+ domReady,
32
+ code,
33
+ iframe,
34
+ style,
35
+ pre,
36
+ } = require("@saltcorn/markup/tags");
37
+
38
+ const router = new Router();
39
+ module.exports = router;
40
+
41
+ const newModelForm = (table, req) => {
42
+ return new Form({
43
+ action: "/models/new/" + table.id,
44
+ fields: [
45
+ { name: "name", label: "Name", type: "String", required: true },
46
+ {
47
+ name: "modelpattern",
48
+ label: "Model pattern",
49
+ type: "String",
50
+ required: true,
51
+ attributes: { options: Object.keys(getState().modelpatterns) },
52
+ },
53
+ ],
54
+ });
55
+ };
56
+
57
+ router.get(
58
+ "/new/:table_id",
59
+ isAdmin,
60
+ error_catcher(async (req, res) => {
61
+ const { table_id } = req.params;
62
+ const table = await Table.findOne({ id: table_id });
63
+ res.sendWrap(req.__(`New model`), {
64
+ above: [
65
+ {
66
+ type: "breadcrumbs",
67
+ crumbs: [
68
+ { text: req.__("Tables"), href: "/table" },
69
+ { href: `/table/${table.id}`, text: table.name },
70
+ { text: req.__(`New model`) },
71
+ ],
72
+ },
73
+ {
74
+ type: "card",
75
+ class: "mt-0",
76
+ title: req.__(`New model`),
77
+ contents: renderForm(newModelForm(table, req), req.csrfToken()),
78
+ },
79
+ ],
80
+ });
81
+ })
82
+ );
83
+
84
+ router.post(
85
+ "/new/:table_id",
86
+ isAdmin,
87
+ error_catcher(async (req, res) => {
88
+ const { table_id } = req.params;
89
+ const table = await Table.findOne({ id: table_id });
90
+ const form = newModelForm(table, req);
91
+ form.validate(req.body);
92
+ if (form.hasErrors) {
93
+ res.sendWrap(req.__(`New model`), renderForm(form, req.csrfToken()));
94
+ } else {
95
+ const model = await Model.create({ ...form.values, table_id: table.id });
96
+ if (model.templateObj.configuration_workflow)
97
+ res.redirect(`/models/config/${model.id}`);
98
+ else res.redirect(`/models/show/${model.id}`);
99
+ }
100
+ })
101
+ );
102
+
103
+ const respondWorkflow = (model, table, wf, wfres, req, res) => {
104
+ const wrap = (contents, noCard, previewURL) => ({
105
+ above: [
106
+ {
107
+ type: "breadcrumbs",
108
+ crumbs: [
109
+ { text: req.__("Tables"), href: "/table" },
110
+ { href: `/table/${table.id || table.name}`, text: table.name },
111
+ { href: `/models/show/${model.id}`, text: model.name },
112
+ { text: req.__("Configuration") },
113
+ ],
114
+ },
115
+ {
116
+ type: noCard ? "container" : "card",
117
+ class: !noCard && "mt-0",
118
+ title: wfres.title,
119
+ titleAjaxIndicator: true,
120
+ contents,
121
+ },
122
+ ],
123
+ });
124
+ if (wfres.flash) req.flash(wfres.flash[0], wfres.flash[1]);
125
+ if (wfres.renderForm)
126
+ res.sendWrap(
127
+ {
128
+ title: req.__(`%s configuration`, model.name),
129
+ headers: [
130
+ {
131
+ script: `/static_assets/${db.connectObj.version_tag}/jquery-menu-editor.min.js`,
132
+ },
133
+ {
134
+ script: `/static_assets/${db.connectObj.version_tag}/iconset-fontawesome5-3-1.min.js`,
135
+ },
136
+ {
137
+ script: `/static_assets/${db.connectObj.version_tag}/bootstrap-iconpicker.js`,
138
+ },
139
+ {
140
+ css: `/static_assets/${db.connectObj.version_tag}/bootstrap-iconpicker.min.css`,
141
+ },
142
+ ],
143
+ },
144
+ wrap(
145
+ renderForm(wfres.renderForm, req.csrfToken()),
146
+ false,
147
+ wfres.previewURL
148
+ )
149
+ );
150
+ else res.redirect(wfres.redirect);
151
+ };
152
+
153
+ const get_model_workflow = (model, req) => {
154
+ const workflow = model.templateObj.configuration_workflow(req);
155
+ workflow.action = `/models/config/${model.id}`;
156
+ const oldOnDone = workflow.onDone || ((c) => c);
157
+ workflow.onDone = async (ctx) => {
158
+ const { id, ...configuration } = await oldOnDone(ctx);
159
+ await model.update({ configuration });
160
+
161
+ return {
162
+ redirect: `/models/show/${model.id}`,
163
+ flash: ["success", `Model ${this.name || ""} saved`],
164
+ };
165
+ };
166
+ return workflow;
167
+ };
168
+
169
+ router.get(
170
+ "/config/:id",
171
+ isAdmin,
172
+ error_catcher(async (req, res) => {
173
+ const { id } = req.params;
174
+ const { step } = req.query;
175
+
176
+ const model = await Model.findOne({ id });
177
+ const table = await Table.findOne({ id: model.table_id });
178
+ const configFlow = get_model_workflow(model, req);
179
+ const wfres = await configFlow.run(
180
+ {
181
+ ...model.configuration,
182
+ id: model.id,
183
+ table_id: model.table_id,
184
+ ...(step ? { stepName: step } : {}),
185
+ },
186
+ req
187
+ );
188
+ respondWorkflow(model, table, configFlow, wfres, req, res);
189
+ })
190
+ );
191
+
192
+ router.post(
193
+ "/config/:id",
194
+ isAdmin,
195
+ error_catcher(async (req, res) => {
196
+ const { id } = req.params;
197
+ const { step } = req.query;
198
+
199
+ const model = await Model.findOne({ id });
200
+ const table = await Table.findOne({ id: model.table_id });
201
+ if (!table) {
202
+ req.flash("error", `Table not found`);
203
+ res.redirect(`/table`);
204
+ return;
205
+ }
206
+ const workflow = get_model_workflow(model, req);
207
+ const wfres = await workflow.run(req.body, req);
208
+ respondWorkflow(model, table, workflow, wfres, req, res);
209
+ })
210
+ );
211
+
212
+ router.get(
213
+ "/show/:id",
214
+ isAdmin,
215
+ error_catcher(async (req, res) => {
216
+ const { id } = req.params;
217
+ const model = await Model.findOne({ id });
218
+ const table = await Table.findOne({ id: model.table_id });
219
+ const instances = await ModelInstance.find({ model_id: model.id });
220
+ const metrics = model.templateObj.metrics || {};
221
+ const metricCols = Object.entries(metrics).map(([k, v]) => ({
222
+ label: k,
223
+ key: (inst) => inst.metric_values?.[k]?.toPrecision(6),
224
+ }));
225
+ const anyReport = instances.some((i) => !!i.report);
226
+ res.sendWrap(req.__(`Show model`), {
227
+ above: [
228
+ {
229
+ type: "breadcrumbs",
230
+ crumbs: [
231
+ { text: req.__("Tables"), href: "/table" },
232
+ { href: `/table/${table.id}`, text: table.name },
233
+ { text: model.name },
234
+ ],
235
+ after: a(
236
+ { href: `/models/config/${model.id}` },
237
+ req.__("Edit"),
238
+ i({ class: "ms-1 fas fa-edit" })
239
+ ),
240
+ },
241
+ {
242
+ type: "card",
243
+ class: "mt-0",
244
+ title: req.__("Model instances"),
245
+ contents: div(
246
+ mkTable(
247
+ [
248
+ { label: req.__("Name"), key: "name" },
249
+ {
250
+ label: req.__("Trained"),
251
+ key: (inst) => moment(inst.trained_on).fromNow(),
252
+ },
253
+ ...(anyReport
254
+ ? [
255
+ {
256
+ label: req.__("Report"),
257
+ key: (inst) =>
258
+ inst.report
259
+ ? a(
260
+ { href: `/models/show-report/${inst.id}` },
261
+ i({ class: "fas fa-file-alt" })
262
+ )
263
+ : "",
264
+ },
265
+ ]
266
+ : []),
267
+ ...metricCols,
268
+ {
269
+ label: req.__("Default"),
270
+ key: (inst) =>
271
+ form(
272
+ {
273
+ action: `/models/make-default-instance/${inst.id}`,
274
+ method: "POST",
275
+ },
276
+ span(
277
+ { class: "form-switch" },
278
+ input({
279
+ class: ["form-check-input"],
280
+ type: "checkbox",
281
+ onChange: "this.form.submit()",
282
+ role: "switch",
283
+ name: "enabled",
284
+ ...(inst.is_default && { checked: true }),
285
+ })
286
+ ),
287
+ input({
288
+ type: "hidden",
289
+ name: "_csrf",
290
+ value: req.csrfToken(),
291
+ })
292
+ ),
293
+ },
294
+ {
295
+ label: req.__("Delete"),
296
+ key: (r) =>
297
+ post_delete_btn(
298
+ `/models/delete-instance/${encodeURIComponent(r.id)}`,
299
+ req
300
+ ),
301
+ },
302
+ ],
303
+ instances
304
+ ),
305
+ a(
306
+ { href: `/models/train/${model.id}`, class: "btn btn-primary" },
307
+ i({ class: "fas fa-graduation-cap me-1" }),
308
+ req.__("Train new instance")
309
+ )
310
+ ),
311
+ },
312
+ ],
313
+ });
314
+ })
315
+ );
316
+ const model_train_form = (model, table, req) => {
317
+ const hyperparameter_fields =
318
+ model.templateObj.hyperparameter_fields?.({
319
+ table,
320
+ ...model,
321
+ }) || [];
322
+ return new Form({
323
+ action: `/models/train/${model.id}`,
324
+ onSubmit: "press_store_button(this)",
325
+ fields: [
326
+ {
327
+ name: "name",
328
+ label: req.__("Name"),
329
+ type: "String",
330
+ required: true,
331
+ },
332
+ ...hyperparameter_fields,
333
+ ],
334
+ });
335
+ };
336
+ router.post(
337
+ "/delete/:id",
338
+ isAdmin,
339
+ error_catcher(async (req, res) => {
340
+ const { id } = req.params;
341
+ const model = await Model.findOne({ id });
342
+ await model.delete();
343
+ req.flash("success", req.__("Model %s deleted", model.name));
344
+ res.redirect(`/table/${model.table_id}`);
345
+ })
346
+ );
347
+
348
+ router.post(
349
+ "/delete-instance/:id",
350
+ isAdmin,
351
+ error_catcher(async (req, res) => {
352
+ const { id } = req.params;
353
+ const model_inst = await ModelInstance.findOne({ id });
354
+ await model_inst.delete();
355
+ req.flash("success", req.__("Model instance %s deleted", model_inst.name));
356
+ res.redirect(`/models/show/${model_inst.model_id}`);
357
+ })
358
+ );
359
+
360
+ router.get(
361
+ "/train/:id",
362
+ isAdmin,
363
+ error_catcher(async (req, res) => {
364
+ const { id } = req.params;
365
+ const model = await Model.findOne({ id });
366
+ const table = await Table.findOne({ id: model.table_id });
367
+ const form = model_train_form(model, table, req);
368
+ res.sendWrap(req.__(`Train model`), {
369
+ above: [
370
+ {
371
+ type: "breadcrumbs",
372
+ crumbs: [
373
+ { text: req.__("Tables"), href: "/table" },
374
+ { href: `/table/${table.id}`, text: table.name },
375
+ { href: `/models/show/${model.id}`, text: model.name },
376
+ { text: req.__(`Train`) },
377
+ ],
378
+ },
379
+ {
380
+ type: "card",
381
+ class: "mt-0",
382
+ title: req.__(`New model`),
383
+ contents: renderForm(form, req.csrfToken()),
384
+ },
385
+ ],
386
+ });
387
+ })
388
+ );
389
+ router.post(
390
+ "/train/:id",
391
+ isAdmin,
392
+ error_catcher(async (req, res) => {
393
+ const { id } = req.params;
394
+ const model = await Model.findOne({ id });
395
+ const table = Table.findOne({ id: model.table_id });
396
+ const form = model_train_form(model, table, req);
397
+ form.validate(req.body);
398
+ if (form.hasErrors) {
399
+ res.sendWrap(req.__(`Train model`), renderForm(form, req.csrfToken()));
400
+ } else {
401
+ const { name, ...hyperparameters } = form.values;
402
+
403
+ const train_res = await model.train_instance(name, hyperparameters, {});
404
+ if (typeof train_res === "string") {
405
+ res.sendWrap(req.__(`Model training error`), {
406
+ above: [
407
+ {
408
+ type: "breadcrumbs",
409
+ crumbs: [
410
+ { text: req.__("Tables"), href: "/table" },
411
+ { href: `/table/${table.id}`, text: table.name },
412
+ { href: `/models/show/${model.id}`, text: model.name },
413
+ { text: req.__(`Training error`) },
414
+ ],
415
+ },
416
+ {
417
+ type: "card",
418
+ class: "mt-0",
419
+ title: req.__(`Training error`),
420
+ contents: pre(train_res),
421
+ },
422
+ ],
423
+ });
424
+ } else {
425
+ req.flash("success", "Model trained");
426
+ res.redirect(`/models/show/${model.id}`);
427
+ }
428
+ }
429
+ })
430
+ );
431
+
432
+ router.post(
433
+ "/make-default-instance/:id",
434
+ isAdmin,
435
+ error_catcher(async (req, res) => {
436
+ const { id } = req.params;
437
+ const model_instance = await ModelInstance.findOne({ id });
438
+ await model_instance.make_default(!req.body.enabled);
439
+ res.redirect(`/models/show/${model_instance.model_id}`);
440
+ })
441
+ );
442
+
443
+ const encode = (s) =>
444
+ s.replace(
445
+ //https://stackoverflow.com/a/57448862/19839414
446
+ /[&<>'"]/g,
447
+ (tag) =>
448
+ ({
449
+ "&": "&amp;",
450
+ "<": "&lt;",
451
+ ">": "&gt;",
452
+ "'": "&#39;",
453
+ '"': "&quot;",
454
+ }[tag])
455
+ );
456
+
457
+ router.get(
458
+ "/show-report/:id",
459
+ isAdmin,
460
+ error_catcher(async (req, res) => {
461
+ const { id } = req.params;
462
+ const model_instance = await ModelInstance.findOne({ id });
463
+ const model = await Model.findOne({ id: model_instance.model_id });
464
+ const table = Table.findOne({ id: model.table_id });
465
+ res.sendWrap(req.__(`Train model`), {
466
+ above: [
467
+ {
468
+ type: "breadcrumbs",
469
+ crumbs: [
470
+ { text: req.__("Tables"), href: "/table" },
471
+ { href: `/table/${table.id}`, text: table.name },
472
+ { href: `/models/show/${model.id}`, text: model.name },
473
+ { text: model_instance.name },
474
+ { text: req.__(`Report`) },
475
+ ],
476
+ },
477
+ {
478
+ type: "card",
479
+ class: "mt-0",
480
+ title: req.__(`Model training report`),
481
+ contents:
482
+ iframe({
483
+ id: "trainreport",
484
+ width: "100%",
485
+ height: "100vh",
486
+ srcdoc: encode(model_instance.report),
487
+ }) + style(`iframe#trainreport { height: 100vh}`),
488
+ },
489
+ ],
490
+ });
491
+ })
492
+ );
package/routes/page.js CHANGED
@@ -7,6 +7,7 @@
7
7
  const Router = require("express-promise-router");
8
8
 
9
9
  const Page = require("@saltcorn/data/models/page");
10
+ const Trigger = require("@saltcorn/data/models/trigger");
10
11
  const { getState } = require("@saltcorn/data/db/state");
11
12
  const {
12
13
  error_catcher,
@@ -40,18 +41,21 @@ router.get(
40
41
  const { pagename } = req.params;
41
42
  const state = getState();
42
43
  state.log(3, `Route /page/${pagename} user=${req.user?.id}`);
43
- const tic = state.logLevel >= 5 ? new Date() : null;
44
+ const tic = new Date();
44
45
 
45
46
  const role = req.user && req.user.id ? req.user.role_id : 100;
46
47
  const db_page = await Page.findOne({ name: pagename });
47
48
  if (db_page && role <= db_page.min_role) {
48
49
  const contents = await db_page.run(req.query, { res, req });
49
50
  const title = scan_for_page_title(contents, db_page.title);
50
- if (tic) {
51
- const tock = new Date();
52
- const ms = tock.getTime() - tic.getTime();
53
- state.log(5, `Page ${pagename} rendered in ${ms} ms`);
54
- }
51
+ const tock = new Date();
52
+ const ms = tock.getTime() - tic.getTime();
53
+ Trigger.emitEvent("PageLoad", null, req.user, {
54
+ text: req.__("Page '%s' was loaded", pagename),
55
+ type: "page",
56
+ name: pagename,
57
+ render_time: ms,
58
+ });
55
59
  res.sendWrap(
56
60
  {
57
61
  title,