mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
feat(settings): surface security, notifications, and app store on remote nodes (#716)
Flip Security (Trivy), Notifications (agents + history), and App Store from global-and-hidden-on-remote to node-scoped so operators can manage them when a remote node is selected in the node picker. The primary instance proxies the calls to each remote, which resolves the correct per-instance binary state, agent config, and template registry. Backend: key `agents` and `notification_history` by `node_id` with idempotent column-add migrations and a `(node_id, type)` unique index on agents, matching the Labels pattern. Thread `req.nodeId` through the /api/agents and /api/notifications routes. Internal NotificationService and ImageUpdateService writes resolve the middleware default via `NodeRegistry.getDefaultNodeId()` so monitor-emitted rows share a bucket with user-facing ones (avoids split-brain where the UI sees test notifications but not internal alerts). Frontend: split Security on remote to render only the scanner card and hide scan policies and CVE suppressions (those remain control-plane-only). Drop the misleading "Always Local" badge on Developer since retention windows govern backend jobs, not UI state. Flip the App Store registry to node-scoped. Docs: add a "What Settings apply per node" table to multi-node, clarify remote alert setup in alerts-notifications, and note Trivy's per-host install in vulnerability-scanning.
This commit is contained in:
@@ -326,7 +326,6 @@ export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged
|
||||
onSave={saveDeveloperSettings}
|
||||
isSaving={isSavingDeveloper}
|
||||
isLoading={isSettingsLoading}
|
||||
isRemote={isRemote}
|
||||
/>
|
||||
);
|
||||
case 'nodes': return <NodeManager />;
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { RefreshCw, Database, Info } from 'lucide-react';
|
||||
import { RefreshCw, Database } from 'lucide-react';
|
||||
import type { PatchableSettings } from './types';
|
||||
|
||||
interface DeveloperSectionProps {
|
||||
@@ -15,7 +13,6 @@ interface DeveloperSectionProps {
|
||||
onSave: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
isLoading: boolean;
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
function SettingsSkeleton() {
|
||||
@@ -32,29 +29,11 @@ function SettingsSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) {
|
||||
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading }: DeveloperSectionProps) {
|
||||
const { isPaid, license } = useLicense();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isRemote && (
|
||||
<div className="flex justify-end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="text-xs cursor-help">
|
||||
<Info className="w-3 h-3 mr-1" />
|
||||
Always Local
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[220px] text-center">
|
||||
These settings control this Sencho instance's UI behaviour and are never synced to remote nodes.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? <SettingsSkeleton /> : (
|
||||
<>
|
||||
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PaidGate } from '@/components/PaidGate';
|
||||
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
|
||||
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { SuppressionsPanel } from './SuppressionsPanel';
|
||||
|
||||
@@ -85,6 +86,8 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
|
||||
const { license } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
|
||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
|
||||
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
||||
@@ -100,7 +103,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
setTrivyBusy(op);
|
||||
const toastId = toast.loading(loading);
|
||||
try {
|
||||
const res = await apiFetch(path, { method, localOnly: true });
|
||||
const res = await apiFetch(path, { method });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || `Trivy ${op} failed`);
|
||||
@@ -127,7 +130,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
try {
|
||||
const res = await apiFetch('/security/trivy-auto-update', {
|
||||
method: 'PUT',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -158,11 +160,17 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaid) fetchPolicies();
|
||||
else setLoading(false);
|
||||
}, [isPaid]);
|
||||
if (!isPaid) { setLoading(false); return; }
|
||||
if (isRemote) { setPolicies([]); setLoading(false); return; }
|
||||
fetchPolicies();
|
||||
}, [isPaid, isRemote]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTrivy();
|
||||
}, [activeNode?.id, refreshTrivy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRemote) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -177,7 +185,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [isRemote]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
@@ -267,7 +275,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!isReplica && (
|
||||
{!isRemote && !isReplica && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
@@ -276,7 +284,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReplica && (
|
||||
{!isRemote && isReplica && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -367,14 +375,30 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
{isRemote && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
||||
>
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Scanner is per-node</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRemote && loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && policies.length === 0 && (
|
||||
{!isRemote && !loading && policies.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No scan policies configured.</p>
|
||||
@@ -384,7 +408,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
{!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">
|
||||
@@ -436,7 +460,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<SuppressionsPanel isReplica={isReplica} />
|
||||
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -131,7 +131,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
description: 'In-app toasts and browser push for stack, container, and system events.',
|
||||
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
scope: 'node',
|
||||
},
|
||||
{
|
||||
id: 'notification-routing',
|
||||
@@ -170,9 +170,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
description: 'Image scanning, suppressions, and posture defaults.',
|
||||
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
|
||||
tier: 'skipper',
|
||||
scope: 'global',
|
||||
scope: 'node',
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
{
|
||||
id: 'developer',
|
||||
@@ -190,8 +189,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
description: 'Template registry URL and featured-catalog source.',
|
||||
keywords: ['templates', 'registry', 'catalog', 'featured'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
hiddenOnRemote: true,
|
||||
scope: 'node',
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
|
||||
Reference in New Issue
Block a user