@skyhook-io/radar-app 1.13.3 → 1.13.5

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.
Files changed (45) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +68 -23
  3. package/src/RadarApp.tsx +17 -1
  4. package/src/api/client.ts +28 -10
  5. package/src/api/diagnose.ts +61 -15
  6. package/src/components/ConnectionErrorView.test.tsx +30 -0
  7. package/src/components/ConnectionErrorView.tsx +31 -19
  8. package/src/components/diagnose/AgentCase.tsx +131 -0
  9. package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
  10. package/src/components/diagnose/DiagnoseContext.tsx +402 -77
  11. package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
  12. package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
  13. package/src/components/diagnose/Home.test.tsx +23 -1
  14. package/src/components/diagnose/Home.tsx +49 -3
  15. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
  16. package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
  17. package/src/components/diagnose/InvestigationView.tsx +227 -5
  18. package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
  19. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  20. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  21. package/src/components/diagnose/investigationCase.ts +439 -0
  22. package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
  23. package/src/components/diagnose/investigationEvidence.ts +2246 -166
  24. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  27. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  28. package/src/components/diagnose/investigationMetrics.ts +393 -0
  29. package/src/components/diagnose/investigationSourceFocus.ts +6 -0
  30. package/src/components/diagnose/investigationState.test.ts +322 -0
  31. package/src/components/diagnose/investigationState.ts +147 -25
  32. package/src/components/diagnose/parts.test.tsx +537 -1
  33. package/src/components/diagnose/parts.tsx +486 -42
  34. package/src/components/resource/HPACharts.render.test.tsx +77 -0
  35. package/src/components/resource/HPACharts.tsx +32 -25
  36. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  37. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  38. package/src/components/resources/PodFilePreview.tsx +394 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  40. package/src/components/resources/ResourcesView.tsx +27 -2
  41. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  42. package/src/context/DiagnoseCustomization.tsx +14 -2
  43. package/src/index.ts +8 -5
  44. package/src/utils/shell-safe.test.ts +25 -1
  45. package/src/utils/shell-safe.ts +16 -0
@@ -30,6 +30,8 @@ import {
30
30
  investigationEvidenceInputsEqual,
31
31
  investigationEvidenceCoverageLimited,
32
32
  investigationEvidenceConflictsWithHealthy,
33
+ investigationHealthConflictExplainedBy,
34
+ investigationLiveCaseTurnIndex,
33
35
  investigationEndedBeforeConclusion,
34
36
  type InvestigationHistoryUnavailableState,
35
37
  investigationHistoryUnavailablePresentation,
@@ -78,6 +80,7 @@ import {
78
80
  AssessmentSources,
79
81
  ApplyDialog,
80
82
  appendThinking,
83
+ mergeStartupSignal,
81
84
  upsertTool,
82
85
  type Turn,
83
86
  } from "./parts";
@@ -88,9 +91,16 @@ import {
88
91
  projectInvestigationEvidence,
89
92
  resolveInvestigationRootCauseEvidence,
90
93
  } from "./investigationEvidence";
94
+ import {
95
+ resolveInvestigationCase,
96
+ investigationCaseItemsStillRendered,
97
+ mergeInvestigationCases,
98
+ type InvestigationCaseResolution,
99
+ } from "./investigationCase";
91
100
  import {
92
101
  InvestigationEvidencePane,
93
102
  partitionInvestigationEvidence,
103
+ type InvestigationTimelineScope,
94
104
  } from "./InvestigationEvidencePane";
95
105
  import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
96
106
  import { formatInvestigationTarget } from "./target";
@@ -153,12 +163,15 @@ export function InvestigationView({
153
163
  agentLabel,
154
164
  maximized,
155
165
  onOpenResource,
166
+ onOpenTimeline,
156
167
  }: {
157
168
  run: RunSummary;
158
169
  agentLabel: string;
159
170
  maximized: boolean;
160
171
  /** Opens an unambiguous evidence subject in Radar's native resource views. */
161
172
  onOpenResource?: (ref: DiagnosisResourceRef) => void;
173
+ /** Opens Radar's Timeline filtered to the resource a changes card is about. */
174
+ onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
162
175
  }) {
163
176
  const { kind, namespace, name } = run;
164
177
  // Apply is off for hosted agents (read-only server-side). Keyed on the selected
@@ -473,6 +486,14 @@ export function InvestigationView({
473
486
  },
474
487
  ]);
475
488
  break;
489
+ case "phase":
490
+ // Startup phases only feed the pending status line; a replayed
491
+ // finished turn is not running, so nothing shows for it.
492
+ updateLast((t) => {
493
+ const startup = mergeStartupSignal(t.startup, ev);
494
+ return startup === t.startup ? t : { ...t, startup };
495
+ });
496
+ break;
476
497
  case "thinking":
477
498
  if (ev.token) {
478
499
  if (live) {
@@ -963,15 +984,114 @@ export function InvestigationView({
963
984
  : undefined,
964
985
  [currentAssessment, currentAssessmentIdx, projection],
965
986
  );
987
+ // The agent's case binds for every assessment, healthy and inconclusive
988
+ // included, so it is resolved independently of the root cause.
989
+ const investigationCase = useMemo(
990
+ () =>
991
+ currentAssessment?.diagnosis
992
+ ? resolveInvestigationCase(
993
+ projection,
994
+ currentAssessment.diagnosis,
995
+ currentAssessmentIdx,
996
+ )
997
+ : undefined,
998
+ [currentAssessment, currentAssessmentIdx, projection],
999
+ );
1000
+ // A follow-up answer that cites evidence takes over the pane's case; the
1001
+ // assessment's own items are then listed read-only under it.
1002
+ const liveCaseTurnIdx = investigationLiveCaseTurnIndex(
1003
+ turns,
1004
+ currentAssessmentIdx,
1005
+ );
1006
+ const liveCaseIsCurrentAssessment = liveCaseTurnIdx === currentAssessmentIdx;
1007
+ // The qualification banner and the "Used for assessment" markers describe the
1008
+ // assessment on screen, so they always read its own resolution. A later turn's
1009
+ // citations only widen the selection (below): replacing the resolution would
1010
+ // drop the assessment's qualification and push its cited evidence back into
1011
+ // the withheld set.
1012
+ const paneResolution = rootCauseEvidenceResolution;
1013
+ // The live turn's own resolution stays available for the live turn's own
1014
+ // receipt: its sources are its, not the assessment's.
1015
+ const liveTurnResolution = useMemo(() => {
1016
+ if (liveCaseIsCurrentAssessment) return rootCauseEvidenceResolution;
1017
+ const diagnosis = turns[liveCaseTurnIdx]?.diagnosis;
1018
+ return diagnosis?.rootCause
1019
+ ? resolveInvestigationRootCauseEvidence(
1020
+ projection,
1021
+ diagnosis.rootCauseEvidence,
1022
+ liveCaseTurnIdx,
1023
+ )
1024
+ : undefined;
1025
+ }, [
1026
+ liveCaseIsCurrentAssessment,
1027
+ rootCauseEvidenceResolution,
1028
+ turns,
1029
+ liveCaseTurnIdx,
1030
+ projection,
1031
+ ]);
1032
+ const followUpSelectedGroupIds = useMemo(() => {
1033
+ if (liveCaseIsCurrentAssessment) return undefined;
1034
+ if (liveTurnResolution?.status !== "linked") return undefined;
1035
+ return liveTurnResolution.links.flatMap((link) =>
1036
+ link.originalGroupId
1037
+ ? [{ groupId: link.originalGroupId, source: link.source }]
1038
+ : [],
1039
+ );
1040
+ }, [liveCaseIsCurrentAssessment, liveTurnResolution]);
1041
+ // The live turn's own case, before anything is carried onto it. Its source
1042
+ // receipt must list what IT cited, not what the merge brought along.
1043
+ const liveTurnCase = useMemo(
1044
+ () =>
1045
+ liveCaseIsCurrentAssessment
1046
+ ? investigationCase
1047
+ : resolveInvestigationCase(
1048
+ projection,
1049
+ turns[liveCaseTurnIdx]?.diagnosis,
1050
+ liveCaseTurnIdx,
1051
+ ),
1052
+ [
1053
+ liveCaseIsCurrentAssessment,
1054
+ investigationCase,
1055
+ projection,
1056
+ turns,
1057
+ liveCaseTurnIdx,
1058
+ ],
1059
+ );
1060
+ const paneCase = useMemo(() => {
1061
+ const live = liveTurnCase;
1062
+ const earlier: InvestigationCaseResolution[] = [];
1063
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
1064
+ const diagnosis = turns[i]?.diagnosis;
1065
+ if (i === liveCaseTurnIdx || !diagnosis) continue;
1066
+ // Reuse the assessment's own resolution rather than resolving it a
1067
+ // second time: same projection, same result, and it keeps the items the
1068
+ // conflict banner reasons about the ones the merge received.
1069
+ earlier.push(
1070
+ i === currentAssessmentIdx && investigationCase
1071
+ ? investigationCase
1072
+ : resolveInvestigationCase(projection, diagnosis, i),
1073
+ );
1074
+ }
1075
+ return mergeInvestigationCases(live, earlier);
1076
+ }, [
1077
+ liveTurnCase,
1078
+ investigationCase,
1079
+ currentAssessmentIdx,
1080
+ projection,
1081
+ turns,
1082
+ liveCaseTurnIdx,
1083
+ ]);
966
1084
  const visibleEvidenceGroupIds = useMemo(
967
1085
  () =>
968
1086
  new Set(
969
1087
  partitionInvestigationEvidence(
970
1088
  projection.groups,
971
- rootCauseEvidenceResolution,
1089
+ paneResolution,
1090
+ paneCase,
1091
+ followUpSelectedGroupIds,
972
1092
  ).collectionByGroup.keys(),
973
1093
  ),
974
- [projection.groups, rootCauseEvidenceResolution],
1094
+ [projection.groups, paneResolution, paneCase, followUpSelectedGroupIds],
975
1095
  );
976
1096
  const evidenceStepIdsByTurn = useMemo(
977
1097
  () =>
@@ -1269,6 +1389,32 @@ export function InvestigationView({
1269
1389
  const currentAssessmentEvidenceConflict =
1270
1390
  currentAssessment?.diagnosis?.healthy === true &&
1271
1391
  investigationEvidenceConflictsWithHealthy(projection);
1392
+ // The banner qualifies THIS assessment, so only this assessment's own case
1393
+ // may reframe it. `paneCase` also carries a later answer's items and notes
1394
+ // inherited from superseded assessments; neither of those spoke about this
1395
+ // verdict, and letting them soften it would let an unrelated follow-up
1396
+ // retire a warning the reader still needs.
1397
+ const currentAssessmentEvidenceConflictExplainedBy = useMemo(() => {
1398
+ if (!currentAssessmentEvidenceConflict) return undefined;
1399
+ // Two conditions, and both are required. The note must belong to THIS
1400
+ // assessment — a later answer or a superseded assessment did not speak
1401
+ // about this verdict. And it must survive into the case the pane actually
1402
+ // renders: the merge lets a later turn take over a group, and a banner
1403
+ // that points at a note the reader cannot find is worse than no banner.
1404
+ const qualifying = investigationCaseItemsStillRendered(
1405
+ investigationCase?.items,
1406
+ paneCase?.items,
1407
+ );
1408
+ return (
1409
+ investigationHealthConflictExplainedBy(projection, qualifying) ??
1410
+ undefined
1411
+ );
1412
+ }, [
1413
+ currentAssessmentEvidenceConflict,
1414
+ projection,
1415
+ investigationCase,
1416
+ paneCase,
1417
+ ]);
1272
1418
  const hasEvidenceCollectedAfterAssessment =
1273
1419
  currentAssessmentIdx >= 0 &&
1274
1420
  projection.sources.some(
@@ -1676,6 +1822,7 @@ export function InvestigationView({
1676
1822
  <Fragment key={index}>
1677
1823
  <TurnView
1678
1824
  turn={turn}
1825
+ agentLabel={agentLabel}
1679
1826
  turnIndex={index}
1680
1827
  evidenceStepIds={evidenceStepIdsByTurn.get(index)}
1681
1828
  onViewEvidence={viewEvidenceSource}
@@ -1700,6 +1847,49 @@ export function InvestigationView({
1700
1847
  : undefined
1701
1848
  }
1702
1849
  hideConclusion={assessmentIndexes.includes(index)}
1850
+ assessmentSources={(() => {
1851
+ // Answer turns show what they cited; the live
1852
+ // one also drives Findings, earlier ones are
1853
+ // read-only.
1854
+ if (
1855
+ !turn.question ||
1856
+ turn.verify ||
1857
+ turn.apply ||
1858
+ turn.status !== "done" ||
1859
+ !turn.diagnosis
1860
+ )
1861
+ return undefined;
1862
+ const answerCase =
1863
+ index === liveCaseTurnIdx
1864
+ ? liveTurnCase
1865
+ : resolveInvestigationCase(
1866
+ projection,
1867
+ turn.diagnosis,
1868
+ index,
1869
+ );
1870
+ const answerResolution =
1871
+ index === liveCaseTurnIdx
1872
+ ? liveTurnResolution
1873
+ : undefined;
1874
+ return answerCase?.items.length ||
1875
+ answerResolution?.links.length ||
1876
+ turn.diagnosis?.unlinkedEvidence ||
1877
+ turn.diagnosis?.evidenceMalformed ? (
1878
+ <AssessmentSources
1879
+ renderedGroupIds={visibleEvidenceGroupIds}
1880
+ resolution={answerResolution}
1881
+ investigationCase={answerCase}
1882
+ unlinkedEvidence={
1883
+ turn.diagnosis?.unlinkedEvidence
1884
+ }
1885
+ evidenceMalformed={
1886
+ turn.diagnosis?.evidenceMalformed
1887
+ }
1888
+ readOnly={index !== liveCaseTurnIdx}
1889
+ onViewSource={viewActivitySource}
1890
+ />
1891
+ ) : undefined;
1892
+ })()}
1703
1893
  />
1704
1894
  {showSplitWorkspace &&
1705
1895
  index === currentAssessmentIdx &&
@@ -1938,9 +2128,24 @@ export function InvestigationView({
1938
2128
  }
1939
2129
  diagnosis={currentAssessment.diagnosis}
1940
2130
  assessmentSources={
1941
- rootCauseEvidenceResolution?.links.length ? (
2131
+ // An assessment whose every note was rejected has no
2132
+ // items and no links, and the loss is the only thing
2133
+ // there is to say about it.
2134
+ rootCauseEvidenceResolution?.links.length ||
2135
+ investigationCase?.items.length ||
2136
+ currentAssessment.diagnosis.unlinkedEvidence ||
2137
+ currentAssessment.diagnosis.evidenceMalformed ? (
1942
2138
  <AssessmentSources
2139
+ renderedGroupIds={visibleEvidenceGroupIds}
1943
2140
  resolution={rootCauseEvidenceResolution}
2141
+ investigationCase={investigationCase}
2142
+ unlinkedEvidence={
2143
+ currentAssessment.diagnosis?.unlinkedEvidence
2144
+ }
2145
+ evidenceMalformed={
2146
+ currentAssessment.diagnosis?.evidenceMalformed
2147
+ }
2148
+ readOnly={!liveCaseIsCurrentAssessment}
1944
2149
  onViewSource={viewActivitySource}
1945
2150
  />
1946
2151
  ) : undefined
@@ -1979,6 +2184,9 @@ export function InvestigationView({
1979
2184
  showDisclaimer={false}
1980
2185
  coverageLimited={currentAssessmentCoverageLimited}
1981
2186
  evidenceConflict={currentAssessmentEvidenceConflict}
2187
+ evidenceConflictExplainedBy={
2188
+ currentAssessmentEvidenceConflictExplainedBy
2189
+ }
1982
2190
  />
1983
2191
  ) : (
1984
2192
  <div className="mt-2 flex items-center gap-2 rounded-md bg-theme-surface/60 px-2.5 py-2 text-xs text-theme-text-tertiary">
@@ -2042,15 +2250,26 @@ export function InvestigationView({
2042
2250
  diagnosis: turns[index].diagnosis!,
2043
2251
  explanation: explanationFor(turns[index]),
2044
2252
  sources: (() => {
2253
+ // Each earlier assessment owns its own items; they
2254
+ // render read-only here and never annotate cards.
2045
2255
  const resolution =
2046
2256
  resolveInvestigationRootCauseEvidence(
2047
2257
  projection,
2048
2258
  turns[index].diagnosis!.rootCauseEvidence,
2049
2259
  index,
2050
2260
  );
2051
- return resolution.links.length ? (
2261
+ const earlierCase = resolveInvestigationCase(
2262
+ projection,
2263
+ turns[index].diagnosis,
2264
+ index,
2265
+ );
2266
+ return resolution.links.length ||
2267
+ earlierCase.items.length ? (
2052
2268
  <AssessmentSources
2269
+ renderedGroupIds={visibleEvidenceGroupIds}
2053
2270
  resolution={resolution}
2271
+ investigationCase={earlierCase}
2272
+ readOnly
2054
2273
  onViewSource={viewActivitySource}
2055
2274
  />
2056
2275
  ) : undefined;
@@ -2060,7 +2279,9 @@ export function InvestigationView({
2060
2279
 
2061
2280
  <InvestigationEvidencePane
2062
2281
  projection={projection}
2063
- rootCauseEvidence={rootCauseEvidenceResolution}
2282
+ rootCauseEvidence={paneResolution}
2283
+ alsoSelectedGroupIds={followUpSelectedGroupIds}
2284
+ investigationCase={paneCase}
2064
2285
  collecting={
2065
2286
  explanationRequest?.status !== "running" &&
2066
2287
  (requestPending ||
@@ -2074,6 +2295,7 @@ export function InvestigationView({
2074
2295
  onViewSource={viewActivitySource}
2075
2296
  onViewActivity={viewActivity}
2076
2297
  onOpenResource={stale ? undefined : onOpenResource}
2298
+ onOpenTimeline={stale ? undefined : onOpenTimeline}
2077
2299
  revealRequest={evidenceRevealRequest}
2078
2300
  onRevealReady={revealEvidenceSource}
2079
2301
  afterEvidence={
@@ -162,10 +162,9 @@ export function IssueDiagnoseButton({
162
162
  // investigations). Self-hides when no agent CLI is present.
163
163
  export function GlobalDiagnoseButton() {
164
164
  const d = useDiagnose();
165
- const { runningKeys } = useDiagnoseLayout();
165
+ const { runningCount } = useDiagnoseLayout();
166
166
  if (d.setupState === "off") return null;
167
167
  const ready = d.available;
168
- const runningCount = runningKeys.size;
169
168
  const agentSuffix = d.hosted
170
169
  ? `powered by ${d.agentLabel}`
171
170
  : `runs your own ${d.agentLabel} locally`;
@@ -181,7 +180,7 @@ export function GlobalDiagnoseButton() {
181
180
  position="bottom"
182
181
  >
183
182
  <button
184
- onClick={d.openHome}
183
+ onClick={() => d.openWorkspace()}
185
184
  className="relative rounded-md bg-theme-elevated p-1.5 text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
186
185
  aria-label={
187
186
  runningCount > 0
@@ -3,6 +3,8 @@
3
3
  * investigation projection. These deliberately exclude any presentation or
4
4
  * transport envelope so the live Diagnose UI is not coupled to another route.
5
5
  */
6
+ import type { HPADiagnosisView } from "@skyhook-io/k8s-ui";
7
+
6
8
  export interface DiagnosisFilteredLogs {
7
9
  lines: string[] | null;
8
10
  totalLines: number;
@@ -53,6 +55,25 @@ export interface DiagnosisResourceRef {
53
55
  name: string;
54
56
  }
55
57
 
58
+ /** The HPA diagnosis Radar computes for the scaler itself, attached to the workload it scales. */
59
+ export interface DiagnosisHPASummary extends HPADiagnosisView {
60
+ target?: DiagnosisResourceRef;
61
+ metrics?: Array<{
62
+ type: string;
63
+ name: string;
64
+ current?: string;
65
+ target?: string;
66
+ status: string;
67
+ }>;
68
+ }
69
+
70
+ export interface DiagnosisScalerRef extends DiagnosisResourceRef {
71
+ /** The KEDA ScaledObject that owns this HPA, when KEDA created it. */
72
+ managedBy?: DiagnosisResourceRef;
73
+ /** Present only for an HPA the caller may read; KEDA scalers carry the ref alone. */
74
+ hpaSummary?: DiagnosisHPASummary;
75
+ }
76
+
56
77
  export interface DiagnosisResourceContext {
57
78
  tier: "basic" | "diagnostic";
58
79
  owner?: DiagnosisResourceRef;
@@ -71,7 +92,7 @@ export interface DiagnosisResourceContext {
71
92
  pvcs?: DiagnosisResourceRef[];
72
93
  };
73
94
  runsOn?: DiagnosisResourceRef;
74
- scaledBy?: DiagnosisResourceRef[];
95
+ scaledBy?: DiagnosisScalerRef[];
75
96
  statusSummary?: {
76
97
  phase?: string;
77
98
  conditions?: Array<{