zibri-cli 2.2.0 → 2.2.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/assets/public/assets.svg +3 -1
- package/assets/public/home.svg +3 -0
- package/assets/public/lib/chartjs-adapter-date-fns.js +7 -0
- package/assets/public/metrics.svg +3 -0
- package/assets/public/style.css +4 -127
- package/dist/commands/new/new.command.js +164 -26
- package/dist/commands/new/new.command.js.map +1 -1
- package/dist/encapsulation/fs.utilities.js +13 -3
- package/dist/encapsulation/fs.utilities.js.map +1 -1
- package/package.json +2 -2
- package/templates/components/base-page.tsx.tpl +24 -0
- package/templates/components/button.tsx.tpl +34 -0
- package/templates/components/card.tsx.tpl +13 -0
- package/templates/components/chart.tsx.tpl +19 -0
- package/templates/components/checkbox.tsx.tpl +24 -0
- package/templates/components/dialog.tsx.tpl +14 -0
- package/templates/components/empty-page.tsx.tpl +34 -0
- package/templates/components/file-entry.tsx.tpl +31 -0
- package/templates/components/form.tsx.tpl +17 -0
- package/templates/components/heading.tsx.tpl +19 -0
- package/templates/components/link.tsx.tpl +29 -0
- package/templates/components/metrics-status.tsx.tpl +92 -0
- package/templates/components/navbar.tsx.tpl +31 -0
- package/templates/components/network-chart.tsx.tpl +99 -0
- package/templates/components/request-duration-chart.tsx.tpl +83 -0
- package/templates/components/requests-per-second-chart.tsx.tpl +167 -0
- package/templates/components/resource-usage-chart.tsx.tpl +121 -0
- package/templates/components/textarea.tsx.tpl +23 -0
- package/templates/pages/assets.tsx.tpl +23 -0
- package/templates/pages/error.tsx.tpl +42 -0
- package/templates/pages/home.tsx.tpl +44 -0
- package/templates/pages/mailing-list-preferences.tsx.tpl +149 -0
- package/templates/pages/mailing-list-unsubscribe-confirmation.tsx.tpl +40 -0
- package/templates/pages/metrics.tsx.tpl +82 -0
- package/assets/public/socket.io.js +0 -4908
- package/templates/pages/assets.hbs +0 -25
- package/templates/pages/base-page.hbs +0 -15
- package/templates/pages/error.hbs +0 -41
- package/templates/pages/index.hbs +0 -20
- package/templates/pages/mailing-list-preferences.hbs +0 -152
- package/templates/pages/mailing-list-unsubscribe.hbs +0 -25
- package/templates/pages/metrics.hbs +0 -443
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Chart as ChartJsChart } from 'chart.js?client';
|
|
2
|
+
import { Metric, MetricsSnapshot, onClient, PreactComponent } from 'zibri';
|
|
3
|
+
|
|
4
|
+
import { Chart } from './chart';
|
|
5
|
+
import { MetricsEvent } from '../pages/metrics';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
className?: string,
|
|
9
|
+
secondary: string
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const RequestDurationChart: PreactComponent<Props> = ({ className = '', secondary }) => {
|
|
13
|
+
let requestDurationChart: ChartJsChart | undefined;
|
|
14
|
+
|
|
15
|
+
onClient(() => {
|
|
16
|
+
document.addEventListener('metrics:update', (ev) => {
|
|
17
|
+
if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) {
|
|
18
|
+
throw new Error('received invalid metrics event');
|
|
19
|
+
}
|
|
20
|
+
const { snaps } = (ev as MetricsEvent).detail;
|
|
21
|
+
renderRequestDurationChart(snaps);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function renderRequestDurationChart(snaps: MetricsSnapshot[]): void {
|
|
26
|
+
if (!snaps.length) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// Build histogram from the *latest* snapshot only
|
|
30
|
+
const latest: Metric[] = snaps[snaps.length - 1]?.metrics ?? [];
|
|
31
|
+
const sumsByLe: Record<string, number> = latest
|
|
32
|
+
.reduce<Record<string, number>>((map, m) => {
|
|
33
|
+
if (m.name !== 'http_request_duration_ms' || m.labels.le == undefined) {
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
const le: string | number = m.labels.le;
|
|
37
|
+
map[le] = (map[le] ?? 0) + m.value;
|
|
38
|
+
return map;
|
|
39
|
+
}, {});
|
|
40
|
+
const sorted: { le: string, v: number }[] = Object.entries(sumsByLe)
|
|
41
|
+
.map(([le, v]) => ({ le, v }))
|
|
42
|
+
.sort((a, b) => {
|
|
43
|
+
const na: number = a.le === '+Inf' ? Infinity : +a.le;
|
|
44
|
+
const nb: number = b.le === '+Inf' ? Infinity : +b.le;
|
|
45
|
+
return na - nb;
|
|
46
|
+
});
|
|
47
|
+
const perBucket: { le: string, v: number }[] = sorted.map((cur, i) => {
|
|
48
|
+
const prev: number = i > 0 ? sorted[i - 1].v : 0;
|
|
49
|
+
return { le: cur.le, v: cur.v - prev };
|
|
50
|
+
});
|
|
51
|
+
const hist: { labels: string[], values: number[] } = perBucket.reduce<{ labels: string[], values: number[] }>(
|
|
52
|
+
(acc, m) => {
|
|
53
|
+
acc.labels.push(`${m.le} ms`);
|
|
54
|
+
acc.values.push(m.v);
|
|
55
|
+
return acc;
|
|
56
|
+
},
|
|
57
|
+
{ labels: [], values: [] }
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (requestDurationChart) {
|
|
61
|
+
requestDurationChart.data.labels = hist.labels;
|
|
62
|
+
requestDurationChart.data.datasets[0].data = hist.values;
|
|
63
|
+
requestDurationChart.update();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const el: HTMLCanvasElement | null = document.querySelector('#requestDurationChart');
|
|
68
|
+
if (!el) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// render latency histogram
|
|
72
|
+
requestDurationChart = new ChartJsChart(el, {
|
|
73
|
+
type: 'bar',
|
|
74
|
+
data: { labels: hist.labels, datasets: [{ label: 'Count', data: hist.values, backgroundColor: secondary }] }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
<Chart canvasId="requestDurationChart" title="Request duration" className={className}></Chart>
|
|
81
|
+
</>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { ChartDataset, Chart as ChartJsChart } from 'chart.js?client';
|
|
2
|
+
import { Metric, MetricsSnapshot, onClient, PreactComponent } from 'zibri';
|
|
3
|
+
|
|
4
|
+
import { Chart } from './chart';
|
|
5
|
+
import { MetricsEvent } from '../pages/metrics';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
secondary: string,
|
|
9
|
+
className?: string
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type StatusClass = 'success' | 'client' | 'server';
|
|
13
|
+
|
|
14
|
+
type DataPoint = { x: Date, y: number };
|
|
15
|
+
|
|
16
|
+
export const RequestsPerSecondChart: PreactComponent<Props> = ({ secondary, className = '' }) => {
|
|
17
|
+
let rpsChart: ChartJsChart<'line', DataPoint[]> | undefined;
|
|
18
|
+
|
|
19
|
+
onClient(() => {
|
|
20
|
+
document.addEventListener('metrics:update', (ev) => {
|
|
21
|
+
if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) {
|
|
22
|
+
throw new Error('received invalid metrics event');
|
|
23
|
+
}
|
|
24
|
+
const { snaps } = (ev as MetricsEvent).detail;
|
|
25
|
+
renderRequestsPerSecondChart(snaps);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function renderRequestsPerSecondChart(snaps: MetricsSnapshot[]): void {
|
|
30
|
+
const seriesSuccess: DataPoint[] = [];
|
|
31
|
+
const seriesClientError: DataPoint[] = [];
|
|
32
|
+
const seriesServerError: DataPoint[] = [];
|
|
33
|
+
|
|
34
|
+
snaps.forEach((snap, i, all) => {
|
|
35
|
+
const t: Date = new Date(snap.timestamp);
|
|
36
|
+
if (i === 0) {
|
|
37
|
+
seriesSuccess.push({ x: t, y: 0 });
|
|
38
|
+
seriesClientError.push({ x: t, y: 0 });
|
|
39
|
+
seriesServerError.push({ x: t, y: 0 });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const intervalSec: number = (new Date(snap.timestamp).getTime() - new Date(all[i - 1].timestamp).getTime()) / 1000;
|
|
44
|
+
|
|
45
|
+
const prev: Metric[] = all[i - 1].metrics;
|
|
46
|
+
const curr: Metric[] = snap.metrics;
|
|
47
|
+
// sum counts by class
|
|
48
|
+
const sumFor = (statusClass: StatusClass) => {
|
|
49
|
+
const sum = (metrics: Metric[]) => metrics
|
|
50
|
+
.filter(m => {
|
|
51
|
+
if (m.name !== 'http_requests_total') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
switch (statusClass) {
|
|
55
|
+
case 'success': {
|
|
56
|
+
return !m.labels.status_code?.toString().startsWith('5') && !m.labels.status_code?.toString().startsWith('4');
|
|
57
|
+
}
|
|
58
|
+
case 'client': {
|
|
59
|
+
return m.labels.status_code?.toString().startsWith('4');
|
|
60
|
+
}
|
|
61
|
+
case 'server': {
|
|
62
|
+
return m.labels.status_code?.toString().startsWith('5');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.reduce((acc, m) => acc + m.value, 0);
|
|
67
|
+
return (sum(curr) - sum(prev)) / intervalSec;
|
|
68
|
+
};
|
|
69
|
+
seriesSuccess.push({ x: t, y: sumFor('success') });
|
|
70
|
+
seriesClientError.push({ x: t, y: sumFor('client') });
|
|
71
|
+
seriesServerError.push({ x: t, y: sumFor('server') });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// build event‑loop lag series (in ms)
|
|
75
|
+
const lagSeries: DataPoint[] = snaps.map(snap => {
|
|
76
|
+
const m: Metric | undefined = snap.metrics.find(m => m.name === 'nodejs_eventloop_lag_seconds');
|
|
77
|
+
return {
|
|
78
|
+
x: new Date(snap.timestamp),
|
|
79
|
+
y: m ? m.value * 1000 : 0
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const datasets: ChartDataset<'line', DataPoint[]>[] = [
|
|
84
|
+
{
|
|
85
|
+
label: 'Event Loop Lag',
|
|
86
|
+
data: lagSeries,
|
|
87
|
+
fill: false,
|
|
88
|
+
yAxisID: 'y1',
|
|
89
|
+
backgroundColor: 'purple',
|
|
90
|
+
borderColor: 'purple'
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
label: 'Server Error',
|
|
94
|
+
data: seriesServerError,
|
|
95
|
+
fill: true,
|
|
96
|
+
backgroundColor: 'red',
|
|
97
|
+
borderColor: 'red'
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
label: 'Client Error',
|
|
101
|
+
data: seriesClientError,
|
|
102
|
+
fill: true,
|
|
103
|
+
backgroundColor: secondary,
|
|
104
|
+
borderColor: secondary
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
label: 'Success',
|
|
108
|
+
data: seriesSuccess,
|
|
109
|
+
fill: true,
|
|
110
|
+
backgroundColor: 'green',
|
|
111
|
+
borderColor: 'green'
|
|
112
|
+
}
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
if (rpsChart) {
|
|
116
|
+
rpsChart.data.datasets[0].data = datasets[0].data;
|
|
117
|
+
rpsChart.data.datasets[1].data = datasets[1].data;
|
|
118
|
+
rpsChart.data.datasets[2].data = datasets[2].data;
|
|
119
|
+
rpsChart.data.datasets[3].data = datasets[3].data;
|
|
120
|
+
rpsChart.update();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const el: HTMLCanvasElement | null = document.querySelector('#rpsChart');
|
|
125
|
+
if (!el) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// render RPS chart
|
|
130
|
+
rpsChart = new ChartJsChart<'line', DataPoint[]>(el, {
|
|
131
|
+
type: 'line',
|
|
132
|
+
data: { datasets },
|
|
133
|
+
options: {
|
|
134
|
+
scales: {
|
|
135
|
+
x: {
|
|
136
|
+
type: 'time',
|
|
137
|
+
time: {
|
|
138
|
+
unit: 'second',
|
|
139
|
+
displayFormats: {
|
|
140
|
+
second: 'HH:mm:ss'
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
ticks: { stepSize: 5, source: 'auto' },
|
|
144
|
+
grid: { display: false }
|
|
145
|
+
},
|
|
146
|
+
y: {
|
|
147
|
+
stacked: true,
|
|
148
|
+
beginAtZero: true,
|
|
149
|
+
title: { display: true, text: 'Requests/sec' },
|
|
150
|
+
grid: { display: true }
|
|
151
|
+
},
|
|
152
|
+
y1: {
|
|
153
|
+
position: 'right',
|
|
154
|
+
title: { display: true, text: 'Lag (ms)' },
|
|
155
|
+
ticks: { callback: (v): string => typeof v === 'number' ? `${v.toFixed(1)} ms` : v }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<>
|
|
164
|
+
<Chart canvasId="rpsChart" title="Requests per second" className={className}></Chart>
|
|
165
|
+
</>
|
|
166
|
+
);
|
|
167
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Chart as ChartJsChart } from 'chart.js?client';
|
|
2
|
+
import { Metric, MetricsSnapshot, onClient, PreactComponent } from 'zibri';
|
|
3
|
+
|
|
4
|
+
import { Chart } from './chart';
|
|
5
|
+
import { MetricsEvent } from '../pages/metrics';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
primary: string,
|
|
9
|
+
secondary: string,
|
|
10
|
+
className?: string
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type DataPoint = { x: Date, y: number };
|
|
14
|
+
|
|
15
|
+
export const ResourceUsageChart: PreactComponent<Props> = ({ primary, secondary, className = '' }) => {
|
|
16
|
+
let resourceUsageChart: ChartJsChart<'line', DataPoint[]> | undefined;
|
|
17
|
+
|
|
18
|
+
onClient(() => {
|
|
19
|
+
document.addEventListener('metrics:update', (ev) => {
|
|
20
|
+
if (!(ev instanceof CustomEvent) || !('snaps' in ev.detail)) {
|
|
21
|
+
throw new Error('received invalid metrics event');
|
|
22
|
+
}
|
|
23
|
+
const { snaps } = (ev as MetricsEvent).detail;
|
|
24
|
+
renderResourceUsageChart(snaps);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function renderResourceUsageChart(snaps: MetricsSnapshot[]): void {
|
|
29
|
+
const data: DataPoint[] = snaps.map(snap => {
|
|
30
|
+
const mem: Metric | undefined = snap.metrics.find(m => m.name === 'process_resident_memory_bytes');
|
|
31
|
+
return {
|
|
32
|
+
x: new Date(snap.timestamp),
|
|
33
|
+
y: mem ? mem.value / 1024 / 1024 : 0 // MB
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const userSeries: number[] = snaps.map(s => s.metrics.find(m => m.name === 'process_cpu_user_seconds_total')?.value ?? 0);
|
|
38
|
+
const systemSeries: number[] = snaps.map(s => s.metrics.find(m => m.name === 'process_cpu_system_seconds_total')?.value ?? 0);
|
|
39
|
+
const times: number[] = snaps.map(s => new Date(s.timestamp).getTime() / 1000); // seconds
|
|
40
|
+
const coresSeries: number[] = snaps.map(s => s.metrics.find(m => m.name === 'process_cpu_count')?.value ?? 1);
|
|
41
|
+
const cpuSeries: DataPoint[] = times.map((t, i) => {
|
|
42
|
+
if (i === 0) {
|
|
43
|
+
return { x: new Date(t * 1000), y: 0 };
|
|
44
|
+
}
|
|
45
|
+
const dt: number = t - times[i - 1]; // interval in seconds (should be ~5)
|
|
46
|
+
const du: number = userSeries[i] - userSeries[i - 1];
|
|
47
|
+
const ds: number = systemSeries[i] - systemSeries[i - 1];
|
|
48
|
+
const cores: number = coresSeries[i];
|
|
49
|
+
const usedSec: number = du + ds; // total CPU seconds used in that interval
|
|
50
|
+
const pct: number = (usedSec / dt / cores) * 100; // CPU% across all cores
|
|
51
|
+
return { x: new Date(t * 1000), y: pct };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (resourceUsageChart) {
|
|
55
|
+
resourceUsageChart.data.datasets[0].data = data;
|
|
56
|
+
resourceUsageChart.data.datasets[1].data = cpuSeries;
|
|
57
|
+
resourceUsageChart.update();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const el: HTMLCanvasElement | null = document.querySelector('#resourceUsageChart');
|
|
62
|
+
if (!el) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
resourceUsageChart = new ChartJsChart(el, {
|
|
67
|
+
type: 'line',
|
|
68
|
+
data: {
|
|
69
|
+
datasets: [
|
|
70
|
+
{
|
|
71
|
+
label: 'RAM (MB)',
|
|
72
|
+
data,
|
|
73
|
+
yAxisID: 'y',
|
|
74
|
+
borderColor: secondary,
|
|
75
|
+
backgroundColor: secondary
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
label: 'CPU (%)',
|
|
79
|
+
data: cpuSeries,
|
|
80
|
+
yAxisID: 'yCPU',
|
|
81
|
+
borderColor: primary,
|
|
82
|
+
backgroundColor: primary
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
options: {
|
|
87
|
+
scales: {
|
|
88
|
+
x: {
|
|
89
|
+
type: 'time',
|
|
90
|
+
time: {
|
|
91
|
+
unit: 'second',
|
|
92
|
+
displayFormats: {
|
|
93
|
+
second: 'HH:mm:ss'
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
grid: { display: false }
|
|
97
|
+
},
|
|
98
|
+
y: {
|
|
99
|
+
ticks: {
|
|
100
|
+
callback: v => `${v} MB`
|
|
101
|
+
},
|
|
102
|
+
beginAtZero: true
|
|
103
|
+
},
|
|
104
|
+
yCPU: {
|
|
105
|
+
position: 'right',
|
|
106
|
+
grid: { drawOnChartArea: false }, // don't duplicate grid lines
|
|
107
|
+
ticks: {
|
|
108
|
+
callback: v => `${v}%`
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<>
|
|
118
|
+
<Chart canvasId="resourceUsageChart" title="Resource Usage" className={className}></Chart>
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PreactComponent } from 'zibri';
|
|
2
|
+
|
|
3
|
+
type Props = {
|
|
4
|
+
id: string,
|
|
5
|
+
label: string | undefined,
|
|
6
|
+
onChange?: (value: string) => void,
|
|
7
|
+
className?: string
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const TextArea: PreactComponent<Props> = ({ label, id, className, onChange }) => {
|
|
11
|
+
return (
|
|
12
|
+
<div className={`flex flex-col gap-1 ${className}`}>
|
|
13
|
+
{label && <label className="cursor-pointer" for={id}>
|
|
14
|
+
{label}
|
|
15
|
+
</label>}
|
|
16
|
+
<textarea onChange={(ev) => onChange?.(ev.currentTarget.value)}
|
|
17
|
+
className={'p-1 border-none rounded resize-none'}
|
|
18
|
+
autocomplete="off"
|
|
19
|
+
id={id}
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PreactComponent, TreeNode } from 'zibri';
|
|
2
|
+
|
|
3
|
+
import { BasePage } from '../components/base-page';
|
|
4
|
+
import { Card } from '../components/card';
|
|
5
|
+
import { FileEntryComponent } from '../components/file-entry';
|
|
6
|
+
import { Heading } from '../components/heading';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
nodes: TreeNode[]
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const AssetsPage: PreactComponent<Props> = ({ nodes }) => {
|
|
13
|
+
return (
|
|
14
|
+
<BasePage title='Assets' activeRoute='/assets' className="flex flex-col gap-4 py-8">
|
|
15
|
+
<Heading className="text-center">Assets</Heading>
|
|
16
|
+
<Card className="overflow-scroll h-[520px] min-w-[500px] mx-auto">
|
|
17
|
+
<div className="flex flex-col gap-2">
|
|
18
|
+
{nodes.map(n => <FileEntryComponent node={n}></FileEntryComponent>)}
|
|
19
|
+
</div>
|
|
20
|
+
</Card>
|
|
21
|
+
</BasePage>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ErrorPageTemplate, GlobalRegistry, onServer } from 'zibri';
|
|
2
|
+
|
|
3
|
+
import { Card } from '../components/card';
|
|
4
|
+
import { EmptyPage } from '../components/empty-page';
|
|
5
|
+
import { Heading } from '../components/heading';
|
|
6
|
+
import { Link } from '../components/link';
|
|
7
|
+
|
|
8
|
+
export const ErrorPage: ErrorPageTemplate = ({ error }) => {
|
|
9
|
+
let name: string = '';
|
|
10
|
+
|
|
11
|
+
onServer(() => {
|
|
12
|
+
name = GlobalRegistry.getAppData('name') ?? '';
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return <EmptyPage title={error.title} className='flex items-center justify-center'>
|
|
16
|
+
<div className="flex gap-8">
|
|
17
|
+
<Card className='!py-0 max-w-64'>
|
|
18
|
+
<Link href="/">
|
|
19
|
+
<img src="/assets/logo.jpg" width="256px" height="256px" />
|
|
20
|
+
</Link>
|
|
21
|
+
<Link href="/">
|
|
22
|
+
<Heading tag="h2" className='text-center !text-2xl'>{name}</Heading>
|
|
23
|
+
</Link>
|
|
24
|
+
</Card>
|
|
25
|
+
<div className='flex flex-col gap-4 w-[500px]'>
|
|
26
|
+
<Card>
|
|
27
|
+
<Heading>{error.status}: {error.title}</Heading>
|
|
28
|
+
</Card>
|
|
29
|
+
<Card className='flex-1'>
|
|
30
|
+
<div className='flex flex-col gap-2'>
|
|
31
|
+
{error.paragraphs.map(p => <p>{p}</p>)}
|
|
32
|
+
</div>
|
|
33
|
+
</Card>
|
|
34
|
+
<Card>
|
|
35
|
+
<Link icon='/assets/open-api/swagger.png' href="/explorer">
|
|
36
|
+
OpenAPI Explorer
|
|
37
|
+
</Link>
|
|
38
|
+
</Card>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</EmptyPage>;
|
|
42
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PreactComponent } from 'zibri';
|
|
2
|
+
|
|
3
|
+
import { BasePage } from '../components/base-page';
|
|
4
|
+
import { Card } from '../components/card';
|
|
5
|
+
import { Heading } from '../components/heading';
|
|
6
|
+
import { Link } from '../components/link';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
appName: string
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const HomePage: PreactComponent<Props> = ({ appName }) => {
|
|
13
|
+
return (
|
|
14
|
+
<BasePage title='' activeRoute='/' className="flex flex-col gap-4 py-8">
|
|
15
|
+
<Heading className="text-center">{appName}</Heading>
|
|
16
|
+
<div className="max-w-fit mx-auto grid grid-cols-2 gap-4">
|
|
17
|
+
<Card className="flex flex-col gap-2 max-w-80">
|
|
18
|
+
<Link href="/assets" icon="/assets/assets.svg">
|
|
19
|
+
Assets
|
|
20
|
+
</Link>
|
|
21
|
+
<p>
|
|
22
|
+
Lists all publicly registered assets.
|
|
23
|
+
</p>
|
|
24
|
+
</Card>
|
|
25
|
+
<Card className="flex flex-col gap-2 max-w-80">
|
|
26
|
+
<Link href="/explorer" icon="/assets/open-api/swagger.png">
|
|
27
|
+
OpenAPI Explorer
|
|
28
|
+
</Link>
|
|
29
|
+
<p>
|
|
30
|
+
The official OpenAPI/Swagger documentation.
|
|
31
|
+
</p>
|
|
32
|
+
</Card>
|
|
33
|
+
{/* <Card className="flex flex-col gap-2 max-w-80">
|
|
34
|
+
<Link href="/metrics/dashboard" icon="/assets/metrics.svg">
|
|
35
|
+
Metrics
|
|
36
|
+
</Link>
|
|
37
|
+
<p>
|
|
38
|
+
A basic metrics dashboard.
|
|
39
|
+
</p>
|
|
40
|
+
</Card> */}
|
|
41
|
+
</div>
|
|
42
|
+
</BasePage>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { MailingList, MailingListSubscriber, MaskUtilities, onClient, PreactComponent } from 'zibri';
|
|
2
|
+
|
|
3
|
+
import { Button } from '../components/button';
|
|
4
|
+
import { Card } from '../components/card';
|
|
5
|
+
import { Checkbox } from '../components/checkbox';
|
|
6
|
+
import { EmptyPage } from '../components/empty-page';
|
|
7
|
+
import { Heading } from '../components/heading';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
subscriber: MailingListSubscriber,
|
|
11
|
+
mailingLists: MailingList[]
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type MailingListDisplayData = MailingList & {
|
|
15
|
+
isSubscribedTo: boolean
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const MailingListPreferencesPage: PreactComponent<Props> = ({ subscriber, mailingLists }) => {
|
|
19
|
+
let updateButton: HTMLButtonElement;
|
|
20
|
+
let statusBar: HTMLDivElement;
|
|
21
|
+
let subscriberId: string;
|
|
22
|
+
|
|
23
|
+
const email: string = MaskUtilities.mask(subscriber.email);
|
|
24
|
+
const lists: MailingListDisplayData[] = mailingLists.map(l => ({
|
|
25
|
+
...l,
|
|
26
|
+
isSubscribedTo: subscriber.mailingLists.some(sl => sl.id === l.id)
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
let checkedMailingListIdsPriorChanges: string[] = lists.filter(l => l.isSubscribedTo).map(l => l.id);
|
|
30
|
+
let currentCheckedMailingListIds: string[] = [...checkedMailingListIdsPriorChanges];
|
|
31
|
+
|
|
32
|
+
onClient(() => {
|
|
33
|
+
updateButton = document.querySelector('#update-button')!;
|
|
34
|
+
statusBar = document.querySelector('#status-bar')!;
|
|
35
|
+
const subscriberIdParam: string | null = new URL(window.location.href).searchParams.get('subscriberId');
|
|
36
|
+
if (!subscriberIdParam) {
|
|
37
|
+
location.href = '/';
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
subscriberId = subscriberIdParam;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function updateCheckedMailingListIds(l: MailingListDisplayData): void {
|
|
44
|
+
if (l.isSubscribedTo) {
|
|
45
|
+
currentCheckedMailingListIds.push(l.id);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
currentCheckedMailingListIds = currentCheckedMailingListIds.filter(id => id !== l.id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
checkIsDirty();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function checkIsDirty(): void {
|
|
55
|
+
if (checkedMailingListIdsPriorChanges.find(id => !currentCheckedMailingListIds.includes(id))) {
|
|
56
|
+
updateButton.disabled = false;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (currentCheckedMailingListIds.find(id => !checkedMailingListIdsPriorChanges.includes(id))) {
|
|
60
|
+
updateButton.disabled = false;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
updateButton.disabled = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function update(): Promise<void> {
|
|
68
|
+
setIsLoading();
|
|
69
|
+
|
|
70
|
+
const success: boolean = (await fetch(
|
|
71
|
+
`/mailing-lists/preferences?subscriberId=${subscriberId}`,
|
|
72
|
+
{
|
|
73
|
+
method: 'PATCH',
|
|
74
|
+
body: JSON.stringify({ mailingListIds: currentCheckedMailingListIds }),
|
|
75
|
+
headers: { 'content-type': 'application/json' }
|
|
76
|
+
}
|
|
77
|
+
)).ok;
|
|
78
|
+
|
|
79
|
+
if (success) {
|
|
80
|
+
setIsSuccess();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const id of lists.map(l => l.id)) {
|
|
85
|
+
if (!checkedMailingListIdsPriorChanges.includes(id)) {
|
|
86
|
+
const checkbox: HTMLInputElement = document.querySelector(`#${id}`)!;
|
|
87
|
+
checkbox.checked = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const id of checkedMailingListIdsPriorChanges) {
|
|
91
|
+
if (!lists.map(l => l.id).includes(id)) {
|
|
92
|
+
const checkbox: HTMLInputElement = document.querySelector(`#${id}`)!;
|
|
93
|
+
checkbox.checked = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
setIsFailed();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reset(): void {
|
|
100
|
+
checkedMailingListIdsPriorChanges = [...currentCheckedMailingListIds];
|
|
101
|
+
checkIsDirty();
|
|
102
|
+
}
|
|
103
|
+
function removeStatus(): void {
|
|
104
|
+
statusBar.style.backgroundColor = '';
|
|
105
|
+
statusBar.textContent = null;
|
|
106
|
+
}
|
|
107
|
+
function setIsLoading(): void {
|
|
108
|
+
updateButton.disabled = true;
|
|
109
|
+
statusBar.style.backgroundColor = 'gray';
|
|
110
|
+
statusBar.textContent = 'loading...';
|
|
111
|
+
}
|
|
112
|
+
function setIsSuccess(): void {
|
|
113
|
+
removeStatus();
|
|
114
|
+
statusBar.style.backgroundColor = 'green';
|
|
115
|
+
statusBar.textContent = 'preferences updated';
|
|
116
|
+
reset();
|
|
117
|
+
setTimeout(removeStatus, 3000);
|
|
118
|
+
}
|
|
119
|
+
function setIsFailed(): void {
|
|
120
|
+
removeStatus();
|
|
121
|
+
statusBar.style.backgroundColor = 'red';
|
|
122
|
+
statusBar.textContent = 'failed updating preferences';
|
|
123
|
+
reset();
|
|
124
|
+
setTimeout(removeStatus, 3000);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
<EmptyPage title='Mailing List Preferences'>
|
|
130
|
+
<Card className='text-center flex flex-col gap-6'>
|
|
131
|
+
<img className="block mx-auto" src="/assets/logo.jpg" width="200px" height="200px"/>
|
|
132
|
+
<p className="text-center">{email}</p>
|
|
133
|
+
<Heading className='my-2'>
|
|
134
|
+
Mailing list preferences
|
|
135
|
+
</Heading>
|
|
136
|
+
<ul>
|
|
137
|
+
{lists.map(l => <li className="mb-2">
|
|
138
|
+
<Checkbox onChange={() => updateCheckedMailingListIds(l)} label={l.name} id={l.id} checked={l.isSubscribedTo}>
|
|
139
|
+
</Checkbox>
|
|
140
|
+
</li>)}
|
|
141
|
+
</ul>
|
|
142
|
+
<Button onClick={() => update()} className='mx-auto' disabled id='update-button'>Update</Button>
|
|
143
|
+
<div class="flex items-center justify-center h-6 -mt-[9px] -m-[15px] transition duration-300 ease-in" id="status-bar">
|
|
144
|
+
</div>
|
|
145
|
+
</Card>
|
|
146
|
+
</EmptyPage>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
};
|