@skyhook-io/radar-app 1.13.1 → 1.13.2
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 +2 -2
- package/src/App.tsx +19 -1
- package/src/RadarApp.tsx +2 -2
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +72 -9
- package/src/components/diagnose/AISettings.tsx +1 -1
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +12 -17
- package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
- package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +289 -119
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
- package/src/components/diagnose/InvestigationView.test.ts +17 -0
- package/src/components/diagnose/InvestigationView.tsx +1900 -393
- package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
- package/src/components/diagnose/agentCatalog.ts +1 -1
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
- package/src/components/diagnose/investigationEvidence.ts +3492 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
- package/src/components/diagnose/investigationExplanation.test.ts +63 -0
- package/src/components/diagnose/investigationExplanation.ts +22 -0
- package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
- package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
- package/src/components/diagnose/investigationSourceFocus.ts +98 -0
- package/src/components/diagnose/investigationState.test.ts +695 -0
- package/src/components/diagnose/investigationState.ts +451 -0
- package/src/components/diagnose/parts.test.tsx +864 -3
- package/src/components/diagnose/parts.tsx +1337 -541
- package/src/components/diagnose/target.test.ts +39 -0
- package/src/components/diagnose/target.ts +36 -0
- package/src/components/diagnose/useDisclosureReveal.ts +117 -0
- package/src/components/home/MCPSetupDialog.tsx +2 -2
- package/src/components/home/mcpToolCatalog.test.ts +22 -0
- package/src/components/home/mcpToolCatalog.ts +3 -2
- package/src/components/issues/IssuesPane.tsx +5 -1
- package/src/components/settings/SettingsDialog.tsx +11 -13
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +11 -8
- package/src/index.css +63 -79
- package/src/index.ts +1 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
const PRIMARY_CONFIG_KEY_MARKERS = ["address", "host", "url", "endpoint"];
|
|
2
|
+
const SECONDARY_CONFIG_KEY_MARKERS = ["user", "database", "db", "name"];
|
|
3
|
+
export const INVESTIGATION_CONFIG_ROW_LIMIT = 8;
|
|
4
|
+
const SENSITIVE_KEY_TOKENS = new Set([
|
|
5
|
+
"password",
|
|
6
|
+
"passwd",
|
|
7
|
+
"passphrase",
|
|
8
|
+
"token",
|
|
9
|
+
"secret",
|
|
10
|
+
"key",
|
|
11
|
+
"credential",
|
|
12
|
+
"credentials",
|
|
13
|
+
"private",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
type ResourceRecord = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
export interface InvestigationResourceEvidenceInput {
|
|
19
|
+
kind: string;
|
|
20
|
+
metadata: {
|
|
21
|
+
annotations?: unknown;
|
|
22
|
+
creationTimestamp?: unknown;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
};
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InvestigationConfigEntry {
|
|
29
|
+
key: string;
|
|
30
|
+
value?: string;
|
|
31
|
+
binary: boolean;
|
|
32
|
+
sensitive: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface InvestigationResourceConditionEvidence {
|
|
36
|
+
type: string;
|
|
37
|
+
status: string;
|
|
38
|
+
reason?: string;
|
|
39
|
+
message?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type InvestigationResourceEvidenceModel =
|
|
43
|
+
| {
|
|
44
|
+
kind: "configmap";
|
|
45
|
+
entries: InvestigationConfigEntry[];
|
|
46
|
+
summary: string;
|
|
47
|
+
hasDetails: boolean;
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
kind: "secret";
|
|
51
|
+
keys: string[];
|
|
52
|
+
summary: string;
|
|
53
|
+
hasDetails: boolean;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
kind: "sealedsecret";
|
|
57
|
+
encryptedKeys: string[];
|
|
58
|
+
conditions: InvestigationResourceConditionEvidence[];
|
|
59
|
+
syncLabel: "Synced" | "Not synced" | "Sync unknown";
|
|
60
|
+
scope: "Cluster-wide" | "Namespace-wide" | "Strict";
|
|
61
|
+
observedGeneration?: string;
|
|
62
|
+
created?: { dateTime: string; label: string };
|
|
63
|
+
summary: string;
|
|
64
|
+
hasDetails: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function valueRecord(value: unknown): ResourceRecord | undefined {
|
|
68
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
69
|
+
? (value as ResourceRecord)
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stringValue(value: unknown): string | undefined {
|
|
74
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function keyMarkers(key: string): string[] {
|
|
78
|
+
return key
|
|
79
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.split(/[^a-z0-9]+/)
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compactKey(key: string): string {
|
|
86
|
+
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isSensitiveInvestigationConfigKey(key: string): boolean {
|
|
90
|
+
const tokens = keyMarkers(key);
|
|
91
|
+
if (tokens.some((token) => SENSITIVE_KEY_TOKENS.has(token))) return true;
|
|
92
|
+
|
|
93
|
+
const compact = compactKey(key);
|
|
94
|
+
return [
|
|
95
|
+
"password",
|
|
96
|
+
"passphrase",
|
|
97
|
+
"token",
|
|
98
|
+
"secret",
|
|
99
|
+
"credential",
|
|
100
|
+
"privatekey",
|
|
101
|
+
"apikey",
|
|
102
|
+
"accesskey",
|
|
103
|
+
].some((marker) => compact.includes(marker));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isSensitiveInvestigationConfigValue(value: string): boolean {
|
|
107
|
+
return (
|
|
108
|
+
/\b[a-z][a-z0-9+.-]*:\/\/[^:/@\s]*:[^/\s?#]+@/i.test(value) ||
|
|
109
|
+
/\bBearer\s+[A-Za-z0-9\-._~+/]{20,}/i.test(value) ||
|
|
110
|
+
/\bsk-[A-Za-z0-9_-]{20,}\b/.test(value) ||
|
|
111
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(value) ||
|
|
112
|
+
/\bAKIA[0-9A-Z]{16}\b/.test(value) ||
|
|
113
|
+
/\bgh[oprsu]_[A-Za-z0-9]{20,}\b/.test(value) ||
|
|
114
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,}\b/.test(value) ||
|
|
115
|
+
/password[=:]\s*\S{8,}/i.test(value) ||
|
|
116
|
+
/\$(?:apr1|2[aby]|5|6)\$[./A-Za-z0-9$]{8,}/.test(value) ||
|
|
117
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/.test(
|
|
118
|
+
value,
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function configKeyPriority(key: string): number {
|
|
124
|
+
const compact = compactKey(key);
|
|
125
|
+
if (PRIMARY_CONFIG_KEY_MARKERS.some((marker) => compact.includes(marker))) {
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
if (SECONDARY_CONFIG_KEY_MARKERS.some((marker) => compact.includes(marker))) {
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function configEntries(
|
|
135
|
+
resource: InvestigationResourceEvidenceInput,
|
|
136
|
+
): InvestigationConfigEntry[] {
|
|
137
|
+
const plainData = valueRecord(resource.data);
|
|
138
|
+
const binaryData = valueRecord(resource.binaryData);
|
|
139
|
+
const entries: InvestigationConfigEntry[] = [];
|
|
140
|
+
|
|
141
|
+
for (const [key, value] of Object.entries(plainData ?? {})) {
|
|
142
|
+
if (typeof value !== "string") continue;
|
|
143
|
+
entries.push({
|
|
144
|
+
key,
|
|
145
|
+
value,
|
|
146
|
+
binary: false,
|
|
147
|
+
sensitive:
|
|
148
|
+
isSensitiveInvestigationConfigKey(key) ||
|
|
149
|
+
isSensitiveInvestigationConfigValue(value),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
for (const key of Object.keys(binaryData ?? {})) {
|
|
153
|
+
entries.push({
|
|
154
|
+
key,
|
|
155
|
+
binary: true,
|
|
156
|
+
sensitive: isSensitiveInvestigationConfigKey(key),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return entries.sort(
|
|
161
|
+
(left, right) =>
|
|
162
|
+
configKeyPriority(left.key) - configKeyPriority(right.key) ||
|
|
163
|
+
left.key.localeCompare(right.key),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function secretKeys(resource: InvestigationResourceEvidenceInput): string[] {
|
|
168
|
+
// Radar's current get_resource producer structurally removes Secret values
|
|
169
|
+
// and emits only this explicit key-name list. Do not inspect data/stringData:
|
|
170
|
+
// this stays fail-closed even if an unexpected object reaches the UI.
|
|
171
|
+
if (!Array.isArray(resource.keys)) return [];
|
|
172
|
+
return resource.keys
|
|
173
|
+
.filter((key): key is string => typeof key === "string")
|
|
174
|
+
.sort((left, right) => left.localeCompare(right));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sealedSecretEncryptedKeys(
|
|
178
|
+
resource: InvestigationResourceEvidenceInput,
|
|
179
|
+
): string[] {
|
|
180
|
+
const spec = valueRecord(resource.spec);
|
|
181
|
+
return Object.keys(valueRecord(spec?.encryptedData) ?? {}).sort(
|
|
182
|
+
(left, right) => left.localeCompare(right),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resourceConditions(
|
|
187
|
+
resource: InvestigationResourceEvidenceInput,
|
|
188
|
+
): InvestigationResourceConditionEvidence[] {
|
|
189
|
+
const status = valueRecord(resource.status);
|
|
190
|
+
if (!Array.isArray(status?.conditions)) return [];
|
|
191
|
+
|
|
192
|
+
return status.conditions.flatMap((value) => {
|
|
193
|
+
const condition = valueRecord(value);
|
|
194
|
+
const type = stringValue(condition?.type);
|
|
195
|
+
const conditionStatus = stringValue(condition?.status);
|
|
196
|
+
if (!type || !conditionStatus) return [];
|
|
197
|
+
return [
|
|
198
|
+
{
|
|
199
|
+
type,
|
|
200
|
+
status: conditionStatus,
|
|
201
|
+
reason: stringValue(condition?.reason),
|
|
202
|
+
message: stringValue(condition?.message),
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sealedSecretScope(
|
|
209
|
+
resource: InvestigationResourceEvidenceInput,
|
|
210
|
+
): "Cluster-wide" | "Namespace-wide" | "Strict" {
|
|
211
|
+
const annotations = valueRecord(resource.metadata.annotations);
|
|
212
|
+
if (annotations?.["sealedsecrets.bitnami.com/cluster-wide"] === "true") {
|
|
213
|
+
return "Cluster-wide";
|
|
214
|
+
}
|
|
215
|
+
if (annotations?.["sealedsecrets.bitnami.com/namespace-wide"] === "true") {
|
|
216
|
+
return "Namespace-wide";
|
|
217
|
+
}
|
|
218
|
+
return "Strict";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sealedSecretSyncLabel(
|
|
222
|
+
conditions: InvestigationResourceConditionEvidence[],
|
|
223
|
+
): "Synced" | "Not synced" | "Sync unknown" {
|
|
224
|
+
const synced = conditions.find(
|
|
225
|
+
(condition) => condition.type.toLowerCase() === "synced",
|
|
226
|
+
);
|
|
227
|
+
if (synced?.status.toLowerCase() === "true") return "Synced";
|
|
228
|
+
if (synced?.status.toLowerCase() === "false") return "Not synced";
|
|
229
|
+
return "Sync unknown";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function timestamp(
|
|
233
|
+
value: unknown,
|
|
234
|
+
): { dateTime: string; label: string } | undefined {
|
|
235
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
236
|
+
const date = new Date(value);
|
|
237
|
+
if (Number.isNaN(date.getTime())) return { dateTime: value, label: value };
|
|
238
|
+
const iso = date.toISOString();
|
|
239
|
+
return {
|
|
240
|
+
dateTime: value,
|
|
241
|
+
label: `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function resourceKeySummary(keys: string[], previewLimit = 3): string {
|
|
246
|
+
if (keys.length === 0) return "No key names in this result";
|
|
247
|
+
const remaining = keys.length - previewLimit;
|
|
248
|
+
return `Keys: ${keys.slice(0, previewLimit).join(", ")}${remaining > 0 ? ` · ${remaining} more` : ""}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function buildInvestigationResourceEvidenceModel(
|
|
252
|
+
resource: InvestigationResourceEvidenceInput,
|
|
253
|
+
): InvestigationResourceEvidenceModel | undefined {
|
|
254
|
+
switch (resource.kind.toLowerCase()) {
|
|
255
|
+
case "configmap": {
|
|
256
|
+
const entries = configEntries(resource);
|
|
257
|
+
return {
|
|
258
|
+
kind: "configmap",
|
|
259
|
+
entries,
|
|
260
|
+
summary: resourceKeySummary(
|
|
261
|
+
entries.map((entry) => entry.key),
|
|
262
|
+
INVESTIGATION_CONFIG_ROW_LIMIT,
|
|
263
|
+
),
|
|
264
|
+
hasDetails: entries.length > 0,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
case "secret": {
|
|
268
|
+
const keys = secretKeys(resource);
|
|
269
|
+
return {
|
|
270
|
+
kind: "secret",
|
|
271
|
+
keys,
|
|
272
|
+
summary: resourceKeySummary(keys),
|
|
273
|
+
hasDetails: keys.length > 3,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
case "sealedsecret": {
|
|
277
|
+
const encryptedKeys = sealedSecretEncryptedKeys(resource);
|
|
278
|
+
const conditions = resourceConditions(resource);
|
|
279
|
+
const syncLabel = sealedSecretSyncLabel(conditions);
|
|
280
|
+
const status = valueRecord(resource.status);
|
|
281
|
+
const observedGeneration =
|
|
282
|
+
typeof status?.observedGeneration === "number" ||
|
|
283
|
+
typeof status?.observedGeneration === "string"
|
|
284
|
+
? String(status.observedGeneration)
|
|
285
|
+
: undefined;
|
|
286
|
+
const created = timestamp(resource.metadata.creationTimestamp);
|
|
287
|
+
const scope = sealedSecretScope(resource);
|
|
288
|
+
return {
|
|
289
|
+
kind: "sealedsecret",
|
|
290
|
+
encryptedKeys,
|
|
291
|
+
conditions,
|
|
292
|
+
syncLabel,
|
|
293
|
+
scope,
|
|
294
|
+
observedGeneration,
|
|
295
|
+
created,
|
|
296
|
+
summary: `${encryptedKeys.length} encrypted ${encryptedKeys.length === 1 ? "key" : "keys"} · ${syncLabel}`,
|
|
297
|
+
hasDetails: Boolean(
|
|
298
|
+
encryptedKeys.length > 0 ||
|
|
299
|
+
conditions.length > 0 ||
|
|
300
|
+
created ||
|
|
301
|
+
scope !== "Strict",
|
|
302
|
+
),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
default:
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A compact, redacted summary suitable for the collapsed evidence-card row. */
|
|
311
|
+
export function investigationResourceEvidenceSummary(
|
|
312
|
+
resource: InvestigationResourceEvidenceInput,
|
|
313
|
+
): string | undefined {
|
|
314
|
+
return buildInvestigationResourceEvidenceModel(resource)?.summary;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Whether the specialized resource body adds facts beyond its summary. */
|
|
318
|
+
export function investigationResourceEvidenceHasDetails(
|
|
319
|
+
resource: InvestigationResourceEvidenceInput,
|
|
320
|
+
): boolean {
|
|
321
|
+
return buildInvestigationResourceEvidenceModel(resource)?.hasDetails ?? false;
|
|
322
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
locateSourceExcerpt,
|
|
4
|
+
evidenceSourceExcerpt,
|
|
5
|
+
} from "./investigationSourceFocus";
|
|
6
|
+
|
|
7
|
+
describe("source excerpt targeting", () => {
|
|
8
|
+
it("targets the selected crash field when the same line is repeated in current and previous logs", () => {
|
|
9
|
+
const line = "2026-09-06T07:04:54Z Error: Missing MONGO_PASSWORD";
|
|
10
|
+
const display = JSON.stringify(
|
|
11
|
+
{
|
|
12
|
+
logsCurrent: { lines: [line] },
|
|
13
|
+
logsPrevious: { lines: [line] },
|
|
14
|
+
crashCauses: [{ logLine: line }],
|
|
15
|
+
},
|
|
16
|
+
null,
|
|
17
|
+
2,
|
|
18
|
+
);
|
|
19
|
+
expect(locateSourceExcerpt(display, line)).toBeUndefined();
|
|
20
|
+
const range = locateSourceExcerpt(display, {
|
|
21
|
+
text: line,
|
|
22
|
+
field: "logLine",
|
|
23
|
+
})!;
|
|
24
|
+
expect(display.slice(range.start, range.end)).toBe(JSON.stringify(line));
|
|
25
|
+
expect(range.start).toBeGreaterThan(display.indexOf('"crashCauses"'));
|
|
26
|
+
expect(
|
|
27
|
+
locateSourceExcerpt(
|
|
28
|
+
JSON.stringify({ a: { logLine: line }, b: { logLine: line } }, null, 2),
|
|
29
|
+
{ text: line, field: "logLine" },
|
|
30
|
+
),
|
|
31
|
+
).toBeUndefined();
|
|
32
|
+
expect(
|
|
33
|
+
locateSourceExcerpt(JSON.stringify({ lines: [line] }, null, 2), {
|
|
34
|
+
text: line,
|
|
35
|
+
field: "logLine",
|
|
36
|
+
}),
|
|
37
|
+
).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
it("locates a single Secret key, never its values or a guess among multiple keys", () => {
|
|
40
|
+
expect(
|
|
41
|
+
evidenceSourceExcerpt({
|
|
42
|
+
type: "resource",
|
|
43
|
+
warnings: [],
|
|
44
|
+
resource: {
|
|
45
|
+
apiVersion: "v1",
|
|
46
|
+
metadata: { name: "api", namespace: "dev" },
|
|
47
|
+
kind: "Secret",
|
|
48
|
+
keys: ["QUALIFIRE_API_KEY"],
|
|
49
|
+
data: { password: "do-not-use" },
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
).toBe("QUALIFIRE_API_KEY");
|
|
53
|
+
expect(
|
|
54
|
+
evidenceSourceExcerpt({
|
|
55
|
+
type: "resource",
|
|
56
|
+
warnings: [],
|
|
57
|
+
resource: {
|
|
58
|
+
apiVersion: "v1",
|
|
59
|
+
metadata: { name: "api", namespace: "dev" },
|
|
60
|
+
kind: "Secret",
|
|
61
|
+
keys: ["FIRST_KEY", "SECOND_KEY"],
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
).toBeUndefined();
|
|
65
|
+
expect(
|
|
66
|
+
evidenceSourceExcerpt({
|
|
67
|
+
type: "resource",
|
|
68
|
+
warnings: [],
|
|
69
|
+
resource: {
|
|
70
|
+
apiVersion: "v1",
|
|
71
|
+
metadata: { name: "api", namespace: "dev" },
|
|
72
|
+
kind: "Secret",
|
|
73
|
+
data: { password: "do-not-use" },
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
it("targets a unique original log line", () => {
|
|
79
|
+
const text = "before\nMissing required MONGO_PASSWORD\nafter";
|
|
80
|
+
const range = locateSourceExcerpt(text, "Missing required MONGO_PASSWORD")!;
|
|
81
|
+
expect(text.slice(range.start, range.end)).toBe(
|
|
82
|
+
"Missing required MONGO_PASSWORD",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
it("does not turn one line of a multi-line log collection into the source locator", () => {
|
|
86
|
+
const data = {
|
|
87
|
+
type: "logs" as const,
|
|
88
|
+
pod: "api-abc",
|
|
89
|
+
container: "api",
|
|
90
|
+
previous: false,
|
|
91
|
+
warnings: [],
|
|
92
|
+
logs: {
|
|
93
|
+
lines: ["ERROR missing DATABASE_URL"],
|
|
94
|
+
totalLines: 2,
|
|
95
|
+
matchedLines: 1,
|
|
96
|
+
fallback: false,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
expect(evidenceSourceExcerpt(data)).toBe("ERROR missing DATABASE_URL");
|
|
100
|
+
expect(
|
|
101
|
+
evidenceSourceExcerpt({
|
|
102
|
+
...data,
|
|
103
|
+
logs: {
|
|
104
|
+
...data.logs,
|
|
105
|
+
lines: ["ERROR missing DATABASE_URL", "ERROR startup failed"],
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
it("targets JSON-escaped original text, including quotes and newlines", () => {
|
|
111
|
+
const message = 'Missing "secret"\nconfiguration';
|
|
112
|
+
const text = JSON.stringify({ message }, null, 2);
|
|
113
|
+
const range = locateSourceExcerpt(text, message)!;
|
|
114
|
+
expect(text.slice(range.start, range.end)).toBe(JSON.stringify(message));
|
|
115
|
+
});
|
|
116
|
+
it("does not guess for absent, short or repeated content", () => {
|
|
117
|
+
expect(
|
|
118
|
+
locateSourceExcerpt("redacted", "Missing required MONGO_PASSWORD"),
|
|
119
|
+
).toBeUndefined();
|
|
120
|
+
expect(locateSourceExcerpt("Error", "Error")).toBeUndefined();
|
|
121
|
+
expect(
|
|
122
|
+
locateSourceExcerpt("long message; long message", "long message"),
|
|
123
|
+
).toBeUndefined();
|
|
124
|
+
expect(
|
|
125
|
+
locateSourceExcerpt(
|
|
126
|
+
'{"a":"long message","b":"long message"}',
|
|
127
|
+
"long message",
|
|
128
|
+
),
|
|
129
|
+
).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
it("does not invent a locator for aggregate or absence evidence", () => {
|
|
132
|
+
expect(
|
|
133
|
+
evidenceSourceExcerpt({ type: "events", events: [], scope: "namespace" }),
|
|
134
|
+
).toBeUndefined();
|
|
135
|
+
expect(
|
|
136
|
+
evidenceSourceExcerpt({
|
|
137
|
+
type: "inventory",
|
|
138
|
+
resources: [],
|
|
139
|
+
scope: "namespace",
|
|
140
|
+
}),
|
|
141
|
+
).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { InvestigationEvidenceData } from "./investigationEvidence";
|
|
2
|
+
|
|
3
|
+
export type InvestigationSourceExcerpt =
|
|
4
|
+
string | { text: string; field: "logLine" };
|
|
5
|
+
|
|
6
|
+
// Only exact producer-authored text is eligible. Titles and agent prose are
|
|
7
|
+
// not locators; an ambiguous or missing match leaves the whole result visible.
|
|
8
|
+
export function evidenceSourceExcerpt(
|
|
9
|
+
data: InvestigationEvidenceData,
|
|
10
|
+
): InvestigationSourceExcerpt | undefined {
|
|
11
|
+
switch (data.type) {
|
|
12
|
+
case "crash":
|
|
13
|
+
return { text: data.crash.logLine, field: "logLine" };
|
|
14
|
+
case "startup":
|
|
15
|
+
return data.blocker.message;
|
|
16
|
+
case "issue":
|
|
17
|
+
return data.issue.message;
|
|
18
|
+
case "logs":
|
|
19
|
+
return data.logs?.lines?.length === 1 ? data.logs.lines[0] : undefined;
|
|
20
|
+
case "events":
|
|
21
|
+
return data.events.length === 1 ? data.events[0].message : undefined;
|
|
22
|
+
case "resource": {
|
|
23
|
+
// Secret values are deliberately never inspected, even to find a locator.
|
|
24
|
+
const keys = data.resource.keys;
|
|
25
|
+
return data.resource.kind === "Secret" &&
|
|
26
|
+
Array.isArray(keys) &&
|
|
27
|
+
keys.length === 1 &&
|
|
28
|
+
typeof keys[0] === "string"
|
|
29
|
+
? keys[0]
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
32
|
+
default:
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function locateSourceExcerpt(
|
|
38
|
+
display: string,
|
|
39
|
+
excerpt?: InvestigationSourceExcerpt,
|
|
40
|
+
): { start: number; end: number } | undefined {
|
|
41
|
+
if (!excerpt) return undefined;
|
|
42
|
+
const text = typeof excerpt === "string" ? excerpt : excerpt.text;
|
|
43
|
+
if (text.trim().length < 8) return undefined;
|
|
44
|
+
if (typeof excerpt !== "string") {
|
|
45
|
+
// PayloadBlock pretty-prints parsed JSON. Scope a selected crash line to
|
|
46
|
+
// its producer's logLine field: the same text may also occur in both log
|
|
47
|
+
// streams. Still reject multiple matching fields rather than picking one.
|
|
48
|
+
const prefix = `${JSON.stringify(excerpt.field)}: `;
|
|
49
|
+
const needle = prefix + JSON.stringify(text);
|
|
50
|
+
const start = display.indexOf(needle);
|
|
51
|
+
if (start < 0 || display.indexOf(needle, start + 1) !== -1)
|
|
52
|
+
return undefined;
|
|
53
|
+
return { start: start + prefix.length, end: start + needle.length };
|
|
54
|
+
}
|
|
55
|
+
// Structured output contains JSON-escaped strings; plain logs do not.
|
|
56
|
+
const quoted = JSON.stringify(text);
|
|
57
|
+
const needle = display.includes(quoted) ? quoted : text;
|
|
58
|
+
const start = display.indexOf(needle);
|
|
59
|
+
if (start < 0 || display.indexOf(needle, start + 1) !== -1) return undefined;
|
|
60
|
+
return { start, end: start + needle.length };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function highlightRelatedEvidence(
|
|
64
|
+
row: HTMLElement,
|
|
65
|
+
sourceId?: string,
|
|
66
|
+
): void {
|
|
67
|
+
const workspace = row.closest("[data-investigation-workspace]");
|
|
68
|
+
if (!workspace) return;
|
|
69
|
+
workspace
|
|
70
|
+
.querySelectorAll("[data-source-related]")
|
|
71
|
+
.forEach((node) => node.removeAttribute("data-source-related"));
|
|
72
|
+
const findings = workspace.querySelector<HTMLElement>(
|
|
73
|
+
"[data-investigation-findings-scroll]",
|
|
74
|
+
);
|
|
75
|
+
const activity = workspace.querySelector<HTMLElement>(
|
|
76
|
+
"[data-investigation-activity-scroll]",
|
|
77
|
+
);
|
|
78
|
+
if (!sourceId || !findings?.offsetParent || !activity?.offsetParent) return;
|
|
79
|
+
const viewport = findings.getBoundingClientRect();
|
|
80
|
+
workspace
|
|
81
|
+
.querySelectorAll<HTMLElement>("[data-evidence-source]")
|
|
82
|
+
.forEach((card) => {
|
|
83
|
+
if (
|
|
84
|
+
card.dataset.evidenceSource !== sourceId ||
|
|
85
|
+
!card.getClientRects().length
|
|
86
|
+
)
|
|
87
|
+
return;
|
|
88
|
+
const rect = card.getBoundingClientRect();
|
|
89
|
+
if (
|
|
90
|
+
rect.bottom > viewport.top &&
|
|
91
|
+
rect.top < viewport.bottom &&
|
|
92
|
+
rect.right > viewport.left &&
|
|
93
|
+
rect.left < viewport.right
|
|
94
|
+
) {
|
|
95
|
+
card.setAttribute("data-source-related", "true");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|