Compare commits
No commits in common. "v0.1.9" and "main" have entirely different histories.
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -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
74
REFACTORING_CHANGELOG.md
Normal 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
|
||||
2
desktop/src-tauri/Cargo.lock
generated
2
desktop/src-tauri/Cargo.lock
generated
@ -81,6 +81,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"log",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
356
desktop/src-tauri/src/commands/ask_llm.rs
Normal file
356
desktop/src-tauri/src/commands/ask_llm.rs
Normal 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}"#
|
||||
)
|
||||
}
|
||||
104
desktop/src-tauri/src/commands/collect_context.rs
Normal file
104
desktop/src-tauri/src/commands/collect_context.rs
Normal 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); }
|
||||
}
|
||||
}
|
||||
134
desktop/src-tauri/src/commands/generate_ai_actions.rs
Normal file
134
desktop/src-tauri/src/commands/generate_ai_actions.rs
Normal 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)]
|
||||
)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
158
desktop/src-tauri/src/deep_analysis.rs
Normal file
158
desktop/src-tauri/src/deep_analysis.rs
Normal 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) });
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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-ключ' },
|
||||
};
|
||||
|
||||
@ -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\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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
197
desktop/ui/src/pages/LlmSettings.tsx
Normal file
197
desktop/ui/src/pages/LlmSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
@ -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 }),
|
||||
}));
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user