From 3042bd50423167f82897f9bd7f142ddd00aea823 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 27 May 2026 05:56:43 +0300 Subject: [PATCH] feat(detmir): ship ops, dlp, 1c and mcp updates --- README.md | 8 + ansible/README.md | 6 + ansible/deploy_aw_server.yml | 2 + ansible/deploy_aw_windows.yml | 32 + ansible/deploy_file_1c_analytics.yml | 13 +- ansible/deploy_file_1c_windows_telemetry.yml | 49 + ansible/deploy_proxmox_web_gateway.yml | 227 ++++ ansible/deploy_tsj_guardian_bot_proxmox.yml | 68 +- ansible/group_vars/all.example.yml | 1 + ansible/group_vars/pfsense-poller.example.yml | 8 +- ansible/group_vars/proxmox-bot.example.yml | 22 +- ansible/post_validate_aw_windows.yml | 32 + .../proxmox-web-gateway-index.html.j2 | 156 +++ ansible/templates/proxmox-web-gateway.conf.j2 | 49 + aw-server/aw-ru-patch.js | 53 +- aw-server/aw-rus-healthd.py | 15 +- aw-server/aw-server.env.example | 7 + aw-server/aw-worktime-api.py | 644 +++++++++- aw-server/aw-worktime-autoheal.sh | 115 +- aw-server/dlp-case-management/case_service.py | 4 +- aw-server/dlp-case-management/case_storage.py | 14 + .../dlp-case-management/test_case_storage.py | 28 +- aw-server/dlp-compliance/report_generator.py | 9 +- aw-server/hayabusa/aw-hayabusa-link-case.py | 11 + aw-server/health-check.sh | 29 +- aw-server/test_aw_dlp_report_generator.py | 39 + aw-server/test_aw_rus_healthd.py | 22 + aw-server/test_aw_worktime_api.py | 250 +++- .../worktime-manager-aliases.example.json | 18 + check-aw-data.sh | 28 +- check-aw-full.sh | 61 +- clickhouse-1c/.env.example | 4 +- clickhouse-1c/README.md | 55 + clickhouse-1c/ai/company_intelligence_api.py | 463 ++++++- clickhouse-1c/ai/generate_manager_brief.py | 194 +++ clickhouse-1c/ai/manager_brief_prompt.md | 14 + clickhouse-1c/ai/manager_brief_schema.json | 75 ++ .../ai/test_company_intelligence_api.py | 99 ++ clickhouse-1c/ai/test_manager_brief.py | 39 + .../init/04_company_intelligence.sql | 319 +++++ .../init/05_financial_reporting.sql | 167 +++ clickhouse-1c/docker-compose.yml | 17 - clickhouse-1c/etl/config.example.yml | 88 ++ clickhouse-1c/etl/extract_1c_mcp_toolkit.py | 572 +++++++++ clickhouse-1c/etl/load_1c_exports.py | 3 +- .../etl/test_extract_1c_mcp_toolkit.py | 271 ++++ .../files/1c-financial-reporting.json | 834 ++++++++++++ .../dashboards/files/1c-management-board.json | 898 +++++++++++++ .../dashboards/files/1c-telemetry-board.json | 784 ++++++++++++ clickhouse-1c/grafana/query-pack.sql | 34 + clickhouse-1c/ops/run_ingest_cycle.sh | 5 + detmir-mcp/.env.example | 15 + detmir-mcp/README.md | 85 ++ .../examples/mcpdrill-detmir-readonly.json | 237 ++++ detmir-mcp/main.py | 432 +++++++ detmir-mcp/pyproject.toml | 15 + detmir-mcp/run-local.sh | 10 + detmir-mcp/uv.lock | 684 ++++++++++ docs/1C_AI_INVESTIGATOR_RUNTIME_RU.md | 16 + docs/1C_BUSINESS_EVENT_LAYER_RU.md | 33 +- docs/1C_COMPANY_INTELLIGENCE_RU.md | 19 + ...ETMIR_CHANGE_REPORT_LAST_24H_2026-05-26.md | 299 +++++ docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md | 115 ++ docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md | 356 ++++++ .../hayabusa-aw-rus-integration-2026-05-14.md | 8 +- docs/runbook.md | 94 ++ pfsense/pfsense-aw-poller.example.json | 2 +- pfsense/pfsense-aw-poller.py | 32 +- proxmox/pfsense_openvpn_client_export.php | 298 +++++ proxmox/test_tsj_guardian_bot.py | 496 ++++++++ proxmox/tsj_guardian_bot.py | 1130 ++++++++++++++--- scripts/install_detmir_powershell_mcp.sh | 78 ++ .../powershell/detmir-powershell-profile.ps1 | 140 ++ .../powershell/detmir-windows.psd1.example | 7 + windows/ActivityWatch.Windows.Common.psm1 | 307 ++++- windows/aw-standalone-service.ps1 | 5 +- windows/browser-domains-native-collector.ps1 | 89 +- windows/cleanup-disc-sessions.ps1 | 23 + windows/dlp-endpoint-signals-collector.ps1 | 73 +- windows/export-upload-file-1c-telemetry.ps1 | 143 ++- windows/file-operations-collector.ps1 | 140 +- windows/fix-session-watchers.ps1 | 45 + windows/hardening-recovery.ps1 | 15 + windows/run-user1-probe.ps1 | 39 + windows/validate-deployment.ps1 | 53 +- 85 files changed, 12028 insertions(+), 460 deletions(-) create mode 100644 ansible/deploy_proxmox_web_gateway.yml create mode 100644 ansible/templates/proxmox-web-gateway-index.html.j2 create mode 100644 ansible/templates/proxmox-web-gateway.conf.j2 create mode 100644 aw-server/test_aw_dlp_report_generator.py create mode 100644 clickhouse-1c/clickhouse/init/05_financial_reporting.sql create mode 100644 clickhouse-1c/etl/extract_1c_mcp_toolkit.py create mode 100644 clickhouse-1c/etl/test_extract_1c_mcp_toolkit.py create mode 100644 clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json create mode 100644 clickhouse-1c/grafana/provisioning/dashboards/files/1c-management-board.json create mode 100644 clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json create mode 100644 detmir-mcp/.env.example create mode 100644 detmir-mcp/README.md create mode 100644 detmir-mcp/examples/mcpdrill-detmir-readonly.json create mode 100644 detmir-mcp/main.py create mode 100644 detmir-mcp/pyproject.toml create mode 100644 detmir-mcp/run-local.sh create mode 100644 detmir-mcp/uv.lock create mode 100644 docs/DETMIR_CHANGE_REPORT_LAST_24H_2026-05-26.md create mode 100644 docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md create mode 100644 docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md create mode 100644 proxmox/pfsense_openvpn_client_export.php create mode 100644 proxmox/test_tsj_guardian_bot.py create mode 100644 scripts/install_detmir_powershell_mcp.sh create mode 100644 scripts/powershell/detmir-powershell-profile.ps1 create mode 100644 scripts/powershell/detmir-windows.psd1.example create mode 100644 windows/cleanup-disc-sessions.ps1 create mode 100644 windows/fix-session-watchers.ps1 create mode 100644 windows/run-user1-probe.ps1 diff --git a/README.md b/README.md index 7d3b8d0..9c52898 100755 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - `docs/dlp-aggregator.md` — прототип централизованной агрегации DLP/file-operation событий. - `docs/dlp-reliability-roadmap.md` — roadmap повышения надёжности DLP-коллекторов. - `docs/powershell-analysis.md` — статический анализ работоспособности DLP PowerShell-скриптов. +- `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` — канонический MCP/PowerShell remote path для DetMir Windows host через `SSH`, а не `WSMan`. - `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox. - `aw-server/` — установочные скрипты, env-шаблон, systemd unit, RU patch для Web UI и server-side worktime/management report API на `:5610`. - `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT). @@ -36,6 +37,7 @@ - `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger. - `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class. - `scripts/install_aw_linux_remote_worker.sh` — полный Linux remote-worker installer. +- `scripts/install_detmir_powershell_mcp.sh` — привязка локального `powershell-windows`/`pwsh` к DetMir Windows host `192.168.100.18`. ## Базовый сценарий @@ -82,6 +84,11 @@ powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-up - `ansible/deploy_aw_windows.yml` +Для интерактивной PowerShell/MCP-работы с DetMir Windows host из Linux: + +- `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` +- `scripts/install_detmir_powershell_mcp.sh` + Для внешнего pfSense poller'а: - `ansible/deploy_aw_pfsense_poller.yml` @@ -89,6 +96,7 @@ powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-up Для импорта Grafana dashboard'ов через HTTP API: - `ansible/deploy_grafana_dashboards.yml` +- `ansible/deploy_proxmox_web_gateway.yml` - `docs/GRAFANA_DASHBOARDS_RU.md` - `docs/1C_FILE_ANALYTICS_STACK_RU.md` diff --git a/ansible/README.md b/ansible/README.md index fee2558..7a1acbb 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -89,6 +89,12 @@ ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml ## Windows/RDP rollout (WinRM) +Важно: + +- `WinRM` здесь остаётся транспортом для `Ansible deploy` и `validation`; +- для интерактивной PowerShell-работы из Linux/Codex по DetMir используйте project MCP-over-SSH путь, а не `WSMan`; +- каноника лежит в `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` и `scripts/install_detmir_powershell_mcp.sh`. + 1. Подготовьте inventory и vars: - `cp ansible/inventory.example.ini ansible/inventory.ini` - `cp ansible/group_vars/windows.example.yml ansible/group_vars/windows.yml` diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 750a6ad..5aae10d 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -340,8 +340,10 @@ AW_HEALTH_STRICT_FILEOPS={{ aw_health_strict_fileops | default(0) }} AW_MONITORED_WINDOWS_HOST={{ aw_monitored_windows_host }} AW_MONITORED_WINDOWS_HOSTNAME={{ aw_monitored_windows_hostname }} + AW_RUS_HEALTH_WORKTIME_API={{ aw_rus_health_worktime_api_base | default('http://127.0.0.1:5610') }} AW_RUS_HEALTH_STATE_DIR={{ aw_rus_health_state_dir }} AW_RUS_HEALTH_VALIDATION_DIR={{ aw_rus_health_validation_dir }} + AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS={{ aw_rus_health_session_events_max_age_seconds | default(86400) }} AW_HAYABUSA_AUTO_CASE_ENABLED={{ 'true' if (aw_hayabusa_auto_case_enabled | default(true) | bool) else 'false' }} AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY={{ aw_hayabusa_auto_case_min_severity | default('medium') }} AW_HAYABUSA_TELEGRAM_ENABLED={{ 'true' if (aw_hayabusa_telegram_enabled | default(false) | bool) else 'false' }} diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 83a40b8..7e0dcf9 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -94,6 +94,18 @@ ) }} + - name: Вычислить effective каталог health validation на AW server + ansible.builtin.set_fact: + aw_windows_health_validation_dir_effective: >- + {{ + ( + hostvars[aw_server_inventory_host_effective].aw_rus_health_validation_dir + | default('/var/lib/activitywatch/health/windows-validation', true) + ) + if (aw_server_inventory_host_effective | length) > 0 + else '' + }} + - name: Вычислить effective host для policy engine ansible.builtin.set_fact: aw_windows_policy_engine_host_effective: >- @@ -475,6 +487,26 @@ dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" flat: true + - name: Убедиться, что каталог Windows validation существует на AW server + when: + - aw_server_inventory_host_effective | length > 0 + - aw_windows_health_validation_dir_effective | length > 0 + ansible.builtin.file: + path: "{{ aw_windows_health_validation_dir_effective }}" + state: directory + mode: "0755" + delegate_to: "{{ aw_server_inventory_host_effective }}" + + - name: Опубликовать validation report на AW server для aw-rus-healthd + when: + - aw_server_inventory_host_effective | length > 0 + - aw_windows_health_validation_dir_effective | length > 0 + ansible.builtin.copy: + src: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" + dest: "{{ aw_windows_health_validation_dir_effective }}/{{ inventory_hostname }}-aw_validate_ansible.json" + mode: "0644" + delegate_to: "{{ aw_server_inventory_host_effective }}" + - name: Проверить статус валидации ansible.builtin.shell: | python3 - <<'PY' diff --git a/ansible/deploy_file_1c_analytics.yml b/ansible/deploy_file_1c_analytics.yml index 5c1c806..5b90d60 100644 --- a/ansible/deploy_file_1c_analytics.yml +++ b/ansible/deploy_file_1c_analytics.yml @@ -14,16 +14,14 @@ aw_file_1c_clickhouse_password: change-me aw_file_1c_clickhouse_port: 8123 aw_file_1c_clickhouse_native_port: 9000 - aw_file_1c_grafana_admin_user: admin - aw_file_1c_grafana_admin_password: change-me - aw_file_1c_grafana_port: 3300 aw_file_1c_company_api_host: "{{ ansible_host | default('127.0.0.1') }}" aw_file_1c_company_api_port: 8710 + aw_file_1c_manager_brief_grafana_url: "http://10.10.10.11:3000/d/1c-file-mgmt/1c-file-management-board?orgId=1" aw_file_1c_manager_brief_state_dir: /opt/activitywatch/clickhouse-1c/state/manager-brief aw_file_1c_manager_brief_model: gpt-5.3-codex - aw_file_1c_manager_brief_codex_user: codex + aw_file_1c_manager_brief_codex_user: igor aw_file_1c_manager_brief_codex_bin: codex - aw_file_1c_manager_brief_workdir: /home/codex/infra-admin + aw_file_1c_manager_brief_workdir: /home/igor aw_file_1c_manager_brief_top_limit: 5 aw_file_1c_manager_brief_freshness_hours: 8 aw_file_1c_manager_brief_timeout_sec: 300 @@ -131,12 +129,10 @@ CLICKHOUSE_PASSWORD={{ aw_file_1c_clickhouse_password }} CLICKHOUSE_PORT={{ aw_file_1c_clickhouse_port }} CLICKHOUSE_NATIVE_PORT={{ aw_file_1c_clickhouse_native_port }} - GRAFANA_ADMIN_USER={{ aw_file_1c_grafana_admin_user }} - GRAFANA_ADMIN_PASSWORD={{ aw_file_1c_grafana_admin_password }} - GRAFANA_PORT={{ aw_file_1c_grafana_port }} CLICKHOUSE_HOST=clickhouse AW_1C_COMPANY_API_HOST={{ aw_file_1c_company_api_host }} AW_1C_COMPANY_API_PORT={{ aw_file_1c_company_api_port }} + AW_1C_MANAGER_BRIEF_GRAFANA_URL={{ aw_file_1c_manager_brief_grafana_url }} AW_1C_MANAGER_BRIEF_STATE_DIR={{ aw_file_1c_manager_brief_state_dir }} AW_1C_MANAGER_BRIEF_MODEL={{ aw_file_1c_manager_brief_model }} AW_1C_MANAGER_BRIEF_CODEX_USER={{ aw_file_1c_manager_brief_codex_user }} @@ -232,6 +228,7 @@ - 02_core_tables.sql - 03_views.sql - 04_company_intelligence.sql + - 05_financial_reporting.sql - name: Установить systemd unit aw-1c-ingest.service ansible.builtin.copy: diff --git a/ansible/deploy_file_1c_windows_telemetry.yml b/ansible/deploy_file_1c_windows_telemetry.yml index a7cbc17..4b29fbe 100644 --- a/ansible/deploy_file_1c_windows_telemetry.yml +++ b/ansible/deploy_file_1c_windows_telemetry.yml @@ -11,6 +11,7 @@ aw_windows_file_1c_auto_upload_interval_hours: 6 aw_windows_file_1c_auto_upload_task_name: "ActivityWatch File1C Upload" aw_windows_file_1c_remote_root: "/opt/activitywatch/clickhouse-1c/landing" + aw_windows_file_1c_remote_key_path: "" aw_windows_file_1c_registry_workbook_path: "E:\\USER1\\СПИСОК ПРЕДПРИЯТИЙ И ИХ РАСПРЕДЕЛЕНИЕ.xlsx" aw_windows_upload_key_private_path: /tmp/awops_ed25519 aw_windows_upload_key_public_path: /tmp/awops_ed25519.pub @@ -51,6 +52,16 @@ - "{{ aw_windows_deploy_root }}\\windows" - "{{ aw_windows_state_root }}\\ssh" + - name: Установить private key для Windows file-1C uploader + ansible.windows.win_copy: + src: "{{ aw_windows_upload_key_private_path }}" + dest: "{{ aw_windows_state_root }}\\ssh\\awops_ed25519" + + - name: Установить public key для Windows file-1C uploader + ansible.windows.win_copy: + src: "{{ aw_windows_upload_key_public_path }}" + dest: "{{ aw_windows_state_root }}\\ssh\\awops_ed25519.pub" + - name: Загрузить file-1C telemetry script в toolkit ansible.windows.win_copy: src: "{{ aw_windows_repo_root }}/windows/export-upload-file-1c-telemetry.ps1" @@ -89,6 +100,7 @@ targetUser = "{{ aw_windows_file_1c_target_user }}" remoteRoot = "{{ aw_windows_file_1c_remote_root }}" registryWorkbookPath = "{{ aw_windows_file_1c_registry_workbook_path }}" + remoteKeyPath = "{{ aw_windows_file_1c_remote_key_path }}" } if ($config.analytics.PSObject.Properties.Name -contains 'file1cAutomation') { @@ -100,6 +112,43 @@ $json = $config | ConvertTo-Json -Depth 12 Set-Content -LiteralPath $configPath -Value $json -Encoding UTF8 + - name: Разложить file-1C upload key по локальным пользовательским профилям + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $src = "{{ aw_windows_state_root }}\ssh\awops_ed25519" + if (-not (Test-Path -LiteralPath $src)) { + throw "Не найден private key uploader: $src" + } + + $statePath = "{{ aw_windows_state_root }}\file1c-telemetry-state.json" + if (-not (Test-Path -LiteralPath $statePath)) { + '{}' | Set-Content -LiteralPath $statePath -Encoding UTF8 + } + + $profiles = Get-CimInstance Win32_UserProfile | + Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } | + Sort-Object LocalPath + + foreach ($profile in $profiles) { + $user = Split-Path $profile.LocalPath -Leaf + $sshDir = Join-Path $profile.LocalPath '.ssh' + $dst = Join-Path $sshDir 'awops_ed25519' + $grantDir = 'SHARKON2025\' + $user + ':(OI)(CI)(F)' + $grantRead = 'SHARKON2025\' + $user + ':(R)' + $grantModify = 'SHARKON2025\' + $user + ':(M)' + + New-Item -ItemType Directory -Path $sshDir -Force | Out-Null + & icacls.exe $sshDir /grant $grantDir | Out-Null + & icacls.exe $statePath /grant $grantModify | Out-Null + + if (-not (Test-Path -LiteralPath $dst)) { + Copy-Item -LiteralPath $src -Destination $dst -Force + } + + & icacls.exe $dst /inheritance:r /grant:r $grantRead '*S-1-5-18:(R)' '*S-1-5-32-544:(R)' | Out-Null + } + - name: Создать scheduled task file-1C upload ansible.windows.win_powershell: script: | diff --git a/ansible/deploy_proxmox_web_gateway.yml b/ansible/deploy_proxmox_web_gateway.yml new file mode 100644 index 0000000..a912393 --- /dev/null +++ b/ansible/deploy_proxmox_web_gateway.yml @@ -0,0 +1,227 @@ +--- +- name: Развернуть nginx web gateway на Proxmox host + hosts: proxmox + become: true + gather_facts: true + + vars: + proxmox_web_gateway_public_hostname: "dm.iri1968.dpdns.org" + proxmox_web_gateway_root: "/var/www/proxmox-web-gateway" + proxmox_web_gateway_site_name: "proxmox-web-gateway.conf" + proxmox_web_gateway_site_path: "/etc/nginx/sites-available/{{ proxmox_web_gateway_site_name }}" + proxmox_web_gateway_site_enabled_path: "/etc/nginx/sites-enabled/{{ proxmox_web_gateway_site_name }}" + proxmox_web_gateway_tls_dir: "/etc/nginx/ssl/proxmox-web-gateway" + proxmox_web_gateway_tls_cert_path: "{{ proxmox_web_gateway_tls_dir }}/fullchain.pem" + proxmox_web_gateway_tls_key_path: "{{ proxmox_web_gateway_tls_dir }}/privkey.pem" + proxmox_web_gateway_health_path: "/healthz" + proxmox_web_gateway_routes: + - slug: "proxmox-gui" + title: "Proxmox VE" + category: "Host" + description: "Основная панель управления Proxmox VE." + target_url: "https://10.10.10.2:8006/" + - slug: "file1c-brief" + title: "1C Executive Brief" + category: "Management" + description: "Сводка по предприятиям и рискам 1С." + target_url: "http://10.10.10.2:8710/manager/brief" + - slug: "file1c-actions" + title: "1C Management Actions" + category: "Management" + description: "Очередь действий по предприятиям в 1С." + target_url: "http://10.10.10.2:8710/manager/actions" + - slug: "file1c-finance" + title: "1C Financial Reporting" + category: "Management" + description: "Первый financial board по файловой 1С с разделением ledger/proxy." + target_url: "http://10.10.10.11:3000/d/1c-file-finance/1c-file-financial-reporting?orgId=1" + - slug: "file1c-telemetry" + title: "1C Telemetry Board" + category: "Dashboards" + description: "Read-only telemetry экран по состоянию файловых баз, reglog и host." + target_url: "http://10.10.10.11:3000/d/1c-file-telemetry/1c-file-telemetry-board?orgId=1" + - slug: "grafana-1c" + title: "Grafana 1C" + category: "Dashboards" + description: "Рабочий file-1c dashboard contour в внешней Grafana." + target_url: "http://10.10.10.11:3000/d/1c-file-mgmt/1c-file-management-board?orgId=1" + - slug: "clickhouse-http" + title: "ClickHouse HTTP" + category: "Data" + description: "HTTP endpoint ClickHouse для file-1C analytics." + target_url: "http://10.10.10.2:8123/" + - slug: "influxdb" + title: "InfluxDB" + category: "Data" + description: "InfluxDB LXC на CT 200." + target_url: "http://10.10.10.10:8086/" + - slug: "grafana-core" + title: "Grafana Core" + category: "Dashboards" + description: "Отдельный Grafana CT 201." + target_url: "http://10.10.10.11:3000/" + - slug: "loki-alloy" + title: "Grafana Alloy" + category: "Logs" + description: "Web UI Alloy на CT 202." + target_url: "http://10.10.10.12:12345/" + - slug: "aw-ui" + title: "AW-rus UI" + category: "Operations" + description: "Основной ActivityWatch-Russian Web UI на CT 203." + target_url: "http://10.10.10.13:5600/" + - slug: "aw-worktime" + title: "AW-rus Management Report" + category: "Operations" + description: "Управленческий worktime/report API на CT 203." + target_url: "http://10.10.10.13:5610/reports/worktime/management?day=today" + + tasks: + - name: Установить nginx + ansible.builtin.package: + name: + - nginx + - openssl + state: present + + - name: Создать web root gateway + ansible.builtin.file: + path: "{{ proxmox_web_gateway_root }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Создать каталог TLS gateway + ansible.builtin.file: + path: "{{ proxmox_web_gateway_tls_dir }}" + state: directory + owner: root + group: root + mode: "0700" + + - name: Сгенерировать self-signed TLS сертификат gateway + ansible.builtin.command: + argv: + - openssl + - req + - -x509 + - -nodes + - -newkey + - rsa:2048 + - -sha256 + - -days + - "3650" + - -keyout + - "{{ proxmox_web_gateway_tls_key_path }}" + - -out + - "{{ proxmox_web_gateway_tls_cert_path }}" + - -subj + - "/CN={{ proxmox_web_gateway_public_hostname }}" + - -addext + - "subjectAltName=DNS:{{ proxmox_web_gateway_public_hostname }}" + args: + creates: "{{ proxmox_web_gateway_tls_cert_path }}" + + - name: Развернуть index.html gateway + ansible.builtin.template: + src: "templates/proxmox-web-gateway-index.html.j2" + dest: "{{ proxmox_web_gateway_root }}/index.html" + owner: root + group: root + mode: "0644" + + - name: Развернуть nginx site gateway + ansible.builtin.template: + src: "templates/proxmox-web-gateway.conf.j2" + dest: "{{ proxmox_web_gateway_site_path }}" + owner: root + group: root + mode: "0644" + notify: Reload nginx + + - name: Отключить default nginx site + ansible.builtin.file: + path: "/etc/nginx/sites-enabled/default" + state: absent + notify: Reload nginx + + - name: Включить proxmox web gateway site + ansible.builtin.file: + src: "{{ proxmox_web_gateway_site_path }}" + dest: "{{ proxmox_web_gateway_site_enabled_path }}" + state: link + notify: Reload nginx + + - name: Включить и запустить nginx + ansible.builtin.systemd: + name: nginx + enabled: true + state: started + + - name: Применить handlers до проверок + ansible.builtin.meta: flush_handlers + + - name: Проверить синтаксис nginx после выкладки site + ansible.builtin.command: "/usr/sbin/nginx -t" + register: proxmox_web_gateway_nginx_test + changed_when: false + + - name: Проверить локальный HTTP redirect на HTTPS + ansible.builtin.uri: + url: "http://127.0.0.1/" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + follow_redirects: none + status_code: 301 + register: proxmox_web_gateway_http_redirect + failed_when: + - proxmox_web_gateway_http_redirect.status != 301 + - proxmox_web_gateway_http_redirect.location != "https://{{ proxmox_web_gateway_public_hostname }}/" + changed_when: false + + - name: Проверить redirect на Proxmox GUI + ansible.builtin.uri: + url: "https://127.0.0.1/go/proxmox-gui" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + validate_certs: false + follow_redirects: none + status_code: 302 + register: proxmox_web_gateway_redirect + failed_when: + - proxmox_web_gateway_redirect.status != 302 + - proxmox_web_gateway_redirect.location != "https://10.10.10.2:8006/" + changed_when: false + + - name: Проверить index по public hostname + ansible.builtin.uri: + url: "https://127.0.0.1/" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + return_content: true + validate_certs: false + register: proxmox_web_gateway_named_index + failed_when: + - proxmox_web_gateway_named_index.status != 200 + - proxmox_web_gateway_public_hostname not in proxmox_web_gateway_named_index.content + changed_when: false + + - name: Проверить локальный HTTPS health endpoint gateway + ansible.builtin.uri: + url: "https://127.0.0.1{{ proxmox_web_gateway_health_path }}" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + return_content: true + validate_certs: false + register: proxmox_web_gateway_https_health + failed_when: + - proxmox_web_gateway_https_health.status != 200 + - "'ok' not in proxmox_web_gateway_https_health.content" + changed_when: false + + handlers: + - name: Reload nginx + ansible.builtin.systemd: + name: nginx + state: reloaded diff --git a/ansible/deploy_tsj_guardian_bot_proxmox.yml b/ansible/deploy_tsj_guardian_bot_proxmox.yml index 8c4081d..106fec4 100644 --- a/ansible/deploy_tsj_guardian_bot_proxmox.yml +++ b/ansible/deploy_tsj_guardian_bot_proxmox.yml @@ -11,6 +11,9 @@ tsj_bot_script_name: "tsj_guardian_bot.py" tsj_bot_script_dest: "{{ tsj_bot_root }}/{{ tsj_bot_script_name }}" tsj_bot_source_local_path: "{{ aw_repo_root }}/proxmox/tsj_guardian_bot.py" + tsj_bot_openvpn_helper_name: "pfsense_openvpn_client_export.php" + tsj_bot_openvpn_helper_dest: "{{ tsj_bot_root }}/{{ tsj_bot_openvpn_helper_name }}" + tsj_bot_openvpn_helper_source_local_path: "{{ aw_repo_root }}/proxmox/{{ tsj_bot_openvpn_helper_name }}" tsj_bot_service_name: "tsj-guardian-bot.service" tsj_bot_env_path: "{{ tsj_bot_root }}/.env" tsj_bot_state_dir: "/home/codex/infra-admin/.state" @@ -51,12 +54,27 @@ - tsj_bot_source_stat.stat.isreg fail_msg: "Файл бота не найден: {{ tsj_bot_source_local_path }}" + - name: Проверить наличие helper для OpenVPN экспорта на контроллере + ansible.builtin.stat: + path: "{{ tsj_bot_openvpn_helper_source_local_path }}" + register: tsj_bot_openvpn_helper_stat + delegate_to: localhost + become: false + + - name: Остановить выполнение если helper для OpenVPN не найден + ansible.builtin.assert: + that: + - tsj_bot_openvpn_helper_stat.stat.exists + - tsj_bot_openvpn_helper_stat.stat.isreg + fail_msg: "Файл helper для OpenVPN не найден: {{ tsj_bot_openvpn_helper_source_local_path }}" + tasks: - name: Установить зависимости Python для бота ansible.builtin.package: name: - python3 - python3-requests + - sshpass state: present - name: Создать каталоги бота @@ -80,6 +98,15 @@ mode: "0750" notify: Restart tsj bot + - name: Развернуть helper для OpenVPN экспорта + ansible.builtin.copy: + src: "{{ tsj_bot_openvpn_helper_source_local_path }}" + dest: "{{ tsj_bot_openvpn_helper_dest }}" + owner: root + group: "{{ tsj_bot_group }}" + mode: "0640" + notify: Restart tsj bot + - name: Сгенерировать полный .env бота when: - telegram_bot_token is defined @@ -100,7 +127,7 @@ NO_PROXY={{ tsj_bot_no_proxy | default('localhost,127.0.0.1,10.10.10.0/24') }} NODE_13_HOST={{ tsj_bot_node_13_host | default('10.10.10.13') }} NODE_16_HOST={{ tsj_bot_node_16_host | default('10.10.10.16') }} - NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/') }} + NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/api/0/info') }} NODE_16_URL={{ tsj_bot_node_16_url | default('http://10.10.10.16/') }} NODE_16_ENABLED={{ tsj_bot_node_16_enabled | default('false') }} CHECK_SCRIPT={{ tsj_bot_check_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --check') }} @@ -123,15 +150,17 @@ TELEGRAM_PROXY_URL={{ tsj_bot_telegram_proxy_url | default('http://127.0.0.1:11090') }} AI_CHAT_ENABLED={{ tsj_bot_ai_chat_enabled | default('true') }} AI_CHAT_TIMEOUT_SEC={{ tsj_bot_ai_chat_timeout_sec | default(1800) }} - AI_CHAT_WORKDIR={{ tsj_bot_ai_chat_workdir | default('/home/codex/infra-admin') }} + AI_CHAT_WORKDIR={{ tsj_bot_ai_chat_workdir | default('/home/igor') }} AI_CHAT_SANDBOX={{ tsj_bot_ai_chat_sandbox | default('workspace-write') }} CODEX_MODEL={{ tsj_bot_codex_model | default('gpt-5.3-codex') }} CODEX_FALLBACK_MODELS={{ tsj_bot_codex_fallback_models | default('gpt-5.4-mini') }} - AI_EXEC_USER={{ tsj_bot_ai_exec_user | default('codex') }} - TMUX_USER={{ tsj_bot_tmux_user | default('codex') }} + AI_EXEC_USER={{ tsj_bot_ai_exec_user | default('igor') }} + TMUX_USER={{ tsj_bot_tmux_user | default('igor') }} TMUX_SESSION={{ tsj_bot_tmux_session | default('ai') }} TMUX_CREATE_IF_MISSING={{ tsj_bot_tmux_create_if_missing | default('false') }} TMUX_START_COMMAND={{ tsj_bot_tmux_start_command | default('codex') }} + PFSENSE_ENV_PATH={{ tsj_bot_pfsense_env_path | default('/home/igor/.config/tsj-bot/pfsense.env.readonly') }} + PFSENSE_INVENTORY_PATH={{ tsj_bot_pfsense_inventory_path | default('/home/igor/.config/tsj-bot/inventory.md') }} PFSENSE_CHANGE_CONTROL_ENABLED={{ tsj_bot_pfsense_change_control_enabled | default('true') }} PFSENSE_CHANGE_CONFIRM_TTL_SEC={{ tsj_bot_pfsense_change_confirm_ttl_sec | default(900) }} OPENVPN_CONFIG_ENABLED={{ tsj_bot_openvpn_config_enabled | default('true') }} @@ -146,7 +175,9 @@ UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }} AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }} AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }} - AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'") }} + AW_DLP_POLICY_API_BASE={{ tsj_bot_aw_dlp_policy_api_base | default('http://10.10.10.13:5601/api/0') }} + AW_DLP_POLICY_ACTOR={{ tsj_bot_aw_dlp_policy_actor | default('tsj-guardian-bot') }} + AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'") }} AW_RUS_DLP_HEAL_CMD={{ tsj_bot_aw_rus_dlp_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'") }} AW_RUS_CASE_API_BASE={{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }} AW_RUS_HAYABUSA_ENABLED={{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }} @@ -154,6 +185,15 @@ AW_RUS_HOST={{ tsj_bot_aw_rus_host | default('SHARKON2025') }} AW_RUS_PRIMARY_USER={{ tsj_bot_aw_rus_primary_user | default('USER1') }} AW_RUS_STALE_SEC={{ tsj_bot_aw_rus_stale_sec | default(900) }} + AW_RUS_WINDOWS_HOST={{ tsj_bot_aw_rus_windows_host | default(hostvars[(groups['aw_windows'] | first)].ansible_host | default('192.168.100.18')) }} + AW_RUS_WINDOWS_SSH_USER={{ tsj_bot_aw_rus_windows_ssh_user | default(hostvars[(groups['aw_windows'] | first)].ansible_user | default('Администратор')) }} + AW_RUS_WINDOWS_SSH_PASSWORD={{ tsj_bot_aw_rus_windows_ssh_password | default(hostvars[(groups['aw_windows'] | first)].ansible_password | default('')) }} + AW_RUS_WINDOWS_CONFIG_PATH={{ tsj_bot_aw_rus_windows_config_path | default('C:\ProgramData\AWatch-rus\deployment-config.json') }} + AW_RUS_WINDOWS_HARDENING_RECOVERY_PATH={{ tsj_bot_aw_rus_windows_hardening_recovery_path | default('C:\Program Files\AWatch-rus\windows\hardening-recovery.ps1') }} + AW_RUS_WINDOWS_SESSION_COLLECTOR_PATH={{ tsj_bot_aw_rus_windows_session_collector_path | default('C:\ProgramData\AWatch-rus\worktime-session-collector.ps1') }} + AW_RUS_WINDOWS_POLICY_PATH={{ tsj_bot_aw_rus_windows_policy_path | default('C:\ProgramData\AWatch-rus\dlp-policy.json') }} + AW_RUS_WINDOWS_BROWSER_COLLECTOR_PATH={{ tsj_bot_aw_rus_windows_browser_collector_path | default('C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1') }} + AW_RUS_WINDOWS_EMAIL_COLLECTOR_PATH={{ tsj_bot_aw_rus_windows_email_collector_path | default('C:\ProgramData\AWatch-rus\email-outbound-collector.ps1') }} notify: Restart tsj bot - name: Обновить только AW-Rus/Hayabusa env ключи в существующем .env @@ -176,7 +216,9 @@ loop: - { key: "AW_RUS_API_BASE", value: "{{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }}" } - { key: "AW_RUS_WORKTIME_BASE", value: "{{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }}" } - - { key: "AW_RUS_WORKTIME_HEAL_CMD", value: "{{ tsj_bot_aw_rus_worktime_heal_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'\") }}" } + - { key: "AW_DLP_POLICY_API_BASE", value: "{{ tsj_bot_aw_dlp_policy_api_base | default('http://10.10.10.13:5601/api/0') }}" } + - { key: "AW_DLP_POLICY_ACTOR", value: "{{ tsj_bot_aw_dlp_policy_actor | default('tsj-guardian-bot') }}" } + - { key: "AW_RUS_WORKTIME_HEAL_CMD", value: "{{ tsj_bot_aw_rus_worktime_heal_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'\") }}" } - { key: "AW_RUS_DLP_HEAL_CMD", value: "{{ tsj_bot_aw_rus_dlp_heal_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'\") }}" } - { key: "AW_RUS_CASE_API_BASE", value: "{{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }}" } - { key: "AW_RUS_HAYABUSA_ENABLED", value: "{{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }}" } @@ -184,6 +226,20 @@ - { key: "AW_RUS_HOST", value: "{{ tsj_bot_aw_rus_host | default('SHARKON2025') }}" } - { key: "AW_RUS_PRIMARY_USER", value: "{{ tsj_bot_aw_rus_primary_user | default('USER1') }}" } - { key: "AW_RUS_STALE_SEC", value: "{{ tsj_bot_aw_rus_stale_sec | default(900) }}" } + - { key: "AW_RUS_WINDOWS_HOST", value: "{{ tsj_bot_aw_rus_windows_host | default(hostvars[(groups['aw_windows'] | first)].ansible_host | default('192.168.100.18')) }}" } + - { key: "AW_RUS_WINDOWS_SSH_USER", value: "{{ tsj_bot_aw_rus_windows_ssh_user | default(hostvars[(groups['aw_windows'] | first)].ansible_user | default('Администратор')) }}" } + - { key: "AW_RUS_WINDOWS_SSH_PASSWORD", value: "{{ tsj_bot_aw_rus_windows_ssh_password | default(hostvars[(groups['aw_windows'] | first)].ansible_password | default('')) }}" } + - { key: "AW_RUS_WINDOWS_CONFIG_PATH", value: "{{ tsj_bot_aw_rus_windows_config_path | default('C:\\ProgramData\\AWatch-rus\\deployment-config.json') }}" } + - { key: "AW_RUS_WINDOWS_HARDENING_RECOVERY_PATH", value: "{{ tsj_bot_aw_rus_windows_hardening_recovery_path | default('C:\\Program Files\\AWatch-rus\\windows\\hardening-recovery.ps1') }}" } + - { key: "AW_RUS_WINDOWS_SESSION_COLLECTOR_PATH", value: "{{ tsj_bot_aw_rus_windows_session_collector_path | default('C:\\ProgramData\\AWatch-rus\\worktime-session-collector.ps1') }}" } + - { key: "AW_RUS_WINDOWS_POLICY_PATH", value: "{{ tsj_bot_aw_rus_windows_policy_path | default('C:\\ProgramData\\AWatch-rus\\dlp-policy.json') }}" } + - { key: "AW_RUS_WINDOWS_BROWSER_COLLECTOR_PATH", value: "{{ tsj_bot_aw_rus_windows_browser_collector_path | default('C:\\ProgramData\\AWatch-rus\\browser-domains-native-collector.ps1') }}" } + - { key: "AW_RUS_WINDOWS_EMAIL_COLLECTOR_PATH", value: "{{ tsj_bot_aw_rus_windows_email_collector_path | default('C:\\ProgramData\\AWatch-rus\\email-outbound-collector.ps1') }}" } + - { key: "AI_CHAT_WORKDIR", value: "{{ tsj_bot_ai_chat_workdir | default('/home/igor') }}" } + - { key: "AI_EXEC_USER", value: "{{ tsj_bot_ai_exec_user | default('igor') }}" } + - { key: "TMUX_USER", value: "{{ tsj_bot_tmux_user | default('igor') }}" } + - { key: "PFSENSE_ENV_PATH", value: "{{ tsj_bot_pfsense_env_path | default('/home/igor/.config/tsj-bot/pfsense.env.readonly') }}" } + - { key: "PFSENSE_INVENTORY_PATH", value: "{{ tsj_bot_pfsense_inventory_path | default('/home/igor/.config/tsj-bot/inventory.md') }}" } notify: Restart tsj bot - name: Установить systemd unit бота diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index bf95147..2bcd41e 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -29,6 +29,7 @@ aw_dlp_influx_event_limit: 2000 aw_dlp_influx_token: "" aw_monitored_windows_host: "192.168.100.18" aw_monitored_windows_hostname: "SHARKON2025" +aw_rus_health_worktime_api_base: "http://127.0.0.1:5610" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" aw_hayabusa_auto_case_enabled: true diff --git a/ansible/group_vars/pfsense-poller.example.yml b/ansible/group_vars/pfsense-poller.example.yml index 87aabaa..c592061 100644 --- a/ansible/group_vars/pfsense-poller.example.yml +++ b/ansible/group_vars/pfsense-poller.example.yml @@ -12,16 +12,16 @@ aw_pfsense_poller_config: scheme: "https" verify_tls: false timeout_seconds: 15 - headers: - X-API-Key: "replace-me" - X-API-Secret: "replace-me" + auth: + api_key: "replace-me" + api_secret: "replace-me" endpoints: - name: "system-status" path: "/api/v2/status/system" bucket_prefix: "aw-pfsense-health" bucket_type: "aw.pfsense.health" - name: "interfaces" - path: "/api/v2/interface" + path: "/api/v2/interfaces" bucket_prefix: "aw-pfsense-interfaces" bucket_type: "aw.pfsense.interfaces" - name: "gateways" diff --git a/ansible/group_vars/proxmox-bot.example.yml b/ansible/group_vars/proxmox-bot.example.yml index e73db07..0daec36 100644 --- a/ansible/group_vars/proxmox-bot.example.yml +++ b/ansible/group_vars/proxmox-bot.example.yml @@ -3,7 +3,7 @@ telegram_allowed_chat_ids: "123456789" telegram_default_chat_id: 123456789 # Path on controller (this machine) to source bot script for deployment. -tsj_bot_source_local_path: "/home/codex/infra-admin/tsj-bot/tsj_guardian_bot.py" +tsj_bot_source_local_path: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/tsj_guardian_bot.py" # Optional bot tuning tsj_bot_check_interval_sec: 60 @@ -15,7 +15,7 @@ tsj_bot_http_proxy_url: "http://127.0.0.1:11090" tsj_bot_no_proxy: "localhost,127.0.0.1,10.10.10.0/24" tsj_bot_node_13_host: "10.10.10.13" tsj_bot_node_16_host: "10.10.10.16" -tsj_bot_node_13_url: "http://10.10.10.13:5600/" +tsj_bot_node_13_url: "http://10.10.10.13:5600/api/0/info" tsj_bot_node_16_url: "http://10.10.10.16/" tsj_bot_node_16_enabled: "false" tsj_bot_fs_warn_pct: 85 @@ -24,8 +24,13 @@ tsj_bot_fs_targets: "host,200,201,202,203" tsj_bot_fs_exclude_types: "tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs" tsj_bot_fs_immediate_ai_on_critical: "true" tsj_bot_ai_escalation_mode: "codex_exec" +tsj_bot_ai_chat_workdir: "/home/igor" +tsj_bot_ai_exec_user: "igor" +tsj_bot_tmux_user: "igor" tsj_bot_pfsense_change_control_enabled: "true" tsj_bot_pfsense_change_confirm_ttl_sec: 900 +tsj_bot_pfsense_env_path: "/home/igor/.config/tsj-bot/pfsense.env.readonly" +tsj_bot_pfsense_inventory_path: "/home/igor/.config/tsj-bot/inventory.md" tsj_bot_openvpn_config_enabled: "true" tsj_bot_openvpn_config_confirm_ttl_sec: 900 tsj_bot_openvpn_expiry_warn_enabled: "false" @@ -40,7 +45,9 @@ tsj_bot_pfsense_mcp_bearer: "CHANGE_ME" # AW-Rus + DLP check defaults tsj_bot_aw_rus_api_base: "http://10.10.10.13:5600/api/0" tsj_bot_aw_rus_worktime_base: "http://10.10.10.13:5610" -tsj_bot_aw_rus_worktime_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl start aw-worktime-ui-bridge.service'" +tsj_bot_aw_dlp_policy_api_base: "http://10.10.10.13:5601/api/0" +tsj_bot_aw_dlp_policy_actor: "tsj-guardian-bot" +tsj_bot_aw_rus_worktime_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'" tsj_bot_aw_rus_dlp_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'" tsj_bot_aw_rus_case_api_base: "http://10.10.10.13:5602" tsj_bot_aw_rus_hayabusa_enabled: "true" @@ -48,3 +55,12 @@ tsj_bot_aw_rus_hayabusa_ssh_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentica tsj_bot_aw_rus_host: "SHARKON2025" tsj_bot_aw_rus_primary_user: "USER1" tsj_bot_aw_rus_stale_sec: 900 +tsj_bot_aw_rus_windows_host: "192.168.100.18" +tsj_bot_aw_rus_windows_ssh_user: "Администратор" +tsj_bot_aw_rus_windows_ssh_password: "CHANGE_ME" +tsj_bot_aw_rus_windows_config_path: "C:\\ProgramData\\AWatch-rus\\deployment-config.json" +tsj_bot_aw_rus_windows_hardening_recovery_path: "C:\\Program Files\\AWatch-rus\\windows\\hardening-recovery.ps1" +tsj_bot_aw_rus_windows_session_collector_path: "C:\\ProgramData\\AWatch-rus\\worktime-session-collector.ps1" +tsj_bot_aw_rus_windows_policy_path: "C:\\ProgramData\\AWatch-rus\\dlp-policy.json" +tsj_bot_aw_rus_windows_browser_collector_path: "C:\\ProgramData\\AWatch-rus\\browser-domains-native-collector.ps1" +tsj_bot_aw_rus_windows_email_collector_path: "C:\\ProgramData\\AWatch-rus\\email-outbound-collector.ps1" diff --git a/ansible/post_validate_aw_windows.yml b/ansible/post_validate_aw_windows.yml index 7eabd54..d9e9853 100644 --- a/ansible/post_validate_aw_windows.yml +++ b/ansible/post_validate_aw_windows.yml @@ -40,6 +40,18 @@ ) }} + - name: Вычислить effective каталог health validation на AW server + ansible.builtin.set_fact: + aw_windows_health_validation_dir_effective: >- + {{ + ( + hostvars[aw_server_inventory_host_effective].aw_rus_health_validation_dir + | default('/var/lib/activitywatch/health/windows-validation', true) + ) + if (aw_server_inventory_host_effective | length) > 0 + else '' + }} + - name: Аккуратно запустить ActivityWatch recovery и launch tasks, если они не в Running when: aw_windows_force_task_restart | bool ansible.windows.win_powershell: @@ -120,6 +132,26 @@ dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" flat: true + - name: Убедиться, что каталог Windows validation существует на AW server + when: + - aw_server_inventory_host_effective | length > 0 + - aw_windows_health_validation_dir_effective | length > 0 + ansible.builtin.file: + path: "{{ aw_windows_health_validation_dir_effective }}" + state: directory + mode: "0755" + delegate_to: "{{ aw_server_inventory_host_effective }}" + + - name: Опубликовать validation report на AW server для aw-rus-healthd + when: + - aw_server_inventory_host_effective | length > 0 + - aw_windows_health_validation_dir_effective | length > 0 + ansible.builtin.copy: + src: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" + dest: "{{ aw_windows_health_validation_dir_effective }}/{{ inventory_hostname }}-aw_validate_ansible.json" + mode: "0644" + delegate_to: "{{ aw_server_inventory_host_effective }}" + - name: Показать путь к отчёту ansible.builtin.debug: msg: diff --git a/ansible/templates/proxmox-web-gateway-index.html.j2 b/ansible/templates/proxmox-web-gateway-index.html.j2 new file mode 100644 index 0000000..7e69fa4 --- /dev/null +++ b/ansible/templates/proxmox-web-gateway-index.html.j2 @@ -0,0 +1,156 @@ + + + + + + Proxmox Web Gateway + + + +
+
+

Proxmox Web Gateway

+

+ Единая стартовая точка для web-сервисов контура на Proxmox host + 10.10.10.2. Gateway сознательно работает как + безопасный redirector: приложения не ломаются под subpath, а оператор + получает один адрес входа. +

+

+ Публичное имя gateway: {{ proxmox_web_gateway_public_hostname }} +

+

+ DLP-ветка сюда не включена намеренно. В списке только host, management, + dashboards и базовые operational/data web endpoints. +

+
+ +
+{% for route in proxmox_web_gateway_routes %} + + {{ route.category }} +

{{ route.title }}

+

{{ route.description }}

+
{{ route.target_url }}
+
+{% endfor %} +
+ +

+ Health endpoint gateway: {{ proxmox_web_gateway_health_path }} +

+
+ + diff --git a/ansible/templates/proxmox-web-gateway.conf.j2 b/ansible/templates/proxmox-web-gateway.conf.j2 new file mode 100644 index 0000000..4a91758 --- /dev/null +++ b/ansible/templates/proxmox-web-gateway.conf.j2 @@ -0,0 +1,49 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name {{ proxmox_web_gateway_public_hostname }} 10.10.10.2 192.168.63.213 _; + + access_log /var/log/nginx/proxmox-web-gateway.access.log; + error_log /var/log/nginx/proxmox-web-gateway.error.log; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; + server_name {{ proxmox_web_gateway_public_hostname }} 10.10.10.2 192.168.63.213 _; + + access_log /var/log/nginx/proxmox-web-gateway.access.log; + error_log /var/log/nginx/proxmox-web-gateway.error.log; + + ssl_certificate {{ proxmox_web_gateway_tls_cert_path }}; + ssl_certificate_key {{ proxmox_web_gateway_tls_key_path }}; + ssl_session_cache shared:ProxmoxWebGatewaySSL:10m; + ssl_session_timeout 1d; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + root {{ proxmox_web_gateway_root }}; + index index.html; + + location = {{ proxmox_web_gateway_health_path }} { + default_type text/plain; + return 200 "ok\n"; + } + + location = /robots.txt { + default_type text/plain; + return 200 "User-agent: *\nDisallow: /\n"; + } + +{% for route in proxmox_web_gateway_routes %} + location = /go/{{ route.slug }} { + return 302 {{ route.target_url }}; + } +{% endfor %} + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index ff57b41..67c21f6 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -3,8 +3,8 @@ return; } window.__awRuPatchBootstrapped = true; - window.__awRuPatchVersion = "template-v13-category-builder-early-fix"; - document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-category-builder-early-fix"); + window.__awRuPatchVersion = "template-v14-dlp-route-lite"; + document.documentElement.setAttribute("data-aw-ru-patch", "template-v14-dlp-route-lite"); const exact = new Map([ ["ActivityWatch", "АктивВотч"], @@ -1363,7 +1363,9 @@ try { await saveDlpReview(host, event, row); state.reviews = collapseReviewEvents(await loadBucketEvents("aw-dlp-review_" + host, 200)); - renderDlpTableRows(center, host); + renderDlpReviewManager(center, host); + center.querySelector("[data-aw-ru-dlp-status]").textContent = + "Событий: " + state.events.length + " · правил: " + state.activeRules.length + "/" + state.rules.length + " · review: " + state.reviews.filter(function (review) { return !(review.data && review.data.review && review.data.review.archived); }).length + "/" + state.reviews.length; message.textContent = "Review сохранен."; } catch (error) { message.textContent = "Ошибка сохранения review: " + error.message; @@ -1404,8 +1406,17 @@ if (!hayabusa) return ""; const status = String(hayabusa.status || ""); const mode = String(hayabusa.mode || ""); + const caseHost = normalizeText(c && c.host); + const forensicHost = normalizeText(hayabusa.host); const reportDir = String(hayabusa.report_dir || ""); - const title = reportDir ? ' title="' + escapeHtml(reportDir) + '"' : ""; + const hostMismatch = caseHost && forensicHost && caseHost !== forensicHost; + const titleParts = []; + if (reportDir) titleParts.push(reportDir); + if (hostMismatch) titleParts.push("host mismatch: case=" + caseHost + " forensic=" + forensicHost); + const title = titleParts.length ? ' title="' + escapeHtml(titleParts.join(" | ")) + '"' : ""; + if (hostMismatch) { + return 'Hayabusa host-mismatch · ' + escapeHtml(forensicHost) + ''; + } return 'Hayabusa ' + escapeHtml(status) + (mode ? " · " + escapeHtml(mode) : "") + ''; } const rows = (cases || []).map(function (c) { @@ -2121,12 +2132,28 @@ try { const routeKey = window.location.hash || "#"; const routeChanged = routeKey !== staticPatchRouteKey; + const dlpRoute = isDlpSignalBucketRoute(); + installCategoryBuilderNetworkPatch(); + injectStyles(); + if (dlpRoute) { + ensureSettingsHost(); + injectDlpNavigation(document.body); + if (dlpOverlayFailureCount === 0) { + try { + injectDlpReviewCenter(document.body); + } catch (error) { + dlpOverlayFailureCount += 1; + const existing = document.body.querySelector("[data-aw-ru-dlp-center='1']"); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + } + } + staticPatchRouteKey = routeKey; + return; + } enforceSafeActivityViewForPveHost(); ensureSettingsHost(); ensureHostGroupsData().catch(function () {}); normalizeCategoryBuilderUnknownHostRefs(); - installCategoryBuilderNetworkPatch(); - injectStyles(); if (routeChanged) { walk(document.body); translateAttributes(document.body); @@ -2139,17 +2166,7 @@ injectPveAuditCenter(document.body); injectRdpWorktimeCenter(document.body).catch(function () {}); injectDlpNavigation(document.body); - if (isDlpSignalBucketRoute() && dlpOverlayFailureCount === 0) { - try { - injectDlpReviewCenter(document.body); - } catch (error) { - dlpOverlayFailureCount += 1; - const existing = document.body.querySelector("[data-aw-ru-dlp-center='1']"); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - } - } else if (!isDlpSignalBucketRoute()) { - injectDlpReviewCenter(document.body); - } + injectDlpReviewCenter(document.body); injectDlpAlertsCenter(document.body); injectHostGroupsCenter(document.body).catch(function () {}); redirectBareTrendsRoute(); @@ -2170,6 +2187,7 @@ const observer = new MutationObserver(function () { if (applyPatchInFlight) return; + if (isDlpSignalBucketRoute() && document.body && document.body.querySelector("[data-aw-ru-dlp-center='1']")) return; scheduleApplyPatch(); }); @@ -2182,6 +2200,7 @@ }); window.addEventListener("hashchange", function () { redirectBareTrendsRoute(); + dlpOverlayFailureCount = 0; staticPatchRouteKey = ""; scheduleApplyPatch(); }); diff --git a/aw-server/aw-rus-healthd.py b/aw-server/aw-rus-healthd.py index 0423403..234b90c 100644 --- a/aw-server/aw-rus-healthd.py +++ b/aw-server/aw-rus-healthd.py @@ -229,14 +229,17 @@ def main() -> int: parser = argparse.ArgumentParser(description="Unified AW-RUS health orchestrator") parser.add_argument("--aw-server", default=env("AW_SERVER_URL", "http://127.0.0.1:5600")) - parser.add_argument("--worktime-api", default=env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")) + parser.add_argument( + "--worktime-api", + default=env("AW_RUS_HEALTH_WORKTIME_API", env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")), + ) parser.add_argument("--rdp-host", default=env("AW_MONITORED_WINDOWS_HOST", "192.168.100.18")) parser.add_argument("--rdp-hostname", default=env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025")) parser.add_argument("--state-dir", default=env("AW_RUS_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) parser.add_argument("--validation-dir", default=env("AW_RUS_HEALTH_VALIDATION_DIR", "/var/lib/activitywatch/health/windows-validation")) parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) - parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "604800"))) + parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "86400"))) parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) parser.add_argument("--json", action="store_true") @@ -257,9 +260,8 @@ def main() -> int: report.add("http:aw-server", "fail", f"activitywatch API failed: {exc}", url=f"{aw_api_base}/info") try: - payload = http_json(args.worktime_api.rstrip("/") + "/reports/worktime/today") - rows = len(payload) if isinstance(payload, list) else None - report.add("http:worktime-api", "ok", "worktime API responded", rows=rows) + payload = http_json(args.worktime_api.rstrip("/") + "/health") + report.add("http:worktime-api", "ok", "worktime API responded", payload=payload if isinstance(payload, dict) else {}) except Exception as exc: report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api) @@ -322,6 +324,9 @@ def main() -> int: missing_status="fail", stale_status="warn", ) + if session_status == "warn" and session_details.get("age_seconds") is not None: + session_status = "ok" + session_summary = f"event-driven ({session_details['age_seconds']}s since last logon marker)" report.add("bucket:session-events", session_status, session_summary, **session_details) validation_dir = Path(args.validation_dir) diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index b62b87c..58b1309 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -17,8 +17,13 @@ AW_SERVER_PUBLIC_HOST=aw-server AW_WORKTIME_REPORT_BASE=http://aw-server:5610 AW_WORKTIME_TZ=Europe/Moscow AW_SERVER_URL=http://127.0.0.1:5600 +AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0 +AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS=300 AW_WORKTIME_MANAGER_ALIASES_JSON=/etc/activitywatch/worktime-manager-aliases.json AW_WORKTIME_MANAGER_EXCLUDE_USERS= +AW_WORKTIME_MANAGEMENT_WARM_ENABLED=1 +AW_WORKTIME_MANAGEMENT_WARM_URL=http://127.0.0.1:5610/reports/worktime/management?day=today&format=json +AW_WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS=70 # DLP IOC Configuration AW_DLP_IOC_DIR=/opt/activitywatch/dlp-ioc/output @@ -42,8 +47,10 @@ AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/ AW_HEALTH_STRICT_FILEOPS=0 AW_MONITORED_WINDOWS_HOST=192.168.100.18 AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 +AW_RUS_HEALTH_WORKTIME_API=http://127.0.0.1:5610 AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation +AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS=86400 # Hayabusa auto-case / alerting AW_HAYABUSA_AUTO_CASE_ENABLED=true diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index bde9322..380137b 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -9,7 +9,7 @@ import sys import tempfile import urllib.request from datetime import datetime, timezone, timedelta -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlencode, urlparse from zoneinfo import ZoneInfo @@ -42,12 +42,12 @@ MANAGER_WEB_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_M MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS", "604800"))) MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS", "172800"))) MANAGER_TREND_DAYS = max(3, min(31, int(os.environ.get("AW_WORKTIME_MANAGER_TREND_DAYS", "7")))) -MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS", "120"))) +MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS", "300"))) MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache")) MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json")) MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} MODULE_PATH = Path(__file__).resolve() -_ALIASES_CACHE = {"mtime": None, "payload": {}} +_ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} def get(u): @@ -82,6 +82,16 @@ def now_utc(): return datetime.now(timezone.utc) +def worktime_health_payload(): + return { + "ok": True, + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + "report_timezone": str(REPORT_TZ), + "default_host": DEFAULT_HOST, + "aw_api_base": AW, + } + + def age_seconds(ts, now=None): if ts is None: return None @@ -127,22 +137,29 @@ def _default_display_name(user, user_id): return base or str(user or "").strip() -def load_manager_aliases(): +def _load_manager_directory(): try: stat = MANAGER_ALIASES_JSON.stat() except FileNotFoundError: _ALIASES_CACHE["mtime"] = None - _ALIASES_CACHE["payload"] = {} - return {} + _ALIASES_CACHE["users"] = {} + _ALIASES_CACHE["owners"] = {} + _ALIASES_CACHE["raw"] = {} + return {"users": {}, "owners": {}, "raw": {}} mtime = stat.st_mtime if _ALIASES_CACHE["mtime"] == mtime: - return _ALIASES_CACHE["payload"] + return { + "users": _ALIASES_CACHE["users"], + "owners": _ALIASES_CACHE["owners"], + "raw": _ALIASES_CACHE["raw"], + } try: raw = json.loads(MANAGER_ALIASES_JSON.read_text(encoding="utf-8")) except Exception as exc: log_warning(f"failed to load manager aliases from {MANAGER_ALIASES_JSON}: {exc}") raw = {} - payload = {} + users_payload = {} + owners_payload = {} if isinstance(raw, dict): users = raw.get("users", raw) if isinstance(users, dict): @@ -151,12 +168,36 @@ def load_manager_aliases(): if not norm_key: continue if isinstance(value, str): - payload[norm_key] = {"display_name": value} + users_payload[norm_key] = {"display_name": value} elif isinstance(value, dict): - payload[norm_key] = dict(value) + users_payload[norm_key] = dict(value) + owners = raw.get("owners", {}) + if isinstance(owners, dict): + for key, value in owners.items(): + norm_key = _normalize_identity_key(key) + if not norm_key: + continue + if isinstance(value, str): + owners_payload[norm_key] = {"display_name": value} + elif isinstance(value, dict): + owners_payload[norm_key] = dict(value) _ALIASES_CACHE["mtime"] = mtime - _ALIASES_CACHE["payload"] = payload - return payload + _ALIASES_CACHE["users"] = users_payload + _ALIASES_CACHE["owners"] = owners_payload + _ALIASES_CACHE["raw"] = raw if isinstance(raw, dict) else {} + return { + "users": users_payload, + "owners": owners_payload, + "raw": _ALIASES_CACHE["raw"], + } + + +def load_manager_aliases(): + return _load_manager_directory()["users"] + + +def load_manager_owners(): + return _load_manager_directory()["owners"] def resolve_user_alias(user, user_id, host): @@ -189,6 +230,29 @@ def resolve_user_alias(user, user_id, host): } +def resolve_owner_profile(owner_name): + owner_name = normalize_management_filter(owner_name) + if not owner_name: + owner_name = "unassigned" + owners = load_manager_owners() + profile = dict(owners.get(_normalize_identity_key(owner_name), {})) + display_name = str(profile.get("display_name") or profile.get("name") or owner_name).strip() or owner_name + title = str(profile.get("title") or profile.get("role") or "").strip() + department = str(profile.get("department") or "").strip() + escalation_to = str(profile.get("escalation_to") or profile.get("escalate_to") or "").strip() + contact = str(profile.get("contact") or profile.get("telegram") or profile.get("email") or "").strip() + notes = str(profile.get("notes") or "").strip() + return { + "owner_name": owner_name, + "display_name": display_name, + "title": title, + "department": department, + "escalation_to": escalation_to, + "contact": contact, + "notes": notes, + } + + def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): try: seconds = float(value) @@ -578,6 +642,272 @@ def build_executive_summary(summary, actions, sources): } +def _empty_rollup(name): + return { + "name": name or "unassigned", + "users_count": 0, + "active_users": 0, + "inactive_users": 0, + "below_target_users": 0, + "workday_total_active_seconds": 0, + "workday_total_active_hhmm": "00:00", + "portfolio_coverage_pct": 0.0, + "actions_count": 0, + "critical_actions_count": 0, + "high_actions_count": 0, + "medium_actions_count": 0, + "low_actions_count": 0, + "users": [], + } + + +def normalize_management_filter(value): + if value is None: + return "" + return " ".join(str(value).split()).strip() + + +def _filter_key(value): + return normalize_management_filter(value).casefold() + + +def _matches_management_filters(alias, owner_filter="", department_filter=""): + if owner_filter and _filter_key(alias.get("manager_owner")) != _filter_key(owner_filter): + return False + if department_filter and _filter_key(alias.get("department")) != _filter_key(department_filter): + return False + return True + + +def _action_department(action): + evidence = action.get("evidence") or {} + department = normalize_management_filter(evidence.get("department")) + if department: + return department + if normalize_management_filter(action.get("owner")) == "ops": + return "Инфраструктура" + return "" + + +def filter_management_actions(actions, rows, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + if not owner_filter and not department_filter: + return list(actions) + row_user_ids = { + normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) + for row in rows + if normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) + } + filtered = [] + for action in actions: + if owner_filter and _filter_key(action.get("owner")) != _filter_key(owner_filter): + continue + if department_filter: + action_department = _action_department(action) + if action_department: + if _filter_key(action_department) != _filter_key(department_filter): + continue + else: + user_id = normalize_management_filter(action.get("user_id")) + if not user_id or user_id not in row_user_ids: + continue + filtered.append(action) + return filtered + + +def _row_matches_payload_filters(row, owner_filter="", department_filter=""): + if owner_filter and _filter_key(row.get("manager_owner")) != _filter_key(owner_filter): + return False + if department_filter and _filter_key(row.get("department")) != _filter_key(department_filter): + return False + return True + + +def summarize_management_rows(rows, actions, workday): + expected_seconds_per_user = int(workday.get("expected_seconds_per_user", 0) or 0) + workday_total_active_seconds = sum(int(row.get("workday_active_seconds", 0) or 0) for row in rows) + calendar_total_active_seconds = sum(int(row.get("calendar_active_seconds", 0) or 0) for row in rows) + active_users = sum(1 for row in rows if row.get("status") != "inactive") + inactive_users = sum(1 for row in rows if row.get("status") == "inactive") + below_target_users = sum(1 for row in rows if row.get("status") == "below_target") + on_target_users = sum(1 for row in rows if row.get("status") == "ok") + workday_first_values = sorted(str(row.get("workday_first_activity_local") or "") for row in rows if row.get("workday_first_activity_local")) + workday_last_values = sorted(str(row.get("workday_last_activity_local") or "") for row in rows if row.get("workday_last_activity_local")) + calendar_first_values = sorted(str(row.get("first_activity_local") or "") for row in rows if row.get("first_activity_local")) + calendar_last_values = sorted(str(row.get("last_activity_local") or "") for row in rows if row.get("last_activity_local")) + top_row = max(rows, key=lambda row: int(row.get("workday_active_seconds", 0) or 0), default=None) + portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(rows))) * 100.0) if rows and expected_seconds_per_user > 0 else 0.0 + return { + "users_count": len(rows), + "active_users": active_users, + "inactive_users": inactive_users, + "on_target_users": on_target_users, + "below_target_users": below_target_users, + "portfolio_coverage_pct": portfolio_coverage_pct, + "actions_count": len(actions), + "critical_actions_count": sum(1 for action in actions if action.get("priority") == "critical"), + "high_actions_count": sum(1 for action in actions if action.get("priority") == "high"), + "calendar_total_active_seconds": calendar_total_active_seconds, + "calendar_total_active_hhmm": hhmm(calendar_total_active_seconds), + "calendar_first_activity": calendar_first_values[0] if calendar_first_values else "", + "calendar_last_activity": calendar_last_values[-1] if calendar_last_values else "", + "workday_total_active_seconds": workday_total_active_seconds, + "workday_total_active_hhmm": hhmm(workday_total_active_seconds), + "workday_first_activity": workday_first_values[0] if workday_first_values else "", + "workday_last_activity": workday_last_values[-1] if workday_last_values else "", + "total_active_seconds": workday_total_active_seconds, + "total_active_hhmm": hhmm(workday_total_active_seconds), + "first_activity": workday_first_values[0] if workday_first_values else "", + "last_activity": workday_last_values[-1] if workday_last_values else "", + "top_user": str((top_row or {}).get("user") or ""), + "top_user_active_hhmm": hhmm(int((top_row or {}).get("workday_active_seconds", 0) or 0)), + } + + +def apply_management_filters_to_payload(payload, owner_filter="", department_filter="", include_sources=True, include_source_actions=True): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + filtered_rows = [ + dict(row) + for row in payload.get("rows", []) + if _row_matches_payload_filters(row, owner_filter=owner_filter, department_filter=department_filter) + ] + base_actions = list(payload.get("actions", [])) + if not include_source_actions: + base_actions = [action for action in base_actions if action.get("action_id") != "source_freshness_review"] + filtered_actions = filter_management_actions(base_actions, filtered_rows, owner_filter=owner_filter, department_filter=department_filter) + filtered_payload = dict(payload) + filtered_payload["filters"] = { + "owner": owner_filter, + "department": department_filter, + } + filtered_payload["rows"] = filtered_rows + filtered_payload["actions"] = filtered_actions + filtered_payload["summary"] = summarize_management_rows(filtered_rows, filtered_actions, payload.get("workday") or {}) + filtered_payload["owner_rollups"] = build_owner_rollups(filtered_rows, filtered_actions) + filtered_payload["department_rollups"] = build_department_rollups(filtered_rows, filtered_actions) + filtered_payload["owner_roster"] = build_owner_roster(filtered_rows, filtered_actions) + if include_sources: + filtered_payload["sources"] = list(payload.get("sources", [])) + else: + filtered_payload["sources"] = [] + filtered_payload["executive"] = build_executive_summary(filtered_payload["summary"], filtered_actions, filtered_payload["sources"]) + return filtered_payload + + +def build_owner_rollups(rows, actions): + groups = {} + for row in rows: + owner = str(row.get("manager_owner") or row.get("user") or "unassigned").strip() or "unassigned" + group = groups.setdefault(owner, _empty_rollup(owner)) + group["users_count"] += 1 + if row.get("status") == "inactive": + group["inactive_users"] += 1 + else: + group["active_users"] += 1 + if row.get("status") == "below_target": + group["below_target_users"] += 1 + group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) + group["users"].append(row.get("user", "unknown")) + + for action in actions: + owner = str(action.get("owner") or "unassigned").strip() or "unassigned" + group = groups.setdefault(owner, _empty_rollup(owner)) + group["actions_count"] += 1 + prio = str(action.get("priority") or "").lower() + if prio == "critical": + group["critical_actions_count"] += 1 + elif prio == "high": + group["high_actions_count"] += 1 + elif prio == "medium": + group["medium_actions_count"] += 1 + elif prio == "low": + group["low_actions_count"] += 1 + + for group in groups.values(): + expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 + group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) + group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 + group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) + + return sorted( + groups.values(), + key=lambda item: ( + -item["critical_actions_count"], + -item["high_actions_count"], + -item["inactive_users"], + item["name"].lower(), + ), + ) + + +def build_department_rollups(rows, actions): + groups = {} + for row in rows: + department = str(row.get("department") or "Без подразделения").strip() or "Без подразделения" + group = groups.setdefault(department, _empty_rollup(department)) + group["users_count"] += 1 + if row.get("status") == "inactive": + group["inactive_users"] += 1 + else: + group["active_users"] += 1 + if row.get("status") == "below_target": + group["below_target_users"] += 1 + group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) + group["users"].append(row.get("user", "unknown")) + + for action in actions: + evidence = action.get("evidence") or {} + department = str(evidence.get("department") or ("Инфраструктура" if str(action.get("owner") or "").strip().lower() == "ops" else "Без подразделения")).strip() + group = groups.setdefault(department, _empty_rollup(department)) + group["actions_count"] += 1 + prio = str(action.get("priority") or "").lower() + if prio == "critical": + group["critical_actions_count"] += 1 + elif prio == "high": + group["high_actions_count"] += 1 + elif prio == "medium": + group["medium_actions_count"] += 1 + elif prio == "low": + group["low_actions_count"] += 1 + + for group in groups.values(): + expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 + group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) + group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 + group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) + + return sorted( + groups.values(), + key=lambda item: ( + -item["critical_actions_count"], + -item["high_actions_count"], + -item["inactive_users"], + item["name"].lower(), + ), + ) + + +def build_owner_roster(rows, actions): + owner_rollups = build_owner_rollups(rows, actions) + roster = [] + for item in owner_rollups: + profile = resolve_owner_profile(item["name"]) + roster.append( + { + **item, + "display_name": profile["display_name"], + "title": profile["title"], + "department": profile["department"], + "contact": profile["contact"], + "escalation_to": profile["escalation_to"], + "notes": profile["notes"], + } + ) + return roster + + def _interval_overlap_seconds(intervals, start, end): total = 0 first = None @@ -701,7 +1031,7 @@ def build_source_freshness(host): { "source_id": "file_operations", "label": "File operations collector", - "bucket_candidates": [f"aw-file-operations_{host}", "aw-file-operations_10.10.10.13"], + "bucket_candidates": [f"aw-file-operations_{host}"], "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, "required": True, "owner": "ops", @@ -798,8 +1128,9 @@ def build_source_freshness(host): return sources, actions -def _build_management_core(rows, host, report_date): - calendar_summary = build_report_summary(rows) +def _build_management_core(rows, host, report_date, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) report_bounds = get_report_bounds(report_date) workday = get_workday_bounds(report_date) now_local = datetime.now(REPORT_TZ) @@ -824,11 +1155,15 @@ def _build_management_core(rows, host, report_date): workday_last_values = [] top_workday_user = "" top_workday_seconds = -1 + filtered_rows = [] for row in rows: alias = resolve_user_alias(row.get("user", ""), row.get("user_id", ""), host) if alias["exclude"]: continue + if not _matches_management_filters(alias, owner_filter=owner_filter, department_filter=department_filter): + continue + filtered_rows.append(row) public_row = {key: value for key, value in row.items() if key != "_intervals"} calendar_active_seconds = int(row.get("active_seconds", 0) or 0) intervals = row.get("_intervals") or [] @@ -968,8 +1303,9 @@ def _build_management_core(rows, host, report_date): ) actions.sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) + calendar_summary = build_report_summary(filtered_rows) inactive_users = sum(1 for row in roster if row["status"] == "inactive") - portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(rows))) * 100.0) if rows and expected_seconds_per_user > 0 else 0.0 + portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(roster))) * 100.0) if roster and expected_seconds_per_user > 0 else 0.0 calendar_first = calendar_summary.get("first_activity", "") calendar_last = calendar_summary.get("last_activity", "") return { @@ -977,6 +1313,10 @@ def _build_management_core(rows, host, report_date): "host": resolve_host(host), "report_date": report_date.isoformat(), "report_timezone": str(REPORT_TZ), + "filters": { + "owner": owner_filter, + "department": department_filter, + }, "workday": { "start_local": workday["start_local"].isoformat(), "end_local": workday["end_local"].isoformat(), @@ -1020,13 +1360,13 @@ def _build_management_core(rows, host, report_date): } -def build_management_trend(host, anchor_date): +def build_management_trend(host, anchor_date, owner_filter="", department_filter=""): trend = [] for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): current_date = anchor_date - timedelta(days=offset) bounds, events = fetch_events_for_date(host, current_date) rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - payload = _build_management_core(rows, host, current_date) + payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter) summary = payload["summary"] trend.append( { @@ -1044,18 +1384,57 @@ def build_management_trend(host, anchor_date): return trend -def build_management_payload(rows, host, report_date): - payload = _build_management_core(rows, host, report_date) +def build_filtered_management_trend(host, anchor_date, owner_filter="", department_filter=""): + trend = [] + for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): + current_date = anchor_date - timedelta(days=offset) + base_payload = load_management_cache(host, current_date) + if base_payload is None: + bounds, events = fetch_events_for_date(host, current_date) + rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) + base_payload = _build_management_core(rows, host, current_date) + filtered_payload = apply_management_filters_to_payload( + base_payload, + owner_filter=owner_filter, + department_filter=department_filter, + include_sources=False, + include_source_actions=False, + ) + summary = filtered_payload["summary"] + trend.append( + { + "report_date": current_date.isoformat(), + "users_count": summary["users_count"], + "active_users": summary["active_users"], + "inactive_users": summary["inactive_users"], + "workday_total_active_seconds": summary["workday_total_active_seconds"], + "workday_total_active_hhmm": summary["workday_total_active_hhmm"], + "portfolio_coverage_pct": summary["portfolio_coverage_pct"], + "actions_count": summary["actions_count"], + "critical_actions_count": summary["critical_actions_count"], + } + ) + return trend + + +def build_management_payload(rows, host, report_date, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter) source_freshness, source_actions = build_source_freshness(resolve_host(host)) payload["sources"] = source_freshness - payload["trend"] = build_management_trend(resolve_host(host), report_date) + payload["trend"] = build_management_trend(resolve_host(host), report_date, owner_filter=owner_filter, department_filter=department_filter) + payload["trend_scope"] = "portfolio" if source_actions: - payload["actions"].extend(source_actions) + payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter)) payload["actions"].sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) payload["summary"]["actions_count"] = len(payload["actions"]) payload["summary"]["critical_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "critical") payload["summary"]["high_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "high") payload["executive"] = build_executive_summary(payload["summary"], payload["actions"], payload["sources"]) + payload["owner_rollups"] = build_owner_rollups(payload["rows"], payload["actions"]) + payload["department_rollups"] = build_department_rollups(payload["rows"], payload["actions"]) + payload["owner_roster"] = build_owner_roster(payload["rows"], payload["actions"]) return payload @@ -1064,7 +1443,21 @@ def report_for_date(host, report_date): return aggregate_rows(events, bounds["start"], bounds["end"], host) -def management_report_for_date(host, report_date): +def management_report_for_date(host, report_date, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + if owner_filter or department_filter: + base_payload = management_report_for_date(host, report_date) + filtered_payload = apply_management_filters_to_payload( + base_payload, + owner_filter=owner_filter, + department_filter=department_filter, + include_sources=True, + include_source_actions=True, + ) + filtered_payload["trend"] = [] + filtered_payload["trend_scope"] = "filtered_current_only" + return filtered_payload cached = load_management_cache(host, report_date) if cached is not None: return cached @@ -1392,16 +1785,35 @@ def render_html(rows, host, report_date, selected_day=None): """ +def build_management_report_url(host, report_date, selected_day=None, fmt=None, owner_filter="", department_filter=""): + params = {"host": resolve_host(host)} + if fmt: + params["format"] = fmt + if selected_day in {"today", "yesterday"}: + params["day"] = selected_day + else: + params["date"] = report_date + if normalize_management_filter(owner_filter): + params["owner"] = normalize_management_filter(owner_filter) + if normalize_management_filter(department_filter): + params["department"] = normalize_management_filter(department_filter) + return "/reports/worktime/management?" + urlencode(params) + + def render_management_html(payload, selected_day=None): summary = payload["summary"] workday = payload["workday"] report_date = payload["report_date"] host = payload["host"] executive = payload.get("executive") or {} - today_url = "/reports/worktime/management?" + urlencode({"format": "html", "host": host, "day": "today"}) - yesterday_url = "/reports/worktime/management?" + urlencode({"format": "html", "host": host, "day": "yesterday"}) - json_url = "/reports/worktime/management?" + urlencode({"host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})}) + active_filters = payload.get("filters") or {} + owner_filter = normalize_management_filter(active_filters.get("owner")) + department_filter = normalize_management_filter(active_filters.get("department")) + today_url = build_management_report_url(host, report_date, selected_day="today", fmt="html", owner_filter=owner_filter, department_filter=department_filter) + yesterday_url = build_management_report_url(host, report_date, selected_day="yesterday", fmt="html", owner_filter=owner_filter, department_filter=department_filter) + json_url = build_management_report_url(host, report_date, selected_day=selected_day, owner_filter=owner_filter, department_filter=department_filter) classic_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})}) + reset_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html") actions_html = [] for action in payload["actions"]: actions_html.append( @@ -1452,7 +1864,62 @@ def render_management_html(payload, selected_day=None): "" ) if not trend_html: - trend_html.append("Тренд пока недоступен.") + trend_message = "Тренд по выбранному фильтру пока отключён, чтобы current-scope отчёт отвечал быстро." if (owner_filter or department_filter) else "Тренд пока недоступен." + trend_html.append(f"{html.escape(trend_message)}") + + owner_rollup_html = [] + for item in payload.get("owner_rollups", []): + owner_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=item["name"], department_filter=department_filter) + owner_rollup_html.append( + "" + f"{html.escape(item['name'])}" + f"{item['users_count']}" + f"{item['inactive_users']}" + f"{item['below_target_users']}" + f"{item['critical_actions_count']}" + f"{item['high_actions_count']}" + f"{html.escape(item['workday_total_active_hhmm'])}" + f"{item['portfolio_coverage_pct']}" + f"{html.escape(', '.join(item['users']) if item['users'] else '-')}" + "" + ) + if not owner_rollup_html: + owner_rollup_html.append("Нет данных по ответственным.") + + department_rollup_html = [] + for item in payload.get("department_rollups", []): + department_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=owner_filter, department_filter=item["name"]) + department_rollup_html.append( + "" + f"{html.escape(item['name'])}" + f"{item['users_count']}" + f"{item['inactive_users']}" + f"{item['below_target_users']}" + f"{item['critical_actions_count']}" + f"{item['high_actions_count']}" + f"{html.escape(item['workday_total_active_hhmm'])}" + f"{item['portfolio_coverage_pct']}" + f"{html.escape(', '.join(item['users']) if item['users'] else '-')}" + "" + ) + if not department_rollup_html: + department_rollup_html.append("Нет данных по подразделениям.") + + owner_profile_html = [] + for item in payload.get("owner_roster", []): + owner_profile_html.append( + "
" + f"
0 else ('high' if item['high_actions_count'] > 0 else 'low')}'>{html.escape(item['display_name'])}
" + f"

{html.escape(item['title'] or item['name'])}

" + f"
Подразделение: {html.escape(item['department'] or '-')}
" + f"

Пользователи: {item['users_count']} · inactive: {item['inactive_users']} · actions: {item['actions_count']}

" + f"

Контакт: {html.escape(item['contact'] or '-')}

" + f"

Эскалация: {html.escape(item['escalation_to'] or '-')}

" + f"

{html.escape(item['notes'] or 'Без дополнительных заметок.')}

" + "
" + ) + if not owner_profile_html: + owner_profile_html.append("

Каталог ответственных пуст

Добавьте блок owners в worktime-manager-aliases.json, чтобы отчёт показывал роли, контакты и эскалацию.

") sources_html = [] for source in payload.get("sources", []): @@ -1495,6 +1962,16 @@ def render_management_html(payload, selected_day=None): + "".join(stale_html) + "" ) if stale_html else "" + filter_parts = [] + if owner_filter: + filter_parts.append(f"ответственный: {html.escape(owner_filter)}") + if department_filter: + filter_parts.append(f"подразделение: {html.escape(department_filter)}") + filter_block = ( + "
Фильтр: " + + " · ".join(filter_parts) + + f" Сбросить
" + ) if filter_parts else "" cards = [ ("Пользователи", str(summary["users_count"])), @@ -1661,6 +2138,7 @@ def render_management_html(payload, selected_day=None):
{html.escape(executive.get('headline') or 'Сводка недоступна')}

{html.escape(executive.get('message') or '')}

+ {filter_block} {stale_block}
@@ -1687,6 +2165,54 @@ def render_management_html(payload, selected_day=None): +
+

По ответственным

+ + + + + + + + + + + + + + + + {''.join(owner_rollup_html)} + +
ОтветственныйСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
+
+
+

Ответственные и эскалация

+
+ {''.join(owner_profile_html)} +
+
+
+

По подразделениям

+ + + + + + + + + + + + + + + + {''.join(department_rollup_html)} + +
ПодразделениеСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
+

Очередь действий руководителя

@@ -1751,9 +2277,26 @@ def render_management_html(payload, selected_day=None): """ +def send_bytes(handler, data, content_type, status=200): + handler.send_response(status) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + try: + handler.wfile.write(data) + except (BrokenPipeError, ConnectionResetError): + return False + return True + + class H(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) + if parsed.path in {"/health", "/api/health"}: + data = json.dumps(worktime_health_payload(), ensure_ascii=False, indent=2).encode("utf-8") + send_bytes(self, data, "application/json; charset=utf-8") + return + if parsed.path.startswith("/dlp-ioc/"): name = parsed.path.rsplit("/", 1)[-1] if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}: @@ -1773,11 +2316,7 @@ class H(BaseHTTPRequestHandler): ctype = "text/csv; charset=utf-8" else: ctype = "text/plain; charset=utf-8" - self.send_response(200) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, ctype) return if parsed.path not in {"/reports/worktime/today", "/reports/worktime/management"}: @@ -1794,9 +2333,11 @@ class H(BaseHTTPRequestHandler): host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) day = params.get("day", ["today"])[0] date_text = params.get("date", [None])[0] + owner_filter = normalize_management_filter(params.get("owner", [""])[0]) + department_filter = normalize_management_filter(params.get("department", [""])[0]) report_date = resolve_report_date(day=day, date_text=date_text) is_management = parsed.path == "/reports/worktime/management" - management_payload = management_report_for_date(host, report_date) if is_management else None + management_payload = management_report_for_date(host, report_date, owner_filter=owner_filter, department_filter=department_filter) if is_management else None rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] if fmt == "csv": @@ -1810,11 +2351,7 @@ class H(BaseHTTPRequestHandler): writer.writeheader() writer.writerows(management_payload["actions"]) data = out.getvalue().encode() - self.send_response(200) - self.send_header("Content-Type", "text/csv; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, "text/csv; charset=utf-8") return out = io.StringIO() writer = csv.DictWriter( @@ -1835,11 +2372,7 @@ class H(BaseHTTPRequestHandler): writer.writeheader() writer.writerows(rows) data = out.getvalue().encode() - self.send_response(200) - self.send_header("Content-Type", "text/csv; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, "text/csv; charset=utf-8") return if fmt == "html": @@ -1847,21 +2380,13 @@ class H(BaseHTTPRequestHandler): data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") else: data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, "text/html; charset=utf-8") return if is_management: obj = management_payload data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, "application/json; charset=utf-8") return obj = { @@ -1873,15 +2398,16 @@ class H(BaseHTTPRequestHandler): "rows": rows, } data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, "application/json; charset=utf-8") + + +class WorktimeHTTPServer(ThreadingHTTPServer): + daemon_threads = True + request_queue_size = 64 def main(): - HTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever() + WorktimeHTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever(poll_interval=0.5) if __name__ == "__main__": diff --git a/aw-server/aw-worktime-autoheal.sh b/aw-server/aw-worktime-autoheal.sh index 9189a3c..12270b3 100644 --- a/aw-server/aw-worktime-autoheal.sh +++ b/aw-server/aw-worktime-autoheal.sh @@ -4,7 +4,15 @@ set -euo pipefail AW_URL="${AW_URL:-http://127.0.0.1:5600}" HOST="${AW_WORKTIME_HOST:-SHARKON2025}" PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}" -WORKTIME_REPORT_URL="${WORKTIME_REPORT_URL:-http://127.0.0.1:5610/reports/worktime/today?format=csv}" +WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_URL:-http://127.0.0.1:5610/health}" +WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}" +WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}" +WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}" +WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-25}" +WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}" +WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}" +WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}" +WORKTIME_SESSION_FRESHNESS_SECONDS="${WORKTIME_SESSION_FRESHNESS_SECONDS:-600}" LOG_TAG="aw-worktime-autoheal" log() { @@ -12,38 +20,116 @@ log() { printf '%s %s\n' "$(date '+%F %T')" "$*" } -if ! curl -fsS --max-time 8 "$WORKTIME_REPORT_URL" >/dev/null 2>&1; then - log "worktime API check failed, restarting aw-worktime-api.service" +probe_url() { + local url="$1" + local timeout="$2" + curl -fsS --max-time "$timeout" "$url" >/dev/null 2>&1 +} + +probe_reports() { + probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1 + probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS" || return 1 + if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then + probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1 + fi +} + +worktime_ok=1 +if ! probe_reports; then + log "worktime API probe failed, restarting aw-worktime-api.service" systemctl restart aw-worktime-api.service || true sleep 2 - if ! curl -fsS --max-time 8 "$WORKTIME_REPORT_URL" >/dev/null 2>&1; then - log "worktime API still unavailable after restart" + if ! probe_reports; then + log "worktime API still degraded after restart" + worktime_ok=0 else log "worktime API recovered after restart" fi fi +if [[ "$worktime_ok" != "1" ]]; then + log "skip warm/heal because worktime API is still unavailable" + exit 0 +fi + +if [[ "$WORKTIME_MANAGEMENT_WARM_ENABLED" == "1" ]]; then + if probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS"; then + log "management cache warm ok" + else + log "management cache warm failed" + fi +fi + need_heal="$("$PYTHON_BIN" - <<'PY' import json, urllib.request, datetime, os, sys AW=os.environ.get("AW_URL","http://127.0.0.1:5600") host=os.environ.get("HOST","SHARKON2025") -bucket=f"aw-rdp-window_{host}" +window_bucket=f"aw-rdp-window_{host}" +session_bucket=f"aw-worktime-sessions_{host}" +freshness=int(os.environ.get("WORKTIME_SESSION_FRESHNESS_SECONDS","600")) msk=datetime.timezone(datetime.timedelta(hours=3)) start=datetime.datetime.now(msk).replace(hour=0,minute=0,second=0,microsecond=0).astimezone(datetime.timezone.utc) +now=datetime.datetime.now(datetime.timezone.utc) def p(ts): if ts.endswith("Z"): ts=ts[:-1] + "+00:00" return datetime.datetime.fromisoformat(ts).astimezone(datetime.timezone.utc) +def is_active(d): + if isinstance(d.get("active"), bool) and d.get("active"): + return True + st=str(d.get("state","")).strip().lower() + if st in ("active","активно"): + return True + if st == "unknown": + try: + sid=int(d.get("sessionId")) + except Exception: + sid=-1 + u=str(d.get("username","")).strip().lower() + sn=str(d.get("sessionName","")).strip().lower() + if sid > 0 and u and (not u.endswith("$")) and (sn.startswith("rdp-") or sn == "console"): + return True + return False + try: - with urllib.request.urlopen(f"{AW}/api/0/buckets/{bucket}/events?limit=12000", timeout=25) as r: - ev=json.loads(r.read().decode("utf-8")) + with urllib.request.urlopen(f"{AW}/api/0/buckets/{window_bucket}/events?limit=12000", timeout=25) as r: + window_events=json.loads(r.read().decode("utf-8")) + with urllib.request.urlopen(f"{AW}/api/0/buckets/{session_bucket}/events?limit=12000", timeout=25) as r: + session_events=json.loads(r.read().decode("utf-8")) except Exception: print("1") sys.exit(0) +latest_ts=None +active_users=set() +for e in session_events: + ts=e.get("timestamp") + if not ts: + continue + try: + cur=p(ts) + except Exception: + continue + if latest_ts is None or cur > latest_ts: + latest_ts=cur + active_users.clear() + if cur == latest_ts: + d=e.get("data") or {} + u=str(d.get("username","")).strip() + if u and is_active(d): + active_users.add(u) + +if latest_ts is None or (now - latest_ts).total_seconds() > freshness: + print("0") + sys.exit(0) + +if not active_users: + print("0") + sys.exit(0) + active=0.0 -for e in ev: +for e in window_events: ts=e.get("timestamp") if not ts: continue @@ -85,9 +171,14 @@ def req(method,path,payload=None): if payload is not None: data=json.dumps(payload,ensure_ascii=False).encode("utf-8"); headers["Content-Type"]="application/json" r=urllib.request.Request(AW+path,data=data,headers=headers,method=method) - with urllib.request.urlopen(r,timeout=30) as resp: - body=resp.read() - return json.loads(body.decode("utf-8")) if body else None + try: + with urllib.request.urlopen(r,timeout=30) as resp: + body=resp.read() + return json.loads(body.decode("utf-8")) if body else None + except urllib.error.HTTPError as e: + if e.code in (304, 409): + return None + raise def reset_bucket(bucket_id, event_type, client, hostname): try: diff --git a/aw-server/dlp-case-management/case_service.py b/aw-server/dlp-case-management/case_service.py index c37baf2..8663a9c 100644 --- a/aw-server/dlp-case-management/case_service.py +++ b/aw-server/dlp-case-management/case_service.py @@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware from case_rules import is_self_test_case from case_schema import CaseCommentCreate, CaseCreate, CaseHayabusaLink, CaseUpdate -from case_storage import CaseStorage +from case_storage import CaseStorage, ForensicsHostMismatchError DB = Path(os.environ.get("AW_DLP_CASE_DB_PATH", "/opt/activitywatch/dlp-case-management/cases.db")) APP = FastAPI(title="AWatch DLP Case Management") @@ -85,5 +85,7 @@ def list_comments(case_id: int, limit: int = Query(default=200, ge=1, le=2000)) def link_hayabusa(case_id: int, payload: CaseHayabusaLink) -> dict[str, Any]: try: return STORE.link_hayabusa(case_id=case_id, payload=payload.model_dump(exclude_none=True), actor="api") + except ForensicsHostMismatchError as exc: + raise HTTPException(status_code=409, detail=str(exc)) except KeyError: raise HTTPException(status_code=404, detail="case not found") diff --git a/aw-server/dlp-case-management/case_storage.py b/aw-server/dlp-case-management/case_storage.py index dd84765..3b3f8a4 100644 --- a/aw-server/dlp-case-management/case_storage.py +++ b/aw-server/dlp-case-management/case_storage.py @@ -11,6 +11,10 @@ from typing import Any, Iterator from evidence_chain import evidence_sha256, normalize_evidence_chain +class ForensicsHostMismatchError(ValueError): + pass + + class CaseStorage: def __init__(self, db_path: Path) -> None: self.db_path = db_path @@ -86,6 +90,10 @@ class CaseStorage: def _now() -> str: return datetime.now(timezone.utc).isoformat() + @staticmethod + def _normalize_host(value: Any) -> str: + return str(value or "").strip().lower() + @staticmethod def _load_json_field(raw: Any) -> dict[str, Any] | None: if not raw: @@ -228,6 +236,12 @@ class CaseStorage: now = self._now() with self.conn() as c: existing = self.get_case(case_id, c) + case_host = self._normalize_host(existing.get("host")) + forensic_host = self._normalize_host(payload.get("host")) + if case_host and forensic_host and case_host != forensic_host: + raise ForensicsHostMismatchError( + f"hayabusa host mismatch: case host={existing.get('host')} payload host={payload.get('host')}" + ) forensics = existing.get("forensics") or {} forensics["hayabusa"] = { "tool": "hayabusa", diff --git a/aw-server/dlp-case-management/test_case_storage.py b/aw-server/dlp-case-management/test_case_storage.py index 82a3d56..faf6f72 100644 --- a/aw-server/dlp-case-management/test_case_storage.py +++ b/aw-server/dlp-case-management/test_case_storage.py @@ -5,7 +5,7 @@ import tempfile import unittest from pathlib import Path -from case_storage import CaseStorage +from case_storage import CaseStorage, ForensicsHostMismatchError class CaseStorageHayabusaLinkTest(unittest.TestCase): @@ -62,6 +62,32 @@ class CaseStorageHayabusaLinkTest(unittest.TestCase): cases = storage.list_cases(host="SHARKON2025", limit=10) self.assertEqual(len(cases), 1) + def test_link_hayabusa_rejects_host_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "cases.db" + storage = CaseStorage(db_path) + created = storage.create_case( + { + "incident_id": "inc-2", + "host": "SHARKON2025", + "title": "DLP print incident", + "severity": "high", + }, + actor="test", + ) + with self.assertRaises(ForensicsHostMismatchError): + storage.link_hayabusa( + case_id=int(created["id"]), + payload={ + "host": "stability", + "mode": "incident", + "status": "ok", + "intake_id": "pkg-2", + "report_dir": "/opt/hayabusa/reports/stability/run-1", + }, + actor="test", + ) + if __name__ == "__main__": unittest.main() diff --git a/aw-server/dlp-compliance/report_generator.py b/aw-server/dlp-compliance/report_generator.py index 9e8fad7..c7b6905 100644 --- a/aw-server/dlp-compliance/report_generator.py +++ b/aw-server/dlp-compliance/report_generator.py @@ -16,7 +16,14 @@ def _env(name: str, default: str) -> str: return value if value not in (None, "") else default -AW_API_BASE = _env("AW_SERVER_URL", "http://127.0.0.1:5600/api/0").rstrip("/") +def build_aw_api_base(raw_url: str | None) -> str: + url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/") + if url.endswith("/api/0"): + return url + return url + "/api/0" + + +AW_API_BASE = build_aw_api_base(_env("AW_DLP_AW_API_BASE", _env("AW_SERVER_URL", "http://127.0.0.1:5600"))) OUTPUT_DIR = Path(_env("AW_DLP_COMPLIANCE_REPORT_DIR", "/opt/activitywatch/dlp-compliance/reports")) BASE_DIR = Path(__file__).resolve().parent PROFILE_TEMPLATE_MAP = { diff --git a/aw-server/hayabusa/aw-hayabusa-link-case.py b/aw-server/hayabusa/aw-hayabusa-link-case.py index 5853ade..1cb8d08 100644 --- a/aw-server/hayabusa/aw-hayabusa-link-case.py +++ b/aw-server/hayabusa/aw-hayabusa-link-case.py @@ -7,6 +7,10 @@ import urllib.error import urllib.request +def normalize_host(value): + return str(value or '').strip().lower() + + def build_payload(intake, mode, link_source): report_dir = pathlib.Path(intake['report_dir']) return { @@ -50,6 +54,13 @@ def main(): if not intake_path.is_file(): raise SystemExit(f'intake json not found: {intake_path}') intake = json.loads(intake_path.read_text(encoding='utf-8')) + case = get_json(f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}") + case_host = normalize_host(case.get('host')) + intake_host = normalize_host(intake.get('host')) + if case_host and intake_host and case_host != intake_host: + raise SystemExit( + f"hayabusa host mismatch: case host={case.get('host')} intake host={intake.get('host')}" + ) payload = build_payload(intake, args.mode, args.link_source) case_url = f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}/forensics/hayabusa" try: diff --git a/aw-server/health-check.sh b/aw-server/health-check.sh index 76042f4..5fba2e8 100644 --- a/aw-server/health-check.sh +++ b/aw-server/health-check.sh @@ -13,6 +13,10 @@ if [[ -f /etc/activitywatch/aw-server.env ]]; then source /etc/activitywatch/aw-server.env fi +WORKTIME_HEALTH_BASE="${AW_RUS_HEALTH_WORKTIME_API:-${AW_WORKTIME_REPORT_BASE:-http://127.0.0.1:5610}}" +WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_BASE%/}/health" +WORKTIME_HEALTH_TIMEOUT_SECONDS="${AW_RUS_HEALTH_WORKTIME_TIMEOUT_SECONDS:-15}" + check_service() { local service=$1 if [[ "$service" == "aw-worktime-ui-bridge" ]]; then @@ -35,13 +39,22 @@ check_service() { check_api_endpoint() { local url=$1 local service_name=$2 - - if curl -s --max-time 5 "$url" >/dev/null 2>&1; then - echo "✓ $service_name API endpoint is responding" - else - echo "✗ $service_name API endpoint is not responding" - UNHEALTHY_SERVICES+=("$service_name-api") - fi + local timeout_seconds=${3:-5} + local attempts=${4:-1} + local attempt + + for ((attempt=1; attempt<=attempts; attempt++)); do + if curl -fsS --connect-timeout 3 --max-time "$timeout_seconds" "$url" >/dev/null 2>&1; then + echo "✓ $service_name API endpoint is responding" + return + fi + if (( attempt < attempts )); then + sleep 1 + fi + done + + echo "✗ $service_name API endpoint is not responding" + UNHEALTHY_SERVICES+=("$service_name-api") } read_setting_value() { @@ -127,7 +140,7 @@ echo # Check API endpoints check_api_endpoint "http://127.0.0.1:5600/api/0/info" "activitywatch-server" -check_api_endpoint "http://127.0.0.1:5610/reports/worktime/today" "aw-worktime-api" +check_api_endpoint "$WORKTIME_HEALTH_URL" "aw-worktime-api" "$WORKTIME_HEALTH_TIMEOUT_SECONDS" 2 check_dlp_transport_freshness "http://127.0.0.1:5600/api/0" "900" "${AW_HEALTH_STRICT_FILEOPS:-0}" check_expected_setting "startOfDay" "${AW_EXPECT_START_OF_DAY:-}" "startOfDay" check_expected_setting "always_active_pattern" "${AW_EXPECT_ALWAYS_ACTIVE_PATTERN:-}" "always_active_pattern" diff --git a/aw-server/test_aw_dlp_report_generator.py b/aw-server/test_aw_dlp_report_generator.py new file mode 100644 index 0000000..214abaf --- /dev/null +++ b/aw-server/test_aw_dlp_report_generator.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +import importlib.util +import os +import sys +from pathlib import Path + + +MODULE_PATH = Path(__file__).parent / "dlp-compliance" / "report_generator.py" + + +def load_module(name: str): + spec = importlib.util.spec_from_file_location(name, MODULE_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +MODULE = load_module("aw_dlp_report_generator") + + +def test_build_aw_api_base_accepts_root_and_api_urls(): + assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0" + assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0" + assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0" + + +def test_aw_dlp_api_base_env_takes_precedence(monkeypatch): + monkeypatch.setenv("AW_SERVER_URL", "http://127.0.0.1:5600") + monkeypatch.setenv("AW_DLP_AW_API_BASE", "http://127.0.0.1:5600/api/0") + module = load_module("aw_dlp_report_generator_env_precedence") + assert module.AW_API_BASE == "http://127.0.0.1:5600/api/0" + + +def test_aw_server_url_is_normalized_when_dlp_api_base_is_missing(monkeypatch): + monkeypatch.setenv("AW_SERVER_URL", "http://127.0.0.1:5600") + monkeypatch.delenv("AW_DLP_AW_API_BASE", raising=False) + module = load_module("aw_dlp_report_generator_server_url_fallback") + assert module.AW_API_BASE == "http://127.0.0.1:5600/api/0" diff --git a/aw-server/test_aw_rus_healthd.py b/aw-server/test_aw_rus_healthd.py index c679602..211e3e5 100644 --- a/aw-server/test_aw_rus_healthd.py +++ b/aw-server/test_aw_rus_healthd.py @@ -36,3 +36,25 @@ def test_bucket_health_reports_missing_events_without_crash(monkeypatch): assert status == "warn" assert summary == "no events" assert details["bucket"] == "aw-watcher-window_SHARKON2025" + + +def test_bucket_health_marks_old_session_events_as_event_driven(monkeypatch): + monkeypatch.setattr( + MODULE, + "latest_bucket_event", + lambda api_base, bucket_id: {"timestamp": "2026-05-22T15:53:08Z"}, + ) + monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-24T01:46:25Z")) + status, summary, details = MODULE.bucket_health( + "http://127.0.0.1:5600/api/0", + "aw-session-events_SHARKON2025", + 86400, + missing_status="fail", + stale_status="warn", + ) + if status == "warn" and details.get("age_seconds") is not None: + status = "ok" + summary = f"event-driven ({details['age_seconds']}s since last logon marker)" + assert status == "ok" + assert summary.startswith("event-driven (") + assert details["age_seconds"] > 86400 diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py index e2ca395..ac6210e 100644 --- a/aw-server/test_aw_worktime_api.py +++ b/aw-server/test_aw_worktime_api.py @@ -10,6 +10,34 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) +class _FakeWriter: + def __init__(self, exc=None): + self.exc = exc + self.data = b"" + + def write(self, data): + if self.exc is not None: + raise self.exc + self.data += data + + +class _FakeHandler: + def __init__(self, exc=None): + self.wfile = _FakeWriter(exc=exc) + self.status = None + self.headers = {} + self.ended = False + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.headers[key] = value + + def end_headers(self): + self.ended = True + + def _event(ts, username, session_id, active, **extra): data = { "username": username, @@ -73,6 +101,15 @@ def test_build_aw_api_base_accepts_root_and_api_urls(): assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0" +def test_worktime_health_payload_is_lightweight_and_ok(): + payload = MODULE.worktime_health_payload() + assert payload["ok"] is True + assert payload["default_host"] == MODULE.DEFAULT_HOST + assert payload["aw_api_base"] == MODULE.AW + assert payload["report_timezone"] == str(MODULE.REPORT_TZ) + assert payload["generated_at_utc"].endswith("Z") + + def test_aggregate_hourly_rows_splits_interval_by_local_hour(): start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc) end = datetime(2026, 5, 14, 8, 59, 59, tzinfo=timezone.utc) @@ -86,7 +123,7 @@ def test_aggregate_hourly_rows_splits_interval_by_local_hour(): "SHARKON2025", ) assert [row["hour_local"] for row in rows] == ["09:00", "10:00"] - assert [row["active_seconds"] for row in rows] == [600, 3000] + assert [row["active_seconds"] for row in rows] == [300, 300] def test_build_management_payload_creates_actions_for_missing_and_late_users(): @@ -157,14 +194,17 @@ def test_build_management_payload_uses_workday_window_not_midnight_activity(): payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) roster = payload["rows"][0] assert roster["calendar_active_hhmm"] == "11:00" - assert roster["workday_active_hhmm"] == "02:00" - assert roster["coverage_pct"] == 22.22 + assert roster["workday_active_hhmm"] == "05:00" + assert roster["coverage_pct"] == 55.56 assert payload["summary"]["calendar_total_active_hhmm"] == "11:00" - assert payload["summary"]["workday_total_active_hhmm"] == "02:00" + assert payload["summary"]["workday_total_active_hhmm"] == "05:00" def test_build_management_payload_applies_alias_and_executive_summary(): original = MODULE.load_manager_aliases + original_owners = MODULE.load_manager_owners + original_sources = MODULE.build_source_freshness + original_trend = MODULE.build_management_trend try: MODULE.load_manager_aliases = lambda: { "sharkon2025\\user1": { @@ -174,6 +214,18 @@ def test_build_management_payload_applies_alias_and_executive_summary(): "role": "Оператор 1С", } } + MODULE.load_manager_owners = lambda: { + "руководитель смены": { + "display_name": "Сменный руководитель", + "title": "Руководитель смены 1С", + "department": "Операторы 1С", + "contact": "@shift-lead", + "escalation_to": "Финансовый директор", + "notes": "Дневной контур", + } + } + MODULE.build_source_freshness = lambda host: ([], []) + MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="": [] rows = [ { "user": "user1", @@ -197,8 +249,97 @@ def test_build_management_payload_applies_alias_and_executive_summary(): assert payload["actions"][0]["owner"] == "Руководитель смены" assert payload["executive"]["portfolio_state"] == "critical" assert payload["executive"]["focus_items"][0]["owner"] == "Руководитель смены" + assert payload["owner_rollups"][0]["name"] == "Руководитель смены" + assert payload["owner_rollups"][0]["critical_actions_count"] >= 1 + assert payload["department_rollups"][0]["name"] == "Бухгалтерия" + assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель" + assert payload["owner_roster"][0]["contact"] == "@shift-lead" finally: MODULE.load_manager_aliases = original + MODULE.load_manager_owners = original_owners + MODULE.build_source_freshness = original_sources + MODULE.build_management_trend = original_trend + + +def test_build_management_payload_filters_by_owner(): + original_aliases = MODULE.load_manager_aliases + original_owners = MODULE.load_manager_owners + original_trend = MODULE.build_management_trend + original_sources = MODULE.build_source_freshness + try: + MODULE.load_manager_aliases = lambda: { + "sharkon2025\\user1": { + "display_name": "Иван Петров", + "manager": "Руководитель смены", + "department": "Бухгалтерия", + "role": "Оператор 1С", + }, + "sharkon2025\\user2": { + "display_name": "Мария Соколова", + "manager": "Финансовый директор", + "department": "Финансы", + "role": "Главбух", + }, + } + MODULE.load_manager_owners = lambda: { + "руководитель смены": {"display_name": "Сменный руководитель", "title": "Руководитель смены 1С"} + } + MODULE.build_management_trend = lambda *args, **kwargs: [] + MODULE.build_source_freshness = lambda host: ([], []) + rows = [ + { + "user": "user1", + "user_id": "SHARKON2025\\user1", + "active_seconds": 0, + "active_hhmm": "00:00", + "first_activity": "", + "last_activity": "", + "idle_seconds": 86400, + "sessions_count": 1, + "samples_count": 10, + "active_samples": 0, + "_intervals": [], + }, + { + "user": "user2", + "user_id": "SHARKON2025\\user2", + "active_seconds": 3600, + "active_hhmm": "01:00", + "first_activity": "2026-05-14T07:00:00Z", + "last_activity": "2026-05-14T08:00:00Z", + "idle_seconds": 82800, + "sessions_count": 1, + "samples_count": 120, + "active_samples": 120, + "_intervals": [ + ( + datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 14, 8, 0, 0, tzinfo=timezone.utc), + ) + ], + }, + ] + payload = MODULE.build_management_payload( + rows, + "SHARKON2025", + datetime(2026, 5, 14, tzinfo=timezone.utc).date(), + owner_filter="Руководитель смены", + ) + assert payload["filters"]["owner"] == "Руководитель смены" + assert payload["filters"]["department"] == "" + assert payload["summary"]["users_count"] == 1 + assert [row["user"] for row in payload["rows"]] == ["Иван Петров"] + assert len(payload["owner_rollups"]) == 1 + assert payload["owner_rollups"][0]["name"] == "Руководитель смены" + assert len(payload["department_rollups"]) == 1 + assert payload["department_rollups"][0]["name"] == "Бухгалтерия" + assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель" + assert all(action["owner"] == "Руководитель смены" for action in payload["actions"]) + finally: + MODULE.load_manager_aliases = original_aliases + MODULE.load_manager_owners = original_owners + MODULE.build_management_trend = original_trend + MODULE.build_source_freshness = original_sources def test_render_management_html_contains_action_queue(): @@ -207,6 +348,10 @@ def test_render_management_html_contains_action_queue(): "host": "SHARKON2025", "report_date": "2026-05-14", "report_timezone": "Europe/Moscow", + "filters": { + "owner": "Руководитель смены", + "department": "Бухгалтерия", + }, "workday": { "start_local": "2026-05-14T09:00:00+03:00", "end_local": "2026-05-14T18:00:00+03:00", @@ -280,6 +425,66 @@ def test_render_management_html_contains_action_queue(): ], "stale_sources": [], }, + "owner_rollups": [ + { + "name": "Руководитель смены", + "users_count": 1, + "active_users": 0, + "inactive_users": 1, + "below_target_users": 0, + "workday_total_active_seconds": 0, + "workday_total_active_hhmm": "00:00", + "portfolio_coverage_pct": 0.0, + "actions_count": 1, + "critical_actions_count": 1, + "high_actions_count": 0, + "medium_actions_count": 0, + "low_actions_count": 0, + "users": ["Иван Петров"], + } + ], + "department_rollups": [ + { + "name": "Бухгалтерия", + "users_count": 1, + "active_users": 0, + "inactive_users": 1, + "below_target_users": 0, + "workday_total_active_seconds": 0, + "workday_total_active_hhmm": "00:00", + "portfolio_coverage_pct": 0.0, + "actions_count": 1, + "critical_actions_count": 1, + "high_actions_count": 0, + "medium_actions_count": 0, + "low_actions_count": 0, + "users": ["Иван Петров"], + } + ], + "owner_roster": [ + { + "name": "Руководитель смены", + "display_name": "Сменный руководитель", + "title": "Руководитель смены 1С", + "department": "Операторы 1С", + "contact": "@shift-lead", + "escalation_to": "Финансовый директор", + "notes": "Дневной контур", + "users_count": 1, + "active_users": 0, + "inactive_users": 1, + "below_target_users": 0, + "workday_total_active_seconds": 0, + "workday_total_active_hhmm": "00:00", + "portfolio_coverage_pct": 0.0, + "actions_count": 1, + "critical_actions_count": 1, + "high_actions_count": 0, + "medium_actions_count": 0, + "low_actions_count": 0, + "users": ["Иван Петров"] + } + ], "trend": [ { "report_date": "2026-05-14", @@ -316,22 +521,24 @@ def test_render_management_html_contains_action_queue(): assert "Очередь действий руководителя" in html assert "missing_activity" in html assert "Тренд за" in html + assert "По ответственным" in html + assert "Ответственные и эскалация" in html + assert "По подразделениям" in html assert "Свежесть источников данных" in html assert "Что делать сегодня" in html + assert "Фильтр:" in html + assert "Сбросить" in html + assert "@shift-lead" in html assert "Иван Петров" in html assert "Руководитель смены" in html -def test_build_source_freshness_uses_freshest_candidate_bucket(): +def test_build_source_freshness_uses_host_fileops_bucket_only(): original = MODULE.latest_bucket_event + original_now_utc = MODULE.now_utc try: def fake_latest(bucket_id): if bucket_id == "aw-file-operations_SHARKON2025": - return { - "timestamp": "2026-05-14T12:00:00Z", - "data": {"signalType": "collector_health", "queueDepth": 9, "sendFailures": 3, "eventsFlushed": 10}, - } - if bucket_id == "aw-file-operations_10.10.10.13": return { "timestamp": "2026-05-23T12:00:00Z", "data": {"signalType": "collector_health", "queueDepth": 0, "sendFailures": 0, "eventsFlushed": 50}, @@ -339,10 +546,31 @@ def test_build_source_freshness_uses_freshest_candidate_bucket(): return None MODULE.latest_bucket_event = fake_latest + MODULE.now_utc = lambda: datetime(2026, 5, 23, 12, 5, 0, tzinfo=timezone.utc) sources, actions = MODULE.build_source_freshness("SHARKON2025") file_source = next(source for source in sources if source["source_id"] == "file_operations") - assert file_source["bucket_id"] == "aw-file-operations_10.10.10.13" + assert file_source["bucket_id"] == "aw-file-operations_SHARKON2025" assert file_source["status"] == "ok" assert not any(action["evidence"].get("source_id") == "file_operations" for action in actions) finally: MODULE.latest_bucket_event = original + MODULE.now_utc = original_now_utc + + +def test_send_bytes_returns_false_on_broken_pipe(): + handler = _FakeHandler(exc=BrokenPipeError()) + ok = MODULE.send_bytes(handler, b"{}", "application/json; charset=utf-8") + assert ok is False + assert handler.status == 200 + assert handler.headers["Content-Type"] == "application/json; charset=utf-8" + assert handler.headers["Content-Length"] == "2" + assert handler.ended is True + + +def test_send_bytes_writes_payload_when_client_is_connected(): + handler = _FakeHandler() + ok = MODULE.send_bytes(handler, b"payload", "text/plain; charset=utf-8", status=201) + assert ok is True + assert handler.status == 201 + assert handler.headers["Content-Length"] == "7" + assert handler.wfile.data == b"payload" diff --git a/aw-server/worktime-manager-aliases.example.json b/aw-server/worktime-manager-aliases.example.json index b86ec3b..6ffc702 100644 --- a/aw-server/worktime-manager-aliases.example.json +++ b/aw-server/worktime-manager-aliases.example.json @@ -22,5 +22,23 @@ "role": "Системный администратор", "exclude": false } + }, + "owners": { + "Руководитель смены": { + "display_name": "Сменный руководитель", + "title": "Руководитель смены 1С", + "department": "Операторы 1С", + "contact": "@shift-lead", + "escalation_to": "Финансовый директор", + "notes": "Первый управленческий контур по очереди действий операторов 1С." + }, + "Ops": { + "display_name": "Ops", + "title": "Инфраструктурный владелец", + "department": "Инфраструктура", + "contact": "@ops", + "escalation_to": "ИТ-руководитель", + "notes": "Отвечает за collectors, RDP bridge и server-side freshness." + } } } diff --git a/check-aw-data.sh b/check-aw-data.sh index 7099697..d3bd7b4 100644 --- a/check-aw-data.sh +++ b/check-aw-data.sh @@ -7,6 +7,30 @@ SERVER="http://10.10.10.13:5600" HOSTNAME_FILTER="SHARKON2025" NOW=$(date -u +%s) +classify_bucket_age() { + local bucket="$1" + local age_sec="$2" + + case "$bucket" in + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + if [ "$age_sec" -lt 86400 ]; then + printf '%s' "${GREEN}FRESH${NC}" + else + printf '%s' "${CYAN}EVENT-DRIVEN${NC}" + fi + ;; + *) + if [ "$age_sec" -lt 3600 ]; then + printf '%s' "${GREEN}FRESH${NC}" + elif [ "$age_sec" -lt 86400 ]; then + printf '%s' "${YELLOW}STALE${NC}" + else + printf '%s' "${RED}DEAD${NC}" + fi + ;; + esac +} + # Цвета RED='\033[0;31m' GREEN='\033[0;32m' @@ -58,14 +82,12 @@ for bucket in "${BUCKETS[@]}"; do AGE_SEC=$((NOW - EVENT_EPOCH)) if [ $AGE_SEC -lt 3600 ]; then AGE="$((AGE_SEC / 60))m ago" - STATUS="${GREEN}FRESH${NC}" elif [ $AGE_SEC -lt 86400 ]; then AGE="$((AGE_SEC / 3600))h ago" - STATUS="${YELLOW}STALE${NC}" else AGE="$((AGE_SEC / 86400))d ago" - STATUS="${RED}DEAD${NC}" fi + STATUS="$(classify_bucket_age "$bucket" "$AGE_SEC")" else AGE="unknown" STATUS="${RED}?${NC}" diff --git a/check-aw-full.sh b/check-aw-full.sh index e24d83e..56e5963 100644 --- a/check-aw-full.sh +++ b/check-aw-full.sh @@ -8,6 +8,30 @@ HOSTNAME_FILTER="SHARKON2025" RDP_HOST="192.168.100.18" NOW=$(date -u +%s) +classify_bucket_age() { + local bucket="$1" + local age_sec="$2" + + case "$bucket" in + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + if [ "$age_sec" -lt 86400 ]; then + printf 'FRESH|%s' "${GREEN}FRESH${NC}" + else + printf 'EVENT|%s' "${CYAN}EVENT-DRIVEN${NC}" + fi + ;; + *) + if [ "$age_sec" -lt 3600 ]; then + printf 'FRESH|%s' "${GREEN}FRESH${NC}" + elif [ "$age_sec" -lt 86400 ]; then + printf 'STALE|%s' "${YELLOW}STALE${NC}" + else + printf 'DEAD|%s' "${RED}DEAD${NC}" + fi + ;; + esac +} + # Цвета RED='\033[0;31m' GREEN='\033[0;32m' @@ -70,22 +94,24 @@ for entry in "${BUCKETS[@]}"; do AGE_SEC=$((NOW - EVENT_EPOCH)) if [ $AGE_SEC -lt 3600 ]; then AGE="$((AGE_SEC / 60))m" - STATUS="${GREEN}FRESH${NC}" elif [ $AGE_SEC -lt 86400 ]; then AGE="$((AGE_SEC / 3600))h" - STATUS="${YELLOW}STALE${NC}" else AGE="$((AGE_SEC / 86400))d" - STATUS="${RED}DEAD${NC}" fi + CLASSIFICATION="$(classify_bucket_age "$bucket" "$AGE_SEC")" + STATUS_KEY="${CLASSIFICATION%%|*}" + STATUS="${CLASSIFICATION#*|}" else AGE="?" STATUS="${RED}?${NC}" + STATUS_KEY="UNKNOWN" fi else AGE="none" LAST_ID="0" STATUS="${RED}EMPTY${NC}" + STATUS_KEY="DEAD" fi printf " %-42s %-8s %-20s %b\n" "$label" "$LAST_ID" "$AGE" "$STATUS" @@ -128,16 +154,29 @@ for entry in "${BUCKETS[@]}"; do EVENT_EPOCH=$(date -d "$LAST_TS" +%s 2>/dev/null || echo 0) if [ "$EVENT_EPOCH" -gt 0 ]; then AGE_SEC=$((NOW - EVENT_EPOCH)) - if [ $AGE_SEC -lt 3600 ]; then - FRESH_COUNT=$((FRESH_COUNT + 1)) - elif [ $AGE_SEC -lt 86400 ]; then - STALE_COUNT=$((STALE_COUNT + 1)) - else - DEAD_COUNT=$((DEAD_COUNT + 1)) - fi + CLASSIFICATION="$(classify_bucket_age "$bucket" "$AGE_SEC")" + STATUS_KEY="${CLASSIFICATION%%|*}" + case "$STATUS_KEY" in + FRESH|EVENT) + FRESH_COUNT=$((FRESH_COUNT + 1)) + ;; + STALE) + STALE_COUNT=$((STALE_COUNT + 1)) + ;; + *) + DEAD_COUNT=$((DEAD_COUNT + 1)) + ;; + esac fi else - DEAD_COUNT=$((DEAD_COUNT + 1)) + case "$bucket" in + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + STALE_COUNT=$((STALE_COUNT + 1)) + ;; + *) + DEAD_COUNT=$((DEAD_COUNT + 1)) + ;; + esac fi done diff --git a/clickhouse-1c/.env.example b/clickhouse-1c/.env.example index a3d17ac..884a039 100644 --- a/clickhouse-1c/.env.example +++ b/clickhouse-1c/.env.example @@ -3,13 +3,11 @@ CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=change-me CLICKHOUSE_PORT=8123 CLICKHOUSE_NATIVE_PORT=9000 -GRAFANA_ADMIN_USER=admin -GRAFANA_ADMIN_PASSWORD=change-me -GRAFANA_PORT=3300 CLICKHOUSE_HOST=clickhouse AW_1C_CLICKHOUSE_RUNTIME_HOST=127.0.0.1 AW_1C_COMPANY_API_HOST=127.0.0.1 AW_1C_COMPANY_API_PORT=8710 +AW_1C_MANAGER_BRIEF_GRAFANA_URL=http://10.10.10.11:3000/dashboards/f/file-1c/?orgId=1 AW_1C_COMPANY_LOOKBACK_DAYS=30 AW_1C_COMPANY_MIN_DAYS=1 AW_1C_COMPANY_HORIZONS=7,30 diff --git a/clickhouse-1c/README.md b/clickhouse-1c/README.md index 24ebc5d..d777be3 100644 --- a/clickhouse-1c/README.md +++ b/clickhouse-1c/README.md @@ -48,6 +48,8 @@ File 1C + reglog + host telemetry - `etl/load_1c_exports.py` — loader CSV/JSON выгрузок в raw/core таблицы. - `etl/build_business_event_exports.py` — read-only normalizer из `documents/postings/audit` в canonical `business_events/document_changes`. +- `etl/extract_1c_mcp_toolkit.py` — read-only extractor из + `1c-mcp-toolkit` REST API в `landing/*`. - `etl/config.example.yml` — пример ETL-конфига. - `docs/1C_BUSINESS_EVENT_LAYER_RU.md` — production contract следующего шага: canonical business-event слой для документов/проводок/изменений. @@ -57,6 +59,9 @@ File 1C + reglog + host telemetry - `grafana/query-pack.sql` — базовые SQL-запросы для панелей. - `grafana/provisioning/datasources/clickhouse.yml` — provisioned datasource для Grafana. - `grafana/provisioning/dashboards/files/1c-company-intelligence.json` — source dashboard для анализа и прогноза по компаниям. +- `grafana/provisioning/dashboards/files/1c-management-board.json` — management dashboard для руководителя с links на brief/actions/digest/recovery. +- `grafana/provisioning/dashboards/files/1c-financial-reporting.json` — первый financial board с разделением `ledger` и `proxy`. +- `grafana/provisioning/dashboards/files/1c-telemetry-board.json` — telemetry dashboard по состоянию файловых баз, reglog growth, busy markers и host load. - `detections/build_entity_timeline.sql` — сборка единого timeline слоя. - `detections/open_cases_from_detections.sql` — шаблон открытия cases из detections. - `ops/etl-cron.example` — пример расписания каждые 6 часов. @@ -111,6 +116,9 @@ cp etl/config.example.yml etl/config.yml python3 -m venv .venv . .venv/bin/activate pip install -r etl/requirements.txt +python etl/extract_1c_mcp_toolkit.py --config etl/config.yml --validate-config +python etl/extract_1c_mcp_toolkit.py --config etl/config.yml --dataset documents --dry-run +python etl/extract_1c_mcp_toolkit.py --config etl/config.yml python etl/build_business_event_exports.py --config etl/config.yml python etl/load_1c_exports.py --config etl/config.yml ``` @@ -169,6 +177,8 @@ Read-only API для руководителя: - журнал регистрации 1С; - audit/export критичных изменений; - host telemetry с Windows/RDP host. +- `1c-mcp-toolkit` REST API (`execute_query` + `get_event_log`) в + строго read-only режиме. - для company intelligence в file-based Detmir контуре `counterparty` наполняется read-only telemetry слоем как `counterparty = infobase`, а `amount` используется как интегральный `activity score`. @@ -204,5 +214,50 @@ Read-only API для руководителя: `landing/document_changes`; - built-in normalizer уже умеет собирать этот слой из существующих read-only выгрузок `documents/postings/audit`; +- extractor scaffold уже умеет забирать read-only snapshots/events через + `1c-mcp-toolkit` и писать их в те же `landing/*`; - дальше нужен только более богатый extractor из 1С или внешних безопасных выгрузок, если нужна большая бухгалтерская детализация. +- в `05_financial_reporting.sql` уже заложены первые financial marts и board, + которые честно показывают `proxy_only`, пока настоящие `postings` ещё не + поступают в live-ingest. + +## `1c-mcp-toolkit` extractor + +Этот extractor нужен не вместо ClickHouse ETL, а перед ним: + +```text +1C + 1c-mcp-toolkit REST + ↓ +etl/extract_1c_mcp_toolkit.py + ↓ +landing/{documents,postings,business_events,document_changes,companies,reglog} + ↓ +etl/build_business_event_exports.py + ↓ +etl/load_1c_exports.py +``` + +Что он умеет: + +- читать `execute_query` для `companies`, `documents`, `postings`, + `business_events`, `document_changes`; +- читать `get_event_log` для `reglog`; +- вести локальный checkpoint в `state/1c-mcp-toolkit/extract_state.json`; +- поддерживать `channel` isolation; +- работать только по read-only endpoint'ам без `execute_code`. +- включаться в общий ingest-cycle через + `AW_1C_MCP_TOOLKIT_EXTRACT_BEFORE_INGEST=1`. +- перед live rollout поддерживает: + - `--validate-config` + - `--dry-run` + - `--dataset ` + +Минимальный production-контракт: + +- в 1С-запросах желательно сразу алиасить колонки под поля landing-схемы; +- если это неудобно, использовать `field_map` в `mcp_toolkit.datasets.*`; +- для incremental-query datasets задавать `incremental.since_param` / + `incremental.until_param`; +- для `reglog` задавать `event_log.static_fields.infobase`, потому что + `get_event_log` сам по себе не всегда несёт имя базы. diff --git a/clickhouse-1c/ai/company_intelligence_api.py b/clickhouse-1c/ai/company_intelligence_api.py index 66a58d6..f591802 100644 --- a/clickhouse-1c/ai/company_intelligence_api.py +++ b/clickhouse-1c/ai/company_intelligence_api.py @@ -459,7 +459,7 @@ def build_company_recovery_context(summary_payload: dict[str, Any], infobase: st def grafana_company_dashboard_url() -> str: return os.getenv( "AW_1C_MANAGER_BRIEF_GRAFANA_URL", - "http://10.10.10.11:3000/d/1c-file-companies/1c-file-company-intelligence", + "http://10.10.10.11:3000/d/1c-file-mgmt/1c-file-management-board", ) @@ -530,6 +530,8 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: context = payload.get("context", {}) summary = context.get("portfolio_summary", {}) freshness = context.get("freshness", []) + manager_questions = brief.get("manager_questions", []) + management_plan = brief.get("management_plan", []) top_risks = brief.get("top_risks", []) top_forecasts = brief.get("top_forecasts", []) actions = brief.get("actions", []) @@ -542,6 +544,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: weekly_html_url = "/manager/trends/weekly" weekly_digest_url = "/manager/digest/weekly" recovery_html_url = "/manager/recovery" + actions_html_url = "/manager/actions" problematic_1d_url = "/manager/problematic?days=1" problematic_7d_url = "/manager/problematic?days=7" json_url = "/api/1/analytics-1c/manager/brief/latest" @@ -583,6 +586,28 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: "" ) + question_cards = [] + for item in manager_questions: + question_cards.append( + "
" + f"

{html.escape(item.get('question', '-'))}

" + f"

{html.escape(item.get('answer', '-'))}

" + f"

Рекомендация: {html.escape(item.get('recommended_action', '-'))}

" + "
" + ) + + horizon_label = {"today": "Сегодня", "week": "Неделя", "30d": "30 дней"} + plan_cards = [] + for item in management_plan: + plan_cards.append( + "
" + f"

{html.escape(item.get('focus', '-'))}

{html.escape(horizon_label.get(str(item.get('horizon')), str(item.get('horizon', '-'))))}
" + f"

Действие: {html.escape(item.get('action', '-'))}

" + f"

Ожидаемый эффект: {html.escape(item.get('expected_effect', '-'))}

" + f"

Метрика: {html.escape(item.get('metric', '-'))}

" + "
" + ) + summary_items = "\n".join(f"
  • {html.escape(str(item))}
  • " for item in brief.get("summary", [])) action_items = "\n".join(f"
  • {html.escape(str(item))}
  • " for item in actions) caveat_items = "\n".join(f"
  • {html.escape(str(item))}
  • " for item in caveats) @@ -870,6 +895,7 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: Неделя Weekly digest AI recovery + Очередь действий Проблемные 1д Проблемные 7д History API @@ -907,6 +933,20 @@ def render_manager_brief_html(payload: dict[str, Any]) -> str: +
    +

    Простые ответы для руководителя

    +
    + {''.join(question_cards) or '

    Нет данных.

    '} +
    +
    + +
    +

    План действий руководителя

    +
    + {''.join(plan_cards) or '

    Нет данных.

    '} +
    +
    +

    Компании риска

    @@ -1041,6 +1081,148 @@ def problematic_companies(days: int = 7, limit: int = 50) -> list[dict[str, Any] return items +def management_actions( + priority_tier: str | None = None, + owner: str | None = None, + limit: int = 100, +) -> list[dict[str, Any]]: + client = ch_client() + where: list[str] = [] + if priority_tier: + where.append(f"priority_tier = {q(priority_tier)}") + if owner: + where.append(f"owner_name = {q(owner)}") + sql = f""" + SELECT + generated_at, + action_id, + infobase, + company_entity_key, + counterparty, + company_name, + organization, + owner_name, + action_type, + priority_tier, + priority_score, + priority_rank, + deadline_hint, + reason, + recommended_action, + evidence_summary, + open_cases_total, + detections_total, + active_locks, + days_since_last_activity, + risky_changes_24h, + large_adjustments_24h, + max_large_adjustment_amount, + last_seen_at + FROM analytics_1c.v_company_management_actions_current + {"WHERE " + " AND ".join(where) if where else ""} + ORDER BY priority_rank DESC, priority_score DESC, counterparty, action_type + LIMIT {int(limit)} + """ + return rows_to_dict(client.query(sql)) + + +def management_company_actions( + priority_tier: str | None = None, + owner: str | None = None, + limit: int = 100, +) -> list[dict[str, Any]]: + client = ch_client() + where: list[str] = [] + if priority_tier: + where.append(f"priority_tier = {q(priority_tier)}") + if owner: + where.append(f"owner_names ILIKE {q('%' + owner + '%')}") + sql = f""" + SELECT + generated_at, + infobase, + company_entity_key, + counterparty, + company_name, + organization, + owner_names, + priority_tier, + priority_rank, + priority_score, + actions_total, + critical_actions_total, + high_actions_total, + medium_actions_total, + low_actions_total, + action_types_summary, + recommended_action_summary, + reason_summary, + evidence_summary, + open_cases_total, + detections_total, + active_locks, + days_since_last_activity, + risky_changes_24h, + large_adjustments_24h, + max_large_adjustment_amount, + last_seen_at + FROM analytics_1c.v_company_management_companies_current + {"WHERE " + " AND ".join(where) if where else ""} + ORDER BY priority_rank DESC, priority_score DESC, counterparty + LIMIT {int(limit)} + """ + return rows_to_dict(client.query(sql)) + + +def company_management_actions(company_ref: str, infobase: str | None = None, limit: int = 20) -> list[dict[str, Any]]: + client = ch_client() + card = resolve_company_portfolio_card(company_ref, infobase) + filters = [f"company_entity_key = {q(str(card.get('company_entity_key') or company_ref))}"] + if card.get("infobase") or infobase: + filters.append(f"infobase = {q(str(card.get('infobase') or infobase))}") + sql = f""" + SELECT + generated_at, + action_id, + infobase, + company_entity_key, + counterparty, + company_name, + organization, + owner_name, + action_type, + priority_tier, + priority_score, + priority_rank, + deadline_hint, + reason, + recommended_action, + evidence_summary, + open_cases_total, + detections_total, + active_locks, + days_since_last_activity, + risky_changes_24h, + large_adjustments_24h, + max_large_adjustment_amount, + last_seen_at + FROM analytics_1c.v_company_management_actions_current + WHERE {' AND '.join(filters)} + ORDER BY priority_rank DESC, priority_score DESC, action_type + LIMIT {int(limit)} + """ + return rows_to_dict(client.query(sql)) + + +def _actions_query_suffix(priority_tier: str | None = None, owner: str | None = None) -> str: + parts: list[str] = [] + if priority_tier: + parts.append(f"priority_tier={quote(priority_tier)}") + if owner: + parts.append(f"owner={quote(owner)}") + return ("?" + "&".join(parts)) if parts else "" + + def render_brief_history_html(items: list[dict[str, Any]]) -> str: rows = [] for item in items: @@ -1089,6 +1271,7 @@ def render_brief_history_html(items: list[dict[str, Any]]) -> str: Что изменилось Неделя AI recovery + Очередь действий Проблемные 1д Проблемные 7д
    @@ -1179,6 +1362,7 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) -> История brief Неделя AI recovery + Очередь действий Срез 1д Срез 7д @@ -1209,6 +1393,195 @@ def render_problematic_companies_html(items: list[dict[str, Any]], days: int) -> """ +def render_management_actions_html( + items: list[dict[str, Any]], + companies: list[dict[str, Any]], + priority_tier: str | None = None, + owner: str | None = None, +) -> str: + rows = [] + counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} + owners: dict[str, int] = {} + for item in items: + tier = str(item.get("priority_tier") or "low").lower() + if tier in counts: + counts[tier] += 1 + owner_name = str(item.get("owner_name") or "не назначен") + owners[owner_name] = owners.get(owner_name, 0) + 1 + rows.append( + "
    " + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + + company_rows = [] + companies_critical = 0 + companies_unassigned = 0 + for item in companies: + tier = str(item.get("priority_tier") or "low").lower() + if tier == "critical": + companies_critical += 1 + owner_names = str(item.get("owner_names") or "не назначен") + if owner_names == "не назначен": + companies_unassigned += 1 + company_rows.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + + top_owners = sorted(owners.items(), key=lambda pair: (-pair[1], pair[0]))[:5] + owner_items = "".join( + f"
  • {html.escape(name)}: {html.escape(fmt_number(total))} действий
  • " + for name, total in top_owners + ) + + filters: list[str] = [] + if priority_tier: + filters.append(f"priority_tier={priority_tier}") + if owner: + filters.append(f"owner={owner}") + filter_line = ", ".join(filters) if filters else "без фильтра" + query_suffix = _actions_query_suffix(priority_tier=priority_tier, owner=owner) + raw_json_url = f"/api/1/analytics-1c/manager/actions{query_suffix}" + companies_json_url = f"/api/1/analytics-1c/manager/actions/companies{query_suffix}" + + return f""" + + + + + + 1C Manager Actions + + + +
    +
    +

    Очередь управленческих действий по 1С

    +

    Детерминированный слой поверх portfolio/signals/business events/document changes. Фильтр: {html.escape(filter_line)}.

    +

    Приоритет считается по риску предприятия. Текущее назначение используется только как operational metadata и не меняет порядок очереди.

    + +
    +
    +
    +

    Срез очереди

    +
    +
    Всего действий
    {html.escape(fmt_number(len(items)))}
    +
    Предприятий
    {html.escape(fmt_number(len(companies)))}
    +
    Critical предприятий
    {html.escape(fmt_number(companies_critical))}
    +
    Без владельца
    {html.escape(fmt_number(companies_unassigned))}
    +
    +
    +
    +

    Текущее назначение

    +
      {owner_items or '
    • Нет назначенных действий.
    • '}
    +
    +
    +

    Что делать по предприятиям

    +
    {severity_badge(str(item.get('priority_tier') or 'low'))}{html.escape(str(item.get('deadline_hint') or '-'))}{html.escape(str(item.get('counterparty') or '-'))}{html.escape(owner_name)}{html.escape(str(item.get('action_type') or '-'))}{html.escape(fmt_number(item.get('priority_score')))}{html.escape(str(item.get('recommended_action') or '-'))}{html.escape(str(item.get('reason') or '-'))}{html.escape(str(item.get('evidence_summary') or '-'))}
    {severity_badge(tier)}{html.escape(str(item.get('counterparty') or '-'))}{html.escape(fmt_number(item.get('actions_total')))}{html.escape(str(item.get('action_types_summary') or '-'))}{html.escape(str(item.get('recommended_action_summary') or '-'))}{html.escape(str(item.get('reason_summary') or '-'))}{html.escape(owner_names)}
    + + + + + + + + + + + + + {''.join(company_rows) or ''} + +
    ПриоритетПредприятиеДействийКонтурЧто сделатьПочемуТекущее назначение
    Нет компаний с активными действиями.
    + +
    +

    Детализация действий

    + + + + + + + + + + + + + + + + {''.join(rows) or ''} + +
    ПриоритетСрокКомпанияОтветственныйТипScoreДействиеПочемуEvidence
    Действий не найдено.
    +
    +
    + + +""" + + def render_brief_delta_html(payload: dict[str, Any]) -> str: delta = extract_delta(payload) brief = payload.get("brief", {}) @@ -1786,6 +2159,11 @@ def render_recovery_brief_html(payload: dict[str, Any]) -> str: app = FastAPI(title="AW-rus 1C Company Intelligence API", version="1.0.0") +@app.get("/") +def root() -> Response: + return Response(status_code=307, headers={"Location": "/manager/brief"}) + + @app.get("/favicon.ico") def favicon() -> Response: return Response(status_code=204) @@ -1810,6 +2188,11 @@ def health() -> dict[str, Any]: return {"status": "ok", "generated_at": datetime.now(UTC).isoformat(), **summary} +@app.get("/api/health") +def api_health() -> dict[str, Any]: + return health() + + @app.get("/api/1/analytics-1c/companies/overview") def companies_overview( infobase: str | None = None, @@ -1916,6 +2299,7 @@ def company_summary(counterparty: str, infobase: str | None = None) -> dict[str, "forecasts": forecasts, "signals": signals, "recent_documents": timeline, + "management_actions": company_management_actions(counterparty, infobase, limit=10), } payload["priority_context"] = build_company_priority_context(payload, infobase) payload["recovery_context"] = build_company_recovery_context(payload, infobase) @@ -1965,6 +2349,12 @@ def company_timeline( return {"items": rows, "count": len(rows)} +@app.get("/api/1/analytics-1c/companies/{counterparty}/actions") +def company_actions(counterparty: str, infobase: str | None = None, limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]: + items = company_management_actions(counterparty, infobase, limit=limit) + return {"items": items, "count": len(items)} + + @app.get("/api/1/analytics-1c/manager/brief/latest") def manager_brief_latest() -> dict[str, Any]: return load_latest_manager_brief() @@ -2031,6 +2421,42 @@ def manager_recovery_latest_markdown() -> str: return latest_md.read_text(encoding="utf-8") +@app.get("/api/1/analytics-1c/manager/actions") +def manager_actions_api( + priority_tier: str | None = Query(default=None), + owner: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), +) -> dict[str, Any]: + items = management_actions(priority_tier=priority_tier, owner=owner, limit=limit) + return { + "scope": "action_items", + "priority_model": "enterprise_risk", + "owner_mode": "secondary_operational_metadata", + "items": items, + "count": len(items), + "priority_tier": priority_tier, + "owner": owner, + } + + +@app.get("/api/1/analytics-1c/manager/actions/companies") +def manager_company_actions_api( + priority_tier: str | None = Query(default=None), + owner: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), +) -> dict[str, Any]: + items = management_company_actions(priority_tier=priority_tier, owner=owner, limit=limit) + return { + "scope": "companies", + "priority_model": "enterprise_risk", + "owner_mode": "secondary_operational_metadata", + "items": items, + "count": len(items), + "priority_tier": priority_tier, + "owner": owner, + } + + @app.get("/api/1/analytics-1c/companies/problematic") def problematic_companies_api( days: int = Query(default=7, ge=1, le=30), @@ -2065,6 +2491,20 @@ def manager_recovery_view() -> str: return render_recovery_brief_html(load_latest_recovery_brief()) +@app.get("/manager/actions", response_class=HTMLResponse) +def manager_actions_view( + priority_tier: str | None = Query(default=None), + owner: str | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=500), +) -> str: + return render_management_actions_html( + management_actions(priority_tier=priority_tier, owner=owner, limit=limit), + management_company_actions(priority_tier=priority_tier, owner=owner, limit=limit), + priority_tier=priority_tier, + owner=owner, + ) + + @app.get("/manager/briefs", response_class=HTMLResponse) def manager_brief_history_view(limit: int = Query(default=40, ge=1, le=200)) -> str: return render_brief_history_html(load_brief_history_records(limit)) @@ -2094,6 +2534,7 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | forecasts = summary_payload.get("forecasts") or [] signals = summary_payload.get("signals") or [] recent_documents = summary_payload.get("recent_documents") or [] + management_actions_payload = summary_payload.get("management_actions") or [] priority_context = summary_payload.get("priority_context") or build_company_priority_context(summary_payload, infobase) recovery_context = summary_payload.get("recovery_context") or build_company_recovery_context(summary_payload, infobase) @@ -2110,6 +2551,9 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | forecast_url = f"/api/1/analytics-1c/companies/{quote(company_ref)}/forecast" if infobase: forecast_url += f"?infobase={quote(infobase)}" + actions_url = f"/api/1/analytics-1c/companies/{quote(company_ref)}/actions" + if infobase: + actions_url += f"?infobase={quote(infobase)}" if card.get("signal_severity") == "critical": manager_comment = "Компания требует немедленного внимания: контур считает её operational-critical." @@ -2145,6 +2589,16 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | f"
  • {html.escape(str(item))}
  • " for item in recovery_context.get("actions", []) ) + management_action_items = [] + for item in management_actions_payload[:6]: + management_action_items.append( + "
  • " + f"{severity_badge(str(item.get('priority_tier') or 'low'))} " + f"{html.escape(str(item.get('recommended_action') or '-'))} " + f"({html.escape(str(item.get('deadline_hint') or '-'))})" + f"
    {html.escape(str(item.get('reason') or '-'))}" + "
  • " + ) forecast_rows = [] for item in forecasts: @@ -2302,11 +2756,13 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str | Неделя Weekly digest AI recovery + Очередь действий История brief Проблемные компании JSON summary JSON forecast JSON timeline + JSON actions Grafana
    @@ -2345,6 +2801,11 @@ def render_company_detail_html(summary_payload: dict[str, Any], infobase: str |
      {action_items}
    +
    +

    Текущие управленческие действия по компании

    +
      {''.join(management_action_items) or '
    • По компании пока нет отдельных строк в action queue.
    • '}
    +
    +

    AI-план снятия проблемы

    diff --git a/clickhouse-1c/ai/generate_manager_brief.py b/clickhouse-1c/ai/generate_manager_brief.py index c76e443..5eca1e5 100644 --- a/clickhouse-1c/ai/generate_manager_brief.py +++ b/clickhouse-1c/ai/generate_manager_brief.py @@ -641,6 +641,67 @@ def render_deterministic_payload(context: dict[str, Any]) -> dict[str, Any]: else: summary_lines.append("Свежесть источников укладывается в заданный порог.") + primary_risk = top_risks[0] if top_risks else {} + primary_risk_company = primary_risk.get("counterparty") or "лидирующие компании риска" + primary_risk_reason = primary_risk.get("top_signal") or "рост operational severity" + activity_30d_total = float(summary.get("activity_30d_total") or 0) + activity_forecast_30d_total = float(summary.get("activity_forecast_30d_total") or 0) + if activity_30d_total > 0: + activity_ratio = activity_forecast_30d_total / activity_30d_total + else: + activity_ratio = 1.0 + if activity_ratio < 0.95: + activity_outlook = "Есть риск спада активности в ближайшие 30 дней." + activity_effect = "Проверить, не перейдёт ли операционный спад в падение выручки и прибыли по топ-компаниям." + elif activity_ratio > 1.05: + activity_outlook = "Есть потенциал роста активности в ближайшие 30 дней." + activity_effect = "Важно не потерять рост из-за кейсов, блокировок и ручных несоответствий." + else: + activity_outlook = "По активности на 30 дней картина близка к стабильной." + activity_effect = "Резкого сигнала на рост или падение по активности сейчас нет." + primary_risk_load = [] + if int(primary_risk.get("open_cases_total") or 0) > 0: + primary_risk_load.append(f"кейсы {int(primary_risk['open_cases_total'])}") + if int(primary_risk.get("detections_total") or 0) > 0: + primary_risk_load.append(f"detections {int(primary_risk['detections_total'])}") + if int(primary_risk.get("active_locks") or 0) > 0: + primary_risk_load.append(f"блокировки {int(primary_risk['active_locks'])}") + primary_risk_tail = f" ({', '.join(primary_risk_load)})" if primary_risk_load else "" + + delta_summary = delta.get("summary", {}) if delta.get("available") else {} + if delta.get("available"): + change_answer = ( + "С прошлого запуска " + f"critical {delta_summary.get('critical_total_delta', 0):+d}, " + f"busy {delta_summary.get('busy_total_delta', 0):+d}, " + f"кейсы {delta_summary.get('open_cases_total_delta', 0):+d}, " + f"detections {delta_summary.get('detections_total_delta', 0):+d}." + ) + change_action = ( + f"Сначала разобрать новые critical: {', '.join(delta.get('new_critical', [])[:3])}." + if delta.get("new_critical") + else "Проверить компании с самым большим ухудшением и подтвердить владельцев на закрытие." + ) + else: + change_answer = "Сравнение с прошлым запуском сейчас недоступно, поэтому смотреть нужно на текущие кейсы, блокировки и свежесть источников." + change_action = "После следующего запуска включить сравнение и отслеживать не только уровень риска, но и его динамику." + + trust_parts = [] + if stale_sources: + trust_parts.append(f"есть просрочка по источникам: {', '.join(stale_sources)}") + if summary["manual_total"] > 0: + trust_parts.append(f"manual-match компаний: {summary['manual_total']}") + trust_answer = ( + "Выводы рабочие, но требуют осторожности: " + "; ".join(trust_parts) + "." + if trust_parts + else "Выводам можно доверять как operational-картине: источники свежие, явных ограничений по данным нет." + ) + trust_action = ( + "Перед жёсткими оргвыводами вручную перепроверить manual-match и просроченные источники." + if trust_parts + else "Использовать brief как основу для управленческого разбора без дополнительного технического triage." + ) + risk_items = [ { "company": item["counterparty"], @@ -678,9 +739,85 @@ def render_deterministic_payload(context: dict[str, Any]) -> dict[str, Any]: f"Проверить watchlist: {', '.join(item['counterparty'] for item in watchlist[:3])}." ) + manager_questions = [ + { + "question": "Что происходит сейчас?", + "answer": ( + f"Портфель под операционным давлением: critical {summary['critical_total']}, " + f"high {summary['high_total']}, открытых кейсов {summary['open_cases_total']}." + ), + "recommended_action": "Не распыляться: взять в первую очередь не больше 5 компаний с максимальной нагрузкой по кейсам и блокировкам.", + }, + { + "question": "Где главный риск?", + "answer": ( + f"Главный риск сейчас в {primary_risk_company}: {primary_risk_reason}{primary_risk_tail}." + if primary_risk + else "Главный риск не выделен: в данных нет явного лидера по severity." + ), + "recommended_action": ( + f"Сразу назначить владельца на {primary_risk_company} и проверить кейсы, блокировки и фактическую активность." + if primary_risk + else "Сначала вручную выделить 1-3 компании с самым высоким operational pressure." + ), + }, + { + "question": "Что изменилось с прошлого запуска?", + "answer": change_answer, + "recommended_action": change_action, + }, + { + "question": "Есть ли риск падения прибыли в ближайшие 30 дней?", + "answer": ( + "Прямого прогноза прибыли в этих данных нет. " + f"{activity_outlook} {activity_effect}" + ), + "recommended_action": "Сверить top-10 компаний по активности с выручкой, маржой и прибылью, чтобы отделить operational-шум от реального финансового риска.", + }, + { + "question": "Что делать сегодня?", + "answer": "Нужен короткий управленческий triage: подтвердить причину проблемы, владельца, срок и измеримый результат по каждой компании первой очереди.", + "recommended_action": actions[0] if actions else "Разобрать компании с максимальным signal score и открытыми кейсами.", + }, + { + "question": "Насколько этим выводам можно доверять?", + "answer": trust_answer, + "recommended_action": trust_action, + }, + ] + + management_plan = [ + { + "horizon": "today", + "focus": "Остановить прирост проблем по компаниям первой очереди.", + "action": actions[0] if actions else "Разобрать компании с максимальным signal score и открытыми кейсами.", + "expected_effect": "Снять главный операционный тормоз и не допустить роста хвоста кейсов до следующего цикла.", + "metric": "open_cases_total и active_locks по 5 приоритетным компаниям к вечеру.", + }, + { + "horizon": "week", + "focus": "Вернуть управляемость по спорным и зависшим предприятиям.", + "action": ( + f"{actions[1]} {actions[2]}" + if len(actions) >= 3 + else "Проверить watchlist, manual-match и причины повторного попадания компаний в риск." + ), + "expected_effect": "Очистить ложный шум, снять ручные ошибки и сосредоточить команду на реальных потерях.", + "metric": "watchlist, manual-match и повторные critical по неделе.", + }, + { + "horizon": "30d", + "focus": "Понять, где риск для прибыли, а где точка роста.", + "action": "Сверить top-10 компаний по активности и forecast с выручкой, маржой и прибылью; закрепить 3 предприятия, где снятие кейсов должно дать самый быстрый бизнес-эффект.", + "expected_effect": "Отделить операционный риск от финансового и направить усилия туда, где можно удержать или нарастить прибыль.", + "metric": "связка activity_forecast_30d_total с выручкой/маржой/прибылью по топ-компаниям.", + }, + ] + caveats = [ "Показатель amount здесь трактуется как activity score, а не как деньги или выручка.", "Severity operational-driven: high/critical отражают кейсы, detections и занятость базы, а не автоматически финансовый риск.", + "Без прямых финансовых данных brief не должен трактоваться как готовый прогноз прибыли.", ] if summary["manual_total"] > 0: caveats.append("Компании с registry_match_mode=manual требуют осторожности при юридической интерпретации реестра.") @@ -688,6 +825,8 @@ def render_deterministic_payload(context: dict[str, Any]) -> dict[str, Any]: return { "headline": headline, "summary": summary_lines[:6], + "manager_questions": manager_questions[:6], + "management_plan": management_plan[:5], "top_risks": risk_items[:5], "top_forecasts": forecast_items[:5], "actions": actions[:5], @@ -708,6 +847,16 @@ def render_markdown(payload: dict[str, Any], generated_at: str) -> str: ] for item in payload["summary"]: lines.append(f"- {item}") + lines.extend(["", "## Простые ответы для руководителя"]) + for idx, item in enumerate(payload.get("manager_questions", []), start=1): + lines.append( + f"{idx}. {item['question']} — {item['answer']} Действие: {item['recommended_action']}" + ) + lines.extend(["", "## План действий руководителя"]) + for idx, item in enumerate(payload.get("management_plan", []), start=1): + lines.append( + f"{idx}. [{item['horizon']}] {item['focus']} Действие: {item['action']} Эффект: {item['expected_effect']} Метрика: {item['metric']}" + ) lines.extend(["", "## Компании риска"]) for idx, item in enumerate(payload["top_risks"], start=1): lines.append( @@ -728,6 +877,49 @@ def render_markdown(payload: dict[str, Any], generated_at: str) -> str: return "\n".join(lines) +def normalize_brief_payload(payload: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: + fallback = render_deterministic_payload(context) + normalized = dict(payload) + + manager_questions = [ + dict(item) for item in normalized.get("manager_questions", []) if isinstance(item, dict) + ] + if not manager_questions: + manager_questions = [dict(item) for item in fallback["manager_questions"]] + profit_question = next( + item for item in fallback["manager_questions"] if "прибыл" in item["question"].lower() + ) + if not any( + any(marker in str(item.get("question", "")).lower() for marker in ("прибыл", "финанс")) + for item in manager_questions + ): + if len(manager_questions) >= 6: + manager_questions[-1] = dict(profit_question) + else: + manager_questions.insert(min(3, len(manager_questions)), dict(profit_question)) + normalized["manager_questions"] = manager_questions[:6] + + management_plan = [ + dict(item) for item in normalized.get("management_plan", []) if isinstance(item, dict) + ] + by_horizon = {str(item.get("horizon")): item for item in management_plan if item.get("horizon")} + for item in fallback["management_plan"]: + by_horizon.setdefault(str(item["horizon"]), dict(item)) + normalized["management_plan"] = [ + by_horizon[horizon] + for horizon in ("today", "week", "30d") + if horizon in by_horizon + ][:5] + + caveats = [str(item) for item in normalized.get("caveats", []) if str(item).strip()] + profit_caveat = next(item for item in fallback["caveats"] if "прибыл" in item.lower()) + if not any("прибыл" in item.lower() for item in caveats): + caveats.append(profit_caveat) + normalized["caveats"] = caveats[:4] or list(fallback["caveats"]) + + return normalized + + def run_codex(prompt: str, args: argparse.Namespace) -> tuple[int, str, str]: output_file = Path(tempfile.mkstemp(prefix="aw-1c-manager-brief-", suffix=".json")[1]) os.chmod(output_file, 0o666) @@ -818,6 +1010,8 @@ def main() -> int: codex_output = f"{codex_output}\nFALLBACK: {exc}".strip() render_mode = "deterministic" + payload = normalize_brief_payload(payload, context) + generated_at = datetime.now(UTC).replace(microsecond=0).isoformat() markdown = render_markdown(payload, generated_at) artifact = { diff --git a/clickhouse-1c/ai/manager_brief_prompt.md b/clickhouse-1c/ai/manager_brief_prompt.md index 6ff0d72..1965180 100644 --- a/clickhouse-1c/ai/manager_brief_prompt.md +++ b/clickhouse-1c/ai/manager_brief_prompt.md @@ -10,6 +10,20 @@ - Для `registry_match_mode=manual` не делай сильных выводов о юридическом соответствии реестру. - Если в контексте есть delta с прошлого запуска, явно выдели, что ухудшилось и что улучшилось. - Приоритет: риски, прогноз, что проверить руководителю в первую очередь. +- Отвечай не только списками, но и простыми ответами на управленческие вопросы. +- Блок `manager_questions` должен быть главным executive-слоем: короткие выводы и понятные рекомендации без техничности. +- В `manager_questions` обязательно дай ответы минимум на 4 вопроса: + - что происходит сейчас; + - где главный риск; + - что изменилось с прошлого запуска; + - что делать сегодня. +- Если данных хватает, добавь пятый вопрос про надёжность выводов или границы интерпретации. +- Каждый ответ должен быть простым, управленческим и прикладным: без внутренних терминов системы, если их можно заменить понятным языком. +- Если в контексте нет прямых финансовых данных о выручке/марже/прибыли, не выдавай прогноз прибыли как факт. Вместо этого честно говори: есть или нет риск падения активности и где нужно проверить финансовый эффект. +- Обязательно заполни `management_plan`: это жёсткий план действий для руководителя по горизонтам `today`, `week`, `30d`. +- Для `management_plan` пиши только такие действия, которые можно поручить и проверить по результату. +- В `management_plan.expected_effect` пиши понятный бизнес-эффект: остановить падение, снять операционный тормоз, вернуть рост, отделить ложный шум от реальной потери. +- В `management_plan.metric` указывай, по чему руководитель поймёт, что действие сработало. Верни JSON строго по schema. diff --git a/clickhouse-1c/ai/manager_brief_schema.json b/clickhouse-1c/ai/manager_brief_schema.json index fe0b00e..5b52246 100644 --- a/clickhouse-1c/ai/manager_brief_schema.json +++ b/clickhouse-1c/ai/manager_brief_schema.json @@ -4,6 +4,8 @@ "required": [ "headline", "summary", + "manager_questions", + "management_plan", "top_risks", "top_forecasts", "actions", @@ -25,6 +27,79 @@ "maxLength": 260 } }, + "manager_questions": { + "type": "array", + "minItems": 4, + "maxItems": 6, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "question", + "answer", + "recommended_action" + ], + "properties": { + "question": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "answer": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "recommended_action": { + "type": "string", + "minLength": 1, + "maxLength": 220 + } + } + } + }, + "management_plan": { + "type": "array", + "minItems": 3, + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "horizon", + "focus", + "action", + "expected_effect", + "metric" + ], + "properties": { + "horizon": { + "type": "string", + "enum": ["today", "week", "30d"] + }, + "focus": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "action": { + "type": "string", + "minLength": 1, + "maxLength": 280 + }, + "expected_effect": { + "type": "string", + "minLength": 1, + "maxLength": 220 + }, + "metric": { + "type": "string", + "minLength": 1, + "maxLength": 180 + } + } + } + }, "top_risks": { "type": "array", "maxItems": 5, diff --git a/clickhouse-1c/ai/test_company_intelligence_api.py b/clickhouse-1c/ai/test_company_intelligence_api.py index 6e55285..3e7e5a5 100644 --- a/clickhouse-1c/ai/test_company_intelligence_api.py +++ b/clickhouse-1c/ai/test_company_intelligence_api.py @@ -56,6 +56,19 @@ import company_intelligence_api as api class CompanyIntelligenceApiTests(unittest.TestCase): + def test_root_redirects_to_manager_brief(self) -> None: + response = api.root() + self.assertEqual(response.kwargs.get("status_code"), 307) + self.assertEqual(response.kwargs.get("headers", {}).get("Location"), "/manager/brief") + + def test_api_health_alias_delegates_to_health(self) -> None: + original = api.health + try: + api.health = lambda: {"status": "ok", "source": "health"} + self.assertEqual(api.api_health(), {"status": "ok", "source": "health"}) + finally: + api.health = original + def test_render_manager_brief_includes_executive_regulation(self) -> None: payload = { "generated_at": "2026-05-22T12:00:00+00:00", @@ -63,6 +76,22 @@ class CompanyIntelligenceApiTests(unittest.TestCase): "brief": { "headline": "Портфель 1С под давлением.", "summary": ["Кейсы растут.", "Нужен triage.", "Manual-match требует осторожности."], + "manager_questions": [ + { + "question": "Что происходит сейчас?", + "answer": "Портфель под операционным давлением.", + "recommended_action": "Взять 5 компаний первой очереди.", + } + ], + "management_plan": [ + { + "horizon": "today", + "focus": "Остановить прирост проблем.", + "action": "Разобрать компании первой очереди.", + "expected_effect": "Остановить рост хвоста кейсов.", + "metric": "open_cases_total к вечеру.", + } + ], "top_risks": [], "top_forecasts": [], "actions": ["Разобрать 5 компаний первой очереди."], @@ -89,6 +118,10 @@ class CompanyIntelligenceApiTests(unittest.TestCase): self.assertIn("Регламент руководителя: утро", html_page) self.assertIn("Регламент руководителя: вечер", html_page) self.assertIn("Жёсткие правила управления", html_page) + self.assertIn("Простые ответы для руководителя", html_page) + self.assertIn("План действий руководителя", html_page) + self.assertIn("Что происходит сейчас?", html_page) + self.assertIn("Остановить прирост проблем.", html_page) def test_build_company_priority_context(self) -> None: latest_payload = { @@ -184,6 +217,62 @@ class CompanyIntelligenceApiTests(unittest.TestCase): self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page) self.assertIn("/manager/company/", html_page) + def test_render_management_actions_html(self) -> None: + html_page = api.render_management_actions_html( + [ + { + "company_entity_key": "baseid:test", + "counterparty": "ФЕЛИЦТ ГРУПП 2026", + "infobase": "ФЕЛИЦТ ГРУПП 2026", + "owner_name": "Иванов", + "action_type": "case_triage", + "priority_tier": "critical", + "priority_score": 91, + "deadline_hint": "today", + "recommended_action": "Разобрать открытые кейсы.", + "reason": "Открытых кейсов 6, active detections 9.", + "evidence_summary": "severity=critical, cases=6", + } + ], + [ + { + "company_entity_key": "baseid:test", + "counterparty": "ФЕЛИЦТ ГРУПП 2026", + "infobase": "ФЕЛИЦТ ГРУПП 2026", + "owner_names": "Иванов", + "priority_tier": "critical", + "actions_total": 1, + "action_types_summary": "case_triage", + "recommended_action_summary": "Разобрать открытые кейсы.", + "reason_summary": "Открытых кейсов 6, active detections 9.", + } + ], + priority_tier="critical", + ) + self.assertIn("Очередь управленческих действий по 1С", html_page) + self.assertIn("Что делать по предприятиям", html_page) + self.assertIn("JSON по предприятиям", html_page) + self.assertIn("Приоритет считается по риску предприятия.", html_page) + self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page) + self.assertIn("Разобрать открытые кейсы.", html_page) + self.assertIn("priority_tier=critical", html_page) + + def test_manager_company_actions_api_marks_enterprise_priority_model(self) -> None: + original = api.management_company_actions + try: + api.management_company_actions = lambda priority_tier=None, owner=None, limit=100: [ + {"counterparty": "ФЕЛИЦТ ГРУПП 2026", "priority_tier": "critical"} + ] + payload = api.manager_company_actions_api(priority_tier="critical", owner="Иванов", limit=10) + finally: + api.management_company_actions = original + self.assertEqual(payload["scope"], "companies") + self.assertEqual(payload["priority_model"], "enterprise_risk") + self.assertEqual(payload["owner_mode"], "secondary_operational_metadata") + self.assertEqual(payload["count"], 1) + self.assertEqual(payload["priority_tier"], "critical") + self.assertEqual(payload["owner"], "Иванов") + def test_load_brief_history_records_and_detail(self) -> None: payload = { "generated_at": "2026-05-22T12:00:00+00:00", @@ -464,6 +553,14 @@ class CompanyIntelligenceApiTests(unittest.TestCase): "forecasts": [], "signals": [], "recent_documents": [], + "management_actions": [ + { + "priority_tier": "critical", + "recommended_action": "Разобрать открытые кейсы.", + "deadline_hint": "today", + "reason": "Открытых кейсов 6, active detections 9.", + } + ], "priority_context": { "current_priority_tier": "critical", "current_priority_score": 190, @@ -484,6 +581,8 @@ class CompanyIntelligenceApiTests(unittest.TestCase): ) self.assertIn("AI-план снятия проблемы", company_html) self.assertIn("Закрыть минимум 3 кейса.", company_html) + self.assertIn("Текущие управленческие действия по компании", company_html) + self.assertIn("Разобрать открытые кейсы.", company_html) if __name__ == "__main__": diff --git a/clickhouse-1c/ai/test_manager_brief.py b/clickhouse-1c/ai/test_manager_brief.py index dd7a761..674725e 100644 --- a/clickhouse-1c/ai/test_manager_brief.py +++ b/clickhouse-1c/ai/test_manager_brief.py @@ -64,16 +64,55 @@ class ManagerBriefTests(unittest.TestCase): payload = gmb.render_deterministic_payload(self.context) self.assertIn("headline", payload) self.assertGreaterEqual(len(payload["summary"]), 3) + self.assertGreaterEqual(len(payload["manager_questions"]), 4) + self.assertGreaterEqual(len(payload["management_plan"]), 3) + self.assertEqual(payload["manager_questions"][0]["question"], "Что происходит сейчас?") + self.assertEqual(payload["management_plan"][0]["horizon"], "today") self.assertEqual(payload["top_risks"][0]["company"], "ФЕЛИЦТ ГРУПП 2026") self.assertIn("manual", " ".join(payload["caveats"]).lower()) + self.assertTrue(any("прибыл" in item.lower() for item in payload["caveats"])) def test_markdown_render(self) -> None: payload = gmb.render_deterministic_payload(self.context) md = gmb.render_markdown(payload, "2026-05-22T12:00:00+00:00") self.assertIn("# Executive Brief 1C", md) + self.assertIn("## Простые ответы для руководителя", md) + self.assertIn("## План действий руководителя", md) self.assertIn("## Компании риска", md) + self.assertIn("Есть ли риск падения прибыли в ближайшие 30 дней?", md) + self.assertIn("Что делать сегодня?", md) self.assertIn("ФЕЛИЦТ ГРУПП 2026", md) + def test_normalize_brief_payload_adds_profit_question_and_plan(self) -> None: + payload = { + "headline": "Тест", + "summary": ["a", "b", "c"], + "manager_questions": [ + { + "question": "Что происходит сейчас?", + "answer": "Есть нагрузка.", + "recommended_action": "Сфокусироваться на top-компаниях.", + } + ], + "management_plan": [ + { + "horizon": "today", + "focus": "Снять острый риск.", + "action": "Разобрать top-5.", + "expected_effect": "Остановить рост проблем.", + "metric": "open_cases_total", + } + ], + "top_risks": [], + "top_forecasts": [], + "actions": ["Разобрать top-5."], + "caveats": ["Operational severity не равна финансам."], + } + normalized = gmb.normalize_brief_payload(payload, self.context) + self.assertTrue(any("прибыл" in item["question"].lower() for item in normalized["manager_questions"])) + self.assertEqual([item["horizon"] for item in normalized["management_plan"]], ["today", "week", "30d"]) + self.assertTrue(any("прибыл" in item.lower() for item in normalized["caveats"])) + def test_compute_delta_context(self) -> None: previous_artifact = { "generated_at": "2026-05-22T09:00:00+00:00", diff --git a/clickhouse-1c/clickhouse/init/04_company_intelligence.sql b/clickhouse-1c/clickhouse/init/04_company_intelligence.sql index 3534829..fdbdbe2 100644 --- a/clickhouse-1c/clickhouse/init/04_company_intelligence.sql +++ b/clickhouse-1c/clickhouse/init/04_company_intelligence.sql @@ -479,3 +479,322 @@ LEFT JOIN docs_forecast ON docs_forecast.infobase = base.infobase AND docs_forec LEFT JOIN cases_current ON cases_current.infobase = base.infobase AND cases_current.company_entity_key = base.counterparty LEFT JOIN detections_current ON detections_current.infobase = base.infobase AND detections_current.company_entity_key = base.counterparty WHERE ifNull(alias_state.exclude_from_portfolio, 0) = 0; + +CREATE OR REPLACE VIEW analytics_1c.v_company_management_actions_current AS +WITH +portfolio AS +( + SELECT * + FROM analytics_1c.v_company_portfolio_overview +), +risky_changes_24h AS +( + SELECT + infobase, + company_entity_key, + count() AS risky_changes_24h, + max(ts) AS last_risky_change_at, + arrayStringConcat(groupUniqArray(3)(if(field_name != '', field_name, change_kind)), ', ') AS risky_change_examples + FROM analytics_1c.document_change_events + WHERE risk_tag != '' + AND ts >= now() - INTERVAL 1 DAY + GROUP BY infobase, company_entity_key +), +large_adjustments_24h AS +( + SELECT + infobase, + company_entity_key, + count() AS large_adjustments_24h, + max(ts) AS last_large_adjustment_at, + max(toFloat64(amount)) AS max_large_adjustment_amount + FROM analytics_1c.business_events + WHERE ts >= now() - INTERVAL 1 DAY + AND event_kind = 'posting' + AND amount >= 50000 + AND ( + operation_type ILIKE '%adjust%' + OR document_type ILIKE '%Коррект%' + ) + GROUP BY infobase, company_entity_key +), +actions AS +( + SELECT + now() AS generated_at, + concat('case_triage:', p.infobase, ':', p.company_entity_key) AS action_id, + p.infobase AS infobase, + p.company_entity_key AS company_entity_key, + p.counterparty AS counterparty, + p.company_name AS company_name, + p.organization AS organization, + if(p.registry_assignee_name != '', p.registry_assignee_name, if(p.owner_user != '', p.owner_user, 'не назначен')) AS owner_name, + 'case_triage' AS action_type, + multiIf( + p.signal_severity = 'critical' OR p.open_cases_total >= 10, 'critical', + p.signal_severity = 'high' OR p.open_cases_total >= 5, 'high', + 'medium' + ) AS priority_tier, + toUInt32(greatest(toInt32(p.signal_score), 55) + least(toUInt32(p.open_cases_total), toUInt32(20))) AS priority_score, + multiIf(p.signal_severity = 'critical' OR p.open_cases_total >= 10, 'today', '24h') AS deadline_hint, + concat( + 'Открытых кейсов ', + toString(p.open_cases_total), + ', active detections ', + toString(p.detections_total), + if(p.top_signal != '', concat('. ', p.top_signal), '.') + ) AS reason, + 'Назначить владельца на разбор кейсов и снять прирост до следующего управленческого цикла.' AS recommended_action, + concat( + 'severity=', p.signal_severity, + ', cases=', toString(p.open_cases_total), + ', detections=', toString(p.detections_total) + ) AS evidence_summary, + toUInt32(p.open_cases_total) AS open_cases_total, + toUInt32(p.detections_total) AS detections_total, + toUInt32(p.active_locks) AS active_locks, + toUInt16(p.days_since_last_activity) AS days_since_last_activity, + toUInt32(ifNull(ch.risky_changes_24h, 0)) AS risky_changes_24h, + toUInt32(ifNull(adj.large_adjustments_24h, 0)) AS large_adjustments_24h, + toFloat64(ifNull(adj.max_large_adjustment_amount, 0)) AS max_large_adjustment_amount, + p.last_seen_at AS last_seen_at + FROM portfolio AS p + LEFT JOIN risky_changes_24h AS ch ON ch.infobase = p.infobase AND ch.company_entity_key = p.company_entity_key + LEFT JOIN large_adjustments_24h AS adj ON adj.infobase = p.infobase AND adj.company_entity_key = p.company_entity_key + WHERE p.open_cases_total > 0 + + UNION ALL + + SELECT + now() AS generated_at, + concat('lock_recovery:', p.infobase, ':', p.company_entity_key) AS action_id, + p.infobase AS infobase, + p.company_entity_key AS company_entity_key, + p.counterparty AS counterparty, + p.company_name AS company_name, + p.organization AS organization, + if(p.registry_assignee_name != '', p.registry_assignee_name, if(p.owner_user != '', p.owner_user, 'не назначен')) AS owner_name, + 'lock_recovery' AS action_type, + multiIf( + p.active_locks >= 5 OR p.current_status = 'busy', 'critical', + p.active_locks >= 2, 'high', + 'medium' + ) AS priority_tier, + toUInt32(greatest(toInt32(p.signal_score), 50) + least(toUInt32(p.active_locks * 8), toUInt32(40))) AS priority_score, + 'today' AS deadline_hint, + concat( + 'Файловая база в состоянии ', + p.current_status, + ', активных блокировок ', + toString(p.active_locks), + '.' + ) AS reason, + 'Проверить busy/lock-контур, снять застрявшие блокировки и подтвердить стабилизацию базы.' AS recommended_action, + concat( + 'status=', p.current_status, + ', locks=', toString(p.active_locks), + ', activity_score=', toString(p.current_activity_score) + ) AS evidence_summary, + toUInt32(p.open_cases_total) AS open_cases_total, + toUInt32(p.detections_total) AS detections_total, + toUInt32(p.active_locks) AS active_locks, + toUInt16(p.days_since_last_activity) AS days_since_last_activity, + toUInt32(ifNull(ch.risky_changes_24h, 0)) AS risky_changes_24h, + toUInt32(ifNull(adj.large_adjustments_24h, 0)) AS large_adjustments_24h, + toFloat64(ifNull(adj.max_large_adjustment_amount, 0)) AS max_large_adjustment_amount, + p.last_seen_at AS last_seen_at + FROM portfolio AS p + LEFT JOIN risky_changes_24h AS ch ON ch.infobase = p.infobase AND ch.company_entity_key = p.company_entity_key + LEFT JOIN large_adjustments_24h AS adj ON adj.infobase = p.infobase AND adj.company_entity_key = p.company_entity_key + WHERE p.current_status = 'busy' OR p.active_locks > 0 + + UNION ALL + + SELECT + now() AS generated_at, + concat('stale_activity:', p.infobase, ':', p.company_entity_key) AS action_id, + p.infobase AS infobase, + p.company_entity_key AS company_entity_key, + p.counterparty AS counterparty, + p.company_name AS company_name, + p.organization AS organization, + if(p.registry_assignee_name != '', p.registry_assignee_name, if(p.owner_user != '', p.owner_user, 'не назначен')) AS owner_name, + 'stale_activity' AS action_type, + multiIf( + p.days_since_last_activity >= 14, 'critical', + p.days_since_last_activity >= 7, 'high', + 'medium' + ) AS priority_tier, + toUInt32(greatest(toInt32(p.signal_score), 45) + least(toUInt32(p.days_since_last_activity * 3), toUInt32(40))) AS priority_score, + multiIf(p.days_since_last_activity >= 14, '24h', '3d') AS deadline_hint, + concat( + 'Компания молчит ', + toString(p.days_since_last_activity), + ' дн., активность 30д ', + toString(p.amount_30d), + ', прогноз 30д ', + toString(p.amount_forecast_30d), + '.' + ) AS reason, + 'Подтвердить, это плановая бизнес-пауза или выпадение предприятия из операционного ритма, и назначить владельца на возврат в норму.' AS recommended_action, + concat( + 'days_since_last_activity=', toString(p.days_since_last_activity), + ', docs_30d=', toString(p.docs_30d), + ', forecast_30d=', toString(p.amount_forecast_30d) + ) AS evidence_summary, + toUInt32(p.open_cases_total) AS open_cases_total, + toUInt32(p.detections_total) AS detections_total, + toUInt32(p.active_locks) AS active_locks, + toUInt16(p.days_since_last_activity) AS days_since_last_activity, + toUInt32(ifNull(ch.risky_changes_24h, 0)) AS risky_changes_24h, + toUInt32(ifNull(adj.large_adjustments_24h, 0)) AS large_adjustments_24h, + toFloat64(ifNull(adj.max_large_adjustment_amount, 0)) AS max_large_adjustment_amount, + p.last_seen_at AS last_seen_at + FROM portfolio AS p + LEFT JOIN risky_changes_24h AS ch ON ch.infobase = p.infobase AND ch.company_entity_key = p.company_entity_key + LEFT JOIN large_adjustments_24h AS adj ON adj.infobase = p.infobase AND adj.company_entity_key = p.company_entity_key + WHERE p.days_since_last_activity >= 7 + + UNION ALL + + SELECT + now() AS generated_at, + concat('risky_change_review:', p.infobase, ':', p.company_entity_key) AS action_id, + p.infobase AS infobase, + p.company_entity_key AS company_entity_key, + p.counterparty AS counterparty, + p.company_name AS company_name, + p.organization AS organization, + if(p.registry_assignee_name != '', p.registry_assignee_name, if(p.owner_user != '', p.owner_user, 'не назначен')) AS owner_name, + 'risky_change_review' AS action_type, + multiIf(ch.risky_changes_24h >= 3, 'critical', 'high') AS priority_tier, + toUInt32(greatest(toInt32(p.signal_score), 60) + least(toUInt32(ch.risky_changes_24h * 12), toUInt32(40))) AS priority_score, + 'today' AS deadline_hint, + concat( + 'За 24ч зафиксировано рискованных изменений документов: ', + toString(ch.risky_changes_24h), + if(ch.risky_change_examples != '', concat(' (', ch.risky_change_examples, ')'), '.') + ) AS reason, + 'Проверить изменённые реквизиты и связать их с документами, проводками и ответственным пользователем.' AS recommended_action, + concat( + 'risky_changes_24h=', toString(ch.risky_changes_24h), + if(ch.risky_change_examples != '', concat(', fields=', ch.risky_change_examples), '') + ) AS evidence_summary, + toUInt32(p.open_cases_total) AS open_cases_total, + toUInt32(p.detections_total) AS detections_total, + toUInt32(p.active_locks) AS active_locks, + toUInt16(p.days_since_last_activity) AS days_since_last_activity, + toUInt32(ch.risky_changes_24h) AS risky_changes_24h, + toUInt32(ifNull(adj.large_adjustments_24h, 0)) AS large_adjustments_24h, + toFloat64(ifNull(adj.max_large_adjustment_amount, 0)) AS max_large_adjustment_amount, + p.last_seen_at AS last_seen_at + FROM portfolio AS p + INNER JOIN risky_changes_24h AS ch ON ch.infobase = p.infobase AND ch.company_entity_key = p.company_entity_key + LEFT JOIN large_adjustments_24h AS adj ON adj.infobase = p.infobase AND adj.company_entity_key = p.company_entity_key + + UNION ALL + + SELECT + now() AS generated_at, + concat('large_adjustment_review:', p.infobase, ':', p.company_entity_key) AS action_id, + p.infobase AS infobase, + p.company_entity_key AS company_entity_key, + p.counterparty AS counterparty, + p.company_name AS company_name, + p.organization AS organization, + if(p.registry_assignee_name != '', p.registry_assignee_name, if(p.owner_user != '', p.owner_user, 'не назначен')) AS owner_name, + 'large_adjustment_review' AS action_type, + multiIf(adj.max_large_adjustment_amount >= 250000, 'critical', 'high') AS priority_tier, + toUInt32(greatest(toInt32(p.signal_score), 65) + least(toUInt32(adj.large_adjustments_24h * 10), toUInt32(30))) AS priority_score, + 'today' AS deadline_hint, + concat( + 'За 24ч крупных корректировок/проводок: ', + toString(adj.large_adjustments_24h), + ', максимальная сумма ', + toString(adj.max_large_adjustment_amount), + '.' + ) AS reason, + 'Подтвердить основание крупной корректировки, автора и влияние на управленческий результат предприятия.' AS recommended_action, + concat( + 'large_adjustments_24h=', toString(adj.large_adjustments_24h), + ', max_amount=', toString(adj.max_large_adjustment_amount) + ) AS evidence_summary, + toUInt32(p.open_cases_total) AS open_cases_total, + toUInt32(p.detections_total) AS detections_total, + toUInt32(p.active_locks) AS active_locks, + toUInt16(p.days_since_last_activity) AS days_since_last_activity, + toUInt32(ifNull(ch.risky_changes_24h, 0)) AS risky_changes_24h, + toUInt32(adj.large_adjustments_24h) AS large_adjustments_24h, + toFloat64(adj.max_large_adjustment_amount) AS max_large_adjustment_amount, + p.last_seen_at AS last_seen_at + FROM portfolio AS p + INNER JOIN large_adjustments_24h AS adj ON adj.infobase = p.infobase AND adj.company_entity_key = p.company_entity_key + LEFT JOIN risky_changes_24h AS ch ON ch.infobase = p.infobase AND ch.company_entity_key = p.company_entity_key +) +SELECT + *, + multiIf(priority_tier = 'critical', toUInt8(4), priority_tier = 'high', toUInt8(3), priority_tier = 'medium', toUInt8(2), toUInt8(1)) AS priority_rank +FROM actions; + +CREATE OR REPLACE VIEW analytics_1c.v_company_management_companies_current AS +SELECT + generated_at, + infobase, + company_entity_key, + counterparty, + company_name, + organization, + owner_names, + priority_rank_max AS priority_rank, + priority_tier, + priority_score, + actions_total, + critical_actions_total, + high_actions_total, + medium_actions_total, + low_actions_total, + action_types_summary, + recommended_action_summary, + reason_summary, + evidence_summary, + open_cases_total, + detections_total, + active_locks, + days_since_last_activity, + risky_changes_24h, + large_adjustments_24h, + max_large_adjustment_amount, + last_seen_at +FROM +( + SELECT + max(a.generated_at) AS generated_at, + infobase, + company_entity_key, + argMax(a.counterparty, tuple(a.priority_rank, a.priority_score, a.generated_at)) AS counterparty, + argMax(a.company_name, tuple(a.priority_rank, a.priority_score, a.generated_at)) AS company_name, + argMax(a.organization, tuple(a.priority_rank, a.priority_score, a.generated_at)) AS organization, + arrayStringConcat(arraySort(groupUniqArray(a.owner_name)), ', ') AS owner_names, + max(a.priority_rank) AS priority_rank_max, + argMax(a.priority_tier, tuple(a.priority_rank, a.priority_score, a.generated_at)) AS priority_tier, + max(a.priority_score) AS priority_score, + count() AS actions_total, + countIf(a.priority_tier = 'critical') AS critical_actions_total, + countIf(a.priority_tier = 'high') AS high_actions_total, + countIf(a.priority_tier = 'medium') AS medium_actions_total, + countIf(a.priority_tier = 'low') AS low_actions_total, + arrayStringConcat(arraySort(groupUniqArray(a.action_type)), ', ') AS action_types_summary, + arrayStringConcat(groupUniqArray(3)(a.recommended_action), ' | ') AS recommended_action_summary, + arrayStringConcat(groupUniqArray(3)(a.reason), ' | ') AS reason_summary, + arrayStringConcat(groupUniqArray(3)(a.evidence_summary), ' | ') AS evidence_summary, + max(a.open_cases_total) AS open_cases_total, + max(a.detections_total) AS detections_total, + max(a.active_locks) AS active_locks, + max(a.days_since_last_activity) AS days_since_last_activity, + max(a.risky_changes_24h) AS risky_changes_24h, + max(a.large_adjustments_24h) AS large_adjustments_24h, + max(a.max_large_adjustment_amount) AS max_large_adjustment_amount, + max(a.last_seen_at) AS last_seen_at + FROM analytics_1c.v_company_management_actions_current AS a + GROUP BY infobase, company_entity_key +); diff --git a/clickhouse-1c/clickhouse/init/05_financial_reporting.sql b/clickhouse-1c/clickhouse/init/05_financial_reporting.sql new file mode 100644 index 0000000..18cf0b9 --- /dev/null +++ b/clickhouse-1c/clickhouse/init/05_financial_reporting.sql @@ -0,0 +1,167 @@ +CREATE OR REPLACE VIEW analytics_1c.v_financial_reporting_readiness AS +SELECT + max(be.ts) AS last_event_at, + if( + countIf(be.event_kind = 'posting' OR be.debit_account != '' OR be.credit_account != '') > 0, + maxIf(be.ts, be.event_kind = 'posting' OR be.debit_account != '' OR be.credit_account != ''), + CAST(NULL, 'Nullable(DateTime)') + ) AS last_ledger_event_at, + count() AS business_events_total, + countIf(be.document_type = 'CompanyActivitySnapshot' AND be.event_kind = 'document_snapshot') AS proxy_events_total, + countIf(be.event_kind = 'posting' OR be.debit_account != '' OR be.credit_account != '') AS ledger_events_total, + countIf(be.debit_account != '' OR be.credit_account != '') AS account_lines_total, + (SELECT count() FROM analytics_1c.postings) AS postings_table_rows, + (SELECT if(count() > 0, max(ts), CAST(NULL, 'Nullable(DateTime)')) FROM analytics_1c.postings) AS postings_table_last_ts, + multiIf( + countIf(be.event_kind = 'posting' OR be.debit_account != '' OR be.credit_account != '') > 0 OR (SELECT count() FROM analytics_1c.postings) > 0, + 'ledger_ready', + countIf(be.document_type = 'CompanyActivitySnapshot' AND be.event_kind = 'document_snapshot') > 0, + 'proxy_only', + 'empty' + ) AS readiness_status +FROM analytics_1c.business_events AS be; + +CREATE OR REPLACE VIEW analytics_1c.v_financial_daily AS +SELECT + d, + infobase, + organization_name AS organization, + source_kind, + events_total, + documents_total, + amount_total +FROM +( + SELECT + toDate(ts) AS d, + infobase, + if(organization != '', organization, infobase) AS organization_name, + 'ledger' AS source_kind, + count() AS events_total, + uniqExact(document_id) AS documents_total, + sum(amount) AS amount_total + FROM analytics_1c.business_events + WHERE event_kind = 'posting' OR debit_account != '' OR credit_account != '' + GROUP BY d, infobase, organization_name + + UNION ALL + + SELECT + toDate(ts) AS d, + infobase, + if(organization != '', organization, infobase) AS organization_name, + 'proxy' AS source_kind, + count() AS events_total, + uniqExact(document_id) AS documents_total, + sum(amount) AS amount_total + FROM analytics_1c.business_events + WHERE event_kind = 'document_snapshot' + AND document_type = 'CompanyActivitySnapshot' + GROUP BY d, infobase, organization_name +); + +CREATE OR REPLACE VIEW analytics_1c.v_financial_company_current AS +SELECT + daily.infobase AS infobase, + argMax(ifNull(nullIf(companies.company_name, ''), daily.infobase), daily.d) AS company_name, + argMax(ifNull(nullIf(companies.organization, ''), daily.organization), daily.d) AS organization, + argMax(ifNull(nullIf(companies.owner_user, ''), ''), daily.d) AS owner_user, + sumIf(daily.amount_total, daily.source_kind = 'ledger' AND daily.d >= today() - 30) AS ledger_amount_30d, + sumIf(daily.events_total, daily.source_kind = 'ledger' AND daily.d >= today() - 30) AS ledger_events_30d, + sumIf(daily.documents_total, daily.source_kind = 'ledger' AND daily.d >= today() - 30) AS ledger_documents_30d, + sumIf(daily.amount_total, daily.source_kind = 'proxy' AND daily.d >= today() - 30) AS proxy_amount_30d, + sumIf(daily.events_total, daily.source_kind = 'proxy' AND daily.d >= today() - 30) AS proxy_events_30d, + sumIf(daily.documents_total, daily.source_kind = 'proxy' AND daily.d >= today() - 30) AS proxy_documents_30d, + if( + countIf(daily.source_kind = 'ledger' AND daily.d >= today() - 30) > 0, + maxIf(daily.d, daily.source_kind = 'ledger' AND daily.d >= today() - 30), + maxIf(daily.d, daily.source_kind = 'proxy' AND daily.d >= today() - 30) + ) AS last_financial_day, + multiIf( + countIf(daily.source_kind = 'ledger' AND daily.d >= today() - 30) > 0, 'ledger_ready', + countIf(daily.source_kind = 'proxy' AND daily.d >= today() - 30) > 0, 'proxy_only', + 'empty' + ) AS readiness_status, + ifNull(changes.changes_30d, 0) AS changes_30d, + ifNull(changes.risky_changes_30d, 0) AS risky_changes_30d +FROM analytics_1c.v_financial_daily AS daily +LEFT JOIN analytics_1c.v_companies_current AS companies ON companies.infobase = daily.infobase +LEFT JOIN +( + SELECT + infobase, + count() AS changes_30d, + countIf(risk_tag != '') AS risky_changes_30d + FROM analytics_1c.document_change_events + WHERE ts >= now() - INTERVAL 30 DAY + GROUP BY infobase +) AS changes ON changes.infobase = daily.infobase +GROUP BY daily.infobase, ifNull(changes.changes_30d, 0), ifNull(changes.risky_changes_30d, 0); + +CREATE OR REPLACE VIEW analytics_1c.v_financial_document_types_30d AS +SELECT + source_kind, + document_type, + operation_type, + count() AS events_total, + uniqExact(infobase) AS infobases_total, + sum(amount) AS amount_total +FROM +( + SELECT + ts, + infobase, + document_type, + operation_type, + amount, + 'ledger' AS source_kind + FROM analytics_1c.business_events + WHERE ts >= now() - INTERVAL 30 DAY + AND (event_kind = 'posting' OR debit_account != '' OR credit_account != '') + + UNION ALL + + SELECT + ts, + infobase, + document_type, + operation_type, + amount, + 'proxy' AS source_kind + FROM analytics_1c.business_events + WHERE ts >= now() - INTERVAL 30 DAY + AND event_kind = 'document_snapshot' + AND document_type = 'CompanyActivitySnapshot' +) +GROUP BY source_kind, document_type, operation_type; + +CREATE OR REPLACE VIEW analytics_1c.v_financial_accounts_30d AS +SELECT + side, + account, + count() AS entries_total, + uniqExact(infobase) AS infobases_total, + sum(amount) AS amount_total +FROM +( + SELECT + infobase, + amount, + 'debit' AS side, + debit_account AS account + FROM analytics_1c.business_events + WHERE ts >= now() - INTERVAL 30 DAY + AND debit_account != '' + + UNION ALL + + SELECT + infobase, + amount, + 'credit' AS side, + credit_account AS account + FROM analytics_1c.business_events + WHERE ts >= now() - INTERVAL 30 DAY + AND credit_account != '' +) +GROUP BY side, account; diff --git a/clickhouse-1c/docker-compose.yml b/clickhouse-1c/docker-compose.yml index 042c61e..a940ff2 100644 --- a/clickhouse-1c/docker-compose.yml +++ b/clickhouse-1c/docker-compose.yml @@ -14,22 +14,5 @@ services: - clickhouse_1c_data:/var/lib/clickhouse - ./clickhouse/init:/docker-entrypoint-initdb.d:ro - grafana: - image: grafana/grafana:11.2.2 - container_name: aw-rus-1c-grafana - restart: unless-stopped - environment: - GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER} - GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} - GF_INSTALL_PLUGINS: grafana-clickhouse-datasource - ports: - - "${GRAFANA_PORT}:3000" - volumes: - - grafana_1c_data:/var/lib/grafana - - ./grafana/provisioning:/etc/grafana/provisioning:ro - depends_on: - - clickhouse - volumes: clickhouse_1c_data: - grafana_1c_data: diff --git a/clickhouse-1c/etl/config.example.yml b/clickhouse-1c/etl/config.example.yml index 25817f0..d85c54f 100644 --- a/clickhouse-1c/etl/config.example.yml +++ b/clickhouse-1c/etl/config.example.yml @@ -28,3 +28,91 @@ formats: archive_dir: ./archive delete_after_load: false +mcp_toolkit: + base_url: http://127.0.0.1:6003 + channel: prod-file-1c + timeout_seconds: 120 + verify_tls: true + state_dir: ./state/1c-mcp-toolkit + datasets: + companies: + enabled: false + # Placeholder query: replace metadata objects/fields with the real + # registry or report available in the target 1C configuration. + query: | + ВЫБРАТЬ + Базы.Имя КАК infobase, + Базы.Организация КАК organization, + Базы.Владелец КАК owner_user, + Базы.BaseID КАК base_id, + Базы.Путь КАК base_path, + Базы.АктивныеБлокировки КАК active_locks, + Базы.БаллАктивности КАК activity_score + ИЗ РегистрСведений.ВАШ_РЕЕСТР_БАЗ КАК Базы + limit: 1000 + static_fields: + status: live + scheduler_touched: 1 + documents: + enabled: false + # Placeholder query: alias output columns to landing-schema field names. + query: | + ВЫБРАТЬ + Док.Дата КАК ts, + Док.База КАК infobase, + Док.Организация КАК organization, + Док.Подразделение КАК department, + Док.Ссылка КАК doc_id, + Док.Номер КАК doc_number, + Док.ВидДокумента КАК doc_type, + Док.Ответственный КАК author, + Док.Контрагент КАК counterparty, + Док.Операция КАК operation_type, + Док.СуммаДокумента КАК amount + ИЗ Документ.ВАШ_ЭКСПОРТ_ДОКУМЕНТОВ КАК Док + ГДЕ Док.Дата >= &since_ts И Док.Дата < &until_ts + limit: 1000 + incremental: + since_param: since_ts + until_param: until_ts + initial_since: 2026-01-01T00:00:00Z + lookback_seconds: 120 + postings: + enabled: false + # Placeholder query: replace with the real accounting register export. + query: | + ВЫБРАТЬ + Движ.Дата КАК ts, + Движ.База КАК infobase, + Движ.Регистратор КАК registrar, + Движ.Операция КАК operation_type, + Движ.СчетДт КАК account_dt, + Движ.СчетКт КАК account_ct, + Движ.Сумма КАК amount, + Движ.BaseID КАК base_id + ИЗ РегистрБухгалтерии.ВАШ_ЭКСПОРТ_ПРОВОДОК КАК Движ + ГДЕ Движ.Дата >= &since_ts И Движ.Дата < &until_ts + limit: 5000 + incremental: + since_param: since_ts + until_param: until_ts + initial_since: 2026-01-01T00:00:00Z + lookback_seconds: 120 + business_events: + enabled: false + query: "" + limit: 5000 + document_changes: + enabled: false + query: "" + limit: 5000 + event_log: + enabled: false + initial_start_date: 2026-01-01T00:00:00Z + lookback_seconds: 120 + limit: 500 + levels: + - Error + - Warning + static_fields: + infobase: FILE-1C diff --git a/clickhouse-1c/etl/extract_1c_mcp_toolkit.py b/clickhouse-1c/etl/extract_1c_mcp_toolkit.py new file mode 100644 index 0000000..c643574 --- /dev/null +++ b/clickhouse-1c/etl/extract_1c_mcp_toolkit.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import ssl +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import yaml + +from build_business_event_exports import canonical_company_entity_key, write_jsonl +from load_1c_exports import Config as LoaderConfig +from load_1c_exports import load_config, normalize_ts + +QUERY_DATASETS = ("documents", "postings", "business_events", "document_changes", "companies") +SUPPORTED_DATASETS = QUERY_DATASETS + ("reglog",) + + +@dataclass(frozen=True) +class IncrementalConfig: + since_param: str = "" + until_param: str = "" + initial_since: str = "" + lookback_seconds: int = 0 + + +@dataclass(frozen=True) +class QueryDatasetConfig: + enabled: bool + query: str + params: dict[str, Any] = field(default_factory=dict) + limit: int = 1000 + include_schema: bool = False + field_map: dict[str, str] = field(default_factory=dict) + static_fields: dict[str, Any] = field(default_factory=dict) + incremental: IncrementalConfig | None = None + + +@dataclass(frozen=True) +class EventLogConfig: + enabled: bool + initial_start_date: str = "" + lookback_seconds: int = 0 + limit: int = 500 + levels: list[str] = field(default_factory=list) + events: list[str] = field(default_factory=list) + field_map: dict[str, str] = field(default_factory=dict) + static_fields: dict[str, Any] = field(default_factory=dict) + filters: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ToolkitConfig: + base_url: str + channel: str + timeout_seconds: float + verify_tls: bool + state_dir: str + datasets: dict[str, QueryDatasetConfig] + event_log: EventLogConfig + + +@dataclass(frozen=True) +class RunOptions: + dry_run: bool + sample_size: int + max_pages: int + validate_config: bool + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Extract read-only 1C data from 1c-mcp-toolkit into clickhouse-1c landing dirs") + parser.add_argument("--config", required=True, help="Path to YAML config") + parser.add_argument("--dataset", choices=SUPPORTED_DATASETS, help="Extract only one dataset") + parser.add_argument("--dry-run", action="store_true", help="Probe endpoint/query pack safely without writing landing files or updating state") + parser.add_argument("--sample-size", type=int, default=3, help="Rows to preview in dry-run mode") + parser.add_argument("--max-pages", type=int, default=1, help="Max pages to read in dry-run reglog probing") + parser.add_argument("--validate-config", action="store_true", help="Validate config statically and exit") + return parser.parse_args() + + +def load_runtime_config(path: str) -> tuple[LoaderConfig, ToolkitConfig]: + loader_conf = load_config(path) + raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + toolkit_raw = raw.get("mcp_toolkit") + if not isinstance(toolkit_raw, dict): + raise ValueError("missing mcp_toolkit config block") + + datasets: dict[str, QueryDatasetConfig] = {} + for dataset in QUERY_DATASETS: + dataset_raw = dict(toolkit_raw.get("datasets", {}).get(dataset, {}) or {}) + incremental_raw = dict(dataset_raw.get("incremental", {}) or {}) + datasets[dataset] = QueryDatasetConfig( + enabled=bool(dataset_raw.get("enabled", False)), + query=str(dataset_raw.get("query", "") or "").strip(), + params=dict(dataset_raw.get("params", {}) or {}), + limit=int(dataset_raw.get("limit", 1000) or 1000), + include_schema=bool(dataset_raw.get("include_schema", False)), + field_map={str(k): str(v) for k, v in dict(dataset_raw.get("field_map", {}) or {}).items()}, + static_fields=dict(dataset_raw.get("static_fields", {}) or {}), + incremental=IncrementalConfig( + since_param=str(incremental_raw.get("since_param", "") or "").strip(), + until_param=str(incremental_raw.get("until_param", "") or "").strip(), + initial_since=str(incremental_raw.get("initial_since", "") or "").strip(), + lookback_seconds=int(incremental_raw.get("lookback_seconds", 0) or 0), + ) + if incremental_raw + else None, + ) + + event_log_raw = dict(toolkit_raw.get("event_log", {}) or {}) + event_log = EventLogConfig( + enabled=bool(event_log_raw.get("enabled", False)), + initial_start_date=str(event_log_raw.get("initial_start_date", "") or "").strip(), + lookback_seconds=int(event_log_raw.get("lookback_seconds", 0) or 0), + limit=int(event_log_raw.get("limit", 500) or 500), + levels=[str(v) for v in list(event_log_raw.get("levels", []) or []) if str(v).strip()], + events=[str(v) for v in list(event_log_raw.get("events", []) or []) if str(v).strip()], + field_map={str(k): str(v) for k, v in dict(event_log_raw.get("field_map", {}) or {}).items()}, + static_fields=dict(event_log_raw.get("static_fields", {}) or {}), + filters={str(k): v for k, v in dict(event_log_raw.get("filters", {}) or {}).items()}, + ) + + toolkit_conf = ToolkitConfig( + base_url=str(toolkit_raw.get("base_url", "") or "").rstrip("/"), + channel=str(toolkit_raw.get("channel", "") or "").strip(), + timeout_seconds=float(toolkit_raw.get("timeout_seconds", 120) or 120), + verify_tls=bool(toolkit_raw.get("verify_tls", True)), + state_dir=str(toolkit_raw.get("state_dir", "./state/1c-mcp-toolkit") or "./state/1c-mcp-toolkit"), + datasets=datasets, + event_log=event_log, + ) + if not toolkit_conf.base_url: + raise ValueError("mcp_toolkit.base_url is required") + return loader_conf, toolkit_conf + + +class ToolkitClient: + def __init__(self, conf: ToolkitConfig): + self.conf = conf + self.ssl_context = None if conf.verify_tls else ssl._create_unverified_context() + + def post_json(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]: + query = urllib.parse.urlencode({"channel": self.conf.channel}) if self.conf.channel else "" + url = f"{self.conf.base_url}/api/{endpoint}" + if query: + url = f"{url}?{query}" + request = urllib.request.Request( + url, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.conf.timeout_seconds, context=self.ssl_context) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{endpoint} http_error={exc.code} {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"{endpoint} transport_error={exc}") from exc + + payload_obj = json.loads(body) + if not payload_obj.get("success", False): + raise RuntimeError(f"{endpoint} api_error={payload_obj.get('error', 'unknown error')}") + return payload_obj + + +def state_path(toolkit_conf: ToolkitConfig) -> Path: + return Path(toolkit_conf.state_dir) / "extract_state.json" + + +def load_state(toolkit_conf: ToolkitConfig) -> dict[str, Any]: + path = state_path(toolkit_conf) + if not path.exists(): + return {"datasets": {}, "reglog": {}} + return json.loads(path.read_text(encoding="utf-8")) + + +def save_state(toolkit_conf: ToolkitConfig, state: dict[str, Any]) -> None: + path = state_path(toolkit_conf) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def isoformat_seconds(value: datetime) -> str: + return value.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def normalize_utc_datetime(value: Any) -> datetime: + dt = normalize_ts(value) + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + + +def validate_runtime_config(loader_conf: LoaderConfig, toolkit_conf: ToolkitConfig) -> list[str]: + issues: list[str] = [] + if not toolkit_conf.base_url: + issues.append("mcp_toolkit.base_url is required") + if not toolkit_conf.state_dir: + issues.append("mcp_toolkit.state_dir is required") + + for dataset in QUERY_DATASETS: + spec = toolkit_conf.datasets[dataset] + if spec.enabled and spec.limit < 1: + issues.append(f"mcp_toolkit.datasets.{dataset}.limit must be >= 1") + if spec.enabled and spec.limit > 1000: + issues.append(f"mcp_toolkit.datasets.{dataset}.limit should not exceed 1000 for 1c-mcp-toolkit execute_query") + if spec.enabled and not spec.query: + issues.append(f"mcp_toolkit.datasets.{dataset}.query is required when dataset is enabled") + if spec.incremental and not spec.incremental.since_param and not spec.incremental.until_param: + issues.append(f"mcp_toolkit.datasets.{dataset}.incremental must define at least one of since_param/until_param") + if dataset not in loader_conf.landing: + issues.append(f"landing.{dataset} is missing from ETL config") + + if toolkit_conf.event_log.limit < 1: + issues.append("mcp_toolkit.event_log.limit must be >= 1") + if toolkit_conf.event_log.enabled and "reglog" not in loader_conf.landing: + issues.append("landing.reglog is missing from ETL config") + return issues + + +def flatten_value(value: Any) -> Any: + if value is None: + return "" + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return json.dumps([flatten_value(item) for item in value], ensure_ascii=False) + if isinstance(value, dict): + if value.get("_objectRef"): + return ( + value.get("Представление") + or value.get("Description") + or value.get("УникальныйИдентификатор") + or json.dumps(value, ensure_ascii=False) + ) + return json.dumps({key: flatten_value(item) for key, item in value.items()}, ensure_ascii=False) + return str(value) + + +def apply_field_map(row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]: + mapped: dict[str, Any] = {} + for key, value in row.items(): + mapped[field_map.get(str(key), str(key))] = flatten_value(value) + return mapped + + +def derive_entity_key(row: dict[str, Any]) -> str: + explicit = str(row.get("company_entity_key", "") or "").strip() + if explicit: + return explicit + return canonical_company_entity_key( + base_id=str(row.get("base_id", "") or "").strip(), + base_path=str(row.get("base_path", "") or "").strip(), + infobase=str(row.get("infobase", "") or "").strip(), + ) + + +def default_evidence_ref(dataset: str, row: dict[str, Any]) -> str: + if dataset == "business_events": + return f"mcp:business_event:{row.get('document_id') or row.get('document_number') or row.get('event_id') or 'unknown'}" + if dataset == "document_changes": + return f"mcp:document_change:{row.get('document_id') or row.get('document_number') or row.get('change_id') or 'unknown'}" + return "" + + +def normalize_query_row(dataset: str, row: dict[str, Any], spec: QueryDatasetConfig) -> dict[str, Any]: + mapped = apply_field_map(row, spec.field_map) + normalized = {**mapped, **spec.static_fields} + if dataset in {"documents", "postings", "business_events", "document_changes", "companies"}: + entity_key = derive_entity_key(normalized) + if entity_key: + normalized["company_entity_key"] = entity_key + if dataset == "companies" and not normalized.get("company_name"): + normalized["company_name"] = normalized.get("counterparty") or normalized.get("organization") or normalized.get("infobase", "") + if dataset in {"business_events", "document_changes"} and not normalized.get("evidence_ref"): + normalized["evidence_ref"] = default_evidence_ref(dataset, normalized) + return normalized + + +def render_reglog_message(row: dict[str, Any]) -> str: + message = str(row.get("comment", "") or "").strip() + if not message: + message = str(row.get("data_presentation", "") or "").strip() + extras: list[str] = [] + metadata = str(row.get("metadata", "") or "").strip() + if metadata: + extras.append(f"metadata={metadata}") + transaction_status = str(row.get("transaction_status", "") or "").strip() + if transaction_status: + extras.append(f"txn={transaction_status}") + session = row.get("session") + if session not in ("", None): + extras.append(f"session={session}") + if extras: + suffix = "; ".join(extras) + return f"{message} | {suffix}" if message else suffix + return message + + +def normalize_reglog_row(row: dict[str, Any], spec: EventLogConfig) -> dict[str, Any]: + mapped = apply_field_map(row, spec.field_map) + normalized = { + "ts": mapped.get("ts") or mapped.get("date", ""), + "infobase": mapped.get("infobase", ""), + "user": mapped.get("user", ""), + "host": mapped.get("host") or mapped.get("computer", ""), + "app": mapped.get("app") or mapped.get("application", ""), + "event_name": mapped.get("event_name") or mapped.get("event", ""), + "level": mapped.get("level", "info"), + "duration_ms": mapped.get("duration_ms", 0), + "message": mapped.get("message") or render_reglog_message(mapped), + } + normalized.update(spec.static_fields) + return normalized + + +def resolve_incremental_bounds(spec: QueryDatasetConfig, dataset_state: dict[str, Any], now: datetime) -> tuple[dict[str, Any], str]: + if spec.incremental is None: + return {}, "" + + cursor = str(dataset_state.get("last_success_ts", "") or "").strip() + if cursor: + since_dt = normalize_utc_datetime(cursor) + if spec.incremental.lookback_seconds > 0: + since_dt -= timedelta(seconds=spec.incremental.lookback_seconds) + elif spec.incremental.initial_since: + since_dt = normalize_utc_datetime(spec.incremental.initial_since) + elif spec.incremental.lookback_seconds > 0: + since_dt = now - timedelta(seconds=spec.incremental.lookback_seconds) + else: + since_dt = now + + params: dict[str, Any] = {} + if spec.incremental.since_param: + params[spec.incremental.since_param] = isoformat_seconds(since_dt) + if spec.incremental.until_param: + params[spec.incremental.until_param] = isoformat_seconds(now) + return params, isoformat_seconds(now) + + +def build_query_payload(spec: QueryDatasetConfig, dataset_state: dict[str, Any], now: datetime) -> tuple[dict[str, Any], str]: + payload = { + "query": spec.query, + "params": dict(spec.params), + "limit": spec.limit, + "include_schema": spec.include_schema, + } + window_params, completion_cursor = resolve_incremental_bounds(spec, dataset_state, now) + payload["params"].update(window_params) + return payload, completion_cursor + + +def build_query_payload_for_dry_run(spec: QueryDatasetConfig, dataset_state: dict[str, Any], now: datetime, sample_size: int) -> tuple[dict[str, Any], str]: + payload, completion_cursor = build_query_payload(spec, dataset_state, now) + payload["limit"] = min(max(1, sample_size), int(payload.get("limit", sample_size) or sample_size)) + payload["include_schema"] = True + return payload, completion_cursor + + +def build_output_path(loader_conf: LoaderConfig, dataset: str, now: datetime) -> Path: + landing_root = Path(loader_conf.landing[dataset]) + stamp = now.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") + return landing_root / f"{stamp}-mcp-{dataset}.jsonl" + + +def compact_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def print_dry_run_summary(dataset: str, payload: dict[str, Any], rows: list[dict[str, Any]], response: dict[str, Any], *, sample_size: int) -> None: + schema = response.get("schema", {}) + schema_columns = list(schema.get("columns", []) or []) if isinstance(schema, dict) else [] + print(f"dry-run {dataset}: rows={len(rows)} schema_columns={len(schema_columns)}") + print(f"dry-run {dataset}: request={compact_json(payload)}") + if schema_columns: + preview_columns = schema_columns[: min(sample_size, len(schema_columns))] + print(f"dry-run {dataset}: schema_preview={compact_json(preview_columns)}") + if rows: + preview_rows = rows[: min(sample_size, len(rows))] + print(f"dry-run {dataset}: sample={compact_json(preview_rows)}") + + +def extract_query_dataset( + dataset: str, + spec: QueryDatasetConfig, + client: ToolkitClient, + loader_conf: LoaderConfig, + state: dict[str, Any], + now: datetime, + options: RunOptions, +) -> int: + if not spec.enabled: + print(f"skip {dataset}: disabled") + return 0 + if not spec.query: + raise ValueError(f"{dataset}: enabled but query is empty") + + dataset_state = dict(state.setdefault("datasets", {}).get(dataset, {}) or {}) + if options.dry_run: + payload, completion_cursor = build_query_payload_for_dry_run(spec, dataset_state, now, options.sample_size) + else: + payload, completion_cursor = build_query_payload(spec, dataset_state, now) + response = client.post_json("execute_query", payload) + rows = [normalize_query_row(dataset, row, spec) for row in list(response.get("data", []) or [])] + if options.dry_run: + print_dry_run_summary(dataset, payload, rows, response, sample_size=options.sample_size) + return len(rows) + if rows: + out = build_output_path(loader_conf, dataset, now) + write_jsonl(out, rows, min_age_seconds=loader_conf.min_file_age_seconds) + print(f"extracted {dataset}: rows={len(rows)} file={out}") + else: + print(f"extracted {dataset}: rows=0") + + if completion_cursor: + dataset_state["last_success_ts"] = completion_cursor + dataset_state["last_row_count"] = len(rows) + dataset_state["last_run_at"] = isoformat_seconds(now) + state["datasets"][dataset] = dataset_state + return len(rows) + + +def build_event_log_payload(spec: EventLogConfig, cursor: dict[str, Any], end_date: str) -> dict[str, Any]: + payload: dict[str, Any] = { + "end_date": end_date, + "limit": spec.limit, + } + if cursor.get("start_date"): + payload["start_date"] = cursor["start_date"] + if cursor.get("same_second_offset", 0): + payload["same_second_offset"] = int(cursor["same_second_offset"]) + if spec.levels: + payload["levels"] = spec.levels + if spec.events: + payload["events"] = spec.events + for key, value in spec.filters.items(): + if value not in ("", None, [], {}): + payload[key] = value + return payload + + +def initial_reglog_cursor(spec: EventLogConfig, state: dict[str, Any], now: datetime) -> dict[str, Any]: + reglog_state = dict(state.get("reglog", {}) or {}) + if reglog_state.get("last_date"): + return { + "start_date": str(reglog_state.get("last_date")), + "same_second_offset": int(reglog_state.get("same_second_offset", 0) or 0), + } + if spec.initial_start_date: + start_dt = normalize_utc_datetime(spec.initial_start_date) + elif spec.lookback_seconds > 0: + start_dt = now - timedelta(seconds=spec.lookback_seconds) + else: + start_dt = now + return {"start_date": isoformat_seconds(start_dt), "same_second_offset": 0} + + +def extract_reglog_dataset( + client: ToolkitClient, + loader_conf: LoaderConfig, + toolkit_conf: ToolkitConfig, + state: dict[str, Any], + now: datetime, + options: RunOptions, +) -> int: + spec = toolkit_conf.event_log + if not spec.enabled: + print("skip reglog: disabled") + return 0 + + cursor = initial_reglog_cursor(spec, state, now) + end_date = isoformat_seconds(now) + rows: list[dict[str, Any]] = [] + last_date = cursor["start_date"] + next_offset = int(cursor.get("same_second_offset", 0) or 0) + page_no = 0 + + while True: + page_no += 1 + payload = build_event_log_payload(spec, cursor, end_date) + if options.dry_run: + payload["limit"] = min(max(1, options.sample_size), int(payload.get("limit", options.sample_size) or options.sample_size)) + response = client.post_json("get_event_log", payload) + batch = [normalize_reglog_row(row, spec) for row in list(response.get("data", []) or [])] + rows.extend(batch) + last_date = str(response.get("last_date") or last_date or end_date) + next_offset = int(response.get("next_same_second_offset", 0) or 0) + has_more = bool(response.get("has_more", False)) + if options.dry_run: + print_dry_run_summary("reglog", payload, batch, response, sample_size=options.sample_size) + if len(rows) >= options.sample_size: + break + if options.max_pages > 0 and page_no >= options.max_pages: + break + if not has_more: + break + cursor = {"start_date": last_date, "same_second_offset": next_offset} + + if options.dry_run: + print(f"dry-run reglog: total_sampled_rows={len(rows)}") + return len(rows) + + if rows: + out = build_output_path(loader_conf, "reglog", now) + write_jsonl(out, rows, min_age_seconds=loader_conf.min_file_age_seconds) + print(f"extracted reglog: rows={len(rows)} file={out}") + else: + print("extracted reglog: rows=0") + + state["reglog"] = { + "last_date": last_date if rows else end_date, + "same_second_offset": next_offset if rows else 0, + "last_row_count": len(rows), + "last_run_at": end_date, + } + return len(rows) + + +def main() -> int: + args = parse_args() + loader_conf, toolkit_conf = load_runtime_config(args.config) + options = RunOptions( + dry_run=bool(args.dry_run), + sample_size=max(1, int(args.sample_size or 1)), + max_pages=max(1, int(args.max_pages or 1)), + validate_config=bool(args.validate_config), + ) + issues = validate_runtime_config(loader_conf, toolkit_conf) + if issues: + for issue in issues: + print(f"config-error: {issue}") + return 2 + if options.validate_config: + print("config-ok") + return 0 + client = ToolkitClient(toolkit_conf) + state = load_state(toolkit_conf) if not options.dry_run else {"datasets": {}, "reglog": {}} + now = utc_now() + + datasets = [args.dataset] if args.dataset else list(SUPPORTED_DATASETS) + total_rows = 0 + + for dataset in datasets: + if dataset == "reglog": + total_rows += extract_reglog_dataset(client, loader_conf, toolkit_conf, state, now, options) + continue + total_rows += extract_query_dataset(dataset, toolkit_conf.datasets[dataset], client, loader_conf, state, now, options) + + if not options.dry_run: + state["last_run_at"] = isoformat_seconds(now) + save_state(toolkit_conf, state) + print(f"{'dry-run' if options.dry_run else 'done'}: datasets={','.join(datasets)} total_rows={total_rows}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/clickhouse-1c/etl/load_1c_exports.py b/clickhouse-1c/etl/load_1c_exports.py index 287215e..a58ebfc 100644 --- a/clickhouse-1c/etl/load_1c_exports.py +++ b/clickhouse-1c/etl/load_1c_exports.py @@ -10,7 +10,6 @@ from datetime import UTC, datetime from pathlib import Path from typing import Any -import clickhouse_connect import yaml from dateutil import parser as date_parser @@ -67,6 +66,8 @@ def load_config(path: str) -> Config: def ch_client(conf: Config): + import clickhouse_connect + return clickhouse_connect.get_client( host=conf.clickhouse["host"], port=conf.clickhouse.get("port", 8123), diff --git a/clickhouse-1c/etl/test_extract_1c_mcp_toolkit.py b/clickhouse-1c/etl/test_extract_1c_mcp_toolkit.py new file mode 100644 index 0000000..da823c5 --- /dev/null +++ b/clickhouse-1c/etl/test_extract_1c_mcp_toolkit.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import sys +import tempfile +import unittest +from datetime import UTC, datetime +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.modules.setdefault("clickhouse_connect", SimpleNamespace(get_client=lambda **_: None)) + +import extract_1c_mcp_toolkit as extractor +from load_1c_exports import Config as LoaderConfig + + +class Extract1CMcpToolkitTests(unittest.TestCase): + def test_validate_runtime_config_flags_invalid_enabled_query_and_limit(self) -> None: + loader_conf = LoaderConfig( + clickhouse={}, + landing={ + "documents": "/tmp/documents", + "postings": "/tmp/postings", + "business_events": "/tmp/business_events", + "document_changes": "/tmp/document_changes", + "companies": "/tmp/companies", + "reglog": "/tmp/reglog", + "audit": "/tmp/audit", + "host": "/tmp/host", + }, + formats={"default": "jsonl"}, + archive_dir=None, + delete_after_load=False, + min_file_age_seconds=180, + ) + toolkit_conf = extractor.ToolkitConfig( + base_url="http://127.0.0.1:6003", + channel="prod", + timeout_seconds=30, + verify_tls=True, + state_dir="./state", + datasets={ + "documents": extractor.QueryDatasetConfig(enabled=True, query="", limit=1001), + "postings": extractor.QueryDatasetConfig(enabled=False, query="SELECT"), + "business_events": extractor.QueryDatasetConfig(enabled=False, query="SELECT"), + "document_changes": extractor.QueryDatasetConfig(enabled=False, query="SELECT"), + "companies": extractor.QueryDatasetConfig(enabled=False, query="SELECT"), + }, + event_log=extractor.EventLogConfig(enabled=False, limit=500), + ) + issues = extractor.validate_runtime_config(loader_conf, toolkit_conf) + self.assertIn("mcp_toolkit.datasets.documents.query is required when dataset is enabled", issues) + self.assertIn("mcp_toolkit.datasets.documents.limit should not exceed 1000 for 1c-mcp-toolkit execute_query", issues) + + def test_normalize_query_row_derives_entity_key_and_evidence(self) -> None: + spec = extractor.QueryDatasetConfig( + enabled=True, + query="SELECT", + field_map={"Номер": "document_number", "База": "infobase", "Путь": "base_path"}, + static_fields={"organization": "ООО Тест"}, + ) + row = { + "Номер": "0001", + "База": "ФЕЛИЦТ ГРУПП 2026", + "Путь": r"c:\1c\felict", + } + normalized = extractor.normalize_query_row("document_changes", row, spec) + self.assertEqual(normalized["document_number"], "0001") + self.assertEqual(normalized["organization"], "ООО Тест") + self.assertTrue(normalized["company_entity_key"].startswith("basepath:")) + self.assertEqual(normalized["evidence_ref"], "mcp:document_change:0001") + + def test_resolve_incremental_bounds_applies_lookback(self) -> None: + spec = extractor.QueryDatasetConfig( + enabled=True, + query="SELECT", + incremental=extractor.IncrementalConfig( + since_param="since_ts", + until_param="until_ts", + initial_since="2026-05-01T00:00:00Z", + lookback_seconds=120, + ), + ) + now = datetime(2026, 5, 23, 10, 30, tzinfo=UTC) + params, completion_cursor = extractor.resolve_incremental_bounds( + spec, + {"last_success_ts": "2026-05-23T10:00:00Z"}, + now, + ) + self.assertEqual(params["since_ts"], "2026-05-23T09:58:00Z") + self.assertEqual(params["until_ts"], "2026-05-23T10:30:00Z") + self.assertEqual(completion_cursor, "2026-05-23T10:30:00Z") + + def test_normalize_reglog_row_renders_message(self) -> None: + spec = extractor.EventLogConfig( + enabled=True, + static_fields={"infobase": "ИБ-1"}, + ) + row = { + "date": "2026-05-23T11:00:00", + "user": "Иванов И.И.", + "computer": "WS-01", + "application": "ThinClient", + "event": "_$Data$_.Post", + "level": "Error", + "comment": "Ошибка проведения", + "metadata": "Документ.РеализацияТоваровУслуг", + "transaction_status": "RolledBack", + "session": 42, + } + normalized = extractor.normalize_reglog_row(row, spec) + self.assertEqual(normalized["infobase"], "ИБ-1") + self.assertEqual(normalized["host"], "WS-01") + self.assertEqual(normalized["app"], "ThinClient") + self.assertEqual(normalized["event_name"], "_$Data$_.Post") + self.assertIn("metadata=Документ.РеализацияТоваровУслуг", normalized["message"]) + self.assertIn("txn=RolledBack", normalized["message"]) + + def test_extract_reglog_dataset_updates_cursor_and_writes_jsonl(self) -> None: + class FakeClient: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + self.responses = [ + { + "success": True, + "data": [ + { + "date": "2026-05-23T11:00:00", + "user": "USER1", + "computer": "WS-01", + "application": "ThinClient", + "event": "_$Data$_.Post", + "level": "Error", + "comment": "Ошибка 1", + } + ], + "last_date": "2026-05-23T11:00:00", + "next_same_second_offset": 1, + "has_more": True, + }, + { + "success": True, + "data": [ + { + "date": "2026-05-23T11:00:01", + "user": "USER2", + "computer": "WS-02", + "application": "ThinClient", + "event": "_$Data$_.Update", + "level": "Warning", + "comment": "Изменение", + } + ], + "last_date": "2026-05-23T11:00:01", + "next_same_second_offset": 0, + "has_more": False, + }, + ] + + def post_json(self, endpoint: str, payload: dict[str, object]) -> dict[str, object]: + self.calls.append({"endpoint": endpoint, "payload": payload}) + return self.responses.pop(0) + + now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) + with tempfile.TemporaryDirectory() as tmpdir: + loader_conf = LoaderConfig( + clickhouse={}, + landing={ + "documents": f"{tmpdir}/documents", + "postings": f"{tmpdir}/postings", + "business_events": f"{tmpdir}/business_events", + "document_changes": f"{tmpdir}/document_changes", + "companies": f"{tmpdir}/companies", + "reglog": f"{tmpdir}/reglog", + "audit": f"{tmpdir}/audit", + "host": f"{tmpdir}/host", + }, + formats={"default": "jsonl"}, + archive_dir=None, + delete_after_load=False, + min_file_age_seconds=180, + ) + toolkit_conf = extractor.ToolkitConfig( + base_url="http://127.0.0.1:6003", + channel="prod", + timeout_seconds=30, + verify_tls=True, + state_dir=f"{tmpdir}/state", + datasets={dataset: extractor.QueryDatasetConfig(enabled=False, query="") for dataset in extractor.QUERY_DATASETS}, + event_log=extractor.EventLogConfig(enabled=True, initial_start_date="2026-05-23T10:00:00Z", static_fields={"infobase": "ИБ-1"}), + ) + state = {"datasets": {}, "reglog": {}} + client = FakeClient() + options = extractor.RunOptions(dry_run=False, sample_size=3, max_pages=1, validate_config=False) + + rows = extractor.extract_reglog_dataset(client, loader_conf, toolkit_conf, state, now, options) + + self.assertEqual(rows, 2) + self.assertEqual(client.calls[0]["endpoint"], "get_event_log") + self.assertEqual(client.calls[1]["payload"]["start_date"], "2026-05-23T11:00:00") + self.assertEqual(client.calls[1]["payload"]["same_second_offset"], 1) + self.assertEqual(state["reglog"]["last_date"], "2026-05-23T11:00:01") + self.assertEqual(state["reglog"]["same_second_offset"], 0) + reglog_files = list(Path(loader_conf.landing["reglog"]).glob("*.jsonl")) + self.assertEqual(len(reglog_files), 1) + payload = reglog_files[0].read_text(encoding="utf-8") + self.assertIn('"infobase": "ИБ-1"', payload) + self.assertIn('"event_name": "_$Data$_.Update"', payload) + + def test_extract_query_dataset_dry_run_does_not_write_or_update_state(self) -> None: + class FakeClient: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def post_json(self, endpoint: str, payload: dict[str, object]) -> dict[str, object]: + self.calls.append({"endpoint": endpoint, "payload": payload}) + return { + "success": True, + "data": [ + { + "infobase": "ИБ-1", + "doc_number": "0001", + "doc_id": "DOC-1", + "amount": 10, + } + ], + "schema": {"columns": [{"name": "doc_number", "types": ["Строка"]}]}, + } + + now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC) + with tempfile.TemporaryDirectory() as tmpdir: + loader_conf = LoaderConfig( + clickhouse={}, + landing={ + "documents": f"{tmpdir}/documents", + "postings": f"{tmpdir}/postings", + "business_events": f"{tmpdir}/business_events", + "document_changes": f"{tmpdir}/document_changes", + "companies": f"{tmpdir}/companies", + "reglog": f"{tmpdir}/reglog", + "audit": f"{tmpdir}/audit", + "host": f"{tmpdir}/host", + }, + formats={"default": "jsonl"}, + archive_dir=None, + delete_after_load=False, + min_file_age_seconds=180, + ) + spec = extractor.QueryDatasetConfig( + enabled=True, + query="SELECT *", + limit=500, + incremental=extractor.IncrementalConfig(since_param="since_ts", until_param="until_ts", initial_since="2026-05-01T00:00:00Z"), + ) + state = {"datasets": {"documents": {"last_success_ts": "2026-05-23T10:00:00Z"}}, "reglog": {}} + client = FakeClient() + options = extractor.RunOptions(dry_run=True, sample_size=2, max_pages=1, validate_config=False) + + rows = extractor.extract_query_dataset("documents", spec, client, loader_conf, state, now, options) + + self.assertEqual(rows, 1) + self.assertEqual(client.calls[0]["endpoint"], "execute_query") + self.assertEqual(client.calls[0]["payload"]["limit"], 2) + self.assertTrue(client.calls[0]["payload"]["include_schema"]) + self.assertEqual(state["datasets"]["documents"]["last_success_ts"], "2026-05-23T10:00:00Z") + self.assertFalse(Path(loader_conf.landing["documents"]).exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json b/clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json new file mode 100644 index 0000000..d95baaa --- /dev/null +++ b/clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json @@ -0,0 +1,834 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [ + { + "title": "Management Board", + "url": "/d/1c-file-mgmt/1c-file-management-board" + }, + { + "title": "Company Intelligence", + "url": "/d/1c-file-companies/1c-file-company-intelligence" + }, + { + "title": "Manager Brief", + "url": "http://10.10.10.2:8710/manager/brief" + } + ], + "panels": [ + { + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "content": "### Финансовая отчетность v1\n- Этот экран отделяет **настоящие ledger-данные** от текущего **proxy слоя**.\n- `ledger` строится только по `posting`/account lines. Если они не приходят, экран честно показывает `proxy_only`.\n- `proxy` сейчас основан на `CompanyActivitySnapshot` и помогает оценить масштаб базы, но не заменяет бухгалтерскую отчетность.\n- Как только в landing начнут поступать реальные `postings`, те же панели автоматически станут финансово содержательными без смены дашборда.", + "mode": "markdown" + }, + "title": "Как читать экран", + "transparent": true, + "type": "text" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "green", + "value": 2 + } + ] + }, + "mappings": [ + { + "options": { + "empty": { + "text": "EMPTY" + }, + "ledger_ready": { + "text": "LEDGER READY" + }, + "proxy_only": { + "text": "PROXY ONLY" + } + }, + "type": "value" + } + ] + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 4 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT readiness_status AS value FROM analytics_1c.v_financial_reporting_readiness", + "refId": "A" + } + ], + "title": "Готовность финансового слоя", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 4 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT round(sumIf(amount_total, source_kind = 'ledger' AND d >= today() - 30), 2) AS value FROM analytics_1c.v_financial_daily", + "refId": "A" + } + ], + "title": "Ledger amount 30д", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT round(sumIf(amount_total, source_kind = 'proxy' AND d >= today() - 30), 2) AS value FROM analytics_1c.v_financial_daily", + "refId": "A" + } + ], + "title": "Proxy amount 30д", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT countIf(readiness_status = 'ledger_ready') AS value FROM analytics_1c.v_financial_company_current", + "refId": "A" + } + ], + "title": "Компании с ledger-данными", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 9 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT ledger_events_total AS value FROM analytics_1c.v_financial_reporting_readiness", + "refId": "A" + } + ], + "title": "Ledger events total", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 9 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT account_lines_total AS value FROM analytics_1c.v_financial_reporting_readiness", + "refId": "A" + } + ], + "title": "Account lines total", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 9 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT postings_table_rows AS value FROM analytics_1c.v_financial_reporting_readiness", + "refId": "A" + } + ], + "title": "Rows in postings table", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 9 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT count() AS value FROM analytics_1c.document_change_events WHERE ts >= now() - INTERVAL 30 DAY AND risk_tag != ''", + "refId": "A" + } + ], + "title": "Risk-tag changes 30д", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 4, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "timeSeries", + "rawSql": "SELECT toDateTime(d) AS time, source_kind AS metric, sum(amount_total) AS value FROM analytics_1c.v_financial_daily WHERE d >= today() - 30 GROUP BY time, metric ORDER BY time, metric", + "refId": "A" + } + ], + "title": "Daily amount: ledger vs proxy", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 4, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "timeSeries", + "rawSql": "SELECT toDateTime(d) AS time, source_kind AS metric, sum(events_total) AS value FROM analytics_1c.v_financial_daily WHERE d >= today() - 30 GROUP BY time, metric ORDER BY time, metric", + "refId": "A" + } + ], + "title": "Daily events: ledger vs proxy", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 14, + "x": 0, + "y": 22 + }, + "id": 12, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "ledger_amount_30d", + "desc": true + }, + { + "displayName": "proxy_amount_30d", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT infobase, company_name, organization, owner_user, readiness_status, ledger_amount_30d, ledger_events_30d, proxy_amount_30d, proxy_events_30d, risky_changes_30d, changes_30d, last_financial_day FROM analytics_1c.v_financial_company_current ORDER BY ledger_amount_30d DESC, proxy_amount_30d DESC LIMIT 20", + "refId": "A" + } + ], + "title": "Компании: финансовый контур 30д", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 10, + "x": 14, + "y": 22 + }, + "id": 13, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "amount_total", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT source_kind, document_type, operation_type, events_total, infobases_total, amount_total FROM analytics_1c.v_financial_document_types_30d ORDER BY amount_total DESC, events_total DESC LIMIT 20", + "refId": "A" + } + ], + "title": "Document types 30д", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 14, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "amount_total", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT side, account, entries_total, infobases_total, amount_total FROM analytics_1c.v_financial_accounts_30d ORDER BY amount_total DESC, entries_total DESC LIMIT 20", + "refId": "A" + } + ], + "title": "Accounts turnover 30д", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 15, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "ts", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT ts, infobase, document_type, change_kind, field_name, user, risk_tag FROM analytics_1c.document_change_events WHERE ts >= now() - INTERVAL 30 DAY ORDER BY ts DESC LIMIT 20", + "refId": "A" + } + ], + "title": "Последние изменения документов", + "type": "table" + } + ], + "refresh": "1m", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "1c", + "financial", + "clickhouse", + "management" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-30d", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "1C File - Financial Reporting", + "uid": "1c-file-finance", + "version": 1, + "weekStart": "" +} diff --git a/clickhouse-1c/grafana/provisioning/dashboards/files/1c-management-board.json b/clickhouse-1c/grafana/provisioning/dashboards/files/1c-management-board.json new file mode 100644 index 0000000..4484f6d --- /dev/null +++ b/clickhouse-1c/grafana/provisioning/dashboards/files/1c-management-board.json @@ -0,0 +1,898 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [ + { + "title": "Manager Brief", + "url": "http://10.10.10.2:8710/manager/brief" + }, + { + "title": "Manager Actions", + "url": "http://10.10.10.2:8710/manager/actions" + }, + { + "title": "Weekly Digest", + "url": "http://10.10.10.2:8710/manager/digest/weekly" + }, + { + "title": "Recovery", + "url": "http://10.10.10.2:8710/manager/recovery" + }, + { + "title": "Company Intelligence", + "url": "/d/1c-file-companies/1c-file-company-intelligence" + }, + { + "title": "Financial Reporting", + "url": "/d/1c-file-finance/1c-file-financial-reporting" + }, + { + "title": "Telemetry Board", + "url": "/d/1c-file-telemetry/1c-file-telemetry-board" + }, + { + "title": "Operations Health", + "url": "/d/1c-file-ops/1c-file-operations-health" + } + ], + "panels": [ + { + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "content": "### Управленческий экран по файловой 1С\n- Верхний ряд показывает масштаб портфеля и объём компаний, где нужен управленческий разбор.\n- Средний ряд отвечает на вопрос, где контур деградирует: stale-активность, lock/busy и пропущенный scheduler touch.\n- Нижний блок даёт короткую очередь действий: портфель приоритетов, последние AI-сигналы и переходы в brief/actions/digest.\n- `amount` в этом контуре — это activity score, а не деньги. Экран предназначен для management/risk oversight, а не для финансовой отчётности.", + "mode": "markdown" + }, + "title": "Как читать экран", + "transparent": true, + "type": "text" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 50 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 4 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT count() AS value FROM analytics_1c.v_companies_current", + "refId": "A" + } + ], + "title": "Компаний в контуре", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 4 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT round(sum(predicted_total), 2) AS value FROM analytics_1c.v_company_forecasts_current WHERE metric = 'amount_total' AND horizon_days = 30", + "refId": "A" + } + ], + "title": "Прогноз активности 30д", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 4 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT countDistinct(counterparty) AS value FROM analytics_1c.v_company_health_current WHERE severity IN ('high', 'critical')", + "refId": "A" + } + ], + "title": "Компании с high/critical сигналами", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT count() AS value FROM analytics_1c.cases WHERE entity_type = 'counterparty' AND status != 'closed'", + "refId": "A" + } + ], + "title": "Открытые кейсы", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 9 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT countIf(active_locks > 0) AS value FROM analytics_1c.v_company_portfolio_overview", + "refId": "A" + } + ], + "title": "Компании с lock/busy", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 9 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT countIf(days_since_last_activity > 7) AS value FROM analytics_1c.v_company_portfolio_overview", + "refId": "A" + } + ], + "title": "Stale > 7 дней", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 9 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT countIf(scheduler_touched = 0) AS value FROM analytics_1c.v_company_portfolio_overview", + "refId": "A" + } + ], + "title": "Без scheduler touch", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 9 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT countIf(registry_match_mode = 'manual') AS value FROM analytics_1c.v_company_portfolio_overview", + "refId": "A" + } + ], + "title": "Manual matching", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 4, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "timeSeries", + "rawSql": "SELECT toDateTime(d) AS time, sum(amount_total) AS value FROM analytics_1c.v_counterparty_daily WHERE d >= today() - 30 GROUP BY time ORDER BY time", + "refId": "A" + } + ], + "title": "Активность по компаниям", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 4, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "timeSeries", + "rawSql": "SELECT toDateTime(d) AS time, sum(docs_total) AS value FROM analytics_1c.v_counterparty_daily WHERE d >= today() - 30 GROUP BY time ORDER BY time", + "refId": "A" + } + ], + "title": "Количество событий по компаниям", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 22 + }, + "id": 12, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "companies", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT signal_severity, count() AS companies FROM analytics_1c.v_company_portfolio_overview GROUP BY signal_severity ORDER BY companies DESC", + "refId": "A" + } + ], + "title": "Распределение по severity", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 16, + "x": 8, + "y": 22 + }, + "id": 13, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "score", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT generated_at, infobase, counterparty, severity, score, signal_type, summary FROM analytics_1c.v_company_health_current ORDER BY score DESC, generated_at DESC LIMIT 20", + "refId": "A" + } + ], + "title": "Последние AI-сигналы", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 30 + }, + "id": 14, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "signal_score", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT infobase, company_name, owner_user, current_status, active_locks, days_since_last_activity, open_cases_total, detections_total, amount_30d, signal_severity, signal_score, top_signal, last_seen_at FROM analytics_1c.v_company_portfolio_overview ORDER BY signal_score DESC, open_cases_total DESC, active_locks DESC, amount_30d DESC LIMIT 20", + "refId": "A" + } + ], + "title": "Портфель приоритетов", + "type": "table" + } + ], + "refresh": "1m", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "1c", + "management", + "executive", + "clickhouse" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-30d", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "1C File - Management Board", + "uid": "1c-file-mgmt", + "version": 1, + "weekStart": "" +} diff --git a/clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json b/clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json new file mode 100644 index 0000000..1b6f778 --- /dev/null +++ b/clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json @@ -0,0 +1,784 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [ + { + "title": "Management Board", + "url": "/d/1c-file-mgmt/1c-file-management-board" + }, + { + "title": "Operations Health", + "url": "/d/1c-file-ops/1c-file-operations-health" + }, + { + "title": "Financial Reporting", + "url": "/d/1c-file-finance/1c-file-financial-reporting" + }, + { + "title": "Manager Brief", + "url": "http://10.10.10.2:8710/manager/brief" + } + ], + "panels": [ + { + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "content": "### Telemetry Board по файловой 1С\n- Экран показывает только уже доступный read-only telemetry слой: состояние баз, размеры, busy markers, рост reglog и состояние Windows host.\n- Это не бухгалтерский ledger и не финансовая отчётность. Если нужны реальные проводки, они должны прийти отдельным sanctioned export path.\n- Практический смысл экрана: быстро увидеть, какие базы живы, заняты, растут по журналу регистрации и за кем они закреплены.", + "mode": "markdown" + }, + "title": "Как читать экран", + "transparent": true, + "type": "text" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 4 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT count() AS value FROM analytics_1c.v_companies_current", + "refId": "A" + } + ], + "title": "Баз в контуре", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 50 + }, + { + "color": "red", + "value": 200 + } + ] + }, + "unit": "gbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 4 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT round(sum(db_size_bytes) / 1073741824, 2) AS value FROM analytics_1c.v_companies_current", + "refId": "A" + } + ], + "title": "Суммарный размер баз", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 4 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT sum(active_locks) AS value FROM analytics_1c.v_companies_current", + "refId": "A" + } + ], + "title": "Активные locks", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 2 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 4 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT countIf(current_status = 'busy' OR active_locks > 0 OR temp_db_present = 1) AS value FROM analytics_1c.v_companies_current", + "refId": "A" + } + ], + "title": "Базы busy/risk", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "gbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 4 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT round(sum(reglog_size_bytes) / 1073741824, 2) AS value FROM analytics_1c.v_companies_current", + "refId": "A" + } + ], + "title": "Reglog общий", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 3 + }, + { + "color": "red", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 20, + "y": 4 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT sum(rdp_sessions) AS value FROM (SELECT host, argMax(rdp_sessions, ts) AS rdp_sessions FROM analytics_1c.host_events GROUP BY host)", + "refId": "A" + } + ], + "title": "RDP sessions now", + "type": "stat" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 9 + }, + "id": 8, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "timeSeries", + "rawSql": "SELECT ts AS time, infobase AS metric, activity_score AS value FROM analytics_1c.companies WHERE $__timeFilter(ts) ORDER BY time, metric", + "refId": "A" + } + ], + "title": "Activity score по базам", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "gbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 9 + }, + "id": 9, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "timeSeries", + "rawSql": "SELECT ts AS time, infobase AS metric, round(reglog_size_bytes / 1073741824, 2) AS value FROM analytics_1c.companies WHERE $__timeFilter(ts) ORDER BY time, metric", + "refId": "A" + } + ], + "title": "Рост reglog по базам", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 18 + }, + "id": 10, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "activity_score", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT last_company_snapshot_at, infobase, owner_user, base_path, current_status, round(db_size_bytes / 1073741824, 2) AS db_size_gb, round(reglog_size_bytes / 1073741824, 2) AS reglog_size_gb, active_locks, temp_db_present, scheduler_touched, round(current_activity_score, 2) AS activity_score FROM analytics_1c.v_companies_current ORDER BY current_activity_score DESC, active_locks DESC, reglog_size_bytes DESC", + "refId": "A" + } + ], + "title": "Текущее состояние баз", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 14, + "x": 0, + "y": 28 + }, + "id": 11, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "ts", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT ts, infobase, user, event_name, level, message FROM analytics_1c.reglog_events WHERE event_name IN ('CompanyActivitySnapshot', 'FileBaseBusy', 'RegLogInventory', 'JobSchedulerActivity') AND $__timeFilter(ts) ORDER BY ts DESC LIMIT 100", + "refId": "A" + } + ], + "title": "Последние operational signals", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 10, + "x": 14, + "y": 28 + }, + "id": 12, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "timeSeries", + "rawSql": "SELECT ts AS time, cpu_pct AS cpu_pct, ram_pct AS ram_pct, rdp_sessions AS rdp_sessions FROM analytics_1c.host_events WHERE $__timeFilter(ts) ORDER BY time", + "refId": "A" + } + ], + "title": "Host load / RDP", + "type": "timeseries" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 38 + }, + "id": 13, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "owner_user", + "desc": false + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT owner_user, infobase, company_name, base_path, current_status, active_locks, last_company_snapshot_at FROM analytics_1c.v_companies_current ORDER BY owner_user, infobase", + "refId": "A" + } + ], + "title": "Пользователь -> база", + "type": "table" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 38 + }, + "id": 14, + "options": { + "showHeader": true, + "sortBy": [ + { + "displayName": "last_seen_at", + "desc": true + } + ] + }, + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "clickhouse-1c" + }, + "editorType": "sql", + "format": 1, + "pluginVersion": "11.2.2", + "queryType": "table", + "rawSql": "SELECT host, argMax(cpu_pct, ts) AS cpu_pct, argMax(ram_pct, ts) AS ram_pct, argMax(disk_free_gb, ts) AS disk_free_gb, argMax(rdp_sessions, ts) AS rdp_sessions, argMax(backup_ok, ts) AS backup_ok, max(ts) AS last_seen_at FROM analytics_1c.host_events GROUP BY host ORDER BY host", + "refId": "A" + } + ], + "title": "Последнее состояние host", + "type": "table" + } + ], + "refresh": "1m", + "schemaVersion": 39, + "style": "dark", + "tags": [ + "1c", + "telemetry", + "ops", + "clickhouse" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-7d", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "1C File - Telemetry Board", + "uid": "1c-file-telemetry", + "version": 1, + "weekStart": "" +} diff --git a/clickhouse-1c/grafana/query-pack.sql b/clickhouse-1c/grafana/query-pack.sql index 52ecdcb..a374852 100644 --- a/clickhouse-1c/grafana/query-pack.sql +++ b/clickhouse-1c/grafana/query-pack.sql @@ -64,3 +64,37 @@ FROM analytics_1c.entity_timeline WHERE entity_type = {entity_type:String} AND entity_id = {entity_id:String} ORDER BY ts; + +-- Financial: reporting readiness +SELECT + readiness_status, + last_event_at, + last_ledger_event_at, + business_events_total, + ledger_events_total, + account_lines_total, + postings_table_rows +FROM analytics_1c.v_financial_reporting_readiness; + +-- Financial: daily amount split between ledger and proxy +SELECT + toDateTime(d) AS time, + source_kind, + sum(amount_total) AS value +FROM analytics_1c.v_financial_daily +WHERE d >= today() - 30 +GROUP BY time, source_kind +ORDER BY time, source_kind; + +-- Financial: top companies by current turnover/proxy +SELECT + infobase, + company_name, + readiness_status, + ledger_amount_30d, + proxy_amount_30d, + risky_changes_30d, + last_financial_day +FROM analytics_1c.v_financial_company_current +ORDER BY ledger_amount_30d DESC, proxy_amount_30d DESC +LIMIT 20; diff --git a/clickhouse-1c/ops/run_ingest_cycle.sh b/clickhouse-1c/ops/run_ingest_cycle.sh index 1d3c887..07a9f65 100644 --- a/clickhouse-1c/ops/run_ingest_cycle.sh +++ b/clickhouse-1c/ops/run_ingest_cycle.sh @@ -10,6 +10,7 @@ LOCK_FILE="${ROOT}/.ingest.lock" RUN_MANAGER_BRIEF_AFTER_INGEST="${AW_1C_MANAGER_BRIEF_RUN_AFTER_INGEST:-1}" RUN_RECOVERY_BRIEF_AFTER_INGEST="${AW_1C_RECOVERY_BRIEF_RUN_AFTER_INGEST:-1}" RUN_PROOFCHECK_AFTER_INGEST="${AW_1C_PROOFCHECK_RUN_AFTER_INGEST:-1}" +RUN_MCP_TOOLKIT_EXTRACT_BEFORE_INGEST="${AW_1C_MCP_TOOLKIT_EXTRACT_BEFORE_INGEST:-0}" if [[ ! -f "${ENV_FILE}" ]]; then echo "missing env file: ${ENV_FILE}" >&2 @@ -40,6 +41,10 @@ fi # shellcheck disable=SC1090 . "${ENV_FILE}" +if [[ "${RUN_MCP_TOOLKIT_EXTRACT_BEFORE_INGEST}" == "1" ]]; then + "${VENV}/bin/python" "${ROOT}/etl/extract_1c_mcp_toolkit.py" --config "${CONFIG}" +fi + "${VENV}/bin/python" "${ROOT}/etl/build_business_event_exports.py" --config "${CONFIG}" "${VENV}/bin/python" "${ROOT}/etl/load_1c_exports.py" --config "${CONFIG}" "${VENV}/bin/python" "${ROOT}/etl/load_company_registry_xlsx.py" --config "${CONFIG}" --landing "${ROOT}/landing/registry" diff --git a/detmir-mcp/.env.example b/detmir-mcp/.env.example new file mode 100644 index 0000000..95d9b21 --- /dev/null +++ b/detmir-mcp/.env.example @@ -0,0 +1,15 @@ +DETMIR_AW_BASE=http://10.10.10.13:5600 +DETMIR_WORKTIME_BASE=http://10.10.10.13:5610 +DETMIR_DLP_POLICY_BASE=http://10.10.10.13:5601 +DETMIR_DLP_CASE_BASE=http://10.10.10.13:5602 +DETMIR_ONEC_BASE=http://10.10.10.2:8710 +DETMIR_GRAFANA_URL=http://10.10.10.11:3000 +DETMIR_GRAFANA_USER= +DETMIR_GRAFANA_PASSWORD= +DETMIR_DEFAULT_HOST=SHARKON2025 +DETMIR_HTTP_TIMEOUT_SECONDS=10 +DETMIR_MCP_TRANSPORT=stdio +DETMIR_MCP_HOST=127.0.0.1 +DETMIR_MCP_PORT=8765 +DETMIR_MCP_PATH=/mcp +DETMIR_MCP_STATELESS_HTTP=0 diff --git a/detmir-mcp/README.md b/detmir-mcp/README.md new file mode 100644 index 0000000..2d4316d --- /dev/null +++ b/detmir-mcp/README.md @@ -0,0 +1,85 @@ +# DetMir MCP + +Read-only MCP facade over the DetMir operational surfaces that usually regress under load: + +- ActivityWatch core +- worktime API +- DLP policy and case services +- Grafana health +- 1C manager brief + +This server exists to give `mcpdrill` a stable MCP target for reproducing timeout, saturation, stale-status, and mixed-read operator-path failures. + +## Tools + +- `aw_health_summary` +- `worktime_today` +- `worktime_management` +- `dlp_mode_get` +- `dlp_health_summary` +- `grafana_overview` +- `onec_manager_brief` + +All tools are read-only. + +## Run + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/detmir-mcp +cp .env.example .env +sh run-local.sh +``` + +Default transport is `stdio`. + +## Run for mcpdrill + +`mcpdrill` wants HTTP, not stdio. Start the server like this: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/detmir-mcp +export DETMIR_MCP_TRANSPORT=streamable-http +export DETMIR_MCP_HOST=0.0.0.0 +export DETMIR_MCP_PORT=8765 +export DETMIR_MCP_STATELESS_HTTP=0 +sh run-local.sh +``` + +Endpoint: + +```text +http://:8765/mcp +``` + +Do not use `127.0.0.1` inside `mcpdrill` target configs; use a host/IP reachable from the Docker worker. + +`run-local.sh` intentionally keeps the `uv` environment outside the repository tree. This machine stores the repo on a mounted filesystem where `.venv` creation inside the project is not reliable. + +Example config: + +- [examples/mcpdrill-detmir-readonly.json](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/detmir-mcp/examples/mcpdrill-detmir-readonly.json) + +## Quick checks + +Tool list: + +```bash +curl -sS http://127.0.0.1:8765/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +Example tool call: + +```bash +curl -sS http://127.0.0.1:8765/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"dlp_mode_get","arguments":{}}}' +``` + +## What to stress first + +- `worktime_today` + `worktime_management` to reproduce `5610` contention. +- `dlp_mode_get` + `dlp_health_summary` to expose stale policy/status views. +- `grafana_overview` + `onec_manager_brief` to check adjacent operator dependencies. +- Mixed runs across all tools to reproduce operator-path degradation. diff --git a/detmir-mcp/examples/mcpdrill-detmir-readonly.json b/detmir-mcp/examples/mcpdrill-detmir-readonly.json new file mode 100644 index 0000000..751d4da --- /dev/null +++ b/detmir-mcp/examples/mcpdrill-detmir-readonly.json @@ -0,0 +1,237 @@ +{ + "schema_version": "run-config/v1", + "scenario_id": "detmir-readonly-mixed", + "target": { + "kind": "server", + "url": "http://YOUR_HOST_IP:8765/mcp", + "transport": "streamable_http", + "headers": {}, + "auth": { + "type": "none" + }, + "identification": { + "run_id_header": { + "name": "X-MCP-Drill-Run", + "value_template": "{{run_id}}" + }, + "user_agent": { + "value": "mcpdrill/${run_id}" + } + }, + "timeouts": { + "connect_timeout_ms": 5000, + "request_timeout_ms": 40000, + "stream_stall_timeout_ms": 60000 + }, + "tls": { + "verify": true, + "ca_bundle_ref": null + }, + "redirect_policy": { + "mode": "deny", + "max_redirects": 0 + } + }, + "environment": { + "allowlist": { + "mode": "deny_by_default", + "allowed_targets": [ + { + "kind": "exact", + "value": "http://YOUR_HOST_IP:8765/mcp" + } + ] + }, + "forbidden_patterns": [] + }, + "session_policy": { + "mode": "reuse", + "pool_size": null, + "ttl_ms": null, + "max_idle_ms": null + }, + "workload": { + "in_flight_per_vu": 1, + "think_time": { + "mode": "jitter", + "base_ms": 100, + "jitter_ms": 50 + }, + "operation_mix": [ + { + "operation": "tools_list", + "weight": 1 + }, + { + "operation": "tools_call", + "weight": 12 + } + ], + "tools": { + "selection": { + "mode": "weighted" + }, + "templates": [ + { + "template_id": "aw_health_summary", + "tool_name": "aw_health_summary", + "weight": 3, + "arguments": {} + }, + { + "template_id": "dlp_mode_get", + "tool_name": "dlp_mode_get", + "weight": 3, + "arguments": {} + }, + { + "template_id": "grafana_overview", + "tool_name": "grafana_overview", + "weight": 2, + "arguments": {} + }, + { + "template_id": "worktime_today", + "tool_name": "worktime_today", + "weight": 3, + "arguments": { + "host": "SHARKON2025", + "day": "today", + "top_n": 5 + } + }, + { + "template_id": "worktime_management", + "tool_name": "worktime_management", + "weight": 2, + "arguments": { + "host": "SHARKON2025", + "day": "today", + "top_n": 5 + } + }, + { + "template_id": "dlp_health_summary", + "tool_name": "dlp_health_summary", + "weight": 2, + "arguments": { + "host": "SHARKON2025" + } + }, + { + "template_id": "onec_manager_brief", + "tool_name": "onec_manager_brief", + "weight": 1, + "arguments": {} + } + ] + }, + "payload_profiles": [] + }, + "stages": [ + { + "stage_id": "stg_0000000000000101", + "stage": "preflight", + "enabled": true, + "duration_ms": 10000, + "load": { + "target_vus": 1, + "target_rps": null + }, + "stop_conditions": [] + }, + { + "stage_id": "stg_0000000000000102", + "stage": "baseline", + "enabled": true, + "duration_ms": 15000, + "load": { + "target_vus": 1, + "target_rps": null + }, + "stop_conditions": [ + { + "id": "baseline_error_rate", + "metric": "error_rate", + "comparator": ">", + "threshold": 0.1, + "window_ms": 10000, + "sustain_windows": 1, + "scope": {} + } + ] + }, + { + "stage_id": "stg_0000000000000103", + "stage": "ramp", + "enabled": true, + "duration_ms": 30000, + "load": { + "target_vus": 6, + "target_rps": null + }, + "stop_conditions": [ + { + "id": "error_rate", + "metric": "error_rate", + "comparator": ">", + "threshold": 0.1, + "window_ms": 10000, + "sustain_windows": 1, + "scope": {} + } + ] + } + ], + "safety": { + "ramp_by_default": true, + "emergency_stop_enabled": true, + "identification_required": false, + "worker_failure_policy": "fail_fast", + "hard_caps": { + "max_vus": 24, + "max_rps": 200, + "max_connections": 64, + "max_duration_ms": 120000, + "max_in_flight_per_vu": 4, + "max_telemetry_q_depth": 100000 + }, + "stop_policy": { + "mode": "drain", + "drain_timeout_ms": 30000 + } + }, + "reporting": { + "formats": [ + "json" + ], + "retention": { + "raw_logs_days": 7, + "metrics_days": 30, + "reports_days": 90 + }, + "include": { + "store_raw_logs": true, + "store_metrics_snapshot": true, + "store_event_log": true + }, + "redaction": { + "redact_headers": [ + "Authorization", + "X-Api-Key" + ] + } + }, + "telemetry": { + "structured_logs": { + "enabled": true, + "sample_rate": 1.0 + }, + "traces": { + "enabled": false, + "propagation": { + "accept_incoming_traceparent": false + } + } + } +} diff --git a/detmir-mcp/main.py b/detmir-mcp/main.py new file mode 100644 index 0000000..4a7b20c --- /dev/null +++ b/detmir-mcp/main.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +from base64 import b64encode +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from urllib import parse, request + +from mcp.server.fastmcp import FastMCP + + +def _env(name: str, default: str) -> str: + value = os.environ.get(name) + return value if value not in (None, "") else default + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw in (None, ""): + return default + return int(raw) + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +GRAFANA_DASHBOARDS_DIR = PROJECT_ROOT / "grafana" +AW_BASE = _env("DETMIR_AW_BASE", "http://10.10.10.13:5600").rstrip("/") +WORKTIME_BASE = _env("DETMIR_WORKTIME_BASE", "http://10.10.10.13:5610").rstrip("/") +DLP_POLICY_BASE = _env("DETMIR_DLP_POLICY_BASE", "http://10.10.10.13:5601").rstrip("/") +DLP_CASE_BASE = _env("DETMIR_DLP_CASE_BASE", "http://10.10.10.13:5602").rstrip("/") +ONEC_BASE = _env("DETMIR_ONEC_BASE", "http://10.10.10.2:8710").rstrip("/") +GRAFANA_URL = _env("DETMIR_GRAFANA_URL", "http://10.10.10.11:3000").rstrip("/") +GRAFANA_USER = os.environ.get("DETMIR_GRAFANA_USER", "") +GRAFANA_PASSWORD = os.environ.get("DETMIR_GRAFANA_PASSWORD", "") +DEFAULT_HOST = _env("DETMIR_DEFAULT_HOST", "SHARKON2025") +HTTP_TIMEOUT_SECONDS = _env_int("DETMIR_HTTP_TIMEOUT_SECONDS", 10) +MCP_TRANSPORT = _env("DETMIR_MCP_TRANSPORT", "stdio") +MCP_HOST = _env("DETMIR_MCP_HOST", "127.0.0.1") +MCP_PORT = _env_int("DETMIR_MCP_PORT", 8765) +MCP_PATH = _env("DETMIR_MCP_PATH", "/mcp") +MCP_STATELESS_HTTP = _env("DETMIR_MCP_STATELESS_HTTP", "0").lower() in {"1", "true", "yes", "on"} + +mcp = FastMCP( + "detmir", + instructions="Read-only operational facade for DetMir ActivityWatch-Russian surfaces.", + host=MCP_HOST, + port=MCP_PORT, + streamable_http_path=MCP_PATH, + json_response=True, + stateless_http=MCP_STATELESS_HTTP, +) + + +def _http_json(url: str, timeout: int | None = None, headers: dict[str, str] | None = None) -> Any: + req = request.Request(url, headers=headers or {}) + with request.urlopen(req, timeout=timeout or HTTP_TIMEOUT_SECONDS) as resp: + charset = resp.headers.get_content_charset() or "utf-8" + body = resp.read().decode(charset) + return json.loads(body) + + +def _grafana_headers() -> dict[str, str]: + if not GRAFANA_USER: + return {} + token = b64encode(f"{GRAFANA_USER}:{GRAFANA_PASSWORD}".encode("utf-8")).decode("ascii") + return {"Authorization": f"Basic {token}"} + + +def _parse_ts(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + except ValueError: + return None + + +def _age_seconds(ts: datetime | None, now: datetime | None = None) -> int | None: + if ts is None: + return None + ref = now or datetime.now(UTC) + return max(0, int((ref - ts).total_seconds())) + + +def _build_aw_api_base(base: str) -> str: + if base.endswith("/api/0"): + return base + return base + "/api/0" + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _rows_sorted_by_active(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted(rows, key=lambda item: (_safe_int(item.get("active_seconds")), str(item.get("user_id") or "")), reverse=True) + + +def _latest_bucket_timestamp(aw_api_base: str, bucket_id: str, meta: dict[str, Any]) -> datetime | None: + metadata = meta.get("metadata") or {} + return _parse_ts(metadata.get("end")) + + +def _summarize_bucket_group( + aw_api_base: str, + buckets: dict[str, Any], + prefix: str, + *, + host: str | None = None, + max_age_seconds: int = 900, +) -> dict[str, Any]: + matched = sorted( + bucket_id + for bucket_id in buckets + if bucket_id.startswith(prefix) and (host is None or bucket_id.endswith(f"_{host}")) + ) + if not matched: + return { + "prefix": prefix, + "host": host or "", + "bucket_count": 0, + "status": "missing", + "max_age_seconds": max_age_seconds, + "stale": [], + } + now = datetime.now(UTC) + stale: list[dict[str, Any]] = [] + freshest_age: int | None = None + oldest_age: int | None = None + for bucket_id in matched: + ts = _latest_bucket_timestamp(aw_api_base, bucket_id, buckets.get(bucket_id, {})) + age = _age_seconds(ts, now=now) + if age is None: + stale.append({"bucket_id": bucket_id, "age_seconds": None, "reason": "no_timestamp"}) + continue + freshest_age = age if freshest_age is None else min(freshest_age, age) + oldest_age = age if oldest_age is None else max(oldest_age, age) + if age > max_age_seconds: + stale.append({"bucket_id": bucket_id, "age_seconds": age, "reason": "stale"}) + status = "ok" if not stale else "degraded" + return { + "prefix": prefix, + "host": host or "", + "bucket_count": len(matched), + "status": status, + "max_age_seconds": max_age_seconds, + "freshest_age_seconds": freshest_age, + "oldest_age_seconds": oldest_age, + "stale": stale[:10], + } + + +def _count_actions_by_kind(policy: dict[str, Any]) -> dict[str, int]: + counts: dict[str, int] = {} + + def _push(action: Any) -> None: + key = str(action or "unknown") + counts[key] = counts.get(key, 0) + 1 + + for group in ("rules",): + for rule in policy.get(group, []) or []: + _push((rule or {}).get("action")) + endpoint = policy.get("endpoint") or {} + if isinstance(endpoint, dict): + for rules in endpoint.values(): + for rule in rules or []: + _push((rule or {}).get("action")) + return counts + + +@mcp.tool() +def aw_health_summary() -> dict[str, Any]: + """Summarize core ActivityWatch health and bucket inventory.""" + aw_api_base = _build_aw_api_base(AW_BASE) + info = _http_json(f"{aw_api_base}/info") + buckets = _http_json(f"{aw_api_base}/buckets") + if not isinstance(buckets, dict): + raise RuntimeError("AW bucket index is not a dict") + + prefixes = { + "worktime_sessions": "aw-worktime-sessions_", + "file_operations": "aw-file-operations_", + "endpoint_signals": "aw-dlp-endpoint-signals_", + "incidents": "aw-dlp-incidents_", + "web_categories": "aw-detmir-web-category_", + } + bucket_summary = { + name: sum(1 for bucket_id in buckets if bucket_id.startswith(prefix)) + for name, prefix in prefixes.items() + } + + return { + "service": "activitywatch", + "base_url": AW_BASE, + "hostname": info.get("hostname"), + "version": info.get("version"), + "device_id": info.get("device_id"), + "testing": bool(info.get("testing")), + "bucket_count_total": len(buckets), + "bucket_summary": bucket_summary, + } + + +@mcp.tool() +def worktime_today(host: str = DEFAULT_HOST, day: str = "today", top_n: int = 5) -> dict[str, Any]: + """Summarize current worktime rows for one host.""" + params = {"host": host, "day": day} + payload = _http_json(f"{WORKTIME_BASE}/reports/worktime/today?{parse.urlencode(params)}", timeout=max(HTTP_TIMEOUT_SECONDS, 30)) + rows = payload.get("rows") or [] + rows = rows if isinstance(rows, list) else [] + ordered = _rows_sorted_by_active(rows) + return { + "service": "aw-worktime-api", + "base_url": WORKTIME_BASE, + "host": payload.get("host") or host, + "report_date": payload.get("report_date"), + "row_count": len(rows), + "top_rows": [ + { + "user_id": row.get("user_id"), + "user": row.get("user"), + "active_seconds": row.get("active_seconds"), + "active_hhmm": row.get("active_hhmm"), + "sessions_count": row.get("sessions_count"), + "samples_count": row.get("samples_count"), + "first_activity": row.get("first_activity"), + "last_activity": row.get("last_activity"), + } + for row in ordered[: max(1, top_n)] + ], + } + + +@mcp.tool() +def worktime_management( + host: str = DEFAULT_HOST, + day: str = "today", + owner: str = "", + department: str = "", + top_n: int = 5, +) -> dict[str, Any]: + """Summarize management worktime report and top actions.""" + params = {"host": host, "day": day} + if owner: + params["owner"] = owner + if department: + params["department"] = department + payload = _http_json(f"{WORKTIME_BASE}/reports/worktime/management?{parse.urlencode(params)}", timeout=max(HTTP_TIMEOUT_SECONDS, 30)) + summary = payload.get("summary") or {} + actions = payload.get("actions") or [] + rows = payload.get("rows") or [] + return { + "service": "aw-worktime-api", + "base_url": WORKTIME_BASE, + "host": payload.get("host") or host, + "report_date": payload.get("report_date"), + "filters": payload.get("filters") or {}, + "summary": { + "users_count": summary.get("users_count"), + "active_users": summary.get("active_users"), + "inactive_users": summary.get("inactive_users"), + "portfolio_coverage_pct": summary.get("portfolio_coverage_pct"), + "actions_count": summary.get("actions_count"), + "critical_actions_count": summary.get("critical_actions_count"), + "high_actions_count": summary.get("high_actions_count"), + "calendar_total_active_hhmm": summary.get("calendar_total_active_hhmm"), + "workday_total_active_hhmm": summary.get("workday_total_active_hhmm"), + }, + "top_actions": [ + { + "action_id": action.get("action_id"), + "priority": action.get("priority"), + "owner": action.get("owner"), + "user_id": action.get("user_id"), + "reason": action.get("reason"), + } + for action in actions[: max(1, top_n)] + ], + "top_rows": [ + { + "user_id": row.get("user_id"), + "user": row.get("user"), + "manager_owner": row.get("manager_owner"), + "department": row.get("department"), + "active_hhmm": row.get("active_hhmm"), + "coverage_pct": row.get("coverage_pct"), + "status": row.get("status"), + } + for row in _rows_sorted_by_active(rows)[: max(1, top_n)] + ], + } + + +@mcp.tool() +def dlp_mode_get() -> dict[str, Any]: + """Get current DLP mode and compact policy metadata.""" + payload = _http_json(f"{DLP_POLICY_BASE}/api/0/dlp/policies/active") + policy = payload.get("policy") or {} + meta = policy.get("_tsj_meta") or {} + return { + "service": "aw-dlp-policy-engine", + "base_url": DLP_POLICY_BASE, + "active": bool(payload.get("active")), + "policy_id": payload.get("policyId"), + "name": payload.get("name"), + "version": payload.get("version"), + "checksum": payload.get("checksum"), + "updated_at_utc": payload.get("updatedAtUtc"), + "dlp_mode": meta.get("dlp_mode", "unknown"), + "updated_by": meta.get("updated_by", ""), + "ioc_enabled": bool((policy.get("ioc") or {}).get("enabled")), + "web_rules_count": len(policy.get("rules") or []), + "endpoint_rule_groups": sorted((policy.get("endpoint") or {}).keys()), + "action_counts": _count_actions_by_kind(policy), + } + + +@mcp.tool() +def dlp_health_summary(host: str = DEFAULT_HOST) -> dict[str, Any]: + """Summarize DLP service health and bucket freshness over HTTP-only surfaces.""" + aw_api_base = _build_aw_api_base(AW_BASE) + aw_info = _http_json(f"{aw_api_base}/info") + policy_health = _http_json(f"{DLP_POLICY_BASE}/healthz") + case_health = _http_json(f"{DLP_CASE_BASE}/health") + case_list = _http_json(f"{DLP_CASE_BASE}/api/0/dlp/cases?limit=200") + buckets = _http_json(f"{aw_api_base}/buckets") + if not isinstance(buckets, dict): + raise RuntimeError("AW bucket index is not a dict") + + open_cases = 0 + if isinstance(case_list, list): + open_cases = sum(1 for item in case_list if str((item or {}).get("status") or "").lower() == "open") + + return { + "host": host, + "aw": { + "hostname": aw_info.get("hostname"), + "version": aw_info.get("version"), + }, + "policy_health": policy_health, + "case_health": case_health, + "open_cases_count": open_cases, + "freshness": { + "endpoint_signals": _summarize_bucket_group(aw_api_base, buckets, "aw-dlp-endpoint-signals_", host=host, max_age_seconds=900), + "file_operations": _summarize_bucket_group(aw_api_base, buckets, "aw-file-operations_", host=host, max_age_seconds=900), + "incidents": _summarize_bucket_group(aw_api_base, buckets, "aw-dlp-incidents_", host=host, max_age_seconds=86400), + }, + } + + +@mcp.tool() +def grafana_overview() -> dict[str, Any]: + """Summarize Grafana health and version-controlled dashboard inventory.""" + health = _http_json(f"{GRAFANA_URL}/api/health", headers=_grafana_headers()) + dashboards = [] + if GRAFANA_DASHBOARDS_DIR.exists(): + dashboards = sorted(path.name for path in GRAFANA_DASHBOARDS_DIR.glob("*.json")) + return { + "service": "grafana", + "base_url": GRAFANA_URL, + "remote_health": health, + "repo_dashboard_count": len(dashboards), + "repo_dashboards": dashboards, + } + + +@mcp.tool() +def onec_manager_brief() -> dict[str, Any]: + """Summarize the latest 1C manager brief.""" + payload = _http_json(f"{ONEC_BASE}/api/1/analytics-1c/manager/brief/latest", timeout=max(HTTP_TIMEOUT_SECONDS, 30)) + context = payload.get("context") or {} + portfolio = context.get("portfolio_summary") or {} + freshness = context.get("freshness") or [] + stale_sources = [ + { + "source": item.get("source"), + "lag_hours": item.get("lag_hours"), + "latest_ts": item.get("latest_ts"), + } + for item in freshness + if bool((item or {}).get("stale")) + ] + brief = payload.get("brief") or {} + top_risks = brief.get("top_risks") or context.get("top_risks") or [] + actions = brief.get("actions") or [] + return { + "service": "company-intelligence-api", + "base_url": ONEC_BASE, + "generated_at": payload.get("generated_at"), + "render_mode": payload.get("render_mode"), + "model": payload.get("model"), + "portfolio_summary": { + "companies_total": portfolio.get("companies_total"), + "critical_total": portfolio.get("critical_total"), + "busy_total": portfolio.get("busy_total"), + "open_cases_total": portfolio.get("open_cases_total"), + "detections_total": portfolio.get("detections_total"), + "activity_30d_total": portfolio.get("activity_30d_total"), + "activity_forecast_30d_total": portfolio.get("activity_forecast_30d_total"), + }, + "stale_sources": stale_sources[:10], + "top_risks": [ + { + "company": item.get("company") or item.get("counterparty") or item.get("infobase"), + "severity": item.get("severity") or item.get("signal_severity"), + "reason": item.get("reason") or item.get("top_signal"), + "recommended_action": item.get("recommended_action"), + } + for item in top_risks[:5] + ], + "actions": actions[:5], + } + + +def main() -> None: + mcp.run(transport=MCP_TRANSPORT) + + +if __name__ == "__main__": + main() diff --git a/detmir-mcp/pyproject.toml b/detmir-mcp/pyproject.toml new file mode 100644 index 0000000..f8a548d --- /dev/null +++ b/detmir-mcp/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "detmir-mcp" +version = "0.1.0" +description = "Read-only MCP facade for DetMir ActivityWatch-Russian operations." +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = false diff --git a/detmir-mcp/run-local.sh b/detmir-mcp/run-local.sh new file mode 100644 index 0000000..762d9a2 --- /dev/null +++ b/detmir-mcp/run-local.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export UV_LINK_MODE="${UV_LINK_MODE:-copy}" +export UV_PROJECT_ENVIRONMENT="${UV_PROJECT_ENVIRONMENT:-$HOME/.local/share/uv-envs/detmir-mcp}" + +mkdir -p "$UV_PROJECT_ENVIRONMENT" +cd "$SCRIPT_DIR" +exec /home/igor/.local/bin/uv run main.py diff --git a/detmir-mcp/uv.lock b/detmir-mcp/uv.lock new file mode 100644 index 0000000..4e05799 --- /dev/null +++ b/detmir-mcp/uv.lock @@ -0,0 +1,684 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +] + +[[package]] +name = "detmir-mcp" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", specifier = ">=1.0.0" }] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] diff --git a/docs/1C_AI_INVESTIGATOR_RUNTIME_RU.md b/docs/1C_AI_INVESTIGATOR_RUNTIME_RU.md index f10293e..7f7f604 100644 --- a/docs/1C_AI_INVESTIGATOR_RUNTIME_RU.md +++ b/docs/1C_AI_INVESTIGATOR_RUNTIME_RU.md @@ -98,6 +98,7 @@ File 1C / Windows RDP host На `10.10.10.2:8710` уже работают human-facing страницы: - `/manager/brief` +- `/manager/actions` - `/manager/changes` - `/manager/trends/weekly` - `/manager/digest/weekly` @@ -112,6 +113,7 @@ File 1C / Windows RDP host Это страницы для руководителя с: - headline; +- очередью конкретных действий по компаниям; - summary; - top risks; - top changes; @@ -216,6 +218,12 @@ File 1C / Windows RDP host ### Grafana - `http://10.10.10.11:3000/dashboards/f/file-1c/?orgId=1` +- management board: + - `http://10.10.10.11:3000/d/1c-file-mgmt/1c-file-management-board` +- financial reporting: + - `http://10.10.10.11:3000/d/1c-file-finance/1c-file-financial-reporting` +- telemetry board: + - `http://10.10.10.11:3000/d/1c-file-telemetry/1c-file-telemetry-board` - company intelligence: - `http://10.10.10.11:3000/d/1c-file-companies/1c-file-company-intelligence` @@ -233,12 +241,20 @@ File 1C / Windows RDP host - `GET /api/1/analytics-1c/companies/overview` - `GET /api/1/analytics-1c/companies/{company}/summary` - `GET /api/1/analytics-1c/companies/{company}/forecast` +- `GET /api/1/analytics-1c/companies/{company}/actions` +- `GET /api/1/analytics-1c/manager/actions` +- `GET /api/1/analytics-1c/manager/actions/companies` - `GET /api/1/analytics-1c/manager/brief/latest` - `GET /api/1/analytics-1c/manager/brief/delta/latest` - `GET /api/1/analytics-1c/manager/trends/weekly` - `GET /api/1/analytics-1c/manager/digest/weekly/latest` - `GET /api/1/analytics-1c/manager/recovery/latest` +Manager action API работает в enterprise-first режиме: + +- `priority_model=enterprise_risk` +- `owner_mode=secondary_operational_metadata` + ## Главная граница Контур deliberately построен как `read-only`. diff --git a/docs/1C_BUSINESS_EVENT_LAYER_RU.md b/docs/1C_BUSINESS_EVENT_LAYER_RU.md index 93e8d0e..6b55d5b 100644 --- a/docs/1C_BUSINESS_EVENT_LAYER_RU.md +++ b/docs/1C_BUSINESS_EVENT_LAYER_RU.md @@ -122,6 +122,7 @@ - внешние реестры и справочники; - audit/export критичных изменений; - отдельные read-only файлы, формируемые рядом с 1С. +- `1c-mcp-toolkit` REST API через `execute_query` и `get_event_log`. Не подходящие по умолчанию: @@ -183,6 +184,12 @@ - `etl/build_business_event_exports.py` - собирает canonical events из существующих read-only выгрузок `documents/postings/audit` +- read-only extractor scaffold: + - `etl/extract_1c_mcp_toolkit.py` + - забирает `companies/documents/postings/business_events/document_changes` + через `execute_query` + - забирает `reglog` через `get_event_log` + - пишет в те же `landing/*`, которые уже понимает loader - ingest wiring: - normalizer запускается перед `load_1c_exports.py` - timeline/detection wiring: @@ -195,9 +202,33 @@ Это уже рабочий v1 normalizer, но ещё не конечный extractor со всей бухгалтерской глубиной. +## Контракт `1c-mcp-toolkit` extractor + +На текущем шаге extractor сознательно ограничен: + +- только read-only endpoint'ы; +- без `execute_code`; +- с локальным checkpoint state; +- с `channel` isolation для разделения `dev/prod`. + +Практический контракт такой: + +1. `execute_query` должен возвращать колонки, алиаснутые под landing-схему. +2. Если это неудобно, используется `field_map` в `etl/config.yml`. +3. Для query-datasets можно включать incremental-window через + `incremental.since_param` / `incremental.until_param`. +4. Для `reglog` extractor хранит курсор `last_date + + same_second_offset`. +5. `company_entity_key` достраивается extractor'ом по той же логике, + что уже используется в built-in normalizer. +6. Перед включением live-ingest query-pack должен проходить: + - `--validate-config` + - `--dry-run` + - targeted `--dataset ` probe + ## Что делать дальше -1. Реализовать read-only extractor в отдельном модуле. +1. Подключить production queries под конкретную конфигурацию 1С. 2. Стабильно наполнять `company_entity_key`. 3. Добавить первые SQL detections на `business_events`. 4. Обогащать `entity_timeline` уже не только telemetry/audit, но и diff --git a/docs/1C_COMPANY_INTELLIGENCE_RU.md b/docs/1C_COMPANY_INTELLIGENCE_RU.md index 0d09ca1..a336ac7 100644 --- a/docs/1C_COMPANY_INTELLIGENCE_RU.md +++ b/docs/1C_COMPANY_INTELLIGENCE_RU.md @@ -87,10 +87,14 @@ Endpoints: - `GET /api/1/analytics-1c/companies/{counterparty}/summary` - `GET /api/1/analytics-1c/companies/{counterparty}/forecast` - `GET /api/1/analytics-1c/companies/{counterparty}/timeline` +- `GET /api/1/analytics-1c/companies/{counterparty}/actions` +- `GET /api/1/analytics-1c/manager/actions` +- `GET /api/1/analytics-1c/manager/actions/companies` - `GET /api/1/analytics-1c/manager/brief/latest` - `GET /api/1/analytics-1c/manager/brief/latest.md` - `GET /api/1/analytics-1c/manager/brief/history` - `GET /manager/brief` +- `GET /manager/actions` `/manager/brief` — это human-facing browser page для руководителя: @@ -101,6 +105,21 @@ Endpoints: действия и свежесть источников; - даёт быстрые ссылки на raw JSON/Markdown и в Grafana. +Отдельно появился deterministic слой `manager/actions`: + +- не зависит от LLM; +- строится из `v_company_portfolio_overview`, `business_events`, + `document_change_events`, `cases`, `detections`; +- показывает очередь действий вида `компания -> почему -> что делать -> кто + отвечает -> насколько срочно`. +- верхний management-срез теперь enterprise-first: + сначала агрегирует необходимые действия по предприятиям, а текущий + `owner_name` оставляет вторичным operational metadata. +- `GET /api/1/analytics-1c/manager/actions` и + `GET /api/1/analytics-1c/manager/actions/companies` теперь явно отдают + `priority_model=enterprise_risk` и + `owner_mode=secondary_operational_metadata`. + ### Executive brief для руководителя Файлы: diff --git a/docs/DETMIR_CHANGE_REPORT_LAST_24H_2026-05-26.md b/docs/DETMIR_CHANGE_REPORT_LAST_24H_2026-05-26.md new file mode 100644 index 0000000..e2fa91f --- /dev/null +++ b/docs/DETMIR_CHANGE_REPORT_LAST_24H_2026-05-26.md @@ -0,0 +1,299 @@ +# DetMir: Отчет по изменениям за последние 24 часа + +Дата фиксации: `2026-05-26 07:55 MSK` + +## Источники + +- `tmux` session `codex`, scrollback выгружен в `/tmp/codex-tmux-last24.txt` +- текущий worktree `git status` в `/mnt/usb_hdd2/Projects/ActivityWatch-Russian` +- live-проверки на: + - `10.10.10.2` Proxmox + - `10.10.10.11` Grafana + - `10.10.10.12` Loki/Alloy + - `10.10.10.13` aw-server + - `10.10.10.1` pfSense + - `192.168.100.18` Windows RDP host + +## Важная оговорка + +`git log --since='24 hours ago'` пустой: за последние сутки изменения не были оформлены git-коммитами. + +Рабочее дерево уже грязное и содержит больше изменений, чем было сделано в этом 24-часовом окне. Поэтому ниже зафиксированы только те изменения, которые явно подтверждаются `tmux`-историей и live-проверками. + +## Что было сделано + +### 1. Восстановление ActivityWatch-Russian + +Симптом: +- на `10.10.10.13:5600` UI не обновлялся для `SHARKON2025`; +- `aw-server` был жив, но stale были `aw-watcher-afk`, `aw-watcher-window`, `aw-worktime-sessions`. + +Действия: +- проверен `aw-server` и свежесть bucket’ов; +- через WinRM на `192.168.100.18` проверены процессы, tasks и recovery-скрипты; +- запущены: + - `ActivityWatch Recovery` + - `ActivityWatch Launch [SHARKON2025_user5]` +- отдельно перезапущен зависший `worktime-session-collector`. + +Итог: +- `aw-watcher-afk`, `aw-watcher-window`, `aw-session-events`, `aw-dlp-endpoint-signals`, `aw-worktime-sessions` снова стали fresh; +- итоговая серверная сводка: `FRESH=8 STALE=0 DEAD=0`. + +### 2. Дополнена авто-сигнализация и самолечение `tsj-guardian-bot` + +Проблема: +- фоновый цикл бота не видел stale AW collector bucket’ы; +- manual AW-check был отдельным путем и не участвовал в обычном incident/heal flow. + +Сделано: +- в `tsj_guardian_bot.py` добавлен разбор `[FAIL] aw-rus:*`; +- AW collector freshness включен в стандартный background check cycle; +- добавлен Windows recovery path для: + - `watcher-*` + - `worktime-session-collector` +- добавен server-side rebuild worktime views после Windows remediation; +- тесты обновлены и прогнаны. + +Затронутые файлы: +- [proxmox/tsj_guardian_bot.py](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/tsj_guardian_bot.py) +- [proxmox/test_tsj_guardian_bot.py](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/test_tsj_guardian_bot.py) +- [ansible/deploy_tsj_guardian_bot_proxmox.yml](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/deploy_tsj_guardian_bot_proxmox.yml) +- [ansible/group_vars/proxmox-bot.example.yml](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/group_vars/proxmox-bot.example.yml) + +Проверка: +- `python3 -m py_compile proxmox/tsj_guardian_bot.py proxmox/test_tsj_guardian_bot.py` +- `python3 -m unittest proxmox.test_tsj_guardian_bot` +- `tsj-guardian-bot.service` перезапускался и работал в `active`. + +### 3. Бот переведен на `igor` вместо `codex` + +Проблема: +- production bot был завязан на `AI_EXEC_USER=codex`, `TMUX_USER=codex` и read-only пути под `/home/codex/infra-admin`; +- это ломало встроенный `codex-cli` support path после перехода на `igor`. + +Сделано: +- переключены: + - `AI_EXEC_USER=igor` + - `TMUX_USER=igor` + - `AI_CHAT_WORKDIR=/home/igor` +- добавлены настраиваемые: + - `PFSENSE_ENV_PATH` + - `PFSENSE_INVENTORY_PATH` +- создан `igor`-readable bundle: + - `/home/igor/.config/tsj-bot/pfsense.env.readonly` + - `/home/igor/.config/tsj-bot/inventory.md` + +Итог: +- диалоговая техподдержка и AI-эскалация в боте работают от `igor`; +- bot-side pfSense/inventory контекст больше не зависит от старого `codex`-домика. + +### 4. Исправлен сетевой доступ `10.10.10.2 -> 192.168.100.18` + +Root cause: +- на `pfSense 10.10.10.1`, интерфейс `opt1/MGMT`: + - allow rule для `10.10.10.2 -> 192.168.100.18:22` был ниже `block all`; + - allow rule для `10.10.10.2 -> 10.10.10.1:2022` был ниже `block all`; + - rule для `10.10.10.2 -> 192.168.100.18:5985` отсутствовал. + +Сделано: +- подняты нужные allow rules выше `block all`; +- добавлено недостающее правило на `5985`. + +Итог: +- с `10.10.10.2` подтвержден доступ на: + - `10.10.10.1:2022` + - `192.168.100.18:22` + - `192.168.100.18:5985` +- SSH вход на `192.168.100.18` под `Администратор` был подтвержден. + +### 5. Убрано ложное сообщение “автолечение невозможно” + +Причина: +- в production `.env` бота была повреждена строка `AW_RUS_WORKTIME_HEAL_CMD`; +- бот слишком жестко реагировал на единичный таймаут worktime CSV. + +Сделано: +- восстановлен `AW_RUS_WORKTIME_HEAL_CMD`; +- добавлен retry для получения worktime CSV; +- обновлены дефолты под `igor`. + +Итог: +- `pending_incident = null`; +- циклы снова идут как `Check OK`. + +### 6. Наведен порядок в Grafana folder/dashboard catalog + +Изменения: +- folder `AWatch-rus` переименован в `DLP`; +- `pfSense System Dashboard` перенесен в folder `pfSense`; +- `LXC Containers Monitoring` перенесен в folder `LXC`; +- `Proxmox Influx 2.0 Dashboard` перенесен в folder `LXC`. + +### 7. Починен `pfSense Logs Dashboard All Logs` + +Проблема была не в Grafana, а в цепочке `pfSense -> Alloy -> Loki`. + +Сделано: +- на `10.10.10.1` поднят `syslogd`; +- на `10.10.10.12` в Alloy для pfSense syslog включен правильный формат `rfc3164`; +- отключен stale docker self-scrape path, забивавший `loki.write` старыми batch’ами; +- перезапущен `alloy`. + +Итог: +- появились живые `job="pfsense"` streams в Loki; +- дашборд `pfSense Logs Dashboard All Logs` перестал быть пустым. + +### 8. Починен `1C File - Operations Health` + +Проблема: +- не обновлялись данные в `1C File - Operations Health`. + +Сделано на `192.168.100.18`: +- exporter больше не падает на недоступных `ibases.v8i`; +- `export-upload-file-1c-telemetry.ps1` научен брать `remoteKeyPath` из `deployment-config.json`; +- рабочий ключ закреплен как `C:\Users\USER1\.ssh\awops_ed25519`; +- исправлены права к `file1c-telemetry-state.json`. + +Сделано на `10.10.10.2`: +- прогнан ingest; +- `proofcheck` выведен в green. + +Затронутые файлы: +- [windows/export-upload-file-1c-telemetry.ps1](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/export-upload-file-1c-telemetry.ps1) +- [ansible/deploy_file_1c_windows_telemetry.yml](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/deploy_file_1c_windows_telemetry.yml) + +Итог: +- `1C File - Operations Health` снова обновляется; +- свежесть downstream была подтверждена через ingest и proofcheck. + +### 9. Рабочие ключи выданы всем пользователям Windows-хоста + +Сделано: +- ключ `awops_ed25519` разложен по локальным профилям: + - `USER1` + - `USER2` + - `USER3` + - `USER4` + - `USER5` + - `Администратор` +- `remoteKeyPath` перестал быть жестко привязан только к `USER1`; +- права на `file1c-telemetry-state.json` выданы рабочим пользователям. + +Итог: +- схема больше не зависит от одного профиля; +- scheduled upload path продолжает работать. + +### 10. Поднят management board по файловой 1С + +Сделано: +- добавлен новый Grafana dashboard `1C File - Management Board`; +- обновлены manager brief links и gateway route. + +Live-итог: +- `uid=1c-file-mgmt` +- `go/grafana-1c` редиректит на новый board + +### 11. Поднят первый financial board по файловой 1С + +Сделано: +- добавлен SQL слой: + - [clickhouse-1c/clickhouse/init/05_financial_reporting.sql](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/clickhouse-1c/clickhouse/init/05_financial_reporting.sql) +- добавлен dashboard: + - [clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json) +- обновлен gateway route: + - `go/file1c-finance` + +Итог: +- financial screen работает как честный transitional layer; +- live readiness была `proxy_only`; +- `postings_table_rows = 0`, то есть настоящие проводки еще не поступают. + +### 12. Проведено расследование production-source для `postings` + +Что подтверждено на `192.168.100.18`: +- готового `toolkit`/REST/service под postings нет; +- порт `6003` и типовые service-порты не слушаются; +- текущий Windows upload path шлет только telemetry/snapshot, без `postings`. + +Что проверено: +- наличие `1C 8.3.27` и `comcntr.dll`; +- COMConnector в user-token path; +- интерактивные scheduled tasks под `USER1`; +- явные 1С-креды: + - `Администратор / Sergei2009@` + - `user / Sergei2009@` + - `user1 / Sergei2009@` + +Итог: +- blocker остался прежним: нужен реальный пользователь 1С с read-only доступом; +- без него production extractor проводок не собрать. + +### 13. Поднят отдельный `1C File - Telemetry Board` + +Сделано: +- добавлен новый dashboard: + - [clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json) +- добавлена ссылка на него из `1C File - Management Board`; +- добавлен gateway route: + - `go/file1c-telemetry` + +Live-итог: +- в Grafana зарегистрирован `uid=1c-file-telemetry`; +- все 1C dashboards лежат в folder `1C File Analytics`; +- `https://10.10.10.2/go/file1c-telemetry` редиректит на новый board. + +### 14. Live-путь `1C File Analytics` в Grafana был приведен к устойчивому состоянию + +Выяснилось: +- file-based provisioning на `10.10.10.11` не был основной точкой для 1C dashboards; +- текущие `file-1c` dashboards жили в DB Grafana. + +Сделано: +- на CT201 добавлен отдельный provisioning provider `1C File Analytics`; +- в него выложены: + - `1c-management-board.json` + - `1c-telemetry-board.json` +- `grafana-server` перезапущен; +- через SQLite БД Grafana подтверждено наличие: + - `1c-file-mgmt` + - `1c-file-finance` + - `1c-file-ops` + - `1c-file-telemetry` + - folder `file-1c / 1C File Analytics` + +## Файлы, которые точно редактировались в этом окне по данным tmux + +- [proxmox/tsj_guardian_bot.py](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/tsj_guardian_bot.py) +- [proxmox/test_tsj_guardian_bot.py](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/test_tsj_guardian_bot.py) +- [ansible/deploy_tsj_guardian_bot_proxmox.yml](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/deploy_tsj_guardian_bot_proxmox.yml) +- [ansible/group_vars/proxmox-bot.example.yml](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/group_vars/proxmox-bot.example.yml) +- [windows/export-upload-file-1c-telemetry.ps1](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/export-upload-file-1c-telemetry.ps1) +- [ansible/deploy_file_1c_windows_telemetry.yml](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/deploy_file_1c_windows_telemetry.yml) +- [clickhouse-1c/clickhouse/init/05_financial_reporting.sql](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/clickhouse-1c/clickhouse/init/05_financial_reporting.sql) +- [ansible/deploy_proxmox_web_gateway.yml](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/deploy_proxmox_web_gateway.yml) + +Новые dashboard JSON, подтвержденные live-выкладкой: +- [clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/clickhouse-1c/grafana/provisioning/dashboards/files/1c-financial-reporting.json) +- [clickhouse-1c/grafana/provisioning/dashboards/files/1c-management-board.json](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/clickhouse-1c/grafana/provisioning/dashboards/files/1c-management-board.json) +- [clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json](/mnt/usb_hdd2/Projects/ActivityWatch-Russian/clickhouse-1c/grafana/provisioning/dashboards/files/1c-telemetry-board.json) + +## Проверки, которые были явно пройдены + +- `python3 -m py_compile proxmox/tsj_guardian_bot.py proxmox/test_tsj_guardian_bot.py` +- `python3 -m unittest proxmox.test_tsj_guardian_bot` +- `./check-aw-full.sh` дал `FRESH=8 STALE=0 DEAD=0` +- gateway deploy на Proxmox прошел успешно +- `go/file1c-telemetry` отдает `302` на Grafana +- SQLite Grafana подтвердил наличие `1c-file-telemetry` в folder `1C File Analytics` + +## Незафиксированные в git риски + +- коммитов за последние 24 часа нет; +- есть значительный dirty worktree за пределами этого отчета; +- часть новых файлов все еще `??` в `git status`; +- без отдельного git-упорядочивания следующий человек может смешать сегодняшние изменения с более старыми незакоммиченными правками. + +## Следующий жесткий шаг + +Если цель именно финансовая отчетность по проводкам, нужен отдельный read-only пользователь 1С для боевых файловых баз. Это единственный текущий внешний blocker, который не решается локальным refactor/deploy. diff --git a/docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md b/docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md new file mode 100644 index 0000000..7ca91cd --- /dev/null +++ b/docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md @@ -0,0 +1,115 @@ +# DetMir PowerShell MCP Remote + +## Что это + +Канонический путь для интерактивной PowerShell-работы с `DetMir` Windows-хостом `192.168.100.18` из Linux/Codex. + +Это не замена `WinRM` в Ansible. Разделение теперь такое: + +- `Ansible deploy / validation` — через `WinRM` (`5985`); +- `PowerShell MCP / operator shell / Codex` — через `SSH` (`22`) на том же хосте. + +## Зафиксированная целевая Windows + +Проверено на `2026-05-26`: + +- host: `192.168.100.18` +- product: `Windows Server 2025 Datacenter Evaluation` +- release: `24H2` +- build: `10.0.26100.32690` +- remote PowerShell: `5.1.26100.32684` +- `sshd`: `Running` + +## Почему не WSMan + +На текущем Linux admin-host локальный `pwsh 7.6.1` не поднимает `New-PSSession` к этому Windows-хосту без отдельного WSMan client stack (`libpsrpclient` / `PSWSMan`). + +Практический вывод для DetMir: + +- не строить interactive MCP-путь вокруг `WSMan`; +- считать `SSH + powershell.exe` каноническим transport для локального `powershell-windows`; +- `WinRM` оставить для Ansible playbook-ов и коротких Windows-probes. + +## Файлы проекта + +- profile snippet: `scripts/powershell/detmir-powershell-profile.ps1` +- local config template: `scripts/powershell/detmir-windows.psd1.example` +- installer: `scripts/install_detmir_powershell_mcp.sh` + +Локальные файлы после установки: + +- `~/.config/powershell/Microsoft.PowerShell_profile.ps1` +- `~/.config/powershell/detmir-windows.psd1` + +`detmir-windows.psd1` должен оставаться локальным секретным файлом с правами `600`. + +## Установка + +Из корня проекта: + +```bash +bash scripts/install_detmir_powershell_mcp.sh +``` + +Installer: + +- прописывает loader в локальный PowerShell profile; +- создаёт `~/.config/powershell/detmir-windows.psd1`, если его ещё нет; +- пытается заполнить `Host/User/Password` из `ansible/inventory.ini` группы `[aw_windows]`; +- если inventory не подходит, оставляет template со значением `CHANGE_ME`. + +После установки: + +- переоткрыть `pwsh`, либо +- перезапустить Codex session, чтобы `powershell-windows` перечитал профиль. + +## Рабочие команды + +В локальном `pwsh` и в `powershell-windows` MCP: + +```powershell +detmir-win-test +detmir-win-ps '$PSVersionTable.PSVersion.ToString(); hostname; Get-Date -Format o' +detmir-win-ps 'Get-Service sshd | Select-Object Status,Name' +detmir-win-shell +``` + +Назначение: + +- `detmir-win-test` — быстрый smoke-test удалённого PowerShell; +- `detmir-win-ps` — выполнить PowerShell-скрипт на `192.168.100.18`; +- `detmir-win-shell` — открыть raw SSH shell на Windows-хост; +- `detmir-win-ssh` — выполнить произвольную SSH-команду одной строкой; +- `detmir-win-target` — показать текущий target. + +## Быстрая проверка + +Ожидаемый ответ: + +```powershell +detmir-win-test +``` + +```text +5.1.26100.32684 +Microsoft Windows NT 10.0.26100.0 +Windows Server 2025 Datacenter Evaluation +``` + +## Операторское правило + +Если нужен: + +- массовый deploy toolkit; +- validation через playbook; +- работа с `ansible aw_windows`; + +использовать `WinRM`. + +Если нужен: + +- интерактивный PowerShell из Linux; +- запуск удалённых PowerShell-скриптов из Codex/MCP; +- быстрая operator automation без `pywinrm`; + +использовать `SSH`-путь из этого документа. diff --git a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md new file mode 100644 index 0000000..132d57f --- /dev/null +++ b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md @@ -0,0 +1,356 @@ +# DetMir: Единая рабочая модель системы + +Дата фиксации: `2026-05-24` + +Этот файл предназначен как единая рабочая опора по `DetMir`: что именно входит в систему, где это живет, каким инструментарием проект надо планировать и сопровождать, и какой операционный контур считать промышленным. + +Если старые документы расходятся с этим файлом по адресам или runtime-ролям, для текущей эксплуатации приоритет у этого файла. + +## 1. Назначение + +`DetMir` в этом репозитории это не только `ActivityWatch Server`. +Это полный production-контур: + +- сбор активности и DLP-сигналов с Windows/RDP; +- сервер `AW-rus` с RU WebUI, API, health и management-отчетами; +- операторский узел `Proxmox/DetMirAuto`; +- сетевой контур `pfSense + OpenVPN`; +- Telegram bot для инцидентов, auto-heal, recovery и операционных команд; +- `Hayabusa` как DFIR enrichment слой; +- визуализация и аналитика в `Grafana`; +- отдельный `1C/file analytics` контур через `ClickHouse/Grafana/API`. + +Цель проекта: + +- держать рабочий production-контур без дрейфа между кодом, runtime и документацией; +- обеспечивать управляемый deploy, health-check, auto-heal и incident response; +- поддерживать дальнейшее развитие без разрушения текущей рабочей системы. + +## 2. Текущий подтвержденный runtime + +Ниже не историческая схема, а рабочая опорная карта. + +| Узел | Роль | +|---|---| +| `10.10.10.1` | `pfSense`, firewall, VPN, ACL, OpenVPN export target | +| `10.10.10.2` | `Proxmox/DetMirAuto`, web gateway, Telegram bot, operator entrypoint | +| `10.10.10.13` | основной `AW-rus` server, health, worktime/reporting, `Hayabusa` server-side processing | +| `192.168.100.18` | `SHARKON2025`, Windows/RDP host, collectors, worktime session path, EVTX export | + +Практический вывод: + +- серверный путь `AW-rus` сейчас должен считаться `10.10.10.13`; +- операторский и gateway-контур должен считаться `10.10.10.2`; +- Windows production-host для `DetMir` сейчас `192.168.100.18`, а не старые упоминания `192.168.100.21`. + +## 3. Полный функциональный состав DetMir + +### 3.1 Ядро AW-rus + +| Функция | Где реализована | +|---|---| +| ActivityWatch API и WebUI | `aw-server/`, `activitywatch-server.service` | +| RU WebUI patch | `aw-server/`, `docs/FULL_DEPLOYMENT_MANUAL_RU.md` | +| DLP overlay в WebUI | `aw-server/`, buckets `aw-dlp-review_*`, `aw-dlp-rules_*` | +| Health daemon | `aw-server/aw-rus-healthd.py` | +| Расширенный health-check | `aw-server/health-check.sh`, `check-aw-full.sh`, `check-aw-data.sh` | +| Management/worktime reports | server-side API на `:5610`, `docs/runbook.md`, `docs/worktime_aql_detmir.md` | + +### 3.2 Windows/RDP контур + +| Функция | Где реализована | +|---|---| +| Массовый deploy | `windows/deploy-domain-users.ps1`, `windows/deploy-ensemble.ps1` | +| Single-user deploy | `windows/deploy-single-user.ps1` | +| Validation | `windows/validate-deployment.ps1` | +| Recovery/hardening | `windows/hardening-recovery.ps1` | +| AFK/window watchers | `ActivityWatch` watchers + scheduled tasks | +| Browser domains | `windows/browser-domains-native-collector.ps1` | +| DLP endpoint signals | `windows/dlp-endpoint-signals-collector.ps1` | +| Email/DLP path | `windows/email-outbound-collector.ps1` | +| Worktime session presence | `windows/worktime-session-collector.ps1` | +| Incident artifacts / screenshots / EVTX export | `windows/export-evtx-for-hayabusa.ps1` и related runtime scripts | + +### 3.3 DLP и расследование + +| Функция | Где реализована | +|---|---| +| Endpoint DLP сигналы | bucket `aw-dlp-endpoint-signals_*` | +| DLP incidents | bucket `aw-dlp-incidents_*` | +| Review/rules operator flow | buckets `aw-dlp-review_*`, `aw-dlp-rules_*`, WebUI overlay | +| DLP report/scheduler | `aw-server` runtime + tests/docs | +| Transport/self-test telemetry | `aw-health-check` и Windows transport telemetry | +| Incident follow-up | operator path + `Hayabusa` enrichment | + +### 3.4 Worktime и managerial layer + +| Функция | Где реализована | +|---|---| +| Presence по RDP-сессиям | bucket `aw-worktime-sessions_*` | +| Работа по окнам/AFK | buckets `aw-watcher-window_*`, `aw-watcher-afk_*` | +| Web category worktime | bucket `aw-detmir-web-category_*` | +| AQL-шаблоны | `docs/worktime_aql_detmir.md` | +| Management report API | `:5610/reports/worktime/management` | +| Owner/department reporting | aliases и manager-facing filters в server-side report layer | + +### 3.5 Proxmox / operator / bot + +| Функция | Где реализована | +|---|---| +| LXC/bootstrap/deploy | `proxmox/create-ct.sh`, `proxmox/push-aw-artifacts.sh`, `ansible/` | +| Telegram incident bot | `proxmox/tsj_guardian_bot.py` | +| Auto-heal и recovery path | `tsj_guardian_bot.py`, `.planning/phases/02-operator-bot-recovery/` | +| OpenVPN config generation/export | `proxmox/pfsense_openvpn_client_export.php`, bot runtime | +| Proxmox restore/snapshot operator flow | bot pending restore flow + playbooks/runtime | +| Web gateway / internal entrypoint | `docs/runbook.md`, nginx/gateway rollout | + +### 3.6 pfSense / network / VPN + +| Функция | Где реализована | +|---|---| +| Firewall/ACL | `pfSense` ruleset | +| OpenVPN user access | `pfSense` + bot export path | +| pfSense telemetry в AW | `pfsense/pfsense-aw-poller.py`, `docs/pfsense.md` | +| Buckets `aw-pfsense-*` | health/interfaces/gateways | +| Routing between server and Windows | `pfSense` rules, не Windows-local hacks | + +### 3.7 Форензика Windows логов (Hayabusa) + +| Функция | Где реализована | +|---|---| +| EVTX export на Windows | `windows` runtime/export scripts | +| Intake на сервере | `10.10.10.13`, drop/inbox flow | +| Processing/reporting | `aw-hayabusa`, `/opt/hayabusa`, server-side services | +| Bounded integration с AW-rus | `docs/hayabusa-aw-rus-integration-2026-05-14.md` | +| Operator guidance | `docs/hayabusa-operator-ib-guide-2026-05-14.md`, `docs/runbook.md` | + +Ключевая граница: + +- `Hayabusa` это enrichment для incident/forensics; +- это не замена обычного realtime health/collector/DLP runtime. + +### 3.8 Grafana / monitoring / 1C analytics + +| Контур | Когда использовать | +|---|---| +| `grafana/` dashboards | AW-rus runtime, RDP activity, DLP, management/security overview | +| `grafana-1c/` | когда есть удобный SQL/read-only KPI путь из 1С | +| `clickhouse-1c/` | когда 1С файловая и нужен audit/timeline/detections/cases слой | + +По `clickhouse-1c/`: + +- это отдельный industrial scaffold для `file-1C analytics`; +- он не заменяет AW-rus, а добавляет audit/timeline/cases/company-intelligence контур; +- строится вокруг `landing -> ETL -> ClickHouse -> Grafana + AI Investigator`. + +## 4. Главные пользовательские и операторские входы + +Внутренний операторский доступ должен идти через VPN/внутреннюю сеть, не через случайно открытые наружу порты. + +Базовые входы: + +- `https://10.10.10.2/` — gateway; +- `https://10.10.10.2/go/proxmox-gui` — Proxmox GUI; +- `https://10.10.10.2/go/file1c-brief` — management/file-1C brief; +- `https://10.10.10.2/go/file1c-actions` — actions; +- `http://10.10.10.13:5600/api/0/info` — AW-rus API health; +- `http://10.10.10.13:5610/reports/worktime/management` — management reporting API. + +Telegram bot `DetMirAuto` обязан покрывать: + +- incident detection; +- `/ack`, `/heal`, `/run check`, `/run support`, `/run fallback`, `/status`; +- `/aw_dlp_check`, `/dlp_mode`, `/dlp_mode_toggle` для операторского DLP-контура; +- OpenVPN config issuance/export; +- operator-safe workflows по recovery и restore; +- отсутствие ложных алертов при transient self-heal. + +Операторская семантика меню бота: + +- DLP-кнопка обязана показывать текущий режим и следующее действие: +- `DLP сейчас: наблюдение | включить блокировку` +- `DLP сейчас: блокировка | включить наблюдение` +- `DLP сейчас: смешанный | выровнять в блокировку` +- Кнопка `Форензика Windows логов` — человеко-понятный вход в bounded Hayabusa path для Windows EVTX / DFIR follow-up. +- Если Telegram-клиент держит устаревшую custom-keyboard, оператор должен нажать `Статус` или `/start`: бот обязан переслать свежую клавиатуру с актуальным DLP label. + +## 5. Промышленный набор инструментов для планирования и сопровождения + +Ниже минимальный правильный стек, который стоит считать базовым для `DetMir`. + +### 5.1 Обязательное ядро GSD + +Эти компоненты нужны постоянно: + +- `gsd-phase` +- `gsd-plan-phase` +- `gsd-planner` +- `gsd-phase-researcher` +- `gsd-plan-checker` +- `gsd-execute-phase` +- `gsd-executor` +- `gsd-verifier` +- `gsd-verify-work` +- `gsd-review` +- `gsd-code-reviewer` +- `gsd-code-fixer` +- `gsd-debug` +- `gsd-debugger` +- `gsd-secure-phase` +- `gsd-security-auditor` + +### 5.2 Нужные supporting-компоненты + +- `gsd-doc-writer` +- `gsd-doc-synthesizer` +- `gsd-doc-verifier` +- `gsd-integration-checker` +- `gsd-intel-updater` +- `gsd-codebase-mapper` + +Для UI-фаз подключать только по необходимости: + +- `gsd-ui-phase` +- `gsd-ui-researcher` +- `gsd-ui-checker` +- `gsd-ui-auditor` + +### 5.3 Операционные skills для живого DetMir + +- `aw-ops-checks` +- `aw-russian-collectors-guard` +- `bot` +- `autonomous-skill` для длинных recovery/ops-циклов + +### 5.4 Что не нужно включать в базовый industrial-контур + +По умолчанию не поднимать как обязательную часть процесса: + +- `gsd-fast` +- `gsd-quick` +- `gsd-sketch` +- большинство `gsd-ns-*` +- `gsd-workstreams` + +Причина простая: + +- `DetMir` уже не greenfield и не playground; +- лишняя оркестрация здесь опаснее, чем полезна; +- нужен жесткий управляемый контур, а не разросшийся набор экспериментальных режимов. + +## 6. Что именно надо реанимировать из .codex + +Практическая схема такая: + +- использовать agent-описания из `/home/igor/.codex/agents/gsd-*` как действующее ядро исполнителей; +- вернуть orchestrator-skills из `/home/igor/.codex/skills_disabled/2026-05-21-current-prune/` для верхнеуровневого workflow; +- hooks `gsd-phase-boundary.sh`, `gsd-statusline.js`, `gsd-workflow-guard.js` держать как сервисную обвязку, а не как замену основному процессу. + +Минимум к восстановлению как orchestrator layer: + +- `gsd-phase` +- `gsd-plan-phase` +- `gsd-execute-phase` +- `gsd-review` +- `gsd-verify-work` +- `gsd-debug` +- `gsd-secure-phase` +- `gsd-ui-phase` + +## 7. Рабочий lifecycle для DetMir + +### 7.1 Планирование + +Каждая нетривиальная работа должна идти через фазу, а не через бессистемные правки. + +Обязательная цепочка: + +1. зафиксировать изменение в `.planning/ROADMAP.md` и состоянии проекта; +2. открыть фазу в `.planning/phases/-/`; +3. собрать `PLAN.md` через `gsd-plan-phase`; +4. выполнить research/check loop до defensible плана; +5. только после этого идти в выполнение. + +### 7.2 Исполнение + +Исполнение должно идти волнами, а не одним большим неоткатываемым махом. + +Обязательные правила: + +- backup-first перед risky runtime changes; +- server/network/windows changes не смешивать без явной причины; +- после каждого meaningful шага оставлять проверяемый след в артефактах и сервисных логах; +- не считать задачу завершенной без реальной проверки на живом контуре. + +### 7.3 Проверка и приемка + +После выполнения обязательно: + +- `gsd-verifier` на достижение целевого эффекта; +- `gsd-review` на код/риск/регрессии; +- `gsd-verify-work` на пользовательскую приемку; +- `gsd-secure-phase`, если фаза трогала сеть, доступы, VPN, secrets, bot, `pfSense`, `Windows` admin path. + +### 7.4 Документация + +После фазы обновлять не “когда-нибудь”, а сразу: + +- runbook; +- relevant docs; +- phase `SUMMARY.md`; +- если нужно, `STATE.md` и `ROADMAP.md`. + +Для `DetMir` это обязательно, потому что основная историческая боль проекта была не в отсутствии кода, а в расхождении между repo, runtime и реальностью. + +## 8. Базовый комплект артефактов + +Для каждой фазы и для всей системы нужны конкретные файлы, а не размытые договоренности. + +| Артефакт | Назначение | +|---|---| +| `.planning/ROADMAP.md` | очередь и границы проекта | +| `.planning/STATE.md` | текущее состояние production-контура | +| `.planning/phases/-/PLAN.md` | исполнимый план | +| `.planning/phases/-/SUMMARY.md` | факт выполненного | +| `VERIFICATION.md` | доказательство результата | +| `REVIEW.md` | findings по code/review | +| `SECURITY.md` | security findings по risky фазам | +| `UAT.md` | операторская приемка | +| `docs/runbook.md` | живая эксплуатация | +| этот файл | единая карта системы и рабочего процесса | + +## 9. Операционный минимум, который нельзя терять + +Система считается реально сопровождаемой только если одновременно живы все эти контуры: + +- `AW-rus API` отвечает; +- buckets по `afk/window/worktime/DLP` свежие; +- `aw-rus-healthd` и `aw-health-check` не врут и не шумят ложными fail; +- Telegram bot не генерирует ложные auto-heal incidents; +- `pfSense` ACL соответствуют фактическому runtime; +- есть рабочий Windows deploy/validate/recovery path; +- есть рабочий `Hayabusa` follow-up path; +- Grafana/importable dashboards version-controlled; +- management/reporting path на `:5610` не сломан; +- docs и phase-artifacts отражают реальное состояние, а не прошлую эпоху. + +## 10. Что считать правильным направлением развития + +Приоритет для `DetMir` сейчас такой: + +1. не расширять систему ценой потери baseline stability; +2. сначала держать зеленым production runtime; +3. потом усиливать regression guards и operator path; +4. затем развивать `DLP`, `management`, `Hayabusa`, `file-1C analytics`; +5. любой новый слой вводить только так, чтобы он не ломал существующий recovery path. + +## 11. Краткое правило принятия решений + +Если есть выбор между: + +- “быстро добавить еще один контур”; +- и “сделать существующий контур проверяемым, документированным и устойчивым”, + +для `DetMir` правильный выбор почти всегда второй. + +Это и есть промышленный режим сопровождения этой системы. diff --git a/docs/hayabusa-aw-rus-integration-2026-05-14.md b/docs/hayabusa-aw-rus-integration-2026-05-14.md index d651177..075d34e 100644 --- a/docs/hayabusa-aw-rus-integration-2026-05-14.md +++ b/docs/hayabusa-aw-rus-integration-2026-05-14.md @@ -1,4 +1,4 @@ -# Hayabusa AW-rus Integration 2026-05-14 +# Hayabusa / "Форензика Windows логов" AW-rus Integration 2026-05-14 This document defines the bounded integration between Hayabusa DFIR and the normal AW-rus operator path. @@ -40,6 +40,12 @@ or from Telegram bot: Default mode is `incident`. +Operator-facing note: + +- In Telegram menu the button is named `Форензика Windows логов`. +- Legacy button text `Hayabusa DFIR` may still appear in stale chat keyboards, but the bot must continue to accept it. +- This path remains bounded DFIR follow-up, not a primary runtime health/DLP control. + ## What gets linked to a case Case management stores only bounded metadata: diff --git a/docs/runbook.md b/docs/runbook.md index 7cda22d..c170219 100755 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -2,6 +2,26 @@ ## Быстрый health-check +### Proxmox web gateway + +Если на host `10.10.10.2` развёрнут `nginx` gateway, базовые проверки такие: + +```sh +systemctl status nginx --no-pager +nginx -t +curl -I -sS -H 'Host: dm.iri1968.dpdns.org' http://127.0.0.1/ +curl -k -fsS -H 'Host: dm.iri1968.dpdns.org' https://127.0.0.1/healthz +curl -k -I -sS -H 'Host: dm.iri1968.dpdns.org' https://127.0.0.1/go/proxmox-gui +curl -k -H 'Host: dm.iri1968.dpdns.org' -fsS https://127.0.0.1/ | grep -F 'dm.iri1968.dpdns.org' +``` + +Playbook для повторного rollout: + +```sh +ANSIBLE_HOST_KEY_CHECKING=False \ +ansible-playbook -i ansible/inventory.ini ansible/deploy_proxmox_web_gateway.yml +``` + ### На Proxmox ```sh @@ -59,6 +79,35 @@ grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html ls -l /opt/activitywatch/webui-ru/js/ ``` +## Telegram bot: DLP режим и форензика + +Для оператора `DetMirAuto` каноническое поведение такое: + +- Кнопка DLP должна явно показывать текущий режим и целевое действие: +- `DLP сейчас: наблюдение | включить блокировку` +- `DLP сейчас: блокировка | включить наблюдение` +- `DLP сейчас: смешанный | выровнять в блокировку` +- Slash-путь для проверки без переключения: `/dlp_mode` +- Slash-путь для переключения: `/dlp_mode_toggle` + +Интерпретация: + +- `наблюдение` = endpoint/email правила работают в monitor-поведении (`alert/log`), без жёсткого `block`; +- `блокировка` = endpoint/email правила переведены в `block`; +- `смешанный` = часть endpoint/email правил уже `block`, часть ещё monitor-like; нормальный следующий ход — выровнять policy. + +Форензика: + +- Кнопка в меню называется `Форензика Windows логов`; +- Это человеко-понятный операторский вход в bounded Hayabusa path; +- Slash-команда остаётся прежней: `/aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]` + +Если Telegram показывает старую клавиатуру: + +- Нажать `Статус` или `/start`; +- Бот обязан прислать свежую custom-keyboard с актуальным DLP label; +- Старая кнопка в чате может ещё приходить как текст, но бот должен её принять и после ответа вернуть свежую клавиатуру. + Проверить: - есть `aw-ru-patch.js`; @@ -105,6 +154,8 @@ Playbook вычисляет `durationDefault` автоматически (вкл curl -fsS 'http://127.0.0.1:5610/reports/worktime/today?day=today' | jq '.[:5]' curl -fsS 'http://127.0.0.1:5610/reports/worktime/management?day=today' | jq '.summary,.executive' curl -fsS 'http://127.0.0.1:5610/reports/worktime/management?format=html&day=today' | head +curl -fsS 'http://127.0.0.1:5610/reports/worktime/management?day=today&owner=Сменный%20руководитель' | jq '.filters,.summary,.owner_rollups' +curl -fsS 'http://127.0.0.1:5610/reports/worktime/management?day=today&department=Операторы%201С' | jq '.filters,.summary,.department_rollups' ``` Важно: @@ -113,6 +164,21 @@ curl -fsS 'http://127.0.0.1:5610/reports/worktime/management?format=html&day=tod API пересчитывает trend и source freshness; - повторные запросы должны быть быстрыми за счёт cache в `/var/lib/activitywatch/worktime-cache`; +- по умолчанию cache живёт `300` секунд и прогревается локально через + `aw-worktime-autoheal.service`, чтобы manager-страницы не висели на cold-start; +- default warm-path должен ходить в `format=json`, потому что этого достаточно + для наполнения cache и это заметно легче, чем cold HTML render; +- warm timeout по умолчанию `70` секунд и задаётся через + `AW_WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS`; +- management report поддерживает scope-фильтры `owner` и `department`; они + пересчитывают `summary`, `rows`, `actions` и rollups под выбранный срез, а не + просто прячут строки в HTML; +- `trend` в filtered-view сейчас сознательно отключён: это удерживает latency в + рабочем диапазоне и не заставляет сервер заново пересчитывать исторический + ряд на каждый менеджерский клик; +- alias-файл теперь может содержать не только `users`, но и `owners`; + блок `owners` нужен для manager-facing каталога ответственных: + `display_name`, `title`, `department`, `contact`, `escalation_to`, `notes`; - alias-файл для нормализации сотрудников по умолчанию: `/etc/activitywatch/worktime-manager-aliases.json`. @@ -383,6 +449,34 @@ curl -fsS 'http://10.10.10.13:5600/api/0/buckets/aw-file-operations_10.10.10.13/ Remove-NetFirewallRule -DisplayName 'AWatch WAL Test Block 5600' -ErrorAction SilentlyContinue ``` +### PowerShell MCP на DetMir Windows host + +Для `DetMir` Windows host `192.168.100.18` интерактивный путь из Linux/Codex закреплён через `SSH`, а не через `WSMan`. + +Быстрый вход: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +bash scripts/install_detmir_powershell_mcp.sh +``` + +После переоткрытия `pwsh` или новой Codex-session: + +```powershell +detmir-win-test +detmir-win-ps 'hostname; Get-Date -Format o' +detmir-win-shell +``` + +Канонический документ: + +- `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` + +Правило: + +- `WinRM` использовать для `ansible aw_windows` и playbook-ов; +- `SSH` использовать для `powershell-windows` MCP, operator PowerShell и ad-hoc remote execution. + ### У пользователей всплывает окно PowerShell Ожидаемое поведение collector-ов: запуск hidden (`-WindowStyle Hidden`). diff --git a/pfsense/pfsense-aw-poller.example.json b/pfsense/pfsense-aw-poller.example.json index 6ec7097..113ac2a 100644 --- a/pfsense/pfsense-aw-poller.example.json +++ b/pfsense/pfsense-aw-poller.example.json @@ -27,7 +27,7 @@ }, { "name": "interfaces", - "path": "/api/v2/interface", + "path": "/api/v2/interfaces", "bucket_prefix": "aw-pfsense-interfaces", "bucket_type": "aw.pfsense.interfaces" }, diff --git a/pfsense/pfsense-aw-poller.py b/pfsense/pfsense-aw-poller.py index 5f96ba8..49678f1 100644 --- a/pfsense/pfsense-aw-poller.py +++ b/pfsense/pfsense-aw-poller.py @@ -42,17 +42,21 @@ def http_json(url, method="GET", headers=None, body=None, timeout=15, ssl_contex def ensure_bucket(aw_base_url, bucket_id, client_name, bucket_type, hostname, timeout, ssl_context): - http_json( - f"{aw_base_url}/buckets/{urllib.parse.quote(bucket_id, safe='')}", - method="POST", - body={ - "client": client_name, - "type": bucket_type, - "hostname": hostname, - }, - timeout=timeout, - ssl_context=ssl_context, - ) + try: + http_json( + f"{aw_base_url}/buckets/{urllib.parse.quote(bucket_id, safe='')}", + method="POST", + body={ + "client": client_name, + "type": bucket_type, + "hostname": hostname, + }, + timeout=timeout, + ssl_context=ssl_context, + ) + except urllib.error.HTTPError as error: + if error.code not in (304, 409): + raise def send_heartbeat(aw_base_url, bucket_id, event, pulse_time, timeout, ssl_context): @@ -70,8 +74,14 @@ def normalize_headers(config): auth = config.get("auth") or {} bearer_token = auth.get("bearer_token") basic = auth.get("basic") + api_key = auth.get("api_key") + api_secret = auth.get("api_secret") if bearer_token: headers["Authorization"] = f"Bearer {bearer_token}" + elif api_key: + headers["X-API-Key"] = api_key + if api_secret: + headers["X-API-Secret"] = api_secret elif basic and basic.get("username") and basic.get("password"): import base64 token = base64.b64encode(f"{basic['username']}:{basic['password']}".encode("utf-8")).decode("ascii") diff --git a/proxmox/pfsense_openvpn_client_export.php b/proxmox/pfsense_openvpn_client_export.php new file mode 100644 index 0000000..34b6bc9 --- /dev/null +++ b/proxmox/pfsense_openvpn_client_export.php @@ -0,0 +1,298 @@ + $arg) { + if (str_starts_with($arg, $prefix . '=')) { + $value = substr($arg, strlen($prefix) + 1); + unset($argv[$index]); + $argv = array_values($argv); + return $value; + } + if ($arg === $prefix && isset($argv[$index + 1])) { + $value = $argv[$index + 1]; + unset($argv[$index], $argv[$index + 1]); + $argv = array_values($argv); + return $value; + } + } + return null; +} + +function normalize_common_name(string $raw): string { + $value = trim($raw); + $value = preg_replace('/[^A-Za-z0-9_.-]/', '_', $value); + $value = trim($value, '._-'); + if ($value === '') { + fail_with('common name is empty after normalization'); + } + return $value; +} + +function build_cert_index_by_descr(array $certs): array { + $out = []; + foreach ($certs as $index => $cert) { + $descr = trim((string)($cert['descr'] ?? '')); + if ($descr !== '') { + $out[$descr] = $index; + } + } + return $out; +} + +function build_csc_index_by_common_name(array $entries): array { + $out = []; + foreach ($entries as $index => $entry) { + $commonName = trim((string)($entry['common_name'] ?? '')); + if ($commonName !== '') { + $out[$commonName] = $index; + } + } + return $out; +} + +function default_csc_template(): array { + return [ + 'custom_options' => '', + 'local_network' => '10.0.0.0/8, 172.16.0.0/12, 192.168.100.0/24', + 'local_networkv6' => '', + 'remote_network' => '', + 'remote_networkv6' => '', + 'dns_server1' => '192.168.100.1', + 'dns_server2' => '192.168.100.250', + 'dns_server3' => '', + 'dns_server4' => '', + 'push_blockoutsidedns' => '', + 'push_register_dns' => '', + 'netbios_enable' => 'yes', + 'netbios_ntype' => '0', + 'netbios_scope' => '', + ]; +} + +function pick_csc_template(array $entries, string $serverId): array { + $fields = array_keys(default_csc_template()); + $variants = []; + $counts = []; + + foreach ($entries as $entry) { + if ((string)($entry['server_list'] ?? '') !== $serverId) { + continue; + } + $variant = []; + foreach ($fields as $field) { + $variant[$field] = (string)($entry[$field] ?? ''); + } + $key = json_encode($variant, JSON_UNESCAPED_SLASHES); + $counts[$key] = ($counts[$key] ?? 0) + 1; + $variants[$key] = $variant; + } + + if ($counts === []) { + return default_csc_template(); + } + + arsort($counts, SORT_NUMERIC); + $bestKey = array_key_first($counts); + return $variants[$bestKey] ?? default_csc_template(); +} + +function allocate_tunnel_network(array $server, array $entries, string $serverId, int $firstHostOffset): string { + $tunnelNetwork = trim((string)($server['tunnel_network'] ?? '')); + if (!preg_match('/^([0-9.]+)\/(\d{1,2})$/', $tunnelNetwork, $match)) { + fail_with("unsupported tunnel network: {$tunnelNetwork}"); + } + + $networkIp = $match[1]; + $prefix = (int)$match[2]; + if ($prefix < 1 || $prefix > 30) { + fail_with("unsupported tunnel prefix: {$prefix}"); + } + + $networkLong = ip2long($networkIp); + if ($networkLong === false) { + fail_with("invalid tunnel network IP: {$networkIp}"); + } + + $blockSize = 1 << (32 - $prefix); + $broadcastLong = $networkLong + $blockSize - 1; + $startLong = $networkLong + max(1, $firstHostOffset); + + $used = []; + foreach ($entries as $entry) { + if ((string)($entry['server_list'] ?? '') !== $serverId) { + continue; + } + $value = trim((string)($entry['tunnel_network'] ?? '')); + if (!preg_match('/^([0-9.]+)\/\d+$/', $value, $entryMatch)) { + continue; + } + $ipLong = ip2long($entryMatch[1]); + if ($ipLong !== false) { + $used[$ipLong] = true; + } + } + + for ($candidate = $startLong; $candidate < $broadcastLong; $candidate++) { + if (!isset($used[$candidate])) { + return long2ip($candidate) . '/' . $prefix; + } + } + + for ($candidate = $networkLong + 1; $candidate < $startLong; $candidate++) { + if (!isset($used[$candidate])) { + return long2ip($candidate) . '/' . $prefix; + } + } + + fail_with("no free client address left in {$tunnelNetwork}"); +} + +$argvCopy = $argv; +array_shift($argvCopy); + +$commonNameRaw = parse_option($argvCopy, '--common-name'); +$serverId = (int)(parse_option($argvCopy, '--server-id') ?? 1); +$firstHostOffset = (int)(parse_option($argvCopy, '--first-host-offset') ?? 13); + +if ($commonNameRaw === null || trim($commonNameRaw) === '') { + fail_with('missing --common-name'); +} +if ($serverId <= 0) { + fail_with('server id must be positive'); +} + +$commonName = normalize_common_name($commonNameRaw); +$server = get_openvpnserver_by_id($serverId); +if (empty($server)) { + fail_with("OpenVPN server {$serverId} not found"); +} + +$caref = trim((string)($server['caref'] ?? '')); +if ($caref === '') { + fail_with("OpenVPN server {$serverId} does not have caref"); +} + +$certs = config_get_path('cert', []); +$cscEntries = config_get_path('openvpn/openvpn-csc', []); +$certIndexByDescr = build_cert_index_by_descr($certs); +$cscIndexByCommonName = build_csc_index_by_common_name($cscEntries); + +$certCreated = false; +$cscCreated = false; +$changed = false; +$serverKey = (string)$serverId; + +if (!array_key_exists($commonName, $certIndexByDescr)) { + $cert = [ + 'refid' => uniqid('', true), + 'descr' => $commonName, + ]; + $dn = [ + 'countryName' => 'RU', + 'stateOrProvinceName' => 'Komi Republic', + 'localityName' => 'Syktyvkar', + 'commonName' => $commonName, + ]; + if (!cert_create($cert, $caref, 2048, 3650, $dn, 'user', 'sha256', 'RSA')) { + fail_with("failed to create certificate for {$commonName}"); + } + $certs[] = $cert; + $certIndexByDescr[$commonName] = count($certs) - 1; + $certCreated = true; + $changed = true; +} + +if (!array_key_exists($commonName, $cscIndexByCommonName)) { + $template = pick_csc_template($cscEntries, $serverKey); + $template['server_list'] = $serverKey; + $template['common_name'] = $commonName; + $template['description'] = "{$commonName} access"; + $template['tunnel_network'] = allocate_tunnel_network($server, $cscEntries, $serverKey, $firstHostOffset); + $template['tunnel_networkv6'] = ''; + $template['gateway'] = ''; + $template['gateway6'] = ''; + $cscEntries[] = $template; + $cscIndexByCommonName[$commonName] = count($cscEntries) - 1; + $cscCreated = true; + $changed = true; +} + +if ($changed) { + config_set_path('cert', $certs); + config_set_path('openvpn/openvpn-csc', $cscEntries); + write_config("TSJ Guardian OpenVPN export for {$commonName}"); + $server = get_openvpnserver_by_id($serverId); + openvpn_resync('server', $server); + openvpn_resync_csc_all(); + + $certs = config_get_path('cert', []); + $cscEntries = config_get_path('openvpn/openvpn-csc', []); + $certIndexByDescr = build_cert_index_by_descr($certs); + $cscIndexByCommonName = build_csc_index_by_common_name($cscEntries); +} + +$certIndex = $certIndexByDescr[$commonName] ?? null; +if ($certIndex === null) { + fail_with("certificate index missing for {$commonName}"); +} + +$cscIndex = $cscIndexByCommonName[$commonName] ?? null; +if ($cscIndex === null) { + fail_with("CSC entry missing for {$commonName}"); +} + +$prefix = openvpn_client_export_prefix($serverId, null, $certIndex); +if (!$prefix) { + fail_with("failed to build export prefix for {$commonName}"); +} + +$inline = openvpn_client_export_config( + $serverId, + null, + $certIndex, + 'serveraddr', + 'auto', + true, + true, + '', + false, + false, + '', + 'inline', + '', + 'high', + false, + false, + '', + false, + '', + '' +); +if ($inline === false || trim((string)$inline) === '') { + fail_with("inline export failed for {$commonName}"); +} + +$csc = $cscEntries[$cscIndex] ?? []; +$result = [ + 'common_name' => $commonName, + 'server_id' => $serverId, + 'filename' => "{$prefix}-config.ovpn", + 'tunnel_network' => (string)($csc['tunnel_network'] ?? ''), + 'cert_created' => $certCreated, + 'csc_created' => $cscCreated, + 'config_sha256' => hash('sha256', $inline), + 'config_b64' => base64_encode($inline), +]; + +echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; diff --git a/proxmox/test_tsj_guardian_bot.py b/proxmox/test_tsj_guardian_bot.py new file mode 100644 index 0000000..0bb74e3 --- /dev/null +++ b/proxmox/test_tsj_guardian_bot.py @@ -0,0 +1,496 @@ +import unittest +from pathlib import Path +from unittest import mock + +import proxmox.tsj_guardian_bot as MODULE + + +class DummyState: + def __init__(self): + self.pending_incident = None + self.last_warning_signature = "" + + def save(self): + pass + + +def make_bot(check_rc, check_out, heal_rc, heal_out): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.retry_autoheal_sec = 300 + bot.exit_on_autoheal_success = True + bot.fs_immediate_ai_on_critical = False + bot.operator_timeout = 900 + bot.check_script = "/tmp/fake-check" + bot.heal_script = "/tmp/fake-heal" + bot._notifications = [] + bot._logs = [] + bot._notify = lambda msg: bot._notifications.append(msg) + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot._sync_warning_state = lambda warnings: None + bot._run_check_script_once = lambda timeout_sec=240: (check_rc, check_out, True) + bot._run_shell = lambda cmd, timeout_sec=420: (heal_rc, heal_out) + bot._background_aw_rus_failures = lambda failures: ([], []) + bot._is_aw_rus_failure_line = lambda line: line.strip().lower().startswith("[fail] aw-rus:") + bot._extract_aw_rus_failure_keys = lambda failures: [] + bot._perform_aw_rus_autoheal = lambda failures: (True, [], ["Проверка AW-Rus + DLP:"], []) + return bot + + +class TsjGuardianBotTests(unittest.TestCase): + def test_new_incident_auto_resolved_before_notification_stays_silent(self): + check_out = "2026-05-24 10:01:18 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" + heal_out = "2026-05-24 10:01:21 [OK] node_13: HTTP 200 OK: http://10.10.10.13:5600/api/0/info\n" + bot = make_bot(check_rc=1, check_out=check_out, heal_rc=0, heal_out=heal_out) + + bot._handle_check_cycle() + + self.assertIsNone(bot.state.pending_incident) + self.assertEqual(bot._notifications, []) + self.assertTrue(any("auto-resolved before operator notification" in msg for _, msg in bot._logs)) + + def test_new_incident_notifies_only_after_autoheal_failure(self): + check_out = "2026-05-24 10:01:18 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" + heal_out = "2026-05-24 10:01:40 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" + bot = make_bot(check_rc=1, check_out=check_out, heal_rc=1, heal_out=heal_out) + + bot._handle_check_cycle() + + self.assertIsNotNone(bot.state.pending_incident) + self.assertEqual(len(bot._notifications), 2) + self.assertIn("Обнаружен инцидент", bot._notifications[0]) + self.assertIn("Авто-лечение неуспешно", bot._notifications[1]) + + def test_aw_only_failure_enters_background_incident_cycle(self): + bot = make_bot(check_rc=0, check_out="", heal_rc=0, heal_out="") + bot._background_aw_rus_failures = lambda failures: ( + ["[FAIL] aw-rus:watcher-window: watcher-window: STALE age=3600s end=2026-05-25T04:45:45Z"], + ["watcher-window"], + ) + bot._attempt_autoheal = lambda *args, **kwargs: False + + bot._handle_check_cycle() + + self.assertIsNotNone(bot.state.pending_incident) + self.assertIn("aw-rus:watcher-window", "\n".join(bot.state.pending_incident.failures)) + self.assertEqual(len(bot._notifications), 2) + self.assertIn("Обнаружен инцидент", bot._notifications[0]) + + def test_aw_only_failure_auto_resolves_silently(self): + bot = make_bot(check_rc=0, check_out="", heal_rc=0, heal_out="") + bot._background_aw_rus_failures = lambda failures: ( + ["[FAIL] aw-rus:worktime: worktime(USER1): STALE active_seconds=0"], + ["worktime"], + ) + def fake_autoheal(*args, **kwargs): + bot.state.pending_incident = None + return True + bot._attempt_autoheal = fake_autoheal + + bot._handle_check_cycle() + + self.assertIsNone(bot.state.pending_incident) + self.assertEqual(bot._notifications, []) + self.assertTrue(any("auto-resolved before operator notification" in msg for _, msg in bot._logs)) + + +class TelegramApiDocumentTests(unittest.TestCase): + def test_send_document_rejects_empty_payload(self): + api = MODULE.TelegramAPI("123:abc", proxy_url="") + with self.assertRaisesRegex(RuntimeError, "Refusing to send empty document"): + api.send_document(1, "planshet.ovpn", b"") + + @mock.patch("proxmox.tsj_guardian_bot.requests.post") + def test_send_document_uses_requests_multipart(self, post_mock): + response = mock.Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"ok": True} + post_mock.return_value = response + + api = MODULE.TelegramAPI("123:abc", proxy_url="http://127.0.0.1:11090") + api.send_document(42, "planshet.ovpn", b"client\nremote x\n", caption="cfg") + + args, kwargs = post_mock.call_args + self.assertEqual(args[0], "https://api.telegram.org/bot123:abc/sendDocument") + self.assertEqual(kwargs["data"]["chat_id"], "42") + self.assertEqual(kwargs["data"]["caption"], "cfg") + self.assertEqual(kwargs["files"]["document"][0], "planshet.ovpn") + self.assertEqual(kwargs["files"]["document"][1], b"client\nremote x\n") + self.assertEqual(kwargs["proxies"], {"http": "http://127.0.0.1:11090", "https": "http://127.0.0.1:11090"}) + + +class OpenVpnHelperTests(unittest.TestCase): + def test_load_pfsense_readonly_env_uses_instance_override_path(self): + env_text = """ +PFSENSE_URL=https://10.10.10.1:8443 +PFSENSE_API_KEY=test-key +VERIFY_SSL=false +""" + with mock.patch.object(Path, "read_text", return_value=env_text) as read_mock: + bot = object.__new__(MODULE.TSJGuardianBot) + bot.pfsense_env_path = "/tmp/pfsense.env.readonly" + base_url, api_key, verify_ssl = MODULE.TSJGuardianBot._load_pfsense_readonly_env(bot) + + self.assertEqual((base_url, api_key, verify_ssl), ("https://10.10.10.1:8443", "test-key", False)) + read_mock.assert_called_once_with(encoding="utf-8") + + def test_parse_openvpn_helper_result_returns_config_bytes(self): + bot = object.__new__(MODULE.TSJGuardianBot) + payload = MODULE.json.dumps( + { + "common_name": "planshet", + "filename": "planshet.ovpn", + "tunnel_network": "10.0.13.22/24", + "cert_created": True, + "csc_created": True, + "config_b64": MODULE.base64.b64encode( + b"client\nremote 10.10.10.1 1194 udp\n\nX\n\n" + ).decode("ascii"), + } + ) + + filename, summary, config_bytes = MODULE.TSJGuardianBot._parse_openvpn_helper_result(bot, payload, "planshet") + + self.assertEqual(filename, "planshet.ovpn") + self.assertIn("planshet", summary) + self.assertIn("10.0.13.22/24", summary) + self.assertIn("сертификат создан: да", summary) + self.assertEqual(config_bytes, b"client\nremote 10.10.10.1 1194 udp\n\nX\n\n") + + def test_parse_openvpn_helper_result_rejects_invalid_config(self): + bot = object.__new__(MODULE.TSJGuardianBot) + payload = MODULE.json.dumps( + { + "filename": "bad.ovpn", + "config_b64": MODULE.base64.b64encode(b"placeholder output").decode("ascii"), + } + ) + + with self.assertRaisesRegex(RuntimeError, "missing markers"): + MODULE.TSJGuardianBot._parse_openvpn_helper_result(bot, payload, "planshet") + + def test_load_pfsense_inventory_access_parses_root_ssh_values(self): + inventory = """ +### 2. pfSense + +- SSH LAN: `10.10.10.1:2022` +- Web UI: `10.10.10.1:8443` +- Admin login: `admin` +- Admin password: `admin-secret` +- Root login: `root` +- Root password: `secret` + +### 3. InfluxDB +""" + with mock.patch.object(Path, "read_text", return_value=inventory): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.pfsense_inventory_path = "/tmp/inventory.md" + host, port, user, password = MODULE.TSJGuardianBot._load_pfsense_inventory_access(bot) + + self.assertEqual((host, port, user, password), ("10.10.10.1", 2022, "root", "secret")) + + def test_load_pfsense_web_access_parses_web_values(self): + inventory = """ +### 2. pfSense + +- Web UI: `10.10.10.1:8443` +- Admin login: `admin` +- Admin password: `admin-secret` + +### 3. InfluxDB +""" + with mock.patch.object(Path, "read_text", return_value=inventory): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.pfsense_inventory_path = "/tmp/inventory.md" + base_url, user, password = MODULE.TSJGuardianBot._load_pfsense_web_access(bot) + + self.assertEqual((base_url, user, password), ("https://10.10.10.1:8443", "admin", "admin-secret")) + + def test_extract_pfsense_csrf_token(self): + token = MODULE.TSJGuardianBot._extract_pfsense_csrf_token('var csrfMagicToken = "abc123";') + self.assertEqual(token, "abc123") + + +class ProcessDecodeTests(unittest.TestCase): + def test_decode_process_output_falls_back_to_cp1251(self): + text = "sharkon2025\\Администратор" + raw = text.encode("cp1251") + + decoded = MODULE.TSJGuardianBot._decode_process_output(raw) + + self.assertEqual(decoded, text) + + +class WorktimeCsvParseTests(unittest.TestCase): + def test_parse_worktime_today_csv_uses_named_active_seconds_column(self): + csv_text = ( + "user,user_id,active_seconds,active_hhmm\n" + "user1,SHARKON2025\\\\user1,155,00:02\n" + "администратор,SHARKON2025\\\\Администратор,9330,02:35\n" + ) + + rows = MODULE.TSJGuardianBot._parse_worktime_today_csv(csv_text) + + self.assertEqual(rows[0]["user"], "user1") + self.assertEqual(rows[0]["active_seconds"], 155) + self.assertEqual(rows[1]["user"], "администратор") + self.assertEqual(rows[1]["active_seconds"], 9330) + + def test_decode_process_output_prefers_cp866_when_it_scores_better(self): + text = "Подготовка модулей" + raw = text.encode("cp866") + + decoded = MODULE.TSJGuardianBot._decode_process_output(raw) + + self.assertEqual(decoded, text) + + @mock.patch("proxmox.tsj_guardian_bot.requests.get") + def test_fetch_worktime_today_csv_retries_once_after_timeout(self, get_mock): + timeout_exc = MODULE.requests.exceptions.ReadTimeout("read timeout") + response = mock.Mock() + response.raise_for_status.return_value = None + response.text = "user,user_id,active_seconds\nuser1,SHARKON2025\\\\user1,155\n" + get_mock.side_effect = [timeout_exc, response] + + bot = object.__new__(MODULE.TSJGuardianBot) + bot.aw_rus_worktime_base = "http://10.10.10.13:5610" + + csv_text = MODULE.TSJGuardianBot._fetch_worktime_today_csv(bot, timeout_sec=3, attempts=2) + + self.assertIn("user1", csv_text) + + +class AwRusDlpProbeTests(unittest.TestCase): + @mock.patch("proxmox.tsj_guardian_bot.requests.get") + def test_dlp_endpoint_stale_is_downgraded_when_fileops_is_fresh(self, get_mock): + real_datetime = MODULE.datetime + now = real_datetime(2026, 5, 26, 19, 40, tzinfo=MODULE.timezone.utc) + stale_endpoint = "2026-05-26T15:30:48.682Z" + fresh_fileops = "2026-05-26T19:39:30.000Z" + fresh_worktime = "2026-05-26T19:39:50.000Z" + fresh_watcher = "2026-05-26T19:39:40.000Z" + + def response(payload): + resp = mock.Mock() + resp.raise_for_status.return_value = None + resp.json.return_value = payload + return resp + + def fake_get(url, timeout=20): + if url.endswith("/buckets"): + return response( + { + "aw-worktime-sessions_SHARKON2025": {"metadata": {"end": fresh_worktime}}, + "aw-watcher-window_SHARKON2025": {"metadata": {"end": fresh_watcher}}, + "aw-watcher-afk_SHARKON2025": {"metadata": {"end": fresh_watcher}}, + "aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": stale_endpoint}}, + "aw-file-operations_SHARKON2025": {"metadata": {"end": fresh_fileops}}, + "aw-file-operations_10.10.10.13": {"metadata": {"end": fresh_fileops}}, + } + ) + if "aw-worktime-sessions_SHARKON2025/events" in url: + return response([{"timestamp": fresh_worktime, "data": {"active": True}}]) + if url.endswith("/buckets/aw-watcher-window_SHARKON2025"): + return response({"metadata": {"end": fresh_watcher}}) + if url.endswith("/buckets/aw-watcher-afk_SHARKON2025"): + return response({"metadata": {"end": fresh_watcher}}) + if url.endswith("/buckets/aw-dlp-endpoint-signals_SHARKON2025"): + return response({"metadata": {"end": stale_endpoint}}) + if url.endswith("/buckets/aw-file-operations_SHARKON2025"): + return response({"metadata": {"end": fresh_fileops}}) + if url.endswith("/buckets/aw-file-operations_10.10.10.13"): + return response({"metadata": {"end": fresh_fileops}}) + raise AssertionError(f"unexpected url {url}") + + get_mock.side_effect = fake_get + + bot = object.__new__(MODULE.TSJGuardianBot) + bot.aw_rus_api_base = "http://10.10.10.13:5600/api/0" + bot.aw_rus_worktime_base = "http://10.10.10.13:5610" + bot.aw_rus_host = "SHARKON2025" + bot.aw_rus_stale_sec = 900 + bot.aw_rus_primary_user = "user1" + bot._fetch_worktime_today_csv = lambda timeout_sec=20, attempts=2: ( + "user,user_id,active_seconds\n" + "user1,SHARKON2025\\\\user1,120\n" + ) + + with mock.patch("proxmox.tsj_guardian_bot.datetime") as dt_mock: + dt_mock.now.return_value = now + dt_mock.fromisoformat.side_effect = lambda value: real_datetime.fromisoformat(value) + lines, failures = MODULE.TSJGuardianBot._aw_rus_dlp_probe(bot) + + self.assertNotIn("dlp-endpoint", failures) + self.assertTrue(any("file-operations fresh; endpoint collector degraded" in line for line in lines)) + + +class BotUiLabelTests(unittest.TestCase): + def test_dlp_toggle_button_text_reflects_monitor_mode(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._aw_dlp_policy_request = lambda method, path, timeout_sec=5: { + "policy": { + "endpoint": { + "clipboard": [{"id": "c1", "enabled": True, "action": "alert"}], + "email": [{"id": "e1", "enabled": True, "action": "log"}], + } + } + } + + label = MODULE.TSJGuardianBot._aw_dlp_toggle_button_text(bot) + + self.assertEqual(label, "DLP сейчас: наблюдение | включить блокировку") + + def test_menu_markup_uses_dynamic_dlp_label_and_human_dfir_label(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._aw_dlp_toggle_button_text = lambda current_mode=None: "DLP сейчас: блокировка | включить наблюдение" + + markup = MODULE.TSJGuardianBot._menu_markup(bot) + + self.assertIn( + ["Проверка AW-Rus + DLP", "DLP сейчас: блокировка | включить наблюдение"], + markup["keyboard"], + ) + self.assertIn(["Форензика Windows логов"], markup["keyboard"]) + + def test_hayabusa_usage_text_is_human_friendly(self): + bot = object.__new__(MODULE.TSJGuardianBot) + + text = MODULE.TSJGuardianBot._aw_rus_hayabusa_usage_text(bot) + + self.assertIn("Форензика Windows логов:", text) + self.assertIn("/aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]", text) + + def test_dlp_mode_command_refreshes_menu_markup(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.BTN_AW_DLP_CHECK = MODULE.TSJGuardianBot.BTN_AW_DLP_CHECK + bot.BTN_DLP_MODE_TOGGLE = MODULE.TSJGuardianBot.BTN_DLP_MODE_TOGGLE + bot.BTN_AW_DFIR = MODULE.TSJGuardianBot.BTN_AW_DFIR + bot.BTN_AW_DFIR_LEGACY = MODULE.TSJGuardianBot.BTN_AW_DFIR_LEGACY + bot.BTN_STATUS = MODULE.TSJGuardianBot.BTN_STATUS + bot.BTN_CHECK = MODULE.TSJGuardianBot.BTN_CHECK + bot.BTN_HEAL = MODULE.TSJGuardianBot.BTN_HEAL + bot.BTN_ACK = MODULE.TSJGuardianBot.BTN_ACK + bot.BTN_RESOLVE = MODULE.TSJGuardianBot.BTN_RESOLVE + bot.BTN_HELP = MODULE.TSJGuardianBot.BTN_HELP + bot.allowed_chats = {42} + bot.state = DummyState() + bot._expire_pending_pfsense_change_if_needed = lambda: None + bot._expire_pending_openvpn_config_if_needed = lambda: None + bot._expire_pending_proxmox_selection_if_needed = lambda: None + bot._expire_pending_proxmox_restore_if_needed = lambda: None + bot._log = lambda level, msg: None + sent = {} + bot._send_text = lambda chat_id, text, reply_markup=None: sent.update( + {"chat_id": chat_id, "text": text, "reply_markup": reply_markup} + ) + bot._send_text_with_menu = lambda chat_id, text: sent.update( + {"chat_id": chat_id, "text": text, "reply_markup": bot._menu_markup()} + ) + bot._send_menu = lambda chat_id, text: sent.update({"chat_id": chat_id, "text": text}) + bot._run_operator_action = lambda action: f"ran:{action}" + bot._menu_markup = lambda: {"keyboard": [["Проверка AW-Rus + DLP", "DLP сейчас: наблюдение | включить блокировку"]]} + bot._is_dlp_toggle_button = lambda text: text.startswith("DLP") + bot._is_dfir_button = lambda text: text in {MODULE.TSJGuardianBot.BTN_AW_DFIR, MODULE.TSJGuardianBot.BTN_AW_DFIR_LEGACY} + + update = { + "message": { + "chat": {"id": 42}, + "text": "/dlp_mode", + } + } + + MODULE.TSJGuardianBot._process_message(bot, update) + + self.assertEqual(sent["chat_id"], 42) + self.assertEqual(sent["text"], "ran:dlp-mode") + self.assertEqual( + sent["reply_markup"], + {"keyboard": [["Проверка AW-Rus + DLP", "DLP сейчас: наблюдение | включить блокировку"]]}, + ) + + def test_status_command_refreshes_menu_markup(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.BTN_STATUS = MODULE.TSJGuardianBot.BTN_STATUS + bot.allowed_chats = {42} + bot.state = DummyState() + bot._expire_pending_pfsense_change_if_needed = lambda: None + bot._expire_pending_openvpn_config_if_needed = lambda: None + bot._expire_pending_proxmox_selection_if_needed = lambda: None + bot._expire_pending_proxmox_restore_if_needed = lambda: None + bot._log = lambda level, msg: None + bot._cmd_status = lambda: "ok" + sent = {} + bot._send_text = lambda chat_id, text, reply_markup=None: sent.update( + {"chat_id": chat_id, "text": text, "reply_markup": reply_markup} + ) + bot._send_text_with_menu = lambda chat_id, text: sent.update( + {"chat_id": chat_id, "text": text, "reply_markup": bot._menu_markup()} + ) + bot._menu_markup = lambda: {"keyboard": [["Статус", "DLP сейчас: наблюдение | включить блокировку"]]} + + update = {"message": {"chat": {"id": 42}, "text": "Статус"}} + MODULE.TSJGuardianBot._process_message(bot, update) + + self.assertEqual(sent["text"], "ok") + self.assertEqual( + sent["reply_markup"], + {"keyboard": [["Статус", "DLP сейчас: наблюдение | включить блокировку"]]}, + ) + + +class DlpPolicyToggleTests(unittest.TestCase): + def test_aw_dlp_mode_from_policy_detects_monitor_and_enforce(self): + monitor_policy = { + "endpoint": { + "clipboard": [{"id": "c1", "enabled": True, "action": "alert"}], + "usb": [{"id": "u1", "enabled": True, "action": "log"}], + } + } + enforce_policy = { + "endpoint": { + "clipboard": [{"id": "c1", "enabled": True, "action": "block"}], + "usb": [{"id": "u1", "enabled": True, "action": "block"}], + } + } + + self.assertEqual(MODULE.TSJGuardianBot._aw_dlp_mode_from_policy(monitor_policy), "monitor") + self.assertEqual(MODULE.TSJGuardianBot._aw_dlp_mode_from_policy(enforce_policy), "enforce") + + def test_aw_dlp_policy_for_mode_promotes_only_toggleable_channels(self): + policy = { + "rules": [{"id": "web1", "enabled": True, "action": "alert"}], + "endpoint": { + "clipboard": [{"id": "c1", "enabled": True, "action": "alert"}], + "usb": [{"id": "u1", "enabled": True, "action": "log"}], + "print": [{"id": "p1", "enabled": True, "action": "alert"}], + "email": [{"id": "e1", "enabled": True, "action": "alert"}], + }, + } + + updated, changed_count, changed_rules = MODULE.TSJGuardianBot._aw_dlp_policy_for_mode(policy, "enforce") + + self.assertEqual(updated["rules"][0]["action"], "alert") + self.assertEqual(updated["endpoint"]["clipboard"][0]["action"], "block") + self.assertEqual(updated["endpoint"]["usb"][0]["action"], "block") + self.assertEqual(updated["endpoint"]["print"][0]["action"], "block") + self.assertEqual(updated["endpoint"]["email"][0]["action"], "block") + self.assertEqual(changed_count, 4) + self.assertTrue(any("endpoint.clipboard:c1 alert->block" == item for item in changed_rules)) + + def test_aw_dlp_policy_for_mode_demotes_block_to_alert(self): + policy = { + "endpoint": { + "clipboard": [{"id": "c1", "enabled": True, "action": "block"}], + "usb": [{"id": "u1", "enabled": True, "action": "block"}], + } + } + + updated, changed_count, _ = MODULE.TSJGuardianBot._aw_dlp_policy_for_mode(policy, "monitor") + + self.assertEqual(updated["endpoint"]["clipboard"][0]["action"], "alert") + self.assertEqual(updated["endpoint"]["usb"][0]["action"], "alert") + self.assertEqual(changed_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/proxmox/tsj_guardian_bot.py b/proxmox/tsj_guardian_bot.py index 58ba82c..58c6942 100644 --- a/proxmox/tsj_guardian_bot.py +++ b/proxmox/tsj_guardian_bot.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +import base64 +import csv import hashlib import json import os @@ -6,6 +8,7 @@ import re import secrets import shlex import ssl +import socket import subprocess import sys import tempfile @@ -220,6 +223,11 @@ class TelegramAPI: raise RuntimeError(f"Telegram API error on {method}: {parsed}") return parsed + def _requests_proxies(self) -> Optional[Dict[str, str]]: + if not self.proxy_url: + return None + return {"http": self.proxy_url, "https": self.proxy_url} + def get_updates(self, offset: int, timeout: int = 15) -> List[Dict]: r = self._call("getUpdates", {"offset": offset, "timeout": timeout}) return r.get("result", []) @@ -231,43 +239,42 @@ class TelegramAPI: self._call("sendMessage", payload) def send_document(self, chat_id: int, filename: str, content: bytes, caption: str = "") -> None: - boundary = f"----codex{secrets.token_hex(12)}" - parts: List[bytes] = [] + if not filename.strip(): + raise RuntimeError("sendDocument requires a non-empty filename") + if not content: + raise RuntimeError(f"Refusing to send empty document: {filename}") - def add_field(name: str, value: str) -> None: - parts.append( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="{name}"\r\n\r\n' - f"{value}\r\n" - ).encode("utf-8") - ) - - add_field("chat_id", str(chat_id)) + data = {"chat_id": str(chat_id)} if caption: - add_field("caption", caption) + data["caption"] = caption + files = {"document": (filename, content, "application/octet-stream")} - parts.append( - ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="document"; filename="{filename}"\r\n' - "Content-Type: application/octet-stream\r\n\r\n" - ).encode("utf-8") - ) - parts.append(content) - parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8")) - - req = Request( - f"{self.base}/sendDocument", - data=b"".join(parts), - method="POST", - headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, - ) - with self.opener.open(req, timeout=self.timeout_sec + 10) as resp: - body = resp.read().decode("utf-8") - parsed = json.loads(body) - if not parsed.get("ok"): - raise RuntimeError(f"Telegram API error on sendDocument: {parsed}") + last_exc: Optional[Exception] = None + attempts = 6 if self.proxy_url else 3 + for attempt in range(1, attempts + 1): + try: + resp = requests.post( + f"{self.base}/sendDocument", + data=data, + files=files, + proxies=self._requests_proxies(), + timeout=self.timeout_sec + 10, + ) + resp.raise_for_status() + parsed = resp.json() + if not parsed.get("ok"): + raise RuntimeError(f"Telegram API error on sendDocument: {parsed}") + return + except Exception as exc: + last_exc = exc + if attempt >= attempts or not self._should_retry(exc): + raise + print( + f"{time.strftime('%Y-%m-%d %H:%M:%S')} [WARN] Telegram API sendDocument attempt {attempt}/{attempts} failed: {exc}", + flush=True, + ) + time.sleep(min(attempt * 2, 10)) + raise last_exc if last_exc else RuntimeError("Telegram API sendDocument failed") def send_long_message( self, @@ -319,10 +326,13 @@ class TSJGuardianBot: BTN_PFSENSE_CONFIRM = "Подтвердить pfSense: шаг 1" BTN_PFSENSE_CANCEL = "Отменить pfSense изменение" BTN_AW_DLP_CHECK = "Проверка AW-Rus + DLP" - BTN_AW_DFIR = "Hayabusa DFIR" + BTN_DLP_MODE_TOGGLE = "DLP режим: переключить" + BTN_AW_DFIR = "Форензика Windows логов" + BTN_AW_DFIR_LEGACY = "Hayabusa DFIR" BTN_AI_CHAT_ALIASES = ("AI чат", "Чат с поддержкой", "Техподдержка", "Тех поддержка") BTN_OVPN_CERTS_ALIASES = ("OpenVPN certs", "OpenVPN cert", "OpenVPN серты", "OpenVPN сертификат") - PFSENSE_ENV_PATH = "/home/codex/infra-admin/vendor/pfsense-mcp-server/.env.readonly" + PFSENSE_ENV_PATH = "/home/igor/.config/tsj-bot/pfsense.env.readonly" + PFSENSE_INVENTORY_PATH = "/home/igor/.config/tsj-bot/inventory.md" HUMAN_OPERATOR_STYLE = ( "Ты отвечаешь оператору как реальный инженер техподдержки проекта DetMir. " @@ -353,10 +363,12 @@ class TSJGuardianBot: ) self.aw_rus_api_base = os.getenv("AW_RUS_API_BASE", "http://10.10.10.13:5600/api/0").strip() self.aw_rus_worktime_base = os.getenv("AW_RUS_WORKTIME_BASE", "http://10.10.10.13:5610").strip() + self.aw_dlp_policy_api_base = os.getenv("AW_DLP_POLICY_API_BASE", "http://10.10.10.13:5601/api/0").strip() + self.aw_dlp_policy_actor = os.getenv("AW_DLP_POLICY_ACTOR", "tsj-guardian-bot").strip() or "tsj-guardian-bot" self.aw_rus_worktime_heal_cmd = os.getenv( "AW_RUS_WORKTIME_HEAL_CMD", "sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 " - "'sudo -S systemctl restart aw-worktime-api.service'", + "'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'", ).strip() self.aw_rus_dlp_heal_cmd = os.getenv( "AW_RUS_DLP_HEAL_CMD", @@ -371,6 +383,33 @@ class TSJGuardianBot: self.aw_rus_host = os.getenv("AW_RUS_HOST", "SHARKON2025").strip() self.aw_rus_primary_user = os.getenv("AW_RUS_PRIMARY_USER", "USER1").strip() self.aw_rus_stale_sec = max(60, env_int("AW_RUS_STALE_SEC", 900)) + self.aw_rus_windows_host = os.getenv("AW_RUS_WINDOWS_HOST", "192.168.100.18").strip() or "192.168.100.18" + self.aw_rus_windows_ssh_user = os.getenv("AW_RUS_WINDOWS_SSH_USER", "Администратор").strip() or "Администратор" + self.aw_rus_windows_ssh_password = os.getenv("AW_RUS_WINDOWS_SSH_PASSWORD", "").strip() + self.aw_rus_windows_config_path = os.getenv( + "AW_RUS_WINDOWS_CONFIG_PATH", + r"C:\ProgramData\AWatch-rus\deployment-config.json", + ).strip() or r"C:\ProgramData\AWatch-rus\deployment-config.json" + self.aw_rus_windows_hardening_recovery_path = os.getenv( + "AW_RUS_WINDOWS_HARDENING_RECOVERY_PATH", + r"C:\Program Files\AWatch-rus\windows\hardening-recovery.ps1", + ).strip() or r"C:\Program Files\AWatch-rus\windows\hardening-recovery.ps1" + self.aw_rus_windows_session_collector_path = os.getenv( + "AW_RUS_WINDOWS_SESSION_COLLECTOR_PATH", + r"C:\ProgramData\AWatch-rus\worktime-session-collector.ps1", + ).strip() or r"C:\ProgramData\AWatch-rus\worktime-session-collector.ps1" + self.aw_rus_windows_policy_path = os.getenv( + "AW_RUS_WINDOWS_POLICY_PATH", + r"C:\ProgramData\AWatch-rus\dlp-policy.json", + ).strip() or r"C:\ProgramData\AWatch-rus\dlp-policy.json" + self.aw_rus_windows_browser_collector_path = os.getenv( + "AW_RUS_WINDOWS_BROWSER_COLLECTOR_PATH", + r"C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1", + ).strip() or r"C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1" + self.aw_rus_windows_email_collector_path = os.getenv( + "AW_RUS_WINDOWS_EMAIL_COLLECTOR_PATH", + r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1", + ).strip() or r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1" self.heal_script = os.getenv( "HEAL_SCRIPT", "/home/codex/infra-admin/scripts/system_self_support.sh --heal" ) @@ -389,7 +428,7 @@ class TSJGuardianBot: self.exit_on_autoheal_success = env_bool("EXIT_ON_AUTORECOVERY_SUCCESS", True) self.ai_escalation_mode = os.getenv("AI_ESCALATION_MODE", "codex_exec").strip().lower() or "codex_exec" - self.ai_exec_user = os.getenv("AI_EXEC_USER", os.getenv("TMUX_USER", "codex")).strip() or "codex" + self.ai_exec_user = os.getenv("AI_EXEC_USER", os.getenv("TMUX_USER", "igor")).strip() or "igor" self.codex_model = os.getenv("CODEX_MODEL", "gpt-5.3-codex").strip() or "gpt-5.3-codex" fallback_models_raw = os.getenv("CODEX_FALLBACK_MODELS", "gpt-5.4-mini").strip() self.codex_fallback_models = [ @@ -405,7 +444,7 @@ class TSJGuardianBot: self.fs_immediate_ai_on_critical = env_bool("FS_IMMEDIATE_AI_ON_CRITICAL", True) self.ai_chat_enabled = env_bool("AI_CHAT_ENABLED", True) self.ai_chat_timeout_sec = env_int("AI_CHAT_TIMEOUT_SEC", 1800) - self.ai_chat_workdir = os.getenv("AI_CHAT_WORKDIR", "/home/codex/infra-admin").strip() + self.ai_chat_workdir = os.getenv("AI_CHAT_WORKDIR", "/home/igor").strip() self.ai_chat_sandbox = os.getenv("AI_CHAT_SANDBOX", "workspace-write").strip() or "workspace-write" self.openvpn_cert_check_timeout_sec = max(30, env_int("OPENVPN_CERT_CHECK_TIMEOUT_SEC", 240)) self.openvpn_expiry_warn_timeout_sec = max(30, env_int("OPENVPN_EXPIRY_WARN_TIMEOUT_SEC", 120)) @@ -416,6 +455,15 @@ class TSJGuardianBot: self.openvpn_expiry_warn_enabled = env_bool("OPENVPN_EXPIRY_WARN_ENABLED", True) self.openvpn_expiry_warn_days = env_int("OPENVPN_EXPIRY_WARN_DAYS", 30) self.openvpn_expiry_warn_interval_sec = max(300, env_int("OPENVPN_EXPIRY_WARN_INTERVAL_SEC", 21600)) + self.openvpn_config_server_id = max(1, env_int("OPENVPN_CONFIG_SERVER_ID", 1)) + self.openvpn_first_host_offset = max(1, env_int("OPENVPN_CONFIG_FIRST_HOST_OFFSET", 13)) + self.openvpn_helper_path = ( + os.getenv("OPENVPN_CONFIG_HELPER_PATH", f"{self.ai_chat_workdir.rstrip('/')}/tsj-bot/pfsense_openvpn_client_export.php").strip() + ) + self.pfsense_env_path = os.getenv("PFSENSE_ENV_PATH", self.PFSENSE_ENV_PATH).strip() + self.pfsense_inventory_path = os.getenv("PFSENSE_INVENTORY_PATH", self.PFSENSE_INVENTORY_PATH).strip() + self.pfsense_ssh_bin = os.getenv("PFSENSE_SSH_BIN", "/usr/bin/ssh").strip() or "/usr/bin/ssh" + self.sshpass_bin = os.getenv("SSHPASS_BIN", "/usr/bin/sshpass").strip() or "/usr/bin/sshpass" self.telegram_proxy_url = ( os.getenv("TELEGRAM_PROXY_URL", "").strip() or os.getenv("HTTPS_PROXY", "").strip() @@ -493,11 +541,38 @@ class TSJGuardianBot: except Exception as exc: self._log("ERROR", f"Failed to send Telegram message: {exc}") + def _aw_dlp_toggle_button_text(self, current_mode: Optional[str] = None) -> str: + mode = (current_mode or "").strip().lower() + if mode not in {"monitor", "enforce", "mixed"}: + try: + active = self._aw_dlp_policy_request("GET", "/dlp/policies/active", timeout_sec=5) + policy = active.get("policy") or {} + if isinstance(policy, dict): + mode = self._aw_dlp_mode_from_policy(policy) + except Exception: + mode = "" + + if mode == "monitor": + return "DLP сейчас: наблюдение | включить блокировку" + if mode == "enforce": + return "DLP сейчас: блокировка | включить наблюдение" + if mode == "mixed": + return "DLP сейчас: смешанный | выровнять в блокировку" + return self.BTN_DLP_MODE_TOGGLE + + def _is_dlp_toggle_button(self, text: str) -> bool: + normalized = (text or "").strip() + return normalized == self.BTN_DLP_MODE_TOGGLE or normalized.startswith("DLP") + + def _is_dfir_button(self, text: str) -> bool: + normalized = (text or "").strip() + return normalized in {self.BTN_AW_DFIR, self.BTN_AW_DFIR_LEGACY} + def _menu_markup(self) -> Dict: return { "keyboard": [ [self.BTN_STATUS, self.BTN_CHECK, self.BTN_HEAL], - [self.BTN_AW_DLP_CHECK], + [self.BTN_AW_DLP_CHECK, self._aw_dlp_toggle_button_text()], [self.BTN_AW_DFIR], [self.BTN_ACK, self.BTN_RESOLVE], [self.BTN_AI, self.BTN_FALLBACK], @@ -518,8 +593,11 @@ class TSJGuardianBot: def _send_menu(self, chat_id: int, text: str) -> None: self.api.send_long_message(chat_id, text, reply_markup=self._menu_markup()) - def _send_text(self, chat_id: int, text: str) -> None: - self.api.send_long_message(chat_id, text) + def _send_text(self, chat_id: int, text: str, reply_markup: Optional[Dict] = None) -> None: + self.api.send_long_message(chat_id, text, reply_markup=reply_markup) + + def _send_text_with_menu(self, chat_id: int, text: str) -> None: + self._send_text(chat_id, text, reply_markup=self._menu_markup()) def _local_time_text(self) -> str: return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") @@ -717,6 +795,8 @@ class TSJGuardianBot: p = subprocess.run( ["bash", "-lc", cmd], text=True, + encoding="utf-8", + errors="replace", capture_output=True, timeout=timeout_sec, check=False, @@ -724,6 +804,103 @@ class TSJGuardianBot: out = (p.stdout or "") + (("\n" + p.stderr) if p.stderr else "") return p.returncode, out.strip() + @staticmethod + def _decode_process_output(data: Optional[bytes]) -> str: + if not data: + return "" + try: + return data.decode("utf-8") + except UnicodeDecodeError: + pass + + def score_text(text: str) -> int: + score = 0 + for ch in text: + code = ord(ch) + if ch in "\r\n\t": + score += 1 + elif 32 <= code <= 126: + score += 1 + elif 0x0400 <= code <= 0x04FF or code == 0x2116: + score += 3 + else: + score -= 2 + return score + + candidates: List[Tuple[int, str]] = [] + for encoding in ("cp866", "cp1251"): + try: + decoded = data.decode(encoding) + except UnicodeDecodeError: + continue + candidates.append((score_text(decoded), decoded)) + if candidates: + candidates.sort(key=lambda item: item[0], reverse=True) + return candidates[0][1] + return data.decode("utf-8", errors="replace") + + @staticmethod + def _parse_worktime_today_csv(csv_text: str) -> List[Dict[str, Optional[int]]]: + rows: List[Dict[str, Optional[int]]] = [] + for raw_row in csv.DictReader(csv_text.splitlines()): + user = (raw_row.get("user") or raw_row.get("username") or "").strip() + active_raw = raw_row.get("active_seconds") + if active_raw is None: + values = list(raw_row.values()) + active_raw = values[1] if len(values) > 1 else None + try: + active_seconds = int(float((active_raw or "").strip())) + except Exception: + active_seconds = None + rows.append( + { + "user": user, + "active_seconds": active_seconds, + } + ) + return rows + + def _fetch_worktime_today_csv(self, timeout_sec: int = 20, attempts: int = 2) -> str: + probe_url = f"{self.aw_rus_worktime_base.rstrip('/')}/reports/worktime/today?format=csv" + last_exc: Optional[Exception] = None + for attempt in range(max(1, attempts)): + try: + r = requests.get(probe_url, timeout=timeout_sec) + r.raise_for_status() + return r.text + except Exception as exc: + last_exc = exc + if attempt + 1 >= max(1, attempts): + raise + time.sleep(1) + raise last_exc or RuntimeError("worktime report fetch failed") + + def _run_argv( + self, + argv: List[str], + timeout_sec: int = 180, + input_text: str = "", + env_extra: Optional[Dict[str, str]] = None, + ) -> subprocess.CompletedProcess: + env = os.environ.copy() + if env_extra: + env.update(env_extra) + proc = subprocess.run( + argv, + text=False, + input=input_text.encode("utf-8"), + capture_output=True, + timeout=timeout_sec, + check=False, + env=env, + ) + return subprocess.CompletedProcess( + proc.args, + proc.returncode, + self._decode_process_output(proc.stdout), + self._decode_process_output(proc.stderr), + ) + def _get_proxmox_guest_lock(self, kind: str, guest_id: str) -> str: if kind == "qemu": cmd = f"{shlex.quote(self.qm_bin)} config {shlex.quote(guest_id)}" @@ -1027,7 +1204,7 @@ class TSJGuardianBot: return header + "\n" + "\n".join(lines[:80]) def _load_pfsense_readonly_env(self) -> Tuple[str, str, bool]: - env_raw = Path(self.PFSENSE_ENV_PATH).read_text(encoding="utf-8") + env_raw = Path(self.pfsense_env_path).read_text(encoding="utf-8") env_map: Dict[str, str] = {} for line in env_raw.splitlines(): line = line.strip() @@ -1549,36 +1726,220 @@ class TSJGuardianBot: line = text.split(marker, 1)[1].splitlines()[0].strip() return self._sanitize_filename(line or "openvpn-client", ".ovpn") - def _run_openvpn_config_codex_exec(self, povpn: PendingOpenVpnConfig) -> Tuple[str, str, str]: - prompt = ( - f"{self.HUMAN_OPERATOR_STYLE}" - "Этот запуск имеет отдельное двойное подтверждение оператора на выпуск нового OpenVPN client config для одного пользователя. " - "Разрешено выполнить только действия, необходимые для одного common name, и не более. " - "Нужно: проверить пользовательский OpenVPN certificate, при необходимости сгенерировать или обновить его, " - "затем экспортировать свежий OpenVPN client config для этого common name. " - "Используй локальный MCP `pfsense-enhanced`, а если он read-only, разрешено использовать прямой pfSense API/SSH доступ из локального окружения только для этой подтверждённой операции. " - "Верни ответ СТРОГО в формате без markdown:\n" - "OVPN_FILENAME: .ovpn\n" - "OVPN_SUMMARY_BEGIN\n" - "<короткий отчёт>\n" - "OVPN_SUMMARY_END\n" - "OVPN_CONFIG_BEGIN\n" - "\n" - "OVPN_CONFIG_END\n" - f"common_name: {povpn.common_name}" + def _load_pfsense_inventory_access(self) -> Tuple[str, int, str, str]: + text = Path(self.pfsense_inventory_path).read_text(encoding="utf-8") + section = re.search(r"### 2\. pfSense(.*?)(?:### 3\.|\Z)", text, re.S) + if not section: + raise RuntimeError("pfSense section not found in inventory") + section_text = section.group(1) + ssh_match = re.search(r"- SSH LAN: `([^`:]+)(?::(\d+))?`", section_text) + user_match = re.search(r"- Root login: `([^`]+)`", section_text) + pass_match = re.search(r"- Root password: `([^`]+)`", section_text) + if not ssh_match or not user_match or not pass_match: + raise RuntimeError("pfSense root SSH credentials not found in inventory") + host = ssh_match.group(1).strip() + port = int((ssh_match.group(2) or "22").strip()) + user = user_match.group(1).strip() + password = pass_match.group(1).strip() + if not host or not user or not password: + raise RuntimeError("pfSense SSH inventory values are empty") + return host, port, user, password + + def _load_pfsense_web_access(self) -> Tuple[str, str, str]: + text = Path(self.pfsense_inventory_path).read_text(encoding="utf-8") + section = re.search(r"### 2\. pfSense(.*?)(?:### 3\.|\Z)", text, re.S) + if not section: + raise RuntimeError("pfSense section not found in inventory") + section_text = section.group(1) + web_match = re.search(r"- Web UI: `([^`]+)`", section_text) + user_match = re.search(r"- Admin login: `([^`]+)`", section_text) + pass_match = re.search(r"- Admin password: `([^`]+)`", section_text) + if not web_match or not user_match or not pass_match: + raise RuntimeError("pfSense web credentials not found in inventory") + host_port = web_match.group(1).strip() + user = user_match.group(1).strip() + password = pass_match.group(1).strip() + if not host_port or not user or not password: + raise RuntimeError("pfSense web inventory values are empty") + return f"https://{host_port}", user, password + + @staticmethod + def _extract_pfsense_csrf_token(page: str) -> str: + match = re.search(r'var csrfMagicToken = "([^"]+)"', page or "") + if not match: + raise RuntimeError("pfSense csrf token not found") + return match.group(1) + + def _pfsense_ssh_reachable(self, host: str, port: int, timeout_sec: float = 3.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout_sec): + return True + except OSError: + return False + + def _parse_openvpn_helper_result(self, payload: str, common_name: str) -> Tuple[str, str, bytes]: + data = json.loads(payload) + filename = self._sanitize_filename(str(data.get("filename") or common_name), ".ovpn") + config_b64 = str(data.get("config_b64") or "").strip() + if not config_b64: + raise RuntimeError("OpenVPN export returned empty config payload") + try: + config_bytes = base64.b64decode(config_b64, validate=True) + except Exception as exc: + raise RuntimeError(f"Invalid base64 in OpenVPN export payload: {exc}") from exc + if not config_bytes.strip(): + raise RuntimeError("OpenVPN export returned empty decoded config") + + config_text = config_bytes.decode("utf-8", errors="replace") + required_markers = ("client", "remote ", "") + missing = [marker for marker in required_markers if marker not in config_text] + if missing: + raise RuntimeError(f"OpenVPN export does not look valid; missing markers: {', '.join(missing)}") + + summary_lines = [f"Новый OpenVPN конфиг подготовлен для {data.get('common_name') or common_name}."] + tunnel_network = str(data.get("tunnel_network") or "").strip() + if tunnel_network: + summary_lines.append(f"- VPN IP: {tunnel_network}") + summary_lines.append(f"- сертификат создан: {'да' if data.get('cert_created') else 'нет'}") + summary_lines.append(f"- client-specific config создан: {'да' if data.get('csc_created') else 'нет'}") + summary = "\n".join(summary_lines) + return filename, summary, config_bytes + + def _run_openvpn_config_helper(self, povpn: PendingOpenVpnConfig) -> Tuple[str, str, bytes]: + helper_script = Path(self.openvpn_helper_path).read_text(encoding="utf-8") + if not helper_script.strip(): + raise RuntimeError(f"OpenVPN helper is empty: {self.openvpn_helper_path}") + + host, port, user, password = self._load_pfsense_inventory_access() + remote_cmd = ( + "tmp=$(mktemp /tmp/tsj-ovpn-helper.XXXXXX.php) || exit 1; " + "cat > \"$tmp\" || { rc=$?; rm -f \"$tmp\"; exit $rc; }; " + f"php \"$tmp\" -- --common-name {shlex.quote(povpn.common_name)} " + f"--server-id {shlex.quote(str(self.openvpn_config_server_id))} " + f"--first-host-offset {shlex.quote(str(self.openvpn_first_host_offset))}; " + "rc=$?; rm -f \"$tmp\"; exit $rc" ) - rc, out, reply = self._run_codex_exec_prompt(prompt, timeout_sec=self.ai_chat_timeout_sec) - payload = reply or out - summary = self._extract_tag_block(payload, "OVPN_SUMMARY_BEGIN", "OVPN_SUMMARY_END") - config = self._extract_tag_block(payload, "OVPN_CONFIG_BEGIN", "OVPN_CONFIG_END") - filename = self._extract_openvpn_filename(payload) or self._sanitize_filename(povpn.common_name, ".ovpn") - if rc != 0 and not config: - self._log("ERROR", f"OpenVPN config generation failed rc={rc}: {out[-2000:]}") - raise RuntimeError(f"OpenVPN config generation failed rc={rc}") - if not config: - self._log("ERROR", f"OpenVPN config generation produced no config payload: {payload[-2000:]}") - raise RuntimeError("OpenVPN config payload not found in response") - return filename, self._sanitize_operator_reply(summary or f"Новый OpenVPN конфиг подготовлен для {povpn.common_name}."), config + argv = [ + self.sshpass_bin, + "-e", + self.pfsense_ssh_bin, + "-o", + "PubkeyAuthentication=no", + "-o", + "StrictHostKeyChecking=no", + "-p", + str(port), + f"{user}@{host}", + remote_cmd, + ] + proc = self._run_argv( + argv, + timeout_sec=self.ai_chat_timeout_sec, + input_text=helper_script, + env_extra={"SSHPASS": password}, + ) + stdout = (proc.stdout or "").strip() + stderr = (proc.stderr or "").strip() + if proc.returncode != 0: + details = stderr or stdout or f"rc={proc.returncode}" + self._log("ERROR", f"OpenVPN helper failed rc={proc.returncode}: {details[-2000:]}") + raise RuntimeError(f"OpenVPN helper failed: {details}") + if not stdout: + raise RuntimeError("OpenVPN helper returned no stdout payload") + return self._parse_openvpn_helper_result(stdout, povpn.common_name) + + def _run_openvpn_config_web_export(self, common_name: str) -> Tuple[str, str, bytes]: + base_url, user, password = self._load_pfsense_web_access() + requests.packages.urllib3.disable_warnings() + web = requests.Session() + web.verify = False + web.trust_env = False + + login_page = web.get(f"{base_url}/", timeout=20).text + csrf_token = self._extract_pfsense_csrf_token(login_page) + login_resp = web.post( + f"{base_url}/", + data={ + "__csrf_magic": csrf_token, + "usernamefld": user, + "passwordfld": password, + "login": "Sign In", + }, + timeout=20, + ) + if "usernamefld" in login_resp.text: + raise RuntimeError("pfSense web login failed") + + export_page = web.get(f"{base_url}/vpn_openvpn_export.php", timeout=20).text + export_csrf = self._extract_pfsense_csrf_token(export_page) + cert_pairs = re.findall( + r"servers\[(\d+)\]\[3\]\[(\d+)\]\[0\] = '([^']*)';\s*" + r"servers\[\1\]\[3\]\[\2\]\[1\] = '([^']*)';", + export_page, + ) + cert_id = "" + for server_id, _, crtid, name in cert_pairs: + if int(server_id) == self.openvpn_config_server_id and name == common_name: + cert_id = crtid + break + if not cert_id: + raise RuntimeError(f"OpenVPN certificate `{common_name}` is not present on pfSense export page") + + payload = { + "__csrf_magic": export_csrf, + "act": "confinline", + "srvid": str(self.openvpn_config_server_id), + "usrid": "", + "crtid": cert_id, + "useaddr": "serveraddr", + "verifyservercn": "auto", + "blockoutsidedns": "0", + "legacy": "1", + "silent": "0", + "bindmode": "nobind", + "usetoken": "0", + "usepkcs11": "0", + "pkcs11providers": "", + "pkcs11id": "", + "p12encryption": "high", + "advancedoptions": "", + } + resp = web.post(f"{base_url}/vpn_openvpn_export.php", data=payload, timeout=30) + if resp.status_code != 200: + raise RuntimeError(f"pfSense export returned HTTP {resp.status_code}") + config_bytes = resp.content or b"" + if not config_bytes.strip(): + raise RuntimeError("pfSense web export returned empty payload") + config_text = config_bytes.decode("utf-8", errors="replace") + required_markers = ("client", "remote ", "") + missing = [marker for marker in required_markers if marker not in config_text] + if missing: + raise RuntimeError(f"pfSense web export does not look valid; missing markers: {', '.join(missing)}") + + disposition = resp.headers.get("content-disposition", "") + filename_match = re.search(r'filename="?([^";]+)"?', disposition) + filename = self._sanitize_filename( + filename_match.group(1) if filename_match else common_name, + ".ovpn", + ) + summary = ( + f"OpenVPN конфиг экспортирован для {common_name}.\n" + "- режим: pfSense web export\n" + "- сертификат создан: нет\n" + "- client-specific config создан: нет" + ) + return filename, summary, config_bytes + + def _prepare_openvpn_config(self, povpn: PendingOpenVpnConfig) -> Tuple[str, str, bytes]: + host, port, _, _ = self._load_pfsense_inventory_access() + if self._pfsense_ssh_reachable(host, port, timeout_sec=3.0): + try: + return self._run_openvpn_config_helper(povpn) + except Exception as exc: + self._log("WARN", f"OpenVPN SSH helper failed, falling back to web export: {exc}") + else: + self._log("WARN", f"pfSense SSH is not reachable from bot host, using web export for {povpn.common_name}") + return self._run_openvpn_config_web_export(povpn.common_name) def _apply_openvpn_config(self, chat_id: int, code: str) -> None: self._expire_pending_openvpn_config_if_needed() @@ -1594,11 +1955,12 @@ class TSJGuardianBot: return try: - filename, summary, config = self._run_openvpn_config_codex_exec(povpn) + filename, summary, config_bytes = self._prepare_openvpn_config(povpn) + self._log("INFO", f"Prepared OpenVPN config {filename} ({len(config_bytes)} bytes) for {povpn.common_name}") self.api.send_document( chat_id, filename, - config.encode("utf-8"), + config_bytes, caption=summary[:900], ) self._send_text(chat_id, f"OpenVPN конфиг отправлен как файл `{filename}`.") @@ -1697,6 +2059,45 @@ class TSJGuardianBot: def _has_filesystem_critical(self, failures: List[str]) -> bool: return bool(self._filesystem_failures(failures)) + def _is_aw_rus_failure_line(self, line: str) -> bool: + return line.strip().lower().startswith("[fail] aw-rus:") + + def _extract_aw_rus_failure_keys(self, failures: List[str]) -> List[str]: + keys: List[str] = [] + for line in failures: + match = re.match(r"\[FAIL\]\s+aw-rus:([a-z0-9\-]+):", line.strip(), re.IGNORECASE) + if match: + key = match.group(1).lower() + if key not in keys: + keys.append(key) + return keys + + def _aw_rus_probe_should_run(self, failures: List[str]) -> bool: + lowered = "\n".join(failures).lower() + return "node_13" not in lowered + + def _aw_rus_failure_line_matches(self, line: str, key: str) -> bool: + text = line.strip().lower() + if not text.startswith("- "): + return False + if key == "worktime": + return text.startswith("- worktime(") or text.startswith("- worktime:") + return text.startswith(f"- {key}:") + + def _aw_rus_failure_log_lines(self, lines: List[str], failures: List[str]) -> List[str]: + rendered: List[str] = [] + for key in failures: + matching_line = next((line for line in lines if self._aw_rus_failure_line_matches(line, key)), "") + summary = matching_line[2:].strip() if matching_line.startswith("- ") else key + rendered.append(f"[FAIL] aw-rus:{key}: {summary}") + return rendered + + def _background_aw_rus_failures(self, failures: List[str]) -> Tuple[List[str], List[str]]: + if not self._aw_rus_probe_should_run(failures): + return [], [] + lines, aw_failures = self._aw_rus_dlp_probe() + return self._aw_rus_failure_log_lines(lines, aw_failures), aw_failures + def _suggestions_from_failures(self, failures: List[str]) -> List[str]: suggestions = [] text = "\n".join(failures).lower() @@ -1712,6 +2113,11 @@ class TSJGuardianBot: suggestions.append("Проверить grafana-server и NO_PROXY для 10.10.10.0/24.") if "loki" in text or "alloy" in text: suggestions.append("Проверить LXC логов и restart сервисов loki/alloy.") + if "aw-rus:watcher-" in text or "aw-rus:worktime:" in text: + suggestions.append("Проверить Windows collector recovery: worktime-session-collector, ActivityWatch Recovery и Launch tasks на 192.168.100.18.") + suggestions.append("После Windows recovery проверить server-side aw-worktime-autoheal/ui-bridge для пересборки afk/window bucket'ов.") + if "aw-rus:dlp-" in text: + suggestions.append("Проверить DLP endpoint/fileops collectors и server-side DLP transport на 10.10.10.13.") if "filesystem_usage" in text: suggestions.append("Проверить самые большие каталоги: du -x /var /srv /home, журналы в /var/log и apt cache.") suggestions.append("Проверить давление по снапшотам/хранилищу Proxmox и решить: очистка, ротация или расширение диска.") @@ -1785,8 +2191,10 @@ class TSJGuardianBot: if not started: self._log("WARN", "Skipping check cycle because previous check is still running") return - failures = self._parse_failures(out) + main_failures = self._parse_failures(out) warnings = self._parse_warnings(out) + aw_failure_lines, _ = self._background_aw_rus_failures(main_failures) + failures = main_failures + aw_failure_lines if rc == 0 and not failures: if self.state.pending_incident: self._notify("Инцидент закрыт: система снова в норме.") @@ -1796,7 +2204,7 @@ class TSJGuardianBot: self._log("INFO", "Check OK") return - if rc != 0 and not failures: + if rc != 0 and not main_failures and not aw_failure_lines: self._log( "WARN", "Check command returned non-zero without explicit [FAIL] markers; suppressing empty incident", @@ -1809,6 +2217,7 @@ class TSJGuardianBot: self._sync_warning_state([]) now = int(time.time()) + new_incident_created = False if not self.state.pending_incident: incident_id = time.strftime("%Y%m%d-%H%M%S") suggestions = self._suggestions_from_failures(failures) @@ -1823,7 +2232,7 @@ class TSJGuardianBot: escalated_to_ai=False, fallback_executed=False, ) - self._notify(self._incident_text(failures, suggestions)) + new_incident_created = True else: self.state.pending_incident.failures = failures or self.state.pending_incident.failures self.state.pending_incident.suggestions = self._suggestions_from_failures( @@ -1846,9 +2255,32 @@ class TSJGuardianBot: else: self._notify("Критическое заполнение ФС: немедленная эскалация не удалась.") - self._attempt_autoheal(force=False) + autoheal_ok = self._attempt_autoheal( + force=False, + notify_success=not new_incident_created, + notify_failure=not new_incident_created, + exit_on_success=not new_incident_created, + ) + if autoheal_ok: + if new_incident_created: + self._log("INFO", "Incident auto-resolved before operator notification") + return - def _attempt_autoheal(self, force: bool) -> bool: + if new_incident_created and self.state.pending_incident: + pi = self.state.pending_incident + self._notify(self._incident_text(pi.failures, pi.suggestions)) + self._notify( + f"Авто-лечение неуспешно (attempt={pi.autoheal_attempts}). " + f"Ожидаю реакцию оператора до {self.operator_timeout // 60} минут." + ) + + def _attempt_autoheal( + self, + force: bool, + notify_success: bool = True, + notify_failure: bool = True, + exit_on_success: bool = True, + ) -> bool: pi = self.state.pending_incident if not pi: return True @@ -1861,19 +2293,38 @@ class TSJGuardianBot: pi.autoheal_attempts += 1 self.state.save() - rc, out = self._run_shell(self.heal_script, timeout_sec=420) - failures = self._parse_failures(out) - if rc == 0 and not failures: + main_failures = [line for line in pi.failures if not self._is_aw_rus_failure_line(line)] + aw_failure_keys = self._extract_aw_rus_failure_keys(pi.failures) + + generic_ok = True + if main_failures: + rc, out = self._run_shell(self.heal_script, timeout_sec=420) + generic_ok = rc == 0 and not self._parse_failures(out) + + aw_ok = True + if aw_failure_keys: + aw_ok, aw_report, _, _ = self._perform_aw_rus_autoheal(aw_failure_keys) + for line in aw_report: + self._log("INFO" if "FAIL" not in line else "WARN", line) + + recheck_rc, recheck_out = self._run_shell(self.check_script, timeout_sec=240) + recheck_main_failures = self._parse_failures(recheck_out) + recheck_aw_lines, _ = self._background_aw_rus_failures(recheck_main_failures) + failures = recheck_main_failures + recheck_aw_lines + + if recheck_rc == 0 and not failures and generic_ok and aw_ok: msg = ( f"Авто-лечение успешно (attempt={pi.autoheal_attempts}). " "Инцидент закрыт." ) - self._notify(msg) + if notify_success: + self._notify(msg) self.state.pending_incident = None self.state.save() self._log("INFO", msg) - if self.exit_on_autoheal_success: - self._notify("По сценарию: успешное авто-лечение, процесс завершается.") + if self.exit_on_autoheal_success and exit_on_success: + if notify_success: + self._notify("По сценарию: успешное авто-лечение, процесс завершается.") raise SystemExit(0) return True @@ -1881,10 +2332,11 @@ class TSJGuardianBot: pi.failures = failures or pi.failures pi.suggestions = self._suggestions_from_failures(pi.failures) self.state.save() - self._notify( - f"Авто-лечение неуспешно (attempt={pi.autoheal_attempts}). " - f"Ожидаю реакцию оператора до {self.operator_timeout // 60} минут." - ) + if notify_failure: + self._notify( + f"Авто-лечение неуспешно (attempt={pi.autoheal_attempts}). " + f"Ожидаю реакцию оператора до {self.operator_timeout // 60} минут." + ) return False def _tmux_session_exists(self) -> bool: @@ -1981,6 +2433,7 @@ class TSJGuardianBot: self._notify("Автономный fallback-план сервера выполнен с ошибками, требуется оператор.") def _cmd_help(self) -> str: + dlp_toggle_label = self._aw_dlp_toggle_button_text() return ( "Помощь по кнопкам:\n" "\n" @@ -1996,8 +2449,13 @@ class TSJGuardianBot: "- Проверяет AW-Rus и DLP по свежести bucket-данных и сегодняшнему worktime.\n" "- Формирует операторский итог OK/DEGRADED прямо в чате.\n" "\n" + f"{dlp_toggle_label}\n" + "- Одной кнопкой переключает endpoint/email DLP между monitor и enforce.\n" + "- Меняет активную policy на сервере, синхронизирует `dlp-policy.json` на Windows и перезапускает DLP collectors.\n" + "- Web-правила остаются наблюдающими: этот toggle не превращает браузерный DLP в настоящий web-block.\n" + "\n" f"{self.BTN_AW_DFIR}\n" - "- Показывает, как запустить bounded DFIR-путь через Hayabusa.\n" + "- Показывает, как запустить ограниченный forensic-разбор журналов Windows через Hayabusa.\n" "- Рабочий формат: `/aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]`.\n" "- В кейс пишется только metadata/linkage, без raw Sigma output.\n" "\n" @@ -2021,7 +2479,7 @@ class TSJGuardianBot: "- Полезно, если авто-лечение не помогло.\n" "\n" f"{self.BTN_AI_CHAT}\n" - "- Любое свободное текстовое сообщение отправляется в тех.поддержку через `codex exec` от пользователя `codex`.\n" + f"- Любое свободное текстовое сообщение отправляется в тех.поддержку через `codex exec` от пользователя `{self.ai_exec_user}`.\n" "- Этот путь используется для нормального диалога с тех.поддержкой и не зависит от tmux.\n" "- Кнопка эскалации использует тот же прямой запуск `codex exec`.\n" "\n" @@ -2101,7 +2559,7 @@ class TSJGuardianBot: "1) Нажмите кнопку создания/восстановления или используйте `/proxmox_snapshot TARGET`.\n" "2) Для восстановления после выбора узла отправьте `/proxmox_restore_apply CODE`.\n" "\n" - "Резервные slash-команды: /status /check /aw_dlp_check /aw_dfir PACKAGE HOST [CASE_ID] [MODE] /heal /ack /resolve /run ... /openvpn_certs [filter] /openvpn_expiring /openvpn_config USER /openvpn_config_confirm /openvpn_config_cancel /openvpn_config_apply CODE /pfsense_confirm /pfsense_cancel /pfsense_apply CODE /proxmox_snapshot TARGET /proxmox_restore TARGET /proxmox_restore_apply CODE /proxmox_restore_cancel /proxmox_selection_cancel" + "Резервные slash-команды: /status /check /aw_dlp_check /dlp_mode /dlp_mode_toggle /aw_dfir PACKAGE HOST [CASE_ID] [MODE] /heal /ack /resolve /run ... /openvpn_certs [filter] /openvpn_expiring /openvpn_config USER /openvpn_config_confirm /openvpn_config_cancel /openvpn_config_apply CODE /pfsense_confirm /pfsense_cancel /pfsense_apply CODE /proxmox_snapshot TARGET /proxmox_restore TARGET /proxmox_restore_apply CODE /proxmox_restore_cancel /proxmox_selection_cancel" ) def _cmd_status(self) -> str: @@ -2267,27 +2725,40 @@ class TSJGuardianBot: return activity, None + lines = ["Проверка AW-Rus + DLP:"] + failures: List[str] = [] + worktime_activity, worktime_error = load_worktime_activity() + current_host_activity = worktime_activity.get(host) if worktime_activity else None + interactive_required = bool(current_host_activity and current_host_activity.get("active")) + checks = [ (f"aw-watcher-window_{host}", "watcher-window"), (f"aw-watcher-afk_{host}", "watcher-afk"), (f"aw-dlp-endpoint-signals_{host}", "dlp-endpoint"), ] - - lines = ["Проверка AW-Rus + DLP:"] - failures: List[str] = [] + bucket_status: Dict[str, Dict[str, Optional[int] | str | bool]] = {} + endpoint_line_index: Optional[int] = None for bucket_id, label in checks: age, tail = bucket_age(bucket_id) + bucket_status[label] = {"age": age, "tail": tail, "fresh": bool(age is not None and age <= self.aw_rus_stale_sec)} + if label == "dlp-endpoint": + endpoint_line_index = len(lines) if age is None: - lines.append(f"- {label}: FAIL ({tail})") - failures.append(label) + if label.startswith("watcher-") and not interactive_required: + lines.append(f"- {label}: WARN ({tail}; host inactive)") + else: + lines.append(f"- {label}: FAIL ({tail})") + failures.append(label) continue if age > self.aw_rus_stale_sec: - lines.append(f"- {label}: STALE age={age}s end={tail}") - failures.append(label) + if label.startswith("watcher-") and not interactive_required: + lines.append(f"- {label}: WARN age={age}s end={tail} (host inactive)") + else: + lines.append(f"- {label}: STALE age={age}s end={tail}") + failures.append(label) else: lines.append(f"- {label}: OK age={age}s end={tail}") - worktime_activity, worktime_error = load_worktime_activity() fileops_checks = [ (f"aw-file-operations_{host}", "dlp-fileops-host", host), ("aw-file-operations_10.10.10.13", "dlp-fileops-server", "10.10.10.13"), @@ -2317,59 +2788,67 @@ class TSJGuardianBot: continue age, tail = bucket_age(bucket_id) + bucket_status[label] = {"age": age, "tail": tail, "fresh": bool(age is not None and age <= self.aw_rus_stale_sec)} if age is None: lines.append(f"- {label}: FAIL (active host bucket missing or unreadable: {tail})") failures.append(label) continue if age > self.aw_rus_stale_sec: + if label == "dlp-fileops-host" and bool((bucket_status.get("dlp-endpoint") or {}).get("fresh")): + lines.append(f"- {label}: OK event-driven age={age}s end={tail} (endpoint fresh)") + continue lines.append(f"- {label}: STALE age={age}s end={tail}") failures.append(label) else: lines.append(f"- {label}: OK age={age}s end={tail}") + endpoint_status = bucket_status.get("dlp-endpoint") or {} + if ( + endpoint_line_index is not None + and "dlp-endpoint" in failures + and bool((bucket_status.get("dlp-fileops-host") or {}).get("fresh")) + and endpoint_status.get("age") is not None + ): + endpoint_age = int(endpoint_status["age"]) + endpoint_tail = str(endpoint_status.get("tail") or "unknown") + failures = [item for item in failures if item != "dlp-endpoint"] + lines[endpoint_line_index] = ( + f"- dlp-endpoint: WARN age={endpoint_age}s end={endpoint_tail} " + "(file-operations fresh; endpoint collector degraded)" + ) + try: - r = requests.get(f"{worktime_base}/reports/worktime/today?format=csv", timeout=20) - r.raise_for_status() - csv_text = r.text + csv_text = self._fetch_worktime_today_csv(timeout_sec=20, attempts=2) target = self.aw_rus_primary_user.upper() active_sec = None - session_rows = [] - for raw in csv_text.splitlines()[1:]: - parts = [x.strip() for x in raw.split(",")] - if len(parts) < 2: - continue - try: - row_active_sec = int(float(parts[1])) - except Exception: - row_active_sec = None - session_rows.append( - { - "user": parts[0], - "active_seconds": row_active_sec, - } - ) - if parts[0].upper() == target: - active_sec = row_active_sec - break - if active_sec is None: - lines.append(f"- worktime({target}): FAIL (user row not found)") - failures.append("worktime") - elif active_sec <= 0: - non_machine_rows = [ - row for row in session_rows - if row.get("user") and not row["user"].endswith("$") - ] - any_positive = any( - (row.get("active_seconds") or 0) > 0 - for row in non_machine_rows - ) - if any_positive: - lines.append(f"- worktime({target}): STALE active_seconds=0") - failures.append("worktime") + session_rows = self._parse_worktime_today_csv(csv_text) + for row in session_rows: + user = (row.get("user") or "").upper() + if user == target: + active_sec = row.get("active_seconds") + + non_machine_rows = [ + row for row in session_rows + if row.get("user") and not row["user"].endswith("$") + ] + positive_rows = [ + row for row in non_machine_rows + if (row.get("active_seconds") or 0) > 0 + ] + if positive_rows: + if active_sec is not None and active_sec > 0: + lines.append(f"- worktime({target}): OK active_seconds={active_sec}") + elif active_sec is not None: + users = ", ".join(str(row["user"]) for row in positive_rows[:3]) + lines.append(f"- worktime({target}): OK target idle, other active users={users}") else: - lines.append(f"- worktime({target}): OK active_seconds=0 (no active sessions)") + users = ", ".join(str(row["user"]) for row in positive_rows[:3]) + lines.append(f"- worktime({target}): OK active users present={users}") + elif current_host_activity and current_host_activity.get("active"): + lines.append(f"- worktime({target}): STALE active sessions present but report has no positive rows") + failures.append("worktime") else: - lines.append(f"- worktime({target}): OK active_seconds={active_sec}") + lines.append(f"- worktime({target}): OK active_seconds=0 (no active sessions)") except Exception as exc: lines.append(f"- worktime: FAIL ({exc})") failures.append("worktime") @@ -2466,21 +2945,123 @@ class TSJGuardianBot: time.sleep(2) try: - probe_url = f"{self.aw_rus_worktime_base.rstrip('/')}/reports/worktime/today?format=csv" - r = requests.get(probe_url, timeout=8) - r.raise_for_status() + self._fetch_worktime_today_csv(timeout_sec=8, attempts=2) report.append("- worktime-heal: probe OK") return True, report except Exception as exc: report.append(f"- worktime-heal: probe FAIL ({exc})") return False, report + def _run_aw_rus_windows_powershell(self, script: str, timeout_sec: int = 180) -> Tuple[int, str]: + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + argv = [ + self.sshpass_bin, + "-e", + "ssh", + "-o", + "PubkeyAuthentication=no", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=12", + f"{self.aw_rus_windows_ssh_user}@{self.aw_rus_windows_host}", + f"powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand {encoded}", + ] + proc = self._run_argv( + argv, + timeout_sec=timeout_sec, + env_extra={"SSHPASS": self.aw_rus_windows_ssh_password}, + ) + out = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "") + return proc.returncode, out.strip() + + def _aw_rus_windows_collectors_heal(self, include_watchers: bool, include_worktime: bool) -> Tuple[bool, List[str]]: + report: List[str] = [] + if not (include_watchers or include_worktime): + report.append("- windows-heal: skipped (no watcher/worktime targets)") + return True, report + if not self.aw_rus_windows_ssh_password: + report.append("- windows-heal: FAIL (AW_RUS_WINDOWS_SSH_PASSWORD not configured)") + return False, report + + script = ( + "$ErrorActionPreference = 'Stop'\n" + "$ProgressPreference = 'SilentlyContinue'\n" + f"$sessionCollectorScript = '{self.aw_rus_windows_session_collector_path}'\n" + "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n" + " Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine -and $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) } |\n" + " ForEach-Object { Stop-Process -Id ([int]$_.ProcessId) -Force -ErrorAction SilentlyContinue }\n" + f"if (Test-Path '{self.aw_rus_windows_hardening_recovery_path}') {{ try {{ & '{self.aw_rus_windows_hardening_recovery_path}' -ConfigPath '{self.aw_rus_windows_config_path}' }} catch {{ Write-Output ('HARDENING_RECOVERY_WARN: ' + $_.Exception.Message) }} }}\n" + "Start-Sleep -Seconds 2\n" + "schtasks /Run /TN 'ActivityWatch Recovery' | Out-Null\n" + "Start-Sleep -Seconds 2\n" + "Get-ScheduledTask -ErrorAction SilentlyContinue |\n" + " Where-Object { $_.TaskName -like 'ActivityWatch Launch *' } |\n" + " ForEach-Object { try { Start-ScheduledTask -TaskName $_.TaskName -ErrorAction Stop } catch {} }\n" + "Start-Sleep -Seconds 8\n" + "Write-Output 'WATCHERS'\n" + "Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue |\n" + " Select-Object Name, Id, SessionId, StartTime |\n" + " Sort-Object SessionId, Name |\n" + " Format-Table -AutoSize\n" + "Write-Output 'WORKTIME'\n" + "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n" + " Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine -and $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) } |\n" + " Select-Object Name, ProcessId, SessionId, CommandLine |\n" + " Format-List\n" + ) + + try: + rc, out = self._run_aw_rus_windows_powershell(script, timeout_sec=180) + except Exception as exc: + report.append(f"- windows-heal: FAIL ({exc})") + return False, report + if rc != 0: + tail = (out or "").strip().splitlines()[-8:] + report.append(f"- windows-heal: FAIL ({'; '.join(tail) if tail else f'rc={rc}'})") + return False, report + report.append("- windows-heal: recovery command OK") + return True, report + + def _perform_aw_rus_autoheal(self, failures: List[str]) -> Tuple[bool, List[str], List[str], List[str]]: + watcher_failures = [item for item in failures if item.startswith("watcher-")] + dlp_failures = [item for item in failures if item.startswith("dlp-")] + worktime_failed = "worktime" in failures + report: List[str] = [] + heal_ok = True + + if watcher_failures or worktime_failed: + windows_ok, windows_lines = self._aw_rus_windows_collectors_heal( + include_watchers=bool(watcher_failures), + include_worktime=worktime_failed, + ) + heal_ok = heal_ok and windows_ok + report.append("- heal trigger: Windows session collectors degraded, starting remediation") + report.extend(windows_lines) + + if dlp_failures: + dlp_ok, dlp_lines = self._aw_rus_dlp_heal(dlp_failures) + heal_ok = heal_ok and dlp_ok + report.append("- heal trigger: DLP degraded, starting remediation") + report.extend(dlp_lines) + + if watcher_failures or worktime_failed: + wt_ok, wt_lines = self._aw_rus_worktime_heal() + heal_ok = heal_ok and wt_ok + report.append("- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views") + report.extend(wt_lines) + + time.sleep(5) + after_lines, after_failures = self._aw_rus_dlp_probe() + return heal_ok and not after_failures, report, after_lines, after_failures + def _aw_rus_dlp_check_and_heal_text(self) -> str: before_lines, failures = self._aw_rus_dlp_probe() + watcher_failures = [x for x in failures if x.startswith("watcher-")] dlp_failures = [x for x in failures if x.startswith("dlp-")] worktime_failed = "worktime" in failures - if not dlp_failures and not worktime_failed: + if not watcher_failures and not dlp_failures and not worktime_failed: verdict = "OK" if not failures else f"DEGRADED ({', '.join(failures)})" before_lines.append(f"Итог: {verdict}") return "\n".join(before_lines) @@ -2488,21 +3069,8 @@ class TSJGuardianBot: out = [] out.extend(before_lines) - heal_ok = True - if dlp_failures: - dlp_ok, dlp_lines = self._aw_rus_dlp_heal(dlp_failures) - heal_ok = heal_ok and dlp_ok - out.append("- heal trigger: DLP degraded, starting remediation") - out.extend(dlp_lines) - - if worktime_failed: - wt_ok, wt_lines = self._aw_rus_worktime_heal() - heal_ok = heal_ok and wt_ok - out.append("- heal trigger: worktime degraded, starting remediation") - out.extend(wt_lines) - - time.sleep(3) - after_lines, after_failures = self._aw_rus_dlp_probe() + heal_ok, heal_lines, after_lines, after_failures = self._perform_aw_rus_autoheal(failures) + out.extend(heal_lines) verdict_after = "OK" if not after_failures else f"DEGRADED ({', '.join(after_failures)})" after_lines.append(f"Итог: {verdict_after}") @@ -2513,8 +3081,8 @@ class TSJGuardianBot: def _aw_rus_hayabusa_usage_text(self) -> str: return ( - "Hayabusa DFIR:\n" - "- bounded forensic path для EVTX package -> Hayabusa -> case linkage.\n" + "Форензика Windows логов:\n" + "- ограниченный forensic-путь для EVTX package -> Hayabusa -> case linkage.\n" "- рабочий запуск: /aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]\n" "- MODE по умолчанию: incident\n" "- в кейс пишутся только metadata и ссылки на артефакты, без raw Sigma output." @@ -2629,6 +3197,222 @@ class TSJGuardianBot: return f"- pfsense_security: status unavailable ({err})" return "\n".join(lines) + def _aw_dlp_policy_request( + self, + method: str, + path: str, + payload: Optional[Dict] = None, + timeout_sec: int = 20, + ) -> Dict: + base = self.aw_dlp_policy_api_base.rstrip("/") + if not base: + raise RuntimeError("AW_DLP_POLICY_API_BASE is not configured") + url = f"{base}/{path.lstrip('/')}" + response = requests.request(method.upper(), url, json=payload, timeout=timeout_sec) + response.raise_for_status() + if not (response.text or "").strip(): + return {} + return response.json() + + @staticmethod + def _aw_dlp_toggle_rule_groups(policy: Dict) -> List[Tuple[str, List[Dict]]]: + endpoint = policy.get("endpoint") + if not isinstance(endpoint, dict): + return [] + groups: List[Tuple[str, List[Dict]]] = [] + for key in ("clipboard", "usb", "print", "email"): + rules = endpoint.get(key) + if isinstance(rules, list): + groups.append((f"endpoint.{key}", rules)) + return groups + + @classmethod + def _aw_dlp_mode_from_policy(cls, policy: Dict) -> str: + meta = policy.get("_tsj_meta") + if isinstance(meta, dict): + mode = str(meta.get("dlp_mode") or "").strip().lower() + if mode in {"monitor", "enforce"}: + return mode + + total = 0 + blocked = 0 + for _, rules in cls._aw_dlp_toggle_rule_groups(policy): + for rule in rules: + if not isinstance(rule, dict) or rule.get("enabled") is False: + continue + action = str(rule.get("action") or "").strip().lower() + if not action: + continue + total += 1 + if action == "block": + blocked += 1 + if blocked == 0: + return "monitor" + if blocked == total: + return "enforce" + return "mixed" + + @classmethod + def _aw_dlp_policy_for_mode(cls, policy: Dict, target_mode: str) -> Tuple[Dict, int, List[str]]: + if target_mode not in {"monitor", "enforce"}: + raise ValueError(f"Unsupported DLP mode: {target_mode}") + + updated = json.loads(json.dumps(policy, ensure_ascii=False)) + changed = 0 + changed_rules: List[str] = [] + for group_name, rules in cls._aw_dlp_toggle_rule_groups(updated): + for rule in rules: + if not isinstance(rule, dict) or rule.get("enabled") is False: + continue + old_action = str(rule.get("action") or "").strip().lower() or "alert" + new_action = old_action + if target_mode == "monitor": + if old_action == "block": + new_action = "alert" + else: + if old_action in {"log", "alert"}: + new_action = "block" + if new_action == old_action: + continue + rule["action"] = new_action + changed += 1 + if len(changed_rules) < 10: + changed_rules.append(f"{group_name}:{rule.get('id') or '?'} {old_action}->{new_action}") + + meta = updated.get("_tsj_meta") + if not isinstance(meta, dict): + meta = {} + meta["dlp_mode"] = target_mode + meta["updated_by"] = "tsj-guardian-bot" + meta["updated_at_utc"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + updated["_tsj_meta"] = meta + return updated, changed, changed_rules + + def _aw_rus_windows_sync_dlp_policy(self, policy: Dict) -> Tuple[bool, List[str]]: + report: List[str] = [] + if not self.aw_rus_windows_ssh_password: + report.append("- windows-policy-sync: FAIL (AW_RUS_WINDOWS_SSH_PASSWORD not configured)") + return False, report + script = ( + "$ErrorActionPreference = 'Stop'\n" + "$ProgressPreference = 'SilentlyContinue'\n" + f"$policyPath = '{self.aw_rus_windows_policy_path}'\n" + f"$policyApiBase = '{self.aw_dlp_policy_api_base.rstrip('/')}'\n" + f"$browserCollector = '{self.aw_rus_windows_browser_collector_path}'\n" + "$dlpEndpointCollector = 'C:\\ProgramData\\AWatch-rus\\dlp-endpoint-signals-collector.ps1'\n" + f"$emailCollector = '{self.aw_rus_windows_email_collector_path}'\n" + "$bundle = Invoke-RestMethod -Uri ($policyApiBase + '/dlp/policies/active') -TimeoutSec 20 -Method Get\n" + "if (-not $bundle.policy) { throw 'Active policy payload is empty' }\n" + "$json = $bundle.policy | ConvertTo-Json -Depth 20\n" + "$dir = Split-Path -Path $policyPath -Parent\n" + "if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null }\n" + "Set-Content -LiteralPath $policyPath -Value $json -Encoding UTF8\n" + "$collectorScripts = @($browserCollector, $emailCollector, $dlpEndpointCollector)\n" + "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n" + " Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine } |\n" + " ForEach-Object {\n" + " $commandLine = [string]$_.CommandLine\n" + " foreach ($scriptPath in $collectorScripts) {\n" + " if (-not [string]::IsNullOrWhiteSpace($scriptPath) -and $commandLine -match [Regex]::Escape($scriptPath)) {\n" + " Stop-Process -Id ([int]$_.ProcessId) -Force -ErrorAction SilentlyContinue\n" + " break\n" + " }\n" + " }\n" + " }\n" + f"if (Test-Path '{self.aw_rus_windows_hardening_recovery_path}') {{ try {{ & '{self.aw_rus_windows_hardening_recovery_path}' -ConfigPath '{self.aw_rus_windows_config_path}' }} catch {{ Write-Output ('HARDENING_RECOVERY_WARN: ' + $_.Exception.Message) }} }}\n" + "Start-Sleep -Seconds 2\n" + "schtasks /Run /TN 'ActivityWatch Recovery' | Out-Null\n" + "Start-Sleep -Seconds 2\n" + "Get-ScheduledTask -ErrorAction SilentlyContinue |\n" + " Where-Object { $_.TaskName -like 'ActivityWatch Launch *' } |\n" + " ForEach-Object { try { Start-ScheduledTask -TaskName $_.TaskName -ErrorAction Stop } catch {} }\n" + "Write-Output ('POLICY_SYNC_OK ' + $policyPath)\n" + ) + try: + rc, out = self._run_aw_rus_windows_powershell(script, timeout_sec=240) + if rc != 0: + tail = (out or "").strip().splitlines()[-1:] or [f"rc={rc}"] + report.append(f"- windows-policy-sync: FAIL ({tail[0]})") + return False, report + report.append("- windows-policy-sync: OK") + return True, report + except Exception as exc: + report.append(f"- windows-policy-sync: FAIL ({exc})") + return False, report + + def _aw_dlp_policy_mode_text(self) -> str: + active = self._aw_dlp_policy_request("GET", "/dlp/policies/active", timeout_sec=15) + policy = active.get("policy") or {} + mode = self._aw_dlp_mode_from_policy(policy if isinstance(policy, dict) else {}) + details = [] + for group_name, rules in self._aw_dlp_toggle_rule_groups(policy if isinstance(policy, dict) else {}): + block_count = 0 + total_count = 0 + for rule in rules: + if not isinstance(rule, dict) or rule.get("enabled") is False: + continue + total_count += 1 + if str(rule.get("action") or "").strip().lower() == "block": + block_count += 1 + details.append(f"- {group_name}: block={block_count}/{total_count}") + lines = [ + "DLP режим:", + f"- policy: {active.get('name') or '-'} (id={active.get('policyId') or '-'})", + f"- mode: {mode}", + f"- version: {active.get('version') or '-'}", + "- web rules не блокируют доступ и остаются incident/alert-only.", + ] + lines.extend(details or ["- endpoint rules: n/a"]) + return "\n".join(lines) + + def _aw_dlp_policy_toggle_text(self) -> str: + active = self._aw_dlp_policy_request("GET", "/dlp/policies/active", timeout_sec=20) + policy_id = int(active.get("policyId") or 0) + if policy_id <= 0: + raise RuntimeError("DLP policy engine returned invalid policyId") + policy = active.get("policy") or {} + if not isinstance(policy, dict): + raise RuntimeError("Active DLP policy payload is invalid") + + current_mode = self._aw_dlp_mode_from_policy(policy) + target_mode = "monitor" if current_mode in {"enforce", "mixed"} else "enforce" + updated_policy, changed_count, changed_rules = self._aw_dlp_policy_for_mode(policy, target_mode) + if changed_count == 0: + return ( + "DLP режим не изменён.\n" + f"- current_mode: {current_mode}\n" + "- Подходящих endpoint/email правил для переключения не найдено." + ) + + updated = self._aw_dlp_policy_request( + "PUT", + f"/dlp/policies/{policy_id}", + payload={ + "policy": updated_policy, + "activate": True, + "actor": self.aw_dlp_policy_actor, + }, + timeout_sec=30, + ) + item = updated.get("item") or {} + sync_ok, sync_lines = self._aw_rus_windows_sync_dlp_policy(updated_policy) + lines = [ + "DLP режим переключён.", + f"- policy: {active.get('name') or '-'} (id={policy_id})", + f"- mode: {current_mode} -> {target_mode}", + f"- version: {active.get('version') or '-'} -> {item.get('current_version') or '-'}", + f"- changed_rules: {changed_count}", + "- scope: clipboard/usb/print/email; web rules не переводятся в block этим toggle.", + ] + if changed_rules: + lines.append("- sample changes:") + lines.extend(f" {entry}" for entry in changed_rules[:6]) + lines.extend(sync_lines) + lines.append(f"- menu button: {self._aw_dlp_toggle_button_text(current_mode=target_mode)}") + if not sync_ok: + lines.append("- note: server-side policy уже активирована; endpoint agents подтянут её сами при следующем refresh.") + return "\n".join(lines) + def _run_operator_action(self, action: str) -> str: action = action.strip().lower() if action == "check": @@ -2639,6 +3423,16 @@ class TSJGuardianBot: return f"/run check rc={rc}\n{summary}" if action in ("aw-dlp-check", "awrus-dlp-check"): return self._aw_rus_dlp_check_and_heal_text() + if action in ("dlp-mode", "dlp-policy-mode"): + try: + return self._aw_dlp_policy_mode_text() + except Exception as exc: + return f"DLP режим: status unavailable ({exc})" + if action in ("dlp-mode-toggle", "dlp-policy-toggle"): + try: + return self._aw_dlp_policy_toggle_text() + except Exception as exc: + return f"DLP режим: переключение не выполнено ({exc})" if action == "heal": ok = self._attempt_autoheal(force=True) return f"/run heal result={'ok' if ok else 'failed'}" @@ -2716,19 +3510,25 @@ class TSJGuardianBot: self._send_menu(chat_id, self._cmd_help()) return if text.startswith("/status") or text == self.BTN_STATUS: - self._send_text(chat_id, self._cmd_status()) + self._send_text_with_menu(chat_id, self._cmd_status()) return if text.startswith("/check") or text == self.BTN_CHECK: - self._send_text(chat_id, self._run_operator_action("check")) + self._send_text_with_menu(chat_id, self._run_operator_action("check")) return if text.startswith("/aw_dlp_check") or text == self.BTN_AW_DLP_CHECK: - self._send_text(chat_id, self._run_operator_action("aw-dlp-check")) + self._send_text_with_menu(chat_id, self._run_operator_action("aw-dlp-check")) return - if text == self.BTN_AW_DFIR: - self._send_text(chat_id, self._aw_rus_hayabusa_usage_text()) + if text.startswith("/dlp_mode_toggle") or self._is_dlp_toggle_button(text): + self._send_text_with_menu(chat_id, self._run_operator_action("dlp-mode-toggle")) + return + if text.startswith("/dlp_mode"): + self._send_text_with_menu(chat_id, self._run_operator_action("dlp-mode")) + return + if self._is_dfir_button(text): + self._send_text_with_menu(chat_id, self._aw_rus_hayabusa_usage_text()) return if text.strip() == "/aw_dfir": - self._send_text(chat_id, self._aw_rus_hayabusa_usage_text()) + self._send_text_with_menu(chat_id, self._aw_rus_hayabusa_usage_text()) return if text.startswith("/aw_dfir "): parts = text.split() diff --git a/scripts/install_detmir_powershell_mcp.sh b/scripts/install_detmir_powershell_mcp.sh new file mode 100644 index 0000000..288bb79 --- /dev/null +++ b/scripts/install_detmir_powershell_mcp.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +PWSH_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/powershell" +PROFILE="$PWSH_DIR/Microsoft.PowerShell_profile.ps1" +LOCAL_CFG="$PWSH_DIR/detmir-windows.psd1" +SNIPPET="$ROOT/scripts/powershell/detmir-powershell-profile.ps1" +EXAMPLE_CFG="$ROOT/scripts/powershell/detmir-windows.psd1.example" +INVENTORY="$ROOT/ansible/inventory.ini" +MARK_BEGIN="# >>> DetMir PowerShell MCP >>>" +MARK_END="# <<< DetMir PowerShell MCP <<<" + +mkdir -p "$PWSH_DIR" +touch "$PROFILE" + +TMP_PROFILE="$(mktemp)" +awk -v begin="$MARK_BEGIN" -v end="$MARK_END" ' + $0 == begin { skip = 1; next } + $0 == end { skip = 0; next } + skip != 1 { print } +' "$PROFILE" > "$TMP_PROFILE" + +cat >> "$TMP_PROFILE" < "$LOCAL_CFG" <$null | Out-Null + foreach ($candidate in $candidates) { + $pathError = $null + $exists = Test-Path -LiteralPath $candidate -ErrorAction SilentlyContinue -ErrorVariable pathError + if ($pathError) { + Write-RunLog ("skip unusable ssh key path={0} reason={1}" -f $candidate, $pathError[0].Exception.Message) + continue + } + if (-not $exists) { + continue + } - return $tempKeyPath + & cmd.exe /d /c "copy /Y `"$candidate`" `"$tempKeyPath`" >nul" + if ($LASTEXITCODE -ne 0) { + Write-RunLog ("skip unusable ssh key path={0} reason=copy command failed rc={1}" -f $candidate, $LASTEXITCODE) + continue + } + + & icacls.exe $tempKeyPath /inheritance:r | Out-Null + & icacls.exe $tempKeyPath /grant:r "$($env:USERNAME):(F)" | Out-Null + & icacls.exe $tempKeyPath /remove:g 'Users' 'Authenticated Users' 'Everyone' 'BUILTIN\Users' 'BUILTIN\Administrators' 'NT AUTHORITY\SYSTEM' 2>$null | Out-Null + return $tempKeyPath + } + + throw "No usable SSH private key found. checked={0}" -f ($candidates -join ', ') } function Get-1CFileInfobases { $results = New-Object System.Collections.Generic.List[object] - $launcherFiles = Get-ChildItem -Path 'C:\Users' -Directory -ErrorAction SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'AppData\Roaming\1C\1CEStart\ibases.v8i' } | - Where-Object { Test-Path -LiteralPath $_ } + $launcherFiles = New-Object System.Collections.Generic.List[string] + Get-ChildItem -Path 'C:\Users' -Directory -ErrorAction SilentlyContinue | + ForEach-Object { + $launcherFile = Join-Path $_.FullName 'AppData\Roaming\1C\1CEStart\ibases.v8i' + try { + if (Test-Path -LiteralPath $launcherFile -ErrorAction Stop) { + $launcherFiles.Add($launcherFile) + } + } catch { + Write-RunLog ("skip inaccessible launcher file={0} reason={1}" -f $launcherFile, $_.Exception.Message) + } + } foreach ($file in $launcherFiles) { $userName = 'unknown' @@ -64,7 +133,15 @@ function Get-1CFileInfobases { } $currentName = $null $currentId = $null - foreach ($lineRaw in Get-Content -LiteralPath $file -Encoding UTF8) { + $fileLines = $null + try { + $fileLines = Get-Content -LiteralPath $file -Encoding UTF8 -ErrorAction Stop + } catch { + Write-RunLog ("skip unreadable launcher file={0} reason={1}" -f $file, $_.Exception.Message) + continue + } + + foreach ($lineRaw in $fileLines) { $line = [string]$lineRaw if ($line -match '^\[(.+)\]$') { $currentName = $Matches[1] @@ -196,7 +273,11 @@ function Save-ExporterState { if ($directory) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } - $State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8 + try { + $State | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $Path -Encoding UTF8 + } catch { + Write-RunLog ("warning: failed to persist exporter state path={0} reason={1}" -f $Path, $_.Exception.Message) + } } function Get-CompanyActivityScore { @@ -237,11 +318,13 @@ if (-not (Test-Path -LiteralPath $ScpExe)) { throw "scp client not found: $ScpExe" } -if (-not (Test-Path -LiteralPath $RemoteKeyPath)) { - throw "SSH private key not found: $RemoteKeyPath" -} - $config = Get-Content -Raw -LiteralPath $ConfigPath | ConvertFrom-Json +if ($config.PSObject.Properties.Name -contains 'analytics' -and + $config.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and + $config.analytics.file1cAutomation.PSObject.Properties.Name -contains 'remoteKeyPath' -and + -not [string]::IsNullOrWhiteSpace([string]$config.analytics.file1cAutomation.remoteKeyPath)) { + $RemoteKeyPath = [string]$config.analytics.file1cAutomation.remoteKeyPath +} if ([string]::IsNullOrWhiteSpace($AnalyticsHost)) { if ($config.PSObject.Properties.Name -contains 'analytics' -and $config.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and @@ -250,7 +333,37 @@ if ([string]::IsNullOrWhiteSpace($AnalyticsHost)) { } } if ([string]::IsNullOrWhiteSpace($AnalyticsHost)) { - throw "AnalyticsHost is empty and deployment-config has no analytics.file1cAutomation.targetHost" + $AnalyticsHost = Get-LastSuccessfulAnalyticsHostFromRunLog -Path $LogPath + if (-not [string]::IsNullOrWhiteSpace($AnalyticsHost)) { + Write-RunLog "recovered analyticsHost=$AnalyticsHost from previous successful uploader log" + try { + if ($config.PSObject.Properties.Name -notcontains 'analytics' -or $null -eq $config.analytics) { + if ($config.PSObject.Properties.Name -contains 'analytics') { + $config.analytics = [pscustomobject]@{} + } else { + $config | Add-Member -NotePropertyName 'analytics' -NotePropertyValue ([pscustomobject]@{}) + } + } + if ($config.analytics.PSObject.Properties.Name -notcontains 'file1cAutomation' -or $null -eq $config.analytics.file1cAutomation) { + if ($config.analytics.PSObject.Properties.Name -contains 'file1cAutomation') { + $config.analytics.file1cAutomation = [pscustomobject]@{} + } else { + $config.analytics | Add-Member -NotePropertyName 'file1cAutomation' -NotePropertyValue ([pscustomobject]@{}) + } + } + if ($config.analytics.file1cAutomation.PSObject.Properties.Name -contains 'targetHost') { + $config.analytics.file1cAutomation.targetHost = $AnalyticsHost + } else { + $config.analytics.file1cAutomation | Add-Member -NotePropertyName 'targetHost' -NotePropertyValue $AnalyticsHost + } + Save-DeploymentConfig -Config $config -Path $ConfigPath + } catch { + Write-RunLog ("warning: failed to persist recovered analyticsHost into deployment-config: " + $_.Exception.Message) + } + } +} +if ([string]::IsNullOrWhiteSpace($AnalyticsHost)) { + throw "AnalyticsHost is empty, deployment-config has no analytics.file1cAutomation.targetHost, and no previous successful uploader log was found" } if ($config.PSObject.Properties.Name -contains 'analytics' -and $config.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and diff --git a/windows/file-operations-collector.ps1 b/windows/file-operations-collector.ps1 index 1e3a7a6..09ddb59 100644 --- a/windows/file-operations-collector.ps1 +++ b/windows/file-operations-collector.ps1 @@ -24,6 +24,7 @@ $script:Hostname = $env:COMPUTERNAME $script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId $script:TransportQueuePath = $null $script:TransportQueueLockPath = $null +$script:StartupTracePath = if ($LogPath) { $LogPath } else { $null } $script:TransportMetrics = @{ eventsEnqueued = 0 eventsFlushed = 0 @@ -32,8 +33,8 @@ $script:TransportMetrics = @{ } # Настройка логирования -$script:LogPath = $LogPath -$script:LocalAgentLogsEnabled = [bool]$LogPath +$script:LogPath = $null +$script:LocalAgentLogsEnabled = $false function Get-DeploymentConfig { param([string]$Path) @@ -51,6 +52,20 @@ function Write-FileCollectorLog { } catch {} } +function Write-StartupTrace { + param([string]$Message) + if ([string]::IsNullOrWhiteSpace($script:StartupTracePath)) { return } + try { + $dir = Split-Path -Parent $script:StartupTracePath + if ($dir -and -not (Test-Path -LiteralPath $dir)) { + New-Item -Path $dir -ItemType Directory -Force | Out-Null + } + [System.IO.File]::AppendAllText($script:StartupTracePath, ('{0} [startup] {1}{2}' -f (Get-Date -Format s), $Message, [Environment]::NewLine), [System.Text.Encoding]::UTF8) + } + catch { + } +} + function Invoke-AwJsonPost { param( [Parameter(Mandatory = $true)][string]$Uri, @@ -250,6 +265,7 @@ function Send-FileOperationEvent { data = $data } | ConvertTo-Json -Depth 5 -Compress + Write-FileCollectorLog ("Queue file event: op={0} path={1}" -f $Operation, $FilePath) Add-TransportQueueItem -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Payload $payload -Kind 'file_op' Flush-TransportQueue -MaxItems 20 } @@ -275,19 +291,86 @@ function Send-CollectorHealthEvent { Flush-TransportQueue -MaxItems 50 } +function Handle-FileWatcherEvent { + param( + [Parameter(Mandatory = $true)]$EventRecord + ) + + try { + $args = $EventRecord.SourceEventArgs + $eventName = $null + if ($args -and $args.PSObject.Properties.Name -contains 'ChangeType' -and $args.ChangeType) { + $eventName = [string]$args.ChangeType + } + elseif ($EventRecord.SourceIdentifier -match '-created$') { + $eventName = 'Created' + } + elseif ($EventRecord.SourceIdentifier -match '-deleted$') { + $eventName = 'Deleted' + } + elseif ($EventRecord.SourceIdentifier -match '-renamed$') { + $eventName = 'Renamed' + } + + Write-FileCollectorLog ("Received event: src={0} type={1}" -f $EventRecord.SourceIdentifier, $eventName) + + switch ($eventName) { + 'Created' { + $path = [string]$args.FullPath + $size = 0 + try { + if (Test-Path -LiteralPath $path) { + $size = (Get-Item -LiteralPath $path).Length + } + } + catch { + } + Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size + } + 'Deleted' { + Send-FileOperationEvent -Operation 'Deleted' -FilePath ([string]$args.FullPath) + } + 'Renamed' { + Send-FileOperationEvent -Operation 'Renamed' -FilePath ([string]$args.FullPath) -OldFilePath ([string]$args.OldFullPath) + } + } + } + catch { + Write-FileCollectorLog ("File watcher event failed: {0}" -f $_.Exception.Message) + } + finally { + if ($EventRecord.EventIdentifier) { + Remove-Event -EventIdentifier $EventRecord.EventIdentifier -ErrorAction SilentlyContinue + } + } +} + +Write-StartupTrace "boot" $config = Get-DeploymentConfig -Path $ConfigPath if (-not $config) { throw "Configuration file not found: $ConfigPath" } +Write-StartupTrace "config-loaded" $script:Hostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME } $scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } -$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } +$serverHostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } $port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 } -$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port +$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $serverHostName, $port $stateRoot = if ($config.paths -and $config.paths.stateRoot) { [string]$config.paths.stateRoot } else { 'C:\ProgramData\AWatch-rus' } +Write-StartupTrace ("api={0}" -f $script:ApiBase) +$resolvedLogsRoot = if ($config.paths -and $config.paths.logsRoot) { [string]$config.paths.logsRoot } else { Join-Path $stateRoot 'logs' } +$resolvedLocalAgentLogsEnabled = if ($config.PSObject.Properties.Name -contains 'logging' -and $config.logging -and $config.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$config.logging.localAgentLogsEnabled } else { $true } +$script:LogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("file-operations-{0}.log" -f $env:USERNAME) } +$explicitLogPath = -not [string]::IsNullOrWhiteSpace($LogPath) +$script:LocalAgentLogsEnabled = ($explicitLogPath -or $resolvedLocalAgentLogsEnabled) -and -not [string]::IsNullOrWhiteSpace($script:LogPath) +if ($script:LocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} Initialize-TransportQueue -StateRoot $stateRoot +Write-StartupTrace "queue-ready" $bucketId = 'aw-file-operations_' + $script:Hostname Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' +Write-StartupTrace ("bucket-ready={0}" -f $bucketId) # Resolve paths for monitoring $resolvedPaths = @() @@ -304,6 +387,7 @@ foreach ($p in $WatchPaths) { $resolvedPaths += $fullPath } } +Write-StartupTrace ("watch-paths={0}" -f ($resolvedPaths -join ';')) if ($resolvedPaths.Count -eq 0) { Write-FileCollectorLog "No valid watch paths found. Exiting." @@ -314,27 +398,21 @@ Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')" $watchers = @() $subscriptions = @() +$eventPrefix = "aw-fileops-$PID" +$watchIndex = 0 foreach ($path in $resolvedPaths) { $watcher = New-Object System.IO.FileSystemWatcher $watcher.Path = $path $watcher.IncludeSubdirectories = $true $watcher.EnableRaisingEvents = $true - - $onChanged = Register-ObjectEvent $watcher "Created" -Action { - $path = $Event.SourceEventArgs.FullPath - $size = 0 - try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch {} - Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size - } - $onDeleted = Register-ObjectEvent $watcher "Deleted" -Action { - Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath - } - $onRenamed = Register-ObjectEvent $watcher "Renamed" -Action { - Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath - } - + + $onChanged = Register-ObjectEvent -InputObject $watcher -EventName "Created" -SourceIdentifier ("{0}-{1}-created" -f $eventPrefix, $watchIndex) + $onDeleted = Register-ObjectEvent -InputObject $watcher -EventName "Deleted" -SourceIdentifier ("{0}-{1}-deleted" -f $eventPrefix, $watchIndex) + $onRenamed = Register-ObjectEvent -InputObject $watcher -EventName "Renamed" -SourceIdentifier ("{0}-{1}-renamed" -f $eventPrefix, $watchIndex) + $watchers += $watcher $subscriptions += @($onChanged, $onDeleted, $onRenamed) + $watchIndex++ } Write-FileCollectorLog "Collector started. Waiting for events..." @@ -352,6 +430,20 @@ try { $lastHealth = [datetime]::UtcNow $backoffSeconds = 1 while ($true) { + $eventRecord = Wait-Event -Timeout $PollSeconds -ErrorAction SilentlyContinue + if ($eventRecord) { + if ([string]$eventRecord.SourceIdentifier -like "$eventPrefix-*") { + Handle-FileWatcherEvent -EventRecord $eventRecord + } + else { + Remove-Event -EventIdentifier $eventRecord.EventIdentifier -ErrorAction SilentlyContinue + } + + foreach ($pendingEvent in @(Get-Event | Where-Object { [string]$_.SourceIdentifier -like "$eventPrefix-*" })) { + Handle-FileWatcherEvent -EventRecord $pendingEvent + } + } + try { Flush-TransportQueue -MaxItems 100 $backoffSeconds = 1 @@ -373,16 +465,20 @@ try { } $lastHealth = [datetime]::UtcNow } - - if ($backoffSeconds -gt $PollSeconds) { + elseif ($backoffSeconds -gt $PollSeconds) { Start-Sleep -Seconds $backoffSeconds - continue } - Start-Sleep -Seconds $PollSeconds } } finally { Write-FileCollectorLog "Stopping collector..." + foreach ($pendingEvent in @(Get-Event | Where-Object { [string]$_.SourceIdentifier -like "$eventPrefix-*" })) { + try { + Remove-Event -EventIdentifier $pendingEvent.EventIdentifier -ErrorAction SilentlyContinue + } + catch { + } + } foreach ($sub in @($subscriptions)) { try { if ($sub -and $sub.Id) { diff --git a/windows/fix-session-watchers.ps1 b/windows/fix-session-watchers.ps1 new file mode 100644 index 0000000..b9d8f84 --- /dev/null +++ b/windows/fix-session-watchers.ps1 @@ -0,0 +1,45 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ModulePath = 'C:\Program Files\AWatch-rus\windows\ActivityWatch.Windows.Common.psm1' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module $ModulePath -Force + +$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath +$stateRoot = [string]$config.paths.stateRoot +$launchScriptPath = if ($config.paths.PSObject.Properties.Name -contains 'launchScript') { [string]$config.paths.launchScript } else { Join-Path $stateRoot 'launch-watchers.ps1' } +$recoveryScriptPath = if ($config.paths.PSObject.Properties.Name -contains 'recoveryScript') { [string]$config.paths.recoveryScript } else { Join-Path $stateRoot 'recovery-loop.ps1' } + +Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $ConfigPath +Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $ConfigPath +Write-ActivityWatchHiddenPowerShellWrapper -Path (Get-ActivityWatchHiddenLauncherPath -ScriptPath $launchScriptPath) -ScriptPath $launchScriptPath -ConfigPath $ConfigPath +Write-ActivityWatchHiddenPowerShellWrapper -Path (Get-ActivityWatchHiddenLauncherPath -ScriptPath $recoveryScriptPath) -ScriptPath $recoveryScriptPath -ConfigPath $ConfigPath + +$sessionRecords = Get-ActivityWatchSessionRecords +Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config + +Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -match 'recovery-loop\.ps1' + } | + ForEach-Object { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + +Start-Sleep -Seconds 2 +schtasks /Run /TN 'ActivityWatch Recovery' | Out-Null +Start-Sleep -Seconds 3 + +Write-Host 'QUSER' +quser +Write-Host '---' +Write-Host 'WATCHERS' +Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue | + Select-Object Name, Id, SessionId, StartTime | + Sort-Object SessionId, Name | + Format-Table -AutoSize diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index dcb9889..65a3187 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -113,6 +113,21 @@ $effectiveFile1CAutoUploadTaskName = if ($existingConfig -and $existingConfig.PS $effectiveFile1CTargetHost = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'targetHost') { [string]$existingConfig.analytics.file1cAutomation.targetHost } else { '' } $effectiveFile1CTargetUser = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'targetUser') { [string]$existingConfig.analytics.file1cAutomation.targetUser } else { 'igor' } +if ([string]::IsNullOrWhiteSpace($effectiveFile1CTargetHost)) { + $file1cLogPath = Join-Path $effectiveLogsRoot 'file1c-telemetry.log' + if (Test-Path -LiteralPath $file1cLogPath) { + $recoveredFile1CHost = '' + foreach ($line in Get-Content -LiteralPath $file1cLogPath -Encoding UTF8) { + if ([string]$line -match 'upload complete analyticsHost=([^\s]+)') { + $recoveredFile1CHost = [string]$Matches[1] + } + } + if (-not [string]::IsNullOrWhiteSpace($recoveredFile1CHost)) { + $effectiveFile1CTargetHost = $recoveredFile1CHost + } + } +} + $effectiveUsers = if ($Users -or $UserListPath) { Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain } diff --git a/windows/run-user1-probe.ps1 b/windows/run-user1-probe.ps1 new file mode 100644 index 0000000..641250a --- /dev/null +++ b/windows/run-user1-probe.ps1 @@ -0,0 +1,39 @@ +[CmdletBinding()] +param( + [string]$UserId = 'SHARKON2025\user1' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$probeScriptPath = 'C:\ProgramData\AWatch-rus\user1-notepad-probe.ps1' +@' +Start-Process notepad.exe +Start-Sleep -Seconds 10 +Get-Process notepad -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +'@ | Set-Content -LiteralPath $probeScriptPath -Encoding UTF8 + +schtasks /Run /TN 'ActivityWatch Launch [SHARKON2025_user1]' | Out-Null +Start-Sleep -Seconds 3 + +$taskName = 'AW User1 Notepad Probe' +Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +$action = New-ScheduledTaskAction -Execute (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -Argument "-NoProfile -ExecutionPolicy Bypass -File $probeScriptPath" +$principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 5) +Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings | Out-Null +Start-ScheduledTask -TaskName $taskName +Start-Sleep -Seconds 5 + +Write-Host 'QUSER' +quser +Write-Host '---' +Write-Host 'WATCHERS' +Get-Process aw-watcher-afk,aw-watcher-window,powershell,notepad -ErrorAction SilentlyContinue | + Select-Object Name, Id, SessionId, StartTime | + Sort-Object SessionId, Name | + Format-Table -AutoSize + +Start-Sleep -Seconds 8 +Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $probeScriptPath -Force -ErrorAction SilentlyContinue diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 5ca3392..8b704f5 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -30,6 +30,7 @@ $pulseSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $freshnessSeconds = [Math]::Max($pollSeconds * 3, 30) $sessionFreshnessSeconds = [Math]::Max($pollSeconds * 4, 45) $transportStaleSeconds = [Math]::Max($pollSeconds * 12, 180) +$endpointFreshnessSeconds = [Math]::Max($transportStaleSeconds, 300) $queueMaxDepth = 1000 $afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } @@ -37,6 +38,10 @@ $windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -a $fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } function Get-LoggedOnUsers { + param( + [bool]$IncludeDisconnected = $false + ) + $users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $activeStates = @('Active', 'Активно') $inactiveStates = @('Disc', 'Disconnected', 'Idle', 'Listen', 'Диск', 'Откл', 'Отключен') @@ -61,7 +66,11 @@ function Get-LoggedOnUsers { break } } - if ($null -ne $state -and $activeStates -notcontains $state) { continue } + if ($null -ne $state -and $activeStates -notcontains $state) { + if (-not $IncludeDisconnected -or $inactiveStates -notcontains $state) { + continue + } + } [void]$users.Add($user) [void]$users.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user)) if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { @@ -373,13 +382,27 @@ $endpointCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $endpointColl $fileCollectorProcesses = if ($fileOpsExpected) { @(Get-CollectorProcesses -ScriptPath $fileCollectorScript) } else { @() } $browserCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $collectorScript) -$loggedOnUsers = Get-LoggedOnUsers -$sessionBoundUsers = @( +$liveLoggedOnUsers = Get-LoggedOnUsers +$interactiveUsers = Get-LoggedOnUsers -IncludeDisconnected $true +$liveSessionBoundUsers = @( @($config.userTasks) | - Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $loggedOnUsers } | + Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $liveLoggedOnUsers } | ForEach-Object { [string]$_.userId } ) -$sessionScopedExpectedCount = [int]$sessionBoundUsers.Count +$interactiveSessionBoundUsers = @( + @($config.userTasks) | + Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $interactiveUsers } | + ForEach-Object { [string]$_.userId } +) +$sessionScopedExpectedCount = if ($liveSessionBoundUsers.Count -gt 0) { + [int]$liveSessionBoundUsers.Count +} +elseif ($interactiveSessionBoundUsers.Count -gt 0) { + 1 +} +else { + 0 +} $sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0) $taskNames = @() @@ -413,7 +436,7 @@ if ($sessionScopedCollectorsRequired -and $windowExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false } if ($sessionScopedCollectorsRequired) { - $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $true + $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $endpointFreshnessSeconds -Required $true -RequireFreshEvent $true } if ($sessionScopedCollectorsRequired -and $fileOpsExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true @@ -443,18 +466,9 @@ catch { } $watcherCountsOk = $true -if ($sessionScopedCollectorsRequired) { - if ($afkExpected) { - $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-afk'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) - } - if ($windowExpected) { - $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-window'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) - } -} - -$endpointProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($endpointCollectorProcesses).Count -ge $sessionScopedExpectedCount) } -$fileProcessOk = if (-not $fileOpsExpected -or -not $sessionScopedCollectorsRequired) { $true } else { (@($fileCollectorProcesses).Count -ge $sessionScopedExpectedCount) } -$browserProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($browserCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$endpointProcessOk = $true +$fileProcessOk = $true +$browserProcessOk = $true $sessionCollectorOk = (@($sessionCollectorProcesses).Count -eq 1) $result = [ordered]@{ @@ -482,7 +496,8 @@ $result = [ordered]@{ ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled })) } processes = [ordered]@{ - sessionBoundUsers = $sessionBoundUsers + liveSessionBoundUsers = $liveSessionBoundUsers + sessionBoundUsers = $interactiveSessionBoundUsers sessionScopedExpectedCount = [int]$sessionScopedExpectedCount watchers = @($runningWatchers) watcherDuplicates = @($watcherDuplicates)