feat(s25): autonomous continuous quality monitoring (8/8)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Hourly smoke watchdog + auto-fix loop + dashboard + multi-tenant guard + perf regression + cleanup job + README badge. 1. ~/quality-watchdog.sh (cron 5 * * * *) — 8 checks (~60s): /health/ready, signup→login→/api/me, GET products, Playwright UI smoke (3.1 product CRUD), /metrics format, /hubs/notifications negotiate with token, multi-tenant isolation, perf p95. 2. Auto-fix loop: 2× consecutive red → ~/.fm-watchdog/incident-*.txt + queue/0000-incident-* to bump it ahead of Server-Claude's sprint queue. fm-watchdog.sh sees prefix 0000- as next. 3. scripts/quality-dashboard.py — renders docs/quality-status.md (current emoji, 8-step table, perf baseline, 7-day history, 24-run sparkline) + injects README badge 🟢/🟡/🔴. 4. Multi-tenant smoke: signup 2 orgs `quality-{epoch}-A/B`, create product in A, verify B sees 404/403 + total=0. 5. Perf regression: p95 over 10 reqs for /api/me, products, sales/retail/stats. Baseline = median of last 10 samples (robust to noise). >50% from baseline → alert. First 5 runs always green (warm-up). 6. HousekeepingJobs.PruneQualityTestOrgsAsync (cron 30 2 * * * UTC): finds orgs `quality-%` older than 24h, dynamically scans information_schema for tables with OrganizationId, iteratively DELETEs with FK-violation retry (up to 10 passes), then cleans AspNetUser*/OpenIddict* by email pattern `quality-%@test-fm.local`, finally users + organizations. 7. README badge: <!-- quality-badge --> marker updated each run. Validated: stage deploy ✓, Hangfire job registered ✓, dry-run SQL on 24 stage candidates → 0 remaining ✓, 3 cron-triggered runs all 8/8 green (12:42/12:45/12:48 +05) ✓. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4cc4922463
commit
019c57ae3b
|
|
@ -1,5 +1,7 @@
|
|||
# food-market
|
||||
|
||||
<!-- quality-badge --> 🟢 **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->
|
||||
|
||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||
|
|
@ -124,4 +126,4 @@ food-market/
|
|||
|
||||
## Лицензия
|
||||
|
||||
Internal proprietary, не для публикации без разрешения владельца.
|
||||
Internal proprietary, не для публикации без разрешения владельца.
|
||||
60
docs/quality-status.md
Normal file
60
docs/quality-status.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Quality status
|
||||
|
||||
_Обновлено: 2026-06-08T07:48:24+00:00 · auto-gen из `~/quality-watchdog.sh`_
|
||||
|
||||
## 🟢 Текущий статус
|
||||
|
||||
**Последний прогон:** `2026-06-08T12:48:01+05:00`
|
||||
**Зелёных шагов:** 8/8
|
||||
**Красных шагов:** 0
|
||||
|
||||
## Шаги smoke-suite
|
||||
|
||||
| Шаг | Статус | Последнее изменение | Consecutive fail |
|
||||
|---|---|---|---|
|
||||
| /health/ready | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
| signup→login→/api/me | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
| GET /api/catalog/products | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
| Playwright UI (product CRUD) | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
| /metrics (Prometheus) | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
| /hubs/notifications/negotiate | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
| Multi-tenant isolation | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
| Performance p95 vs baseline | 🟢 | `2026-06-08T12:48:01+05:00` | 0 |
|
||||
|
||||
## Performance baseline (p95, ms)
|
||||
|
||||
| Endpoint | p95 (ms) |
|
||||
|---|---|
|
||||
| `/api/catalog/products/page/1/pageSize/10` | 233 |
|
||||
| `/api/me` | 259 |
|
||||
| `/api/sales/retail/stats/days/7` | 228 |
|
||||
|
||||
_Регрессия = текущий p95 >50% от baseline. Baseline обновляется только когда регрессии нет (берёт min)._
|
||||
|
||||
## История за 7 дней
|
||||
|
||||
**Прогонов:** 14
|
||||
**С красным:** 7
|
||||
**Green-ratio:** 50%
|
||||
|
||||
### Прогоны с красным шагом
|
||||
|
||||
| Время | Красные шаги |
|
||||
|---|---|
|
||||
| `2026-06-08T12:16:05+05:00` | multi_tenant |
|
||||
| `2026-06-08T12:16:52+05:00` | multi_tenant |
|
||||
| `2026-06-08T12:18:01+05:00` | multi_tenant |
|
||||
| `2026-06-08T12:18:35+05:00` | multi_tenant |
|
||||
| `2026-06-08T12:22:49+05:00` | health, auth_me, products, metrics, signalr, multi_tenant, perf |
|
||||
| `2026-06-08T12:23:00+05:00` | health, auth_me, products, metrics, signalr, multi_tenant, perf |
|
||||
| `2026-06-08T12:23:18+05:00` | perf |
|
||||
|
||||
## Последние 24 прогона
|
||||
|
||||
`🔴🔴🔴🔴🟢🟢🔴🔴🔴🟢🟢🟢🟢🟢`
|
||||
|
||||
---
|
||||
|
||||
Скрипт: `~/quality-watchdog.sh` (cron `0 * * * *`).
|
||||
Источник: `~/.fm-watchdog/quality-history.jsonl`.
|
||||
Sprint 25 — autonomous continuous quality monitoring.
|
||||
216
docs/sprint25-progress.md
Normal file
216
docs/sprint25-progress.md
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
# Sprint 25 — autonomous continuous quality monitoring
|
||||
|
||||
Цель: чтобы существующие фичи постоянно проверялись на работоспособность,
|
||||
и при поломке — автоматическая попытка починить через инцидент-задачу в
|
||||
очередь Server-Claude'а.
|
||||
|
||||
Старт: 2026-06-08. Исполнитель: Claude Opus 4.7.
|
||||
|
||||
## Чек-лист
|
||||
|
||||
- [x] **1. Hourly smoke watchdog** — `~/quality-watchdog.sh` запускается
|
||||
cron'ом каждый час в минуту 5. 8 проверок (~60 сек): /health/ready,
|
||||
signup→login→/api/me, GET /api/catalog/products, Playwright UI flow
|
||||
(3.1 product create-list-get), /metrics, /hubs/notifications/negotiate,
|
||||
multi-tenant изоляция, performance p95. Падение → Telegram + лог в
|
||||
`~/.fm-watchdog/quality.log`.
|
||||
|
||||
- [x] **2. Auto-fix loop при двукратном падении** — при consecutive_fail≥2
|
||||
watchdog создаёт `~/.fm-watchdog/incident-{ts}-{step}.txt` + копирует в
|
||||
очередь Server-Claude'а как `queue/0000-incident-*` (префикс `0000-`
|
||||
гарантирует сортировку в начало). После завершения spirinta Server-Claude
|
||||
возвращается к текущему.
|
||||
|
||||
- [x] **3. Quality dashboard** — `docs/quality-status.md` рендерится
|
||||
hourly через `scripts/quality-dashboard.py`: текущий статус-эмодзи,
|
||||
список 8 шагов с last-green/last-red, perf baseline (median p95 по
|
||||
последним 10 измерениям), история падений за 7 дней, sparkline 24
|
||||
последних прогонов.
|
||||
|
||||
- [x] **4. Multi-tenant smoke 24/7** — создаёт TWO org с уникальными
|
||||
именами `quality-{epoch}-A/B`, заводит товар в A, проверяет что B
|
||||
не видит его ни через GET-by-id (404/403), ни через search (total=0).
|
||||
Leak в multi-tenant изоляции — самый дорогой баг, поэтому ежечасный
|
||||
контроль.
|
||||
|
||||
- [x] **5. Performance regression hourly** — p95 за 10 повторов для
|
||||
`/api/me`, `/api/catalog/products`, `/api/sales/retail/stats`. Baseline
|
||||
= median последних 10 измерений (robust к шуму). Регрессия = текущий
|
||||
p95 >50% от baseline → alert + (при consecutive≥2) incident.
|
||||
Первые 5 запусков всегда green (собираем стартовую выборку).
|
||||
|
||||
- [x] **6. Hangfire job: cleanup quality test orgs** —
|
||||
`HousekeepingJobs.PruneQualityTestOrgsAsync` (recurring-id
|
||||
`prune-quality-test-orgs`, default cron `30 2 * * *` UTC). Удаляет
|
||||
org'и `quality-%` старше 24h: динамически по `information_schema`
|
||||
находит все tenant-таблицы с `OrganizationId`, итеративно DELETE'ит
|
||||
с обработкой FK-violation (до 10 проходов), затем чистит
|
||||
`AspNetUserRoles/Tokens/Claims/Logins`, `OpenIddictTokens/Authorizations`
|
||||
по email-шаблону `quality-%@test-fm.local`, и finally `users` +
|
||||
`organizations`. Threshold переопределяется через
|
||||
`Cleanup:QualityTestOrgHours`.
|
||||
|
||||
- [x] **7. Status badge в README** — `<!-- quality-badge -->` маркер с
|
||||
эмодзи 🟢/🟡/🔴 + ссылкой на `docs/quality-status.md`. Обновляется
|
||||
через `scripts/quality-dashboard.py`. 🟢 = последний run all green;
|
||||
🟡 = красные шаги, но <2 consecutive (transient); 🔴 = 2+
|
||||
consecutive (incident-уровень).
|
||||
|
||||
- [x] **8. Cron-валидация** — временно поменял на `*/3 * * * *`,
|
||||
поймал 3 cron-запуска подряд (12:42 / 12:45 / 12:48 +05), все
|
||||
8/8 green. Вернул на hourly (`5 * * * *`). См. `~/.fm-watchdog/quality-cron.log`.
|
||||
|
||||
## Журнал
|
||||
|
||||
### 2026-06-08 старт
|
||||
Sprint 24 закрыт (8/8). Поехали по quality watchdog.
|
||||
|
||||
### 2026-06-08 quality-watchdog.sh (#1)
|
||||
Главный скрипт ~16 КБ:
|
||||
- shell-helpers `state_get`/`state_set`/`mark_red`/`mark_green`/`create_incident`.
|
||||
- Все шаги логируют в `~/.fm-watchdog/quality.log` + state в
|
||||
`~/.fm-watchdog/quality-state.json` (per-step `consecutive_fail`,
|
||||
`last_green`, `last_red`, `last_detail`).
|
||||
- Telegram-нотификация на каждый red-шаг. Если token/chat не настроены
|
||||
(default на dev-vm) — просто лог без падения.
|
||||
- Лог ротируется при превышении 1 МБ.
|
||||
|
||||
Подобранные через debug refs:
|
||||
- `/api/catalog/units-of-measure` (не `/api/refs/units`)
|
||||
- `/api/catalog/product-groups` (обязательное поле ProductGroupId)
|
||||
- POST product требует `barcodes: [{code, type, isPrimary}]` + price
|
||||
`[{priceTypeId, amount, currencyId}]` где priceTypeId = isRequired-row
|
||||
из `/api/catalog/price-types`.
|
||||
|
||||
### 2026-06-08 auto-fix loop (#2)
|
||||
`create_incident` пишет 2 файла: один в `~/.fm-watchdog/incident-{ts}-{step}.txt`
|
||||
(для архива/обзора инженером), другой в `~/.fm-watchdog/queue/0000-incident-{ts}-{step}.txt`.
|
||||
Префикс `0000-` критичен — `fm-watchdog.sh` ротирует очередь через
|
||||
`ls | sort | head -1`, так что инцидент-задача рулит раньше любой
|
||||
плановой работы. Проверил через симуляцию (STAGE_URL=https://nonexistent.example.invalid):
|
||||
1-я итерация — yellow, 2-я — RED + 7 incident-файлов в очереди (по одному
|
||||
на каждый упавший шаг, кроме ui_flow — там node_modules + playwright всё
|
||||
равно работают).
|
||||
|
||||
### 2026-06-08 dashboard + history (#3)
|
||||
`scripts/quality-dashboard.py` (Python 3.11+, no deps) рендерит markdown
|
||||
из `quality-history.jsonl` (1 JSONL-строка на прогон, append-only,
|
||||
trimmed до 400 последних = ~2 недели hourly). Содержит:
|
||||
- Текущий статус-эмодзи.
|
||||
- Таблицу 8 шагов: `статус | last-change | consecutive_fail`.
|
||||
- Performance baseline (median p95 по 10 последним измерениям).
|
||||
- История за 7 дней + green-ratio %.
|
||||
- Sparkline последних 24 прогонов (🟢/🔴).
|
||||
|
||||
`updateReadmeBadge` инжектит `<!-- quality-badge --> ... <!-- /quality-badge -->`
|
||||
маркер в README между h1 и existing badges.
|
||||
|
||||
### 2026-06-08 multi-tenant scenario (#4)
|
||||
2 signup'a с unique `quality-{epoch}-A/B`, токены через `/connect/token`,
|
||||
POST product в A → GET в B должен вернуть 404/403 + search в B должен
|
||||
дать total=0. Хитрость: B видит "Все товары" (root group)
|
||||
**своей** организации (создан bootstrap'ом при signup), не A's; это
|
||||
правильно. Leak бы означал, что product создан вне tenant'a A.
|
||||
|
||||
### 2026-06-08 performance baseline (#5)
|
||||
Первая версия использовала `min(old, new)` как baseline — после первого
|
||||
быстрого замера (cold connection) baseline=200ms, и любой warm-replay
|
||||
295ms давал +47% и срабатывал alert. Переделал на median последних 10
|
||||
измерений + первые 5 запусков всегда green (warm-up). Это сглаживает
|
||||
шум и держит alert на действительно sustained регрессиях.
|
||||
|
||||
Файл `~/.fm-watchdog/quality-perf-baseline.json` теперь содержит:
|
||||
```json
|
||||
{
|
||||
"median": { "_api_me": 245, ... },
|
||||
"samples": { "_api_me": [216, 245, 297, ...], ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 2026-06-08 PruneQualityTestOrgs job (#6)
|
||||
Добавил метод в `HousekeepingJobs` + регистрация в
|
||||
`HangfireJobsConfigurator` под id `prune-quality-test-orgs`,
|
||||
cron `30 2 * * *` UTC.
|
||||
|
||||
Реализация:
|
||||
1. `_db.Organizations.IgnoreQueryFilters().Where(Name LIKE 'quality-%' AND CreatedAt < threshold)` → candidate ids.
|
||||
2. `DO $do$ ... END $do$` блок с `org_ids uuid[] := ARRAY[...]`.
|
||||
3. Итеративный FOR pass IN 1..10 LOOP по
|
||||
`information_schema.columns WHERE column_name = 'OrganizationId'`
|
||||
— каждая итерация пытается DELETE из каждой tenant-таблицы; при
|
||||
`foreign_key_violation` пропускает (следующий проход уберёт parent
|
||||
запись). Реально хватает 2 проходов (нашёл employees ↔ employee_roles).
|
||||
4. AspNetUserRoles/Tokens/Claims/Logins/OpenIddictTokens/Authorizations
|
||||
через email-pattern `quality-%@test-fm.local`.
|
||||
5. `users` + `organizations` отдельным шагом в конце.
|
||||
|
||||
Dry-run на stage (24 кандидата, ROLLBACK): pass 1 remaining=2, pass 2
|
||||
remaining=0, итог 0 orgs в `quality-%` — работает.
|
||||
|
||||
### 2026-06-08 README badge (#7)
|
||||
`updateReadmeBadge` инжектит маркированный блок:
|
||||
```html
|
||||
<!-- quality-badge --> 🟢 **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->
|
||||
```
|
||||
Каждый запуск watchdog'a обновляет эмодзи по результату последнего прогона.
|
||||
|
||||
### 2026-06-08 cron-валидация (#8)
|
||||
Crontab: `5 * * * * /home/nns/quality-watchdog.sh >> ~/.fm-watchdog/quality-cron.log 2>&1`.
|
||||
Для быстрого подтверждения временно поменял на `*/3 * * * *`, поймал
|
||||
3 запуска подряд (12:42 / 12:45 / 12:48 +05), все 8/8 green. Восстановил
|
||||
hourly.
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
~/quality-watchdog.sh (cron 5 * * * *)
|
||||
↓ запускает 8 проверок
|
||||
├── HTTP curl (steps 1, 2, 3, 5, 6, 7, 8)
|
||||
└── Playwright @smoke (step 4, использует tests/regression/flows/03)
|
||||
↓ итог
|
||||
├── ~/.fm-watchdog/quality.log (append-only)
|
||||
├── ~/.fm-watchdog/quality-state.json (per-step consecutive_fail)
|
||||
├── ~/.fm-watchdog/quality-history.jsonl (1 line/run, 400 lines max)
|
||||
├── ~/.fm-watchdog/quality-perf-baseline.json (median + samples)
|
||||
└── docs/quality-status.md + README badge (через quality-dashboard.py)
|
||||
↓ если consecutive_fail ≥ 2
|
||||
├── ~/.fm-watchdog/incident-{ts}-{step}.txt
|
||||
└── ~/.fm-watchdog/queue/0000-incident-{ts}-{step}.txt (для fm-watchdog.sh ротации)
|
||||
|
||||
Hangfire job `prune-quality-test-orgs` (cron 30 2 * * * UTC):
|
||||
- находит orgs `quality-%` старше 24h
|
||||
- итеративно DELETE'ит из всех tenant-таблиц
|
||||
- чистит Identity+OpenIddict по email-pattern
|
||||
- DELETE users + organizations
|
||||
```
|
||||
|
||||
## Что найдено и зафиксировано
|
||||
|
||||
- **Stage perf шумит на старте**: первые 1-2 запроса к /api/sales/retail/stats
|
||||
доходят до 400ms (cold DB cache), затем 200-250ms steady. Поэтому
|
||||
baseline стал median-based, не min-based.
|
||||
- **POST product требует non-trivial payload**: barcodes (массив объектов
|
||||
с code/type/isPrimary), prices (массив с priceTypeId/amount/currencyId),
|
||||
productGroupId — все обязательные. Зафиксил в watchdog: вытягивает
|
||||
refs из дефолтных значений orga через `/api/catalog/{units,product-groups,price-types,currencies}`.
|
||||
- **FK dependency между employees и employee_roles**: первый DELETE на
|
||||
`employee_roles` валится на employees.RoleId FK. Решил итеративным
|
||||
ретраем (10 проходов) — гибкая стратегия, не привязанная к ручному
|
||||
порядку, новые tenant-таблицы автоматом подцепятся.
|
||||
|
||||
## Метрики
|
||||
|
||||
| | До Sprint 25 | После | Δ |
|
||||
|---|---|---|---|
|
||||
| **Watchdog scripts** | `fm-watchdog.sh` + `nightly-verify.sh` + `nightly-perf-check.sh` | + `quality-watchdog.sh` + `quality-dashboard.py` | +2 |
|
||||
| **Cron entries** | 2 | 3 | +1 |
|
||||
| **Hangfire recurring jobs** | 11 | 12 | +1 (prune-quality-test-orgs) |
|
||||
| **Hourly smoke шаги** | (нет hourly smoke) | 8 шагов | new |
|
||||
| **Docs (.md в `docs/`)** | 60 | 62 | +2 (quality-status.md, sprint25-progress.md) |
|
||||
| **README badges** | 5 | 6 | +1 (quality 🟢/🟡/🔴) |
|
||||
| **Auto-fix integration** | (нет) | incident → fm-watchdog queue | new |
|
||||
|
||||
## Итог
|
||||
|
||||
8/8 ✓. 3 cron-trigger'd runs все green. `~/.fm-watchdog/DONE` создан
|
||||
после ratiosit's финального прогона.
|
||||
239
scripts/quality-dashboard.py
Executable file
239
scripts/quality-dashboard.py
Executable file
|
|
@ -0,0 +1,239 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sprint 25: рендерер docs/quality-status.md из ~/.fm-watchdog/quality-history.jsonl.
|
||||
|
||||
Запускается из ~/quality-watchdog.sh после каждого прогона.
|
||||
Также обновляет статус-бейдж в README.md (🟢/🟡/🔴).
|
||||
|
||||
Usage: python3 scripts/quality-dashboard.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
HISTORY = Path.home() / ".fm-watchdog" / "quality-history.jsonl"
|
||||
STATE = Path.home() / ".fm-watchdog" / "quality-state.json"
|
||||
BASELINE = Path.home() / ".fm-watchdog" / "quality-perf-baseline.json"
|
||||
DASHBOARD = REPO / "docs" / "quality-status.md"
|
||||
README = REPO / "README.md"
|
||||
|
||||
STEP_NAMES = {
|
||||
"health": "/health/ready",
|
||||
"auth_me": "signup→login→/api/me",
|
||||
"products": "GET /api/catalog/products",
|
||||
"ui_flow": "Playwright UI (product CRUD)",
|
||||
"metrics": "/metrics (Prometheus)",
|
||||
"signalr": "/hubs/notifications/negotiate",
|
||||
"multi_tenant": "Multi-tenant isolation",
|
||||
"perf": "Performance p95 vs baseline",
|
||||
}
|
||||
|
||||
|
||||
def load_history() -> list[dict]:
|
||||
if not HISTORY.exists():
|
||||
return []
|
||||
out = []
|
||||
for line in HISTORY.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if not STATE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(STATE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def load_baseline() -> dict:
|
||||
"""Возвращает dict {endpoint: median_ms} из нового формата с samples/median."""
|
||||
if not BASELINE.exists():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(BASELINE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
if "median" in raw:
|
||||
return raw["median"]
|
||||
# Совместимость со старым форматом (просто {key: value}).
|
||||
return {k: v for k, v in raw.items() if isinstance(v, (int, float))}
|
||||
|
||||
|
||||
def parse_ts(s: str) -> datetime:
|
||||
# python <3.11 не парсит +03:00 в datetime.fromisoformat для всех вариантов; чистим.
|
||||
s = s.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except Exception:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def determine_color(history: list[dict]) -> str:
|
||||
"""🟢 если последний run all green; 🟡 если есть red но <consec 2;
|
||||
🔴 если есть consecutive-2+ red (incident-уровень)."""
|
||||
if not history:
|
||||
return "🟡"
|
||||
last = history[-1]
|
||||
if not last.get("red"):
|
||||
return "🟢"
|
||||
# Считаем consecutive_fail из state.
|
||||
state = load_state()
|
||||
for step in last.get("red", []):
|
||||
if int(state.get(step, {}).get("consecutive_fail", 0)) >= 2:
|
||||
return "🔴"
|
||||
return "🟡"
|
||||
|
||||
|
||||
def render_dashboard(history: list[dict], state: dict, baseline: dict) -> str:
|
||||
last = history[-1] if history else None
|
||||
color = determine_color(history)
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
out = ["# Quality status", "", f"_Обновлено: {now} · auto-gen из `~/quality-watchdog.sh`_", ""]
|
||||
out.append(f"## {color} Текущий статус")
|
||||
out.append("")
|
||||
if last:
|
||||
out.append(f"**Последний прогон:** `{last['ts']}` ")
|
||||
out.append(f"**Зелёных шагов:** {len(last.get('green', []))}/{len(STEP_NAMES)} ")
|
||||
out.append(f"**Красных шагов:** {len(last.get('red', []))} ")
|
||||
else:
|
||||
out.append("_История пока пуста — quality-watchdog ещё не запускался cron'ом._")
|
||||
out.append("")
|
||||
|
||||
# Step-by-step table.
|
||||
out.append("## Шаги smoke-suite")
|
||||
out.append("")
|
||||
out.append("| Шаг | Статус | Последнее изменение | Consecutive fail |")
|
||||
out.append("|---|---|---|---|")
|
||||
last_green = set(last.get("green", [])) if last else set()
|
||||
last_red = set(last.get("red", [])) if last else set()
|
||||
for key, label in STEP_NAMES.items():
|
||||
if key in last_green:
|
||||
icon = "🟢"
|
||||
elif key in last_red:
|
||||
icon = "🔴"
|
||||
else:
|
||||
icon = "⚪"
|
||||
s = state.get(key, {})
|
||||
recent = s.get("last_green") if icon == "🟢" else s.get("last_red", "—")
|
||||
cf = s.get("consecutive_fail", 0)
|
||||
out.append(f"| {label} | {icon} | `{recent or '—'}` | {cf} |")
|
||||
out.append("")
|
||||
|
||||
# Red detail для текущего прогона.
|
||||
if last and last.get("details"):
|
||||
out.append("## Детали падений (последний прогон)")
|
||||
out.append("")
|
||||
for d in last["details"]:
|
||||
out.append(f"- `{d}`")
|
||||
out.append("")
|
||||
|
||||
# Performance baseline.
|
||||
if baseline:
|
||||
out.append("## Performance baseline (p95, ms)")
|
||||
out.append("")
|
||||
out.append("| Endpoint | p95 (ms) |")
|
||||
out.append("|---|---|")
|
||||
for k, v in sorted(baseline.items()):
|
||||
# _api_me → /api/me. Грубо, восстанавливаем читаемое имя.
|
||||
pretty = k.replace("_", "/")
|
||||
out.append(f"| `{pretty}` | {v} |")
|
||||
out.append("")
|
||||
out.append("_Регрессия = текущий p95 >50% от baseline. Baseline обновляется только когда регрессии нет (берёт min)._")
|
||||
out.append("")
|
||||
|
||||
# История за неделю.
|
||||
week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
week_runs = []
|
||||
for h in history:
|
||||
ts = parse_ts(h["ts"])
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
if ts >= week_ago:
|
||||
week_runs.append(h)
|
||||
|
||||
out.append("## История за 7 дней")
|
||||
out.append("")
|
||||
if not week_runs:
|
||||
out.append("_Нет прогонов за последнюю неделю._")
|
||||
else:
|
||||
red_runs = [h for h in week_runs if h.get("red")]
|
||||
out.append(f"**Прогонов:** {len(week_runs)} ")
|
||||
out.append(f"**С красным:** {len(red_runs)} ")
|
||||
out.append(f"**Green-ratio:** {((len(week_runs)-len(red_runs))*100)//max(1,len(week_runs))}% ")
|
||||
out.append("")
|
||||
if red_runs:
|
||||
out.append("### Прогоны с красным шагом")
|
||||
out.append("")
|
||||
out.append("| Время | Красные шаги |")
|
||||
out.append("|---|---|")
|
||||
for h in red_runs[-20:]:
|
||||
out.append(f"| `{h['ts']}` | {', '.join(h.get('red', []))} |")
|
||||
out.append("")
|
||||
|
||||
# Последние 24 прогона как sparkline.
|
||||
out.append("## Последние 24 прогона")
|
||||
out.append("")
|
||||
spark = "".join("🟢" if not h.get("red") else "🔴" for h in history[-24:])
|
||||
out.append(f"`{spark or '— нет данных —'}`")
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("")
|
||||
out.append("Скрипт: `~/quality-watchdog.sh` (cron `0 * * * *`). ")
|
||||
out.append("Источник: `~/.fm-watchdog/quality-history.jsonl`. ")
|
||||
out.append("Sprint 25 — autonomous continuous quality monitoring.")
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
BADGE_RE = re.compile(r"<!-- quality-badge -->.*?<!-- /quality-badge -->", re.S)
|
||||
|
||||
|
||||
def update_readme_badge(color: str) -> None:
|
||||
if not README.exists():
|
||||
return
|
||||
txt = README.read_text()
|
||||
badge = f"<!-- quality-badge --> {color} **Quality:** [`docs/quality-status.md`](docs/quality-status.md) <!-- /quality-badge -->"
|
||||
if BADGE_RE.search(txt):
|
||||
new_txt = BADGE_RE.sub(badge, txt)
|
||||
else:
|
||||
# Вставляем после первого заголовка.
|
||||
lines = txt.splitlines()
|
||||
for i, ln in enumerate(lines):
|
||||
if ln.startswith("# "):
|
||||
lines.insert(i + 1, "")
|
||||
lines.insert(i + 2, badge)
|
||||
break
|
||||
new_txt = "\n".join(lines)
|
||||
if new_txt != txt:
|
||||
README.write_text(new_txt)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
history = load_history()
|
||||
state = load_state()
|
||||
baseline = load_baseline()
|
||||
DASHBOARD.parent.mkdir(parents=True, exist_ok=True)
|
||||
DASHBOARD.write_text(render_dashboard(history, state, baseline))
|
||||
color = determine_color(history)
|
||||
update_readme_badge(color)
|
||||
print(f"dashboard updated: {DASHBOARD} (status={color})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -63,6 +63,15 @@ public Task StartAsync(CancellationToken ct)
|
|||
cronExpression: cronTokens,
|
||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
|
||||
// Sprint 25: чистка quality-{epoch}-* test-org, созданных
|
||||
// ~/quality-watchdog.sh hourly. Default 02:30 UTC, до утреннего prune-блока.
|
||||
var cronQualityOrgs = _cfg["Hangfire:Cron:PruneQualityTestOrgs"] ?? "30 2 * * *";
|
||||
_jobs.AddOrUpdate<HousekeepingJobs>(
|
||||
recurringJobId: "prune-quality-test-orgs",
|
||||
methodCall: j => j.PruneQualityTestOrgsAsync(CancellationToken.None),
|
||||
cronExpression: cronQualityOrgs,
|
||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
|
||||
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
|
||||
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
|
||||
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час
|
||||
|
|
|
|||
|
|
@ -134,4 +134,124 @@ public async Task<int> PruneRevokedRefreshTokensAsync(CancellationToken ct = def
|
|||
deleted, threshold);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>Sprint 25: hard-delete тестовых организаций, которые
|
||||
/// создаёт <c>~/quality-watchdog.sh</c> по конвенции
|
||||
/// <c>quality-{epoch}-A/B</c>. Stage-БД иначе разрастается на
|
||||
/// ~2 org/час = ~50/день. Делается строго по имени-шаблону, чтобы
|
||||
/// никогда не задеть реальные орги (даже архивные).
|
||||
///
|
||||
/// Реализация: динамически находим все таблицы с колонкой
|
||||
/// <c>OrganizationId</c> через information_schema, и DELETE'им из
|
||||
/// них строки для match'ащих org-id. Заказ в одной транзакции через
|
||||
/// CTE с DEFERRABLE FK не понадобится — PG умеет резолвить cascade
|
||||
/// если делать DELETE в правильном порядке (сначала дочерние,
|
||||
/// потом родительские); используем общий приём «удаляем по списку
|
||||
/// org-id из всех таблиц, последний — Organizations».
|
||||
///
|
||||
/// Порог давности — <c>Cleanup:QualityTestOrgHours</c> (default 24)
|
||||
/// чтобы свежие диагностические прогоны не зачищались до того, как
|
||||
/// инженер посмотрит лог. Возвращает количество удалённых
|
||||
/// организаций.</summary>
|
||||
public async Task<int> PruneQualityTestOrgsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var minHours = _cfg.GetValue("Cleanup:QualityTestOrgHours", 24);
|
||||
var threshold = DateTime.UtcNow.AddHours(-minHours);
|
||||
|
||||
var ids = await _db.Organizations
|
||||
.IgnoreQueryFilters()
|
||||
.Where(o => o.Name.StartsWith("quality-") && o.CreatedAt < threshold)
|
||||
.Select(o => o.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (ids.Count == 0)
|
||||
{
|
||||
_log.LogInformation("Hangfire/PruneQualityTestOrgs: нет кандидатов старше {Threshold:O}", threshold);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Динамически собираем DELETE для каждой таблицы с колонкой "OrganizationId".
|
||||
// Имена таблиц: snake_case (наши доменные) + PascalCase (Identity/OpenIddict).
|
||||
// Идём по public-схеме, исключаем сам organizations (удаляем в конце).
|
||||
// Тестовые юзеры распознаются по email LIKE 'quality-%@test-fm.local'.
|
||||
//
|
||||
// FK ordering: между tenant-таблицами есть ссылки (employees → employee_roles),
|
||||
// поэтому делаем итеративные проходы: на каждом проходе пытаемся DELETE
|
||||
// из каждой таблицы; если FK-violation — пропускаем, на след. проходе
|
||||
// зависимые строки уже удалены и DELETE проходит. Максимум 10 проходов
|
||||
// (depth FK графа гораздо меньше).
|
||||
var sql = $@"
|
||||
DO $do$
|
||||
DECLARE
|
||||
org_ids uuid[] := ARRAY[{string.Join(",", ids.Select(i => $"'{i}'::uuid"))}];
|
||||
tbl text;
|
||||
pass int := 0;
|
||||
remaining int;
|
||||
BEGIN
|
||||
-- 1. Итеративная очистка tenant-таблиц с OrganizationId.
|
||||
-- Пропускаем organizations и users — отдельные шаги ниже.
|
||||
FOR pass IN 1..10 LOOP
|
||||
remaining := 0;
|
||||
FOR tbl IN
|
||||
SELECT c.table_name
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
||||
WHERE c.table_schema = 'public'
|
||||
AND c.column_name = 'OrganizationId'
|
||||
AND t.table_type = 'BASE TABLE'
|
||||
AND c.table_name NOT IN ('organizations', 'users')
|
||||
LOOP
|
||||
BEGIN
|
||||
EXECUTE format('DELETE FROM public.%I WHERE ""OrganizationId"" = ANY($1)', tbl) USING org_ids;
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
remaining := remaining + 1;
|
||||
END;
|
||||
END LOOP;
|
||||
EXIT WHEN remaining = 0;
|
||||
END LOOP;
|
||||
IF remaining > 0 THEN
|
||||
RAISE WARNING 'PruneQualityTestOrgs: % таблиц остались с FK-violation после 10 проходов', remaining;
|
||||
END IF;
|
||||
|
||||
-- 2. Чистим AspNetUser* и OpenIddict-токены тестовых юзеров по email-шаблону.
|
||||
-- Эти таблицы НЕ имеют OrganizationId, идут отдельным сегментом.
|
||||
DELETE FROM ""AspNetUserRoles"" WHERE ""UserId"" IN (
|
||||
SELECT u.""Id"" FROM public.users u
|
||||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||
);
|
||||
DELETE FROM ""AspNetUserTokens"" WHERE ""UserId"" IN (
|
||||
SELECT u.""Id"" FROM public.users u
|
||||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||
);
|
||||
DELETE FROM ""AspNetUserClaims"" WHERE ""UserId"" IN (
|
||||
SELECT u.""Id"" FROM public.users u
|
||||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||
);
|
||||
DELETE FROM ""AspNetUserLogins"" WHERE ""UserId"" IN (
|
||||
SELECT u.""Id"" FROM public.users u
|
||||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||
);
|
||||
DELETE FROM ""OpenIddictTokens"" WHERE ""Subject"" IN (
|
||||
SELECT u.""Id""::text FROM public.users u
|
||||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||
);
|
||||
DELETE FROM ""OpenIddictAuthorizations"" WHERE ""Subject"" IN (
|
||||
SELECT u.""Id""::text FROM public.users u
|
||||
WHERE u.""Email"" LIKE 'quality-%@test-fm.local'
|
||||
);
|
||||
|
||||
-- 3. AppUser (public.users) — привязан через OrganizationId, чистим по нему.
|
||||
DELETE FROM public.users WHERE ""OrganizationId"" = ANY(org_ids);
|
||||
|
||||
-- 4. Сама organizations.
|
||||
DELETE FROM public.organizations WHERE ""Id"" = ANY(org_ids);
|
||||
END $do$;
|
||||
";
|
||||
|
||||
await _db.Database.ExecuteSqlRawAsync(sql, ct);
|
||||
_log.LogInformation("Hangfire/PruneQualityTestOrgs: удалено {Count} тест-org (старше {Hours}ч)",
|
||||
ids.Count, minHours);
|
||||
return ids.Count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
tests/e2e/s23-setup.ts
Normal file
27
tests/e2e/s23-setup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { login, makeClient } from './lib/api.js'
|
||||
|
||||
// Создаём 2 org'и с tokens, печатаем в формате shell env-vars.
|
||||
async function main() {
|
||||
const ts = Date.now()
|
||||
const api = makeClient()
|
||||
const orgs = []
|
||||
for (const tag of ['A', 'B']) {
|
||||
const email = `s23-${tag.toLowerCase()}-${ts}@food-market.local`
|
||||
await api.post('/api/auth/signup', {
|
||||
organizationName: `S23-${tag}-${ts}`, email, password: 'Stage12345!',
|
||||
phone: '+77001234567',
|
||||
})
|
||||
const sess = await login(email, 'Stage12345!')
|
||||
const t = makeClient(sess.accessToken)
|
||||
const me = await t.get('/api/me')
|
||||
orgs.push({ tag, token: sess.accessToken, orgId: me.data.orgId, email, userId: me.data.sub })
|
||||
}
|
||||
console.log('export S23_TS=' + ts)
|
||||
for (const o of orgs) {
|
||||
console.log(`export S23_${o.tag}_TOKEN='${o.token}'`)
|
||||
console.log(`export S23_${o.tag}_ORG='${o.orgId}'`)
|
||||
console.log(`export S23_${o.tag}_EMAIL='${o.email}'`)
|
||||
console.log(`export S23_${o.tag}_USER='${o.userId}'`)
|
||||
}
|
||||
}
|
||||
main().catch(e => { console.error('FAIL:', e.response?.status, e.response?.data ?? e.message); process.exit(1) })
|
||||
44
tests/e2e/scripts/screenshot-breadcrumbs.ts
Normal file
44
tests/e2e/scripts/screenshot-breadcrumbs.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Sprint 7 item 6 — визуальная проверка breadcrumbs на edit-странице.
|
||||
* Логинимся, seed-demo, открываем первый товар, скриншот шапки.
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { makeClient, login } from '../lib/api.js'
|
||||
|
||||
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||
const TS = Date.now()
|
||||
const EMAIL = `crumb-shot-${TS}@food-market.local`
|
||||
const PASS = 'CrumbShot12345!'
|
||||
|
||||
async function ensureSession() {
|
||||
const api = makeClient()
|
||||
const r = await api.post('/api/auth/signup', {
|
||||
email: EMAIL, password: PASS,
|
||||
organizationName: `CrumbShot ${TS}`, phone: '+77011190001', plan: 'start',
|
||||
})
|
||||
if (r.status !== 200) throw new Error(`signup ${r.status}: ${JSON.stringify(r.data)}`)
|
||||
const sess = await login(EMAIL, PASS)
|
||||
await makeClient(sess.accessToken).post('/api/admin/seed-demo', {})
|
||||
return sess
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sess = await ensureSession()
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 800 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(`${BASE}/`)
|
||||
await page.evaluate(({ token }) => localStorage.setItem('fm.access_token', token), { token: sess.accessToken })
|
||||
|
||||
await page.goto(`${BASE}/catalog/products`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.locator('tbody tr').first().waitFor({ timeout: 12000 })
|
||||
await page.locator('tbody tr').first().click()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForSelector('nav[aria-label="Хлебные крошки"]', { timeout: 8000 })
|
||||
await page.screenshot({ path: `reports/breadcrumbs-product-${TS}.png`, fullPage: false })
|
||||
console.log(`[shot] saved → reports/breadcrumbs-product-${TS}.png`)
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1) })
|
||||
Loading…
Reference in a new issue