From dc78b7f3bf02f79bf668cce179ea118d9e2e0d15 Mon Sep 17 00:00:00 2001 From: Yuriy Date: Thu, 12 Feb 2026 12:50:38 +0300 Subject: [PATCH] feat: Dashboard metrics+health ring, SecretsGuard AI fix, PolicyEngine toggles --- desktop/ui/src/pages/Dashboard.tsx | 237 +++++++++++++++++--------- desktop/ui/src/pages/PolicyEngine.tsx | 84 +++++++-- desktop/ui/src/pages/SecretsGuard.tsx | 57 ++++++- 3 files changed, 286 insertions(+), 92 deletions(-) diff --git a/desktop/ui/src/pages/Dashboard.tsx b/desktop/ui/src/pages/Dashboard.tsx index 32ea108..7f7db37 100644 --- a/desktop/ui/src/pages/Dashboard.tsx +++ b/desktop/ui/src/pages/Dashboard.tsx @@ -1,9 +1,64 @@ import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { ROUTES } from '../config/routes'; +import { eventBus, Events } from '../lib/event-bus'; import { useAppStore } from '../store/app-store'; import { animateCardsStagger, animateFadeInUp } from '../lib/anime-utils'; -import { Shield, FileText, Lock, CheckCircle2, AlertTriangle, ArrowRight, Sparkles, Info } from 'lucide-react'; +import { + Shield, FileText, Lock, CheckCircle2, AlertTriangle, ArrowRight, + Sparkles, Info, Activity, Code2, FolderOpen, Bug, Brain, +} from 'lucide-react'; + +function HealthRing({ score, size = 120 }: { score: number; size?: number }) { + const r = (size - 12) / 2; + const circ = 2 * Math.PI * r; + const offset = circ * (1 - score / 100); + const color = score >= 80 ? '#22c55e' : score >= 50 ? '#eab308' : '#ef4444'; + return ( +
+ + + + +
+ {score} + из 100 +
+
+ ); +} + +function MiniBar({ label, value, max, color }: { label: string; value: number; max: number; color: string }) { + const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0; + return ( +
+
+ {label} + {value} +
+
+
+
+
+ ); +} + +function StatCard({ icon: Icon, label, value, sub, color }: { + icon: typeof Activity; label: string; value: string | number; sub?: string; color: string; +}) { + return ( +
+
+ + {label} +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} export function Dashboard() { const headerRef = useRef(null); @@ -11,20 +66,38 @@ export function Dashboard() { const navigate = useNavigate(); const lastReport = useAppStore((s) => s.lastReport); const auditEvents = useAppStore((s) => s.auditEvents); + const addAuditEvent = useAppStore((s) => s.addAuditEvent); const hasData = !!lastReport; const findings = lastReport?.findings ?? []; const signals = lastReport?.signals ?? []; + const stats = lastReport?.stats; const highFindings = findings.filter((f) => f.severity === 'high'); + const warnFindings = findings.filter((f) => f.severity === 'warn'); const securitySignals = signals.filter((s) => s.category === 'security'); const secretFindings = findings.filter( - (f) => f.title.toLowerCase().includes('.env') || f.title.toLowerCase().includes('secret') || f.title.toLowerCase().includes('gitignore') + (f) => f.title.includes('\u{1F510}') || f.title.toLowerCase().includes('secret') || f.title.toLowerCase().includes('.env') ); + const qualityFindings = findings.filter((f) => f.title.includes('\u{1F4DD}') || f.title.includes('\u{1F4CF}')); + + const calcHealth = () => { + if (!hasData) return 0; + let score = 100; + score -= highFindings.length * 15; + score -= warnFindings.length * 5; + score -= securitySignals.filter((s) => s.level === 'high').length * 10; + return Math.max(0, Math.min(100, score)); + }; + const healthScore = calcHealth(); const policyStatus = hasData && highFindings.length === 0 && securitySignals.filter((s) => s.level === 'high').length === 0; const secretsStatus = hasData && secretFindings.length === 0; const handleCardClick = (path: string) => { + try { + eventBus.emit(Events.NAVIGATE, { path }); + addAuditEvent({ id: `nav-${Date.now()}`, event: 'navigation', timestamp: new Date().toISOString(), actor: 'user' }); + } catch { /* ignored */ } navigate(path); }; @@ -38,98 +111,109 @@ export function Dashboard() { const cards = [ { path: ROUTES.POLICY_ENGINE.path, - title: 'Политики безопасности', - description: hasData - ? (policyStatus ? 'Критичных проблем не обнаружено' : `Обнаружено проблем: ${highFindings.length}`) - : 'Запустите анализ проекта', - icon: Shield, - isOk: policyStatus, - gradient: 'from-blue-500/10 to-blue-600/5', - iconColor: 'text-blue-600', - borderColor: 'border-blue-200/50', + title: 'Политики', + description: hasData ? (policyStatus ? 'Критичных проблем нет' : `Проблем: ${highFindings.length}`) : 'Запустите анализ', + icon: Shield, isOk: policyStatus, + gradient: 'from-blue-500/10 to-blue-600/5', iconColor: 'text-blue-600', borderColor: 'border-blue-200/50', }, { path: ROUTES.AUDIT_LOGGER.path, title: 'Журнал аудита', description: auditEvents.length > 0 ? `Записей: ${auditEvents.length}` : 'Журнал пуст', - icon: FileText, - isOk: true, - gradient: 'from-purple-500/10 to-purple-600/5', - iconColor: 'text-purple-600', - borderColor: 'border-purple-200/50', + icon: FileText, isOk: true, + gradient: 'from-purple-500/10 to-purple-600/5', iconColor: 'text-purple-600', borderColor: 'border-purple-200/50', }, { path: ROUTES.SECRETS_GUARD.path, - title: 'Защита секретов', - description: hasData - ? (secretsStatus ? 'Утечек не обнаружено' : `Потенциальных проблем: ${secretFindings.length}`) - : 'Запустите анализ проекта', - icon: Lock, - isOk: secretsStatus, - gradient: 'from-emerald-500/10 to-emerald-600/5', - iconColor: 'text-emerald-600', - borderColor: 'border-emerald-200/50', + title: 'Секреты', + description: hasData ? (secretsStatus ? 'Утечек нет' : `Проблем: ${secretFindings.length}`) : 'Запустите анализ', + icon: Lock, isOk: secretsStatus, + gradient: 'from-emerald-500/10 to-emerald-600/5', iconColor: 'text-emerald-600', borderColor: 'border-emerald-200/50', + }, + { + path: ROUTES.LLM_SETTINGS.path, + title: 'AI Настройки', + description: 'OpenAI / Anthropic / Ollama', + icon: Brain, isOk: true, + gradient: 'from-orange-500/10 to-orange-600/5', iconColor: 'text-orange-600', borderColor: 'border-orange-200/50', }, ]; return ( -
-
-
-
- -
-

Безопасность

+
+
+
+
+

PAPA YU

-

- {hasData ? `Проект: ${lastReport.path}` : 'Сначала запустите анализ на главной странице'} +

+ {hasData ? `Проект: ${lastReport.path}` : 'AI-аудитор проектов. Начните с анализа на главной.'}

{!hasData && ( -
+
-

Данные безопасности появятся после анализа проекта

-
)} -
+ {hasData && ( +
+
+ + Здоровье +
+
+ + + + +
+
+ )} + + {hasData && findings.length > 0 && ( +
+

Распределение проблем

+
+ + + + f.title.includes('\u{1F4E6}')).length} max={findings.length} color="#8b5cf6" /> +
+
+ )} + +
{cards.map((card) => { const Icon = card.icon; return ( -
handleCardClick(card.path)} onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleCardClick(card.path)} - className={`card-item-anime group relative bg-card/80 backdrop-blur-sm p-8 rounded-2xl border-2 cursor-pointer hover-lift transition-all-smooth ${card.borderColor} hover:border-primary/50 hover:shadow-primary-lg focus:outline-none focus:ring-2 focus:ring-primary`} + className={`card-item-anime group relative bg-card/80 backdrop-blur-sm p-6 rounded-2xl border-2 cursor-pointer hover-lift transition-all-smooth ${card.borderColor} hover:border-primary/50 hover:shadow-primary-lg focus:outline-none focus:ring-2 focus:ring-primary`} >
-
-
- +
+
+
- {hasData && ( + {hasData && card.path !== ROUTES.LLM_SETTINGS.path && (
- {card.isOk ? : } - {card.isOk ? 'OK' : 'Внимание'} + {card.isOk ? : } + {card.isOk ? 'OK' : '!'}
)}
-

- {card.title} -

-

{card.description}

-
+

{card.title}

+

{card.description}

+
Открыть - +
@@ -138,29 +222,22 @@ export function Dashboard() {
{hasData && lastReport.llm_context && ( -
-
-
- -

Сводка анализа

-
-

- {lastReport.llm_context.concise_summary} -

- {lastReport.llm_context.key_risks.length > 0 && ( -
-

Ключевые риски:

-
    - {lastReport.llm_context.key_risks.map((r, i) => ( -
  • - - {r} -
  • - ))} -
-
- )} +
+
+ +

Сводка анализа

+

{lastReport.llm_context.concise_summary}

+ {lastReport.llm_context.key_risks.length > 0 && ( +
+ {lastReport.llm_context.key_risks.map((r, i) => ( +
+ + {r} +
+ ))} +
+ )}
)}
diff --git a/desktop/ui/src/pages/PolicyEngine.tsx b/desktop/ui/src/pages/PolicyEngine.tsx index c28002b..40f0e46 100644 --- a/desktop/ui/src/pages/PolicyEngine.tsx +++ b/desktop/ui/src/pages/PolicyEngine.tsx @@ -1,10 +1,23 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Shield, ArrowLeft, CheckCircle2, AlertTriangle, Info } from 'lucide-react'; +import { Shield, ArrowLeft, CheckCircle2, AlertTriangle, Info, ToggleLeft, ToggleRight } from 'lucide-react'; import { useAppStore } from '../store/app-store'; +function loadPolicyToggles(): Record { + try { + const raw = localStorage.getItem('papayu_policy_toggles'); + if (raw) return JSON.parse(raw); + } catch { /* ignored */ } + return {}; +} +function savePolicyToggles(t: Record) { + localStorage.setItem('papayu_policy_toggles', JSON.stringify(t)); +} + export function PolicyEngine() { const navigate = useNavigate(); const lastReport = useAppStore((s) => s.lastReport); + const [toggles, setToggles] = useState>(loadPolicyToggles); const signals = lastReport?.signals ?? []; const findings = lastReport?.findings ?? []; @@ -18,36 +31,78 @@ export function PolicyEngine() { const policyRules = [ { + id: 'env-gitignore', title: '.env без .gitignore', description: 'Файлы .env должны быть исключены из git', check: !findings.some((f) => f.title.toLowerCase().includes('.env') || f.title.toLowerCase().includes('gitignore')), color: 'blue', }, { + id: 'readme', title: 'Наличие README', description: 'Проект должен содержать README', check: !findings.some((f) => f.title.toLowerCase().includes('readme')), color: 'purple', }, { + id: 'tests', title: 'Наличие тестов', description: 'Проект должен содержать директорию tests/', check: !findings.some((f) => f.title.toLowerCase().includes('тест') || f.title.toLowerCase().includes('test')), color: 'emerald', }, { + id: 'depth', title: 'Глубина вложенности', description: 'Не должна превышать 6 уровней', check: !findings.some((f) => f.title.toLowerCase().includes('глубина') || f.title.toLowerCase().includes('вложен')), color: 'orange', }, + { + id: 'secrets', + title: 'Нет секретов в коде', + description: 'Пароли, API-ключи, токены не захардкожены', + check: !findings.some((f) => f.title.includes('\u{1F510}')), + color: 'red', + }, + { + id: 'vulns', + title: 'Нет уязвимостей', + description: 'eval(), innerHTML, SQL injection и другие паттерны', + check: !findings.some((f) => f.title.includes('\u{26A0}\u{FE0F}')), + color: 'red', + }, + { + id: 'quality', + title: 'Качество кода', + description: 'Минимум TODO/FIXME, нет console.log в проде', + check: !findings.some((f) => f.title.includes('\u{1F4DD}')), + color: 'blue', + }, + { + id: 'large-files', + title: 'Размер файлов', + description: 'Файлы не должны превышать 500 строк', + check: !findings.some((f) => f.title.includes('\u{1F4CF}')), + color: 'purple', + }, ]; + const togglePolicy = (id: string) => { + const next = { ...toggles, [id]: !(toggles[id] ?? true) }; + setToggles(next); + savePolicyToggles(next); + }; + + const enabledRules = policyRules.filter((r) => toggles[r.id] !== false); + const passedCount = enabledRules.filter((r) => r.check).length; + const colorClasses: Record = { blue: 'from-blue-500/10 to-blue-600/5 border-blue-200/50 text-blue-700 dark:text-blue-400', purple: 'from-purple-500/10 to-purple-600/5 border-purple-200/50 text-purple-700 dark:text-purple-400', emerald: 'from-emerald-500/10 to-emerald-600/5 border-emerald-200/50 text-emerald-700 dark:text-emerald-400', orange: 'from-orange-500/10 to-orange-600/5 border-orange-200/50 text-orange-700 dark:text-orange-400', + red: 'from-red-500/10 to-red-600/5 border-red-200/50 text-red-700 dark:text-red-400', }; return ( @@ -113,26 +168,35 @@ export function PolicyEngine() {
-
- -

Проверки

+
+
+ +

Проверки

+
+ {passedCount}/{enabledRules.length} пройдено
- {policyRules.map((rule, index) => { + {policyRules.map((rule) => { const cls = colorClasses[rule.color] || colorClasses.blue; + const enabled = toggles[rule.id] !== false; return ( -
+
- {rule.check ? : } + {enabled ? (rule.check ? : ) : }
{rule.title}
{rule.description}
-
- {rule.check ? '✓ Пройдено' : '✗ Нарушение'} -
+ {enabled && ( +
+ {rule.check ? '\u2713 Пройдено' : '\u2717 Нарушение'} +
+ )}
+
); diff --git a/desktop/ui/src/pages/SecretsGuard.tsx b/desktop/ui/src/pages/SecretsGuard.tsx index 2cb0863..31bd43d 100644 --- a/desktop/ui/src/pages/SecretsGuard.tsx +++ b/desktop/ui/src/pages/SecretsGuard.tsx @@ -1,10 +1,22 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Lock, ArrowLeft, CheckCircle2, AlertTriangle, Shield, Key, Info } from 'lucide-react'; +import { Lock, ArrowLeft, CheckCircle2, AlertTriangle, Shield, Key, Info, Wand2 } from 'lucide-react'; import { useAppStore } from '../store/app-store'; +import { generateAiActions, DEFAULT_LLM_SETTINGS, type LlmSettings } from '../lib/analyze'; + +function loadLlmSettings(): LlmSettings { + try { + const raw = localStorage.getItem('papayu_llm_settings'); + if (raw) return { ...DEFAULT_LLM_SETTINGS, ...JSON.parse(raw) }; + } catch { /* ignored */ } + return { ...DEFAULT_LLM_SETTINGS }; +} export function SecretsGuard() { const navigate = useNavigate(); const lastReport = useAppStore((s) => s.lastReport); + const [fixing, setFixing] = useState(false); + const [fixResult, setFixResult] = useState(null); const hasData = !!lastReport; const signals = lastReport?.signals ?? []; @@ -13,12 +25,15 @@ export function SecretsGuard() { // Extract security-related findings const secretFindings = findings.filter( (f) => + f.title.includes('\u{1F510}') || f.title.toLowerCase().includes('.env') || f.title.toLowerCase().includes('secret') || f.title.toLowerCase().includes('gitignore') || f.title.toLowerCase().includes('key') || f.title.toLowerCase().includes('token') || - f.title.toLowerCase().includes('password') + f.title.toLowerCase().includes('password') || + f.title.toLowerCase().includes('pem') || + f.title.toLowerCase().includes('aws') ); const securitySignals = signals.filter((s) => s.category === 'security'); @@ -103,9 +118,47 @@ export function SecretsGuard() { <>Есть проблемы )}
+ {!isClean && lastReport && ( + + )}
+ {fixResult && ( +
+ {fixResult} +
+ )} +
{statCards.map((stat, i) => (