@skyhook-io/k8s-ui 1.14.3 → 1.14.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 (78) hide show
  1. package/package.json +2 -2
  2. package/src/components/charts/AreaChart.test.tsx +160 -0
  3. package/src/components/charts/AreaChart.tsx +191 -39
  4. package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
  5. package/src/components/charts/PrometheusChartsView.tsx +193 -76
  6. package/src/components/charts/SeriesLegend.tsx +8 -3
  7. package/src/components/charts/annotations.ts +59 -0
  8. package/src/components/charts/axis.test.ts +32 -0
  9. package/src/components/charts/axis.ts +60 -0
  10. package/src/components/charts/colors.test.ts +27 -0
  11. package/src/components/charts/colors.ts +23 -0
  12. package/src/components/charts/format.test.ts +19 -0
  13. package/src/components/charts/format.ts +17 -1
  14. package/src/components/charts/index.ts +3 -1
  15. package/src/components/charts/types.ts +12 -0
  16. package/src/components/logs/LogCore.tsx +182 -16
  17. package/src/components/logs/LogsViewer.tsx +4 -21
  18. package/src/components/logs/WorkloadLogsViewer.tsx +5 -23
  19. package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
  20. package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
  21. package/src/components/resources/ResourcesView.tsx +17 -3
  22. package/src/components/resources/index.ts +7 -0
  23. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +2 -2
  24. package/src/components/resources/renderers/AlertRenderer.tsx +3 -3
  25. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +5 -5
  26. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -3
  28. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +5 -5
  29. package/src/components/resources/renderers/ChallengeRenderer.tsx +8 -8
  30. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +3 -3
  31. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +2 -2
  32. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +3 -3
  33. package/src/components/resources/renderers/GatewayRenderer.tsx +2 -2
  34. package/src/components/resources/renderers/GenericRenderer.tsx +1 -1
  35. package/src/components/resources/renderers/HPARenderer.tsx +4 -57
  36. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -1
  37. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +3 -3
  38. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +2 -2
  39. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -3
  40. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -1
  42. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +23 -20
  43. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/NodeRenderer.tsx +3 -3
  45. package/src/components/resources/renderers/OrderRenderer.tsx +6 -6
  46. package/src/components/resources/renderers/RoleRenderer.tsx +3 -3
  47. package/src/components/resources/renderers/RolloutRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -3
  49. package/src/components/resources/renderers/SecretStoreRenderer.tsx +8 -8
  50. package/src/components/resources/renderers/VPARenderer.tsx +1 -1
  51. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -1
  52. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -9
  53. package/src/components/resources/renderers/WorkloadRenderer.tsx +4 -20
  54. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +6 -12
  55. package/src/components/resources/renderers/cnpg-cells.tsx +1 -1
  56. package/src/components/resources/renderers/eso-cells.tsx +17 -1
  57. package/src/components/resources/renderers/knative-cells.tsx +1 -1
  58. package/src/components/resources/renderers/kyverno-cells.tsx +7 -3
  59. package/src/components/resources/resource-utils-certmanager.ts +2 -2
  60. package/src/components/resources/resource-utils-eso.test.ts +26 -1
  61. package/src/components/resources/resource-utils-eso.ts +13 -0
  62. package/src/components/resources/resource-utils-knative.ts +3 -3
  63. package/src/components/topology/K8sResourceNode.test.ts +9 -0
  64. package/src/components/topology/K8sResourceNode.tsx +56 -5
  65. package/src/components/topology/TopologyGraph.tsx +18 -1
  66. package/src/components/topology/pod-phase-fallback.test.ts +24 -0
  67. package/src/components/ui/Badge.tsx +5 -1
  68. package/src/components/ui/index.ts +5 -0
  69. package/src/components/ui/monacoRuntime.ts +14 -0
  70. package/src/monaco-deep.d.ts +10 -0
  71. package/src/theme/components.css +29 -0
  72. package/src/types/core.ts +23 -10
  73. package/src/utils/badge-colors.ts +1 -1
  74. package/src/utils/index.ts +1 -0
  75. package/src/utils/log-export.test.ts +225 -0
  76. package/src/utils/log-export.ts +149 -0
  77. package/src/utils/navigation.test.ts +10 -0
  78. package/src/utils/navigation.ts +4 -0
@@ -168,6 +168,34 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
168
168
  );
169
169
  }
170
170
 
171
+ // Full port list for the Service subtitle's hover affordance — the subtitle
172
+ // itself only ever shows the first port + a count, so this is the only place
173
+ // a multi-port Service's other ports are visible without opening the detail
174
+ // page. Formatting (hide targetPort when it matches port) mirrors
175
+ // ServicePortCards in ServiceRenderer.tsx so a Service's ports read the same
176
+ // whether glanced at in the graph or opened in the full resource view.
177
+ function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
178
+ if (ports.length < 2) return null;
179
+ return (
180
+ <div className="max-w-xs space-y-0.5">
181
+ {ports.map((p, i) => {
182
+ const target =
183
+ p.targetPort != null && p.targetPort !== String(p.port)
184
+ ? ` → ${p.targetPort}`
185
+ : "";
186
+ const proto = p.appProtocol || p.protocol;
187
+ return (
188
+ <div key={i} className="text-[11px]">
189
+ {p.name && <span className="text-theme-text-tertiary">{p.name} </span>}
190
+ <span className="font-medium">{p.port}{target}</span>
191
+ {proto && <span className="text-theme-text-tertiary"> ({proto})</span>}
192
+ </div>
193
+ );
194
+ })}
195
+ </div>
196
+ );
197
+ }
198
+
171
199
  // Default dimensions for unknown CRD kinds
172
200
  export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 };
173
201
 
@@ -322,6 +350,16 @@ const SUMMARY_POD_KINDS = new Set<NodeKind>([
322
350
  "Service",
323
351
  ]);
324
352
 
353
+ // Shape of one entry in a Service node's nodeData.ports (pkg/topology/builder.go's
354
+ // serviceTopologyPorts). name/appProtocol are omitted server-side when unset.
355
+ export interface ServicePortEntry {
356
+ name?: string;
357
+ port: number;
358
+ targetPort?: string;
359
+ protocol?: string;
360
+ appProtocol?: string;
361
+ }
362
+
325
363
  export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>): string {
326
364
  if (nodeData.deploymentMembership === 'source-only') {
327
365
  return `Declared by ${nodeData.deploymentSourceLabel ?? 'deployment source'}`
@@ -369,8 +407,10 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
369
407
  return (nodeData.phase as string) || "Unknown";
370
408
  case "Service": {
371
409
  const svcType = (nodeData.type as string) || "ClusterIP";
372
- const port = nodeData.port;
373
- return port ? `${svcType} :${port}` : svcType;
410
+ const ports = (nodeData.ports as ServicePortEntry[] | undefined) || [];
411
+ if (ports.length === 0) return svcType;
412
+ const more = ports.length > 1 ? ` +${ports.length - 1} more` : "";
413
+ return `${svcType} :${ports[0].port}${more}`;
374
414
  }
375
415
  case "CalicoNetworkPolicy":
376
416
  case "CalicoGlobalNetworkPolicy":
@@ -513,6 +553,9 @@ export const K8sResourceNode = memo(function K8sResourceNode({
513
553
  const { ownerColorIndex } = ownershipOf(nodeData)
514
554
  const hue = ownerColorIndex !== null ? workloadHue(ownerColorIndex) : undefined
515
555
  const subtitle = getSubtitle(kind, nodeData)
556
+ const portsTooltip = kind === 'Service'
557
+ ? servicePortsTooltip((nodeData.ports as ServicePortEntry[] | undefined) || [])
558
+ : null
516
559
  const isInternet = kind === 'Internet'
517
560
  const isPodGroup = kind === 'PodGroup'
518
561
  const isSmallNode = kind === 'ConfigMap' || kind === 'Secret' || kind === 'ServiceAccount' || kind === 'SealedSecret' || kind === 'ServiceMonitor' || kind === 'PodMonitor' || kind === 'HorizontalPodAutoscaler'
@@ -730,9 +773,17 @@ export const K8sResourceNode = memo(function K8sResourceNode({
730
773
 
731
774
  {/* Subtitle */}
732
775
  {subtitle && (
733
- <div className="text-xs text-theme-text-secondary truncate mt-0.5">
734
- {subtitle}
735
- </div>
776
+ portsTooltip ? (
777
+ <Tooltip content={portsTooltip} position="bottom" wrapperClassName="max-w-full mt-0.5">
778
+ <div className="text-xs text-theme-text-secondary truncate cursor-help">
779
+ {subtitle}
780
+ </div>
781
+ </Tooltip>
782
+ ) : (
783
+ <div className="text-xs text-theme-text-secondary truncate mt-0.5">
784
+ {subtitle}
785
+ </div>
786
+ )
736
787
  )}
737
788
  </div>
738
789
  </div>
@@ -245,6 +245,23 @@ interface TopologyGraphProps {
245
245
  children?: ReactNode
246
246
  }
247
247
 
248
+ // A pod group's children normally carry the health the server computed. Without
249
+ // it, the phase is honest for every state except Running: a crash-looping pod
250
+ // sits at Phase=Running with its container restarting, so that one case says
251
+ // unknown rather than repeating the bug this fallback exists behind.
252
+ export function phaseOnlyHealth(phase: string | undefined): HealthStatus {
253
+ switch (phase) {
254
+ case 'Failed':
255
+ return 'unhealthy'
256
+ case 'Pending':
257
+ return 'degraded'
258
+ case 'Succeeded':
259
+ return 'neutral'
260
+ default:
261
+ return 'unknown'
262
+ }
263
+ }
264
+
248
265
  export function TopologyGraph({
249
266
  topology,
250
267
  viewMode,
@@ -430,7 +447,7 @@ export function TopologyGraph({
430
447
  // the kubelet's Waiting->Running oscillation, which the phase alone
431
448
  // hides — a crash-looping pod sits at Phase=Running. Deriving it here
432
449
  // again would rebuild that logic in a second place and get it wrong.
433
- status: pod.status ?? 'unknown',
450
+ status: pod.status ?? phaseOnlyHealth(pod.phase),
434
451
  data: {
435
452
  ...podGroupNode.data,
436
453
  namespace: pod.namespace,
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { phaseOnlyHealth } from './TopologyGraph'
3
+
4
+ // Only reached when the server sent no per-pod status — an older Radar behind a
5
+ // newer embedding host. The point of the fallback is that it must not repeat the
6
+ // bug it stands in for.
7
+ describe('phaseOnlyHealth', () => {
8
+ it('does not call a Running pod healthy', () => {
9
+ // A crash-looping pod sits at Phase=Running with its container restarting,
10
+ // so Running is the one phase that hides a fault.
11
+ expect(phaseOnlyHealth('Running')).toBe('unknown')
12
+ })
13
+
14
+ it('reads the phases that are honest on their own', () => {
15
+ expect(phaseOnlyHealth('Failed')).toBe('unhealthy')
16
+ expect(phaseOnlyHealth('Pending')).toBe('degraded')
17
+ expect(phaseOnlyHealth('Succeeded')).toBe('neutral')
18
+ })
19
+
20
+ it('says nothing for a phase it does not recognise', () => {
21
+ expect(phaseOnlyHealth(undefined)).toBe('unknown')
22
+ expect(phaseOnlyHealth('SomeFuturePhase')).toBe('unknown')
23
+ })
24
+ })
@@ -23,7 +23,10 @@ export type BadgeSize = 'sm' | 'default'
23
23
  // "tell these sibling options apart". The only place sharing is intended.
24
24
  // - structural: ports/paths/hosts/weights/names — neutral data fragments, not a
25
25
  // status. Its own name so it can diverge from severity-neutral later.
26
- export type BadgeTone = 'note' | 'accent1' | 'accent2' | 'accent3' | 'structural'
26
+ // - agent: words an AI agent attached to a Radar fact (evidence roles such
27
+ // as "Cause" / "Rules out"). Muted brand accent on purpose: never a severity
28
+ // hue, so an agent's framing can never be mistaken for a Radar finding.
29
+ export type BadgeTone = 'note' | 'accent1' | 'accent2' | 'accent3' | 'structural' | 'agent'
27
30
 
28
31
  interface BadgeProps {
29
32
  /** Severity-based coloring (status badges) */
@@ -231,6 +234,7 @@ const TONE: Record<BadgeTone, string> = {
231
234
  accent3: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
232
235
  // neutral data fragment (ports/paths/hosts/names)
233
236
  structural: 'bg-theme-elevated text-theme-text-secondary border-theme-border',
237
+ agent: 'bg-accent-muted text-accent-text border-accent/30',
234
238
  }
235
239
 
236
240
  // Structure classes
@@ -33,6 +33,11 @@ export { InClusterConsentDialog } from './InClusterConsentDialog'
33
33
  export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
34
34
  export { CodeViewer } from './CodeViewer'
35
35
  export { YamlEditor, YamlDiffEditor } from './YamlEditor'
36
+ // Boots the self-hosted Monaco runtime (bundled editor, local workers) without
37
+ // pulling monaco-editor into the main bundle. Anything that mounts a bare
38
+ // `Editor` must await this first, or @monaco-editor/react reaches for its CDN.
39
+ export const ensureMonacoRuntime = () =>
40
+ import('./monacoRuntime').then(({ ensureMonaco }) => ensureMonaco())
36
41
  export type {
37
42
  YamlDiagnostic,
38
43
  YamlDocumentIdentity,
@@ -2,7 +2,21 @@
2
2
 
3
3
  import { loader } from '@monaco-editor/react'
4
4
  import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
5
+ // Tokenizers only — the set the YAML editor and the pod file viewer hand to
6
+ // Monaco. JSON deliberately absent: its language lives in vs/language/json and
7
+ // spawns a validation worker this runtime does not ship; viewers highlight
8
+ // JSON with the YAML tokenizer, which covers it.
5
9
  import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
10
+ import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution'
11
+ import 'monaco-editor/esm/vs/basic-languages/html/html.contribution'
12
+ import 'monaco-editor/esm/vs/basic-languages/css/css.contribution'
13
+ import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
14
+ import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution'
15
+ import 'monaco-editor/esm/vs/basic-languages/python/python.contribution'
16
+ import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution'
17
+ import 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution'
18
+ import 'monaco-editor/esm/vs/basic-languages/ini/ini.contribution'
19
+ import 'monaco-editor/esm/vs/basic-languages/dockerfile/dockerfile.contribution'
6
20
  import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController.js'
7
21
  import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding.js'
8
22
  import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions.js'
@@ -3,3 +3,13 @@ declare module 'monaco-editor/esm/vs/editor/editor.api' {
3
3
  }
4
4
 
5
5
  declare module 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
6
+ declare module 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution'
7
+ declare module 'monaco-editor/esm/vs/basic-languages/html/html.contribution'
8
+ declare module 'monaco-editor/esm/vs/basic-languages/css/css.contribution'
9
+ declare module 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
10
+ declare module 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution'
11
+ declare module 'monaco-editor/esm/vs/basic-languages/python/python.contribution'
12
+ declare module 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution'
13
+ declare module 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution'
14
+ declare module 'monaco-editor/esm/vs/basic-languages/ini/ini.contribution'
15
+ declare module 'monaco-editor/esm/vs/basic-languages/dockerfile/dockerfile.contribution'
@@ -228,6 +228,35 @@
228
228
  border-color: transparent;
229
229
  }
230
230
 
231
+ /* Colour-named accents carry no health meaning — they exist so an
232
+ informational badge can pick a hue without borrowing a status semantic.
233
+ A categorical badge (an admission verb, a policy action, a configured
234
+ mode) belongs here; only evidence about a resource's condition belongs on
235
+ .status-healthy / -degraded / -unhealthy. */
236
+ .status-green {
237
+ background-color: light-dark(rgba(34, 197, 94, 0.15), rgba(34, 197, 94, 0.2));
238
+ color: light-dark(#15803d, #4ade80);
239
+ border-color: transparent;
240
+ }
241
+
242
+ .status-red {
243
+ background-color: light-dark(rgba(239, 68, 68, 0.15), rgba(239, 68, 68, 0.2));
244
+ color: light-dark(#b91c1c, #f87171);
245
+ border-color: transparent;
246
+ }
247
+
248
+ .status-amber {
249
+ background-color: light-dark(rgba(234, 179, 8, 0.15), rgba(234, 179, 8, 0.2));
250
+ color: light-dark(#a16207, #facc15);
251
+ border-color: transparent;
252
+ }
253
+
254
+ .status-blue {
255
+ background-color: light-dark(rgba(59, 130, 246, 0.15), rgba(59, 130, 246, 0.2));
256
+ color: light-dark(#2563eb, #60a5fa);
257
+ border-color: transparent;
258
+ }
259
+
231
260
  .status-cyan {
232
261
  background-color: light-dark(rgba(6, 182, 212, 0.15), rgba(6, 182, 212, 0.2));
233
262
  color: light-dark(#0891b2, #22d3ee);
package/src/types/core.ts CHANGED
@@ -516,6 +516,9 @@ export interface ContextInfo {
516
516
  /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by the backend
517
517
  * for contexts loaded through the isolated source registry. */
518
518
  source?: string
519
+ /** AWS profile extracted from the exec plugin's --profile arg or AWS_PROFILE
520
+ * env var. Present only for EKS contexts that pin a profile. */
521
+ awsProfile?: string
519
522
  }
520
523
 
521
524
  // Namespace
@@ -683,24 +686,34 @@ export type HPADiagnosisState =
683
686
  | 'stabilized'
684
687
  | 'unknown'
685
688
 
686
- export interface HPADiagnosis {
689
+ export interface HPABounds {
690
+ min: number
691
+ max: number
692
+ current: number
693
+ desired: number
694
+ observedGeneration?: number
695
+ generation?: number
696
+ }
697
+
698
+ // The subset of an HPA diagnosis that HPADiagnosisSummary presents. Producers
699
+ // that carry a richer or looser envelope (Radar's own HPADiagnosis, the
700
+ // investigation evidence projection) extend this so the presentation rules —
701
+ // state severity, state label, reason redundancy — cannot drift per surface.
702
+ export interface HPADiagnosisView {
687
703
  state: HPADiagnosisState
688
704
  summary: string
705
+ bounds?: HPABounds
706
+ reasons?: HPAReasonSummary[]
707
+ }
708
+
709
+ export interface HPADiagnosis extends HPADiagnosisView {
689
710
  target: {
690
711
  apiVersion?: string
691
712
  kind?: string
692
713
  name?: string
693
714
  }
694
- bounds: {
695
- min: number
696
- max: number
697
- current: number
698
- desired: number
699
- observedGeneration?: number
700
- generation?: number
701
- }
715
+ bounds: HPABounds
702
716
  metrics?: HPAMetricSummary[]
703
- reasons?: HPAReasonSummary[]
704
717
  }
705
718
 
706
719
  export interface HPAReasonSummary {
@@ -74,7 +74,7 @@ export const NODEPOOL_MODE_BADGE: Record<string, string> = {
74
74
 
75
75
  // Translucent gray badge for "inactive / unknown / pending / unset / disabled" states.
76
76
  // The de-facto fallback in renderers when no severity or category applies.
77
- export const BADGE_INACTIVE = 'bg-gray-500/20 text-gray-400'
77
+ export const BADGE_INACTIVE = 'status-unknown'
78
78
 
79
79
  // Best practices category colors
80
80
  export const BP_CATEGORY_BADGE: Record<string, string> = {
@@ -7,6 +7,7 @@ export * from './navigation'
7
7
  export * from './context-name'
8
8
  export * from './animation'
9
9
  export * from './resource-hierarchy'
10
+ export * from './log-export'
10
11
  export * from './log-format'
11
12
  export * from './download'
12
13
  export * from './clipboard'
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ LOG_EXPORT_FORMATS,
5
+ previewLogExport,
6
+ serializeLogEntries,
7
+ type ExportableLogEntry,
8
+ type LogExportFormat,
9
+ } from './log-export'
10
+
11
+ const ESC = '\u001b'
12
+
13
+ const entry = (over: Partial<ExportableLogEntry> = {}): ExportableLogEntry => ({
14
+ timestamp: '2024-01-20T10:30:00.123456789Z',
15
+ content: 'connection refused',
16
+ container: 'app',
17
+ pod: 'api-7d9f',
18
+ ...over,
19
+ })
20
+
21
+ const opts = (over: Partial<{ format: LogExportFormat; showTimestamps: boolean; showPodName: boolean }> = {}) => ({
22
+ format: 'txt' as LogExportFormat,
23
+ showTimestamps: true,
24
+ showPodName: true,
25
+ ...over,
26
+ })
27
+
28
+ describe('serializeLogEntries — text', () => {
29
+ it('emits the raw RFC3339 timestamp rather than any display format', () => {
30
+ const { content } = serializeLogEntries([entry()], opts({ showPodName: false }))
31
+ expect(content).toBe('2024-01-20T10:30:00.123456789Z connection refused')
32
+ })
33
+
34
+ it('omits timestamps when the viewer has them hidden', () => {
35
+ const { content } = serializeLogEntries([entry()], opts({ showTimestamps: false, showPodName: false }))
36
+ expect(content).toBe('connection refused')
37
+ })
38
+
39
+ it('attributes pod and container when pod names are shown', () => {
40
+ const { content } = serializeLogEntries([entry()], opts({ showTimestamps: false }))
41
+ expect(content).toBe('[api-7d9f/app] connection refused')
42
+ })
43
+
44
+ it('falls back to the pod alone when no container is known', () => {
45
+ const { content } = serializeLogEntries([entry({ container: undefined })], opts({ showTimestamps: false }))
46
+ expect(content).toBe('[api-7d9f] connection refused')
47
+ })
48
+
49
+ it('drops pod attribution for entries with no pod, even when the toggle is on', () => {
50
+ const { content } = serializeLogEntries([entry({ pod: undefined })], opts({ showTimestamps: false }))
51
+ expect(content).toBe('connection refused')
52
+ })
53
+
54
+ it('skips an empty timestamp instead of emitting a leading space', () => {
55
+ const { content } = serializeLogEntries([entry({ timestamp: '' })], opts({ showPodName: false }))
56
+ expect(content).toBe('connection refused')
57
+ })
58
+
59
+ it('strips ANSI escapes', () => {
60
+ const { content } = serializeLogEntries(
61
+ [entry({ content: `${ESC}[31mconnection refused${ESC}[0m` })],
62
+ opts({ showTimestamps: false, showPodName: false }),
63
+ )
64
+ expect(content).toBe('connection refused')
65
+ })
66
+
67
+ it('preserves raw JSON content rather than any collapsed rendering', () => {
68
+ const json = '{"level":"error","msg":"connection refused","stack":"a\\nb"}'
69
+ const { content } = serializeLogEntries([entry({ content: json })], opts({ showTimestamps: false, showPodName: false }))
70
+ expect(content).toBe(json)
71
+ })
72
+
73
+ it('joins entries with newlines and returns empty for no entries', () => {
74
+ const { content } = serializeLogEntries(
75
+ [entry({ content: 'first' }), entry({ content: 'second' })],
76
+ opts({ showTimestamps: false, showPodName: false }),
77
+ )
78
+ expect(content).toBe('first\nsecond')
79
+ expect(serializeLogEntries([], opts()).content).toBe('')
80
+ })
81
+ })
82
+
83
+ describe('serializeLogEntries — structured formats', () => {
84
+ it('keeps the timestamp field in JSON even when the display toggle is off', () => {
85
+ const { content, mime, extension } = serializeLogEntries(
86
+ [entry()],
87
+ opts({ format: 'json', showTimestamps: false }),
88
+ )
89
+ expect(JSON.parse(content)).toEqual([{
90
+ timestamp: '2024-01-20T10:30:00.123456789Z',
91
+ pod: 'api-7d9f',
92
+ container: 'app',
93
+ content: 'connection refused',
94
+ }])
95
+ expect(mime).toBe('application/json;charset=utf-8')
96
+ expect(extension).toBe('json')
97
+ })
98
+
99
+ it('keeps the pod key in JSON even when pod attribution is off, since the display toggle is not a schema', () => {
100
+ const { content } = serializeLogEntries([entry()], opts({ format: 'json', showPodName: false }))
101
+ expect(Object.keys(JSON.parse(content)[0])).toEqual(['timestamp', 'pod', 'container', 'content'])
102
+ })
103
+
104
+ it('omits the pod key only for entries that genuinely have no pod', () => {
105
+ const { content } = serializeLogEntries([entry({ pod: undefined })], opts({ format: 'json' }))
106
+ expect(Object.keys(JSON.parse(content)[0])).toEqual(['timestamp', 'container', 'content'])
107
+ })
108
+
109
+ it('strips ANSI from structured content too', () => {
110
+ const { content } = serializeLogEntries(
111
+ [entry({ content: `${ESC}[31mboom${ESC}[0m` })],
112
+ opts({ format: 'json' }),
113
+ )
114
+ expect(JSON.parse(content)[0].content).toBe('boom')
115
+ })
116
+
117
+ it('writes a CSV header matching the columns it emits', () => {
118
+ const { content, mime } = serializeLogEntries([entry()], opts({ format: 'csv' }))
119
+ expect(content.split('\n')).toEqual([
120
+ 'timestamp,pod,container,content',
121
+ '"2024-01-20T10:30:00.123456789Z","api-7d9f","app","connection refused"',
122
+ ])
123
+ expect(mime).toBe('text/csv;charset=utf-8')
124
+ })
125
+
126
+ it('keeps the CSV pod column when pod attribution is off but the data has pods', () => {
127
+ const { content } = serializeLogEntries([entry()], opts({ format: 'csv', showPodName: false }))
128
+ expect(content.split('\n')[0]).toBe('timestamp,pod,container,content')
129
+ })
130
+
131
+ it('drops the CSV pod column only when no entry has a pod', () => {
132
+ const { content } = serializeLogEntries([entry({ pod: undefined })], opts({ format: 'csv' }))
133
+ expect(content.split('\n')).toEqual([
134
+ 'timestamp,container,content',
135
+ '"2024-01-20T10:30:00.123456789Z","app","connection refused"',
136
+ ])
137
+ })
138
+
139
+ it('keeps the column count aligned when only some entries carry a pod', () => {
140
+ const { content } = serializeLogEntries(
141
+ [entry({ pod: undefined }), entry()],
142
+ opts({ format: 'csv' }),
143
+ )
144
+ const [header, ...rows] = content.split('\n')
145
+ expect(header.split(',').length).toBe(4)
146
+ for (const row of rows) expect(row.split('","').length).toBe(4)
147
+ })
148
+
149
+ it('neutralizes cells a spreadsheet would evaluate as a formula', () => {
150
+ const { content } = serializeLogEntries(
151
+ [entry({ content: '=cmd|\' /c calc\'!A1' }), entry({ content: '+1' }), entry({ content: '-rf /' }), entry({ content: '@SUM(A1)' })],
152
+ opts({ format: 'csv', showPodName: false }),
153
+ )
154
+ const cells = content.split('\n').slice(1).map(r => r.split(',').pop())
155
+ expect(cells).toEqual([
156
+ '"\'=cmd|\' /c calc\'!A1"',
157
+ '"\'+1"',
158
+ '"\'-rf /"',
159
+ '"\'@SUM(A1)"',
160
+ ])
161
+ })
162
+
163
+ it('leaves ordinary content unprefixed', () => {
164
+ const { content } = serializeLogEntries([entry()], opts({ format: 'csv', showPodName: false }))
165
+ expect(content).toContain('"connection refused"')
166
+ })
167
+
168
+ it('doubles quotes inside a CSV cell', () => {
169
+ const { content } = serializeLogEntries(
170
+ [entry({ content: 'said "hello"', pod: undefined })],
171
+ opts({ format: 'csv' }),
172
+ )
173
+ expect(content.split('\n')[1]).toBe('"2024-01-20T10:30:00.123456789Z","app","said ""hello"""')
174
+ })
175
+
176
+ it('emits a header-only CSV and an empty JSON array for no entries', () => {
177
+ expect(serializeLogEntries([], opts({ format: 'csv' })).content).toBe('timestamp,container,content')
178
+ expect(serializeLogEntries([], opts({ format: 'json' })).content).toBe('[]')
179
+ })
180
+
181
+ it('declares a charset so non-ASCII content survives a spreadsheet import', () => {
182
+ for (const format of LOG_EXPORT_FORMATS) {
183
+ expect(serializeLogEntries([], opts({ format })).mime).toContain('charset=utf-8')
184
+ }
185
+ })
186
+ })
187
+
188
+ describe('previewLogExport', () => {
189
+ it('shows the first two lines of a text export', () => {
190
+ expect(previewLogExport(
191
+ [entry({ content: 'first' }), entry({ content: 'second' }), entry({ content: 'third' })],
192
+ opts({ showTimestamps: false, showPodName: false }),
193
+ )).toEqual(['first', 'second'])
194
+ })
195
+
196
+ it('shows one compact object for JSON, not the pretty-printed array', () => {
197
+ const [line, ...rest] = previewLogExport([entry()], opts({ format: 'json' }))
198
+ expect(rest).toEqual([])
199
+ expect(line).toBe('{"timestamp":"2024-01-20T10:30:00.123456789Z","pod":"api-7d9f","container":"app","content":"connection refused"}')
200
+ })
201
+
202
+ it('shows the CSV header alongside a row so the columns are legible', () => {
203
+ expect(previewLogExport([entry()], opts({ format: 'csv' }))).toEqual([
204
+ 'timestamp,pod,container,content',
205
+ '"2024-01-20T10:30:00.123456789Z","api-7d9f","app","connection refused"',
206
+ ])
207
+ })
208
+
209
+ it('previews exactly what the payload contains', () => {
210
+ for (const format of ['txt', 'json', 'csv'] as LogExportFormat[]) {
211
+ const o = opts({ format })
212
+ const preview = previewLogExport([entry()], o)
213
+ const { content } = serializeLogEntries([entry()], o)
214
+ for (const line of preview) {
215
+ // JSON pretty-prints across lines, so compare on the field values instead.
216
+ if (format === 'json') expect(JSON.parse(content)[0]).toEqual(JSON.parse(line))
217
+ else expect(content.split('\n')).toContain(line)
218
+ }
219
+ }
220
+ })
221
+
222
+ it('returns nothing when there is nothing to export', () => {
223
+ expect(previewLogExport([], opts())).toEqual([])
224
+ })
225
+ })