Compare commits

...

No commits in common. "v0.1.9" and "main" have entirely different histories.
v0.1.9 ... main

36 changed files with 2159 additions and 865 deletions

View File

@ -35,6 +35,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk-3-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:

74
REFACTORING_CHANGELOG.md Normal file
View File

@ -0,0 +1,74 @@
# PAPA YU — Рефакторинг: Changelog
## Что сделано
### 1. Удалены заглушки (6 файлов)
- `Documents.tsx` — пустая страница «Документы» (32 строки)
- `Finances.tsx` — пустая страница «Финансы» (32 строки)
- `Personnel.tsx` — пустая страница «Персонал» (32 строки)
- `TMCZakupki.tsx` — пустая страница «ТМЦ и закупки» (39 строк)
- `Reglamenty.tsx` — пустая страница «Регламенты» (47 строк)
- `ChatAgent.tsx` — дублировал функционал Tasks.tsx (194 строки)
### 2. Почищены роуты
**Было:** 13 роутов (4 рабочие, 9 заглушек/редиректов)
**Стало:** 7 роутов — все рабочие
| Путь | Страница | Статус |
|------|----------|--------|
| `/` | Tasks (Анализ) | ✅ Главная, анализ проекта |
| `/control-panel` | Dashboard (Безопасность) | ✅ Живые данные из анализа |
| `/policies` | PolicyEngine | ✅ Реальные проверки |
| `/audit` | AuditLogger | ✅ Реальные события |
| `/secrets` | SecretsGuard | ✅ Реальные данные |
| `/updates` | Updates | ✅ Без изменений |
| `/diagnostics` | Diagnostics | ✅ Без изменений |
### 3. Оживлены PolicyEngine, SecretsGuard, AuditLogger
**PolicyEngine** — реальные результаты из анализатора:
- Проверяет .env без .gitignore, README, тесты, глубину вложенности
- Каждая проверка: ✓/✗ на основе findings из Rust-анализатора
- Статус «Безопасно» / «Есть проблемы» на основе реальных данных
**SecretsGuard** — фильтрует security-related findings:
- Findings связанные с .env, secrets, gitignore, tokens
- Security signals из анализатора
- Статистика: критичных/предупреждений/информационных
**AuditLogger** — реальные события сессии:
- `project_analyzed` — каждый анализ с metadata
- `actions_applied` / `actions_apply_failed` — применение исправлений
- Фильтрация по типу события и агенту
- Кнопка очистки журнала
### 4. Обновлён Dashboard
- Карточки показывают реальный статус из анализа
- Сводка `llm_context` — ключевые риски, summary
- CTA «Перейти к анализу» если данных нет
### 5. Обновлён app-store
- Убран захардкоженный `systemStatus`
- Добавлен `lastReport` / `lastPath` — shared state
- Типизированные `AuditEvent` с metadata
### 6. Tasks.tsx — интеграция со store
- Report сохраняется в глобальный store при анализе
- Audit events логируются при apply/undo
## Метрики
| Метрика | До | После |
|---------|------|------|
| TypeScript | ~2 700 строк, 24 файла | ~2 410 строк, 18 файлов |
| Rust | ~1 450 строк, 9 файлов | Без изменений |
| Роутов | 13 (4 рабочие) | 7 (все рабочие) |
| Заглушек | 9 | 0 |
| Мок-данные (setTimeout) | 4 файла | 0 |
## Что НЕ менялось
- Rust бэкенд (все 5 команд, types.rs, анализатор)
- CI/CD (.github/workflows)
- Tauri конфигурация
- Updates.tsx, Diagnostics.tsx, NotFound.tsx
- anime-utils, event-bus, analyze.ts, ErrorBoundary, ErrorDisplay

View File

@ -81,6 +81,8 @@ version = "0.1.0"
dependencies = [
"chrono",
"log",
"regex",
"reqwest",
"serde",
"serde_json",
"tauri",

View File

@ -28,3 +28,5 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
walkdir = "2"
chrono = "0.4"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
regex = "1"

View File

@ -241,6 +241,11 @@ pub fn analyze_project(window: tauri::Window, path: String) -> Result<AnalyzeRep
});
}
let _ = window.emit(PROGRESS_EVENT, "Глубокий анализ кода…");
let deep = crate::deep_analysis::run_deep_analysis(std::path::Path::new(&path));
findings.extend(deep.findings);
signals.extend(deep.signals);
let _ = window.emit(PROGRESS_EVENT, "Формирую вывод…");
let recommendations = enrich_recommendations(recommendations);

View File

@ -0,0 +1,356 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize)]
pub struct LlmRequest {
pub provider: String, // "openai" | "anthropic" | "ollama"
pub model: String, // "gpt-4o" | "claude-sonnet-4-20250514" | "llama3"
pub api_key: Option<String>,
pub base_url: Option<String>, // for Ollama: http://localhost:11434
pub context: String, // llm_context JSON
pub prompt: String, // user question or system prompt
pub max_tokens: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LlmResponse {
pub ok: bool,
pub content: String,
pub model: String,
pub usage: Option<LlmUsage>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LlmUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
// ---- OpenAI-compatible request/response ----
#[derive(Serialize)]
struct OpenAiRequest {
model: String,
messages: Vec<OpenAiMessage>,
max_tokens: u32,
temperature: f32,
}
#[derive(Serialize, Deserialize)]
struct OpenAiMessage {
role: String,
content: String,
}
#[derive(Deserialize)]
struct OpenAiResponse {
choices: Option<Vec<OpenAiChoice>>,
usage: Option<OpenAiUsage>,
error: Option<OpenAiError>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiMessage,
}
#[derive(Deserialize)]
struct OpenAiUsage {
prompt_tokens: u32,
completion_tokens: u32,
total_tokens: u32,
}
#[derive(Deserialize)]
struct OpenAiError {
message: String,
}
// ---- Anthropic request/response ----
#[derive(Serialize)]
struct AnthropicRequest {
model: String,
max_tokens: u32,
system: String,
messages: Vec<AnthropicMessage>,
}
#[derive(Serialize, Deserialize)]
struct AnthropicMessage {
role: String,
content: String,
}
#[derive(Deserialize)]
struct AnthropicResponse {
content: Option<Vec<AnthropicContent>>,
usage: Option<AnthropicUsage>,
error: Option<AnthropicError>,
}
#[derive(Deserialize)]
struct AnthropicContent {
text: String,
}
#[derive(Deserialize)]
struct AnthropicUsage {
input_tokens: u32,
output_tokens: u32,
}
#[derive(Deserialize)]
struct AnthropicError {
message: String,
}
#[tauri::command]
pub async fn ask_llm(request: LlmRequest) -> Result<LlmResponse, String> {
let api_key = request.api_key.clone().unwrap_or_default();
if api_key.is_empty() && request.provider != "ollama" {
return Ok(LlmResponse {
ok: false,
content: String::new(),
model: request.model.clone(),
usage: None,
error: Some("API-ключ не указан. Откройте Настройки → LLM.".into()),
});
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
match request.provider.as_str() {
"openai" => call_openai(&client, &request, &api_key).await,
"anthropic" => call_anthropic(&client, &request, &api_key).await,
"ollama" => call_ollama(&client, &request).await,
other => Ok(LlmResponse {
ok: false,
content: String::new(),
model: request.model.clone(),
usage: None,
error: Some(format!("Неизвестный провайдер: {other}")),
}),
}
}
async fn call_openai(
client: &reqwest::Client,
req: &LlmRequest,
api_key: &str,
) -> Result<LlmResponse, String> {
let url = req
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com/v1/chat/completions".into());
let body = OpenAiRequest {
model: req.model.clone(),
messages: vec![
OpenAiMessage {
role: "system".into(),
content: build_system_prompt(&req.context),
},
OpenAiMessage {
role: "user".into(),
content: req.prompt.clone(),
},
],
max_tokens: req.max_tokens.unwrap_or(2048),
temperature: 0.3,
};
let resp = client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("OpenAI request failed: {e}"))?;
let data: OpenAiResponse = resp
.json()
.await
.map_err(|e| format!("OpenAI parse error: {e}"))?;
if let Some(err) = data.error {
return Ok(LlmResponse {
ok: false,
content: String::new(),
model: req.model.clone(),
usage: None,
error: Some(err.message),
});
}
let content = data
.choices
.and_then(|c| c.into_iter().next())
.map(|c| c.message.content)
.unwrap_or_default();
let usage = data.usage.map(|u| LlmUsage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
});
Ok(LlmResponse {
ok: true,
content,
model: req.model.clone(),
usage,
error: None,
})
}
async fn call_anthropic(
client: &reqwest::Client,
req: &LlmRequest,
api_key: &str,
) -> Result<LlmResponse, String> {
let url = "https://api.anthropic.com/v1/messages";
let body = AnthropicRequest {
model: req.model.clone(),
max_tokens: req.max_tokens.unwrap_or(2048),
system: build_system_prompt(&req.context),
messages: vec![AnthropicMessage {
role: "user".into(),
content: req.prompt.clone(),
}],
};
let resp = client
.post(url)
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("Anthropic request failed: {e}"))?;
let data: AnthropicResponse = resp
.json()
.await
.map_err(|e| format!("Anthropic parse error: {e}"))?;
if let Some(err) = data.error {
return Ok(LlmResponse {
ok: false,
content: String::new(),
model: req.model.clone(),
usage: None,
error: Some(err.message),
});
}
let content = data
.content
.and_then(|c| c.into_iter().next())
.map(|c| c.text)
.unwrap_or_default();
let usage = data.usage.map(|u| LlmUsage {
prompt_tokens: u.input_tokens,
completion_tokens: u.output_tokens,
total_tokens: u.input_tokens + u.output_tokens,
});
Ok(LlmResponse {
ok: true,
content,
model: req.model.clone(),
usage,
error: None,
})
}
async fn call_ollama(
client: &reqwest::Client,
req: &LlmRequest,
) -> Result<LlmResponse, String> {
let base = req
.base_url
.clone()
.unwrap_or_else(|| "http://localhost:11434".into());
let url = format!("{base}/api/chat");
let mut body = HashMap::new();
body.insert("model", serde_json::json!(req.model));
body.insert("stream", serde_json::json!(false));
body.insert(
"messages",
serde_json::json!([
{
"role": "system",
"content": build_system_prompt(&req.context)
},
{
"role": "user",
"content": req.prompt
}
]),
);
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("Ollama request failed: {e}. Убедитесь что Ollama запущен."))?;
let data: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Ollama parse error: {e}"))?;
if let Some(err) = data.get("error").and_then(|e| e.as_str()) {
return Ok(LlmResponse {
ok: false,
content: String::new(),
model: req.model.clone(),
usage: None,
error: Some(err.to_string()),
});
}
let content = data
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
Ok(LlmResponse {
ok: true,
content,
model: req.model.clone(),
usage: None,
error: None,
})
}
fn build_system_prompt(context_json: &str) -> String {
format!(
r#"Ты — PAPA YU, AI-аудитор программных проектов. Тебе предоставлен контекст анализа проекта в формате JSON.
На основе этого контекста ты должен:
1. Дать краткое, понятное резюме состояния проекта
2. Выделить критичные проблемы безопасности (если есть)
3. Предложить конкретные шаги по улучшению (приоритезированные)
4. Оценить общее качество и зрелость проекта
Отвечай на русском. Будь конкретен называй файлы, пути, технологии. Избегай общих фраз.
Контекст проекта:
{context_json}"#
)
}

View File

@ -0,0 +1,104 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
const MAX_CONTEXT_BYTES: usize = 100_000;
const MAX_FILE_BYTES: u64 = 30_000;
const CODE_EXTENSIONS: &[&str] = &[
"js","jsx","ts","tsx","mjs","cjs","py","rs","go","rb","php","java","kt",
"sh","bash","yml","yaml","toml","json","md","txt","sql","graphql",
"css","scss","html","vue","svelte",
];
const EXCLUDED_DIRS: &[&str] = &[
"node_modules",".git","target","dist","build",".next",
"__pycache__",".venv","venv","vendor",".cargo",
];
const PRIORITY_FILES: &[&str] = &[
"package.json","Cargo.toml","pyproject.toml","requirements.txt",
"README.md","readme.md","tsconfig.json",
"next.config.js","next.config.ts","vite.config.ts","vite.config.js",
"Dockerfile","docker-compose.yml",".env.example",".gitignore",
];
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectContextRequest { pub path: String }
#[derive(Debug, Serialize, Deserialize)]
pub struct FileContext { pub path: String, pub content: String, pub lines: u32 }
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectContextResponse {
pub ok: bool, pub files: Vec<FileContext>,
pub total_files: u32, pub total_bytes: u32,
pub truncated: bool, pub error: Option<String>,
}
#[tauri::command]
pub async fn collect_project_context(request: ProjectContextRequest) -> Result<ProjectContextResponse, String> {
let root = Path::new(&request.path);
if !root.exists() || !root.is_dir() {
return Ok(ProjectContextResponse { ok: false, files: vec![], total_files: 0, total_bytes: 0, truncated: false, error: Some(format!("Путь не существует: {}", request.path)) });
}
let mut files: Vec<FileContext> = Vec::new();
let mut total_bytes: usize = 0;
let mut truncated = false;
for pf in PRIORITY_FILES {
let fp = root.join(pf);
if fp.exists() && fp.is_file() {
if let Some(fc) = read_file_ctx(root, &fp) { total_bytes += fc.content.len(); files.push(fc); }
}
}
let mut all: Vec<std::path::PathBuf> = Vec::new();
collect_code_files(root, root, 0, &mut all);
all.sort_by(|a, b| {
let a_src = a.to_string_lossy().contains("src/");
let b_src = b.to_string_lossy().contains("src/");
match (a_src, b_src) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.metadata().map(|m| m.len()).unwrap_or(u64::MAX).cmp(&b.metadata().map(|m| m.len()).unwrap_or(u64::MAX)),
}
});
for fp in &all {
if total_bytes >= MAX_CONTEXT_BYTES { truncated = true; break; }
let rel = fp.strip_prefix(root).unwrap_or(fp).to_string_lossy().to_string();
if files.iter().any(|f| f.path == rel) { continue; }
if let Some(fc) = read_file_ctx(root, fp) {
if total_bytes + fc.content.len() > MAX_CONTEXT_BYTES { truncated = true; break; }
total_bytes += fc.content.len();
files.push(fc);
}
}
Ok(ProjectContextResponse { ok: true, total_files: files.len() as u32, total_bytes: total_bytes as u32, truncated, files, error: None })
}
fn read_file_ctx(root: &Path, fp: &Path) -> Option<FileContext> {
let meta = fp.metadata().ok()?;
if meta.len() > MAX_FILE_BYTES { return None; }
let content = fs::read_to_string(fp).ok()?;
let rel = fp.strip_prefix(root).unwrap_or(fp).to_string_lossy().to_string();
Some(FileContext { path: rel, lines: content.lines().count() as u32, content })
}
fn collect_code_files(root: &Path, dir: &Path, depth: u32, out: &mut Vec<std::path::PathBuf>) {
if depth > 8 || out.len() > 300 { return; }
let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => return };
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if EXCLUDED_DIRS.contains(&name) || name.starts_with('.') { continue; }
collect_code_files(root, &path, depth + 1, out);
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if CODE_EXTENSIONS.contains(&ext) { out.push(path); }
}
}

View File

@ -0,0 +1,134 @@
use crate::types::{Action, ActionKind};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct GenerateActionsRequest {
pub provider: String,
pub model: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub context: String, // llm_context JSON
pub findings_json: String, // findings array JSON
pub project_path: String,
pub max_tokens: Option<u32>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GenerateActionsResponse {
pub ok: bool,
pub actions: Vec<Action>,
pub explanation: String,
pub error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LlmActionsOutput {
actions: Vec<LlmAction>,
explanation: String,
}
#[derive(Debug, Deserialize)]
struct LlmAction {
id: String,
title: String,
description: String,
kind: String, // "create_file" | "update_file" | "create_dir"
path: String,
content: Option<String>,
}
#[tauri::command]
pub async fn generate_ai_actions(
request: GenerateActionsRequest,
) -> Result<GenerateActionsResponse, String> {
let api_key = request.api_key.clone().unwrap_or_default();
if api_key.is_empty() && request.provider != "ollama" {
return Ok(GenerateActionsResponse {
ok: false,
actions: vec![],
explanation: String::new(),
error: Some("API-ключ не указан.".into()),
});
}
let user_prompt = format!(
"Ты — PAPA YU, AI-аудитор проектов. На основе контекста и списка найденных проблем сгенерируй конкретные действия для исправления.\n\nВАЖНО: Отвечай ТОЛЬКО валидным JSON без markdown-обёртки. Формат:\n{{\n \"actions\": [\n {{\n \"id\": \"уникальный-id\",\n \"title\": \"Краткое название\",\n \"description\": \"Что делает\",\n \"kind\": \"create_file\",\n \"path\": \"путь/к/файлу\",\n \"content\": \"содержимое\"\n }}\n ],\n \"explanation\": \"Краткое объяснение\"\n}}\n\nДопустимые kind: \"create_file\", \"update_file\", \"create_dir\"\nПуть — относительный от корня проекта. Не более 10 действий.\nПуть проекта: {}\n\nПроблемы:\n{}",
request.project_path,
request.findings_json
);
let llm_request = super::ask_llm::LlmRequest {
provider: request.provider,
model: request.model,
api_key: request.api_key,
base_url: request.base_url,
context: request.context,
prompt: user_prompt,
max_tokens: request.max_tokens.or(Some(4096)),
};
let llm_response = super::ask_llm::ask_llm(llm_request).await?;
if !llm_response.ok {
return Ok(GenerateActionsResponse {
ok: false,
actions: vec![],
explanation: String::new(),
error: llm_response.error,
});
}
// Parse JSON from LLM response
let content = llm_response.content.trim().to_string();
// Strip markdown code fences if present
let json_str = content
.strip_prefix("```json")
.or_else(|| content.strip_prefix("```"))
.unwrap_or(&content)
.strip_suffix("```")
.unwrap_or(&content)
.trim();
match serde_json::from_str::<LlmActionsOutput>(json_str) {
Ok(output) => {
let actions: Vec<Action> = output
.actions
.into_iter()
.filter_map(|a| {
let kind = match a.kind.as_str() {
"create_file" => ActionKind::CreateFile,
"update_file" => ActionKind::UpdateFile,
"create_dir" => ActionKind::CreateDir,
"delete_file" => ActionKind::DeleteFile,
_ => return None,
};
Some(Action {
id: format!("ai-{}", a.id),
title: a.title,
description: a.description,
kind,
path: a.path,
content: a.content,
})
})
.collect();
Ok(GenerateActionsResponse {
ok: true,
actions,
explanation: output.explanation,
error: None,
})
}
Err(e) => Ok(GenerateActionsResponse {
ok: false,
actions: vec![],
explanation: String::new(),
error: Some(format!(
"Ошибка парсинга ответа LLM: {}. Ответ: {}",
e,
&json_str[..json_str.len().min(200)]
)),
}),
}
}

View File

@ -1,11 +1,17 @@
mod analyze_project;
mod apply_actions;
pub mod ask_llm;
mod generate_ai_actions;
mod get_app_info;
mod preview_actions;
mod undo_last;
pub use analyze_project::analyze_project;
pub use apply_actions::apply_actions;
pub use ask_llm::ask_llm;
pub use generate_ai_actions::generate_ai_actions;
pub use get_app_info::get_app_info;
pub use preview_actions::preview_actions;
pub use undo_last::undo_last;
mod collect_context;
pub use collect_context::collect_project_context;

View File

@ -0,0 +1,158 @@
use crate::types::{Finding, ProjectSignal};
use std::path::Path;
use std::fs;
const MAX_SCAN_SIZE: u64 = 512 * 1024;
const CODE_EXTENSIONS: &[&str] = &[
"js", "jsx", "ts", "tsx", "mjs", "cjs",
"py", "rs", "go", "rb", "php", "java", "kt",
"sh", "bash", "zsh",
"yml", "yaml", "toml", "json",
"sql", "env", "cfg", "ini", "conf",
];
const SECRET_PATTERNS: &[(&str, &str)] = &[
(r"(?i)(password|passwd|pwd)\s*[:=]\s*['\x22][^'\x22]{4,}['\x22]", "Захардкоженный пароль"),
(r"(?i)(api[_-]?key|apikey)\s*[:=]\s*['\x22][^'\x22]{8,}['\x22]", "Захардкоженный API-ключ"),
(r"(?i)(secret|token)\s*[:=]\s*['\x22][^'\x22]{8,}['\x22]", "Захардкоженный секрет/токен"),
(r"AKIA[0-9A-Z]{16}", "AWS Access Key ID"),
(r"-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----", "PEM приватный ключ"),
(r"ghp_[0-9a-zA-Z]{36}", "GitHub Personal Access Token"),
(r"sk-[a-zA-Z0-9]{20,}", "Возможный API-ключ (sk-...)"),
];
const VULN_PATTERNS: &[(&str, &str, &str)] = &[
(r"eval\s*\(", "Использование eval() — риск code injection", "js,jsx,ts,tsx,py"),
(r"innerHTML\s*=", "Прямая запись innerHTML — риск XSS", "js,jsx,ts,tsx"),
(r"document\.write\s*\(", "document.write() — устаревший метод", "js,jsx,ts,tsx"),
(r"(?i)dangerouslySetInnerHTML", "dangerouslySetInnerHTML — риск XSS", "jsx,tsx"),
(r"subprocess\.call\s*\(.*shell\s*=\s*True", "subprocess с shell=True", "py"),
(r"os\.system\s*\(", "os.system() — лучше subprocess", "py"),
(r"(?i)cors.*origin.*\*", "CORS с wildcard origin", "js,ts,py,rb"),
(r"(?i)chmod\s+777", "chmod 777 — слишком широкие права", "sh,bash,zsh,yml,yaml"),
];
const QUALITY_PATTERNS: &[(&str, &str, &str)] = &[
(r"TODO|FIXME|HACK|XXX", "TODO/FIXME комментарии", "js,jsx,ts,tsx,py,rs,go,rb"),
(r"console\.(log|debug|info)\s*\(", "console.log в коде", "js,jsx,ts,tsx"),
(r"dbg!\s*\(", "dbg!() макрос (отладочный)", "rs"),
(r"\.unwrap\(\)", "Небезопасный .unwrap()", "rs"),
];
pub struct DeepAnalysisResult {
pub findings: Vec<Finding>,
pub signals: Vec<ProjectSignal>,
pub todo_count: u32,
pub security_issues: u32,
pub quality_issues: u32,
pub files_scanned: u32,
}
pub fn run_deep_analysis(root: &Path) -> DeepAnalysisResult {
let mut result = DeepAnalysisResult {
findings: Vec::new(), signals: Vec::new(),
todo_count: 0, security_issues: 0, quality_issues: 0, files_scanned: 0,
};
let mut files: Vec<std::path::PathBuf> = Vec::new();
collect_files(root, root, 0, &mut files);
for file_path in &files {
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
let content = match fs::read_to_string(file_path) { Ok(c) => c, Err(_) => continue };
result.files_scanned += 1;
let rel = file_path.strip_prefix(root).unwrap_or(file_path).to_string_lossy().to_string();
if !rel.contains(".example") && !rel.contains(".sample") {
for (pat, desc) in SECRET_PATTERNS {
if let Ok(re) = regex::Regex::new(pat) {
if re.is_match(&content) {
result.security_issues += 1;
result.findings.push(Finding { severity: "high".into(), title: format!("🔐 {}", desc), details: format!("Файл: {}", rel) });
result.signals.push(ProjectSignal { category: "security".into(), level: "high".into(), message: format!("{} в {}", desc, rel) });
}
}
}
}
for (pat, title, exts) in VULN_PATTERNS {
let applicable: Vec<&str> = exts.split(',').collect();
if !applicable.contains(&ext.as_str()) { continue; }
if let Ok(re) = regex::Regex::new(pat) {
let matches: Vec<_> = re.find_iter(&content).collect();
if !matches.is_empty() {
result.security_issues += 1;
let line = content[..matches[0].start()].chars().filter(|c| *c == '\n').count() + 1;
result.findings.push(Finding { severity: "high".into(), title: format!("⚠️ {}", title), details: format!("{}:{} ({} шт.)", rel, line, matches.len()) });
}
}
}
for (pat, title, exts) in QUALITY_PATTERNS {
let applicable: Vec<&str> = exts.split(',').collect();
if !applicable.contains(&ext.as_str()) { continue; }
if let Ok(re) = regex::Regex::new(pat) {
let matches: Vec<_> = re.find_iter(&content).collect();
if !matches.is_empty() {
let count = matches.len();
if pat.contains("TODO") { result.todo_count += count as u32; }
result.quality_issues += count as u32;
if count >= 3 || pat.contains("unwrap") {
result.findings.push(Finding { severity: "warn".into(), title: format!("📝 {}", title), details: format!("{}: {} шт.", rel, count) });
}
}
}
}
let lines = content.lines().count();
if lines > 500 {
result.findings.push(Finding { severity: "warn".into(), title: "📏 Большой файл".into(), details: format!("{}: {} строк", rel, lines) });
}
if rel == "package.json" { check_package_json(&content, &mut result); }
if rel == "requirements.txt" { check_requirements_txt(&content, &mut result); }
}
if result.security_issues > 0 {
result.signals.push(ProjectSignal { category: "security".into(), level: "high".into(), message: format!("Deep analysis: {} проблем безопасности", result.security_issues) });
}
if result.todo_count > 5 {
result.signals.push(ProjectSignal { category: "quality".into(), level: "warn".into(), message: format!("{} TODO/FIXME комментариев", result.todo_count) });
}
result
}
fn collect_files(root: &Path, dir: &Path, depth: u32, out: &mut Vec<std::path::PathBuf>) {
if depth > 10 || out.len() > 500 { return; }
let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => return };
let excluded = ["node_modules", ".git", "target", "dist", "build", ".next", "__pycache__", ".venv", "venv", "vendor", ".cargo"];
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if path.is_dir() {
if excluded.contains(&name) { continue; }
collect_files(root, &path, depth + 1, out);
continue;
}
if let Ok(meta) = path.metadata() { if meta.len() > MAX_SCAN_SIZE { continue; } }
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if CODE_EXTENSIONS.contains(&ext) { out.push(path); }
}
}
fn check_package_json(content: &str, result: &mut DeepAnalysisResult) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
if let Some(scripts) = json.get("scripts").and_then(|s| s.as_object()) {
if !scripts.contains_key("test") || scripts.get("test").and_then(|t| t.as_str()).unwrap_or("").contains("no test specified") {
result.findings.push(Finding { severity: "warn".into(), title: "🧪 Нет скрипта test".into(), details: "npm test не настроен".into() });
}
}
}
}
fn check_requirements_txt(content: &str, result: &mut DeepAnalysisResult) {
let unpinned: u32 = content.lines().filter(|l| { let l = l.trim(); !l.is_empty() && !l.starts_with('#') && !l.contains("==") }).count() as u32;
if unpinned > 3 {
result.findings.push(Finding { severity: "warn".into(), title: "📦 Незафиксированные версии".into(), details: format!("{} пакетов без ==", unpinned) });
}
}

View File

@ -1,7 +1,8 @@
mod deep_analysis;
mod commands;
mod types;
use commands::{analyze_project, apply_actions, get_app_info, preview_actions, undo_last};
use commands::{analyze_project, apply_actions, ask_llm, generate_ai_actions, collect_project_context, get_app_info, preview_actions, undo_last};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@ -25,6 +26,9 @@ pub fn run() {
apply_actions,
undo_last,
get_app_info,
ask_llm,
generate_ai_actions,
collect_project_context,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -1,16 +1,13 @@
import { HashRouter, Routes, Route, useLocation, Navigate } from 'react-router-dom';
import { HashRouter, Routes, Route, useLocation } from 'react-router-dom';
import { useEffect } from 'react';
import { Dashboard } from './pages/Dashboard';
import { Tasks } from './pages/Tasks';
import { Reglamenty } from './pages/Reglamenty';
import { TMCZakupki } from './pages/TMCZakupki';
import { Finances } from './pages/Finances';
import { Personnel } from './pages/Personnel';
import { PolicyEngine } from './pages/PolicyEngine';
import { AuditLogger } from './pages/AuditLogger';
import { SecretsGuard } from './pages/SecretsGuard';
import { Updates } from './pages/Updates';
import { Diagnostics } from './pages/Diagnostics';
import { LlmSettingsPage } from './pages/LlmSettings';
import { Layout } from './components/layout/Layout';
import { ErrorBoundary } from './components/ErrorBoundary';
import { ErrorDisplay } from './components/ErrorDisplay';
@ -23,7 +20,7 @@ function RouteTracker() {
useEffect(() => {
try {
useAppStore.getState().setCurrentRoute(location.pathname);
} catch (_) {}
} catch { /* ignored */ }
}, [location.pathname]);
return null;
}
@ -36,19 +33,14 @@ function App() {
<ErrorDisplay />
<Layout>
<Routes>
<Route path={ROUTES.DASHBOARD.path} element={<Navigate to={ROUTES.TASKS.path} replace />} />
<Route path={ROUTES.TASKS.path} element={<Tasks />} />
<Route path={ROUTES.REGLAMENTY.path} element={<Reglamenty />} />
<Route path={ROUTES.TMC_ZAKUPKI.path} element={<TMCZakupki />} />
<Route path={ROUTES.FINANCES.path} element={<Finances />} />
<Route path={ROUTES.PERSONNEL.path} element={<Personnel />} />
<Route path={ROUTES.CONTROL_PANEL.path} element={<Dashboard />} />
<Route path={ROUTES.CHAT_AGENT.path} element={<Navigate to={ROUTES.TASKS.path} replace />} />
<Route path={ROUTES.POLICY_ENGINE.path} element={<PolicyEngine />} />
<Route path={ROUTES.AUDIT_LOGGER.path} element={<AuditLogger />} />
<Route path={ROUTES.SECRETS_GUARD.path} element={<SecretsGuard />} />
<Route path={ROUTES.UPDATES.path} element={<Updates />} />
<Route path={ROUTES.DIAGNOSTICS.path} element={<Diagnostics />} />
<Route path={ROUTES.LLM_SETTINGS.path} element={<LlmSettingsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Layout>

View File

@ -4,23 +4,21 @@ import { Link, useLocation } from 'react-router-dom';
import { ROUTES } from '../../config/routes';
import { eventBus, Events } from '../../lib/event-bus';
import { animateLogo, animateStaggerIn } from '../../lib/anime-utils';
import { LayoutDashboard, ListTodo, FileText, Package, Wallet, Users, MessageSquare, Download } from 'lucide-react';
import { Search, LayoutDashboard, Download, Settings, Shield, FileText, Lock, Brain } from 'lucide-react';
interface LayoutProps {
children: ReactNode;
}
const NAV_ICONS: Record<string, typeof LayoutDashboard> = {
[ROUTES.DASHBOARD.path]: LayoutDashboard,
[ROUTES.TASKS.path]: ListTodo,
[ROUTES.TASKS.path]: Search,
[ROUTES.CONTROL_PANEL.path]: LayoutDashboard,
[ROUTES.POLICY_ENGINE.path]: Shield,
[ROUTES.AUDIT_LOGGER.path]: FileText,
[ROUTES.SECRETS_GUARD.path]: Lock,
[ROUTES.UPDATES.path]: Download,
[ROUTES.DIAGNOSTICS.path]: LayoutDashboard,
[ROUTES.REGLAMENTY.path]: FileText,
[ROUTES.TMC_ZAKUPKI.path]: Package,
[ROUTES.FINANCES.path]: Wallet,
[ROUTES.PERSONNEL.path]: Users,
[ROUTES.CHAT_AGENT.path]: MessageSquare,
[ROUTES.DIAGNOSTICS.path]: Settings,
[ROUTES.LLM_SETTINGS.path]: Brain,
};
async function checkAndInstallUpdate(): Promise<{ ok: boolean; message: string }> {
@ -71,12 +69,13 @@ export function Layout({ children }: LayoutProps) {
try {
eventBus.emit(Events.NAVIGATE, { path });
eventBus.emit(Events.ROUTE_CHANGED, { path });
} catch (_) {}
} catch { /* ignored */ }
};
const navItems = [
ROUTES.TASKS,
ROUTES.CONTROL_PANEL,
ROUTES.LLM_SETTINGS,
ROUTES.UPDATES,
ROUTES.DIAGNOSTICS,
].map((r) => ({ path: r.path, name: r.name, icon: NAV_ICONS[r.path] ?? FileText }));

View File

@ -6,17 +6,12 @@ export interface RouteConfig {
}
export const ROUTES: Record<string, RouteConfig> = {
DASHBOARD: { path: '/', name: 'Панель', component: 'Dashboard', description: 'Главная панель' },
TASKS: { path: '/tasks', name: 'Задачи', component: 'Tasks', description: 'Задачи с вложениями' },
CONTROL_PANEL: { path: '/control-panel', name: 'Панель управления', component: 'Dashboard', description: 'Панель управления' },
REGLAMENTY: { path: '/reglamenty', name: 'Регламенты', component: 'Reglamenty', description: 'АРМАК, ФАА, ЕАСА' },
TMC_ZAKUPKI: { path: '/tmc-zakupki', name: 'ТМЦ и закупки', component: 'TMCZakupki', description: 'ТМЦ и закупки' },
FINANCES: { path: '/finances', name: 'Финансы', component: 'Finances', description: 'Платежи и отчёты' },
PERSONNEL: { path: '/personnel', name: 'Персонал', component: 'Personnel', description: 'Сотрудники и учёт' },
CHAT_AGENT: { path: '/chat', name: 'Чат с агентом', component: 'ChatAgent', description: 'Диалог с ИИ агентом' },
POLICY_ENGINE: { path: '/policies', name: 'Движок политик', component: 'PolicyEngine', description: 'Правила безопасности' },
AUDIT_LOGGER: { path: '/audit', name: 'Журнал аудита', component: 'AuditLogger', description: 'Действия в системе' },
SECRETS_GUARD: { path: '/secrets', name: 'Защита секретов', component: 'SecretsGuard', description: 'Защита от утечек' },
UPDATES: { path: '/updates', name: 'Обновления', component: 'Updates', description: 'Проверка и установка обновлений' },
DIAGNOSTICS: { path: '/diagnostics', name: 'Диагностика', component: 'Diagnostics', description: 'Версии, пути, логи' },
TASKS: { path: '/', name: 'Анализ', component: 'Tasks', description: 'Анализ проекта' },
CONTROL_PANEL: { path: '/control-panel', name: 'Безопасность', component: 'Dashboard', description: 'Панель безопасности' },
POLICY_ENGINE: { path: '/policies', name: 'Политики', component: 'PolicyEngine', description: 'Правила безопасности' },
AUDIT_LOGGER: { path: '/audit', name: 'Аудит', component: 'AuditLogger', description: 'Журнал действий' },
SECRETS_GUARD: { path: '/secrets', name: 'Секреты', component: 'SecretsGuard', description: 'Защита от утечек' },
UPDATES: { path: '/updates', name: 'Обновления', component: 'Updates', description: 'Проверка обновлений' },
DIAGNOSTICS: { path: '/diagnostics', name: 'Диагностика', component: 'Diagnostics', description: 'Версии и логи' },
LLM_SETTINGS: { path: '/llm-settings', name: 'Настройки LLM', component: 'LlmSettings', description: 'Провайдер, модель, API-ключ' },
};

View File

@ -107,3 +107,165 @@ export interface AnalyzeReport {
export async function analyzeProject(path: string): Promise<AnalyzeReport> {
return invoke<AnalyzeReport>('analyze_project', { path });
}
// ---- LLM Integration ----
export interface LlmRequest {
provider: string; // "openai" | "anthropic" | "ollama"
model: string;
api_key?: string | null;
base_url?: string | null;
context: string; // JSON string of llm_context
prompt: string;
max_tokens?: number | null;
}
export interface LlmResponse {
ok: boolean;
content: string;
model: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null;
error?: string | null;
}
export interface LlmSettings {
provider: string;
model: string;
apiKey: string;
baseUrl: string;
}
export const DEFAULT_LLM_SETTINGS: LlmSettings = {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: '',
baseUrl: '',
};
export const LLM_MODELS: Record<string, { label: string; models: { value: string; label: string }[] }> = {
openai: {
label: 'OpenAI',
models: [
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini (дешёвый, быстрый)' },
{ value: 'gpt-4o', label: 'GPT-4o (мощный)' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' },
{ value: 'gpt-4.1', label: 'GPT-4.1' },
],
},
anthropic: {
label: 'Anthropic',
models: [
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 (быстрый)' },
],
},
ollama: {
label: 'Ollama (локальный)',
models: [
{ value: 'llama3.1', label: 'Llama 3.1' },
{ value: 'mistral', label: 'Mistral' },
{ value: 'codellama', label: 'Code Llama' },
{ value: 'qwen2.5-coder', label: 'Qwen 2.5 Coder' },
],
},
};
export async function askLlm(
settings: LlmSettings,
context: LlmContext,
prompt: string,
): Promise<LlmResponse> {
return invoke<LlmResponse>('ask_llm', {
request: {
provider: settings.provider,
model: settings.model,
api_key: settings.apiKey || null,
base_url: settings.baseUrl || null,
context: JSON.stringify(context),
prompt,
max_tokens: 2048,
},
});
}
// ---- AI Code Generation ----
export interface GenerateActionsResponse {
ok: boolean;
actions: Action[];
explanation: string;
error?: string | null;
}
export async function generateAiActions(
settings: LlmSettings,
report: AnalyzeReport,
): Promise<GenerateActionsResponse> {
return invoke<GenerateActionsResponse>('generate_ai_actions', {
request: {
provider: settings.provider,
model: settings.model,
api_key: settings.apiKey || null,
base_url: settings.baseUrl || null,
context: JSON.stringify(report.llm_context),
findings_json: JSON.stringify(report.findings),
project_path: report.path,
max_tokens: 4096,
},
});
}
// ---- RAG Chat ----
export interface FileContext {
path: string;
content: string;
lines: number;
}
export interface ProjectContextResponse {
ok: boolean;
files: FileContext[];
total_files: number;
total_bytes: number;
truncated: boolean;
error?: string | null;
}
export async function collectProjectContext(
path: string,
): Promise<ProjectContextResponse> {
return invoke<ProjectContextResponse>('collect_project_context', {
request: { path },
});
}
export async function chatWithProject(
settings: LlmSettings,
projectPath: string,
projectContext: ProjectContextResponse,
llmContext: LlmContext,
question: string,
chatHistory: { role: string; content: string }[],
): Promise<LlmResponse> {
// Build context from file contents
const filesSummary = projectContext.files
.map((f) => `--- ${f.path} (${f.lines} строк) ---\n${f.content}`)
.join('\n\n');
const contextStr = JSON.stringify(llmContext);
const fullPrompt = `Контекст проекта (${projectPath}):\n${contextStr}\n\айлы проекта (${projectContext.total_files} файлов, ${projectContext.total_bytes} байт${projectContext.truncated ? ', обрезано' : ''}):\n${filesSummary}\n\n${chatHistory.length > 0 ? 'История чата:\n' + chatHistory.map((m) => `${m.role}: ${m.content}`).join('\n') + '\n\n' : ''}Вопрос пользователя: ${question}`;
return invoke<LlmResponse>('ask_llm', {
request: {
provider: settings.provider,
model: settings.model,
api_key: settings.apiKey || null,
base_url: settings.baseUrl || null,
context: contextStr,
prompt: fullPrompt,
max_tokens: 2048,
},
});
}

View File

@ -1,69 +1,41 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, ArrowLeft, CheckCircle2, XCircle, Clock, Filter, Search, Activity, Lock } from 'lucide-react';
interface AuditEvent {
id: string;
event: string;
actor: string;
timestamp: string;
result?: 'success' | 'failure';
metadata?: Record<string, unknown>;
}
import { FileText, ArrowLeft, CheckCircle2, XCircle, Clock, Filter, Search, Activity, Info, Trash2 } from 'lucide-react';
import { useAppStore } from '../store/app-store';
export function AuditLogger() {
const navigate = useNavigate();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const auditEvents = useAppStore((s) => s.auditEvents);
const clearAuditEvents = useAppStore((s) => s.clearAuditEvents);
const lastReport = useAppStore((s) => s.lastReport);
const [filter, setFilter] = useState<{ type?: string; actor?: string }>({});
useEffect(() => {
const t = setTimeout(() => {
setEvents([
{ id: '1', event: 'command_executed', actor: 'command_router', timestamp: new Date().toISOString(), result: 'success', metadata: { tool: 'fs.read', path: './src/App.tsx' } },
{ id: '2', event: 'policy_denial', actor: 'policy_engine', timestamp: new Date(Date.now() - 3600000).toISOString(), result: 'failure', metadata: { reason: 'tool_not_in_allowlist', tool: 'shell.exec' } },
{ id: '3', event: 'secret_detected', actor: 'secrets_guard', timestamp: new Date(Date.now() - 7200000).toISOString(), result: 'success', metadata: { violationCount: 1, type: 'api_key' } },
]);
setLoading(false);
}, 500);
return () => clearTimeout(t);
}, []);
const hasData = auditEvents.length > 0;
const getEventIcon = (event: string) => {
if (event.includes('command')) return Activity;
if (event.includes('policy')) return XCircle;
if (event.includes('secret')) return Lock;
return FileText;
if (event.includes('analyz')) return Search;
if (event.includes('appl')) return CheckCircle2;
if (event.includes('fail') || event.includes('error')) return XCircle;
return Activity;
};
const filtered = events.filter((e) => {
const eventTypes = [...new Set(auditEvents.map((e) => e.event))];
const actors = [...new Set(auditEvents.map((e) => e.actor))];
const filtered = auditEvents.filter((e) => {
if (filter.type && e.event !== filter.type) return false;
if (filter.actor && e.actor !== filter.actor) return false;
return true;
});
if (loading) {
return (
<div className="p-8 md:p-12">
<div className="animate-pulse space-y-6">
<div className="h-10 bg-muted rounded w-1/3 mb-6" />
<div className="h-48 bg-muted rounded" />
<div className="space-y-3">
{[1, 2, 3].map((i) => <div key={i} className="h-24 bg-muted rounded" />)}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen p-8 md:p-12 lg:p-16 bg-gradient-to-br from-background via-background to-purple-50/30 dark:to-purple-950/10">
<button
onClick={() => navigate('/')}
onClick={() => navigate(lastReport ? '/control-panel' : '/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели управления
Назад
</button>
<div className="mb-10 md:mb-12 animate-fade-in">
@ -73,98 +45,121 @@ export function AuditLogger() {
</div>
<div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">Журнал аудита</h1>
<p className="text-lg md:text-xl text-muted-foreground font-light mt-2">Просмотр и анализ всех действий в системе</p>
<p className="text-lg md:text-xl text-muted-foreground font-light mt-2">
Реальные действия в текущей сессии
</p>
</div>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border mb-8 animate-fade-in-up">
<div className="flex items-center gap-3 mb-6">
<Filter className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Фильтры</h2>
{!hasData ? (
<div className="bg-card/80 backdrop-blur-sm p-8 rounded-2xl border text-center">
<Info className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-lg text-muted-foreground mb-4">Журнал пуст действия появятся после анализа проекта</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90"
>
Перейти к анализу
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Тип события</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<select
className="w-full pl-10 pr-4 py-2.5 border-2 rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
value={filter.type || ''}
onChange={(e) => setFilter({ ...filter, type: e.target.value })}
) : (
<>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border mb-8 animate-fade-in-up">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Filter className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Фильтры</h2>
</div>
<button
onClick={clearAuditEvents}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted text-muted-foreground"
>
<option value="">Все события</option>
<option value="command_executed">Выполнение команды</option>
<option value="policy_denial">Отклонение политики</option>
<option value="secret_detected">Обнаружение секрета</option>
</select>
<Trash2 className="w-4 h-4" />
Очистить журнал
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Тип события</label>
<select
className="w-full px-4 py-2.5 border-2 rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
value={filter.type || ''}
onChange={(e) => setFilter({ ...filter, type: e.target.value || undefined })}
>
<option value="">Все события</option>
{eventTypes.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Агент</label>
<select
className="w-full px-4 py-2.5 border-2 rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
value={filter.actor || ''}
onChange={(e) => setFilter({ ...filter, actor: e.target.value || undefined })}
>
<option value="">Все агенты</option>
{actors.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Агент</label>
<select
className="w-full px-4 py-2.5 border-2 rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
value={filter.actor || ''}
onChange={(e) => setFilter({ ...filter, actor: e.target.value })}
>
<option value="">Все агенты</option>
<option value="command_router">Command Router</option>
<option value="policy_engine">Policy Engine</option>
<option value="secrets_guard">Secrets Guard</option>
</select>
</div>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border animate-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Activity className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">События</h2>
</div>
<span className="text-sm text-muted-foreground">Всего: {filtered.length}</span>
</div>
{filtered.length === 0 ? (
<div className="text-center py-16">
<FileText className="w-8 h-8 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground text-lg">Нет событий для отображения</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((event) => {
const Icon = getEventIcon(event.event);
const isSuccess = event.result === 'success';
return (
<div key={event.id} className="p-5 rounded-xl border-2 bg-card/50 hover:shadow-lg transition-all-smooth">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${isSuccess ? 'bg-green-100 dark:bg-green-900/20' : 'bg-red-100 dark:bg-red-900/20'}`}>
<Icon className={`w-5 h-5 ${isSuccess ? 'text-green-600' : 'text-red-600'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="font-semibold">{event.event}</span>
<span className={`status-badge ${isSuccess ? 'status-active' : 'status-inactive'}`}>
{isSuccess ? <><CheckCircle2 className="w-4 h-4" /><span>Успех</span></> : <><XCircle className="w-4 h-4" /><span>Ошибка</span></>}
</span>
<span className="px-3 py-1 rounded-full text-xs font-medium bg-muted">{event.actor}</span>
</div>
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span className="font-mono">{new Date(event.timestamp).toLocaleString('ru-RU')}</span>
</div>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<div className="mt-3 p-3 rounded-lg bg-muted/50 border">
<pre className="text-xs font-mono text-muted-foreground break-all">{JSON.stringify(event.metadata, null, 2)}</pre>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border animate-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Activity className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">События</h2>
</div>
<span className="text-sm text-muted-foreground">Показано: {filtered.length} из {auditEvents.length}</span>
</div>
{filtered.length === 0 ? (
<div className="text-center py-16">
<FileText className="w-8 h-8 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground text-lg">Нет событий для текущего фильтра</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((event) => {
const Icon = getEventIcon(event.event);
const isSuccess = event.result === 'success';
return (
<div key={event.id} className="p-5 rounded-xl border-2 bg-card/50 hover:shadow-lg transition-all-smooth">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${isSuccess ? 'bg-green-100 dark:bg-green-900/20' : 'bg-red-100 dark:bg-red-900/20'}`}>
<Icon className={`w-5 h-5 ${isSuccess ? 'text-green-600' : 'text-red-600'}`} />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="font-semibold">{event.event}</span>
<span className={`status-badge ${isSuccess ? 'status-active' : 'status-inactive'}`}>
{isSuccess ? <><CheckCircle2 className="w-4 h-4" /><span>Успех</span></> : <><XCircle className="w-4 h-4" /><span>Ошибка</span></>}
</span>
<span className="px-3 py-1 rounded-full text-xs font-medium bg-muted">{event.actor}</span>
</div>
<div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span className="font-mono">{new Date(event.timestamp).toLocaleString('ru-RU')}</span>
</div>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<div className="mt-3 p-3 rounded-lg bg-muted/50 border">
<pre className="text-xs font-mono text-muted-foreground break-all">{JSON.stringify(event.metadata, null, 2)}</pre>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
);
})}
</div>
)}
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@ -1,194 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { open } from '@tauri-apps/plugin-dialog';
import { MessageSquare, ArrowLeft, RotateCcw, Trash2, FolderOpen } from 'lucide-react';
import { analyzeProject, type AnalyzeReport } from '../lib/analyze';
type Message =
| { role: 'user'; text: string }
| { role: 'assistant'; text: string }
| { role: 'assistant'; report: AnalyzeReport; error?: string };
export function ChatAgent() {
const navigate = useNavigate();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const handleClearChat = () => {
setMessages([]);
};
const handleUndo = () => {
if (messages.length > 0) {
setMessages((prev) => prev.slice(0, -1));
}
};
const handleSend = () => {
if (!input.trim()) return;
setMessages((prev) => [...prev, { role: 'user', text: input.trim() }]);
setInput('');
setTimeout(() => {
setMessages((prev) => [
...prev,
{ role: 'assistant', text: 'Ответ ИИ агента будет отображаться здесь. Результаты действий агента подключаются к backend.' },
]);
}, 500);
};
const handlePickFolderAndAnalyze = async () => {
const selected = await open({
directory: true,
multiple: false,
});
if (!selected) return;
const pathStr = selected;
setMessages((prev) => [
...prev,
{ role: 'user', text: `Проанализируй проект: ${pathStr}` },
{ role: 'assistant', text: 'Индексирую файлы…' },
]);
try {
const report = await analyzeProject(pathStr);
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', report };
return next;
});
} catch (e) {
const errMsg = e instanceof Error ? e.message : String(e);
setMessages((prev) => {
const next = [...prev];
next[next.length - 1] = { role: 'assistant', report: {} as AnalyzeReport, error: errMsg };
return next;
});
}
};
return (
<div className="min-h-screen flex flex-col bg-background">
<div className="p-4 border-b flex items-center justify-between flex-wrap gap-2">
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" />
Назад
</button>
<div className="flex items-center gap-2">
<button
onClick={handlePickFolderAndAnalyze}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/50 text-primary text-sm font-medium hover:bg-primary/10"
>
<FolderOpen className="w-4 h-4" />
Выбрать папку
</button>
<button
onClick={handleClearChat}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted"
>
<Trash2 className="w-4 h-4" />
Очистка чата
</button>
<button
onClick={handleUndo}
disabled={messages.length === 0}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw className="w-4 h-4" />
Откат
</button>
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="max-w-2xl mx-auto">
{messages.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Диалог с ИИ агентом. Результаты действий отображаются здесь.</p>
<p className="text-sm mt-2">Нажмите «Выбрать папку» для анализа проекта или введите сообщение ниже.</p>
</div>
) : (
<div className="space-y-4">
{messages.map((m, i) => (
<div
key={i}
className={`p-4 rounded-xl ${m.role === 'user' ? 'bg-primary/10 ml-8' : 'bg-muted/50 mr-8'}`}
>
<div className="text-xs font-medium text-muted-foreground mb-1">
{m.role === 'user' ? 'Вы' : 'Агент'}
</div>
{'text' in m && <div className="text-sm">{m.text}</div>}
{'report' in m && m.report && (
<ReportBlock report={m.report} error={(m as Message & { error?: string }).error} />
)}
</div>
))}
</div>
)}
</div>
</div>
<div className="p-4 border-t">
<div className="max-w-2xl mx-auto flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Сообщение или путь к папке для анализа..."
className="flex-1 px-4 py-2.5 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
/>
<button
onClick={handleSend}
className="px-4 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90"
>
Отправить
</button>
</div>
</div>
</div>
);
}
function ReportBlock({ report, error }: { report: AnalyzeReport; error?: string }) {
if (error) {
return <div className="text-sm text-destructive">Ошибка: {error}</div>;
}
const r = report as AnalyzeReport;
return (
<div className="text-sm space-y-3">
<p className="font-medium">{r.narrative || r.path}</p>
{r.findings?.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1">Находки</p>
<ul className="list-disc list-inside space-y-0.5">
{r.findings.slice(0, 10).map((f, i) => (
<li key={i}>
<span className={f.severity === 'high' ? 'text-destructive' : ''}>{f.title}</span>
{f.details && `${f.details}`}
</li>
))}
</ul>
</div>
)}
{r.recommendations?.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1">Рекомендации</p>
<ul className="list-disc list-inside space-y-0.5">
{r.recommendations.slice(0, 10).map((rec, i) => (
<li key={i}>
<span className="font-medium">{rec.title}</span>
{rec.details && `${rec.details}`}
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -4,20 +4,100 @@ 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, ArrowRight, Sparkles } 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 (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth="8" className="text-muted/20" />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth="8"
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
className="transition-all duration-1000 ease-out" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold" style={{ color }}>{score}</span>
<span className="text-xs text-muted-foreground">из 100</span>
</div>
</div>
);
}
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 (
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{value}</span>
</div>
<div className="h-1.5 bg-muted/30 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-700" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
);
}
function StatCard({ icon: Icon, label, value, sub, color }: {
icon: typeof Activity; label: string; value: string | number; sub?: string; color: string;
}) {
return (
<div className="bg-card/60 backdrop-blur-sm border rounded-xl p-4 space-y-1">
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" style={{ color }} />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
<div className="text-2xl font-bold">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
export function Dashboard() {
const headerRef = useRef<HTMLDivElement>(null);
const cardsRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const systemStatus = useAppStore((s) => s.systemStatus);
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.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 (_) {}
const navId = crypto.randomUUID(); addAuditEvent({ id: navId, event: 'navigation', timestamp: new Date().toISOString(), actor: 'user' });
} catch { /* ignored */ }
navigate(path);
};
@ -31,82 +111,109 @@ export function Dashboard() {
const cards = [
{
path: ROUTES.POLICY_ENGINE.path,
title: 'Движок политик',
description: systemStatus.policyEngine === 'active' ? 'Активен и применяет политики безопасности' : 'Неактивен',
icon: Shield,
status: systemStatus.policyEngine,
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: systemStatus.auditLogger === 'active' ? 'Все действия логируются' : 'Логирование неактивно',
icon: FileText,
status: systemStatus.auditLogger,
gradient: 'from-purple-500/10 to-purple-600/5',
iconColor: 'text-purple-600',
borderColor: 'border-purple-200/50',
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',
},
{
path: ROUTES.SECRETS_GUARD.path,
title: 'Защита секретов',
description: systemStatus.secretsGuard === 'active' ? 'Мониторинг утечек секретов' : 'Мониторинг неактивен',
icon: Lock,
status: systemStatus.secretsGuard,
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 (
<div className="min-h-screen p-8 md:p-12 lg:p-16 bg-gradient-to-br from-background via-background to-muted/20">
<div ref={headerRef} className="mb-12 md:mb-16">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-primary/10">
<Sparkles className="w-5 h-5 text-primary" />
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-balance">Панель управления</h1>
<div className="min-h-screen p-6 md:p-10 lg:p-14 bg-gradient-to-br from-background via-background to-muted/20">
<div ref={headerRef} className="mb-8 md:mb-12">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-lg bg-primary/10"><Sparkles className="w-5 h-5 text-primary" /></div>
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">PAPA YU</h1>
</div>
<p className="text-lg md:text-xl text-muted-foreground font-light max-w-2xl">
Управление системой безопасности и политиками
<p className="text-base text-muted-foreground font-light max-w-2xl">
{hasData ? `Проект: ${lastReport.path}` : 'AI-аудитор проектов. Начните с анализа на главной.'}
</p>
</div>
<div ref={cardsRef} className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
{!hasData && (
<div className="bg-card/50 backdrop-blur-sm border rounded-xl p-6 mb-8 text-center">
<Info className="w-10 h-10 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground mb-4">Данные появятся после анализа проекта</p>
<button onClick={() => navigate('/')} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90">
Перейти к анализу
</button>
</div>
)}
{hasData && (
<div className="grid grid-cols-1 md:grid-cols-5 gap-6 mb-8">
<div className="md:col-span-1 bg-card/60 backdrop-blur-sm border rounded-xl p-6 flex flex-col items-center justify-center">
<HealthRing score={healthScore} />
<span className="text-sm font-medium mt-2">Здоровье</span>
</div>
<div className="md:col-span-4 grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon={FolderOpen} label="Файлов" value={stats?.file_count ?? 0} sub={`${stats?.dir_count ?? 0} папок`} color="#6366f1" />
<StatCard icon={Code2} label="Тип" value={lastReport.structure?.project_type ?? '\u2014'} sub={lastReport.structure?.framework ?? ''} color="#8b5cf6" />
<StatCard icon={Bug} label="Проблемы" value={findings.length} sub={`${highFindings.length} критичных`} color="#ef4444" />
<StatCard icon={Activity} label="Сигналы" value={signals.length} sub={`${securitySignals.length} security`} color="#f59e0b" />
</div>
</div>
)}
{hasData && findings.length > 0 && (
<div className="bg-card/60 backdrop-blur-sm border rounded-xl p-6 mb-8">
<h3 className="text-sm font-semibold mb-4 text-muted-foreground">Распределение проблем</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MiniBar label="Безопасность" value={secretFindings.length} max={findings.length} color="#ef4444" />
<MiniBar label="Уязвимости" value={Math.max(0, highFindings.length - secretFindings.length)} max={findings.length} color="#f59e0b" />
<MiniBar label="Качество" value={qualityFindings.length} max={findings.length} color="#3b82f6" />
<MiniBar label="Зависимости" value={findings.filter((f) => f.title.includes('\u{1F4E6}')).length} max={findings.length} color="#8b5cf6" />
</div>
</div>
)}
<div ref={cardsRef} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
{cards.map((card) => {
const Icon = card.icon;
const isActive = card.status === 'active';
return (
<div
key={card.path}
role="button"
tabIndex={0}
<div key={card.path} role="button" tabIndex={0}
onClick={() => 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`}
>
<div className="relative z-10">
<div className="flex items-start justify-between mb-6">
<div className={`p-3 rounded-xl bg-gradient-to-br ${card.gradient} ${isActive ? 'ring-2 ring-primary/20' : ''}`}>
<Icon className={`w-6 h-6 ${card.iconColor}`} />
<div className="flex items-start justify-between mb-4">
<div className={`p-2.5 rounded-xl bg-gradient-to-br ${card.gradient}`}>
<Icon className={`w-5 h-5 ${card.iconColor}`} />
</div>
{isActive && (
<div className="status-badge status-active">
<CheckCircle2 className="w-4 h-4" />
<span>Активен</span>
{hasData && card.path !== ROUTES.LLM_SETTINGS.path && (
<div className={`status-badge ${card.isOk ? 'status-active' : 'status-inactive'}`}>
{card.isOk ? <CheckCircle2 className="w-3.5 h-3.5" /> : <AlertTriangle className="w-3.5 h-3.5" />}
<span className="text-xs">{card.isOk ? 'OK' : '!'}</span>
</div>
)}
</div>
<h2 className="text-2xl md:text-3xl font-bold mb-3 tracking-tight group-hover:text-primary transition-colors">
{card.title}
</h2>
<p className="text-base text-muted-foreground mb-6 min-h-[3rem]">{card.description}</p>
<div className="flex items-center gap-2 text-primary font-semibold group-hover:gap-3 transition-all">
<h2 className="text-lg font-bold mb-1.5 tracking-tight group-hover:text-primary transition-colors">{card.title}</h2>
<p className="text-sm text-muted-foreground mb-4">{card.description}</p>
<div className="flex items-center gap-2 text-primary text-sm font-semibold group-hover:gap-3 transition-all">
<span>Открыть</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
@ -114,17 +221,25 @@ export function Dashboard() {
})}
</div>
<div className="mt-12 md:mt-16 animate-fade-in-up" style={{ animationDelay: '0.4s', animationFillMode: 'both' }}>
<div className="bg-card/50 backdrop-blur-sm border rounded-xl p-6 md:p-8">
<div className="flex items-center gap-3 mb-4">
{hasData && lastReport.llm_context && (
<div className="mt-8 bg-card/50 backdrop-blur-sm border rounded-xl p-6">
<div className="flex items-center gap-3 mb-3">
<FileText className="w-5 h-5 text-muted-foreground" />
<h3 className="text-lg font-semibold">Система безопасности</h3>
<h3 className="text-sm font-semibold">Сводка анализа</h3>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
Все модули работают в режиме реального времени. Изменения применяются немедленно и логируются в журнале аудита.
</p>
<p className="text-sm text-muted-foreground leading-relaxed">{lastReport.llm_context.concise_summary}</p>
{lastReport.llm_context.key_risks.length > 0 && (
<div className="mt-3 space-y-1">
{lastReport.llm_context.key_risks.map((r, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
<AlertTriangle className="w-3.5 h-3.5 text-red-500 mt-0.5 flex-shrink-0" />
<span>{r}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -19,13 +19,13 @@ export function Diagnostics() {
try {
const info = await invoke<AppInfo>('get_app_info');
setAppInfo(info);
} catch (_) {
} catch {
setAppInfo(null);
}
try {
const v = await getVersion();
setTauriVersion(v);
} catch (_) {}
} catch { /* ignored */ }
})();
}, []);

View File

@ -1,32 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { FolderOpen, ArrowLeft } from 'lucide-react';
export function Documents() {
const navigate = useNavigate();
return (
<div className="min-h-screen p-8 md:p-12 bg-gradient-to-br from-background via-background to-muted/20">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели
</button>
<div className="animate-fade-in">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-xl bg-primary/10">
<FolderOpen className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">Документы</h1>
<p className="text-lg text-muted-foreground mt-2">Все документы компании Mura Menasa</p>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm border rounded-xl p-6">
<p className="text-muted-foreground">Раздел документов компании.</p>
</div>
</div>
</div>
);
}

View File

@ -1,32 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { Wallet, ArrowLeft } from 'lucide-react';
export function Finances() {
const navigate = useNavigate();
return (
<div className="min-h-screen p-8 md:p-12 bg-gradient-to-br from-background via-background to-muted/20">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели
</button>
<div className="animate-fade-in">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-xl bg-primary/10">
<Wallet className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">Финансы</h1>
<p className="text-lg text-muted-foreground mt-2">Диалог для ввода платежей (текст, скриншот, PDF), ИИ распознавание, таблица, выгрузка в PDF/Excel за период</p>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm border rounded-xl p-6">
<p className="text-muted-foreground">Модуль финансов: ввод данных о платежах, распознавание ИИ, отчёты за выбранный период.</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,197 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Settings as SettingsIcon, ArrowLeft, Save, Eye, EyeOff, Zap, CheckCircle2, XCircle } from 'lucide-react';
import { ROUTES } from '../config/routes';
import { DEFAULT_LLM_SETTINGS, LLM_MODELS, askLlm, type LlmSettings } from '../lib/analyze';
const STORAGE_KEY = 'papayu_llm_settings';
function loadSettings(): LlmSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return { ...DEFAULT_LLM_SETTINGS, ...JSON.parse(raw) };
} catch { /* ignored */ }
return { ...DEFAULT_LLM_SETTINGS };
}
function saveSettings(s: LlmSettings) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
}
export function LlmSettingsPage() {
const navigate = useNavigate();
const [settings, setSettings] = useState<LlmSettings>(loadSettings);
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saved, setSaved] = useState(false);
const providerConfig = LLM_MODELS[settings.provider];
const models = providerConfig?.models ?? [];
const handleProviderChange = useCallback((provider: string) => {
const newModels = LLM_MODELS[provider]?.models ?? [];
setSettings((s) => ({
...s,
provider,
model: newModels[0]?.value ?? s.model,
}));
}, []);
const handleSave = () => {
saveSettings(settings);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const resp = await askLlm(
settings,
{
concise_summary: 'Тестовый проект; Node.js; 10 файлов, 3 папки. Риск: Low, зрелость: MVP.',
key_risks: [],
top_recommendations: ['Добавить тесты'],
signals: [],
},
'Ответь одним предложением: подключение работает.'
);
if (resp.ok) {
setTestResult({ ok: true, message: `${resp.content.slice(0, 100)}` });
} else {
setTestResult({ ok: false, message: resp.error || 'Неизвестная ошибка' });
}
} catch (e) {
setTestResult({ ok: false, message: String(e) });
}
setTesting(false);
};
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<div className="flex items-center gap-3 mb-6">
<button onClick={() => navigate(ROUTES.TASKS.path)} className="p-2 rounded-lg hover:bg-muted transition-colors">
<ArrowLeft className="w-5 h-5" />
</button>
<SettingsIcon className="w-6 h-6 text-primary" />
<h1 className="text-xl font-semibold">Настройки LLM</h1>
</div>
{/* Provider */}
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">Провайдер</label>
<div className="grid grid-cols-3 gap-2">
{Object.entries(LLM_MODELS).map(([key, cfg]) => (
<button
key={key}
onClick={() => handleProviderChange(key)}
className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
settings.provider === key
? 'border-primary bg-primary/10 text-primary'
: 'border-border hover:border-primary/40'
}`}
>
{cfg.label}
</button>
))}
</div>
</div>
{/* Model */}
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">Модель</label>
<select
value={settings.model}
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm"
>
{models.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
{/* API Key */}
{settings.provider !== 'ollama' && (
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">
API-ключ ({providerConfig?.label})
</label>
<div className="relative">
<input
type={showKey ? 'text' : 'password'}
value={settings.apiKey}
onChange={(e) => setSettings((s) => ({ ...s, apiKey: e.target.value }))}
placeholder={settings.provider === 'openai' ? 'sk-...' : 'sk-ant-...'}
className="w-full px-3 py-2 pr-10 rounded-lg border border-border bg-background text-sm font-mono"
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted-foreground">
Ключ хранится локально на вашем устройстве.
</p>
</div>
)}
{/* Base URL (Ollama or custom) */}
{settings.provider === 'ollama' && (
<div className="space-y-2">
<label className="text-sm font-medium text-muted-foreground">URL Ollama</label>
<input
type="text"
value={settings.baseUrl || 'http://localhost:11434'}
onChange={(e) => setSettings((s) => ({ ...s, baseUrl: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm font-mono"
/>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
>
{saved ? <CheckCircle2 className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{saved ? 'Сохранено!' : 'Сохранить'}
</button>
<button
onClick={handleTest}
disabled={testing}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
>
<Zap className="w-4 h-4" />
{testing ? 'Проверяю...' : 'Тест подключения'}
</button>
</div>
{/* Test result */}
{testResult && (
<div
className={`p-3 rounded-lg text-sm ${
testResult.ok ? 'bg-green-500/10 text-green-600 border border-green-500/20' : 'bg-red-500/10 text-red-600 border border-red-500/20'
}`}
>
<div className="flex items-start gap-2">
{testResult.ok ? <CheckCircle2 className="w-4 h-4 mt-0.5 flex-shrink-0" /> : <XCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />}
<span>{testResult.message}</span>
</div>
</div>
)}
{/* Info */}
<div className="p-4 rounded-lg bg-muted/50 text-xs text-muted-foreground space-y-1">
<p><strong>OpenAI:</strong> GPT-4o для глубокого анализа, GPT-4o Mini для скорости и экономии.</p>
<p><strong>Anthropic:</strong> Claude для детального, структурированного аудита.</p>
<p><strong>Ollama:</strong> Бесплатно, локально, без интернета. Установите Ollama и скачайте модель.</p>
</div>
</div>
);
}

View File

@ -1,32 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { Users, ArrowLeft } from 'lucide-react';
export function Personnel() {
const navigate = useNavigate();
return (
<div className="min-h-screen p-8 md:p-12 bg-gradient-to-br from-background via-background to-muted/20">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели
</button>
<div className="animate-fade-in">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-xl bg-primary/10">
<Users className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">Персонал</h1>
<p className="text-lg text-muted-foreground mt-2">ФИО, дата и место рождения, проживание, паспорт, Emirates ID, аренда, учёт рабочего времени, начисление ЗП</p>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm border rounded-xl p-6">
<p className="text-muted-foreground">Кнопка «Добавить» форма: ФИО, дата/место рождения, проживание в РФ/за рубежом, копии паспорта и Emirates ID, адрес в Дубае, дата платежа за аренду (напоминание за месяц на дашборде), учёт рабочего времени, автоначисление ЗП.</p>
</div>
</div>
</div>
);
}

View File

@ -1,56 +1,118 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Shield, ArrowLeft, CheckCircle2, XCircle, AlertTriangle, Clock, FileText } from 'lucide-react';
import { Shield, ArrowLeft, CheckCircle2, AlertTriangle, Info, ToggleLeft, ToggleRight } from 'lucide-react';
import { useAppStore } from '../store/app-store';
function loadPolicyToggles(): Record<string, boolean> {
try {
const raw = localStorage.getItem('papayu_policy_toggles');
if (raw) return JSON.parse(raw);
} catch { /* ignored */ }
return {};
}
function savePolicyToggles(t: Record<string, boolean>) {
localStorage.setItem('papayu_policy_toggles', JSON.stringify(t));
}
export function PolicyEngine() {
const navigate = useNavigate();
const [status] = useState<'active' | 'inactive'>('active');
const [loading, setLoading] = useState(true);
const lastReport = useAppStore((s) => s.lastReport);
const [toggles, setToggles] = useState<Record<string, boolean>>(loadPolicyToggles);
useEffect(() => {
const t = setTimeout(() => setLoading(false), 500);
return () => clearTimeout(t);
}, []);
const signals = lastReport?.signals ?? [];
const findings = lastReport?.findings ?? [];
const rules = [
{ title: 'Security over convenience', description: 'Безопасность важнее удобства', icon: Shield, color: 'blue' },
{ title: 'Policy Engine supremacy', description: 'Движок политик имеет приоритет над всеми модулями', icon: Shield, color: 'purple' },
{ title: 'Desktop Core authority', description: 'Desktop Core имеет финальную власть над Web слоем', icon: Shield, color: 'emerald' },
{ title: 'Mandatory audit logging', description: 'Все действия должны логироваться', icon: FileText, color: 'orange' },
const securitySignals = signals.filter((s) => s.category === 'security');
const highFindings = findings.filter((f) => f.severity === 'high');
const warnFindings = findings.filter((f) => f.severity === 'warn');
const hasData = !!lastReport;
const isSecure = highFindings.length === 0 && securitySignals.filter((s) => s.level === 'high').length === 0;
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 denials = [
{ timestamp: new Date().toISOString(), reason: "Запрос отклонён: инструмент 'shell.exec' не в allowlist", tool: 'shell.exec' },
{ timestamp: new Date(Date.now() - 3600000).toISOString(), reason: "Запрос отклонён: путь '/etc/passwd' не разрешён", tool: 'fs.read' },
];
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<string, string> = {
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',
};
if (loading) {
return (
<div className="p-8 md:p-12">
<div className="animate-pulse space-y-6">
<div className="h-10 bg-muted rounded w-1/3 mb-6" />
<div className="h-32 bg-muted rounded" />
<div className="h-64 bg-muted rounded" />
</div>
</div>
);
}
return (
<div className="min-h-screen p-8 md:p-12 lg:p-16 bg-gradient-to-br from-background via-background to-blue-50/30 dark:to-blue-950/10">
<button
onClick={() => navigate('/')}
onClick={() => navigate(hasData ? '/control-panel' : '/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели управления
Назад
</button>
<div className="mb-10 md:mb-12 animate-fade-in">
@ -59,78 +121,107 @@ export function PolicyEngine() {
<Shield className="w-8 h-8 text-blue-600" />
</div>
<div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">Движок политик</h1>
<p className="text-lg md:text-xl text-muted-foreground font-light mt-2">Управление правилами безопасности</p>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">Политики безопасности</h1>
<p className="text-lg md:text-xl text-muted-foreground font-light mt-2">
{hasData ? `Проект: ${lastReport.path}` : 'Сначала запустите анализ проекта'}
</p>
</div>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border-2 border-blue-200/50 mb-8 animate-fade-in-up">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className={status === 'active' ? 'p-3 rounded-xl bg-green-100 dark:bg-green-900/20' : 'p-3 rounded-xl bg-red-100 dark:bg-red-900/20'}>
{status === 'active' ? <CheckCircle2 className="w-6 h-6 text-green-600" /> : <XCircle className="w-6 h-6 text-red-600" />}
</div>
<div>
<h2 className="text-xl font-semibold mb-1">Статус системы</h2>
<p className="text-muted-foreground">{status === 'active' ? 'Движок политик активен' : 'Движок политик неактивен'}</p>
</div>
</div>
<div className={`status-badge ${status === 'active' ? 'status-active' : 'status-inactive'}`}>
{status === 'active' ? <><CheckCircle2 className="w-4 h-4" /><span>Активен</span></> : <><XCircle className="w-4 h-4" /><span>Неактивен</span></>}
</div>
{!hasData ? (
<div className="bg-card/80 backdrop-blur-sm p-8 rounded-2xl border text-center">
<Info className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-lg text-muted-foreground mb-4">Нет данных для отображения</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90"
>
Перейти к анализу
</button>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border mb-8 animate-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
<div className="flex items-center gap-3 mb-6">
<Shield className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Правила безопасности</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{rules.map((rule, index) => {
const Icon = rule.icon;
const cls = colorClasses[rule.color] || colorClasses.blue;
return (
<div key={index} className={`p-5 rounded-xl border-2 bg-gradient-to-br ${cls} transition-all-smooth hover:shadow-md`}>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-white/20 dark:bg-black/20">
<Icon className="w-5 h-5" />
</div>
<div className="flex-1">
<div className="font-semibold mb-1">{rule.title}</div>
<div className="text-sm opacity-80">{rule.description}</div>
</div>
) : (
<>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border-2 border-blue-200/50 mb-8 animate-fade-in-up">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className={isSecure ? 'p-3 rounded-xl bg-green-100 dark:bg-green-900/20' : 'p-3 rounded-xl bg-red-100 dark:bg-red-900/20'}>
{isSecure ? <CheckCircle2 className="w-6 h-6 text-green-600" /> : <AlertTriangle className="w-6 h-6 text-red-600" />}
</div>
<div>
<h2 className="text-xl font-semibold mb-1">Статус</h2>
<p className="text-muted-foreground">
{isSecure
? 'Критичных проблем безопасности не обнаружено'
: `Обнаружено проблем: ${highFindings.length} критичных, ${warnFindings.length} предупреждений`}
</p>
</div>
</div>
);
})}
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border animate-fade-in-up" style={{ animationDelay: '0.2s', animationFillMode: 'both' }}>
<div className="flex items-center gap-3 mb-6">
<AlertTriangle className="w-6 h-6 text-destructive" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Журнал блокировок</h2>
</div>
<div className="space-y-3">
{denials.map((d, i) => (
<div key={i} className="p-4 rounded-xl border-2 border-destructive/20 bg-destructive/5">
<div className="flex items-start gap-3">
<XCircle className="w-5 h-5 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="font-mono text-xs text-muted-foreground">{new Date(d.timestamp).toLocaleString('ru-RU')}</span>
</div>
<div className="text-sm font-medium">{d.reason}</div>
{d.tool && <div className="mt-2 inline-block px-2 py-1 rounded-md bg-muted text-xs font-mono">{d.tool}</div>}
</div>
<div className={`status-badge ${isSecure ? 'status-active' : 'status-inactive'}`}>
{isSecure ? (
<><CheckCircle2 className="w-4 h-4" /><span>Безопасно</span></>
) : (
<><AlertTriangle className="w-4 h-4" /><span>Есть проблемы</span></>
)}
</div>
</div>
))}
</div>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border mb-8 animate-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Проверки</h2>
</div>
<span className="text-sm text-muted-foreground">{passedCount}/{enabledRules.length} пройдено</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{policyRules.map((rule) => {
const cls = colorClasses[rule.color] || colorClasses.blue;
const enabled = toggles[rule.id] !== false;
return (
<div key={rule.id} className={`p-5 rounded-xl border-2 bg-gradient-to-br ${cls} transition-all-smooth hover:shadow-md ${!enabled ? 'opacity-40' : ''}`}>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-white/20 dark:bg-black/20">
{enabled ? (rule.check ? <CheckCircle2 className="w-5 h-5 text-green-600" /> : <AlertTriangle className="w-5 h-5 text-red-600" />) : <Shield className="w-5 h-5 opacity-40" />}
</div>
<div className="flex-1">
<div className="font-semibold mb-1">{rule.title}</div>
<div className="text-sm opacity-80">{rule.description}</div>
{enabled && (
<div className={`text-xs font-medium mt-2 ${rule.check ? 'text-green-600' : 'text-red-600'}`}>
{rule.check ? '\u2713 Пройдено' : '\u2717 Нарушение'}
</div>
)}
</div>
<button onClick={() => togglePolicy(rule.id)} className="flex-shrink-0 p-1 hover:opacity-70 transition-opacity" title={enabled ? 'Выключить' : 'Включить'}>
{enabled ? <ToggleRight className="w-6 h-6 text-primary" /> : <ToggleLeft className="w-6 h-6 text-muted-foreground" />}
</button>
</div>
</div>
);
})}
</div>
</div>
{highFindings.length > 0 && (
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border animate-fade-in-up" style={{ animationDelay: '0.2s', animationFillMode: 'both' }}>
<div className="flex items-center gap-3 mb-6">
<AlertTriangle className="w-6 h-6 text-destructive" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Критичные проблемы</h2>
</div>
<div className="space-y-3">
{highFindings.map((f, i) => (
<div key={i} className="p-4 rounded-xl border-2 border-destructive/20 bg-destructive/5">
<div className="font-medium text-sm">{f.title}</div>
{f.details && <div className="text-sm text-muted-foreground mt-1">{f.details}</div>}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@ -1,47 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { FileText, ArrowLeft } from 'lucide-react';
const SECTIONS = [
{ id: 'armak', name: 'АРМАК' },
{ id: 'faa', name: 'ФАА' },
{ id: 'easa', name: 'ЕАСА' },
{ id: 'mura-menasa', name: 'Mura Menasa' },
];
export function Reglamenty() {
const navigate = useNavigate();
return (
<div className="min-h-screen p-8 md:p-12 bg-gradient-to-br from-background via-background to-muted/20">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели
</button>
<div className="animate-fade-in">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-xl bg-primary/10">
<FileText className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">Регламенты</h1>
<p className="text-lg text-muted-foreground mt-2">АРМАК, ФАА, ЕАСА, Mura Menasa разметка документов</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{SECTIONS.map((s) => (
<div
key={s.id}
className="bg-card/80 backdrop-blur-sm border rounded-xl p-6 hover:shadow-lg transition-all-smooth cursor-pointer"
>
<div className="font-semibold text-lg">{s.name}</div>
<p className="text-sm text-muted-foreground mt-1">Документы раздела</p>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -1,83 +1,74 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Lock, ArrowLeft, CheckCircle2, XCircle, AlertTriangle, Shield, Key, Eye, EyeOff, TrendingUp, Clock } 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';
interface SecretViolation {
id: string;
type: string;
timestamp: string;
severity: 'low' | 'medium' | 'high' | 'critical';
original: string;
redacted: string;
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 [status] = useState<'active' | 'inactive'>('active');
const [violations, setViolations] = useState<SecretViolation[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ total: 0, critical: 0, high: 0, medium: 0, low: 0 });
const lastReport = useAppStore((s) => s.lastReport);
const [fixing, setFixing] = useState(false);
const [fixResult, setFixResult] = useState<string | null>(null);
useEffect(() => {
const mock: SecretViolation[] = [
{ id: '1', type: 'api_key', timestamp: new Date().toISOString(), severity: 'high', original: 'api_key=sk_live_***', redacted: 'api_key=***REDACTED***' },
{ id: '2', type: 'aws_key', timestamp: new Date(Date.now() - 3600000).toISOString(), severity: 'critical', original: 'AWS_ACCESS_KEY_ID=AKIA***', redacted: 'AWS_ACCESS_KEY_ID=***REDACTED***' },
{ id: '3', type: 'password', timestamp: new Date(Date.now() - 7200000).toISOString(), severity: 'high', original: 'password=***', redacted: 'password=***REDACTED***' },
];
const t = setTimeout(() => {
setViolations(mock);
setStats({
total: mock.length,
critical: mock.filter((v) => v.severity === 'critical').length,
high: mock.filter((v) => v.severity === 'high').length,
medium: mock.filter((v) => v.severity === 'medium').length,
low: mock.filter((v) => v.severity === 'low').length,
});
setLoading(false);
}, 500);
return () => clearTimeout(t);
}, []);
const hasData = !!lastReport;
const signals = lastReport?.signals ?? [];
const findings = lastReport?.findings ?? [];
const getSeverityConfig = (severity: string) => {
const map: Record<string, { label: string; bg: string; text: string; border: string; icon: typeof Shield }> = {
critical: { label: 'Критично', bg: 'bg-red-50 dark:bg-red-900/20', text: 'text-red-700 dark:text-red-400', border: 'border-red-200', icon: AlertTriangle },
high: { label: 'Высокий', bg: 'bg-orange-50 dark:bg-orange-900/20', text: 'text-orange-700 dark:text-orange-400', border: 'border-orange-200', icon: AlertTriangle },
medium: { label: 'Средний', bg: 'bg-yellow-50 dark:bg-yellow-900/20', text: 'text-yellow-700 dark:text-yellow-400', border: 'border-yellow-200', icon: Shield },
low: { label: 'Низкий', bg: 'bg-blue-50 dark:bg-blue-900/20', text: 'text-blue-700 dark:text-blue-400', border: 'border-blue-200', icon: Shield },
};
return map[severity] || map.low;
};
// 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('pem') ||
f.title.toLowerCase().includes('aws')
);
const securitySignals = signals.filter((s) => s.category === 'security');
const allSecurityIssues = [...secretFindings, ...securitySignals.map((s) => ({ severity: s.level, title: s.message, details: '' }))];
const criticalCount = allSecurityIssues.filter((i) => i.severity === 'high').length;
const warnCount = allSecurityIssues.filter((i) => i.severity === 'warn').length;
const infoCount = allSecurityIssues.filter((i) => i.severity === 'info').length;
const isClean = allSecurityIssues.length === 0;
const statCards = [
{ label: 'Всего обнаружено', value: stats.total, icon: TrendingUp, color: 'from-emerald-500/10 to-emerald-600/5 text-emerald-700' },
{ label: 'Критичных', value: stats.critical, icon: AlertTriangle, color: 'from-red-500/10 to-red-600/5 text-red-700' },
{ label: 'Высокий уровень', value: stats.high, icon: AlertTriangle, color: 'from-orange-500/10 to-orange-600/5 text-orange-700' },
{ label: 'Средний уровень', value: stats.medium, icon: Shield, color: 'from-yellow-500/10 to-yellow-600/5 text-yellow-700' },
{ label: 'Низкий уровень', value: stats.low, icon: Shield, color: 'from-blue-500/10 to-blue-600/5 text-blue-700' },
{ label: 'Всего проблем', value: allSecurityIssues.length, color: 'from-emerald-500/10 to-emerald-600/5 text-emerald-700' },
{ label: 'Критичных', value: criticalCount, color: 'from-red-500/10 to-red-600/5 text-red-700' },
{ label: 'Предупреждений', value: warnCount, color: 'from-orange-500/10 to-orange-600/5 text-orange-700' },
{ label: 'Информация', value: infoCount, color: 'from-blue-500/10 to-blue-600/5 text-blue-700' },
];
if (loading) {
return (
<div className="p-8 md:p-12">
<div className="animate-pulse space-y-6">
<div className="h-10 bg-muted rounded w-1/3 mb-6" />
<div className="h-32 bg-muted rounded" />
<div className="grid grid-cols-5 gap-4">
{[1, 2, 3, 4, 5].map((i) => <div key={i} className="h-24 bg-muted rounded" />)}
</div>
</div>
</div>
);
}
const getSeverityConfig = (severity: string) => {
const map: Record<string, { label: string; bg: string; text: string }> = {
high: { label: 'Критично', bg: 'bg-red-50 dark:bg-red-900/20', text: 'text-red-700 dark:text-red-400' },
warn: { label: 'Предупреждение', bg: 'bg-orange-50 dark:bg-orange-900/20', text: 'text-orange-700 dark:text-orange-400' },
info: { label: 'Информация', bg: 'bg-blue-50 dark:bg-blue-900/20', text: 'text-blue-700 dark:text-blue-400' },
};
return map[severity] || map.info;
};
return (
<div className="min-h-screen p-8 md:p-12 lg:p-16 bg-gradient-to-br from-background via-background to-emerald-50/30 dark:to-emerald-950/10">
<button
onClick={() => navigate('/')}
onClick={() => navigate(hasData ? '/control-panel' : '/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели управления
Назад
</button>
<div className="mb-10 md:mb-12 animate-fade-in">
@ -87,99 +78,135 @@ export function SecretsGuard() {
</div>
<div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">Защита секретов</h1>
<p className="text-lg md:text-xl text-muted-foreground font-light mt-2">Мониторинг и защита от утечек конфиденциальных данных</p>
<p className="text-lg md:text-xl text-muted-foreground font-light mt-2">
{hasData ? `Проект: ${lastReport.path}` : 'Сначала запустите анализ проекта'}
</p>
</div>
</div>
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border-2 border-emerald-200/50 mb-8 animate-fade-in-up">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className={status === 'active' ? 'p-3 rounded-xl bg-green-100 dark:bg-green-900/20' : 'p-3 rounded-xl bg-red-100 dark:bg-red-900/20'}>
{status === 'active' ? <CheckCircle2 className="w-6 h-6 text-green-600" /> : <XCircle className="w-6 h-6 text-red-600" />}
</div>
<div>
<h2 className="text-xl font-semibold mb-1">Статус мониторинга</h2>
<p className="text-muted-foreground">{status === 'active' ? 'Мониторинг активен' : 'Мониторинг неактивен'}</p>
</div>
</div>
<div className={`status-badge ${status === 'active' ? 'status-active' : 'status-inactive'}`}>
{status === 'active' ? <><CheckCircle2 className="w-4 h-4" /><span>Активен</span></> : <><XCircle className="w-4 h-4" /><span>Неактивен</span></>}
</div>
{!hasData ? (
<div className="bg-card/80 backdrop-blur-sm p-8 rounded-2xl border text-center">
<Info className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-lg text-muted-foreground mb-4">Нет данных для отображения</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90"
>
Перейти к анализу
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 md:gap-6 mb-8 animate-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
{statCards.map((stat, i) => {
const Icon = stat.icon;
return (
<div key={i} className={`bg-card/80 backdrop-blur-sm p-6 rounded-xl border-2 bg-gradient-to-br ${stat.color} transition-all-smooth hover:shadow-lg`}>
<div className="flex items-center justify-between mb-3">
<div className="p-2 rounded-lg bg-white/20 dark:bg-black/20">
<Icon className="w-5 h-5" />
) : (
<>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border-2 border-emerald-200/50 mb-8 animate-fade-in-up">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className={isClean ? 'p-3 rounded-xl bg-green-100 dark:bg-green-900/20' : 'p-3 rounded-xl bg-red-100 dark:bg-red-900/20'}>
{isClean ? <CheckCircle2 className="w-6 h-6 text-green-600" /> : <AlertTriangle className="w-6 h-6 text-red-600" />}
</div>
<div>
<h2 className="text-xl font-semibold mb-1">Статус</h2>
<p className="text-muted-foreground">
{isClean ? 'Утечек секретов не обнаружено' : `Обнаружено ${allSecurityIssues.length} потенциальных проблем`}
</p>
</div>
</div>
<div className="text-3xl md:text-4xl font-bold mb-2">{stat.value}</div>
<div className="text-sm text-muted-foreground">{stat.label}</div>
</div>
);
})}
</div>
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border animate-fade-in-up" style={{ animationDelay: '0.2s', animationFillMode: 'both' }}>
<div className="flex items-center gap-3 mb-6">
<Key className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Примеры редактирования</h2>
</div>
<div className="space-y-4">
{violations.map((v) => {
const cfg = getSeverityConfig(v.severity);
const SeverityIcon = cfg.icon;
return (
<div key={v.id} className="p-5 rounded-xl border-2 bg-card/50 hover:shadow-lg transition-all-smooth">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${cfg.bg}`}>
<SeverityIcon className={`w-5 h-5 ${cfg.text}`} />
</div>
<div>
<div className="font-semibold capitalize">{v.type.replace('_', ' ')}</div>
<span className={`status-badge ${cfg.bg} ${cfg.text} border ${cfg.border}`}>
<SeverityIcon className="w-4 h-4" />
<span>{cfg.label}</span>
</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="w-4 h-4" />
<span className="font-mono">{new Date(v.timestamp).toLocaleString('ru-RU')}</span>
</div>
</div>
<div className="space-y-3">
<div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<Eye className="w-4 h-4" />
<span>Оригинал</span>
</div>
<div className="font-mono text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border-2 border-red-200 text-red-900 dark:text-red-100">
{v.original}
</div>
</div>
<div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
<EyeOff className="w-4 h-4" />
<span>После редактирования</span>
</div>
<div className="font-mono text-sm bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border-2 border-green-200 text-green-900 dark:text-green-100">
{v.redacted}
</div>
</div>
</div>
<div className={`status-badge ${isClean ? 'status-active' : 'status-inactive'}`}>
{isClean ? (
<><CheckCircle2 className="w-4 h-4" /><span>Чисто</span></>
) : (
<><AlertTriangle className="w-4 h-4" /><span>Есть проблемы</span></>
)}
</div>
);
})}
</div>
</div>
{!isClean && lastReport && (
<button
onClick={async () => {
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"
>
<Wand2 className={`w-4 h-4 ${fixing ? 'animate-spin' : ''}`} />
{fixing ? 'Генерирую...' : 'AI Fix'}
</button>
)}
</div>
</div>
{fixResult && (
<div className={`p-4 rounded-xl border text-sm mb-8 ${fixResult.startsWith('✅') || fixResult.startsWith('✓') ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400'}`}>
{fixResult}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 md:gap-6 mb-8 animate-fade-in-up" style={{ animationDelay: '0.1s', animationFillMode: 'both' }}>
{statCards.map((stat, i) => (
<div key={i} className={`bg-card/80 backdrop-blur-sm p-6 rounded-xl border-2 bg-gradient-to-br ${stat.color} transition-all-smooth hover:shadow-lg`}>
<div className="text-3xl md:text-4xl font-bold mb-2">{stat.value}</div>
<div className="text-sm text-muted-foreground">{stat.label}</div>
</div>
))}
</div>
{allSecurityIssues.length > 0 && (
<div className="bg-card/80 backdrop-blur-sm p-6 md:p-8 rounded-2xl border animate-fade-in-up" style={{ animationDelay: '0.2s', animationFillMode: 'both' }}>
<div className="flex items-center gap-3 mb-6">
<Key className="w-6 h-6 text-primary" />
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">Обнаруженные проблемы</h2>
</div>
<div className="space-y-4">
{allSecurityIssues.map((issue, i) => {
const cfg = getSeverityConfig(issue.severity);
return (
<div key={i} className="p-5 rounded-xl border-2 bg-card/50 hover:shadow-lg transition-all-smooth">
<div className="flex items-center gap-3 mb-2">
<div className={`p-2 rounded-lg ${cfg.bg}`}>
<Shield className={`w-5 h-5 ${cfg.text}`} />
</div>
<div>
<div className="font-semibold">{issue.title}</div>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
</div>
</div>
{issue.details && <div className="text-sm text-muted-foreground mt-2">{issue.details}</div>}
</div>
);
})}
</div>
</div>
)}
{isClean && (
<div className="bg-card/80 backdrop-blur-sm p-8 rounded-2xl border text-center animate-fade-in-up" style={{ animationDelay: '0.2s', animationFillMode: 'both' }}>
<CheckCircle2 className="w-12 h-12 mx-auto mb-4 text-green-600" />
<p className="text-lg font-medium text-green-700 dark:text-green-400">Проект чист утечек секретов не обнаружено</p>
<p className="text-sm text-muted-foreground mt-2">
Рекомендуем регулярно повторять анализ при изменениях в проекте
</p>
</div>
)}
</>
)}
</div>
);
}

View File

@ -1,39 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { Package, ArrowLeft } from 'lucide-react';
export function TMCZakupki() {
const navigate = useNavigate();
return (
<div className="min-h-screen p-8 md:p-12 bg-gradient-to-br from-background via-background to-muted/20">
<button
onClick={() => navigate('/')}
className="mb-8 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-all-smooth"
>
<ArrowLeft className="w-4 h-4" />
Назад к панели
</button>
<div className="animate-fade-in">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 rounded-xl bg-primary/10">
<Package className="w-8 h-8 text-primary" />
</div>
<div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">ТМЦ и закупки</h1>
<p className="text-lg text-muted-foreground mt-2">Выбор: ТМЦ (ТВ3-117, НР-3, АИ-9) или Закупки</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-card/80 backdrop-blur-sm border rounded-xl p-6 hover:shadow-lg transition-all-smooth cursor-pointer">
<div className="font-semibold text-lg">ТМЦ</div>
<p className="text-sm text-muted-foreground mt-1">ТВ3-117 / НР-3 / АИ-9 акты входного/выходного контроля</p>
</div>
<div className="bg-card/80 backdrop-blur-sm border rounded-xl p-6 hover:shadow-lg transition-all-smooth cursor-pointer">
<div className="font-semibold text-lg">Закупки</div>
<p className="text-sm text-muted-foreground mt-1">Внесение текста или фото, распознавание ИИ агентом</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -19,8 +19,9 @@ import {
X,
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { analyzeProject, type AnalyzeReport, type Action, type ApplyResult, type UndoResult, type PreviewResult, type DiffItem } from '../lib/analyze';
import { analyzeProject, askLlm, generateAiActions, collectProjectContext, chatWithProject, type AnalyzeReport, type Action, type ApplyResult, type UndoResult, type PreviewResult, type DiffItem, type LlmSettings, type ProjectContextResponse, DEFAULT_LLM_SETTINGS } from '../lib/analyze';
import { animateFadeInUp } from '../lib/anime-utils';
import { useAppStore } from '../store/app-store';
type Message =
| { role: 'user'; text: string }
@ -61,6 +62,132 @@ export function Tasks() {
const messagesEndRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const messagesListRef = useRef<HTMLDivElement>(null);
const storeSetLastReport = useAppStore((s) => s.setLastReport);
const addAuditEvent = useAppStore((s) => s.addAuditEvent);
const [isAiAnalyzing, setIsAiAnalyzing] = useState(false);
const 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;
};
const handleAiAnalysis = async (report: AnalyzeReport) => {
const settings = loadLlmSettings();
if (!settings.apiKey && settings.provider !== 'ollama') {
setMessages((prev) => [
...prev,
{ role: 'system', text: '⚠️ API-ключ не настроен. Перейдите в Настройки LLM (🧠) в боковом меню.' },
]);
return;
}
setIsAiAnalyzing(true);
setMessages((prev) => [...prev, { role: 'system', text: '🤖 AI анализирует проект...' }]);
try {
const resp = await askLlm(
settings,
report.llm_context,
`Проанализируй проект "${report.path}" и дай подробный аудит. Найдено ${report.findings.length} проблем, ${report.recommendations.length} рекомендаций. Контекст уже передан в системном промпте.`
);
if (resp.ok) {
setMessages((prev) => [
...prev,
{ role: 'assistant', text: `🤖 **AI-аудит** (${resp.model}):\n\n${resp.content}` },
]);
addAuditEvent({
id: `ai-${Date.now()}`,
event: 'ai_analysis',
timestamp: new Date().toISOString(),
actor: 'ai',
metadata: { model: resp.model, tokens: resp.usage?.total_tokens ?? 0 },
});
} else {
setMessages((prev) => [
...prev,
{ role: 'system', text: `❌ AI ошибка: ${resp.error}` },
]);
}
} catch (e) {
setMessages((prev) => [
...prev,
{ role: 'system', text: `❌ Ошибка соединения: ${e}` },
]);
}
setIsAiAnalyzing(false);
};
const [isGeneratingActions, setIsGeneratingActions] = useState(false);
const [projectContext, setProjectContext] = useState<ProjectContextResponse | null>(null);
const handleAiCodeGen = async (report: AnalyzeReport) => {
const settings = loadLlmSettings();
if (!settings.apiKey && settings.provider !== 'ollama') {
setMessages((prev) => [
...prev,
{ role: 'system', text: '⚠️ API-ключ не настроен. Перейдите в Настройки LLM.' },
]);
return;
}
setIsGeneratingActions(true);
setMessages((prev) => [...prev, { role: 'system', text: '🔧 AI генерирует исправления...' }]);
try {
const resp = await generateAiActions(settings, report);
if (resp.ok && resp.actions.length > 0) {
// Merge AI actions into the report
const updatedReport = {
...report,
actions: [...(report.actions ?? []), ...resp.actions],
};
setLastReport(updatedReport);
storeSetLastReport(updatedReport, report.path);
// Init selection for new actions
const newSelection: Record<string, boolean> = { ...selectedActions };
resp.actions.forEach((a) => { newSelection[a.id] = true; });
setSelectedActions(newSelection);
// Update the last assistant message with new report
setMessages((prev) => {
const updated = [...prev];
// Find the last assistant message with this report and update it
for (let i = updated.length - 1; i >= 0; i--) {
const msg = updated[i];
if ('report' in msg && msg.report.path === report.path) {
updated[i] = { ...msg, report: updatedReport };
break;
}
}
return [
...updated,
{ role: 'assistant', text: `🔧 **AI сгенерировал ${resp.actions.length} исправлений** (${settings.model}):\n\n${resp.explanation}` },
];
});
} else if (resp.ok && resp.actions.length === 0) {
setMessages((prev) => [
...prev,
{ role: 'system', text: '✓ AI не нашёл дополнительных исправлений — проект в хорошем состоянии.' },
]);
} else {
setMessages((prev) => [
...prev,
{ role: 'system', text: `❌ Ошибка генерации: ${resp.error}` },
]);
}
} catch (e) {
setMessages((prev) => [
...prev,
{ role: 'system', text: `❌ Ошибка: ${e}` },
]);
}
setIsGeneratingActions(false);
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
@ -104,16 +231,61 @@ export function Tasks() {
});
};
const handleSend = () => {
const handleSend = async () => {
if (!input.trim()) return;
setMessages((prev) => [...prev, { role: 'user', text: input.trim() }]);
const question = input.trim();
setMessages((prev) => [...prev, { role: 'user', text: question }]);
setInput('');
setTimeout(() => {
setMessages((prev) => [
...prev,
{ role: 'assistant', text: 'Ответ ИИ агента будет отображаться здесь. Результаты действий агента подключаются к backend.' },
]);
}, 500);
const settings = loadLlmSettings();
if (!settings.apiKey && settings.provider !== 'ollama') {
setMessages((prev) => [...prev, { role: 'system', text: '⚠️ Для чата нужен API-ключ. Перейдите в Настройки LLM (🧠).' }]);
return;
}
if (!lastReport || !lastPath) {
setMessages((prev) => [...prev, { role: 'system', text: '📂 Сначала проанализируйте проект — выберите папку для анализа.' }]);
return;
}
// Collect project context if not yet loaded
let ctx = projectContext;
if (!ctx) {
setMessages((prev) => [...prev, { role: 'system', text: '📖 Индексирую файлы проекта...' }]);
try {
ctx = await collectProjectContext(lastPath);
setProjectContext(ctx);
} catch (e) {
setMessages((prev) => [...prev, { role: 'system', text: `❌ Ошибка индексации: ${e}` }]);
return;
}
}
setMessages((prev) => [...prev, { role: 'system', text: '🤔 Думаю...' }]);
try {
// Build chat history from recent messages
const chatHistory = messages
.filter((m): m is { role: 'user'; text: string } | { role: 'assistant'; text: string } => 'text' in m && (m.role === 'user' || m.role === 'assistant'))
.slice(-6)
.map((m) => ({ role: m.role, content: m.text }));
const resp = await chatWithProject(settings, lastPath, ctx, lastReport.llm_context, question, chatHistory);
// Remove "Думаю..." message
setMessages((prev) => {
const filtered = prev.filter((m) => !('text' in m && m.text === '🤔 Думаю...'));
if (resp.ok) {
return [...filtered, { role: 'assistant' as const, text: resp.content }];
}
return [...filtered, { role: 'system' as const, text: `${resp.error}` }];
});
} catch (e) {
setMessages((prev) => {
const filtered = prev.filter((m) => !('text' in m && m.text === '🤔 Думаю...'));
return [...filtered, { role: 'system' as const, text: `❌ Ошибка: ${e}` }];
});
}
};
const runAnalysis = async (pathStr: string) => {
@ -127,8 +299,18 @@ export function Tasks() {
try {
const report = await analyzeProject(pathStr);
setPreviousReport(lastReport);
setProjectContext(null);
setLastReport(report);
setLastPath(pathStr);
storeSetLastReport(report, pathStr);
addAuditEvent({
id: `analyze-${Date.now()}`,
event: 'project_analyzed',
timestamp: new Date().toISOString(),
actor: 'analyzer',
result: 'success',
metadata: { path: pathStr, projectType: report.structure?.project_type, findings: report.findings?.length ?? 0 },
});
const init: Record<string, boolean> = {};
(report.actions ?? []).forEach((a) => { init[a.id] = true; });
setSelectedActions(init);
@ -316,9 +498,25 @@ export function Tasks() {
if (res.ok) {
pushSystem('Изменения применены.');
setUndoAvailable(true);
addAuditEvent({
id: `apply-${Date.now()}`,
event: 'actions_applied',
timestamp: new Date().toISOString(),
actor: 'apply_engine',
result: 'success',
metadata: { applied: res.applied, path },
});
} else {
pushSystem(res.error ?? 'Изменения не применены. Откат выполнен.');
setUndoAvailable(false);
addAuditEvent({
id: `apply-fail-${Date.now()}`,
event: 'actions_apply_failed',
timestamp: new Date().toISOString(),
actor: 'apply_engine',
result: 'failure',
metadata: { error: res.error, path },
});
}
} catch (e) {
pushSystem(String(e ?? 'Ошибка применения.'));
@ -519,6 +717,10 @@ export function Tasks() {
onApplyPending={handleApplyPending}
onCancelPending={handleCancelPending}
onUndo={handleUndoLast}
onAiAnalysis={handleAiAnalysis}
isAiAnalyzing={isAiAnalyzing}
onAiCodeGen={handleAiCodeGen}
isGeneratingActions={isGeneratingActions}
/>
)}
</div>
@ -698,6 +900,8 @@ function ReportBlock({
onApplyPending,
onCancelPending,
onUndo,
onAiAnalysis,
isAiAnalyzing,
}: {
report: AnalyzeReport;
error?: string;
@ -713,6 +917,10 @@ function ReportBlock({
onApplyPending: () => void;
onCancelPending: () => void;
onUndo: (projectPath: string) => void;
onAiAnalysis?: (report: AnalyzeReport) => void;
isAiAnalyzing?: boolean;
onAiCodeGen?: (report: AnalyzeReport) => void;
isGeneratingActions?: boolean;
}) {
if (error) {
return <div className="text-sm text-destructive">Ошибка: {error}</div>;
@ -846,7 +1054,29 @@ function ReportBlock({
</div>
</div>
)}
<div className="flex gap-2 mt-2">
<div className="flex gap-2 mt-2 flex-wrap">
{isCurrentReport && onAiAnalysis && (
<button
type="button"
onClick={() => onAiAnalysis(r)}
disabled={isAiAnalyzing}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<Bot className="w-4 h-4" />
{isAiAnalyzing ? 'AI анализирует...' : 'AI Анализ'}
</button>
)}
{isCurrentReport && onAiCodeGen && (
<button
type="button"
onClick={() => onAiCodeGen(r)}
disabled={isGeneratingActions}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border bg-green-600 text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isGeneratingActions ? 'animate-spin' : ''}`} />
{isGeneratingActions ? 'Генерирую...' : 'AI Исправления'}
</button>
)}
<button
type="button"
onClick={() => onDownload(r)}

View File

@ -1,15 +1,29 @@
import { create } from 'zustand';
import type { AnalyzeReport } from '../lib/analyze';
export interface AuditEvent {
id: string;
event: string;
timestamp: string;
actor: string;
result?: 'success' | 'failure';
metadata?: Record<string, unknown>;
}
export interface AppState {
currentRoute: string;
setCurrentRoute: (route: string) => void;
systemStatus: {
policyEngine: 'active' | 'inactive';
auditLogger: 'active' | 'inactive';
secretsGuard: 'active' | 'inactive';
};
recentAuditEvents: Array<{ id: string; event: string; timestamp: string; actor: string }>;
addAuditEvent: (event: AppState['recentAuditEvents'][0]) => void;
/** Last analysis report — shared across pages */
lastReport: AnalyzeReport | null;
lastPath: string | null;
setLastReport: (report: AnalyzeReport, path: string) => void;
/** Audit events collected from real analysis actions */
auditEvents: AuditEvent[];
addAuditEvent: (event: AuditEvent) => void;
clearAuditEvents: () => void;
error: string | null;
setError: (error: string | null) => void;
}
@ -17,16 +31,18 @@ export interface AppState {
export const useAppStore = create<AppState>((set) => ({
currentRoute: '/',
setCurrentRoute: (route) => set({ currentRoute: route }),
systemStatus: {
policyEngine: 'active',
auditLogger: 'active',
secretsGuard: 'active',
},
recentAuditEvents: [],
lastReport: null,
lastPath: null,
setLastReport: (report, path) => set({ lastReport: report, lastPath: path }),
auditEvents: [],
addAuditEvent: (event) =>
set((s) => ({
recentAuditEvents: [event, ...s.recentAuditEvents].slice(0, 50),
auditEvents: [event, ...s.auditEvents].slice(0, 200),
})),
clearAuditEvents: () => set({ auditEvents: [] }),
error: null,
setError: (error) => set({ error }),
}));

View File

@ -1,4 +1,5 @@
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
export default {
darkMode: ["class"],
@ -22,5 +23,5 @@ export default {
borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)" },
},
},
plugins: [require("tailwindcss-animate")],
plugins: [tailwindcssAnimate],
} satisfies Config;