mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +00:00
feat: add posture reasons and review queue to Security overview (#1462)
Add structured posture reasons derived alongside the posture verb in securityPosture.ts so the masthead and Overview tab can answer why the page is red, what to do first, and what clears it. Backend: - derivePostureReasons() returns blocker, review, and info reasons from the same SecurityPostureFacts used by deriveSecurityPosture() - deriveSecurityPosture() depends on derivePostureReasons() internally - Exposure split: public exposure with KEV, fixable, or EPSS >= 0.1 is a blocker; exposure without any of those is a review item - Fully dismissed exposed images produce no posture reason - postureReasons and primaryAction returned by the overview endpoint Frontend: - ReviewQueueCard on the Overview tab with per-row CTAs for blockers - Action summary in masthead subtitle and desktop primary CTA button - Card gated on posture not being Unknown - Backward compatible with older remote nodes
This commit is contained in:
@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
|
||||
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding } from '@/types/security';
|
||||
import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding, PostureReason } from '@/types/security';
|
||||
import type { SecurityTab } from '@/lib/events';
|
||||
import {
|
||||
RiskTrendChart,
|
||||
@@ -57,6 +57,73 @@ function ChartCard({ title, className, children }: { title: string; className?:
|
||||
);
|
||||
}
|
||||
|
||||
const SEVERITY_DOT: Record<PostureReason['severity'], string> = {
|
||||
blocker: 'bg-destructive',
|
||||
review: 'bg-warning',
|
||||
info: 'bg-stat-subtitle',
|
||||
};
|
||||
|
||||
const SEVERITY_LABEL: Record<PostureReason['severity'], string> = {
|
||||
blocker: 'text-destructive',
|
||||
review: 'text-warning',
|
||||
info: 'text-stat-subtitle',
|
||||
};
|
||||
|
||||
function ReviewQueueCard({
|
||||
reasons,
|
||||
onNavigate,
|
||||
}: {
|
||||
reasons: PostureReason[];
|
||||
onNavigate: (tab: SecurityTab) => void;
|
||||
}) {
|
||||
const blockers = reasons.filter((r) => r.severity === 'blocker');
|
||||
const nonBlockers = reasons.filter((r) => r.severity !== 'blocker');
|
||||
const hasBlockers = blockers.length > 0;
|
||||
const title = hasBlockers ? 'Why Action needed' : 'Review queue';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4">
|
||||
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle mb-3">{title}</h3>
|
||||
<div className="space-y-3">
|
||||
{blockers.map((r, i) => (
|
||||
<div key={`${r.kind}-${i}`} className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[r.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[r.severity])}>{r.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(r.targetTab)}
|
||||
className="text-xs font-medium text-brand hover:underline whitespace-nowrap ml-auto"
|
||||
>
|
||||
Open {r.targetTab === 'compose' ? 'Compose risks' : r.targetTab === 'suppressions' ? 'Suppressions' : r.targetTab === 'secrets' ? 'Secrets' : r.targetTab === 'history' ? 'History' : r.targetTab === 'scanner' ? 'Scanner setup' : 'Images'} →
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{nonBlockers.length > 0 && hasBlockers && (
|
||||
<div className="border-t border-hairline pt-3 mt-1" />
|
||||
)}
|
||||
{nonBlockers.map((r, i) => (
|
||||
<div key={`${r.kind}-${i}`} className="flex items-start gap-3">
|
||||
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', SEVERITY_DOT[r.severity])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('font-mono text-sm', SEVERITY_LABEL[r.severity])}>{r.label}</span>
|
||||
<span className="font-mono tabular-nums text-xs text-stat-subtitle">{r.count}</span>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle mt-0.5">{r.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({ overview, loadError, trend, exploitIntel, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -127,6 +194,15 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, onNaviga
|
||||
scanner-detections note lives in the masthead's info affordance. */}
|
||||
{isMobile && <SecuritySevStrip overview={overview} />}
|
||||
|
||||
{/* Review queue: surfaces the "why" behind the posture -- blocker reasons
|
||||
with CTAs, plus review/info items even when the masthead is not red. */}
|
||||
{overview.posture && overview.posture !== 'Unknown' && overview.postureReasons && overview.postureReasons.length > 0 && (
|
||||
<ReviewQueueCard
|
||||
reasons={overview.postureReasons}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Charts lead the dashboard: the trend gives severity context, the rest
|
||||
answer "what should I act on first?" from posture + exploit intel. */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
|
||||
Reference in New Issue
Block a user