event-storage-ui 0.1.0 → 1.0.0-alpha.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 (44) hide show
  1. package/README.md +53 -1
  2. package/app/components/chart.jsx +34 -0
  3. package/app/components/date.jsx +19 -0
  4. package/app/components/json.jsx +21 -0
  5. package/app/components/kpi.jsx +11 -0
  6. package/app/components/radial.jsx +16 -0
  7. package/app/components/stream-info-panel.jsx +64 -0
  8. package/app/consumers.server.js +198 -0
  9. package/app/entry.client.jsx +12 -0
  10. package/app/entry.server.jsx +153 -0
  11. package/app/hooks/paginate.js +24 -0
  12. package/app/hooks/sysinfo.js +49 -0
  13. package/app/root.jsx +177 -0
  14. package/app/routes/_index.jsx +714 -0
  15. package/app/routes/consumers.$consumerIdentifier.jsx +99 -0
  16. package/app/routes/consumers._index.jsx +380 -0
  17. package/app/routes/resources.stores.jsx +5 -0
  18. package/app/routes/resources.sysinfo.jsx +9 -0
  19. package/app/routes/streams.$streamName.$from.$direction.$amount.jsx +214 -0
  20. package/app/routes/streams.$streamName.jsx +203 -0
  21. package/app/routes/streams._index.jsx +127 -0
  22. package/app/routes/write-events.jsx +308 -0
  23. package/app/routes.ts +4 -0
  24. package/app/sysinfo-history.server.js +134 -0
  25. package/eventstore.config.json +7 -2
  26. package/eventstore.js +203 -74
  27. package/package.json +49 -14
  28. package/public/.well-known/appspecific/com.chrome.devtools.json +7 -0
  29. package/public/assets/css/material-overrides.css +690 -37
  30. package/vite.config.js +6 -0
  31. package/components/chart.js +0 -18
  32. package/components/date.js +0 -19
  33. package/components/json.js +0 -10
  34. package/components/layout.js +0 -86
  35. package/hooks/paginate.js +0 -11
  36. package/hooks/sysinfo.js +0 -62
  37. package/next.config.js +0 -30
  38. package/pages/_app.js +0 -5
  39. package/pages/api/sysinfo/index.js +0 -74
  40. package/pages/consumers/consumer.js +0 -59
  41. package/pages/consumers/index.js +0 -69
  42. package/pages/index.js +0 -395
  43. package/pages/streams/eventstream.js +0 -104
  44. package/pages/streams/index.js +0 -68
package/README.md CHANGED
@@ -1 +1,53 @@
1
- This is a starter template for [Learn Next.js](https://nextjs.org/learn).
1
+ This is an admin dashboard for inspecting a running [node-event-storage](https://github.com/albe/node-event-storage) on the same machine. It is built using [Remix](https://remix.run/) with modern React and based on the [Adminator](https://github.com/puikinsh/Adminator-admin-dashboard) theme.
2
+
3
+ ## Screenshots
4
+
5
+ ### Dashboard
6
+
7
+ ![Dashboard](public/screenshots/dashboard.png)
8
+
9
+ ### Event Stream
10
+
11
+ ![Event Stream](public/screenshots/event-stream.png)
12
+
13
+ ### Consumers (create and list)
14
+
15
+ ![Consumers](public/screenshots/consumers-preview-executed.png)
16
+
17
+ ### Event commit/write
18
+
19
+ ![Write Events](public/screenshots/write-events-filled.png)
20
+
21
+ ## Usage
22
+
23
+ ```
24
+ git clone https://github.com/albe/node-event-storage-ui.git
25
+ cd node-event-storage-ui
26
+ npm install
27
+ npm run dev
28
+ ```
29
+
30
+ or
31
+
32
+ ```
33
+ npm run build && npm start
34
+ ```
35
+ for creating a production build and running it. Make sure the webserver is not reachable from the public internet though.
36
+
37
+ To adjust the path to your local node-event-storage edit the `eventstore.config.json` file and adjust the `storeName` and `options.storageDirectory` JSON properties.
38
+
39
+ You can also protect the UI with HTTP Basic Auth by setting `basicAuth.username` and `basicAuth.password`. If either value is empty, Basic Auth is disabled.
40
+ **WARNING:** Never expose Basic Auth over plain HTTP in untrusted networks; use HTTPS (or a trusted private network only), because credentials are sent with every request and can be intercepted.
41
+ Configuration is loaded at server startup, so restart the app after changing `eventstore.config.json`.
42
+
43
+ ```json
44
+ {
45
+ "storeName": "eventstore",
46
+ "storesDirectory": null,
47
+ "options": {},
48
+ "basicAuth": {
49
+ "username": "admin",
50
+ "password": "change-me"
51
+ }
52
+ }
53
+ ```
@@ -0,0 +1,34 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ let registered = false;
4
+
5
+ async function ensureChartJsRegistered() {
6
+ if (registered) return;
7
+ const { Chart, LineElement, BarElement, PointElement, LineController, BarController,
8
+ CategoryScale, LinearScale, Filler, Tooltip, Legend } = await import('chart.js');
9
+ Chart.register(LineElement, BarElement, PointElement, LineController, BarController,
10
+ CategoryScale, LinearScale, Filler, Tooltip, Legend);
11
+ registered = true;
12
+ }
13
+
14
+ export default function Chart({ type, data, options, className }) {
15
+ const [ChartComponent, setChartComponent] = useState(null);
16
+
17
+ useEffect(() => {
18
+ Promise.all([
19
+ import('react-chartjs-2'),
20
+ ensureChartJsRegistered()
21
+ ]).then(([mod]) => {
22
+ const Components = { Line: mod.Line, Bar: mod.Bar };
23
+ setChartComponent(() => Components[type]);
24
+ });
25
+ }, [type]);
26
+
27
+ if (!ChartComponent) return null;
28
+
29
+ return (
30
+ <div className={className}>
31
+ <ChartComponent data={data} options={options} />
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,19 @@
1
+ const formatter = new Intl.DateTimeFormat(undefined, {
2
+ day: '2-digit',
3
+ month: '2-digit',
4
+ year: 'numeric',
5
+ hour: '2-digit',
6
+ minute: '2-digit',
7
+ second: '2-digit'
8
+ });
9
+
10
+ export default function DateFormat({ value }) {
11
+ const date = value instanceof Date ? value : new Date(value);
12
+
13
+ try {
14
+ return formatter.format(date);
15
+ } catch {
16
+ console.error(`Can not format '${value}' as a date`);
17
+ return value;
18
+ }
19
+ }
@@ -0,0 +1,21 @@
1
+ import JsonViewModule from 'react18-json-view';
2
+ import 'react18-json-view/src/style.css';
3
+ import 'react18-json-view/src/dark.css';
4
+
5
+ const JsonView = JsonViewModule.default ?? JsonViewModule;
6
+
7
+ export default function Json({ data, collapsed = true, style = undefined, className = '' }) {
8
+ const normalizedCollapsed = collapsed === false ? 1 : collapsed === true ? true : collapsed;
9
+
10
+ return (
11
+ <JsonView
12
+ src={data}
13
+ collapsed={normalizedCollapsed}
14
+ displaySize={normalizedCollapsed === false ? false : 'collapsed'}
15
+ dark={true}
16
+ theme="default"
17
+ className={`json-theme-adminator ${className}`.trim()}
18
+ style={{ background: 'transparent', ...style }}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,11 @@
1
+ export default function Kpi({ value, stdDev, className }) {
2
+ if (stdDev && Math.abs(value) < stdDev) {
3
+ return (<span className={"kpi-pill flat" + (className ? ' ' + className : '')}><svg viewBox="0 0 24 24"><path d="M5 12h14"></path></svg> ~{value.toFixed(2)}%</span>);
4
+ } else if (value > 0) {
5
+ return (<span className={"kpi-pill up" + (className ? ' ' + className : '')}><svg viewBox="0 0 24 24"><path d="M7 17l10-10M7 7h10v10"></path></svg> +{value.toFixed(2)}%</span>);
6
+ } else if (value === 0) {
7
+ return (<span className={"kpi-pill info" + (className ? ' ' + className : '')}><svg viewBox="0 0 24 24"><path d="M5 12h14"></path></svg> steady</span>);
8
+ } else {
9
+ return (<span className={"kpi-pill down" + (className ? ' ' + className : '')}><svg viewBox="0 0 24 24"><path d="M7 7l10 10M7 17h10V7"></path></svg> {value.toFixed(2)}%</span>);
10
+ }
11
+ }
@@ -0,0 +1,16 @@
1
+ export default function Radial({ value, max, label, caption, className }) {
2
+ max = max || 100;
3
+ const pct = value * 100 / max;
4
+ return (<div className="sv-radial">
5
+ <div className="sv-radial-chart">
6
+ <svg viewBox="0 0 80 80">
7
+ <circle className="radial-track" cx="40" cy="40" r="32"></circle>
8
+ <circle className={"radial-fill " + (pct >= 95 ? "danger" : className ?? "info")} cx="40" cy="40" r="32" pathLength="100" strokeDasharray={pct + ' 100'}></circle>
9
+ </svg>
10
+ <span className="pct">{pct.toFixed(0)}%</span></div>
11
+ <div className="sv-radial-text">
12
+ {label && <div className="sv-radial-name">{label}</div>}
13
+ {caption && <div className="sv-radial-caption">{caption}</div>}
14
+ </div>
15
+ </div>);
16
+ }
@@ -0,0 +1,64 @@
1
+ import Json from './json';
2
+
3
+ function FunctionBlock({ value }) {
4
+ if (!value) {
5
+ return <div className="stream-info-panel__empty">n/a</div>;
6
+ }
7
+
8
+ return <pre className="stream-info-panel__code">{value}</pre>;
9
+ }
10
+
11
+ export default function StreamInfoPanel({ streamInfo }) {
12
+ const matcherIsFunctionExpression = typeof streamInfo.matcher === 'string';
13
+ const matcherIsJson = streamInfo.matcher !== null && typeof streamInfo.matcher === 'object';
14
+
15
+ function renderJson(data) {
16
+ if (data === null || data === undefined) {
17
+ return <div className="stream-info-panel__empty">n/a</div>;
18
+ }
19
+
20
+ return (
21
+ <div className="stream-info-panel__json">
22
+ <Json data={data} collapsed={1} />
23
+ </div>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <div className="stream-info-panel" role="region" aria-label="Expanded stream info">
29
+ <div className="row">
30
+ <div className="col-lg-7 col-md-12">
31
+ <section className="stream-info-panel__section">
32
+ <div className="stream-info-panel__label">Matcher</div>
33
+ {matcherIsJson ? (
34
+ renderJson(streamInfo.matcher)
35
+ ) : matcherIsFunctionExpression ? (
36
+ <FunctionBlock value={streamInfo.matcher} />
37
+ ) : (
38
+ <div className="stream-info-panel__empty">n/a</div>
39
+ )}
40
+ </section>
41
+ </div>
42
+ <div className="col-lg-5 col-md-12">
43
+ <section className="stream-info-panel__section">
44
+ <div className="stream-info-panel__label">Partition</div>
45
+ <div className="stream-info-panel__status">
46
+ <span className="stream-info-panel__status-label">Write stream</span>
47
+ <span
48
+ className={`tag ${streamInfo.isWriteStream ? 't-active' : 't-unavail'}`}
49
+ >
50
+ {streamInfo.isWriteStream ? 'Yes' : 'No'}
51
+ </span>
52
+ </div>
53
+ {streamInfo.isWriteStream && (
54
+ <>
55
+ <div className="stream-info-panel__sublabel">Metadata</div>
56
+ {renderJson(streamInfo.partitionMetadata)}
57
+ </>
58
+ )}
59
+ </section>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,198 @@
1
+ import vm from 'node:vm';
2
+ import { randomUUID } from 'node:crypto';
3
+ import getEventStore from '../eventstore';
4
+
5
+ const MAX_CONSUMER_LOGIC_LENGTH = 10000;
6
+ const CONSUMER_LOGIC_TIMEOUT_MS = 200;
7
+
8
+ function normalizeStreamNames(streamNamesInput) {
9
+ if (Array.isArray(streamNamesInput)) {
10
+ return Array.from(new Set(streamNamesInput.map((name) => String(name).trim()).filter(Boolean)));
11
+ }
12
+
13
+ return Array.from(
14
+ new Set(
15
+ String(streamNamesInput || '')
16
+ .split(',')
17
+ .map((name) => name.trim())
18
+ .filter(Boolean)
19
+ )
20
+ );
21
+ }
22
+
23
+ function validateConsumerLogicInput(consumerLogic) {
24
+ if (typeof consumerLogic !== 'string' || !consumerLogic.trim()) {
25
+ throw new Error('Consumer logic is required.');
26
+ }
27
+
28
+ if (consumerLogic.length > MAX_CONSUMER_LOGIC_LENGTH) {
29
+ throw new Error(`Consumer logic is too large (max ${MAX_CONSUMER_LOGIC_LENGTH} characters).`);
30
+ }
31
+
32
+ const unsafePatterns = [
33
+ /\b(?:import|export|require)\b/,
34
+ /import\s*\(/,
35
+ /\b(?:process|global|globalThis|module)\b/,
36
+ /\b(?:Function|eval)\b/,
37
+ /<\s*\/?\s*script\b/i
38
+ ];
39
+ if (unsafePatterns.some((pattern) => pattern.test(consumerLogic))) {
40
+ throw new Error('Consumer logic contains unsafe syntax.');
41
+ }
42
+ }
43
+
44
+ function executeConsumerLogic(consumerLogic, event, state, persistState) {
45
+ let nextState = state;
46
+ let calledSetState = false;
47
+ const setState = (update) => {
48
+ let resolvedState;
49
+ try {
50
+ resolvedState = typeof update === 'function' ? update(nextState) : update;
51
+ } catch (err) {
52
+ throw new Error(err?.message || 'setState update failed.');
53
+ }
54
+ nextState = resolvedState;
55
+ calledSetState = true;
56
+ if (persistState) {
57
+ persistState(resolvedState);
58
+ }
59
+ };
60
+
61
+ const context = vm.createContext({
62
+ event,
63
+ state: nextState,
64
+ setState
65
+ });
66
+
67
+ const result = vm.runInContext(
68
+ `(() => {
69
+ const consumerHandler = (${consumerLogic});
70
+ if (typeof consumerHandler !== 'function') {
71
+ throw new Error('Consumer logic must evaluate to a function.');
72
+ }
73
+ return consumerHandler(event, state, setState);
74
+ })()`,
75
+ context,
76
+ { timeout: CONSUMER_LOGIC_TIMEOUT_MS }
77
+ );
78
+
79
+ if (!calledSetState && typeof result !== 'undefined') {
80
+ nextState = result;
81
+ }
82
+
83
+ if (typeof nextState === 'undefined') {
84
+ throw new Error('Consumer state must not become undefined.');
85
+ }
86
+
87
+ return { nextState, calledSetState };
88
+ }
89
+
90
+ function readEventsForStreams(eventstore, streamNames) {
91
+ if (streamNames.length === 0) {
92
+ throw new Error('At least one stream name is required.');
93
+ }
94
+
95
+ if (streamNames.includes('_all')) {
96
+ return eventstore.getAllEvents();
97
+ }
98
+
99
+ if (streamNames.length === 1) {
100
+ const stream = eventstore.getEventStream(streamNames[0]);
101
+ if (stream === false) {
102
+ throw new Error(`Stream "${streamNames[0]}" does not exist.`);
103
+ }
104
+ return stream;
105
+ }
106
+
107
+ return eventstore.fromStreams(`_internal_preview_${randomUUID()}`, streamNames);
108
+ }
109
+
110
+ function replayConsumer({ stream, consumerLogic, initialState }) {
111
+ let state = Object.freeze(initialState ?? {});
112
+ stream.forEach((payload, metadata, eventStream) => {
113
+ const execution = executeConsumerLogic(consumerLogic, { payload, metadata, stream: eventStream }, state);
114
+ state = Object.freeze(execution.nextState);
115
+ });
116
+ return state;
117
+ }
118
+
119
+ export async function previewConsumerState(
120
+ { streamNames: streamNamesInput, consumerLogic, initialState = {} },
121
+ storeNameOverride
122
+ ) {
123
+ validateConsumerLogicInput(consumerLogic);
124
+ const streamNames = normalizeStreamNames(streamNamesInput);
125
+ const { eventstore } = await getEventStore({ readOnly: true }, storeNameOverride);
126
+
127
+ try {
128
+ const stream = readEventsForStreams(eventstore, streamNames);
129
+ const state = replayConsumer({ stream, consumerLogic, initialState });
130
+ return { state, streamNames };
131
+ } finally {
132
+ eventstore.close();
133
+ }
134
+ }
135
+
136
+ export async function createConsumer(
137
+ { streamName, consumerName, consumerLogic, initialState = {}, since = 0 },
138
+ storeNameOverride
139
+ ) {
140
+ validateConsumerLogicInput(consumerLogic);
141
+
142
+ if (typeof streamName !== 'string' || !streamName.trim()) {
143
+ throw new Error('Stream name is required.');
144
+ }
145
+
146
+ if (typeof consumerName !== 'string' || !consumerName.trim()) {
147
+ throw new Error('Consumer name is required.');
148
+ }
149
+
150
+ const normalizedStreamName = streamName.trim();
151
+ const normalizedConsumerName = consumerName.trim();
152
+
153
+ await previewConsumerState(
154
+ { streamNames: [normalizedStreamName], consumerLogic, initialState },
155
+ storeNameOverride
156
+ );
157
+
158
+ const { eventstore } = await getEventStore({ readOnly: false }, storeNameOverride);
159
+
160
+ try {
161
+ if (eventstore.getEventStream(normalizedStreamName) === false) {
162
+ throw new Error(`Stream "${normalizedStreamName}" does not exist.`);
163
+ }
164
+
165
+ const consumer = eventstore.getConsumer(
166
+ normalizedStreamName,
167
+ normalizedConsumerName,
168
+ initialState,
169
+ since
170
+ );
171
+
172
+ await new Promise((resolve, reject) => {
173
+ consumer.on('error', reject);
174
+ consumer.on('data', (event) => {
175
+ try {
176
+ const execution = executeConsumerLogic(consumerLogic, event, consumer.state, (resolvedState) =>
177
+ consumer.setState(resolvedState)
178
+ );
179
+ if (!execution.calledSetState && execution.nextState !== consumer.state) {
180
+ consumer.setState(execution.nextState);
181
+ }
182
+ } catch (err) {
183
+ reject(err);
184
+ }
185
+ });
186
+ consumer.on('caught-up', () => resolve({ position: consumer.position, state: consumer.state }));
187
+ consumer.start();
188
+ });
189
+
190
+ return {
191
+ consumerIdentifier: `${normalizedStreamName}.${normalizedConsumerName}`,
192
+ streamName: normalizedStreamName,
193
+ consumerName: normalizedConsumerName
194
+ };
195
+ } finally {
196
+ eventstore.close();
197
+ }
198
+ }
@@ -0,0 +1,12 @@
1
+ import { HydratedRouter } from 'react-router/dom';
2
+ import { startTransition, StrictMode } from 'react';
3
+ import { hydrateRoot } from 'react-dom/client';
4
+
5
+ startTransition(() => {
6
+ hydrateRoot(
7
+ document,
8
+ <StrictMode>
9
+ <HydratedRouter />
10
+ </StrictMode>
11
+ );
12
+ });
@@ -0,0 +1,153 @@
1
+ import { PassThrough } from 'node:stream';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createReadableStreamFromReadable } from '@react-router/node';
7
+ import { ServerRouter } from 'react-router';
8
+ import { isbot } from 'isbot';
9
+ import { renderToPipeableStream } from 'react-dom/server';
10
+
11
+ const streamTimeout = 5_000;
12
+ const configPath = path.resolve(
13
+ path.dirname(fileURLToPath(import.meta.url)),
14
+ '../eventstore.config.json'
15
+ );
16
+ let cachedConfig;
17
+
18
+ function readConfig() {
19
+ if (cachedConfig) return cachedConfig;
20
+
21
+ try {
22
+ const rawConfig = fs.readFileSync(configPath).toString();
23
+ cachedConfig = JSON.parse(rawConfig);
24
+ } catch (error) {
25
+ if (error instanceof SyntaxError) {
26
+ console.error('Invalid JSON in eventstore.config.json, using empty configuration.', error);
27
+ } else {
28
+ console.error('Failed to load eventstore.config.json, using empty configuration.', error);
29
+ }
30
+ cachedConfig = {};
31
+ }
32
+
33
+ return cachedConfig;
34
+ }
35
+
36
+ function safeCompare(a, b) {
37
+ const aBuffer = Buffer.from(a);
38
+ const bBuffer = Buffer.from(b);
39
+ if (aBuffer.length !== bBuffer.length) return false;
40
+ return timingSafeEqual(aBuffer, bBuffer);
41
+ }
42
+
43
+ function getBasicAuthCredentials() {
44
+ const basicAuth = readConfig().basicAuth;
45
+ if (!basicAuth || typeof basicAuth !== 'object' || Array.isArray(basicAuth)) return null;
46
+
47
+ const username = typeof basicAuth.username === 'string' ? basicAuth.username : '';
48
+ const password = typeof basicAuth.password === 'string' ? basicAuth.password : '';
49
+ if (!username || !password) return null;
50
+
51
+ return { username, password };
52
+ }
53
+
54
+ function isAuthorized(request, credentials) {
55
+ const authorization = request.headers.get('authorization');
56
+ if (!authorization?.startsWith('Basic ')) return false;
57
+
58
+ let decoded = '';
59
+ try {
60
+ decoded = Buffer.from(authorization.slice(6).trim(), 'base64').toString('utf8');
61
+ } catch {
62
+ return false;
63
+ }
64
+
65
+ const separatorIndex = decoded.indexOf(':');
66
+ if (separatorIndex < 0) return false;
67
+
68
+ const username = decoded.slice(0, separatorIndex);
69
+ const password = decoded.slice(separatorIndex + 1);
70
+
71
+ return (
72
+ safeCompare(username, credentials.username) &&
73
+ safeCompare(password, credentials.password)
74
+ );
75
+ }
76
+
77
+ function unauthorizedResponse() {
78
+ return new Response('Authentication required', {
79
+ status: 401,
80
+ headers: new Headers({
81
+ 'WWW-Authenticate': 'Basic realm="event-storage-ui"',
82
+ 'Content-Type': 'text/plain; charset=utf-8',
83
+ }),
84
+ });
85
+ }
86
+
87
+ export default function handleRequest(
88
+ request,
89
+ responseStatusCode,
90
+ responseHeaders,
91
+ routerContext
92
+ ) {
93
+ const basicAuthCredentials = getBasicAuthCredentials();
94
+ if (basicAuthCredentials && !isAuthorized(request, basicAuthCredentials)) {
95
+ return unauthorizedResponse();
96
+ }
97
+
98
+ if (request.method.toUpperCase() === 'HEAD') {
99
+ return new Response(null, {
100
+ status: responseStatusCode,
101
+ headers: responseHeaders,
102
+ });
103
+ }
104
+
105
+ return new Promise((resolve, reject) => {
106
+ let shellRendered = false;
107
+ const userAgent = request.headers.get('user-agent');
108
+
109
+ const readyOption =
110
+ (userAgent && isbot(userAgent)) || routerContext.isSpaMode
111
+ ? 'onAllReady'
112
+ : 'onShellReady';
113
+
114
+ let timeoutId = setTimeout(() => abort(), streamTimeout + 1000);
115
+
116
+ const { pipe, abort } = renderToPipeableStream(
117
+ <ServerRouter context={routerContext} url={request.url} />,
118
+ {
119
+ [readyOption]() {
120
+ shellRendered = true;
121
+ const body = new PassThrough({
122
+ final(callback) {
123
+ clearTimeout(timeoutId);
124
+ timeoutId = undefined;
125
+ callback();
126
+ },
127
+ });
128
+ const stream = createReadableStreamFromReadable(body);
129
+
130
+ responseHeaders.set('Content-Type', 'text/html');
131
+
132
+ pipe(body);
133
+
134
+ resolve(
135
+ new Response(stream, {
136
+ headers: responseHeaders,
137
+ status: responseStatusCode,
138
+ })
139
+ );
140
+ },
141
+ onShellError(error) {
142
+ reject(error);
143
+ },
144
+ onError(error) {
145
+ responseStatusCode = 500;
146
+ if (shellRendered) {
147
+ console.error(error);
148
+ }
149
+ },
150
+ }
151
+ );
152
+ });
153
+ }
@@ -0,0 +1,24 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ export default function usePagination(length) {
4
+ const [page, setPage] = useState({ start: 0, size: 10 });
5
+ const nextPage = useCallback(
6
+ () => setPage((current) => ({ ...current, start: current.start + current.size })),
7
+ []
8
+ );
9
+ const prevPage = useCallback(
10
+ () =>
11
+ setPage((current) => ({
12
+ ...current,
13
+ start: Math.max(0, current.start - current.size)
14
+ })),
15
+ []
16
+ );
17
+ const setPageSize = useCallback(
18
+ (size) => setPage((current) => ({ ...current, size })),
19
+ []
20
+ );
21
+ const hasNext = page.start + page.size < length;
22
+ const hasPrev = page.start > 0;
23
+ return [page.start, page.start + page.size, nextPage, prevPage, hasNext, hasPrev, setPageSize];
24
+ }
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ export default function useSysinfo() {
4
+ const [sysinfo, setSysinfo] = useState({
5
+ fsSize: null,
6
+ fsStats: null,
7
+ currentLoad: null,
8
+ processLoad: null,
9
+ mem: null,
10
+ networkStats: null,
11
+ history: {
12
+ cpu: [],
13
+ cpus: [],
14
+ mem: [],
15
+ network: []
16
+ }
17
+ });
18
+
19
+ useEffect(() => {
20
+ let interval;
21
+ let stopped = false;
22
+
23
+ const refresh = async () => {
24
+ const response = await fetch('/resources/sysinfo');
25
+ const data = await response.json();
26
+ if (stopped) {
27
+ return;
28
+ }
29
+
30
+ setSysinfo(data);
31
+ };
32
+
33
+ refresh().catch((error) => {
34
+ console.error('Failed to load sysinfo', error);
35
+ });
36
+ interval = setInterval(() => {
37
+ refresh().catch((error) => {
38
+ console.error('Failed to load sysinfo', error);
39
+ });
40
+ }, 5000);
41
+
42
+ return () => {
43
+ stopped = true;
44
+ clearInterval(interval);
45
+ };
46
+ }, []);
47
+
48
+ return sysinfo;
49
+ }