@skyhook-io/k8s-ui 1.7.10 → 1.7.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.10",
3
+ "version": "1.7.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -201,6 +201,14 @@ export interface GitOpsTableViewProps {
201
201
  // detected" copy. Hub passes "No GitOps resources across the fleet".
202
202
  emptyStateTitle?: string
203
203
  emptyStateBody?: string
204
+ /**
205
+ * Which side the filter rail sits on. Default 'left' (OSS Radar, which
206
+ * has no app sidebar). A host with its own left navigation rail (the
207
+ * Cloud hub) passes 'right' so the GitOps filters don't stack a second
208
+ * column against that nav and instead match the host's other faceted
209
+ * pages. Mobile stacking (filters above the table) is unaffected.
210
+ */
211
+ filtersSide?: 'left' | 'right'
204
212
  /**
205
213
  * Global namespace pick from the host's NamespaceSwitcher. Used to
206
214
  * surface "viewing in namespace: X" context and to power the Clear
@@ -249,6 +257,7 @@ export function GitOpsTableView({
249
257
  onClearNamespaces,
250
258
  onRowAction,
251
259
  pendingRowActions,
260
+ filtersSide = 'left',
252
261
  }: GitOpsTableViewProps) {
253
262
  const searchInputRef = useRef<HTMLInputElement>(null)
254
263
  const [mode, setMode] = useState<GitOpsMode>('applications')
@@ -492,8 +501,13 @@ export function GitOpsTableView({
492
501
  ]
493
502
 
494
503
  return (
495
- <div className="flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col">
504
+ <div
505
+ className={`flex h-full min-w-0 flex-1 overflow-hidden bg-theme-base max-lg:flex-col ${
506
+ filtersSide === 'right' ? 'lg:flex-row-reverse' : ''
507
+ }`}
508
+ >
496
509
  <GitOpsFilterSidebar
510
+ side={filtersSide}
497
511
  mode={mode}
498
512
  onModeChange={setMode}
499
513
  modeCounts={modeCounts}
@@ -707,6 +721,7 @@ export function GitOpsTableView({
707
721
  // =============================================================================
708
722
 
709
723
  function GitOpsFilterSidebar({
724
+ side,
710
725
  mode,
711
726
  onModeChange,
712
727
  modeCounts,
@@ -729,6 +744,7 @@ function GitOpsFilterSidebar({
729
744
  onToggleNamespace,
730
745
  onClear,
731
746
  }: {
747
+ side: 'left' | 'right'
732
748
  mode: GitOpsMode
733
749
  onModeChange: (mode: GitOpsMode) => void
734
750
  modeCounts: Record<GitOpsMode, number>
@@ -752,7 +768,11 @@ function GitOpsFilterSidebar({
752
768
  onClear: () => void
753
769
  }) {
754
770
  return (
755
- <aside className="flex w-72 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b max-lg:border-r-0">
771
+ <aside
772
+ className={`flex w-72 shrink-0 flex-col overflow-hidden border-theme-border bg-theme-surface/90 max-lg:max-h-72 max-lg:w-full max-lg:border-b ${
773
+ side === 'right' ? 'border-l max-lg:border-l-0' : 'border-r max-lg:border-r-0'
774
+ }`}
775
+ >
756
776
  <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
757
777
  <span className="text-sm font-medium text-theme-text-secondary">GitOps Filters</span>
758
778
  <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
@@ -215,6 +215,7 @@ function IssueRow({
215
215
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
216
216
  <div className="flex flex-col gap-4">
217
217
  <Diagnosis issue={issue} />
218
+ <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
218
219
  <div className="border-t border-theme-border/70 pt-3">
219
220
  <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
220
221
  </div>
@@ -249,6 +250,11 @@ function Diagnosis({ issue }: { issue: Issue }) {
249
250
  error) — so the precise message is never lost, just de-emphasized. */}
250
251
  {detail ? <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p> : null}
251
252
  {crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
253
+ {issue.change_context ? (
254
+ <p className="text-xs text-theme-text-tertiary">
255
+ {changeContextText(issue.change_context)}
256
+ </p>
257
+ ) : null}
252
258
  {issue.first_seen ? (
253
259
  <p className="text-xs text-theme-text-tertiary tabular-nums">
254
260
  Started {formatRelativeAgeTime(issue.first_seen)}
@@ -259,6 +265,105 @@ function Diagnosis({ issue }: { issue: Issue }) {
259
265
  );
260
266
  }
261
267
 
268
+ function changeContextText(change: NonNullable<Issue['change_context']>): string {
269
+ const parts = [change.when ? `Changed ${change.when} ago` : 'Changed', change.what ? change.what.replace(/_/g, ' ') : null, change.evidence].filter(Boolean);
270
+ return parts.join(' · ');
271
+ }
272
+
273
+ function DiagnosticContext({
274
+ issue,
275
+ resourceHref,
276
+ onResourceClick,
277
+ }: {
278
+ issue: Issue;
279
+ resourceHref?: (ref: IssueResourceRef) => string;
280
+ onResourceClick?: (ref: IssueResourceRef) => void;
281
+ }) {
282
+ const ctx = issue.diagnostic_context;
283
+ const facts = ctx?.facts?.filter((fact) => fact.message || fact.refs?.length || fact.related_issues?.length) ?? [];
284
+ if (!ctx || facts.length === 0) return null;
285
+
286
+ return (
287
+ <section className="flex flex-col gap-2">
288
+ <div className="flex items-center gap-2">
289
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</h4>
290
+ {ctx.role ? <span className="badge-sm text-[10px] text-theme-text-secondary">{diagnosticRoleLabel(ctx.role)}</span> : null}
291
+ </div>
292
+ <ul className="flex flex-col gap-2">
293
+ {facts.map((fact, idx) => (
294
+ <li key={`${fact.type}-${idx}`} className="flex flex-col gap-1.5 rounded-md border border-theme-border/70 px-2.5 py-2">
295
+ <div className="flex min-w-0 items-baseline gap-2">
296
+ <span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
297
+ {fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
298
+ </div>
299
+ {fact.related_issues?.length ? (
300
+ <ul className="flex flex-col gap-px">
301
+ {fact.related_issues.map((related, relIdx) => (
302
+ <ResourceLine
303
+ key={`${related.ref.group ?? ''}/${related.ref.kind}/${related.ref.namespace ?? ''}/${related.ref.name}#${relIdx}`}
304
+ label="Related"
305
+ refForLink={memberRef(issue, related.ref)}
306
+ resourceHref={resourceHref}
307
+ onResourceClick={onResourceClick}
308
+ />
309
+ ))}
310
+ </ul>
311
+ ) : null}
312
+ {fact.refs?.length ? (
313
+ <ul className="flex flex-col gap-px">
314
+ {fact.refs.map((ref, refIdx) => (
315
+ <ResourceLine
316
+ key={`${ref.group ?? ''}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}#${refIdx}`}
317
+ refForLink={memberRef(issue, ref)}
318
+ resourceHref={resourceHref}
319
+ onResourceClick={onResourceClick}
320
+ />
321
+ ))}
322
+ </ul>
323
+ ) : null}
324
+ </li>
325
+ ))}
326
+ </ul>
327
+ </section>
328
+ );
329
+ }
330
+
331
+ function diagnosticRoleLabel(role: string): string {
332
+ switch (role) {
333
+ case 'candidate':
334
+ return 'Candidate signal';
335
+ case 'affected':
336
+ return 'Affected signal';
337
+ case 'rollup':
338
+ return 'Rollup';
339
+ default:
340
+ return 'Context';
341
+ }
342
+ }
343
+
344
+ function diagnosticFactLabel(type: string): string {
345
+ switch (type) {
346
+ case 'explicit_reference':
347
+ return 'Explicit reference';
348
+ case 'owner_rollup':
349
+ return 'Owner rollup';
350
+ case 'selected_backend_issue':
351
+ return 'Selected backend';
352
+ case 'service_config_mismatch':
353
+ return 'Service config';
354
+ case 'service_env_reference':
355
+ return 'Service env';
356
+ case 'probe_target_mismatch':
357
+ return 'Probe target';
358
+ case 'blocked_init_container':
359
+ return 'Init container';
360
+ case 'restart_cause':
361
+ return 'Restart cause';
362
+ default:
363
+ return type.replace(/_/g, ' ');
364
+ }
365
+ }
366
+
262
367
  // Native-tooltip detail for the collapsed-row age chip: absolute onset + last-seen
263
368
  // freshness, the two facts the compact "2h" hides.
264
369
  function ageTitle(issue: Issue): string {
@@ -11,7 +11,7 @@ export {
11
11
  subjectRef,
12
12
  memberRef,
13
13
  } from './types';
14
- export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef } from './types';
14
+ export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext } from './types';
15
15
  export {
16
16
  ISSUE_SEVERITY_LABEL,
17
17
  ISSUE_SEVERITY_BADGE_CLASS,
@@ -57,6 +57,34 @@ export interface IssueAffected {
57
57
  nodes?: number;
58
58
  }
59
59
 
60
+ export type IssueDiagnosticRole = 'candidate' | 'affected' | 'rollup' | 'context';
61
+
62
+ export interface IssueDiagnosticIssueRef {
63
+ ref: IssueResourceRef;
64
+ reason?: string;
65
+ category?: string;
66
+ severity?: IssueSeverity;
67
+ }
68
+
69
+ export interface IssueDiagnosticFact {
70
+ type: string;
71
+ message?: string;
72
+ refs?: IssueResourceRef[];
73
+ related_issues?: IssueDiagnosticIssueRef[];
74
+ }
75
+
76
+ export interface IssueDiagnosticContext {
77
+ role?: IssueDiagnosticRole;
78
+ facts?: IssueDiagnosticFact[];
79
+ }
80
+
81
+ export interface IssueChangeContext {
82
+ changed: boolean;
83
+ what?: string;
84
+ when?: string;
85
+ evidence?: string;
86
+ }
87
+
60
88
  /**
61
89
  * A grouped live issue — one row of the triage queue. Subject (kind/group/
62
90
  * namespace/name) is the topmost owner when the rows folded under a workload,
@@ -98,8 +126,11 @@ export interface Issue {
98
126
  count?: number;
99
127
 
100
128
  affected?: IssueAffected;
129
+ owner?: IssueResourceRef;
101
130
  members?: IssueResourceRef[];
102
131
  members_truncated?: boolean;
132
+ diagnostic_context?: IssueDiagnosticContext;
133
+ change_context?: IssueChangeContext;
103
134
 
104
135
  // Pod crash context carried from the representative member.
105
136
  restart_count?: number;
@@ -18,7 +18,7 @@ export interface CreateResourceDialogProps {
18
18
  initialYaml?: string
19
19
  title?: string
20
20
  // Injected by platform (decouples from data-fetching hooks)
21
- onApply: (params: { yaml: string; mode: 'apply' | 'create'; dryRun: boolean }) => Promise<ApplyResult[]>
21
+ onApply: (params: { yaml: string; mode: 'apply' | 'create'; dryRun: boolean; force: boolean }) => Promise<ApplyResult[]>
22
22
  isApplying: boolean
23
23
  /** Called after a successful non-dry-run apply with the first created resource */
24
24
  onCreated?: (result: ApplyResult) => void
@@ -28,6 +28,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
28
28
  const [yaml, setYaml] = useState(initialYaml)
29
29
  const [mode, setMode] = useState<'apply' | 'create'>('apply')
30
30
  const [dryRun, setDryRun] = useState(false)
31
+ const [force, setForce] = useState(false)
31
32
  const [yamlValid, setYamlValid] = useState(true)
32
33
  const [error, setError] = useState<string | null>(null)
33
34
  const [success, setSuccess] = useState<string | null>(null)
@@ -38,6 +39,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
38
39
  setYaml(initialYaml)
39
40
  setMode('apply')
40
41
  setDryRun(false)
42
+ setForce(false)
41
43
  setError(null)
42
44
  setSuccess(null)
43
45
  }
@@ -64,7 +66,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
64
66
  setSuccess(null)
65
67
 
66
68
  try {
67
- const results = await onApply({ yaml, mode, dryRun })
69
+ const results = await onApply({ yaml, mode, dryRun, force: mode === 'apply' && force })
68
70
  const action = mode === 'create' ? 'Created' : 'Applied'
69
71
  const dryRunLabel = dryRun ? ' (dry run)' : ''
70
72
 
@@ -87,7 +89,7 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
87
89
  } catch (err) {
88
90
  setError(err instanceof Error ? err.message : 'Unknown error')
89
91
  }
90
- }, [yaml, mode, dryRun, onApply, onCreated, handleClose])
92
+ }, [yaml, mode, dryRun, force, onApply, onCreated, handleClose])
91
93
 
92
94
  const dialogTitle = title || 'Create Resource'
93
95
  const submitLabel = mode === 'create' ? 'Create' : 'Apply'
@@ -171,6 +173,33 @@ export function CreateResourceDialog({ open, onClose, initialYaml = '', title, o
171
173
  Dry run
172
174
  </label>
173
175
  </Tooltip>
176
+
177
+ {/* Force checkbox — only meaningful for server-side apply */}
178
+ <Tooltip
179
+ content={
180
+ <div className="space-y-1.5 max-w-[19rem]">
181
+ <p>Override field ownership (server-side apply).</p>
182
+ <p>
183
+ <span className="font-medium text-theme-text-primary">Off (default):</span> if a field is owned by Helm/Flux/Argo/kubectl, the whole apply is rejected with a conflict instead of overwriting.
184
+ </p>
185
+ <p>
186
+ <span className="font-medium text-theme-text-primary">On:</span> your manifest overwrites those fields — but an active controller may reconcile them back on its next sync.
187
+ </p>
188
+ </div>
189
+ }
190
+ position="bottom"
191
+ >
192
+ <label className={`flex items-center gap-1.5 text-xs cursor-pointer ${mode === 'apply' ? 'text-theme-text-secondary' : 'text-theme-text-tertiary cursor-not-allowed'}`}>
193
+ <input
194
+ type="checkbox"
195
+ checked={mode === 'apply' && force}
196
+ disabled={mode !== 'apply'}
197
+ onChange={(e) => setForce(e.target.checked)}
198
+ className="w-3.5 h-3.5 rounded border-theme-border bg-theme-base"
199
+ />
200
+ Force
201
+ </label>
202
+ </Tooltip>
174
203
  </div>
175
204
 
176
205
  <div className="flex items-center gap-2">
@@ -114,7 +114,7 @@ interface EditableYamlViewProps {
114
114
  /** Called after a successful save so the parent can refetch */
115
115
  onSaved?: () => void
116
116
  /** Save handler — injected by the platform wrapper */
117
- onSave?: (params: { kind: string; namespace: string; name: string; yaml: string }) => Promise<void>
117
+ onSave?: (params: { kind: string; namespace: string; name: string; yaml: string; force: boolean }) => Promise<void>
118
118
  /** Whether a save is in progress */
119
119
  isSaving?: boolean
120
120
  /** Error message from the last save attempt */
@@ -139,6 +139,9 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
139
139
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
140
140
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
141
141
  const [showErrorDetails, setShowErrorDetails] = useState(false)
142
+ // Default on: the editor resubmits the full live manifest, so an unforced
143
+ // save would conflict on every field owned by Helm/Flux/Argo/a controller.
144
+ const [force, setForce] = useState(true)
142
145
 
143
146
  // Clean up restored draft flag
144
147
  useEffect(() => {
@@ -187,6 +190,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
187
190
  namespace: resource.namespace,
188
191
  name: resource.name,
189
192
  yaml: editedYaml,
193
+ force,
190
194
  })
191
195
  setIsEditing(false)
192
196
  setEditedYaml('')
@@ -194,7 +198,7 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
194
198
  } catch {
195
199
  // Error handled by caller via saveError prop
196
200
  }
197
- }, [onSave, resource, editedYaml, yamlErrors, onSaved])
201
+ }, [onSave, resource, editedYaml, yamlErrors, onSaved, force])
198
202
 
199
203
  const handleYamlValidate = useCallback((_isValid: boolean, errors: string[]) => {
200
204
  setYamlErrors(errors)
@@ -222,6 +226,31 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
222
226
  <XCircle className="w-3.5 h-3.5" />
223
227
  Cancel
224
228
  </button>
229
+ <Tooltip
230
+ content={
231
+ <div className="space-y-1.5 max-w-[19rem]">
232
+ <p>Override field ownership (server-side apply).</p>
233
+ <p>
234
+ <span className="font-medium text-theme-text-primary">On (default):</span> your edits overwrite fields owned by Helm/Flux/Argo/kubectl — but an active controller may reconcile them back on its next sync.
235
+ </p>
236
+ <p>
237
+ <span className="font-medium text-theme-text-primary">Off:</span> if another manager owns a field you changed, the whole save is rejected with a conflict (nothing is applied).
238
+ </p>
239
+ </div>
240
+ }
241
+ position="bottom"
242
+ >
243
+ <label className="flex items-center gap-1.5 text-xs text-theme-text-secondary cursor-pointer select-none">
244
+ <input
245
+ type="checkbox"
246
+ checked={force}
247
+ disabled={isPending}
248
+ onChange={(e) => setForce(e.target.checked)}
249
+ className="w-3.5 h-3.5 rounded border-theme-border bg-theme-base"
250
+ />
251
+ Force
252
+ </label>
253
+ </Tooltip>
225
254
  <button
226
255
  onClick={handleSaveEdit}
227
256
  disabled={isPending || yamlErrors.length > 0}