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 (
-
-
-
-
-
-
-
Безопасность
+
+
+
-
- {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) => (
+
+ ))}
+
+ )}
)}
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 Нарушение'}
+
+ )}
+
togglePolicy(rule.id)} className="flex-shrink-0 p-1 hover:opacity-70 transition-opacity" title={enabled ? 'Выключить' : 'Включить'}>
+ {enabled ? : }
+
);
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 && (
+
{
+ const settings = loadLlmSettings();
+ if (!settings.apiKey && settings.provider !== 'ollama') {
+ setFixResult('⚠️ API-ключ не настроен. Перейдите в Настройки LLM.');
+ return;
+ }
+ setFixing(true);
+ setFixResult(null);
+ try {
+ const secFindings = allSecurityIssues.map((f) => ({ severity: f.severity, title: f.title, details: f.details }));
+ const resp = await generateAiActions(settings, { ...lastReport, findings: secFindings });
+ if (resp.ok && resp.actions.length > 0) {
+ setFixResult(`✅ AI сгенерировал ${resp.actions.length} исправлений. Перейдите на главную для apply.`);
+ } else if (resp.ok) {
+ setFixResult('✓ AI не нашёл автоматических исправлений.');
+ } else {
+ setFixResult(`❌ ${resp.error}`);
+ }
+ } catch (e) {
+ setFixResult(`❌ ${e}`);
+ }
+ setFixing(false);
+ }}
+ disabled={fixing}
+ className="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:opacity-90 disabled:opacity-50 flex items-center gap-2"
+ >
+
+ {fixing ? 'Генерирую...' : 'AI Fix'}
+
+ )}
+ {fixResult && (
+
+ {fixResult}
+
+ )}
+
{statCards.map((stat, i) => (