@blogic-cz/agent-tools 0.7.1 → 0.8.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/package.json +1 -1
- package/src/az-tool/build.ts +7 -3
- package/src/az-tool/service.ts +9 -1
- package/src/az-tool/transformers.ts +127 -0
- package/src/db-tool/service.ts +16 -4
- package/src/db-tool/transformers.ts +38 -0
- package/src/db-tool/types.ts +2 -0
- package/src/k8s-tool/index.ts +44 -12
- package/src/k8s-tool/transformers.ts +508 -0
- package/src/k8s-tool/types.ts +1 -1
- package/src/logs-tool/service.ts +13 -10
- package/src/logs-tool/transformers.ts +212 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/transform.ts +320 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { deduplicateLines } from "#shared";
|
|
2
|
+
|
|
3
|
+
type LogLevel = "ERROR" | "WARN" | "INFO" | "DEBUG";
|
|
4
|
+
|
|
5
|
+
type ParsedLogLine = {
|
|
6
|
+
level: LogLevel;
|
|
7
|
+
timestamp?: string;
|
|
8
|
+
message: string;
|
|
9
|
+
originalIndex: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const TIMESTAMP_FIELDS = ["timestamp", "ts", "time", "@timestamp", "datetime"] as const;
|
|
13
|
+
const LEVEL_FIELDS = ["level", "severity", "lvl", "log.level"] as const;
|
|
14
|
+
const MESSAGE_FIELDS = ["message", "msg", "log", "text"] as const;
|
|
15
|
+
const ERROR_FIELDS = ["error", "err", "stack", "exception", "error.message"] as const;
|
|
16
|
+
|
|
17
|
+
const LEVEL_ORDER: ReadonlyArray<LogLevel> = ["ERROR", "WARN", "INFO", "DEBUG"];
|
|
18
|
+
|
|
19
|
+
function getByPath(obj: unknown, path: string): unknown {
|
|
20
|
+
if (typeof obj !== "object" || obj === null) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!path.includes(".")) {
|
|
25
|
+
return (obj as Record<string, unknown>)[path];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parts = path.split(".");
|
|
29
|
+
let current: unknown = obj;
|
|
30
|
+
|
|
31
|
+
for (const part of parts) {
|
|
32
|
+
if (typeof current !== "object" || current === null) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
current = (current as Record<string, unknown>)[part];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toText(value: unknown): string | undefined {
|
|
42
|
+
if (typeof value === "string") {
|
|
43
|
+
const trimmed = value.trim();
|
|
44
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
48
|
+
return String(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (value && typeof value === "object") {
|
|
52
|
+
if ("message" in value) {
|
|
53
|
+
const nestedMessage = toText((value as Record<string, unknown>).message);
|
|
54
|
+
if (nestedMessage) {
|
|
55
|
+
return nestedMessage;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return JSON.stringify(value);
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function firstMappedField(obj: unknown, fields: ReadonlyArray<string>): string | undefined {
|
|
70
|
+
for (const field of fields) {
|
|
71
|
+
const value = toText(getByPath(obj, field));
|
|
72
|
+
if (value) {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeLevel(rawLevel: string | undefined): LogLevel {
|
|
81
|
+
const normalized = rawLevel?.trim().toLowerCase();
|
|
82
|
+
|
|
83
|
+
if (!normalized) {
|
|
84
|
+
return "INFO";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (normalized === "error" || normalized === "err" || normalized === "fatal") {
|
|
88
|
+
return "ERROR";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (normalized === "warn" || normalized === "warning") {
|
|
92
|
+
return "WARN";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (normalized === "info") {
|
|
96
|
+
return "INFO";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (normalized === "debug" || normalized === "trace") {
|
|
100
|
+
return "DEBUG";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return "INFO";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatLine({ level, timestamp, message }: ParsedLogLine): string {
|
|
107
|
+
if (timestamp) {
|
|
108
|
+
return `[${timestamp}] [${level}] ${message}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return `[${level}] ${message}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseJsonLogLine(line: string, originalIndex: number): ParsedLogLine {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(line) as unknown;
|
|
117
|
+
const timestamp = firstMappedField(parsed, TIMESTAMP_FIELDS);
|
|
118
|
+
const level = normalizeLevel(firstMappedField(parsed, LEVEL_FIELDS));
|
|
119
|
+
const message =
|
|
120
|
+
firstMappedField(parsed, MESSAGE_FIELDS) ??
|
|
121
|
+
firstMappedField(parsed, ERROR_FIELDS) ??
|
|
122
|
+
line.trim();
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
level,
|
|
126
|
+
timestamp,
|
|
127
|
+
message,
|
|
128
|
+
originalIndex,
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return {
|
|
132
|
+
level: "INFO",
|
|
133
|
+
message: line.trim(),
|
|
134
|
+
originalIndex,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isJsonMode(lines: ReadonlyArray<string>): boolean {
|
|
140
|
+
const firstNonEmpty = lines.find((line) => line.trim().length > 0);
|
|
141
|
+
if (!firstNonEmpty) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(firstNonEmpty) as unknown;
|
|
147
|
+
return typeof parsed === "object" && parsed !== null;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function groupHeader(level: LogLevel, count: number): string {
|
|
154
|
+
switch (level) {
|
|
155
|
+
case "ERROR":
|
|
156
|
+
return `--- errors (${count}) ---`;
|
|
157
|
+
case "WARN":
|
|
158
|
+
return `--- warnings (${count}) ---`;
|
|
159
|
+
case "INFO":
|
|
160
|
+
return `--- info (${count}) ---`;
|
|
161
|
+
case "DEBUG":
|
|
162
|
+
return `--- debug (${count}) ---`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function transformJsonLines(lines: ReadonlyArray<string>): string {
|
|
167
|
+
const parsedLines = lines
|
|
168
|
+
.map((line, originalIndex) => ({ line, originalIndex }))
|
|
169
|
+
.filter(({ line }) => line.trim().length > 0)
|
|
170
|
+
.map(({ line, originalIndex }) => parseJsonLogLine(line, originalIndex));
|
|
171
|
+
|
|
172
|
+
if (parsedLines.length === 0) {
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ordered = LEVEL_ORDER.flatMap((level) =>
|
|
177
|
+
parsedLines
|
|
178
|
+
.filter((entry) => entry.level === level)
|
|
179
|
+
.sort((a, b) => a.originalIndex - b.originalIndex),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const output: Array<string> = [];
|
|
183
|
+
|
|
184
|
+
for (const level of LEVEL_ORDER) {
|
|
185
|
+
const group = ordered.filter((entry) => entry.level === level);
|
|
186
|
+
if (group.length === 0) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
output.push(groupHeader(level, group.length));
|
|
191
|
+
output.push(...group.map(formatLine));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return deduplicateLines(output.join("\n"));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function transformLogOutput(rawOutput: string): string {
|
|
198
|
+
if (rawOutput.length === 0) {
|
|
199
|
+
return "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const lines = rawOutput.split(/\r?\n/);
|
|
203
|
+
|
|
204
|
+
if (isJsonMode(lines)) {
|
|
205
|
+
return transformJsonLines(lines);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return deduplicateLines(rawOutput, {
|
|
209
|
+
normalizeTimestamps: true,
|
|
210
|
+
normalizeUUIDs: true,
|
|
211
|
+
});
|
|
212
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -15,3 +15,12 @@ export const VERSION = pkg.version;
|
|
|
15
15
|
export { execEffect, type ExecError } from "./exec";
|
|
16
16
|
|
|
17
17
|
export { createThrottle, type ThrottleError } from "./throttle";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
aggregateByField,
|
|
21
|
+
deduplicateLines,
|
|
22
|
+
formatCountSummary,
|
|
23
|
+
parseTextTable,
|
|
24
|
+
stripEmptyColumns,
|
|
25
|
+
truncateRows,
|
|
26
|
+
} from "./transform";
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
type DeduplicateOptions = {
|
|
2
|
+
normalizeTimestamps?: boolean;
|
|
3
|
+
normalizeUUIDs?: boolean;
|
|
4
|
+
normalizeHex?: boolean;
|
|
5
|
+
normalizeNumbers?: boolean;
|
|
6
|
+
normalizePaths?: boolean;
|
|
7
|
+
maxUniqueLines?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const TIMESTAMP_PATTERN = /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.\d]*Z?/g;
|
|
11
|
+
const SYSLOG_TIMESTAMP_PATTERN = /^[A-Z][a-z]{2}\s+\d+\s+\d{2}:\d{2}:\d{2}/gm;
|
|
12
|
+
const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
13
|
+
const HEX_PATTERN = /0x[0-9a-f]{6,}/gi;
|
|
14
|
+
const LONG_HEX_PATTERN = /\b[0-9a-f]{12,}\b/gi;
|
|
15
|
+
const LARGE_NUMBER_PATTERN = /\b\d{5,}\b/g;
|
|
16
|
+
const PATH_PATTERN = /\/[\w\-./]+/g;
|
|
17
|
+
|
|
18
|
+
function normalizeLine(
|
|
19
|
+
line: string,
|
|
20
|
+
options: Required<Omit<DeduplicateOptions, "maxUniqueLines">>,
|
|
21
|
+
): string {
|
|
22
|
+
let normalized = line;
|
|
23
|
+
|
|
24
|
+
if (options.normalizeTimestamps) {
|
|
25
|
+
normalized = normalized.replace(TIMESTAMP_PATTERN, "<TIMESTAMP>");
|
|
26
|
+
normalized = normalized.replace(SYSLOG_TIMESTAMP_PATTERN, "<TIMESTAMP>");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (options.normalizeUUIDs) {
|
|
30
|
+
normalized = normalized.replace(UUID_PATTERN, "<UUID>");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.normalizeHex) {
|
|
34
|
+
normalized = normalized.replace(HEX_PATTERN, "<HEX>");
|
|
35
|
+
normalized = normalized.replace(LONG_HEX_PATTERN, "<HEX>");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (options.normalizeNumbers) {
|
|
39
|
+
normalized = normalized.replace(LARGE_NUMBER_PATTERN, "<NUM>");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (options.normalizePaths) {
|
|
43
|
+
normalized = normalized.replace(PATH_PATTERN, "<PATH>");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return normalized;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Deduplicate consecutive identical lines after normalization.
|
|
51
|
+
* Normalizes timestamps, UUIDs, hex strings, large numbers before comparison.
|
|
52
|
+
* Returns lines with [×N] suffix for collapsed runs.
|
|
53
|
+
*/
|
|
54
|
+
export function deduplicateLines(text: string, options: DeduplicateOptions = {}): string {
|
|
55
|
+
if (text.length === 0) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const normalizedOptions: Required<Omit<DeduplicateOptions, "maxUniqueLines">> = {
|
|
60
|
+
normalizeTimestamps: options.normalizeTimestamps ?? true,
|
|
61
|
+
normalizeUUIDs: options.normalizeUUIDs ?? true,
|
|
62
|
+
normalizeHex: options.normalizeHex ?? true,
|
|
63
|
+
normalizeNumbers: options.normalizeNumbers ?? false,
|
|
64
|
+
normalizePaths: options.normalizePaths ?? false,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const lines = text.split(/\r?\n/);
|
|
68
|
+
const output: string[] = [];
|
|
69
|
+
|
|
70
|
+
let previousNormalized: string | undefined;
|
|
71
|
+
let runLine = "";
|
|
72
|
+
let runCount = 0;
|
|
73
|
+
|
|
74
|
+
const flushRun = () => {
|
|
75
|
+
if (runCount === 0) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
output.push(runCount > 1 ? `${runLine} [×${runCount}]` : runLine);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const normalized = normalizeLine(line, normalizedOptions);
|
|
84
|
+
|
|
85
|
+
if (previousNormalized !== undefined && normalized === previousNormalized) {
|
|
86
|
+
runCount += 1;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
flushRun();
|
|
91
|
+
|
|
92
|
+
if (typeof options.maxUniqueLines === "number" && output.length >= options.maxUniqueLines) {
|
|
93
|
+
return output.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
previousNormalized = normalized;
|
|
97
|
+
runLine = line;
|
|
98
|
+
runCount = 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
flushRun();
|
|
102
|
+
|
|
103
|
+
if (typeof options.maxUniqueLines === "number") {
|
|
104
|
+
return output.slice(0, options.maxUniqueLines).join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return output.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getColumnStarts(headerLine: string): number[] {
|
|
111
|
+
const starts: number[] = [];
|
|
112
|
+
const len = headerLine.length;
|
|
113
|
+
let i = 0;
|
|
114
|
+
|
|
115
|
+
while (i < len) {
|
|
116
|
+
while (i < len && headerLine[i] === " ") {
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (i >= len) {
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
starts.push(i);
|
|
125
|
+
|
|
126
|
+
while (i < len) {
|
|
127
|
+
if (headerLine[i] === " " && headerLine[i + 1] === " ") {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
i += 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
while (i < len && headerLine[i] === " ") {
|
|
134
|
+
i += 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return starts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseHeaderAtColumns(headerLine: string, columnStarts: number[]): string[] {
|
|
142
|
+
const headers: string[] = [];
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < columnStarts.length; i += 1) {
|
|
145
|
+
const start = columnStarts[i];
|
|
146
|
+
const end = i + 1 < columnStarts.length ? columnStarts[i + 1] : headerLine.length;
|
|
147
|
+
const raw = headerLine.slice(start, end).trim();
|
|
148
|
+
headers.push(raw.replace(/\s+/g, "_"));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return headers;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse kubectl-style whitespace-aligned text tables.
|
|
156
|
+
* Handles multi-word headers (e.g., "NOMINATED NODE" → "NOMINATED_NODE").
|
|
157
|
+
* Returns structured array of row objects keyed by header name.
|
|
158
|
+
*/
|
|
159
|
+
export function parseTextTable(text: string): {
|
|
160
|
+
headers: string[];
|
|
161
|
+
rows: Record<string, string>[];
|
|
162
|
+
} {
|
|
163
|
+
const lines = text
|
|
164
|
+
.split(/\r?\n/)
|
|
165
|
+
.map((line) => line.replace(/\s+$/, ""))
|
|
166
|
+
.filter((line) => line.trim().length > 0);
|
|
167
|
+
|
|
168
|
+
if (lines.length === 0) {
|
|
169
|
+
return { headers: [], rows: [] };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const headerLine = lines[0];
|
|
173
|
+
const columnStarts = getColumnStarts(headerLine);
|
|
174
|
+
const headers = parseHeaderAtColumns(headerLine, columnStarts);
|
|
175
|
+
|
|
176
|
+
const rows = lines.slice(1).map((line) => {
|
|
177
|
+
const row: Record<string, string> = {};
|
|
178
|
+
const values = line.trim().split(/\s{2,}/);
|
|
179
|
+
|
|
180
|
+
for (let i = 0; i < headers.length; i += 1) {
|
|
181
|
+
row[headers[i]] = values[i] ?? "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return row;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return { headers, rows };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Count occurrences of values for a given field across items.
|
|
192
|
+
* Returns a map of value → count, sorted by count descending.
|
|
193
|
+
*/
|
|
194
|
+
export function aggregateByField<T extends Record<string, unknown>>(
|
|
195
|
+
items: T[],
|
|
196
|
+
field: keyof T & string,
|
|
197
|
+
): Record<string, number> {
|
|
198
|
+
const counts = new Map<string, number>();
|
|
199
|
+
|
|
200
|
+
for (const item of items) {
|
|
201
|
+
const value = item[field];
|
|
202
|
+
if (value === null || value === undefined) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const key = String(value);
|
|
207
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sortedEntries: Array<[string, number]> = [];
|
|
211
|
+
|
|
212
|
+
for (const entry of counts.entries()) {
|
|
213
|
+
const insertAt = sortedEntries.findIndex(([key, count]) => {
|
|
214
|
+
if (entry[1] !== count) {
|
|
215
|
+
return entry[1] > count;
|
|
216
|
+
}
|
|
217
|
+
return entry[0].localeCompare(key) < 0;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (insertAt === -1) {
|
|
221
|
+
sortedEntries.push(entry);
|
|
222
|
+
} else {
|
|
223
|
+
sortedEntries.splice(insertAt, 0, entry);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return Object.fromEntries(sortedEntries);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Format aggregated counts into a human-readable summary string.
|
|
232
|
+
* Example: formatCountSummary({Running: 30, Error: 3}, 33, "pods") → "33 pods: 30 Running, 3 Error"
|
|
233
|
+
*/
|
|
234
|
+
export function formatCountSummary(
|
|
235
|
+
counts: Record<string, number>,
|
|
236
|
+
total: number,
|
|
237
|
+
label: string,
|
|
238
|
+
): string {
|
|
239
|
+
const parts = Object.entries(counts).map(([key, count]) => `${count} ${key}`);
|
|
240
|
+
|
|
241
|
+
if (parts.length === 0) {
|
|
242
|
+
return `${total} ${label}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return `${total} ${label}: ${parts.join(", ")}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Truncate an array of rows to a maximum length, returning metadata about truncation.
|
|
250
|
+
*/
|
|
251
|
+
export function truncateRows<T>(
|
|
252
|
+
data: T[],
|
|
253
|
+
limit: number,
|
|
254
|
+
): { rows: T[]; truncated: boolean; total: number; showing: number } {
|
|
255
|
+
const safeLimit = Math.max(0, limit);
|
|
256
|
+
const rows = data.slice(0, safeLimit);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
rows,
|
|
260
|
+
truncated: data.length > safeLimit,
|
|
261
|
+
total: data.length,
|
|
262
|
+
showing: rows.length,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isEmptyValue(value: unknown): boolean {
|
|
267
|
+
if (value === null || value === undefined) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof value === "string") {
|
|
272
|
+
return value.trim().length === 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Remove columns/keys from records where ALL values are empty/null/undefined.
|
|
280
|
+
* Optionally remove columns where >threshold% of values are empty.
|
|
281
|
+
*/
|
|
282
|
+
export function stripEmptyColumns<T extends Record<string, unknown>>(
|
|
283
|
+
records: T[],
|
|
284
|
+
threshold = 1.0,
|
|
285
|
+
): T[] {
|
|
286
|
+
if (records.length === 0) {
|
|
287
|
+
return records;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const boundedThreshold = Math.min(Math.max(threshold, 0), 1);
|
|
291
|
+
const keys = new Set<string>();
|
|
292
|
+
|
|
293
|
+
for (const record of records) {
|
|
294
|
+
for (const key of Object.keys(record)) {
|
|
295
|
+
keys.add(key);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const keysToRemove = new Set<string>();
|
|
300
|
+
|
|
301
|
+
for (const key of keys) {
|
|
302
|
+
let emptyCount = 0;
|
|
303
|
+
|
|
304
|
+
for (const record of records) {
|
|
305
|
+
if (isEmptyValue(record[key])) {
|
|
306
|
+
emptyCount += 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const emptyRatio = emptyCount / records.length;
|
|
311
|
+
if (emptyRatio >= boundedThreshold) {
|
|
312
|
+
keysToRemove.add(key);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return records.map((record) => {
|
|
317
|
+
const nextEntries = Object.entries(record).filter(([key]) => !keysToRemove.has(key));
|
|
318
|
+
return Object.fromEntries(nextEntries) as T;
|
|
319
|
+
});
|
|
320
|
+
}
|