solid_objects 0.12.1 → 0.13.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +58 -11
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/architecture.md +3 -2
- data/docs/authorization.md +11 -4
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/realtime.md +22 -14
- data/docs/roadmap.md +24 -6
- data/docs/security.md +7 -7
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
- data/lib/solid_objects/actor.rb +1 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- metadata +55 -2
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Refreshes the summary bar in place. Nothing else on the page polls, because
|
|
2
|
+
// a dashboard that reloads a table an operator is reading is worse than one
|
|
3
|
+
// that shows a stale table with a live total.
|
|
4
|
+
(function () {
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
var POLL_INTERVAL = 5000;
|
|
8
|
+
var STORAGE_KEY = "solid-objects-live";
|
|
9
|
+
|
|
10
|
+
var timer = null;
|
|
11
|
+
|
|
12
|
+
function readPath() {
|
|
13
|
+
return document.body.getAttribute("data-stats-path");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function lookup(payload, path) {
|
|
17
|
+
return path.split(".").reduce(function (value, key) {
|
|
18
|
+
return value == null ? null : value[key];
|
|
19
|
+
}, payload);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Must match SolidObjects::Web::Helpers#number and #duration. A polled cell
|
|
23
|
+
// sits beside server-rendered ones, and a value that changes shape the
|
|
24
|
+
// moment polling starts reads as a different measurement.
|
|
25
|
+
//
|
|
26
|
+
// The cell declares which one it is. A duration of exactly zero arrives from
|
|
27
|
+
// JSON as an integer-valued number, so guessing from the value would print
|
|
28
|
+
// "0" where the server printed "0.000 s".
|
|
29
|
+
function format(value, kind) {
|
|
30
|
+
if (typeof value !== "number") return String(value);
|
|
31
|
+
if (kind !== "duration") return value.toLocaleString("en-US");
|
|
32
|
+
if (value < 60) return value.toFixed(3) + " s";
|
|
33
|
+
|
|
34
|
+
return Math.floor(value / 60) + " min " + Math.round(value % 60) + " s";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function apply(payload) {
|
|
38
|
+
var cells = document.querySelectorAll("[data-statistic]");
|
|
39
|
+
Array.prototype.forEach.call(cells, function (cell) {
|
|
40
|
+
var value = lookup(payload, cell.getAttribute("data-statistic"));
|
|
41
|
+
if (value == null) return;
|
|
42
|
+
|
|
43
|
+
var text = format(value, cell.getAttribute("data-format"));
|
|
44
|
+
if (cell.textContent !== text) cell.textContent = text;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function poll() {
|
|
49
|
+
var path = readPath();
|
|
50
|
+
if (!path) return;
|
|
51
|
+
|
|
52
|
+
fetch(path, { credentials: "same-origin", headers: { Accept: "application/json" } })
|
|
53
|
+
.then(function (response) {
|
|
54
|
+
return response.ok ? response.json() : null;
|
|
55
|
+
})
|
|
56
|
+
.then(function (payload) {
|
|
57
|
+
if (!payload) return;
|
|
58
|
+
|
|
59
|
+
apply(payload);
|
|
60
|
+
// The charts subscribe to this rather than polling on their own, so
|
|
61
|
+
// one request refreshes every number on the page.
|
|
62
|
+
document.dispatchEvent(
|
|
63
|
+
new CustomEvent("solid-objects:statistics", { detail: payload })
|
|
64
|
+
);
|
|
65
|
+
})
|
|
66
|
+
.catch(function () {
|
|
67
|
+
// A failed poll leaves the last rendered totals in place.
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setLive(button, live) {
|
|
72
|
+
button.setAttribute("aria-pressed", live ? "true" : "false");
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
window.localStorage.setItem(STORAGE_KEY, live ? "on" : "off");
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Storage is unavailable in private windows; the toggle still works.
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (timer) {
|
|
81
|
+
window.clearInterval(timer);
|
|
82
|
+
timer = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (live) {
|
|
86
|
+
poll();
|
|
87
|
+
timer = window.setInterval(poll, POLL_INTERVAL);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stored() {
|
|
92
|
+
try {
|
|
93
|
+
return window.localStorage.getItem(STORAGE_KEY) === "on";
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function start() {
|
|
100
|
+
var button = document.querySelector("[data-poll-toggle]");
|
|
101
|
+
if (!button) return;
|
|
102
|
+
|
|
103
|
+
button.addEventListener("click", function () {
|
|
104
|
+
setLive(button, button.getAttribute("aria-pressed") !== "true");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (stored()) setLive(button, true);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The tag is deferred, so the document is usually still parsing. A script
|
|
111
|
+
// evaluated after the document finished would never see the event, and
|
|
112
|
+
// waiting for one that already fired leaves the toggle inert.
|
|
113
|
+
if (document.readyState === "loading") {
|
|
114
|
+
document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
115
|
+
} else {
|
|
116
|
+
start();
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Draws the dashboard charts. Each canvas carries its own data in a
|
|
2
|
+
// data-chart-values attribute, so this file never fetches on load and the page
|
|
3
|
+
// needs no script-src exception for an inline block.
|
|
4
|
+
//
|
|
5
|
+
// Two of the three charts redraw when the Live poller reports new totals. The
|
|
6
|
+
// third counts instances per actor type, which /stats does not carry, so it
|
|
7
|
+
// stays as the page rendered it.
|
|
8
|
+
(function () {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
// Every status gets its own colour. Related statuses stay in the same
|
|
12
|
+
// family, because a legend with two identical swatches reads as a defect
|
|
13
|
+
// even when the two statuses genuinely mean the same thing.
|
|
14
|
+
var STATUS_COLORS = {
|
|
15
|
+
pending: "#d29922",
|
|
16
|
+
paused: "#e07b39",
|
|
17
|
+
processing: "#4493f8",
|
|
18
|
+
scheduled: "#7c72ff",
|
|
19
|
+
completed: "#3fb950",
|
|
20
|
+
delivered: "#2ea8a0",
|
|
21
|
+
dead: "#f85149"
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
var SERIES_COLOR = "#4493f8";
|
|
25
|
+
var charts = {};
|
|
26
|
+
|
|
27
|
+
function palette() {
|
|
28
|
+
var styles = window.getComputedStyle(document.body);
|
|
29
|
+
return {
|
|
30
|
+
text: styles.getPropertyValue("--text-muted").trim() || "#626d7a",
|
|
31
|
+
grid: styles.getPropertyValue("--border").trim() || "#dfe3e8"
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function values(canvas) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(canvas.getAttribute("data-chart-values"));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function baseOptions(colors) {
|
|
44
|
+
return {
|
|
45
|
+
responsive: true,
|
|
46
|
+
maintainAspectRatio: false,
|
|
47
|
+
animation: { duration: 200 },
|
|
48
|
+
plugins: { legend: { display: false } },
|
|
49
|
+
scales: {
|
|
50
|
+
x: { ticks: { color: colors.text }, grid: { color: colors.grid } },
|
|
51
|
+
y: {
|
|
52
|
+
beginAtZero: true,
|
|
53
|
+
ticks: { color: colors.text, precision: 0 },
|
|
54
|
+
grid: { color: colors.grid }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function barChart(canvas, data, colors, options) {
|
|
61
|
+
var labels = Object.keys(data);
|
|
62
|
+
var settings = baseOptions(colors);
|
|
63
|
+
if (options && options.horizontal) settings.indexAxis = "y";
|
|
64
|
+
|
|
65
|
+
return new window.Chart(canvas, {
|
|
66
|
+
type: "bar",
|
|
67
|
+
data: {
|
|
68
|
+
labels: labels,
|
|
69
|
+
datasets: [
|
|
70
|
+
{
|
|
71
|
+
data: labels.map(function (label) {
|
|
72
|
+
return data[label];
|
|
73
|
+
}),
|
|
74
|
+
backgroundColor: SERIES_COLOR,
|
|
75
|
+
borderRadius: 4,
|
|
76
|
+
maxBarThickness: 36
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
},
|
|
80
|
+
options: settings
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One dataset per status so each row shows only the statuses its subsystem
|
|
85
|
+
// actually has; a subsystem without a status contributes zero to it.
|
|
86
|
+
function stackedChart(canvas, data, colors) {
|
|
87
|
+
var rows = Object.keys(data);
|
|
88
|
+
var statuses = [];
|
|
89
|
+
rows.forEach(function (row) {
|
|
90
|
+
Object.keys(data[row]).forEach(function (status) {
|
|
91
|
+
if (statuses.indexOf(status) === -1) statuses.push(status);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
var settings = baseOptions(colors);
|
|
96
|
+
settings.indexAxis = "y";
|
|
97
|
+
settings.plugins.legend = { display: true, labels: { color: colors.text } };
|
|
98
|
+
settings.scales.x.stacked = true;
|
|
99
|
+
settings.scales.y.stacked = true;
|
|
100
|
+
|
|
101
|
+
return new window.Chart(canvas, {
|
|
102
|
+
type: "bar",
|
|
103
|
+
data: {
|
|
104
|
+
labels: rows,
|
|
105
|
+
datasets: statuses.map(function (status) {
|
|
106
|
+
return {
|
|
107
|
+
label: status,
|
|
108
|
+
backgroundColor: STATUS_COLORS[status] || SERIES_COLOR,
|
|
109
|
+
borderRadius: 2,
|
|
110
|
+
data: rows.map(function (row) {
|
|
111
|
+
return data[row][status] || 0;
|
|
112
|
+
})
|
|
113
|
+
};
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
options: settings
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function build(canvas) {
|
|
121
|
+
var name = canvas.getAttribute("data-chart");
|
|
122
|
+
var data = values(canvas);
|
|
123
|
+
if (!data) return;
|
|
124
|
+
|
|
125
|
+
var colors = palette();
|
|
126
|
+
if (name === "work_by_status") {
|
|
127
|
+
charts[name] = stackedChart(canvas, data, colors);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
charts[name] = barChart(canvas, data, colors, {
|
|
132
|
+
horizontal: name === "instances_by_type"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function replace(chart, data) {
|
|
137
|
+
chart.data.datasets.forEach(function (dataset) {
|
|
138
|
+
if (!dataset.label) {
|
|
139
|
+
dataset.data = chart.data.labels.map(function (label) {
|
|
140
|
+
return data[label] || 0;
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
dataset.data = chart.data.labels.map(function (row) {
|
|
146
|
+
return (data[row] || {})[dataset.label] || 0;
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
chart.update();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function refresh(statistics) {
|
|
153
|
+
if (charts.mailbox_depth) {
|
|
154
|
+
replace(charts.mailbox_depth, {
|
|
155
|
+
Ready: statistics.mailbox.ready,
|
|
156
|
+
Due: statistics.mailbox.due,
|
|
157
|
+
Claimed: statistics.mailbox.claimed
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (charts.work_by_status) {
|
|
162
|
+
replace(charts.work_by_status, {
|
|
163
|
+
Effects: statistics.effects,
|
|
164
|
+
Broadcasts: statistics.broadcasts,
|
|
165
|
+
Reminders: statistics.reminders
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function start() {
|
|
171
|
+
if (!window.Chart) return;
|
|
172
|
+
|
|
173
|
+
var canvases = document.querySelectorAll("[data-chart]");
|
|
174
|
+
Array.prototype.forEach.call(canvases, build);
|
|
175
|
+
|
|
176
|
+
document.addEventListener("solid-objects:statistics", function (event) {
|
|
177
|
+
try {
|
|
178
|
+
refresh(event.detail);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// A payload missing a section leaves the drawn chart alone.
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (document.readyState === "loading") {
|
|
186
|
+
document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
187
|
+
} else {
|
|
188
|
+
start();
|
|
189
|
+
}
|
|
190
|
+
})();
|