@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 +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/issues/IssuesView.tsx +105 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/types.ts +31 -0
- package/src/components/shared/CreateResourceDialog.tsx +32 -3
- package/src/components/shared/EditableYamlView.tsx +31 -2
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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}
|