@pablozaiden/webapp 1.1.0 → 1.2.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.
- package/docs/getting-started.md +36 -33
- package/docs/server.md +65 -0
- package/docs/settings.md +9 -0
- package/package.json +2 -1
- package/src/contracts/index.ts +36 -1
- package/src/server/create-web-app-server.ts +4 -1
- package/src/server/framework-endpoints.ts +34 -1
- package/src/server/index.ts +11 -0
- package/src/server/logger.ts +166 -34
- package/src/server/request-schemas.ts +6 -1
- package/src/server/runtime-config.ts +8 -6
- package/src/web/WebAppRoot.tsx +8 -0
- package/src/web/components/index.tsx +12 -0
- package/src/web/index.ts +1 -0
- package/src/web/logger.ts +44 -0
- package/src/web/settings/account-section.tsx +28 -6
- package/src/web/styles.css +26 -0
- package/src/web/webapp-config.tsx +3 -2
package/docs/getting-started.md
CHANGED
|
@@ -4,10 +4,11 @@ Apps are one Bun application: backend routes, websocket, React UI and static ass
|
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
|
|
7
|
+
import { createLogger, createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
|
|
8
8
|
|
|
9
9
|
const items: Array<{ id: string; userId: string; title: string }> = [];
|
|
10
10
|
const itemCreateSchema = z.object({ title: z.string() });
|
|
11
|
+
const log = createLogger("items");
|
|
11
12
|
|
|
12
13
|
const routes = defineRoutes({
|
|
13
14
|
"/api/items": {
|
|
@@ -16,7 +17,11 @@ const routes = defineRoutes({
|
|
|
16
17
|
description: "List or create items.",
|
|
17
18
|
cliPath: "items",
|
|
18
19
|
tags: ["items"],
|
|
19
|
-
GET: (_req, ctx) =>
|
|
20
|
+
GET: (_req, ctx) => {
|
|
21
|
+
const ownedItems = ctx.filterOwned(items);
|
|
22
|
+
log.info("Listed items", { count: ownedItems.length });
|
|
23
|
+
return jsonResponse(ownedItems);
|
|
24
|
+
},
|
|
20
25
|
async POST(req, ctx) {
|
|
21
26
|
const user = ctx.requireUser();
|
|
22
27
|
const body = await parseJson(req, itemCreateSchema);
|
|
@@ -151,44 +156,41 @@ function ThemeAwareRoute() {
|
|
|
151
156
|
resolved value. Do not inspect or mutate framework-owned root classes or
|
|
152
157
|
attributes to infer theme state.
|
|
153
158
|
|
|
154
|
-
### Client log-level state
|
|
159
|
+
### Client logging and log-level state
|
|
155
160
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
configuration or `/api/preferences/log-level` in an app-local initializer:
|
|
161
|
+
Use the shared browser `tslog` service from `@pablozaiden/webapp/web`; do not
|
|
162
|
+
create an application-owned logger or write application logs directly with
|
|
163
|
+
`console.*`:
|
|
160
164
|
|
|
161
165
|
```tsx
|
|
162
|
-
import {
|
|
163
|
-
import { useLogLevel } from "@pablozaiden/webapp/web";
|
|
164
|
-
import type { LogLevelName } from "@pablozaiden/webapp/contracts";
|
|
165
|
-
|
|
166
|
-
function LoggerAdapter({ setLevel }: { setLevel: (level: LogLevelName) => void }) {
|
|
167
|
-
const { level, fromEnv, loading, error, retry } = useLogLevel();
|
|
168
|
-
|
|
169
|
-
useEffect(() => {
|
|
170
|
-
if (level !== undefined) {
|
|
171
|
-
setLevel(level);
|
|
172
|
-
}
|
|
173
|
-
}, [level, setLevel]);
|
|
174
|
-
|
|
175
|
-
if (error) {
|
|
176
|
-
return <button type="button" onClick={() => void retry()}>Retry log-level configuration</button>;
|
|
177
|
-
}
|
|
178
|
-
if (loading || level === undefined) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
166
|
+
import { createLogger } from "@pablozaiden/webapp/web";
|
|
181
167
|
|
|
182
|
-
|
|
183
|
-
}
|
|
168
|
+
const projectsLog = createLogger("projects");
|
|
169
|
+
projectsLog.debug("Loaded projects", { count: 12 });
|
|
184
170
|
```
|
|
185
171
|
|
|
172
|
+
`WebAppRoot` synchronizes the client logger with the effective level from its
|
|
173
|
+
shared configuration request. The seven levels are `silly`, `trace`, `debug`,
|
|
174
|
+
`info`, `warn`, `error`, and `fatal`; the setting is shared with server
|
|
175
|
+
logging. Use `useLogLevel()` only when UI code needs to display the current
|
|
176
|
+
level or its environment-controlled state. Applications should not fetch
|
|
177
|
+
framework configuration or `/api/preferences/log-level` to configure logging.
|
|
178
|
+
|
|
186
179
|
`level` and `fromEnv` are unavailable until valid framework configuration has
|
|
187
180
|
loaded. Do not substitute a default level when `error` is set. The framework
|
|
188
|
-
Settings control owns persistence and updates the shared
|
|
189
|
-
reload; `fromEnv` indicates that the server
|
|
190
|
-
|
|
191
|
-
|
|
181
|
+
Settings control owns persistence and updates the shared client and server
|
|
182
|
+
logger levels without a page reload; `fromEnv` indicates that the server
|
|
183
|
+
environment locks the value.
|
|
184
|
+
|
|
185
|
+
### In-memory server logs
|
|
186
|
+
|
|
187
|
+
Admins can enable temporary server-log capture from Developer Settings next to
|
|
188
|
+
the log-level selector. The shared WebApp server logger exposes the current state in
|
|
189
|
+
`/api/config`; it starts disabled on every process start and is never stored in
|
|
190
|
+
the database. Admin clients can retrieve a bounded chronological snapshot from
|
|
191
|
+
`GET /api/server/logs` after enabling capture. The buffer is limited to the
|
|
192
|
+
newest 1,000 framework logger entries or 512 KiB of rendered log lines, and
|
|
193
|
+
turning the setting off clears it.
|
|
192
194
|
|
|
193
195
|
### Transient notifications
|
|
194
196
|
|
|
@@ -283,7 +285,8 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
|
|
|
283
285
|
| `{PREFIX}_HOST` | `localhost` | Bind host |
|
|
284
286
|
| `{PREFIX}_PORT` | `3000` | Bind port |
|
|
285
287
|
| `{PREFIX}_DATA_DIR` | `./data` | Durable SQLite persistence directory for framework auth and app data |
|
|
286
|
-
| `{PREFIX}_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`; locks settings log-level control when set |
|
|
288
|
+
| `{PREFIX}_LOG_LEVEL` | `info` | `silly`, `trace`, `debug`, `info`, `warn`, `error`, `fatal`; locks settings log-level control when set |
|
|
289
|
+
| `{PREFIX}_IN_MEMORY_LOGS` | `false` | Initial value for process-local server-log capture; `true`, `1`, or `yes` enables it |
|
|
287
290
|
| `{PREFIX}_DISABLE_PASSKEY` | unset | Emergency bypass that logs in as the existing owner; it does not create users |
|
|
288
291
|
| `{PREFIX}_DISABLE_SAME_ORIGIN_CHECK` | unset | Development/testing escape hatch |
|
|
289
292
|
| `{PREFIX}_TRUST_PROXY` | `false` | Explicitly trust the documented `X-Forwarded-*` headers; keep disabled for direct deployments |
|
package/docs/server.md
CHANGED
|
@@ -138,6 +138,36 @@ the constructor inputs. Do not mutate `app.config` after construction to
|
|
|
138
138
|
replace constructor options; omit `runtimeConfig` when the framework should read
|
|
139
139
|
the environment itself.
|
|
140
140
|
|
|
141
|
+
## Server logging
|
|
142
|
+
|
|
143
|
+
WebApp exposes one server-side `tslog` service for the framework and
|
|
144
|
+
application code. Use the root logger or a cached sub-logger from
|
|
145
|
+
`@pablozaiden/webapp/server`; do not add a second server logger in the app:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { createLogger, log } from "@pablozaiden/webapp/server";
|
|
149
|
+
|
|
150
|
+
const projectsLog = createLogger("projects");
|
|
151
|
+
|
|
152
|
+
log.info("Application initialized");
|
|
153
|
+
projectsLog.debug("Loaded projects", { count: 12 });
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The available levels, from most to least verbose, are `silly`, `trace`,
|
|
157
|
+
`debug`, `info`, `warn`, `error`, and `fatal`. The effective level is shared by
|
|
158
|
+
the root logger and all cached sub-loggers. It is selected from
|
|
159
|
+
`{PREFIX}_LOG_LEVEL`, a persisted administrator preference, or `info` by
|
|
160
|
+
default, in that order.
|
|
161
|
+
|
|
162
|
+
Every emitted entry is written through `tslog` to the appropriate stdout or
|
|
163
|
+
stderr stream. When in-memory capture is enabled, the same rendered entries
|
|
164
|
+
from both framework and application loggers are also retained in the
|
|
165
|
+
process-local buffer. Capture is disabled by default and can be initialized
|
|
166
|
+
with `{PREFIX}_IN_MEMORY_LOGS=true`; an administrator can then change the
|
|
167
|
+
runtime toggle from Developer Settings. The environment value is an initial
|
|
168
|
+
value, not a lock, and capture is cleared when disabled or when the process
|
|
169
|
+
starts.
|
|
170
|
+
|
|
141
171
|
Built-in endpoints include:
|
|
142
172
|
|
|
143
173
|
| Endpoint | Purpose |
|
|
@@ -153,6 +183,8 @@ Built-in endpoints include:
|
|
|
153
183
|
| `/device` | Browser device-code approval screen |
|
|
154
184
|
| `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
|
|
155
185
|
| `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
|
|
186
|
+
| `GET /api/server/logs` | Admin-only snapshot of the in-memory server logs |
|
|
187
|
+
| `PUT /api/server/logs/settings` | Admin-only runtime toggle for in-memory server logs |
|
|
156
188
|
| `/api/server/kill` | Authenticated server shutdown |
|
|
157
189
|
| `/api/ws` | Realtime websocket by default |
|
|
158
190
|
|
|
@@ -162,6 +194,39 @@ The effective log level in `GET /api/config` and `GET
|
|
|
162
194
|
to `true`. PUT remains an authenticated, same-origin admin mutation and
|
|
163
195
|
returns a conflict when the environment controls the value.
|
|
164
196
|
|
|
197
|
+
`GET /api/server/logs` is restricted to admin users and returns
|
|
198
|
+
`{ enabled: boolean, logs: ServerLogEntry[] }`. Entries are returned in
|
|
199
|
+
chronological order and contain the timestamp, level, logger scope, message,
|
|
200
|
+
and the rendered `tslog` line. When capture is disabled, the endpoint returns
|
|
201
|
+
`200` with `enabled: false` and an empty list. `PUT /api/server/logs/settings`
|
|
202
|
+
accepts `{ "enabled": boolean }`, is restricted to admins, and uses the normal
|
|
203
|
+
same-origin policy for browser mutations. API-key and bearer callers continue
|
|
204
|
+
to use the existing token-auth behavior. The in-memory buffer is limited to
|
|
205
|
+
the newest 1,000 entries and 512 KiB of rendered UTF-8 lines; disabling capture
|
|
206
|
+
clears it. These endpoints expose entries emitted through the shared WebApp
|
|
207
|
+
logger and never make the logs durable.
|
|
208
|
+
|
|
209
|
+
## Client logging
|
|
210
|
+
|
|
211
|
+
WebApp also exposes a browser-side `tslog` service through
|
|
212
|
+
`@pablozaiden/webapp/web`. Use it for application code instead of adding a
|
|
213
|
+
second logger or writing application logs directly with `console.*`:
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { createLogger, log } from "@pablozaiden/webapp/web";
|
|
217
|
+
|
|
218
|
+
const projectsLog = createLogger("projects");
|
|
219
|
+
|
|
220
|
+
log.info("Application UI initialized");
|
|
221
|
+
projectsLog.debug("Loaded projects", { count: 12 });
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The client service provides the same seven levels as the server service and
|
|
225
|
+
writes to the browser console. `WebAppRoot` automatically synchronizes its
|
|
226
|
+
effective level with the shared framework configuration, including changes
|
|
227
|
+
made from Developer Settings. Client logs are browser-local and are not
|
|
228
|
+
included in the server's `/api/server/logs` snapshot.
|
|
229
|
+
|
|
165
230
|
Server-side applications that need credentials for internal runtimes should use
|
|
166
231
|
the server-only `createManagedApiKey`, `listManagedApiKeys`, and
|
|
167
232
|
`revokeManagedApiKey` helpers. Managed keys are persisted in the same API-key
|
package/docs/settings.md
CHANGED
|
@@ -8,6 +8,8 @@ Settings is framework-owned so apps stay consistent. It includes:
|
|
|
8
8
|
- Theme preference: system/light/dark, stored per user
|
|
9
9
|
- Admin user management
|
|
10
10
|
- Admin log-level preference unless `{PREFIX}_LOG_LEVEL` is set
|
|
11
|
+
- Admin in-memory server-log capture toggle; disabled and cleared on process
|
|
12
|
+
start or when turned off; `{PREFIX}_IN_MEMORY_LOGS` can enable it initially
|
|
11
13
|
- Admin server kill
|
|
12
14
|
- Version/about
|
|
13
15
|
|
|
@@ -33,6 +35,13 @@ the shared hook state after a successful save, and `fromEnv` is true when
|
|
|
33
35
|
`{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
|
|
34
36
|
configuration fetch or initializer component.
|
|
35
37
|
|
|
38
|
+
Developer Settings also includes **Store server logs in memory**. It is an
|
|
39
|
+
admin-only, process-local setting and is disabled when the server starts unless
|
|
40
|
+
`{PREFIX}_IN_MEMORY_LOGS` is set to `true`, `1`, or `yes`. When enabled, the
|
|
41
|
+
shared `tslog` service retains only the newest 1,000 eligible entries within a
|
|
42
|
+
512 KiB rendered-line limit; disabling it immediately clears the buffer. The
|
|
43
|
+
setting and captured logs are never stored in SQLite.
|
|
44
|
+
|
|
36
45
|
Security lists only show useful active user credentials. Managed API keys created by
|
|
37
46
|
server-side applications, and their metadata, are not returned by the browser API-key
|
|
38
47
|
list or rendered in Settings. Expired API keys are purged before listing, and revoked
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablozaiden/webapp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"bun-plugin-tailwind": "0.1.2",
|
|
50
50
|
"jose": "6.2.3",
|
|
51
51
|
"tailwindcss": "4.3.1",
|
|
52
|
+
"tslog": "4.10.2",
|
|
52
53
|
"zod": "4.4.3"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
package/src/contracts/index.ts
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const VALID_LOG_LEVELS = ["silly", "trace", "debug", "info", "warn", "error", "fatal"] as const;
|
|
2
|
+
|
|
3
|
+
export type LogLevelName = typeof VALID_LOG_LEVELS[number];
|
|
4
|
+
|
|
5
|
+
export const LOG_LEVELS: Record<LogLevelName, number> = {
|
|
6
|
+
silly: 0,
|
|
7
|
+
trace: 1,
|
|
8
|
+
debug: 2,
|
|
9
|
+
info: 3,
|
|
10
|
+
warn: 4,
|
|
11
|
+
error: 5,
|
|
12
|
+
fatal: 6,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const LOG_LEVEL_NAMES: Record<number, LogLevelName> = {
|
|
16
|
+
0: "silly",
|
|
17
|
+
1: "trace",
|
|
18
|
+
2: "debug",
|
|
19
|
+
3: "info",
|
|
20
|
+
4: "warn",
|
|
21
|
+
5: "error",
|
|
22
|
+
6: "fatal",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_LOG_LEVEL: LogLevelName = "info";
|
|
2
26
|
|
|
3
27
|
export type ThemePreference = "system" | "light" | "dark";
|
|
4
28
|
|
|
@@ -114,6 +138,14 @@ export interface AuthSessionSummary {
|
|
|
114
138
|
active: boolean;
|
|
115
139
|
}
|
|
116
140
|
|
|
141
|
+
export interface ServerLogEntry {
|
|
142
|
+
timestamp: string;
|
|
143
|
+
level: LogLevelName;
|
|
144
|
+
scope: string;
|
|
145
|
+
message: string;
|
|
146
|
+
line: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
117
149
|
export interface WebAppConfigResponse {
|
|
118
150
|
appName: string;
|
|
119
151
|
version: string;
|
|
@@ -127,6 +159,9 @@ export interface WebAppConfigResponse {
|
|
|
127
159
|
level: LogLevelName;
|
|
128
160
|
fromEnv: boolean;
|
|
129
161
|
};
|
|
162
|
+
inMemoryLogs: {
|
|
163
|
+
enabled: boolean;
|
|
164
|
+
};
|
|
130
165
|
deviceAuth: {
|
|
131
166
|
enabled: boolean;
|
|
132
167
|
};
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { createAuthentication } from "./authentication";
|
|
12
12
|
import { createFrameworkEndpointHandler } from "./framework-endpoints";
|
|
13
13
|
import { createServerLifecycle } from "./server-lifecycle";
|
|
14
|
-
import { setLogLevel } from "./logger";
|
|
14
|
+
import { inMemoryLogStorage, resetInMemoryLogStorage, setInMemoryLogStorageEnabled, setLogLevel } from "./logger";
|
|
15
15
|
import { createWebDocumentProvider, htmlResponse } from "./web-document";
|
|
16
16
|
import { createPublicRouteDispatcher } from "./public-route-dispatch";
|
|
17
17
|
import { createRouteDispatcher } from "./route-dispatch";
|
|
@@ -45,6 +45,8 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
45
45
|
if (config.appName !== input.appName || config.envPrefix !== input.envPrefix) {
|
|
46
46
|
throw new Error("runtimeConfig appName and envPrefix must match the createWebAppServer inputs");
|
|
47
47
|
}
|
|
48
|
+
resetInMemoryLogStorage();
|
|
49
|
+
setInMemoryLogStorageEnabled(config.inMemoryLogsEnabled);
|
|
48
50
|
const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
|
|
49
51
|
store.initialize();
|
|
50
52
|
const savedLogLevel = store.getLogLevelPreference();
|
|
@@ -80,6 +82,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
80
82
|
deviceAuthEnabled,
|
|
81
83
|
configResponse: input.configResponse,
|
|
82
84
|
onLogLevelChange: input.logLevel?.onChange,
|
|
85
|
+
inMemoryLogs: inMemoryLogStorage,
|
|
83
86
|
ensureWebDocument,
|
|
84
87
|
});
|
|
85
88
|
const publicRouteDispatcher = createPublicRouteDispatcher({
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
createUserRequestSchema,
|
|
49
49
|
deviceAuthorizationRequestSchema,
|
|
50
50
|
deviceCodeActionRequestSchema,
|
|
51
|
+
inMemoryLogSettingsRequestSchema,
|
|
51
52
|
logLevelPreferenceRequestSchema,
|
|
52
53
|
passkeyBootstrapOptionsSchema,
|
|
53
54
|
refreshTokenRequestSchema,
|
|
@@ -61,7 +62,8 @@ import {
|
|
|
61
62
|
} from "./request-schemas";
|
|
62
63
|
import { resolveEffectiveLogLevel, type RuntimeConfig } from "./runtime-config";
|
|
63
64
|
import { setLogLevel } from "./logger";
|
|
64
|
-
import {
|
|
65
|
+
import type { InMemoryLogStorage } from "./logger";
|
|
66
|
+
import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, successResponse } from "./responses";
|
|
65
67
|
import type { WebSocketData } from "./realtime/bus";
|
|
66
68
|
|
|
67
69
|
export interface FrameworkEndpointDependencies {
|
|
@@ -75,6 +77,7 @@ export interface FrameworkEndpointDependencies {
|
|
|
75
77
|
deviceAuthEnabled: boolean;
|
|
76
78
|
configResponse?: WebAppServerConfig["configResponse"];
|
|
77
79
|
onLogLevelChange?: (level: LogLevelName) => void;
|
|
80
|
+
inMemoryLogs: InMemoryLogStorage;
|
|
78
81
|
ensureWebDocument: () => Promise<WebDocument>;
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -97,6 +100,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
97
100
|
deviceAuthEnabled,
|
|
98
101
|
configResponse: extendConfigResponse,
|
|
99
102
|
onLogLevelChange,
|
|
103
|
+
inMemoryLogs,
|
|
100
104
|
ensureWebDocument,
|
|
101
105
|
} = dependencies;
|
|
102
106
|
|
|
@@ -115,6 +119,9 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
115
119
|
level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
|
|
116
120
|
fromEnv: config.logLevelFromEnv,
|
|
117
121
|
},
|
|
122
|
+
inMemoryLogs: {
|
|
123
|
+
enabled: inMemoryLogs.isEnabled(),
|
|
124
|
+
},
|
|
118
125
|
apiKeys: { enabled: Boolean(apiKeysEnabled) },
|
|
119
126
|
deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
|
|
120
127
|
} satisfies WebAppConfigResponse;
|
|
@@ -433,6 +440,32 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
433
440
|
return successResponse({ level: body.level });
|
|
434
441
|
}
|
|
435
442
|
}
|
|
443
|
+
if (path === "/api/server/logs") {
|
|
444
|
+
const auth = await authentication.authorize(req, true);
|
|
445
|
+
if (auth instanceof Response) return auth;
|
|
446
|
+
ensureAdmin(auth);
|
|
447
|
+
if (req.method !== "GET") {
|
|
448
|
+
return methodNotAllowed();
|
|
449
|
+
}
|
|
450
|
+
const enabled = inMemoryLogs.isEnabled();
|
|
451
|
+
return jsonResponse({
|
|
452
|
+
enabled,
|
|
453
|
+
logs: enabled ? inMemoryLogs.getEntries() : [],
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (path === "/api/server/logs/settings") {
|
|
457
|
+
const auth = await authentication.authorize(req, true);
|
|
458
|
+
if (auth instanceof Response) return auth;
|
|
459
|
+
ensureAdmin(auth);
|
|
460
|
+
if (req.method !== "PUT") {
|
|
461
|
+
return methodNotAllowed();
|
|
462
|
+
}
|
|
463
|
+
const originFailure = checkSameOrigin(req, config, auth, "mutations");
|
|
464
|
+
if (originFailure) return originFailure;
|
|
465
|
+
const body = await parseJson(req, inMemoryLogSettingsRequestSchema);
|
|
466
|
+
inMemoryLogs.setEnabled(body.enabled);
|
|
467
|
+
return successResponse({ enabled: inMemoryLogs.isEnabled() });
|
|
468
|
+
}
|
|
436
469
|
if (path === "/api/server/kill" && req.method === "POST") {
|
|
437
470
|
const auth = await authentication.authorize(req, true);
|
|
438
471
|
if (auth instanceof Response) return auth;
|
package/src/server/index.ts
CHANGED
|
@@ -4,6 +4,17 @@ export * from "./route-catalog";
|
|
|
4
4
|
export * from "./responses";
|
|
5
5
|
export * from "./public-assets";
|
|
6
6
|
export * from "./create-web-app-server";
|
|
7
|
+
export {
|
|
8
|
+
DEFAULT_LOG_LEVEL,
|
|
9
|
+
VALID_LOG_LEVELS,
|
|
10
|
+
type LogLevelName,
|
|
11
|
+
} from "../contracts";
|
|
12
|
+
export {
|
|
13
|
+
createLogger,
|
|
14
|
+
getLogLevel,
|
|
15
|
+
log,
|
|
16
|
+
setLogLevel,
|
|
17
|
+
} from "./logger";
|
|
7
18
|
export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
|
|
8
19
|
export * from "./auth/api-keys";
|
|
9
20
|
export * from "./auth/store";
|
package/src/server/logger.ts
CHANGED
|
@@ -1,44 +1,176 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { inspect } from "node:util";
|
|
2
|
+
import { Logger, type ILogObj, type IMeta } from "tslog";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LOG_LEVEL,
|
|
5
|
+
LOG_LEVEL_NAMES,
|
|
6
|
+
LOG_LEVELS,
|
|
7
|
+
VALID_LOG_LEVELS,
|
|
8
|
+
type LogLevelName,
|
|
9
|
+
type ServerLogEntry,
|
|
10
|
+
} from "../contracts";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
export const MAX_IN_MEMORY_LOG_ENTRIES = 1_000;
|
|
13
|
+
export const MAX_IN_MEMORY_LOG_BYTES = 512 * 1024;
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
let inMemoryLogStorageEnabled = false;
|
|
16
|
+
let inMemoryLogEntries: ServerLogEntry[] = [];
|
|
17
|
+
let inMemoryLogBytes = 0;
|
|
18
|
+
const textEncoder = new TextEncoder();
|
|
19
|
+
const subLoggers = new Map<string, Logger<ILogObj>>();
|
|
20
|
+
|
|
21
|
+
export interface InMemoryLogStorage {
|
|
22
|
+
isEnabled(): boolean;
|
|
23
|
+
setEnabled(enabled: boolean): void;
|
|
24
|
+
getEntries(): ServerLogEntry[];
|
|
25
|
+
reset(): void;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
return
|
|
28
|
+
function isLogLevelName(value: unknown): value is LogLevelName {
|
|
29
|
+
return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lineByteLength(line: string): number {
|
|
33
|
+
return textEncoder.encode(line).byteLength;
|
|
19
34
|
}
|
|
20
35
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
36
|
+
function appendInMemoryLogEntry(entry: ServerLogEntry): void {
|
|
37
|
+
if (!inMemoryLogStorageEnabled) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const entryBytes = lineByteLength(entry.line);
|
|
41
|
+
if (entryBytes > MAX_IN_MEMORY_LOG_BYTES) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
inMemoryLogEntries.push(entry);
|
|
45
|
+
inMemoryLogBytes += entryBytes;
|
|
46
|
+
while (inMemoryLogEntries.length > MAX_IN_MEMORY_LOG_ENTRIES || inMemoryLogBytes > MAX_IN_MEMORY_LOG_BYTES) {
|
|
47
|
+
const removed = inMemoryLogEntries.shift();
|
|
48
|
+
if (!removed) {
|
|
49
|
+
break;
|
|
25
50
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
inMemoryLogBytes -= lineByteLength(removed.line);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatValue(value: unknown): string {
|
|
56
|
+
if (value === undefined) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
if (typeof value === "string") {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
if (value instanceof Error) {
|
|
63
|
+
return `${value.name}: ${value.message}`;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === "object" && value !== null && "message" in value) {
|
|
66
|
+
const record = value as { message?: unknown; name?: unknown };
|
|
67
|
+
if (typeof record.message === "string") {
|
|
68
|
+
return typeof record.name === "string" ? `${record.name}: ${record.message}` : record.message;
|
|
35
69
|
}
|
|
36
70
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(value) ?? String(value);
|
|
73
|
+
} catch {
|
|
74
|
+
return String(value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function appendTslogEntry(metadata: IMeta, line: string, logArgs: unknown[], logErrors: string[]): void {
|
|
79
|
+
const level = LOG_LEVEL_NAMES[metadata.logLevelId] ?? (metadata.logLevelName.toLowerCase() as LogLevelName);
|
|
80
|
+
if (!isLogLevelName(level)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const timestamp = metadata.date.toISOString();
|
|
84
|
+
const scope = typeof metadata.name === "string" && metadata.name ? metadata.name : "webapp";
|
|
85
|
+
const message = formatValue(logArgs[0] ?? logErrors[0] ?? "");
|
|
86
|
+
appendInMemoryLogEntry({
|
|
87
|
+
timestamp,
|
|
88
|
+
level,
|
|
89
|
+
scope,
|
|
90
|
+
message,
|
|
91
|
+
line,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderConsoleValue(value: unknown): string {
|
|
96
|
+
return typeof value === "string" ? value : inspect(value, { colors: false, compact: true, depth: Infinity });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function transportFormatted(logMetaMarkup: string, logArgs: unknown[], logErrors: string[], logMeta?: IMeta): void {
|
|
100
|
+
const errors = (logErrors.length > 0 && logArgs.length > 0 ? "\n" : "") + logErrors.join("\n");
|
|
101
|
+
const line = `${logMetaMarkup}${logArgs.map(renderConsoleValue).join(" ")}${errors}`;
|
|
102
|
+
const level = LOG_LEVEL_NAMES[logMeta?.logLevelId ?? LOG_LEVELS.info] ?? DEFAULT_LOG_LEVEL;
|
|
103
|
+
if (level === "fatal" || level === "error") {
|
|
104
|
+
console.error(line);
|
|
105
|
+
} else if (level === "warn") {
|
|
106
|
+
console.warn(line);
|
|
107
|
+
} else {
|
|
108
|
+
console.log(line);
|
|
109
|
+
}
|
|
110
|
+
if (logMeta) {
|
|
111
|
+
appendTslogEntry(logMeta, line, logArgs, logErrors);
|
|
112
|
+
}
|
|
44
113
|
}
|
|
114
|
+
|
|
115
|
+
export const log = new Logger<ILogObj>({
|
|
116
|
+
name: "webapp",
|
|
117
|
+
minLevel: LOG_LEVELS[DEFAULT_LOG_LEVEL],
|
|
118
|
+
prettyLogTimeZone: "UTC",
|
|
119
|
+
stylePrettyLogs: false,
|
|
120
|
+
prettyInspectOptions: { colors: false, compact: true, depth: Infinity },
|
|
121
|
+
overwrite: { transportFormatted },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export function createLogger(scope: string): Logger<ILogObj> {
|
|
125
|
+
const existing = subLoggers.get(scope);
|
|
126
|
+
if (existing) {
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
const logger = log.getSubLogger({ name: scope });
|
|
130
|
+
subLoggers.set(scope, logger);
|
|
131
|
+
return logger;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function setLogLevel(level: LogLevelName): void {
|
|
135
|
+
if (!isLogLevelName(level)) {
|
|
136
|
+
throw new Error(`Invalid log level: ${String(level)}`);
|
|
137
|
+
}
|
|
138
|
+
const numericLevel = LOG_LEVELS[level];
|
|
139
|
+
log.settings.minLevel = numericLevel;
|
|
140
|
+
for (const subLogger of subLoggers.values()) {
|
|
141
|
+
subLogger.settings.minLevel = numericLevel;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getLogLevel(): LogLevelName {
|
|
146
|
+
return LOG_LEVEL_NAMES[log.settings.minLevel] ?? DEFAULT_LOG_LEVEL;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function setInMemoryLogStorageEnabled(enabled: boolean): void {
|
|
150
|
+
inMemoryLogStorageEnabled = enabled;
|
|
151
|
+
if (!enabled) {
|
|
152
|
+
inMemoryLogEntries = [];
|
|
153
|
+
inMemoryLogBytes = 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isInMemoryLogStorageEnabled(): boolean {
|
|
158
|
+
return inMemoryLogStorageEnabled;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function getInMemoryLogEntries(): ServerLogEntry[] {
|
|
162
|
+
return inMemoryLogEntries.map((entry) => ({ ...entry }));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function resetInMemoryLogStorage(): void {
|
|
166
|
+
inMemoryLogStorageEnabled = false;
|
|
167
|
+
inMemoryLogEntries = [];
|
|
168
|
+
inMemoryLogBytes = 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const inMemoryLogStorage: InMemoryLogStorage = {
|
|
172
|
+
isEnabled: isInMemoryLogStorageEnabled,
|
|
173
|
+
setEnabled: setInMemoryLogStorageEnabled,
|
|
174
|
+
getEntries: getInMemoryLogEntries,
|
|
175
|
+
reset: resetInMemoryLogStorage,
|
|
176
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { VALID_LOG_LEVELS } from "../contracts";
|
|
2
3
|
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
|
|
3
4
|
|
|
4
5
|
const nonEmptyString = z.string().min(1);
|
|
@@ -140,5 +141,9 @@ export const themePreferenceRequestSchema = z.object({
|
|
|
140
141
|
});
|
|
141
142
|
|
|
142
143
|
export const logLevelPreferenceRequestSchema = z.object({
|
|
143
|
-
level: z.enum(
|
|
144
|
+
level: z.enum(VALID_LOG_LEVELS),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const inMemoryLogSettingsRequestSchema = z.object({
|
|
148
|
+
enabled: z.boolean(),
|
|
144
149
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { DEFAULT_LOG_LEVEL, VALID_LOG_LEVELS, type LogLevelName } from "../contracts";
|
|
2
2
|
|
|
3
3
|
export const TRUST_PROXY_HEADERS = ["proto", "host", "prefix"] as const;
|
|
4
4
|
export type TrustProxyHeader = typeof TRUST_PROXY_HEADERS[number];
|
|
@@ -18,6 +18,7 @@ export interface RuntimeConfig {
|
|
|
18
18
|
dataDir: string;
|
|
19
19
|
logLevel: LogLevelName;
|
|
20
20
|
logLevelFromEnv: boolean;
|
|
21
|
+
inMemoryLogsEnabled: boolean;
|
|
21
22
|
passkeyDisabled: boolean;
|
|
22
23
|
sameOriginDisabled: boolean;
|
|
23
24
|
publicBaseUrl?: string;
|
|
@@ -26,10 +27,8 @@ export interface RuntimeConfig {
|
|
|
26
27
|
development: false | { hmr: true; console: true };
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
|
|
30
|
-
|
|
31
30
|
function isSupportedLogLevel(value: unknown): value is LogLevelName {
|
|
32
|
-
return typeof value === "string" &&
|
|
31
|
+
return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
export function resolveEffectiveLogLevel(
|
|
@@ -80,7 +79,7 @@ function parseLogLevel(raw: string | undefined, fallback: LogLevelName, name: st
|
|
|
80
79
|
return fallback;
|
|
81
80
|
}
|
|
82
81
|
if (!isSupportedLogLevel(raw)) {
|
|
83
|
-
throw new Error(`${name} must be one of
|
|
82
|
+
throw new Error(`${name} must be one of ${VALID_LOG_LEVELS.join(", ")}; received "${raw}"`);
|
|
84
83
|
}
|
|
85
84
|
return raw;
|
|
86
85
|
}
|
|
@@ -160,7 +159,8 @@ export function readRuntimeConfig(input: {
|
|
|
160
159
|
}): RuntimeConfig {
|
|
161
160
|
const envPrefix = assertEnvPrefix(input.envPrefix);
|
|
162
161
|
const logLevelRaw = readEnv(envPrefix, "LOG_LEVEL");
|
|
163
|
-
const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ??
|
|
162
|
+
const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ?? DEFAULT_LOG_LEVEL, envName(envPrefix, "LOG_LEVEL"));
|
|
163
|
+
const inMemoryLogsEnabled = parseBoolean(readEnv(envPrefix, "IN_MEMORY_LOGS"), false, envName(envPrefix, "IN_MEMORY_LOGS"));
|
|
164
164
|
const publicBaseUrl = parsePublicBaseUrl(readEnv(envPrefix, "PUBLIC_BASE_URL"), envName(envPrefix, "PUBLIC_BASE_URL"));
|
|
165
165
|
const trustProxyEnabled = parseBoolean(readEnv(envPrefix, "TRUST_PROXY"), false, envName(envPrefix, "TRUST_PROXY"));
|
|
166
166
|
const trustProxy = {
|
|
@@ -176,6 +176,7 @@ export function readRuntimeConfig(input: {
|
|
|
176
176
|
dataDir: readEnv(envPrefix, "DATA_DIR") || "./data",
|
|
177
177
|
logLevel,
|
|
178
178
|
logLevelFromEnv: Boolean(logLevelRaw),
|
|
179
|
+
inMemoryLogsEnabled,
|
|
179
180
|
passkeyDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_PASSKEY")),
|
|
180
181
|
sameOriginDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_SAME_ORIGIN_CHECK")),
|
|
181
182
|
publicBaseUrl,
|
|
@@ -194,6 +195,7 @@ export function safeRuntimeConfig(config: RuntimeConfig): Record<string, unknown
|
|
|
194
195
|
dataDir: config.dataDir,
|
|
195
196
|
logLevel: config.logLevel,
|
|
196
197
|
logLevelFromEnv: config.logLevelFromEnv,
|
|
198
|
+
inMemoryLogsEnabled: config.inMemoryLogsEnabled,
|
|
197
199
|
passkeyDisabled: config.passkeyDisabled,
|
|
198
200
|
sameOriginDisabled: config.sameOriginDisabled,
|
|
199
201
|
publicBaseUrl: config.publicBaseUrl,
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import type { HeaderContext, WebAppRootProps } from "./root-types";
|
|
|
11
11
|
import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
12
12
|
import { ThemeProvider } from "./theme";
|
|
13
13
|
import { WebAppConfigProvider, useWebAppConfig } from "./webapp-config";
|
|
14
|
+
import { setLogLevel } from "./logger";
|
|
14
15
|
|
|
15
16
|
export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
|
|
16
17
|
export type {
|
|
@@ -195,6 +196,13 @@ function WebAppRootContent({
|
|
|
195
196
|
|
|
196
197
|
function WebAppRootWithConfig(props: WebAppRootProps) {
|
|
197
198
|
const { config, error, refresh } = useWebAppConfig();
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (config) {
|
|
202
|
+
setLogLevel(config.logLevel.level);
|
|
203
|
+
}
|
|
204
|
+
}, [config?.logLevel.level]);
|
|
205
|
+
|
|
198
206
|
return (
|
|
199
207
|
<ThemeProvider userId={config?.currentUser?.id}>
|
|
200
208
|
<WebAppRootContent {...props} config={config} error={error} refresh={refresh} />
|
|
@@ -252,6 +252,18 @@ export function SelectField({ label, children, ...props }: SelectHTMLAttributes<
|
|
|
252
252
|
);
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
export function CheckboxField({ label, hint, ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; hint?: string }) {
|
|
256
|
+
return (
|
|
257
|
+
<label className="wapp-checkbox-field">
|
|
258
|
+
<input {...props} type="checkbox" />
|
|
259
|
+
<span>
|
|
260
|
+
{label}
|
|
261
|
+
{hint ? <small>{hint}</small> : null}
|
|
262
|
+
</span>
|
|
263
|
+
</label>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
255
267
|
export function SegmentedControl<T extends string>({ value, options, onChange, label }: { value: T; options: Array<{ value: T; label: string }>; onChange: (value: T) => void; label: string }) {
|
|
256
268
|
return (
|
|
257
269
|
<div className="wapp-segmented" role="group" aria-label={label}>
|
package/src/web/index.ts
CHANGED
|
@@ -11,5 +11,6 @@ export { useTheme } from "./theme";
|
|
|
11
11
|
export type { ResolvedTheme, WebAppThemeState } from "./theme";
|
|
12
12
|
export { useLogLevel } from "./log-level";
|
|
13
13
|
export type { WebAppLogLevelState } from "./log-level";
|
|
14
|
+
export { createLogger, getLogLevel, log } from "./logger";
|
|
14
15
|
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
16
|
export type { ThemePreference } from "../contracts";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Logger, type ILogObj } from "tslog";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_LOG_LEVEL,
|
|
4
|
+
LOG_LEVEL_NAMES,
|
|
5
|
+
LOG_LEVELS,
|
|
6
|
+
VALID_LOG_LEVELS,
|
|
7
|
+
type LogLevelName,
|
|
8
|
+
} from "../contracts";
|
|
9
|
+
|
|
10
|
+
const subLoggers = new Map<string, Logger<ILogObj>>();
|
|
11
|
+
|
|
12
|
+
function isLogLevelName(value: unknown): value is LogLevelName {
|
|
13
|
+
return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const log = new Logger<ILogObj>({
|
|
17
|
+
name: "webapp",
|
|
18
|
+
minLevel: LOG_LEVELS[DEFAULT_LOG_LEVEL],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function createLogger(scope: string): Logger<ILogObj> {
|
|
22
|
+
const existing = subLoggers.get(scope);
|
|
23
|
+
if (existing) {
|
|
24
|
+
return existing;
|
|
25
|
+
}
|
|
26
|
+
const logger = log.getSubLogger({ name: scope });
|
|
27
|
+
subLoggers.set(scope, logger);
|
|
28
|
+
return logger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setLogLevel(level: LogLevelName): void {
|
|
32
|
+
if (!isLogLevelName(level)) {
|
|
33
|
+
throw new Error(`Invalid log level: ${String(level)}`);
|
|
34
|
+
}
|
|
35
|
+
const numericLevel = LOG_LEVELS[level];
|
|
36
|
+
log.settings.minLevel = numericLevel;
|
|
37
|
+
for (const subLogger of subLoggers.values()) {
|
|
38
|
+
subLogger.settings.minLevel = numericLevel;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getLogLevel(): LogLevelName {
|
|
43
|
+
return LOG_LEVEL_NAMES[log.settings.minLevel] ?? DEFAULT_LOG_LEVEL;
|
|
44
|
+
}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { VALID_LOG_LEVELS, type LogLevelName, type WebAppConfigResponse } from "../../contracts";
|
|
3
3
|
import { appJson } from "../api-client";
|
|
4
|
-
import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
|
|
4
|
+
import { Badge, Button, CheckboxField, ErrorState, FormSection, LoadingState, SelectField } from "../components";
|
|
5
5
|
import { useLogLevel } from "../log-level";
|
|
6
6
|
import { isThemePreference, useTheme } from "../theme";
|
|
7
7
|
|
|
8
|
-
const LOG_LEVEL_NAMES = ["trace", "debug", "info", "warn", "error"] as const satisfies readonly LogLevelName[];
|
|
9
|
-
|
|
10
8
|
function isLogLevelName(value: string): value is LogLevelName {
|
|
11
|
-
return
|
|
9
|
+
return VALID_LOG_LEVELS.some((level) => level === value);
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
export interface AccountSectionProps {
|
|
@@ -21,6 +19,7 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
|
|
|
21
19
|
const { preference, setPreference, loading, error, retry } = useTheme();
|
|
22
20
|
const logLevel = useLogLevel();
|
|
23
21
|
const [logLevelSaving, setLogLevelSaving] = useState(false);
|
|
22
|
+
const [inMemoryLogsSaving, setInMemoryLogsSaving] = useState(false);
|
|
24
23
|
|
|
25
24
|
async function updateLogLevel(value: string) {
|
|
26
25
|
if (!isLogLevelName(value)) {
|
|
@@ -39,6 +38,22 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
|
|
|
39
38
|
}
|
|
40
39
|
}
|
|
41
40
|
|
|
41
|
+
async function updateInMemoryLogs(enabled: boolean) {
|
|
42
|
+
try {
|
|
43
|
+
setError(undefined);
|
|
44
|
+
setInMemoryLogsSaving(true);
|
|
45
|
+
await appJson<unknown>("/api/server/logs/settings", {
|
|
46
|
+
method: "PUT",
|
|
47
|
+
body: JSON.stringify({ enabled }),
|
|
48
|
+
});
|
|
49
|
+
await refresh();
|
|
50
|
+
} catch (err) {
|
|
51
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
52
|
+
} finally {
|
|
53
|
+
setInMemoryLogsSaving(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
return (
|
|
43
58
|
<>
|
|
44
59
|
{config.currentUser ? (
|
|
@@ -85,9 +100,16 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
|
|
|
85
100
|
disabled={logLevel.fromEnv === true || logLevel.loading || logLevelSaving}
|
|
86
101
|
onChange={(event) => void updateLogLevel(event.currentTarget.value)}
|
|
87
102
|
>
|
|
88
|
-
{
|
|
103
|
+
{VALID_LOG_LEVELS.map((level) => <option key={level} value={level}>{level}</option>)}
|
|
89
104
|
</SelectField>
|
|
90
105
|
) : null}
|
|
106
|
+
<CheckboxField
|
|
107
|
+
label="Store server logs in memory"
|
|
108
|
+
hint="Temporary; disabled and cleared when the server process starts or this setting is turned off."
|
|
109
|
+
checked={config.inMemoryLogs.enabled}
|
|
110
|
+
disabled={inMemoryLogsSaving}
|
|
111
|
+
onChange={(event) => void updateInMemoryLogs(event.currentTarget.checked)}
|
|
112
|
+
/>
|
|
91
113
|
</FormSection>
|
|
92
114
|
) : null}
|
|
93
115
|
</>
|
package/src/web/styles.css
CHANGED
|
@@ -1618,6 +1618,32 @@ textarea::placeholder {
|
|
|
1618
1618
|
resize: vertical;
|
|
1619
1619
|
}
|
|
1620
1620
|
|
|
1621
|
+
.wapp-checkbox-field {
|
|
1622
|
+
display: flex;
|
|
1623
|
+
align-items: flex-start;
|
|
1624
|
+
gap: 0.5rem;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
.wapp-checkbox-field input {
|
|
1628
|
+
width: 1rem;
|
|
1629
|
+
min-height: 1rem;
|
|
1630
|
+
margin-top: 0.125rem;
|
|
1631
|
+
flex: 0 0 auto;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
.wapp-checkbox-field span {
|
|
1635
|
+
font-size: 0.8125rem;
|
|
1636
|
+
font-weight: 500;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
.wapp-checkbox-field small {
|
|
1640
|
+
display: block;
|
|
1641
|
+
color: var(--wapp-muted);
|
|
1642
|
+
font-size: 0.75rem;
|
|
1643
|
+
font-weight: 400;
|
|
1644
|
+
margin-top: 0.25rem;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1621
1647
|
.wapp-field small {
|
|
1622
1648
|
display: block;
|
|
1623
1649
|
color: var(--wapp-muted);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { VALID_LOG_LEVELS, type WebAppConfigResponse } from "../contracts";
|
|
3
3
|
import { appJson } from "./api-client";
|
|
4
4
|
|
|
5
5
|
export interface WebAppConfigState {
|
|
@@ -16,7 +16,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function isLogLevelName(value: unknown): boolean {
|
|
19
|
-
return
|
|
19
|
+
return typeof value === "string" && VALID_LOG_LEVELS.includes(value as WebAppConfigResponse["logLevel"]["level"]);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function isCurrentUser(value: unknown): boolean {
|
|
@@ -50,6 +50,7 @@ function isWebAppConfigResponse(value: unknown): value is WebAppConfigResponse {
|
|
|
50
50
|
&& isRecord(value["logLevel"])
|
|
51
51
|
&& isLogLevelName(value["logLevel"]["level"])
|
|
52
52
|
&& typeof value["logLevel"]["fromEnv"] === "boolean"
|
|
53
|
+
&& hasBooleanFields(value["inMemoryLogs"], ["enabled"])
|
|
53
54
|
&& hasBooleanFields(value["deviceAuth"], ["enabled"])
|
|
54
55
|
&& hasBooleanFields(value["apiKeys"], ["enabled"]);
|
|
55
56
|
}
|