mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
This commit is contained in:
@@ -34,11 +34,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
const experimental = useExperimental();
|
||||
|
||||
const { prefs, updatePrefs } = useFleetPreferences();
|
||||
const updateStatus = useFleetUpdateStatus({ isPaid });
|
||||
const updateStatus = useFleetUpdateStatus();
|
||||
const overview = useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
|
||||
|
||||
useFleetPolling({
|
||||
isPaid,
|
||||
fetchOverview: overview.fetchOverview,
|
||||
fetchUpdateStatus: updateStatus.fetchUpdateStatus,
|
||||
updateStatuses: updateStatus.updateStatuses,
|
||||
@@ -69,13 +68,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="snapshots">
|
||||
<TabsTrigger value="snapshots">
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="snapshots">
|
||||
<TabsTrigger value="snapshots">
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isAdmiral && experimental && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
@@ -114,17 +111,15 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPaid && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={updateStatus.checkUpdates}
|
||||
className="gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
Check Updates
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={updateStatus.checkUpdates}
|
||||
className="gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
Check Updates
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -155,22 +150,19 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
labelFilters={overview.labelFilters}
|
||||
onLabelFiltersChange={overview.setLabelFilters}
|
||||
onClearFilters={overview.clearFilters}
|
||||
isPaid={isPaid}
|
||||
fleetStackLabelMap={overview.fleetStackLabelMap}
|
||||
updateStatusMap={overview.updateStatusMap}
|
||||
onNavigateToNode={onNavigateToNode}
|
||||
onUpdate={isPaid ? updateStatus.triggerNodeUpdate : undefined}
|
||||
onUpdate={updateStatus.triggerNodeUpdate}
|
||||
updatingNodeId={updateStatus.updatingNodeId}
|
||||
onRetryUpdate={isPaid ? updateStatus.retryNodeUpdate : undefined}
|
||||
onDismissUpdate={isPaid ? updateStatus.dismissNodeUpdate : undefined}
|
||||
onRetryUpdate={updateStatus.retryNodeUpdate}
|
||||
onDismissUpdate={updateStatus.dismissNodeUpdate}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isPaid && (
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
{isAdmiral && experimental && (
|
||||
<TabsContent value="routing">
|
||||
<AdmiralGate>
|
||||
@@ -233,6 +225,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
retryNodeUpdate={updateStatus.retryNodeUpdate}
|
||||
dismissNodeUpdate={updateStatus.dismissNodeUpdate}
|
||||
triggerUpdateAll={updateStatus.triggerUpdateAll}
|
||||
canBulkUpdate={isPaid}
|
||||
/>
|
||||
|
||||
<LocalUpdateConfirmDialog
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
@@ -46,7 +45,6 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||
// --- Main Export ---
|
||||
|
||||
export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
|
||||
const [loadingStacks, setLoadingStacks] = useState(false);
|
||||
@@ -60,7 +58,6 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
const diskPercent = getNodeDisk(node);
|
||||
|
||||
const handleExpand = async () => {
|
||||
if (!isPaid) return;
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
|
||||
@@ -222,8 +219,8 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paid: Expandable Stack List with Container Drill-Down */}
|
||||
{isOnline && isPaid && (
|
||||
{/* Expandable Stack List with Container Drill-Down */}
|
||||
{isOnline && (
|
||||
<div className="border-t">
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
|
||||
@@ -24,11 +24,13 @@ interface NodeUpdatesSheetProps {
|
||||
retryNodeUpdate: (nodeId: number) => void;
|
||||
dismissNodeUpdate: (nodeId: number) => void;
|
||||
triggerUpdateAll: () => Promise<void>;
|
||||
canBulkUpdate: boolean;
|
||||
}
|
||||
|
||||
export function NodeUpdatesSheet({
|
||||
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId,
|
||||
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
|
||||
canBulkUpdate,
|
||||
}: NodeUpdatesSheetProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
|
||||
@@ -215,7 +217,7 @@ export function NodeUpdatesSheet({
|
||||
<RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
||||
Recheck
|
||||
</Button>
|
||||
{updatableRemoteCount > 0 && (
|
||||
{canBulkUpdate && updatableRemoteCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -24,7 +24,6 @@ interface OverviewTabProps {
|
||||
labelFilters: Set<string>;
|
||||
onLabelFiltersChange: (filters: Set<string>) => void;
|
||||
onClearFilters: () => void;
|
||||
isPaid: boolean;
|
||||
fleetStackLabelMap: Record<number, Record<string, StackLabel[]>>;
|
||||
updateStatusMap: Map<number, NodeUpdateStatus>;
|
||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||
@@ -50,7 +49,6 @@ export function OverviewTab({
|
||||
labelFilters,
|
||||
onLabelFiltersChange,
|
||||
onClearFilters,
|
||||
isPaid,
|
||||
fleetStackLabelMap,
|
||||
updateStatusMap,
|
||||
onNavigateToNode,
|
||||
@@ -90,7 +88,6 @@ export function OverviewTab({
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
<OverviewToolbar
|
||||
isPaid={isPaid}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={onViewModeChange}
|
||||
searchQuery={searchQuery}
|
||||
@@ -144,11 +141,9 @@ export function OverviewTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPaid && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -39,7 +39,6 @@ function renderPaletteOption(option: { label: string; color?: string }) {
|
||||
}
|
||||
|
||||
interface OverviewToolbarProps {
|
||||
isPaid: boolean;
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
searchQuery: string;
|
||||
@@ -53,7 +52,6 @@ interface OverviewToolbarProps {
|
||||
}
|
||||
|
||||
export function OverviewToolbar({
|
||||
isPaid,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
searchQuery,
|
||||
@@ -65,7 +63,7 @@ export function OverviewToolbar({
|
||||
onLabelFiltersChange,
|
||||
onClearFilters,
|
||||
}: OverviewToolbarProps) {
|
||||
const showPaidControls = isPaid && viewMode === 'grid';
|
||||
const showGridControls = viewMode === 'grid';
|
||||
const activeFilterCount =
|
||||
(prefs.filterStatus !== 'all' ? 1 : 0) +
|
||||
(prefs.filterType !== 'all' ? 1 : 0) +
|
||||
@@ -79,7 +77,7 @@ export function OverviewToolbar({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{showPaidControls && (
|
||||
{showGridControls && (
|
||||
<>
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
|
||||
@@ -103,48 +103,46 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }:
|
||||
);
|
||||
}
|
||||
|
||||
if (isPaid) {
|
||||
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
|
||||
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
|
||||
if (prefs.filterType === 'local') filtered = filtered.filter(n => n.type === 'local');
|
||||
if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type !== 'local');
|
||||
if (prefs.filterCritical) filtered = filtered.filter(isCritical);
|
||||
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
|
||||
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
|
||||
if (prefs.filterType === 'local') filtered = filtered.filter(n => n.type === 'local');
|
||||
if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type !== 'local');
|
||||
if (prefs.filterCritical) filtered = filtered.filter(isCritical);
|
||||
|
||||
if (labelFilters.size > 0) {
|
||||
filtered = filtered.filter(n => {
|
||||
const nodeStackLabels = fleetStackLabelMap[n.id] ?? {};
|
||||
return n.stacks?.some(s => {
|
||||
const sLabels = nodeStackLabels[s] ?? [];
|
||||
return sLabels.some(l => labelFilters.has(labelPaletteKey(l.name, l.color)));
|
||||
});
|
||||
if (labelFilters.size > 0) {
|
||||
filtered = filtered.filter(n => {
|
||||
const nodeStackLabels = fleetStackLabelMap[n.id] ?? {};
|
||||
return n.stacks?.some(s => {
|
||||
const sLabels = nodeStackLabels[s] ?? [];
|
||||
return sLabels.some(l => labelFilters.has(labelPaletteKey(l.name, l.color)));
|
||||
});
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (prefs.sortBy) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'cpu':
|
||||
cmp = getNodeCpu(b) - getNodeCpu(a);
|
||||
break;
|
||||
case 'memory':
|
||||
cmp = getNodeMem(b) - getNodeMem(a);
|
||||
break;
|
||||
case 'containers':
|
||||
cmp = (b.stats?.active ?? 0) - (a.stats?.active ?? 0);
|
||||
break;
|
||||
case 'status':
|
||||
cmp = (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
return prefs.sortDir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (prefs.sortBy) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'cpu':
|
||||
cmp = getNodeCpu(b) - getNodeCpu(a);
|
||||
break;
|
||||
case 'memory':
|
||||
cmp = getNodeMem(b) - getNodeMem(a);
|
||||
break;
|
||||
case 'containers':
|
||||
cmp = (b.stats?.active ?? 0) - (a.stats?.active ?? 0);
|
||||
break;
|
||||
case 'status':
|
||||
cmp = (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
return prefs.sortDir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]);
|
||||
}, [nodes, searchQuery, prefs, labelFilters, fleetStackLabelMap]);
|
||||
|
||||
const localNode = useMemo(
|
||||
() => processedNodes.find(n => n.type === 'local') ?? null,
|
||||
|
||||
@@ -2,14 +2,12 @@ import { useEffect, useRef } from 'react';
|
||||
import type { NodeUpdateStatus } from '../types';
|
||||
|
||||
interface UseFleetPollingOptions {
|
||||
isPaid: boolean;
|
||||
fetchOverview: () => Promise<void> | void;
|
||||
fetchUpdateStatus: () => Promise<void> | void;
|
||||
updateStatuses: NodeUpdateStatus[];
|
||||
}
|
||||
|
||||
export function useFleetPolling({
|
||||
isPaid,
|
||||
fetchOverview,
|
||||
fetchUpdateStatus,
|
||||
updateStatuses,
|
||||
@@ -19,13 +17,12 @@ export function useFleetPolling({
|
||||
fetchUpdateStatus();
|
||||
}, [fetchOverview, fetchUpdateStatus]);
|
||||
|
||||
// Paid tier: auto-refresh every 30s
|
||||
// Auto-refresh every 30s for overview, every 2 min for update status.
|
||||
useEffect(() => {
|
||||
if (!isPaid) return;
|
||||
const overviewInterval = setInterval(fetchOverview, 30000);
|
||||
const updateInterval = setInterval(fetchUpdateStatus, 120000);
|
||||
return () => { clearInterval(overviewInterval); clearInterval(updateInterval); };
|
||||
}, [isPaid, fetchOverview, fetchUpdateStatus]);
|
||||
}, [fetchOverview, fetchUpdateStatus]);
|
||||
|
||||
// Fast poll (5s) when any node is actively updating. Uses ref to avoid interval thrashing.
|
||||
const hasUpdatingRef = useRef(false);
|
||||
|
||||
@@ -3,11 +3,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import type { NodeUpdateStatus } from '../types';
|
||||
|
||||
interface UseFleetUpdateStatusOptions {
|
||||
isPaid: boolean;
|
||||
}
|
||||
|
||||
export function useFleetUpdateStatus({ isPaid }: UseFleetUpdateStatusOptions) {
|
||||
export function useFleetUpdateStatus() {
|
||||
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
|
||||
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
@@ -22,7 +18,6 @@ export function useFleetUpdateStatus({ isPaid }: UseFleetUpdateStatusOptions) {
|
||||
updateStatusesRef.current = updateStatuses;
|
||||
|
||||
const fetchUpdateStatus = useCallback(async () => {
|
||||
if (!isPaid) return;
|
||||
try {
|
||||
const res = await apiFetch('/fleet/update-status', { localOnly: true });
|
||||
if (res.ok) {
|
||||
@@ -33,7 +28,7 @@ export function useFleetUpdateStatus({ isPaid }: UseFleetUpdateStatusOptions) {
|
||||
);
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}, [isPaid]);
|
||||
}, []);
|
||||
|
||||
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
|
||||
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
import { SettingsPrimaryButton } from './SettingsActions';
|
||||
@@ -267,31 +266,18 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
loading
|
||||
? null
|
||||
: [
|
||||
{ label: 'POLICIES', value: `${policies.length}` },
|
||||
...(isPaid ? [{ label: 'POLICIES', value: `${policies.length}` }] : []),
|
||||
{
|
||||
label: 'TRIVY',
|
||||
value: trivy.source === 'none' ? 'missing' : trivy.source,
|
||||
tone: trivy.source === 'none' ? 'warn' : 'value',
|
||||
tone: trivy.source === 'none' ? 'warn' : 'value' as const,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
if (!isPaid) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PaidGate>
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
</div>
|
||||
</PaidGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!isRemote && !isReplica && (
|
||||
{isPaid && !isRemote && !isReplica && (
|
||||
<div className="flex justify-end">
|
||||
<SettingsPrimaryButton size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -330,41 +316,39 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isAdmiral && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{trivy.source === 'none' && (
|
||||
<SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
|
||||
{trivyBusy === 'install' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Install Trivy
|
||||
</SettingsPrimaryButton>
|
||||
)}
|
||||
{trivy.source === 'managed' && updateCheck?.updateAvailable && (
|
||||
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
|
||||
{trivyBusy === 'update' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{trivy.source === 'managed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setUninstallConfirm(true)}
|
||||
disabled={trivyBusy !== null}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{trivy.source === 'none' && (
|
||||
<SettingsPrimaryButton size="sm" onClick={handleInstallTrivy} disabled={trivyBusy !== null}>
|
||||
{trivyBusy === 'install' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Install Trivy
|
||||
</SettingsPrimaryButton>
|
||||
)}
|
||||
{trivy.source === 'managed' && updateCheck?.updateAvailable && (
|
||||
<Button size="sm" variant="outline" onClick={handleUpdateTrivy} disabled={trivyBusy !== null}>
|
||||
{trivyBusy === 'update' ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{trivy.source === 'managed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setUninstallConfirm(true)}
|
||||
disabled={trivyBusy !== null}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trivy.source === 'managed' && trivy.version && (
|
||||
@@ -414,7 +398,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRemote && !loading && policies.length === 0 && (
|
||||
{isPaid && !isRemote && !loading && policies.length === 0 && (
|
||||
<SettingsCallout
|
||||
icon={<ShieldCheck className="h-4 w-4" />}
|
||||
title="No scan policies configured"
|
||||
@@ -422,7 +406,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isRemote && !loading &&
|
||||
{isPaid && !isRemote && !loading &&
|
||||
policies.map((policy) => (
|
||||
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -476,7 +460,9 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
|
||||
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{isPaid && (
|
||||
<>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Policy' : 'New Policy'}</DialogTitle>
|
||||
@@ -548,25 +534,27 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete scan policy?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the policy immediately. Existing scans are not affected.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog open={deleteId != null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete scan policy?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes the policy immediately. Existing scans are not affected.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AlertDialog open={uninstallConfirm} onOpenChange={setUninstallConfirm}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -180,7 +180,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
label: 'Security',
|
||||
description: 'Image scanning, suppressions, and posture defaults.',
|
||||
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
|
||||
tier: 'skipper',
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
adminOnly: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user