@dowel-ui/react 0.2.0 → 0.4.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/README.md +14 -7
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
- package/dist/components/ai-agent-plan/index.d.ts +2 -0
- package/dist/components/ai-agent-plan/index.js +2 -0
- package/dist/components/ai-agent-plan/meta.js +17 -0
- package/dist/components/ai-agent-plan/meta.js.map +1 -0
- package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
- package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
- package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
- package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
- package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
- package/dist/components/ai-approval-request/index.d.ts +2 -0
- package/dist/components/ai-approval-request/index.js +2 -0
- package/dist/components/ai-approval-request/meta.js +17 -0
- package/dist/components/ai-approval-request/meta.js.map +1 -0
- package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
- package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
- package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
- package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
- package/dist/components/ai-disclosure/index.d.ts +2 -0
- package/dist/components/ai-disclosure/index.js +2 -0
- package/dist/components/ai-disclosure/meta.js +17 -0
- package/dist/components/ai-disclosure/meta.js.map +1 -0
- package/dist/components/ai-response/ai-response.d.ts.map +1 -1
- package/dist/components/ai-response/ai-response.js +1 -0
- package/dist/components/ai-response/ai-response.js.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.js +13 -2
- package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -1
- package/dist/components/ai-tool/ai-tool.d.ts +1 -1
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/date-picker/date-picker.js +1 -1
- package/dist/components/diff-viewer/diff-model.d.ts +69 -0
- package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-model.js +180 -0
- package/dist/components/diff-viewer/diff-model.js.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.js +212 -0
- package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
- package/dist/components/diff-viewer/index.d.ts +3 -0
- package/dist/components/diff-viewer/index.js +3 -0
- package/dist/components/diff-viewer/meta.js +17 -0
- package/dist/components/diff-viewer/meta.js.map +1 -0
- package/dist/components/file-upload/file-upload.d.ts +46 -0
- package/dist/components/file-upload/file-upload.d.ts.map +1 -0
- package/dist/components/file-upload/file-upload.js +204 -0
- package/dist/components/file-upload/file-upload.js.map +1 -0
- package/dist/components/file-upload/index.d.ts +3 -0
- package/dist/components/file-upload/index.js +3 -0
- package/dist/components/file-upload/meta.js +17 -0
- package/dist/components/file-upload/meta.js.map +1 -0
- package/dist/components/file-upload/upload-queue.d.ts +87 -0
- package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
- package/dist/components/file-upload/upload-queue.js +249 -0
- package/dist/components/file-upload/upload-queue.js.map +1 -0
- package/dist/components/log-viewer/index.d.ts +3 -0
- package/dist/components/log-viewer/index.js +3 -0
- package/dist/components/log-viewer/log-stream.d.ts +68 -0
- package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
- package/dist/components/log-viewer/log-stream.js +120 -0
- package/dist/components/log-viewer/log-stream.js.map +1 -0
- package/dist/components/log-viewer/log-viewer.d.ts +51 -0
- package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
- package/dist/components/log-viewer/log-viewer.js +284 -0
- package/dist/components/log-viewer/log-viewer.js.map +1 -0
- package/dist/components/log-viewer/meta.js +17 -0
- package/dist/components/log-viewer/meta.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/progress/progress.js +1 -0
- package/dist/components/progress/progress.js.map +1 -1
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/spinner/spinner.js +1 -0
- package/dist/components/spinner/spinner.js.map +1 -1
- package/dist/components/tags-input/index.d.ts +2 -0
- package/dist/components/tags-input/index.js +2 -0
- package/dist/components/tags-input/meta.js +17 -0
- package/dist/components/tags-input/meta.js.map +1 -0
- package/dist/components/tags-input/tags-input.d.ts +47 -0
- package/dist/components/tags-input/tags-input.d.ts.map +1 -0
- package/dist/components/tags-input/tags-input.js +182 -0
- package/dist/components/tags-input/tags-input.js.map +1 -0
- package/dist/components/time-range-picker/index.d.ts +3 -0
- package/dist/components/time-range-picker/index.js +3 -0
- package/dist/components/time-range-picker/meta.js +22 -0
- package/dist/components/time-range-picker/meta.js.map +1 -0
- package/dist/components/time-range-picker/time-expression.d.ts +88 -0
- package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-expression.js +335 -0
- package/dist/components/time-range-picker/time-expression.js.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.js +377 -0
- package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +14 -2
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +66 -50
- package/dist/registry/components.js.map +1 -1
- package/package.json +6 -4
- package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
- package/src/components/ai-agent-plan/index.ts +10 -0
- package/src/components/ai-agent-plan/meta.ts +19 -0
- package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
- package/src/components/ai-approval-request/index.ts +7 -0
- package/src/components/ai-approval-request/meta.ts +21 -0
- package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
- package/src/components/ai-disclosure/index.ts +9 -0
- package/src/components/ai-disclosure/meta.ts +23 -0
- package/src/components/ai-response/ai-response.tsx +1 -0
- package/src/components/ai-structured-output/ai-structured-output.tsx +25 -1
- package/src/components/diff-viewer/diff-model.ts +256 -0
- package/src/components/diff-viewer/diff-viewer.tsx +339 -0
- package/src/components/diff-viewer/index.ts +17 -0
- package/src/components/diff-viewer/meta.ts +20 -0
- package/src/components/file-upload/file-upload.tsx +322 -0
- package/src/components/file-upload/index.ts +20 -0
- package/src/components/file-upload/meta.ts +21 -0
- package/src/components/file-upload/upload-queue.ts +337 -0
- package/src/components/log-viewer/index.ts +21 -0
- package/src/components/log-viewer/log-stream.ts +176 -0
- package/src/components/log-viewer/log-viewer.tsx +424 -0
- package/src/components/log-viewer/meta.ts +21 -0
- package/src/components/progress/progress.tsx +3 -0
- package/src/components/spinner/spinner.tsx +5 -0
- package/src/components/tags-input/index.ts +1 -0
- package/src/components/tags-input/meta.ts +23 -0
- package/src/components/tags-input/tags-input.tsx +300 -0
- package/src/components/time-range-picker/index.ts +25 -0
- package/src/components/time-range-picker/meta.ts +22 -0
- package/src/components/time-range-picker/time-expression.ts +491 -0
- package/src/components/time-range-picker/time-range-picker.tsx +522 -0
- package/src/index.ts +8 -0
- package/src/registry/components.ts +16 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
FileUpload,
|
|
3
|
+
FileUploadItem,
|
|
4
|
+
FileUploadList,
|
|
5
|
+
FileUploadStatus,
|
|
6
|
+
type FileUploadItemProps,
|
|
7
|
+
type FileUploadListProps,
|
|
8
|
+
type FileUploadProps,
|
|
9
|
+
} from "./file-upload";
|
|
10
|
+
export {
|
|
11
|
+
formatBytes,
|
|
12
|
+
matchesAccept,
|
|
13
|
+
useUploadQueue,
|
|
14
|
+
xhrUpload,
|
|
15
|
+
type QueuedFile,
|
|
16
|
+
type UploadContext,
|
|
17
|
+
type UploadFn,
|
|
18
|
+
type UploadQueueOptions,
|
|
19
|
+
type UploadStatus,
|
|
20
|
+
} from "./upload-queue";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "file-upload",
|
|
5
|
+
title: "File Upload",
|
|
6
|
+
description: "A dropzone over a real file input, plus the upload queue nobody ships.",
|
|
7
|
+
category: "form",
|
|
8
|
+
status: "stable",
|
|
9
|
+
dependencies: [],
|
|
10
|
+
registryDependencies: [],
|
|
11
|
+
files: ["upload-queue.ts", "file-upload.tsx"],
|
|
12
|
+
a11y:
|
|
13
|
+
"The APG has no dropzone pattern, and inventing one is the usual failure — a div with " +
|
|
14
|
+
'role="button", a keydown handler, and a picker keyboard users never reach. The control here ' +
|
|
15
|
+
"is a real input[type=file] with a real label, which is already operable, already announced, " +
|
|
16
|
+
"and opens the picker on Enter and Space with nothing added. Drag and drop is a pointer " +
|
|
17
|
+
"convenience layered on top; every drop can also be done from the input. Each uploading file " +
|
|
18
|
+
"gets a progressbar named after it, status is always stated in words as well as drawn, and " +
|
|
19
|
+
"one polite live region summarises the whole queue rather than six per-file regions talking " +
|
|
20
|
+
"over each other.",
|
|
21
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The upload queue.
|
|
7
|
+
*
|
|
8
|
+
* The dropzone is the most duplicated component in the React ecosystem and the
|
|
9
|
+
* least valuable half: it is a styled rectangle over `<input type="file">`.
|
|
10
|
+
* What almost nobody ships is this — progress, cancel, retry with backoff, and
|
|
11
|
+
* a concurrency limit — so every team writes it again, usually twice, because
|
|
12
|
+
* the first version has no cancel and no retry.
|
|
13
|
+
*
|
|
14
|
+
* Transport is injected. This never calls `fetch` or constructs a request,
|
|
15
|
+
* because the request is the part that differs everywhere: presigned S3 PUT,
|
|
16
|
+
* multipart POST, tus, an internal gateway with its own auth. `upload` receives
|
|
17
|
+
* the file, a progress callback and an AbortSignal, and returns a promise. That
|
|
18
|
+
* is the whole contract.
|
|
19
|
+
*
|
|
20
|
+
* Progress needs XHR, not fetch. `fetch` still cannot report upload progress in
|
|
21
|
+
* any shipping browser — there is no readable stream for the request body — so
|
|
22
|
+
* a transport that wants a real progress bar has to use XMLHttpRequest. That is
|
|
23
|
+
* the consumer's choice to make, and `xhrUpload` below is a working example
|
|
24
|
+
* rather than a dependency.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "cancelled";
|
|
28
|
+
|
|
29
|
+
export interface QueuedFile {
|
|
30
|
+
/** Stable across retries, so React keys and announcements do not jump. */
|
|
31
|
+
id: string;
|
|
32
|
+
file: File;
|
|
33
|
+
status: UploadStatus;
|
|
34
|
+
/** 0–1, or null when the transport cannot report it. */
|
|
35
|
+
progress: number | null;
|
|
36
|
+
/** Why it failed or was refused. Kept so it can be read and acted on. */
|
|
37
|
+
error?: string;
|
|
38
|
+
attempts: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UploadContext {
|
|
42
|
+
onProgress: (fraction: number) => void;
|
|
43
|
+
signal: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type UploadFn = (file: File, context: UploadContext) => Promise<void>;
|
|
47
|
+
|
|
48
|
+
export interface UploadQueueOptions {
|
|
49
|
+
upload: UploadFn;
|
|
50
|
+
/** Uploads running at once. More is not faster once the link is saturated. */
|
|
51
|
+
concurrency?: number;
|
|
52
|
+
/** Automatic retries per file before it is left failed. */
|
|
53
|
+
maxAttempts?: number;
|
|
54
|
+
/** Largest accepted file, in bytes. */
|
|
55
|
+
maxSize?: number;
|
|
56
|
+
/** Accepted types, as an `accept` attribute value: ".pdf,image/*". */
|
|
57
|
+
accept?: string;
|
|
58
|
+
/** Total files the queue will hold. */
|
|
59
|
+
maxFiles?: number;
|
|
60
|
+
onComplete?: (file: QueuedFile) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Backoff between attempts. Bounded, because a person is waiting. */
|
|
64
|
+
function backoffMs(attempt: number): number {
|
|
65
|
+
return Math.min(8000, 500 * 2 ** (attempt - 1));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Matches a file against an `accept` string the same way the browser does. */
|
|
69
|
+
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
70
|
+
if (!accept) return true;
|
|
71
|
+
const patterns = accept
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((part) => part.trim().toLowerCase())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
if (patterns.length === 0) return true;
|
|
76
|
+
|
|
77
|
+
const type = file.type.toLowerCase();
|
|
78
|
+
const name = file.name.toLowerCase();
|
|
79
|
+
|
|
80
|
+
return patterns.some((pattern) => {
|
|
81
|
+
if (pattern.startsWith(".")) return name.endsWith(pattern);
|
|
82
|
+
if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
|
|
83
|
+
return type === pattern;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatBytes(bytes: number): string {
|
|
88
|
+
if (bytes < 1000) return `${String(bytes)} B`;
|
|
89
|
+
const units = ["kB", "MB", "GB", "TB"];
|
|
90
|
+
let value = bytes / 1000;
|
|
91
|
+
let unit = 0;
|
|
92
|
+
while (value >= 1000 && unit < units.length - 1) {
|
|
93
|
+
value /= 1000;
|
|
94
|
+
unit += 1;
|
|
95
|
+
}
|
|
96
|
+
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit] ?? "B"}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let sequence = 0;
|
|
100
|
+
|
|
101
|
+
export function useUploadQueue(options: UploadQueueOptions) {
|
|
102
|
+
const {
|
|
103
|
+
upload,
|
|
104
|
+
concurrency = 3,
|
|
105
|
+
maxAttempts = 3,
|
|
106
|
+
maxSize,
|
|
107
|
+
accept,
|
|
108
|
+
maxFiles,
|
|
109
|
+
onComplete,
|
|
110
|
+
} = options;
|
|
111
|
+
|
|
112
|
+
const [files, setFiles] = useState<QueuedFile[]>([]);
|
|
113
|
+
|
|
114
|
+
const controllers = useRef(new Map<string, AbortController>());
|
|
115
|
+
const running = useRef(new Set<string>());
|
|
116
|
+
|
|
117
|
+
// The live option values, so an in-flight upload reads the current transport
|
|
118
|
+
// without `run` being re-created — which would churn the scheduling effect
|
|
119
|
+
// every time a consumer passes an inline `upload`. Written after commit
|
|
120
|
+
// rather than during render, because a ref write during render is unsafe
|
|
121
|
+
// under concurrent rendering; `run` only reads this once it is executing,
|
|
122
|
+
// which is always after the effect has run.
|
|
123
|
+
const latest = useRef({ upload, concurrency, maxAttempts, onComplete });
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
latest.current = { upload, concurrency, maxAttempts, onComplete };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const patch = useCallback((id: string, changes: Partial<QueuedFile>) => {
|
|
129
|
+
setFiles((current) =>
|
|
130
|
+
current.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)),
|
|
131
|
+
);
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const run = useCallback(
|
|
135
|
+
async (entry: QueuedFile) => {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
controllers.current.set(entry.id, controller);
|
|
138
|
+
|
|
139
|
+
const attempt = entry.attempts + 1;
|
|
140
|
+
patch(entry.id, {
|
|
141
|
+
status: "uploading",
|
|
142
|
+
progress: 0,
|
|
143
|
+
attempts: attempt,
|
|
144
|
+
error: undefined,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await latest.current.upload(entry.file, {
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
onProgress: (fraction) => {
|
|
151
|
+
patch(entry.id, { progress: Math.min(1, Math.max(0, fraction)) });
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
patch(entry.id, { status: "done", progress: 1 });
|
|
156
|
+
latest.current.onComplete?.({ ...entry, status: "done", progress: 1 });
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (controller.signal.aborted) {
|
|
159
|
+
patch(entry.id, { status: "cancelled", progress: null });
|
|
160
|
+
} else if (attempt < latest.current.maxAttempts) {
|
|
161
|
+
// Back off, then requeue. Releasing the slot only after the delay is
|
|
162
|
+
// what makes the wait real rather than a busy retry, and the file
|
|
163
|
+
// re-enters the queue like any other so retries obey concurrency too.
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
running.current.delete(entry.id);
|
|
166
|
+
controllers.current.delete(entry.id);
|
|
167
|
+
patch(entry.id, { status: "queued", progress: null });
|
|
168
|
+
}, backoffMs(attempt));
|
|
169
|
+
return;
|
|
170
|
+
} else {
|
|
171
|
+
patch(entry.id, {
|
|
172
|
+
status: "failed",
|
|
173
|
+
progress: null,
|
|
174
|
+
error: error instanceof Error ? error.message : "Upload failed",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
controllers.current.delete(entry.id);
|
|
180
|
+
running.current.delete(entry.id);
|
|
181
|
+
// No explicit pump: the scheduling effect below reacts to the state
|
|
182
|
+
// change and starts whatever can start next.
|
|
183
|
+
setFiles((current) => [...current]);
|
|
184
|
+
},
|
|
185
|
+
[patch],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Starts whatever can start, once the state that made it startable has been
|
|
189
|
+
// committed. Doing this inside a setState updater — the obvious shortcut —
|
|
190
|
+
// means React may run it twice and upload the same file twice; the `running`
|
|
191
|
+
// set guards that, but the effect is the honest place for it.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const free = concurrency - running.current.size;
|
|
194
|
+
if (free <= 0) return;
|
|
195
|
+
|
|
196
|
+
const next = files
|
|
197
|
+
.filter((entry) => entry.status === "queued" && !running.current.has(entry.id))
|
|
198
|
+
.slice(0, free);
|
|
199
|
+
|
|
200
|
+
for (const entry of next) {
|
|
201
|
+
running.current.add(entry.id);
|
|
202
|
+
void run(entry);
|
|
203
|
+
}
|
|
204
|
+
}, [files, concurrency, run]);
|
|
205
|
+
|
|
206
|
+
/** Validates and enqueues. A rejected file is kept and told why. */
|
|
207
|
+
const add = useCallback(
|
|
208
|
+
(incoming: File[]) => {
|
|
209
|
+
setFiles((current) => {
|
|
210
|
+
const room = maxFiles === undefined ? incoming.length : maxFiles - current.length;
|
|
211
|
+
const admitted: QueuedFile[] = [];
|
|
212
|
+
|
|
213
|
+
for (const file of incoming.slice(0, Math.max(0, room))) {
|
|
214
|
+
sequence += 1;
|
|
215
|
+
const id = `upload-${String(sequence)}`;
|
|
216
|
+
|
|
217
|
+
// A rejected file becomes a failed entry rather than disappearing.
|
|
218
|
+
// Silently dropping a file the reader chose is how these components
|
|
219
|
+
// lose work without anybody noticing.
|
|
220
|
+
if (maxSize !== undefined && file.size > maxSize) {
|
|
221
|
+
admitted.push({
|
|
222
|
+
id,
|
|
223
|
+
file,
|
|
224
|
+
status: "failed",
|
|
225
|
+
progress: null,
|
|
226
|
+
attempts: 0,
|
|
227
|
+
error: `Larger than ${formatBytes(maxSize)}`,
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!matchesAccept(file, accept)) {
|
|
232
|
+
admitted.push({
|
|
233
|
+
id,
|
|
234
|
+
file,
|
|
235
|
+
status: "failed",
|
|
236
|
+
progress: null,
|
|
237
|
+
attempts: 0,
|
|
238
|
+
error: "Type not accepted",
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
admitted.push({ id, file, status: "queued", progress: null, attempts: 0 });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return [...current, ...admitted];
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
[accept, maxFiles, maxSize],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const cancel = useCallback((id: string) => {
|
|
253
|
+
controllers.current.get(id)?.abort();
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
const retry = useCallback((id: string) => {
|
|
257
|
+
setFiles((current) =>
|
|
258
|
+
current.map((entry) =>
|
|
259
|
+
entry.id === id
|
|
260
|
+
? { ...entry, status: "queued" as const, error: undefined, attempts: 0 }
|
|
261
|
+
: entry,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
266
|
+
const remove = useCallback((id: string) => {
|
|
267
|
+
controllers.current.get(id)?.abort();
|
|
268
|
+
controllers.current.delete(id);
|
|
269
|
+
running.current.delete(id);
|
|
270
|
+
setFiles((current) => current.filter((entry) => entry.id !== id));
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
const clearCompleted = useCallback(() => {
|
|
274
|
+
setFiles((current) => current.filter((entry) => entry.status !== "done"));
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
const stats = useMemo(() => {
|
|
278
|
+
const by = (status: UploadStatus) =>
|
|
279
|
+
files.filter((entry) => entry.status === status).length;
|
|
280
|
+
return {
|
|
281
|
+
total: files.length,
|
|
282
|
+
queued: by("queued"),
|
|
283
|
+
uploading: by("uploading"),
|
|
284
|
+
done: by("done"),
|
|
285
|
+
failed: by("failed"),
|
|
286
|
+
cancelled: by("cancelled"),
|
|
287
|
+
active: by("queued") + by("uploading"),
|
|
288
|
+
};
|
|
289
|
+
}, [files]);
|
|
290
|
+
|
|
291
|
+
return { files, stats, add, cancel, retry, remove, clearCompleted };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A working XHR transport, as an example rather than a dependency.
|
|
296
|
+
*
|
|
297
|
+
* XMLHttpRequest and not fetch, because fetch still cannot report upload
|
|
298
|
+
* progress: there is no readable stream for a request body in any shipping
|
|
299
|
+
* browser. Copy this and change the request to match your backend.
|
|
300
|
+
*/
|
|
301
|
+
export function xhrUpload(
|
|
302
|
+
url: string,
|
|
303
|
+
init: { method?: string; headers?: Record<string, string> } = {},
|
|
304
|
+
): UploadFn {
|
|
305
|
+
return (file, { onProgress, signal }) =>
|
|
306
|
+
new Promise<void>((resolve, reject) => {
|
|
307
|
+
const request = new XMLHttpRequest();
|
|
308
|
+
request.open(init.method ?? "POST", url);
|
|
309
|
+
|
|
310
|
+
for (const [header, value] of Object.entries(init.headers ?? {})) {
|
|
311
|
+
request.setRequestHeader(header, value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
request.upload.addEventListener("progress", (event) => {
|
|
315
|
+
if (event.lengthComputable) onProgress(event.loaded / event.total);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
request.addEventListener("load", () => {
|
|
319
|
+
if (request.status >= 200 && request.status < 300) resolve();
|
|
320
|
+
else reject(new Error(`Upload failed with status ${String(request.status)}`));
|
|
321
|
+
});
|
|
322
|
+
request.addEventListener("error", () => {
|
|
323
|
+
reject(new Error("Network error"));
|
|
324
|
+
});
|
|
325
|
+
request.addEventListener("abort", () => {
|
|
326
|
+
reject(new Error("Cancelled"));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
signal.addEventListener("abort", () => {
|
|
330
|
+
request.abort();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const body = new FormData();
|
|
334
|
+
body.append("file", file);
|
|
335
|
+
request.send(body);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
LogViewer,
|
|
3
|
+
LogViewerRow,
|
|
4
|
+
LogViewerToolbar,
|
|
5
|
+
type LogViewerProps,
|
|
6
|
+
type LogViewerRowProps,
|
|
7
|
+
type LogViewerToolbarProps,
|
|
8
|
+
} from "./log-viewer";
|
|
9
|
+
export {
|
|
10
|
+
LOG_LEVELS,
|
|
11
|
+
compileQuery,
|
|
12
|
+
findMatches,
|
|
13
|
+
segment,
|
|
14
|
+
useLogStream,
|
|
15
|
+
type FilterState,
|
|
16
|
+
type LogLevel,
|
|
17
|
+
type LogLine,
|
|
18
|
+
type MatchRange,
|
|
19
|
+
type UseLogStreamOptions,
|
|
20
|
+
type VisibleLine,
|
|
21
|
+
} from "./log-stream";
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Filtering and match-finding for a log stream.
|
|
7
|
+
*
|
|
8
|
+
* Separate from the view because it is pure and worth testing without a DOM,
|
|
9
|
+
* and because the awkward parts are here rather than in the rendering: a bad
|
|
10
|
+
* regex must not throw while someone is halfway through typing it, and a match
|
|
11
|
+
* has to be located precisely enough to highlight rather than merely detected.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
15
|
+
|
|
16
|
+
export const LOG_LEVELS: readonly LogLevel[] = [
|
|
17
|
+
"trace",
|
|
18
|
+
"debug",
|
|
19
|
+
"info",
|
|
20
|
+
"warn",
|
|
21
|
+
"error",
|
|
22
|
+
"fatal",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export interface LogLine {
|
|
26
|
+
id: string;
|
|
27
|
+
message: string;
|
|
28
|
+
level?: LogLevel;
|
|
29
|
+
/** ISO timestamp, or anything the consumer wants shown in the gutter. */
|
|
30
|
+
timestamp?: string;
|
|
31
|
+
/** Structured fields, revealed when the row is expanded. */
|
|
32
|
+
fields?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A [start, end) slice of a message that matched the filter. */
|
|
36
|
+
export type MatchRange = readonly [number, number];
|
|
37
|
+
|
|
38
|
+
export interface FilterState {
|
|
39
|
+
/** Substring, or a pattern when `regex` is on. */
|
|
40
|
+
query: string;
|
|
41
|
+
regex: boolean;
|
|
42
|
+
/** Levels to show. Empty means all of them. */
|
|
43
|
+
levels: Set<LogLevel>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compiles the query.
|
|
48
|
+
*
|
|
49
|
+
* Returns null for an invalid pattern rather than throwing: the query is being
|
|
50
|
+
* typed, so it spends most of its life syntactically incomplete, and a viewer
|
|
51
|
+
* that crashes on "(" is unusable.
|
|
52
|
+
*/
|
|
53
|
+
export function compileQuery(query: string, regex: boolean): RegExp | null {
|
|
54
|
+
if (query.length === 0) return null;
|
|
55
|
+
try {
|
|
56
|
+
return regex
|
|
57
|
+
? new RegExp(query, "gi")
|
|
58
|
+
: new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every match in a line, so each can be highlighted rather than just the first. */
|
|
65
|
+
export function findMatches(message: string, pattern: RegExp | null): MatchRange[] {
|
|
66
|
+
if (!pattern) return [];
|
|
67
|
+
|
|
68
|
+
const ranges: MatchRange[] = [];
|
|
69
|
+
// Fresh lastIndex: the pattern is reused across lines and a global regex
|
|
70
|
+
// carries position between calls, which would skip matches in later lines.
|
|
71
|
+
pattern.lastIndex = 0;
|
|
72
|
+
|
|
73
|
+
let match = pattern.exec(message);
|
|
74
|
+
while (match !== null) {
|
|
75
|
+
// A pattern that can match empty — "a*" — would loop forever otherwise.
|
|
76
|
+
if (match[0].length === 0) {
|
|
77
|
+
pattern.lastIndex += 1;
|
|
78
|
+
} else {
|
|
79
|
+
ranges.push([match.index, match.index + match[0].length]);
|
|
80
|
+
}
|
|
81
|
+
match = pattern.exec(message);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return ranges;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Splits a message into alternating plain and matched segments. */
|
|
88
|
+
export function segment(
|
|
89
|
+
message: string,
|
|
90
|
+
ranges: MatchRange[],
|
|
91
|
+
): { text: string; match: boolean }[] {
|
|
92
|
+
if (ranges.length === 0) return [{ text: message, match: false }];
|
|
93
|
+
|
|
94
|
+
const parts: { text: string; match: boolean }[] = [];
|
|
95
|
+
let cursor = 0;
|
|
96
|
+
|
|
97
|
+
for (const [start, end] of ranges) {
|
|
98
|
+
if (start > cursor) parts.push({ text: message.slice(cursor, start), match: false });
|
|
99
|
+
parts.push({ text: message.slice(start, end), match: true });
|
|
100
|
+
cursor = end;
|
|
101
|
+
}
|
|
102
|
+
if (cursor < message.length) parts.push({ text: message.slice(cursor), match: false });
|
|
103
|
+
|
|
104
|
+
return parts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface VisibleLine extends LogLine {
|
|
108
|
+
matches: MatchRange[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface UseLogStreamOptions {
|
|
112
|
+
lines: LogLine[];
|
|
113
|
+
/** Levels present but unchecked are hidden. Empty shows everything. */
|
|
114
|
+
initialLevels?: Set<LogLevel>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function useLogStream({ lines, initialLevels }: UseLogStreamOptions) {
|
|
118
|
+
const [query, setQuery] = useState("");
|
|
119
|
+
const [regex, setRegex] = useState(false);
|
|
120
|
+
const [levels, setLevels] = useState<Set<LogLevel>>(initialLevels ?? new Set());
|
|
121
|
+
|
|
122
|
+
const pattern = useMemo(() => compileQuery(query, regex), [query, regex]);
|
|
123
|
+
|
|
124
|
+
// An invalid pattern is reported, not swallowed: with no feedback the reader
|
|
125
|
+
// sees an empty log and concludes nothing matched.
|
|
126
|
+
const invalidPattern = regex && query.length > 0 && pattern === null;
|
|
127
|
+
|
|
128
|
+
const visible = useMemo<VisibleLine[]>(() => {
|
|
129
|
+
const result: VisibleLine[] = [];
|
|
130
|
+
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
if (levels.size > 0 && line.level && !levels.has(line.level)) continue;
|
|
133
|
+
|
|
134
|
+
const matches = findMatches(line.message, pattern);
|
|
135
|
+
// A query with no match hides the line; an invalid pattern hides nothing,
|
|
136
|
+
// because the reader has not finished saying what they want yet.
|
|
137
|
+
if (pattern && matches.length === 0) continue;
|
|
138
|
+
|
|
139
|
+
result.push({ ...line, matches });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return result;
|
|
143
|
+
}, [lines, levels, pattern]);
|
|
144
|
+
|
|
145
|
+
const counts = useMemo(() => {
|
|
146
|
+
const byLevel = new Map<LogLevel, number>();
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
if (!line.level) continue;
|
|
149
|
+
byLevel.set(line.level, (byLevel.get(line.level) ?? 0) + 1);
|
|
150
|
+
}
|
|
151
|
+
return byLevel;
|
|
152
|
+
}, [lines]);
|
|
153
|
+
|
|
154
|
+
const toggleLevel = useCallback((level: LogLevel) => {
|
|
155
|
+
setLevels((current) => {
|
|
156
|
+
const next = new Set(current);
|
|
157
|
+
if (next.has(level)) next.delete(level);
|
|
158
|
+
else next.add(level);
|
|
159
|
+
return next;
|
|
160
|
+
});
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
query,
|
|
165
|
+
setQuery,
|
|
166
|
+
regex,
|
|
167
|
+
setRegex,
|
|
168
|
+
levels,
|
|
169
|
+
toggleLevel,
|
|
170
|
+
setLevels,
|
|
171
|
+
visible,
|
|
172
|
+
counts,
|
|
173
|
+
invalidPattern,
|
|
174
|
+
total: lines.length,
|
|
175
|
+
};
|
|
176
|
+
}
|