datool 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +218 -0
- package/client-dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/client-dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/client-dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/client-dist/assets/index-BeRNeRUq.css +1 -0
- package/client-dist/assets/index-uoZ4c_I8.js +164 -0
- package/client-dist/index.html +13 -0
- package/index.html +12 -0
- package/package.json +55 -0
- package/src/client/App.tsx +885 -0
- package/src/client/components/connection-status.tsx +43 -0
- package/src/client/components/data-table-cell.tsx +235 -0
- package/src/client/components/data-table-col-icon.tsx +73 -0
- package/src/client/components/data-table-header-col.tsx +225 -0
- package/src/client/components/data-table-search-input.tsx +729 -0
- package/src/client/components/data-table.tsx +2014 -0
- package/src/client/components/stream-controls.tsx +157 -0
- package/src/client/components/theme-provider.tsx +230 -0
- package/src/client/components/ui/button.tsx +68 -0
- package/src/client/components/ui/combobox.tsx +308 -0
- package/src/client/components/ui/context-menu.tsx +261 -0
- package/src/client/components/ui/dropdown-menu.tsx +267 -0
- package/src/client/components/ui/input-group.tsx +153 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/components/viewer-settings.tsx +185 -0
- package/src/client/index.css +192 -0
- package/src/client/lib/data-table-search.ts +750 -0
- package/src/client/lib/datool-icons.ts +37 -0
- package/src/client/lib/datool-url-state.ts +159 -0
- package/src/client/lib/filterable-table.ts +146 -0
- package/src/client/lib/table-search-persistence.ts +94 -0
- package/src/client/lib/utils.ts +6 -0
- package/src/client/main.tsx +14 -0
- package/src/index.ts +19 -0
- package/src/node/cli.ts +54 -0
- package/src/node/config.ts +231 -0
- package/src/node/lines.ts +82 -0
- package/src/node/runtime.ts +102 -0
- package/src/node/server.ts +403 -0
- package/src/node/sources/command.ts +82 -0
- package/src/node/sources/file.ts +116 -0
- package/src/node/sources/ssh.ts +59 -0
- package/src/shared/columns.ts +41 -0
- package/src/shared/types.ts +188 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import { pathToFileURL } from "url"
|
|
3
|
+
import fs from "fs"
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
DatoolClientConfig,
|
|
7
|
+
DatoolConfig,
|
|
8
|
+
DatoolStream,
|
|
9
|
+
} from "../shared/types"
|
|
10
|
+
import {
|
|
11
|
+
LOG_VIEWER_ACTION_BUTTON_SIZES,
|
|
12
|
+
LOG_VIEWER_ACTION_BUTTON_VARIANTS,
|
|
13
|
+
LOG_VIEWER_ICON_NAMES,
|
|
14
|
+
} from "../shared/types"
|
|
15
|
+
|
|
16
|
+
const CONFIG_FILENAMES = [
|
|
17
|
+
"datool.config.ts",
|
|
18
|
+
"datool.config.mts",
|
|
19
|
+
"datool.config.js",
|
|
20
|
+
"datool.config.mjs",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return Boolean(value) && typeof value === "object"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertActionButtonShape(streamId: string, actionId: string, value: unknown) {
|
|
28
|
+
if (value === undefined || value === false) {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
if (
|
|
34
|
+
!LOG_VIEWER_ACTION_BUTTON_VARIANTS.includes(
|
|
35
|
+
value as (typeof LOG_VIEWER_ACTION_BUTTON_VARIANTS)[number]
|
|
36
|
+
)
|
|
37
|
+
) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Action "${actionId}" on stream "${streamId}" defines an invalid button variant.`
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!isRecord(value)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Action "${actionId}" on stream "${streamId}" must define button as false, a variant string, or an object.`
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
value.variant !== undefined &&
|
|
54
|
+
(typeof value.variant !== "string" ||
|
|
55
|
+
!LOG_VIEWER_ACTION_BUTTON_VARIANTS.includes(
|
|
56
|
+
value.variant as (typeof LOG_VIEWER_ACTION_BUTTON_VARIANTS)[number]
|
|
57
|
+
))
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Action "${actionId}" on stream "${streamId}" defines an invalid button variant.`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
value.size !== undefined &&
|
|
66
|
+
(typeof value.size !== "string" ||
|
|
67
|
+
!LOG_VIEWER_ACTION_BUTTON_SIZES.includes(
|
|
68
|
+
value.size as (typeof LOG_VIEWER_ACTION_BUTTON_SIZES)[number]
|
|
69
|
+
))
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Action "${actionId}" on stream "${streamId}" defines an invalid button size.`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (value.className !== undefined && typeof value.className !== "string") {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Action "${actionId}" on stream "${streamId}" defines an invalid button className.`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (value.label !== undefined && typeof value.label !== "string") {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Action "${actionId}" on stream "${streamId}" defines an invalid button label.`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertActionShape(
|
|
90
|
+
streamId: string,
|
|
91
|
+
actionId: string,
|
|
92
|
+
value: unknown
|
|
93
|
+
) {
|
|
94
|
+
if (!isRecord(value)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Action "${actionId}" on stream "${streamId}" must be an object.`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof value.label !== "string" || !value.label) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Action "${actionId}" on stream "${streamId}" must define a label.`
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof value.resolve !== "function") {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Action "${actionId}" on stream "${streamId}" must define a resolve() function.`
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
value.icon !== undefined &&
|
|
114
|
+
(typeof value.icon !== "string" ||
|
|
115
|
+
!LOG_VIEWER_ICON_NAMES.includes(
|
|
116
|
+
value.icon as (typeof LOG_VIEWER_ICON_NAMES)[number]
|
|
117
|
+
))
|
|
118
|
+
) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Action "${actionId}" on stream "${streamId}" defines an invalid icon.`
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
assertActionButtonShape(streamId, actionId, value.button)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertStreamShape(streamId: string, value: unknown) {
|
|
128
|
+
if (!isRecord(value)) {
|
|
129
|
+
throw new Error(`Stream "${streamId}" must be an object.`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (typeof value.label !== "string" || !value.label) {
|
|
133
|
+
throw new Error(`Stream "${streamId}" must define a label.`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!Array.isArray(value.columns)) {
|
|
137
|
+
throw new Error(`Stream "${streamId}" must define a columns array.`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (typeof value.open !== "function") {
|
|
141
|
+
throw new Error(`Stream "${streamId}" must define an open() function.`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof value.parseLine !== "function") {
|
|
145
|
+
throw new Error(`Stream "${streamId}" must define a parseLine() function.`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (value.actions !== undefined) {
|
|
149
|
+
if (!isRecord(value.actions)) {
|
|
150
|
+
throw new Error(`Stream "${streamId}" actions must be an object.`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [actionId, action] of Object.entries(value.actions)) {
|
|
154
|
+
assertActionShape(streamId, actionId, action)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function findConfigPath(cwd: string) {
|
|
160
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
161
|
+
const candidatePath = path.join(cwd, filename)
|
|
162
|
+
|
|
163
|
+
if (fs.existsSync(candidatePath)) {
|
|
164
|
+
return candidatePath
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function loadDatoolConfig(options: {
|
|
172
|
+
configPath?: string
|
|
173
|
+
cwd: string
|
|
174
|
+
}) {
|
|
175
|
+
const configPath = options.configPath ?? findConfigPath(options.cwd)
|
|
176
|
+
|
|
177
|
+
if (!configPath) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Could not find datool.config.ts in ${options.cwd}.`
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const importedModule = await import(pathToFileURL(configPath).href)
|
|
184
|
+
const config = (importedModule.default ?? importedModule) as DatoolConfig
|
|
185
|
+
|
|
186
|
+
validateDatoolConfig(config)
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
config,
|
|
190
|
+
configPath,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function validateDatoolConfig(
|
|
195
|
+
config: unknown
|
|
196
|
+
): asserts config is DatoolConfig {
|
|
197
|
+
if (!isRecord(config)) {
|
|
198
|
+
throw new Error("datool.config.ts must export an object.")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!isRecord(config.streams) || Object.keys(config.streams).length === 0) {
|
|
202
|
+
throw new Error("datool.config.ts must define at least one stream.")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const [streamId, stream] of Object.entries(config.streams)) {
|
|
206
|
+
assertStreamShape(streamId, stream)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function toClientConfig(config: DatoolConfig): DatoolClientConfig {
|
|
211
|
+
return {
|
|
212
|
+
streams: Object.entries(config.streams).map(([id, stream]) => ({
|
|
213
|
+
actions: Object.entries(stream.actions ?? {}).map(([actionId, action]) => ({
|
|
214
|
+
button: action.button,
|
|
215
|
+
icon: action.icon,
|
|
216
|
+
id: actionId,
|
|
217
|
+
label: action.label,
|
|
218
|
+
})),
|
|
219
|
+
columns: stream.columns,
|
|
220
|
+
id,
|
|
221
|
+
label: stream.label,
|
|
222
|
+
})),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function getStreamFromConfig(
|
|
227
|
+
config: DatoolConfig,
|
|
228
|
+
streamId: string
|
|
229
|
+
): DatoolStream<Record<string, unknown>> | null {
|
|
230
|
+
return config.streams[streamId] ?? null
|
|
231
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type SplitLinesResult = {
|
|
2
|
+
lines: string[]
|
|
3
|
+
remainder: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function splitLines(input: string): SplitLinesResult {
|
|
7
|
+
if (!input) {
|
|
8
|
+
return {
|
|
9
|
+
lines: [],
|
|
10
|
+
remainder: "",
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
|
15
|
+
const parts = normalized.split("\n")
|
|
16
|
+
const remainder = parts.pop() ?? ""
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
lines: parts,
|
|
20
|
+
remainder,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readLinesFromStream(
|
|
25
|
+
stream: ReadableStream<Uint8Array>,
|
|
26
|
+
options: {
|
|
27
|
+
onLine: (line: string) => void | Promise<void>
|
|
28
|
+
signal: AbortSignal
|
|
29
|
+
}
|
|
30
|
+
) {
|
|
31
|
+
const reader = stream.getReader()
|
|
32
|
+
const decoder = new TextDecoder()
|
|
33
|
+
let remainder = ""
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
while (!options.signal.aborted) {
|
|
37
|
+
const { done, value } = await reader.read()
|
|
38
|
+
|
|
39
|
+
if (done) {
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
remainder += decoder.decode(value, {
|
|
44
|
+
stream: true,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const splitResult = splitLines(remainder)
|
|
48
|
+
|
|
49
|
+
remainder = splitResult.remainder
|
|
50
|
+
|
|
51
|
+
for (const line of splitResult.lines) {
|
|
52
|
+
await options.onLine(line)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
remainder += decoder.decode()
|
|
57
|
+
|
|
58
|
+
if (remainder) {
|
|
59
|
+
await options.onLine(remainder)
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
reader.releaseLock()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function waitForDelay(ms: number, signal: AbortSignal) {
|
|
67
|
+
return new Promise<void>((resolve) => {
|
|
68
|
+
const timeout = setTimeout(() => {
|
|
69
|
+
signal.removeEventListener("abort", handleAbort)
|
|
70
|
+
resolve()
|
|
71
|
+
}, ms)
|
|
72
|
+
|
|
73
|
+
const handleAbort = () => {
|
|
74
|
+
clearTimeout(timeout)
|
|
75
|
+
resolve()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
signal.addEventListener("abort", handleAbort, {
|
|
79
|
+
once: true,
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DatoolGetRowIdContext,
|
|
3
|
+
DatoolStream,
|
|
4
|
+
} from "../shared/types"
|
|
5
|
+
|
|
6
|
+
export type StreamRuntimeEventHandlers = {
|
|
7
|
+
onError: (error: unknown) => void | Promise<void>
|
|
8
|
+
onRow: (payload: {
|
|
9
|
+
id: string
|
|
10
|
+
row: Record<string, unknown>
|
|
11
|
+
}) => void | Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toErrorMessage(error: unknown) {
|
|
15
|
+
if (error instanceof Error) {
|
|
16
|
+
return error.message
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return String(error)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function resolveRowId(
|
|
23
|
+
streamId: string,
|
|
24
|
+
index: number,
|
|
25
|
+
line: string,
|
|
26
|
+
row: Record<string, unknown>,
|
|
27
|
+
query: URLSearchParams,
|
|
28
|
+
getRowId: DatoolStream<Record<string, unknown>>["getRowId"]
|
|
29
|
+
) {
|
|
30
|
+
if (!getRowId) {
|
|
31
|
+
return `${streamId}:${index}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return getRowId({
|
|
35
|
+
index,
|
|
36
|
+
line,
|
|
37
|
+
query,
|
|
38
|
+
row,
|
|
39
|
+
streamId,
|
|
40
|
+
} satisfies DatoolGetRowIdContext<Record<string, unknown>>)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function openStreamRuntime(
|
|
44
|
+
streamId: string,
|
|
45
|
+
stream: DatoolStream<Record<string, unknown>>,
|
|
46
|
+
query: URLSearchParams,
|
|
47
|
+
signal: AbortSignal,
|
|
48
|
+
handlers: StreamRuntimeEventHandlers
|
|
49
|
+
) {
|
|
50
|
+
let emittedRowCount = 0
|
|
51
|
+
let queue = Promise.resolve()
|
|
52
|
+
|
|
53
|
+
const emit = (line: string) => {
|
|
54
|
+
queue = queue.then(async () => {
|
|
55
|
+
if (signal.aborted) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const parsedRow = await stream.parseLine({
|
|
61
|
+
line,
|
|
62
|
+
query,
|
|
63
|
+
streamId,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (!parsedRow) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const id = await resolveRowId(
|
|
71
|
+
streamId,
|
|
72
|
+
emittedRowCount,
|
|
73
|
+
line,
|
|
74
|
+
parsedRow,
|
|
75
|
+
query,
|
|
76
|
+
stream.getRowId
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
emittedRowCount += 1
|
|
80
|
+
|
|
81
|
+
await handlers.onRow({
|
|
82
|
+
id,
|
|
83
|
+
row: parsedRow,
|
|
84
|
+
})
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await handlers.onError(new Error(toErrorMessage(error)))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cleanup = await stream.open({
|
|
92
|
+
emit,
|
|
93
|
+
query,
|
|
94
|
+
signal,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await queue
|
|
98
|
+
|
|
99
|
+
if (typeof cleanup === "function") {
|
|
100
|
+
await cleanup()
|
|
101
|
+
}
|
|
102
|
+
}
|