mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +00:00
ecf4dd5d52
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.
210 lines
9.8 KiB
TypeScript
210 lines
9.8 KiB
TypeScript
import { useMemo } from 'react';
|
|
import {
|
|
Search, ArrowUpDown, AlertTriangle, Play, Square,
|
|
LayoutGrid, Network, SlidersHorizontal,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Combobox } from '@/components/ui/combobox';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
|
import { SegmentedControl, type SegmentedControlOption } from '@/components/ui/segmented-control';
|
|
import { LabelDot } from '../LabelPill';
|
|
import type { LabelColor } from '../label-types';
|
|
import type { ViewMode, SortField, FilterStatus, FilterType, FleetPreferences, FleetPaletteEntry } from './types';
|
|
|
|
const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle';
|
|
|
|
const SORT_OPTIONS = [
|
|
{ value: 'name', label: 'Name' },
|
|
{ value: 'cpu', label: 'CPU Usage' },
|
|
{ value: 'memory', label: 'Memory Usage' },
|
|
{ value: 'containers', label: 'Containers' },
|
|
{ value: 'status', label: 'Status' },
|
|
];
|
|
|
|
const VIEW_MODE_OPTIONS: SegmentedControlOption<ViewMode>[] = [
|
|
{ value: 'grid', label: 'Grid', icon: LayoutGrid },
|
|
{ value: 'topology', label: 'Topology', icon: Network },
|
|
];
|
|
|
|
function renderPaletteOption(option: { label: string; color?: string }) {
|
|
return (
|
|
<span className="flex items-center gap-1.5">
|
|
<LabelDot color={(option.color as LabelColor) ?? 'slate'} />
|
|
{option.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface OverviewToolbarProps {
|
|
viewMode: ViewMode;
|
|
onViewModeChange: (mode: ViewMode) => void;
|
|
searchQuery: string;
|
|
onSearchQueryChange: (q: string) => void;
|
|
prefs: FleetPreferences;
|
|
onPrefsChange: (update: Partial<FleetPreferences>) => void;
|
|
fleetPalette: FleetPaletteEntry[];
|
|
labelFilters: Set<string>;
|
|
onLabelFiltersChange: (filters: Set<string>) => void;
|
|
onClearFilters: () => void;
|
|
}
|
|
|
|
export function OverviewToolbar({
|
|
viewMode,
|
|
onViewModeChange,
|
|
searchQuery,
|
|
onSearchQueryChange,
|
|
prefs,
|
|
onPrefsChange,
|
|
fleetPalette,
|
|
labelFilters,
|
|
onLabelFiltersChange,
|
|
onClearFilters,
|
|
}: OverviewToolbarProps) {
|
|
const showGridControls = viewMode === 'grid';
|
|
const activeFilterCount =
|
|
(prefs.filterStatus !== 'all' ? 1 : 0) +
|
|
(prefs.filterType !== 'all' ? 1 : 0) +
|
|
(prefs.filterCritical ? 1 : 0) +
|
|
(labelFilters.size > 0 ? 1 : 0);
|
|
|
|
const paletteOptions = useMemo(
|
|
() => fleetPalette.map(p => ({ value: p.key, label: p.name, color: p.color })),
|
|
[fleetPalette],
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
|
{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" />
|
|
<Input
|
|
placeholder="Search nodes or stacks..."
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchQueryChange(e.target.value)}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
<div className="w-40">
|
|
<Combobox
|
|
options={SORT_OPTIONS}
|
|
value={prefs.sortBy}
|
|
onValueChange={(v) => onPrefsChange({ sortBy: v as SortField })}
|
|
placeholder="Sort by..."
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-9 w-9 p-0 shrink-0"
|
|
onClick={() => onPrefsChange({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
|
|
title={prefs.sortDir === 'asc' ? 'Switch to descending' : 'Switch to ascending'}
|
|
>
|
|
<ArrowUpDown className={`w-4 h-4 ${prefs.sortDir === 'desc' ? 'rotate-180' : ''} transition-transform`} />
|
|
</Button>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant={activeFilterCount > 0 ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-9 gap-2 shrink-0"
|
|
>
|
|
<SlidersHorizontal className="w-4 h-4" />
|
|
Filters
|
|
{activeFilterCount > 0 && (
|
|
<Badge variant="secondary" className="h-5 min-w-[1.25rem] px-1.5 text-[10px] tabular-nums">
|
|
{activeFilterCount}
|
|
</Badge>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-80 space-y-4">
|
|
<div className="space-y-1.5">
|
|
<label className={FILTER_SECTION_LABEL_CLASS}>Status</label>
|
|
<div className="flex items-center gap-1.5">
|
|
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
|
|
<Button
|
|
key={status}
|
|
variant={prefs.filterStatus === status ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs px-2.5"
|
|
onClick={() => onPrefsChange({ filterStatus: status })}
|
|
>
|
|
{status === 'all' ? 'All' : status === 'online' ? (
|
|
<><Play className="w-3 h-3 mr-1" />Online</>
|
|
) : (
|
|
<><Square className="w-3 h-3 mr-1" />Offline</>
|
|
)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className={FILTER_SECTION_LABEL_CLASS}>Type</label>
|
|
<div className="flex items-center gap-1.5">
|
|
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
|
|
<Button
|
|
key={type}
|
|
variant={prefs.filterType === type ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs px-2.5"
|
|
onClick={() => onPrefsChange({ filterType: type })}
|
|
>
|
|
{type === 'all' ? 'All' : type.charAt(0).toUpperCase() + type.slice(1)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className={FILTER_SECTION_LABEL_CLASS}>Severity</label>
|
|
<Button
|
|
variant={prefs.filterCritical ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs px-2.5"
|
|
onClick={() => onPrefsChange({ filterCritical: !prefs.filterCritical })}
|
|
>
|
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
|
Critical Only
|
|
</Button>
|
|
</div>
|
|
{fleetPalette.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<label className={FILTER_SECTION_LABEL_CLASS}>Tags</label>
|
|
<MultiSelectCombobox
|
|
options={paletteOptions}
|
|
selected={labelFilters}
|
|
onSelectionChange={onLabelFiltersChange}
|
|
placeholder="Tags"
|
|
renderOption={renderPaletteOption}
|
|
/>
|
|
</div>
|
|
)}
|
|
{activeFilterCount > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full h-8 text-xs"
|
|
onClick={onClearFilters}
|
|
>
|
|
Clear all filters
|
|
</Button>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</>
|
|
)}
|
|
|
|
<SegmentedControl
|
|
value={viewMode}
|
|
onChange={onViewModeChange}
|
|
ariaLabel="View mode"
|
|
options={VIEW_MODE_OPTIONS}
|
|
className="ml-auto shrink-0 shadow-card-bevel"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|