commit f49c0b5badb3ce4238c4bc33554864df52f22138 Author: oliver Date: Sun May 3 23:43:21 2026 +0800 feat: initialize netx ops tool repository Set up netx as a standalone git-managed project with isolated Python dependencies, alarm ingestion/query APIs, oclaw bridge integration, and ops-focused UI enhancements including history and raw field access. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..630bd79 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +NETX_DATABASE_URL=postgresql+psycopg://netx:netx@127.0.0.1:5432/netx +NETX_HOST=127.0.0.1 +NETX_PORT=8890 +NETX_VENDOR=ZTE +NETX_SOURCE_TYPE=gateway_export_excel +NETX_PARSER_CONFIG=netx_api/config/parsers/zte_alarm_monitor_v1.yaml +NETX_OCLAW_ANALYZE_URL=http://127.0.0.1:8787/admin/api/ops-ai/analyze-sync +NETX_OCLAW_ANALYZE_TOKEN= +NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..839a397 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +.env +build/ +dist/ diff --git a/PROD_MIN_CHECKLIST.md b/PROD_MIN_CHECKLIST.md new file mode 100644 index 0000000..9a23004 --- /dev/null +++ b/PROD_MIN_CHECKLIST.md @@ -0,0 +1,28 @@ +# netx Production Minimum Checklist + +## Security +- Replace local shared token with a strong secret and rotate it regularly. +- Keep `.env` and `oclaw/_local/system.env` out of Git (already ignored). +- Restrict access to `127.0.0.1` or internal network only. + +## Runtime +- Ensure PostgreSQL backup policy exists (daily logical backup + retention). +- Run `oclaw` and `netx` under process managers (systemd/Windows service/pm2 equivalent). +- Enable auto-restart and startup-at-boot for both services. + +## Observability +- Health checks: + - `oclaw`: `/admin/api/ops-ai/health` (with Bearer token) + - `netx`: `/v1/integrations/status` +- Alert when `oclaw_bridge.status != up` for more than 2 polling cycles. +- Alert when `db.status != up` or `latency_ms` exceeds threshold. + +## Operations +- Keep one documented restart order: + 1) PostgreSQL + 2) `oclaw` + 3) `netx` +- Validate after restart: + - `GET /v1/integrations/status` returns all major components as `up`. +- Keep parser config and importer changes versioned and reviewed before release. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4ce9b8 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# netx + +Independent operations tool (web + MCP) for alarm-centric workflows. + +## Phase 1 scope + +- Import ZTE Alarm Monitor Excel +- Normalize and store alarms into an isolated PostgreSQL +- Query and aggregate alarms via REST API +- Expose the same read capabilities through MCP stdio tools +- Quick diagnostics endpoint (`/v1/diagnostics`) +- AP analysis bridge to oclaw (`/v1/ap/analyze`) + +## Run locally + +1. Install dependencies: + +```powershell +python -m pip install -r requirements.txt +``` + +2. Set environment variables: + +```powershell +$env:NETX_DATABASE_URL = "postgresql+psycopg://netx:netx@127.0.0.1:5432/netx" +$env:NETX_HOST = "127.0.0.1" +$env:NETX_PORT = "8890" +``` + +Or create a local `.env` (recommended for persistent local run): + +```env +NETX_DATABASE_URL=postgresql+psycopg://postgres:admin123@127.0.0.1:5432/netx +NETX_OCLAW_ANALYZE_TOKEN=admin123 +NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health +``` + +3. Start API: + +```powershell +python -m netx_api.main +``` + +Or use automation scripts: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\start_netx.ps1 -SkipInstall -Background +``` + +Start API + Vite together: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\start_netx.ps1 -SkipInstall -Background -WithWeb +``` + +Stop: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\stop_netx.ps1 -Force +``` + +Primary web UI (Vite): `http://127.0.0.1:5173/` +API base: `http://127.0.0.1:8890/` + +4. Optional: start MCP server (for oclaw integration): + +```powershell +python -m netx_api.mcp_server +``` + +Or use MCP install payload: + +- `netx/mcp_install_payload.json` + +## Industrial UI (Vite + React + TS) + +Frontend project lives in: + +- `netx/web` + +Run: + +```powershell +cd web +npm install +npm run dev +``` + +Dev server: `http://127.0.0.1:5173` +`vite.config.ts` proxies `/v1/*` to `http://127.0.0.1:8890`. + +`8890` is API-only and no longer serves the main UI page. + +## Useful API endpoints + +- `POST /v1/alarms/import` +- `GET /v1/batches` +- `GET /v1/batches/{batch_id}` +- `GET /v1/batches/{batch_id}/errors.csv` +- `GET /v1/alarms` +- `GET /v1/alarms/aggregate` +- `GET /v1/diagnostics?batch_id=...` +- `GET /v1/integrations/status` +- `POST /v1/ap/analyze` + +Web UI now includes an **AI analyze panel** that calls `/v1/ap/analyze` directly. + +## AP bridge auth (optional but recommended) + +Set the same token on both sides: + +- `oclaw` env: `OCLAW_OPS_AI_SHARED_TOKEN=` +- `netx` env: `NETX_OCLAW_ANALYZE_TOKEN=` + +Optional: configure oclaw health check endpoint (defaults shown in `.env.example`): + +- `NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health` + +`analyze-sync` runs a full LLM + gateway turn in oclaw; if netx reports timeout errors, raise the read timeout (seconds): + +- `NETX_OCLAW_ANALYZE_READ_TIMEOUT_SEC=180` (default `180`; was effectively ~35s before) + +## Key sample file + +Phase 1 parser is tuned for: + +`D:/project/chatgpt/fm-active-Alarm Monitor-ZTE-CHEN-20260423100105/fm-active-Alarm Monitor-ZTE-CHEN-20260423100105.xlsx` diff --git a/mcp_install_payload.json b/mcp_install_payload.json new file mode 100644 index 0000000..32e71ce --- /dev/null +++ b/mcp_install_payload.json @@ -0,0 +1,15 @@ +{ + "source_type": "local", + "source_ref": "netx-local-mcp", + "server_id": "netx-local", + "version": "0.1.0", + "entry_command": "python", + "entry_args": [ + "D:/project/chatgpt/netx/netx_api/mcp_server.py" + ], + "env_schema": {}, + "required_permissions": [], + "risk_level": "medium", + "enabled": true, + "timeout_s": 30 +} diff --git a/netx_api/__init__.py b/netx_api/__init__.py new file mode 100644 index 0000000..a05eb9a --- /dev/null +++ b/netx_api/__init__.py @@ -0,0 +1,3 @@ +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/netx_api/ap_client.py b/netx_api/ap_client.py new file mode 100644 index 0000000..03b2e6d --- /dev/null +++ b/netx_api/ap_client.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from .config import settings + + +def _analyze_httpx_timeout() -> httpx.Timeout: + read_s = max(30.0, float(settings.oclaw_analyze_read_timeout_sec)) + connect_s = max(3.0, float(settings.oclaw_connect_timeout_sec)) + return httpx.Timeout( + connect=connect_s, + read=read_s, + write=min(120.0, read_s), + pool=connect_s, + ) + + +def analyze_with_oclaw(payload: dict[str, Any]) -> dict[str, Any]: + headers = {"content-type": "application/json", "accept": "application/json"} + token = str(settings.oclaw_analyze_token or "").strip() + if token: + headers["authorization"] = f"Bearer {token}" + with httpx.Client(timeout=_analyze_httpx_timeout()) as client: + resp = client.post(settings.oclaw_analyze_url, json=payload, headers=headers) + text = resp.text + if not resp.is_success: + raise RuntimeError(f"oclaw analyze failed: {resp.status_code} {text[:300]}") + if not text: + return {} + return resp.json() + + +def health_with_oclaw() -> dict[str, Any]: + headers = {"accept": "application/json"} + token = str(settings.oclaw_analyze_token or "").strip() + if token: + headers["authorization"] = f"Bearer {token}" + health_s = max(3.0, float(settings.oclaw_health_timeout_sec)) + with httpx.Client(timeout=health_s) as client: + resp = client.get(settings.oclaw_health_url, headers=headers) + status = int(resp.status_code) + text = resp.text + if not resp.is_success: + raise RuntimeError(f"oclaw health failed: {status} {text[:200]}") + if not text: + return {"status_code": status, "data": {}} + return {"status_code": status, "data": resp.json()} diff --git a/netx_api/config.py b/netx_api/config.py new file mode 100644 index 0000000..67ef8a1 --- /dev/null +++ b/netx_api/config.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_prefix="NETX_", extra="ignore") + + database_url: str = "postgresql+psycopg://netx:netx@127.0.0.1:5432/netx" + host: str = "127.0.0.1" + port: int = 8890 + vendor: str = "ZTE" + source_type: str = "gateway_export_excel" + parser_config: str = "netx_api/config/parsers/zte_alarm_monitor_v1.yaml" + frontend_url: str = "http://127.0.0.1:5173" + oclaw_analyze_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/analyze-sync" + oclaw_analyze_token: str = "" + oclaw_health_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/health" + # analyze-sync runs a full gateway + LLM turn; 35s is often too short (was hardcoded). + oclaw_connect_timeout_sec: float = 15.0 + oclaw_analyze_read_timeout_sec: float = 180.0 + oclaw_health_timeout_sec: float = 8.0 + + +settings = Settings() diff --git a/netx_api/config/parsers/zte_alarm_monitor_v1.yaml b/netx_api/config/parsers/zte_alarm_monitor_v1.yaml new file mode 100644 index 0000000..f8f5f36 --- /dev/null +++ b/netx_api/config/parsers/zte_alarm_monitor_v1.yaml @@ -0,0 +1,95 @@ +parser_version: zte_alarm_monitor_v1 +dict_version: v1 + +aliases: + alarm_time: + - Occurrence Time + - Alarm Time + - Occur Time + - Event Time + - 告警时间 + - 发生时间 + clear_time: + - Clear Time + - Restore Time + - 恢复时间 + - 清除时间 + severity_raw: + - Alarm Severity + - Severity + - Level + - 告警级别 + - 级别 + alarm_code: + - Alarm Code + - Alarm ID + - Alarm Code Name + - 告警码 + - 告警ID + ne_name: + - ME + - NE Name + - Node Name + - 网元名称 + - 设备名称 + ne_id: + - Aid + - NE ID + - 网元ID + - 设备ID + site_name: + - Site + - Site Name + - 站点 + description: + - Position + - Description + - Additional Info + - 告警描述 + - 详细信息 + relevancy: + - Relevancy + - 相关性 + l3vpn_peer_ne: + - L3VPN Peer NE + - 对端网元 + service: + - Service + - 业务 + affected_client_service_number: + - Affected Client Service Number + - 影响用户数 + intermittence_count: + - Intermittence Count + - 间歇次数 + me_level: + - ME Level + - 网元级别 + ack_state: + - Ack State + - Ack + - 确认状态 + clear_state: + - Clear Type + - Alarm State + - 告警状态 + - 清除状态 + +severity_map: + critical: critical + major: major + minor: minor + warning: warning + info: info + 紧急: critical + 危急: critical + 一级: critical + 重要: major + 严重: major + 二级: major + 次要: minor + 三级: minor + 警告: warning + 四级: warning + 信息: info + 通知: info diff --git a/netx_api/db.py b/netx_api/db.py new file mode 100644 index 0000000..d00422a --- /dev/null +++ b/netx_api/db.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from .config import settings + + +class Base(DeclarativeBase): + pass + + +engine = create_engine(settings.database_url, future=True, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) diff --git a/netx_api/importer.py b/netx_api/importer.py new file mode 100644 index 0000000..7c17905 --- /dev/null +++ b/netx_api/importer.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from io import BytesIO +from typing import Any + +import pandas as pd +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from .config import settings +from .models import AlarmBatch, AlarmNorm, ImportErrorRow +from .parser_config import ParserConfig + + +def _dt(v: Any) -> datetime | None: + if v is None: + return None + text = str(v).strip() + if not text or text.lower() == "nan": + return None + parsed = pd.to_datetime(text, errors="coerce") + if pd.isna(parsed): + return None + dt = parsed.to_pydatetime() + # Convention: Excel source time is local timezone time. + # Convert to UTC before persisting; keep DB as naive UTC for compatibility. + if dt.tzinfo is None: + local_tz = datetime.now().astimezone().tzinfo or timezone.utc + dt = dt.replace(tzinfo=local_tz) + else: + dt = dt.astimezone(timezone.utc) + return dt.astimezone(timezone.utc).replace(tzinfo=None) + + +def _str(v: Any) -> str: + if v is None: + return "" + text = str(v).strip() + if text.lower() == "nan": + return "" + return text + + +def _int(v: Any) -> int: + if v is None: + return 0 + text = str(v).strip() + if not text or text.lower() == "nan": + return 0 + try: + return int(float(text)) + except Exception: + return 0 + + +def import_alarm_excel(db: Session, filename: str, content: bytes, parser: ParserConfig) -> AlarmBatch: + df = pd.read_excel(BytesIO(content)) + headers = [str(h) for h in df.columns] + + col_alarm_time = parser.resolve_col(headers, "alarm_time") + col_clear_time = parser.resolve_col(headers, "clear_time") + col_severity = parser.resolve_col(headers, "severity_raw") + col_alarm_code = parser.resolve_col(headers, "alarm_code") + col_ne_name = parser.resolve_col(headers, "ne_name") + col_ne_id = parser.resolve_col(headers, "ne_id") + col_site_name = parser.resolve_col(headers, "site_name") + col_desc = parser.resolve_col(headers, "description") + col_ack = parser.resolve_col(headers, "ack_state") + col_state = parser.resolve_col(headers, "clear_state") + col_relevancy = parser.resolve_col(headers, "relevancy") + col_l3vpn_peer_ne = parser.resolve_col(headers, "l3vpn_peer_ne") + col_service = parser.resolve_col(headers, "service") + col_affected = parser.resolve_col(headers, "affected_client_service_number") + col_intermittence = parser.resolve_col(headers, "intermittence_count") + col_me_level = parser.resolve_col(headers, "me_level") + + batch = AlarmBatch( + source_file=filename, + parser_version=parser.parser_version, + dict_version=parser.dict_version, + total_rows=int(len(df.index)), + status="processing", + ) + db.add(batch) + db.flush() + + success_rows = 0 + failed_rows = 0 + + for idx, row in df.iterrows(): + row_no = int(idx) + 2 # Excel header is row 1 + alarm_time = _dt(row.get(col_alarm_time)) if col_alarm_time else None + raw_severity = _str(row.get(col_severity)) if col_severity else "" + alarm_code = _str(row.get(col_alarm_code)) if col_alarm_code else "" + ne_name = _str(row.get(col_ne_name)) if col_ne_name else "" + ne_id = _str(row.get(col_ne_id)) if col_ne_id else "" + description = _str(row.get(col_desc)) if col_desc else "" + relevancy = _str(row.get(col_relevancy)) if col_relevancy else "" + l3vpn_peer_ne = _str(row.get(col_l3vpn_peer_ne)) if col_l3vpn_peer_ne else "" + service = _str(row.get(col_service)) if col_service else "" + affected = _int(row.get(col_affected)) if col_affected else 0 + intermittence = _int(row.get(col_intermittence)) if col_intermittence else 0 + me_level = _str(row.get(col_me_level)) if col_me_level else "" + + if not alarm_time: + failed_rows += 1 + db.add( + ImportErrorRow( + batch_id=batch.batch_id, + row_no=row_no, + reason="missing_or_invalid_alarm_time", + raw_json=json.dumps(row.to_dict(), ensure_ascii=False, default=str), + ) + ) + continue + if not (ne_name or ne_id): + failed_rows += 1 + db.add( + ImportErrorRow( + batch_id=batch.batch_id, + row_no=row_no, + reason="missing_ne_name_and_ne_id", + raw_json=json.dumps(row.to_dict(), ensure_ascii=False, default=str), + ) + ) + continue + db.add( + AlarmNorm( + batch_id=batch.batch_id, + row_no=row_no, + alarm_time=alarm_time, + clear_time=_dt(row.get(col_clear_time)) if col_clear_time else None, + severity_raw=raw_severity, + severity_norm=parser.normalize_severity(raw_severity), + ne_name=ne_name, + ne_id=ne_id, + site_name=_str(row.get(col_site_name)) if col_site_name else "", + alarm_code=alarm_code, + alarm_name="", + description=description, + ack_state=_str(row.get(col_ack)) if col_ack else "", + clear_state=_str(row.get(col_state)) if col_state else "", + relevancy=relevancy, + l3vpn_peer_ne=l3vpn_peer_ne, + service=service, + affected_client_service_number=affected, + intermittence_count=intermittence, + me_level=me_level, + vendor=settings.vendor, + source_type=settings.source_type, + source_file=filename, + raw_json=json.dumps(row.to_dict(), ensure_ascii=False, default=str), + ) + ) + success_rows += 1 + + batch.success_rows = success_rows + batch.failed_rows = failed_rows + batch.status = "done" + db.commit() + db.refresh(batch) + return batch + + +def query_alarms( + db: Session, + *, + batch_id: str | None, + severity: str | None, + alarm_code: str | None, + ne_name: str | None, + page: int, + page_size: int, +) -> tuple[int, list[AlarmNorm]]: + stmt = select(AlarmNorm) + if batch_id: + stmt = stmt.where(AlarmNorm.batch_id == batch_id) + if severity: + stmt = stmt.where(AlarmNorm.severity_norm == severity) + if alarm_code: + stmt = stmt.where(AlarmNorm.alarm_code.contains(alarm_code)) + if ne_name: + stmt = stmt.where(AlarmNorm.ne_name.contains(ne_name)) + total = int(db.scalar(select(func.count()).select_from(stmt.subquery())) or 0) + rows = list( + db.scalars( + stmt.order_by(AlarmNorm.alarm_time.desc()).offset((page - 1) * page_size).limit(page_size) + ) + ) + return total, rows + + +def aggregate_alarms(db: Session, *, group_by: str, batch_id: str | None) -> list[tuple[str, int]]: + if group_by not in {"severity_norm", "alarm_code", "ne_name"}: + raise ValueError("unsupported_group_by") + col = getattr(AlarmNorm, group_by) + stmt = select(col, func.count()).group_by(col).order_by(func.count().desc()) + if batch_id: + stmt = stmt.where(AlarmNorm.batch_id == batch_id) + return [(str(key or ""), int(count)) for key, count in db.execute(stmt).all()] diff --git a/netx_api/main.py b/netx_api/main.py new file mode 100644 index 0000000..820d6f3 --- /dev/null +++ b/netx_api/main.py @@ -0,0 +1,670 @@ +from __future__ import annotations + +import csv +import json +from datetime import datetime, timezone +from io import StringIO +import time +import re +from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile +from fastapi.responses import Response +from sqlalchemy import text as sql_text +from sqlalchemy.orm import Session +from typing import Any +import uvicorn + +from .ap_client import analyze_with_oclaw, health_with_oclaw +from .config import settings +from .db import Base, SessionLocal, engine +from .importer import aggregate_alarms, import_alarm_excel, query_alarms +from .models import AiAnalyzeHistory, AlarmBatch, AlarmNorm, ImportErrorRow +from .models import ImportJob +from .parser_config import load_parser_config +from .schemas import ( + AlarmAggregateBucket, + AlarmAggregateResponse, + AiAnalyzeHistoryItem, + AiAnalyzeHistoryResponse, + AlarmItem, + AlarmQueryResponse, + BatchSummary, + ImportJobItem, + ImportJobListResponse, +) + +app = FastAPI(title="netx ops tool", version="0.1.0") +parser_cfg = load_parser_config() + +_SQL_FORBIDDEN_RE = re.compile( + r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|call|copy|vacuum|analyze)\b", + flags=re.IGNORECASE, +) + + +def _ensure_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + # All timestamps are stored as UTC in DB (naive). Treat naive as UTC. + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + try: + return dt.astimezone(timezone.utc) + except Exception: + return dt + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +@app.post("/v1/sql/query") +def sql_query(payload: dict[str, Any] | None = None, db: Session = Depends(get_db)) -> dict: + """ + Read-only SQL query endpoint for AI power users. + + Safety constraints: + - SELECT only, single statement (no ';') + - forbid DDL/DML keywords + - enforce max rows (server-side LIMIT wrapper) + - require batch_id param and require SQL contains ':batch_id' + """ + payload = payload or {} + sql = str(payload.get("sql") or "").strip() + batch_id = str(payload.get("batch_id") or "").strip() + limit = int(payload.get("limit") or 200) + limit = max(1, min(limit, 2000)) + if not sql: + raise HTTPException(status_code=400, detail="sql_required") + if ";" in sql: + raise HTTPException(status_code=400, detail="single_statement_only") + low = sql.lower().lstrip() + if not low.startswith("select"): + raise HTTPException(status_code=400, detail="select_only") + if _SQL_FORBIDDEN_RE.search(sql): + raise HTTPException(status_code=400, detail="forbidden_keyword") + if not batch_id: + raise HTTPException(status_code=400, detail="batch_id_required") + if ":batch_id" not in sql: + raise HTTPException(status_code=400, detail="batch_id_param_required(:batch_id)") + wrapped = f"select * from ({sql}) as q limit {limit}" + try: + res = db.execute(sql_text(wrapped), {"batch_id": batch_id}) + cols = list(res.keys()) + raw_rows = res.fetchall() + rows: list[list[Any]] = [] + for r in raw_rows: + out_row: list[Any] = [] + for v in list(r): + if isinstance(v, datetime): + out_row.append(((_ensure_utc(v) or v).isoformat().replace("+00:00", "Z"))) + else: + out_row.append(v) + rows.append(out_row) + return {"ok": True, "columns": cols, "rows": rows, "limit": limit} + except Exception as exc: + raise HTTPException(status_code=400, detail=f"sql_failed:{str(exc)[:240]}") from exc + + +@app.on_event("startup") +def on_startup() -> None: + Base.metadata.create_all(bind=engine) + # Best-effort schema evolution for new columns (no migrations framework). + # Safe for Postgres (IF NOT EXISTS); ignored on failure. + try: + with engine.begin() as conn: + conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS relevancy VARCHAR(128) DEFAULT ''") + conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS l3vpn_peer_ne VARCHAR(256) DEFAULT ''") + conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS service VARCHAR(256) DEFAULT ''") + conn.exec_driver_sql( + "ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS affected_client_service_number INTEGER DEFAULT 0" + ) + conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS intermittence_count INTEGER DEFAULT 0") + conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS me_level VARCHAR(128) DEFAULT ''") + except Exception: + pass + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/v1/integrations/status") +def integrations_status(db: Session = Depends(get_db)) -> dict: + # netx api is up if this handler executes; still verify DB + oclaw bridge separately. + netx_api = {"status": "up"} + + db_status: dict = {"status": "unknown"} + try: + t0 = time.monotonic() + db.execute(sql_text("select 1")) + db_status = {"status": "up", "latency_ms": int((time.monotonic() - t0) * 1000)} + except Exception as exc: + db_status = {"status": "down", "error": str(exc)[:240]} + + oclaw_status: dict = {"status": "unknown"} + try: + t0 = time.monotonic() + data = health_with_oclaw() + oclaw_status = { + "status": "up", + "latency_ms": int((time.monotonic() - t0) * 1000), + "http_status": int(data.get("status_code") or 200), + "detail": data.get("data") or {}, + } + except Exception as exc: + msg = str(exc) + http_status = None + kind = "unknown" + if " 401 " in msg or "401" in msg: + kind = "auth" + http_status = 401 + elif " 404 " in msg or "404" in msg: + kind = "not_found" + http_status = 404 + elif "timeout" in msg.lower(): + kind = "timeout" + elif "connect" in msg.lower(): + kind = "connect" + else: + kind = "other" + oclaw_status = {"status": "down", "error_kind": kind, "http_status": http_status, "error": msg[:240]} + + return {"netx_api": netx_api, "db": db_status, "oclaw_bridge": oclaw_status} + + +@app.get("/") +def root() -> dict: + return { + "ok": True, + "mode": "api_only", + "message": "netx UI is served by Vite frontend only", + "frontend_url": settings.frontend_url, + "api_health": "/health", + "api_status": "/v1/integrations/status", + } + + +@app.post("/v1/alarms/import", response_model=BatchSummary) +async def import_alarms(file: UploadFile = File(...), db: Session = Depends(get_db)) -> BatchSummary: + filename = str(file.filename or "alarm.xlsx") + if not filename.lower().endswith((".xlsx", ".xls")): + raise HTTPException(status_code=400, detail="only_excel_supported_in_phase1") + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="empty_file") + batch = import_alarm_excel(db, filename=filename, content=content, parser=parser_cfg) + + try: + job = ImportJob( + kind="alarms", + file_name=filename, + batch_id=str(batch.batch_id), + ok=1, + summary=f"success={int(batch.success_rows)} failed={int(batch.failed_rows)}", + ) + db.add(job) + db.commit() + except Exception: + db.rollback() + return BatchSummary( + batch_id=str(batch.batch_id), + total_rows=int(batch.total_rows or 0), + success_rows=int(batch.success_rows or 0), + failed_rows=int(batch.failed_rows or 0), + status=str(batch.status or ""), + created_at=_ensure_utc(batch.created_at) or datetime.now(timezone.utc), + ) + + +@app.post("/v1/logs/import") +async def import_logs(file: UploadFile = File(...)) -> dict: + # Placeholder for Phase 2: logs parsing + storage + query. + filename = str(file.filename or "logs.zip") + if not filename: + raise HTTPException(status_code=400, detail="filename_required") + raise HTTPException(status_code=501, detail="logs_import_not_implemented") + + +@app.get("/v1/jobs", response_model=ImportJobListResponse) +def list_jobs(limit: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db)) -> ImportJobListResponse: + rows = db.query(ImportJob).order_by(ImportJob.created_at.desc()).limit(limit).all() + items = [ + ImportJobItem( + id=int(x.id), + kind=str(x.kind), + file_name=str(x.file_name or ""), + batch_id=str(x.batch_id) if x.batch_id else None, + ok=bool(int(x.ok or 0)), + summary=str(x.summary or ""), + created_at=_ensure_utc(x.created_at) or datetime.now(timezone.utc), + ) + for x in rows + ] + return ImportJobListResponse(items=items) + + +@app.get("/v1/batches") +def list_batches(limit: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db)) -> dict: + rows = db.query(AlarmBatch).order_by(AlarmBatch.created_at.desc()).limit(limit).all() + return { + "items": [ + { + "batch_id": x.batch_id, + "source_file": x.source_file, + "status": x.status, + "total_rows": x.total_rows, + "success_rows": x.success_rows, + "failed_rows": x.failed_rows, + "created_at": (_ensure_utc(x.created_at) or datetime.now(timezone.utc)).isoformat(), + } + for x in rows + ] + } + + +@app.get("/v1/batches/{batch_id}/errors.csv") +def download_batch_errors(batch_id: str, db: Session = Depends(get_db)): + rows = ( + db.query(ImportErrorRow) + .filter(ImportErrorRow.batch_id == batch_id) + .order_by(ImportErrorRow.id.asc()) + .all() + ) + if not rows: + raise HTTPException(status_code=404, detail="batch_or_errors_not_found") + buf = StringIO() + writer = csv.writer(buf) + writer.writerow(["row_no", "reason", "raw_json"]) + for r in rows: + writer.writerow([r.row_no, r.reason, r.raw_json]) + return Response( + content=buf.getvalue(), + media_type="text/csv", + headers={"content-disposition": f'attachment; filename="batch_{batch_id}_errors.csv"'}, + ) + + +@app.delete("/v1/batches/{batch_id}") +def delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict: + batch = db.get(AlarmBatch, batch_id) + if not batch: + raise HTTPException(status_code=404, detail="batch_not_found") + try: + alarms_deleted = int( + db.query(AlarmNorm).filter(AlarmNorm.batch_id == batch_id).delete(synchronize_session=False) + ) + errors_deleted = int( + db.query(ImportErrorRow).filter(ImportErrorRow.batch_id == batch_id).delete(synchronize_session=False) + ) + jobs_deleted = int( + db.query(ImportJob).filter(ImportJob.batch_id == batch_id).delete(synchronize_session=False) + ) + db.delete(batch) + db.commit() + return { + "ok": True, + "batch_id": batch_id, + "deleted": { + "batch": 1, + "alarms": alarms_deleted, + "errors": errors_deleted, + "jobs": jobs_deleted, + }, + } + except Exception as exc: + db.rollback() + raise HTTPException(status_code=500, detail=f"delete_batch_failed: {exc}") from exc + + +@app.delete("/v1/batches") +def delete_all_batches(db: Session = Depends(get_db)) -> dict: + try: + alarms_deleted = int(db.query(AlarmNorm).delete(synchronize_session=False)) + errors_deleted = int(db.query(ImportErrorRow).delete(synchronize_session=False)) + jobs_deleted = int(db.query(ImportJob).delete(synchronize_session=False)) + batches_deleted = int(db.query(AlarmBatch).delete(synchronize_session=False)) + db.commit() + return { + "ok": True, + "deleted": { + "batches": batches_deleted, + "alarms": alarms_deleted, + "errors": errors_deleted, + "jobs": jobs_deleted, + }, + } + except Exception as exc: + db.rollback() + raise HTTPException(status_code=500, detail=f"delete_all_batches_failed: {exc}") from exc + + +@app.get("/v1/diagnostics") +def diagnostics( + batch_id: str = Query(...), + db: Session = Depends(get_db), +) -> dict: + sev_rows = aggregate_alarms(db, group_by="severity_norm", batch_id=batch_id) + code_rows = aggregate_alarms(db, group_by="alarm_code", batch_id=batch_id)[:10] + ne_rows = aggregate_alarms(db, group_by="ne_name", batch_id=batch_id)[:10] + total = sum(count for _, count in sev_rows) + + def _protocol_bucket(text: str) -> str: + t = (text or "").upper() + # IP/MPLS control plane + if any(x in t for x in ("BGP", "OSPF", "ISIS", "LDP", "MPLS", "L3VPN", "VPN")): + return "IP/MPLS" + # Ethernet / packet + if any(x in t for x in ("ETH", "GE", "10GE", "25GE", "40GE", "100GE", "XGE")): + return "ETH" + # OTN / optical + if any(x in t for x in ("OTN", "ODU", "OCH", "OMS", "OSC", "DWDM", "WDM", "ROADM")): + return "OTN/光" + # Timing / clock + if any(x in t for x in ("CLOCK", "SYNC", "PTP", "1588", "BITS", "TOD")): + return "时钟" + # Power + if any(x in t for x in ("PWR", "POWER", "PSU", "BAT", "BATT")): + return "电源" + return "其他" + + proto_counts: dict[str, int] = {} + for name, desc, code, raw in ( + db.query(AlarmNorm.alarm_name, AlarmNorm.description, AlarmNorm.alarm_code, AlarmNorm.raw_json) + .filter(AlarmNorm.batch_id == batch_id) + .all() + ): + blob = " | ".join([str(code or ""), str(name or ""), str(desc or ""), str(raw or "")]) + k = _protocol_bucket(blob) + proto_counts[k] = int(proto_counts.get(k, 0)) + 1 + protocol_summary = sorted(proto_counts.items(), key=lambda x: x[1], reverse=True)[:10] + + return { + "batch_id": batch_id, + "total_alarms": int(total), + "severity_summary": [{"key": k, "count": v} for k, v in sev_rows], + "top_alarm_codes": [{"key": k, "count": v} for k, v in code_rows], + "top_ne": [{"key": k, "count": v} for k, v in ne_rows], + "protocol_summary": [{"key": k, "count": v} for k, v in protocol_summary], + } + + +@app.post("/v1/ap/analyze") +def ap_analyze(payload: dict, db: Session = Depends(get_db)) -> dict: + batch_id = str(payload.get("batch_id") or "").strip() + question = str(payload.get("question") or "").strip() + if not batch_id or not question: + raise HTTPException(status_code=400, detail="batch_id_and_question_required") + diag = diagnostics(batch_id=batch_id, db=db) + analysis_request_id = str(payload.get("analysis_request_id") or "").strip() + filters_obj = payload.get("filters") if isinstance(payload.get("filters"), dict) else {} + req = { + "analysis_request_id": analysis_request_id, + "question": question, + "dataset_ref": { + "batch_id": batch_id, + "filters": filters_obj or {}, + }, + "context": { + "severity_summary": diag["severity_summary"], + "top_alarm_codes": diag["top_alarm_codes"], + "top_ne": diag["top_ne"], + "protocol_summary": diag.get("protocol_summary", []), + "findings": diag.get("findings", []), + }, + "constraints": payload.get("constraints") or {"language": "zh-CN", "max_points": 6}, + "interaction_mode": "expert", + "specialist": "ops", + } + ok = False + err = "" + oclaw_resp: dict[str, Any] | None = None + try: + oclaw_resp = analyze_with_oclaw(req) + ok = bool(oclaw_resp.get("ok")) if isinstance(oclaw_resp, dict) else False + except Exception as exc: + err = str(exc) + # Persist Q&A history (best-effort; never block response). + try: + answer = "" + if isinstance(oclaw_resp, dict): + answer = str(oclaw_resp.get("answer") or "").strip() + row = AiAnalyzeHistory( + analysis_request_id=analysis_request_id, + batch_id=batch_id, + question=question, + filters_json=json.dumps(filters_obj or {}, ensure_ascii=False), + ok=1 if ok else 0, + answer=answer, + error=err, + evidence_json=json.dumps(diag or {}, ensure_ascii=False), + created_at=datetime.utcnow(), + ) + db.add(row) + db.commit() + except Exception: + db.rollback() + if not ok: + return { + "ok": False, + "error": err or "oclaw_bridge_unavailable", + "fallback_diagnostics": diag, + "batch_id": batch_id, + "question": question, + } + return {"ok": True, "batch_id": batch_id, "question": question, "diagnostics": diag, "oclaw": oclaw_resp} + + +@app.get("/v1/ap/history", response_model=AiAnalyzeHistoryResponse) +def ap_history( + batch_id: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +) -> AiAnalyzeHistoryResponse: + q = db.query(AiAnalyzeHistory) + if batch_id and str(batch_id).strip(): + q = q.filter(AiAnalyzeHistory.batch_id == str(batch_id).strip()) + total = int(q.count()) + rows = ( + q.order_by(AiAnalyzeHistory.id.desc()) + .offset((int(page) - 1) * int(page_size)) + .limit(int(page_size)) + .all() + ) + items: list[AiAnalyzeHistoryItem] = [] + for r in rows: + try: + filters = json.loads(str(r.filters_json or "{}")) + except Exception: + filters = {} + items.append( + AiAnalyzeHistoryItem( + id=int(r.id), + analysis_request_id=str(r.analysis_request_id or ""), + batch_id=str(r.batch_id or ""), + question=str(r.question or ""), + filters=filters if isinstance(filters, dict) else {}, + ok=bool(int(r.ok or 0) == 1), + answer=str(r.answer or ""), + error=str(r.error or ""), + created_at=_ensure_utc(r.created_at) or datetime.now(timezone.utc), + ) + ) + return AiAnalyzeHistoryResponse(total=total, page=page, page_size=page_size, items=items) + + +@app.get("/v1/alarms", response_model=AlarmQueryResponse) +def list_alarms( + batch_id: str | None = Query(default=None), + alarm_code: str | None = Query(default=None), + severity: str | None = Query(default=None), + ne_name: str | None = Query(default=None), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + db: Session = Depends(get_db), +) -> AlarmQueryResponse: + total, rows = query_alarms( + db, + batch_id=batch_id, + alarm_code=alarm_code, + severity=severity, + ne_name=ne_name, + page=page, + page_size=page_size, + ) + items = [ + AlarmItem( + id=x.id, + batch_id=x.batch_id, + row_no=x.row_no, + alarm_time=_ensure_utc(x.alarm_time) or datetime.now(timezone.utc), + severity_norm=x.severity_norm, + severity_raw=x.severity_raw, + ne_name=x.ne_name, + alarm_code=x.alarm_code, + description=x.description, + ack_state=x.ack_state, + ) + for x in rows + ] + return AlarmQueryResponse(total=total, page=page, page_size=page_size, items=items) + + +@app.get("/v1/alarms/fields") +def alarms_fields() -> dict: + """List all columns in alarms_norm for power querying.""" + cols = [] + try: + cols = [str(c.name) for c in AlarmNorm.__table__.columns] # type: ignore[attr-defined] + except Exception: + cols = [] + return {"items": cols} + + +def _serialize_alarm_row(row: AlarmNorm) -> dict[str, Any]: + out: dict[str, Any] = {} + for c in AlarmNorm.__table__.columns: # type: ignore[attr-defined] + name = str(c.name) + v = getattr(row, name, None) + if hasattr(v, "isoformat"): + try: + if isinstance(v, datetime): + out[name] = (_ensure_utc(v) or v).isoformat() + else: + out[name] = v.isoformat() # datetime/date + continue + except Exception: + pass + out[name] = v + return out + + +@app.get("/v1/alarms/raw") +def alarms_raw( + batch_id: str | None = Query(default=None), + severity: str | None = Query(default=None), + alarm_code: str | None = Query(default=None), + ne_name: str | None = Query(default=None), + q: str | None = Query(default=None, description="free text contains on alarm_code/ne_name/description/service"), + order_by: str = Query(default="alarm_time"), + order: str = Query(default="desc"), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + db: Session = Depends(get_db), +) -> dict: + """ + Power query: return **all columns** for alarms_norm rows. + + Safety constraints: + - batch_id is required (avoid unbounded scans) + - order_by is whitelisted + - page_size capped + """ + bid = str(batch_id or "").strip() + if not bid: + raise HTTPException(status_code=400, detail="batch_id_required") + stmt = db.query(AlarmNorm).filter(AlarmNorm.batch_id == bid) + if severity and str(severity).strip(): + stmt = stmt.filter(AlarmNorm.severity_norm == str(severity).strip()) + if alarm_code and str(alarm_code).strip(): + stmt = stmt.filter(AlarmNorm.alarm_code.contains(str(alarm_code).strip())) + if ne_name and str(ne_name).strip(): + stmt = stmt.filter(AlarmNorm.ne_name.contains(str(ne_name).strip())) + if q and str(q).strip(): + qw = str(q).strip() + stmt = stmt.filter( + (AlarmNorm.alarm_code.contains(qw)) + | (AlarmNorm.ne_name.contains(qw)) + | (AlarmNorm.description.contains(qw)) + | (AlarmNorm.service.contains(qw)) + ) + allowed_order_by = { + "id": AlarmNorm.id, + "alarm_time": AlarmNorm.alarm_time, + "severity_norm": AlarmNorm.severity_norm, + "ne_name": AlarmNorm.ne_name, + "alarm_code": AlarmNorm.alarm_code, + } + col = allowed_order_by.get(str(order_by or "").strip(), AlarmNorm.alarm_time) + if str(order or "").strip().lower() == "asc": + stmt = stmt.order_by(col.asc()) + else: + stmt = stmt.order_by(col.desc()) + total = int(stmt.count()) + rows = ( + stmt.offset((int(page) - 1) * int(page_size)) + .limit(int(page_size)) + .all() + ) + return { + "total": total, + "page": int(page), + "page_size": int(page_size), + "items": [_serialize_alarm_row(r) for r in rows], + } + + +@app.get("/v1/alarms/aggregate", response_model=AlarmAggregateResponse) +def alarms_aggregate( + group_by: str = Query(default="severity_norm"), + batch_id: str | None = Query(default=None), + db: Session = Depends(get_db), +) -> AlarmAggregateResponse: + try: + rows = aggregate_alarms(db, group_by=group_by, batch_id=batch_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return AlarmAggregateResponse( + group_by=group_by, + buckets=[AlarmAggregateBucket(key=k, count=v) for k, v in rows], + ) + + +@app.get("/v1/batches/{batch_id}") +def get_batch(batch_id: str, db: Session = Depends(get_db)) -> dict: + batch = db.get(AlarmBatch, batch_id) + if not batch: + raise HTTPException(status_code=404, detail="batch_not_found") + errors = ( + db.query(ImportErrorRow) + .filter(ImportErrorRow.batch_id == batch_id) + .order_by(ImportErrorRow.id.asc()) + .limit(20) + .all() + ) + return { + "batch": BatchSummary.model_validate(batch, from_attributes=True).model_dump(), + "errors_preview": [ + {"row_no": e.row_no, "reason": e.reason, "raw_json": e.raw_json} + for e in errors + ], + } + + +if __name__ == "__main__": + uvicorn.run("netx_api.main:app", host=settings.host, port=settings.port, reload=False) diff --git a/netx_api/mcp_server.py b/netx_api/mcp_server.py new file mode 100644 index 0000000..def3247 --- /dev/null +++ b/netx_api/mcp_server.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import json +import sys +from typing import Any + +from .db import SessionLocal +from .importer import aggregate_alarms, query_alarms +from .models import AlarmBatch + + +def _ok(rid: Any, result: dict[str, Any]) -> None: + sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def _err(rid: Any, code: int, message: str) -> None: + sys.stdout.write( + json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False) + + "\n" + ) + sys.stdout.flush() + + +def _tool_list() -> list[dict[str, Any]]: + return [ + { + "name": "queryAlarms", + "description": "Query normalized alarms with filters and pagination.", + "inputSchema": { + "type": "object", + "properties": { + "batch_id": {"type": "string"}, + "alarm_code": {"type": "string"}, + "severity": {"type": "string"}, + "ne_name": {"type": "string"}, + "page": {"type": "integer", "minimum": 1}, + "page_size": {"type": "integer", "minimum": 1, "maximum": 200}, + }, + "additionalProperties": False, + }, + }, + { + "name": "aggregateAlarms", + "description": "Aggregate alarms by severity_norm, alarm_code or ne_name.", + "inputSchema": { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "enum": ["severity_norm", "alarm_code", "ne_name"], + }, + "batch_id": {"type": "string"}, + }, + "required": ["group_by"], + "additionalProperties": False, + }, + }, + { + "name": "getImportBatch", + "description": "Get imported batch summary by batch_id.", + "inputSchema": { + "type": "object", + "properties": {"batch_id": {"type": "string"}}, + "required": ["batch_id"], + "additionalProperties": False, + }, + }, + { + "name": "runDiagnostics", + "description": "Generate quick diagnostics summary by batch.", + "inputSchema": { + "type": "object", + "properties": {"batch_id": {"type": "string"}}, + "required": ["batch_id"], + "additionalProperties": False, + }, + }, + ] + + +def _call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]: + with SessionLocal() as db: + if name == "queryAlarms": + total, rows = query_alarms( + db, + batch_id=str(args.get("batch_id") or "").strip() or None, + alarm_code=str(args.get("alarm_code") or "").strip() or None, + severity=str(args.get("severity") or "").strip() or None, + ne_name=str(args.get("ne_name") or "").strip() or None, + page=max(1, int(args.get("page") or 1)), + page_size=min(200, max(1, int(args.get("page_size") or 50))), + ) + payload = { + "total": total, + "items": [ + { + "id": r.id, + "batch_id": r.batch_id, + "alarm_time": r.alarm_time.isoformat(), + "severity_norm": r.severity_norm, + "ne_name": r.ne_name, + "alarm_code": r.alarm_code, + "alarm_name": r.alarm_name, + } + for r in rows + ], + } + return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} + if name == "aggregateAlarms": + group_by = str(args.get("group_by") or "").strip() + rows = aggregate_alarms( + db, + group_by=group_by, + batch_id=str(args.get("batch_id") or "").strip() or None, + ) + payload = {"group_by": group_by, "buckets": [{"key": k, "count": v} for k, v in rows]} + return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} + if name == "getImportBatch": + batch_id = str(args.get("batch_id") or "").strip() + batch = db.get(AlarmBatch, batch_id) + if not batch: + return {"content": [{"type": "text", "text": json.dumps({"error": "batch_not_found"})}], "isError": True} + payload = { + "batch_id": batch.batch_id, + "total_rows": batch.total_rows, + "success_rows": batch.success_rows, + "failed_rows": batch.failed_rows, + "status": batch.status, + "created_at": batch.created_at.isoformat(), + } + return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} + if name == "runDiagnostics": + batch_id = str(args.get("batch_id") or "").strip() + sev_rows = aggregate_alarms(db, group_by="severity_norm", batch_id=batch_id) + code_rows = aggregate_alarms(db, group_by="alarm_code", batch_id=batch_id)[:5] + ne_rows = aggregate_alarms(db, group_by="ne_name", batch_id=batch_id)[:5] + sev_map = {k: v for k, v in sev_rows} + findings: list[str] = [] + actions: list[str] = [] + risk_level = "low" + if int(sev_map.get("critical", 0)) > 0: + findings.append("critical 告警存在,建议优先确认核心网元影响。") + actions.append("优先处理 critical 告警,确认影响面并升级。") + risk_level = "high" + if int(sev_map.get("warning", 0)) > int(sev_map.get("major", 0)): + findings.append("warning 占比较高,疑似阈值型告警风暴。") + actions.append("检查高频 warning 告警码是否集中在单一阈值策略。") + if risk_level != "high": + risk_level = "medium" + if not findings: + findings.append("分布相对均衡,建议按 top 告警码进一步排查。") + actions.append("按 top 告警码和网元继续下钻分析。") + payload = { + "batch_id": batch_id, + "risk_level": risk_level, + "severity_summary": [{"key": k, "count": v} for k, v in sev_rows], + "top_alarm_codes": [{"key": k, "count": v} for k, v in code_rows], + "top_ne": [{"key": k, "count": v} for k, v in ne_rows], + "findings": findings, + "actions": actions, + } + return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]} + raise ValueError(f"unknown tool: {name}") + + +def main() -> None: + for line in sys.stdin: + raw = line.strip() + if not raw: + continue + try: + req = json.loads(raw) + except Exception: + continue + rid = req.get("id") + method = str(req.get("method") or "") + params = req.get("params") if isinstance(req.get("params"), dict) else {} + + try: + if method == "initialize": + _ok( + rid, + { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "netx-mcp", "version": "0.1.0"}, + }, + ) + continue + if method == "notifications/initialized": + continue + if method == "tools/list": + _ok(rid, {"tools": _tool_list()}) + continue + if method == "tools/call": + name = str(params.get("name") or "") + args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} + _ok(rid, _call_tool(name, args)) + continue + _err(rid, -32601, f"method not found: {method}") + except Exception as exc: + _err(rid, -32000, str(exc)) + + +if __name__ == "__main__": + main() diff --git a/netx_api/models.py b/netx_api/models.py new file mode 100644 index 0000000..d0417a9 --- /dev/null +++ b/netx_api/models.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base + + +class AlarmBatch(Base): + __tablename__ = "alarm_batches" + + batch_id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + source_file: Mapped[str] = mapped_column(String(512)) + parser_version: Mapped[str] = mapped_column(String(64), default="zte_alarm_monitor_v1") + dict_version: Mapped[str] = mapped_column(String(64), default="v1") + total_rows: Mapped[int] = mapped_column(Integer, default=0) + success_rows: Mapped[int] = mapped_column(Integer, default=0) + failed_rows: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[str] = mapped_column(String(32), default="done") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + alarms: Mapped[list["AlarmNorm"]] = relationship(back_populates="batch") + errors: Mapped[list["ImportErrorRow"]] = relationship(back_populates="batch") + + +class AlarmNorm(Base): + __tablename__ = "alarms_norm" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + batch_id: Mapped[str] = mapped_column(ForeignKey("alarm_batches.batch_id"), index=True) + row_no: Mapped[int] = mapped_column(Integer) + + alarm_time: Mapped[datetime] = mapped_column(DateTime, index=True) + clear_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + severity_raw: Mapped[str] = mapped_column(String(64), default="") + severity_norm: Mapped[str] = mapped_column(String(32), index=True, default="unknown") + ne_name: Mapped[str] = mapped_column(String(256), default="", index=True) + ne_id: Mapped[str] = mapped_column(String(128), default="", index=True) + site_name: Mapped[str] = mapped_column(String(256), default="") + alarm_code: Mapped[str] = mapped_column(String(256), default="", index=True) + # NOTE: historically we stored `alarm_name` separately, but for ZTE Alarm Monitor exports + # "Alarm Code Name" is a single column. We keep the DB column for backward compatibility, + # but APIs/UI are unified on `alarm_code`. + alarm_name: Mapped[str] = mapped_column(String(512), default="") + description: Mapped[str] = mapped_column(Text, default="") + ack_state: Mapped[str] = mapped_column(String(64), default="") + clear_state: Mapped[str] = mapped_column(String(64), default="") + relevancy: Mapped[str] = mapped_column(String(128), default="") + l3vpn_peer_ne: Mapped[str] = mapped_column(String(256), default="") + service: Mapped[str] = mapped_column(String(256), default="") + affected_client_service_number: Mapped[int] = mapped_column(Integer, default=0) + intermittence_count: Mapped[int] = mapped_column(Integer, default=0) + me_level: Mapped[str] = mapped_column(String(128), default="") + vendor: Mapped[str] = mapped_column(String(64), default="ZTE") + source_type: Mapped[str] = mapped_column(String(64), default="gateway_export_excel") + source_file: Mapped[str] = mapped_column(String(512), default="") + raw_json: Mapped[str] = mapped_column(Text, default="{}") + + batch: Mapped[AlarmBatch] = relationship(back_populates="alarms") + + +class ImportErrorRow(Base): + __tablename__ = "import_errors" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + batch_id: Mapped[str] = mapped_column(ForeignKey("alarm_batches.batch_id"), index=True) + row_no: Mapped[int] = mapped_column(Integer) + reason: Mapped[str] = mapped_column(String(512)) + raw_json: Mapped[str] = mapped_column(Text, default="{}") + + batch: Mapped[AlarmBatch] = relationship(back_populates="errors") + + +class ImportJob(Base): + __tablename__ = "import_jobs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + kind: Mapped[str] = mapped_column(String(32), index=True) # alarms/logs/config + file_name: Mapped[str] = mapped_column(String(512), default="") + batch_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ok: Mapped[int] = mapped_column(Integer, default=1) # 1 ok, 0 error + summary: Mapped[str] = mapped_column(String(1024), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class AiAnalyzeHistory(Base): + __tablename__ = "ai_analyze_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + analysis_request_id: Mapped[str] = mapped_column(String(128), default="", index=True) + batch_id: Mapped[str] = mapped_column(String(64), default="", index=True) + question: Mapped[str] = mapped_column(Text, default="") + filters_json: Mapped[str] = mapped_column(Text, default="{}") + ok: Mapped[int] = mapped_column(Integer, default=1) # 1 ok, 0 error + answer: Mapped[str] = mapped_column(Text, default="") + error: Mapped[str] = mapped_column(Text, default="") + evidence_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/netx_api/parser_config.py b/netx_api/parser_config.py new file mode 100644 index 0000000..bd22ec3 --- /dev/null +++ b/netx_api/parser_config.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from .config import settings + + +def _normalize_key(s: str) -> str: + return "".join(ch for ch in str(s).strip().lower() if ch not in {" ", "_", "-"}) + + +@dataclass +class ParserConfig: + parser_version: str + dict_version: str + aliases: dict[str, list[str]] + severity_map: dict[str, str] + + def resolve_col(self, headers: list[str], field: str) -> str | None: + alias_set = {_normalize_key(x) for x in self.aliases.get(field, [])} + for h in headers: + if _normalize_key(h) in alias_set: + return h + return None + + def normalize_severity(self, raw: str) -> str: + key = _normalize_key(raw) + return self.severity_map.get(key, "unknown") + + +def load_parser_config(path: str | None = None) -> ParserConfig: + cfg_path = Path(path or settings.parser_config) + data: dict[str, Any] = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + aliases = data.get("aliases") or {} + severity = data.get("severity_map") or {} + severity_map = {_normalize_key(k): str(v) for k, v in severity.items()} + return ParserConfig( + parser_version=str(data.get("parser_version") or "zte_alarm_monitor_v1"), + dict_version=str(data.get("dict_version") or "v1"), + aliases={str(k): [str(x) for x in (v or [])] for k, v in aliases.items()}, + severity_map=severity_map, + ) diff --git a/netx_api/schemas.py b/netx_api/schemas.py new file mode 100644 index 0000000..12e16df --- /dev/null +++ b/netx_api/schemas.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class BatchSummary(BaseModel): + batch_id: str + total_rows: int + success_rows: int + failed_rows: int + status: str + created_at: datetime + + +class AlarmItem(BaseModel): + id: int + batch_id: str + row_no: int + alarm_time: datetime + severity_norm: str + severity_raw: str + ne_name: str + alarm_code: str + description: str + ack_state: str + + +class AlarmQueryResponse(BaseModel): + total: int + page: int + page_size: int + items: list[AlarmItem] + + +class AlarmAggregateBucket(BaseModel): + key: str + count: int + + +class AlarmAggregateResponse(BaseModel): + group_by: str = Field(pattern="^(severity_norm|alarm_code|ne_name)$") + buckets: list[AlarmAggregateBucket] + + +class ImportJobItem(BaseModel): + id: int + kind: str + file_name: str + batch_id: str | None = None + ok: bool + summary: str + created_at: datetime + + +class ImportJobListResponse(BaseModel): + items: list[ImportJobItem] + + +class AiAnalyzeHistoryItem(BaseModel): + id: int + analysis_request_id: str = "" + batch_id: str = "" + question: str = "" + filters: dict = Field(default_factory=dict) + ok: bool + answer: str = "" + error: str = "" + created_at: datetime + + +class AiAnalyzeHistoryResponse(BaseModel): + total: int + page: int + page_size: int + items: list[AiAnalyzeHistoryItem] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8710508 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115.0 +uvicorn>=0.30.0 +sqlalchemy>=2.0.0 +psycopg[binary]>=3.2.0 +pandas>=2.0.0 +openpyxl>=3.1.0 +PyYAML>=6.0.0 +pydantic>=2.8.0 +pydantic-settings>=2.3.0 +python-multipart>=0.0.9 diff --git a/scripts/.run/netx.err.log b/scripts/.run/netx.err.log new file mode 100644 index 0000000..6244e94 --- /dev/null +++ b/scripts/.run/netx.err.log @@ -0,0 +1,13 @@ +D:\project\chatgpt\netx\netx_api\main.py:112: DeprecationWarning: + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + + @app.on_event("startup") +INFO: Started server process [24088] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://127.0.0.1:8890 (Press CTRL+C to quit) +C:\Users\zhout\AppData\Local\Python\pythoncore-3.14-64\Lib\site-packages\openpyxl\styles\stylesheet.py:237: UserWarning: Workbook contains no default style, apply openpyxl's default + warn("Workbook contains no default style, apply openpyxl's default") diff --git a/scripts/.run/netx.out.log b/scripts/.run/netx.out.log new file mode 100644 index 0000000..cdb84f9 --- /dev/null +++ b/scripts/.run/netx.out.log @@ -0,0 +1,206 @@ +INFO: 127.0.0.1:53699 - "GET /v1/batches?limit=30 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50679 - "GET /v1/ap/history?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=10 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50681 - "GET /v1/batches?limit=30 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50680 - "GET /v1/alarms?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50684 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50682 - "GET /v1/diagnostics?batch_id=5077d7cfdf524aed982da687cfe6919e HTTP/1.1" 200 OK +INFO: 127.0.0.1:50128 - "GET /v1/ap/history?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=10 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50129 - "GET /v1/batches?limit=30 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50133 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50130 - "GET /v1/alarms?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50131 - "GET /v1/diagnostics?batch_id=5077d7cfdf524aed982da687cfe6919e HTTP/1.1" 200 OK +INFO: 127.0.0.1:50132 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:55903 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50412 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:63672 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50959 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:51822 - "DELETE /v1/batches HTTP/1.1" 200 OK +INFO: 127.0.0.1:53625 - "GET /v1/batches?limit=30 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63958 - "GET /v1/diagnostics?batch_id=5077d7cfdf524aed982da687cfe6919e HTTP/1.1" 200 OK +INFO: 127.0.0.1:53087 - "GET /v1/alarms?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56843 - "GET /v1/alarms?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60234 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52336 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54662 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:53001 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:59095 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:51558 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:61415 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:62982 - "GET /v1/batches?limit=30 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60448 - "GET /v1/alarms?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60449 - "GET /v1/diagnostics?batch_id=5077d7cfdf524aed982da687cfe6919e HTTP/1.1" 200 OK +INFO: 127.0.0.1:60451 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60450 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:63381 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:57208 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:58447 - "POST /v1/alarms/import HTTP/1.1" 200 OK +INFO: 127.0.0.1:55577 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56709 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49805 - "GET /v1/batches?limit=30 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55972 - "GET /v1/alarms?batch_id=5077d7cfdf524aed982da687cfe6919e&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50230 - "GET /v1/alarms?batch_id=e94cad728d3644ef9e70e54430ff83fb&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49614 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:56075 - "GET /v1/diagnostics?batch_id=e94cad728d3644ef9e70e54430ff83fb HTTP/1.1" 200 OK +INFO: 127.0.0.1:56138 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63784 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:56831 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:57035 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:56807 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58155 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54924 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:59299 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:60141 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60215 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:49289 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50308 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:58794 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50142 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:63565 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63233 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:55024 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55403 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:52894 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52899 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58471 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:64204 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56225 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:58654 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62191 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:53468 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:59075 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:61624 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54981 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:52435 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52438 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52439 - "GET /v1/alarms/raw?batch_id=e94cad728d3644ef9e70e54430ff83fb&page=1&page_size=5&order_by=alarm_time&order=desc HTTP/1.1" 200 OK +INFO: 127.0.0.1:52444 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:59220 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54682 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55254 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:61399 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53659 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:61220 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56157 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:51728 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:64988 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63206 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:50852 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63433 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:50333 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50341 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:57348 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53773 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:63765 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53389 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:58475 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58988 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:64306 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:57293 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:52071 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60569 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54060 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:51111 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:50557 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54845 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54846 - "GET /v1/alarms/raw?batch_id=e94cad728d3644ef9e70e54430ff83fb&page=1&page_size=5&q=clear&order_by=alarm_time&order=desc HTTP/1.1" 200 OK +INFO: 127.0.0.1:54851 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54857 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:54858 - "POST /v1/sql/query HTTP/1.1" 200 OK +INFO: 127.0.0.1:54863 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49581 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49582 - "POST /v1/sql/query HTTP/1.1" 200 OK +INFO: 127.0.0.1:49587 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52029 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52036 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52037 - "POST /v1/sql/query HTTP/1.1" 200 OK +INFO: 127.0.0.1:52042 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52057 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52058 - "POST /v1/sql/query HTTP/1.1" 200 OK +INFO: 127.0.0.1:52063 - "GET /v1/batches?limit=1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53034 - "GET /v1/batches?limit=30 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58502 - "GET /v1/alarms?batch_id=e94cad728d3644ef9e70e54430ff83fb&page=1&page_size=50 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58503 - "GET /v1/diagnostics?batch_id=e94cad728d3644ef9e70e54430ff83fb HTTP/1.1" 200 OK +INFO: 127.0.0.1:57637 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:50493 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53769 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:50863 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62531 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:58215 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52874 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:60083 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:64296 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:52390 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58255 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:59034 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58608 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:65151 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60266 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:64098 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63464 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:60172 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55884 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:60837 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58629 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:57773 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55154 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:64743 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53140 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:56479 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:55490 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:59339 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50152 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:62680 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:50194 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:60458 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52706 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:65229 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:62670 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49803 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:59069 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:59818 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:61263 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:51707 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:58580 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52500 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:64450 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52987 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:60851 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:52434 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54219 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63864 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:53415 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60700 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:62639 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56854 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:61734 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63893 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:55580 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63652 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:65208 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63377 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54536 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60130 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:57014 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58693 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:53500 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:56458 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:49238 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:53638 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54711 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:59241 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:55133 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49518 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:62518 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58671 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:58195 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:60203 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63966 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:63003 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:57340 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:60285 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:65381 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:54301 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:51225 - "GET /v1/integrations/status HTTP/1.1" 200 OK +INFO: 127.0.0.1:56836 - "GET /v1/jobs?limit=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:49260 - "GET /v1/integrations/status HTTP/1.1" 200 OK diff --git a/scripts/.run/netx.pid b/scripts/.run/netx.pid new file mode 100644 index 0000000..304fb13 --- /dev/null +++ b/scripts/.run/netx.pid @@ -0,0 +1 @@ +37360 diff --git a/scripts/.run/web.err.log b/scripts/.run/web.err.log new file mode 100644 index 0000000..e69de29 diff --git a/scripts/.run/web.out.log b/scripts/.run/web.out.log new file mode 100644 index 0000000..e276984 --- /dev/null +++ b/scripts/.run/web.out.log @@ -0,0 +1,9 @@ + +> web@0.0.0 dev +> vite --host 127.0.0.1 --port 5173 + + + VITE v8.0.10 ready in 269 ms + + ➜ Local: http://127.0.0.1:5173/ + ➜ press h + enter to show help diff --git a/scripts/.run/web.pid b/scripts/.run/web.pid new file mode 100644 index 0000000..823685b --- /dev/null +++ b/scripts/.run/web.pid @@ -0,0 +1 @@ +38188 diff --git a/scripts/reset_db.py b/scripts/reset_db.py new file mode 100644 index 0000000..5277761 --- /dev/null +++ b/scripts/reset_db.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +""" +Dangerous: drop all netx tables and recreate schema. + +Usage (PowerShell): + Set-Location D:\\project\\chatgpt\\netx + python scripts\\reset_db.py + +It uses NETX_DATABASE_URL (via netx_api.config settings). +""" + +import sys +from pathlib import Path + +# Ensure `import netx_api` works when executed as a script. +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import netx_api.models # noqa: F401,E402 # ensure SQLAlchemy models are registered to Base.metadata +from netx_api.db import Base, engine # noqa: E402 + + +def main() -> None: + # Drop all tables first (data loss). + Base.metadata.drop_all(bind=engine) + # Recreate with current models. + Base.metadata.create_all(bind=engine) + print("ok: dropped and recreated all tables") + + +if __name__ == "__main__": + main() + diff --git a/scripts/start_netx.ps1 b/scripts/start_netx.ps1 new file mode 100644 index 0000000..a098fe0 --- /dev/null +++ b/scripts/start_netx.ps1 @@ -0,0 +1,92 @@ +param( + [string]$BindHost = "127.0.0.1", + [int]$Port = 8890, + [int]$WebPort = 5173, + [switch]$SkipInstall = $false, + [switch]$Background = $false, + [switch]$WithWeb = $false +) + +$ErrorActionPreference = "Stop" + +$projectRoot = Split-Path -Parent $PSScriptRoot +Set-Location $projectRoot + +$runDir = Join-Path $PSScriptRoot ".run" +if (-not (Test-Path $runDir)) { + New-Item -ItemType Directory -Path $runDir | Out-Null +} + +$pidFile = Join-Path $runDir "netx.pid" +$logFile = Join-Path $runDir "netx.out.log" +$errFile = Join-Path $runDir "netx.err.log" +$webPidFile = Join-Path $runDir "web.pid" +$webLogFile = Join-Path $runDir "web.out.log" +$webErrFile = Join-Path $runDir "web.err.log" + +$venvPython = Join-Path $projectRoot ".venv\\Scripts\\python.exe" +$pythonExe = if (Test-Path $venvPython) { $venvPython } else { "python" } + +Write-Host "==> Project root: $projectRoot" +Write-Host "==> Using python: $pythonExe" + +if (-not $SkipInstall) { + Write-Host "==> Installing dependencies" + & $pythonExe -m pip install -r (Join-Path $projectRoot "requirements.txt") +} else { + Write-Host "==> Skip dependency install" +} + +$env:NETX_HOST = $BindHost +$env:NETX_PORT = "$Port" +$baseUrl = "http://$BindHost`:$Port" +$webUrl = "http://$BindHost`:$WebPort" + +Write-Host "" +Write-Host "==> netx API URL" +Write-Host "Base: $baseUrl/" +Write-Host "Health: $baseUrl/health" +Write-Host "Integrations: $baseUrl/v1/integrations/status" +if ($WithWeb) { + Write-Host "" + Write-Host "==> netx UI URL" + Write-Host "Vite Dev UI: $webUrl/" +} +Write-Host "" + +if ($Background) { + Write-Host "==> Starting netx in background" + $proc = Start-Process -FilePath $pythonExe ` + -ArgumentList @("-m", "netx_api.main") ` + -WorkingDirectory $projectRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $logFile ` + -RedirectStandardError $errFile ` + -PassThru + Set-Content -Path $pidFile -Value "$($proc.Id)" + Write-Host "netx.pid = $pidFile" + Write-Host "PID = $($proc.Id)" + Write-Host "Log = $logFile" + Write-Host "Err = $errFile" + if ($WithWeb) { + Write-Host "==> Starting Vite dev server in background" + $webRoot = Join-Path $projectRoot "web" + $webProc = Start-Process -FilePath "npm.cmd" ` + -ArgumentList @("run", "dev", "--", "--host", $BindHost, "--port", "$WebPort") ` + -WorkingDirectory $webRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $webLogFile ` + -RedirectStandardError $webErrFile ` + -PassThru + Set-Content -Path $webPidFile -Value "$($webProc.Id)" + Write-Host "web.pid = $webPidFile" + Write-Host "PID = $($webProc.Id)" + Write-Host "Log = $webLogFile" + Write-Host "Err = $webErrFile" + } + exit 0 +} + +Write-Host "==> Starting netx in foreground" +& $pythonExe -m netx_api.main + diff --git a/scripts/stop_netx.ps1 b/scripts/stop_netx.ps1 new file mode 100644 index 0000000..ad65fef --- /dev/null +++ b/scripts/stop_netx.ps1 @@ -0,0 +1,76 @@ +param( + [int]$Port = 8890, + [int]$WebPort = 5173, + [switch]$Force = $false +) + +$ErrorActionPreference = "Continue" + +$runDir = Join-Path $PSScriptRoot ".run" +$pidFile = Join-Path $runDir "netx.pid" +$webPidFile = Join-Path $runDir "web.pid" + +Write-Host "==> Stopping netx" + +if (Test-Path $pidFile) { + $pidText = (Get-Content -Path $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1) + $procId = 0 + [void][int]::TryParse("$pidText", [ref]$procId) + if ($procId -gt 0) { + try { + Stop-Process -Id $procId -Force:$Force -ErrorAction Stop + Write-Host "Stopped PID=$procId" + } catch { + Write-Host "[WARN] PID file process not running: $procId" + } + } + Remove-Item -Path $pidFile -Force -ErrorAction SilentlyContinue +} else { + Write-Host "==> No PID file, try by port $Port" +} + +if (Test-Path $webPidFile) { + $webPidText = (Get-Content -Path $webPidFile -ErrorAction SilentlyContinue | Select-Object -First 1) + $webProcId = 0 + [void][int]::TryParse("$webPidText", [ref]$webProcId) + if ($webProcId -gt 0) { + try { + Stop-Process -Id $webProcId -Force:$Force -ErrorAction Stop + Write-Host "Stopped web PID=$webProcId" + } catch { + Write-Host "[WARN] web PID file process not running: $webProcId" + } + } + Remove-Item -Path $webPidFile -Force -ErrorAction SilentlyContinue +} + +$listeners = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue +if ($listeners) { + $owning = $listeners | Select-Object -ExpandProperty OwningProcess -Unique + foreach ($p in $owning) { + try { + Stop-Process -Id $p -Force:$Force -ErrorAction Stop + Write-Host "Stopped by port PID=$p" + } catch { + Write-Host "[WARN] Failed to stop PID=$p by port" + } + } +} else { + Write-Host "[INFO] No listener found on port $Port" +} + +$webListeners = Get-NetTCPConnection -LocalPort $WebPort -State Listen -ErrorAction SilentlyContinue +if ($webListeners) { + $webOwning = $webListeners | Select-Object -ExpandProperty OwningProcess -Unique + foreach ($p in $webOwning) { + try { + Stop-Process -Id $p -Force:$Force -ErrorAction Stop + Write-Host "Stopped web by port PID=$p" + } catch { + Write-Host "[WARN] Failed to stop web PID=$p by port" + } + } +} else { + Write-Host "[INFO] No listener found on web port $WebPort" +} + diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/web/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5e3836a --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + web + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..e5c6219 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2827 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.100.8", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.8.tgz", + "integrity": "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.8.tgz", + "integrity": "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", + "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..ab1db03 --- /dev/null +++ b/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.100.8", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons.svg b/web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..f70da06 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,346 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Navigate, Route, Routes, useNavigate, useSearchParams } from "react-router-dom"; +import { AppLayout } from "./layout/AppLayout"; +import { AlarmsPage } from "./pages/AlarmsPage"; +import { DiagnosticsPage } from "./pages/DiagnosticsPage"; +import { AiAnalysisPage } from "./pages/AiAnalysisPage"; +import { JobsPage } from "./pages/JobsPage"; +import { IngestPage } from "./pages/IngestPage"; +import type { Alarm, Batch, Diagnostics, ImportHistoryItem } from "./types"; +import { apiDelete, apiPost, apiPostForm, fetchAlarms, fetchBatches, fetchDiagnostics, fetchIntegrationStatus } from "./services/api"; +import { fetchJobs } from "./services/api"; +import { useToast } from "./hooks/useToast"; + +function App() { + const capabilities = { + alarms: (import.meta.env.VITE_FEATURE_ALARMS ?? "1") !== "0", + diagnostics: (import.meta.env.VITE_FEATURE_DIAGNOSTICS ?? "1") !== "0", + aiAnalysis: (import.meta.env.VITE_FEATURE_AI_ANALYSIS ?? "1") !== "0", + jobs: (import.meta.env.VITE_FEATURE_JOBS ?? "0") === "1", + }; + + const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [selectedBatch, setSelectedBatch] = useState(() => searchParams.get("batch_id") || ""); + const [alarmCode, setAlarmCode] = useState(() => searchParams.get("alarm_code") || ""); + const [neName, setNeName] = useState(() => searchParams.get("ne_name") || ""); + const [severity, setSeverity] = useState(() => searchParams.get("severity") || ""); + /** AI 分析页专用筛选(与告警列表页过滤独立) */ + const [aiAlarmCode, setAiAlarmCode] = useState(() => searchParams.get("ai_alarm_code") || ""); + const [aiNeName, setAiNeName] = useState(() => searchParams.get("ai_ne_name") || ""); + const [aiSeverity, setAiSeverity] = useState(() => searchParams.get("ai_severity") || ""); + const [page, setPage] = useState(() => Number(searchParams.get("page") || "1") || 1); + const [pageSize, setPageSize] = useState(() => Number(searchParams.get("page_size") || "80") || 80); + const [question, setQuestion] = useState("请总结当前告警风险并给出行动建议"); + const [answer, setAnswer] = useState(""); + const [status, setStatus] = useState("ready"); + const { toast, showError, showOk } = useToast(); + + const batchesQuery = useQuery({ + queryKey: ["batches"], + queryFn: fetchBatches, + staleTime: 30_000, + }); + const alarmsQuery = useQuery({ + queryKey: ["alarms", selectedBatch, alarmCode, neName, severity, page, pageSize], + queryFn: () => fetchAlarms({ batchId: selectedBatch, alarmCode, neName, severity, page, pageSize }), + enabled: Boolean(selectedBatch), + staleTime: 10_000, + }); + const diagnosticsQuery = useQuery({ + queryKey: ["diagnostics", selectedBatch], + queryFn: () => fetchDiagnostics(selectedBatch), + enabled: Boolean(selectedBatch) && capabilities.diagnostics, + staleTime: 10_000, + }); + const integrationsQuery = useQuery({ + queryKey: ["integrationsStatus"], + queryFn: fetchIntegrationStatus, + refetchInterval: 5000, + staleTime: 2000, + }); + const analyzeMutation = useMutation({ + mutationFn: async () => + apiPost("/v1/ap/analyze", { + analysis_request_id: `vite-${Date.now()}`, + batch_id: selectedBatch, + question, + filters: { alarm_code: aiAlarmCode, ne_name: aiNeName, severity: aiSeverity }, + constraints: { language: "zh-CN", max_points: 8 }, + }), + onMutate: () => setStatus("analyzing"), + onSuccess: async (data) => { + const ok = Boolean(data?.ok) && Boolean(data?.oclaw?.ok); + const ans = String(data?.oclaw?.answer || "").trim(); + setAnswer(ans); + setStatus("ready"); + if (!ok) { + showError(`AI 分析失败: ${String(data?.error || "oclaw bridge unavailable")}`); + } else if (!ans) { + showError("AI 分析已执行,但未返回内容"); + } else { + showOk("AI 分析完成"); + } + await queryClient.invalidateQueries({ queryKey: ["diagnostics"] }); + await queryClient.invalidateQueries({ queryKey: ["aiAnalyzeHistory"] }); + }, + onError: (e) => { + setStatus(String(e)); + showError(`AI 分析失败: ${String(e)}`); + }, + }); + + const [alarmImportStatus, setAlarmImportStatus] = useState(""); + const [logsImportStatus, setLogsImportStatus] = useState(""); + const jobsQuery = useQuery({ + queryKey: ["jobs"], + queryFn: fetchJobs, + refetchInterval: 5000, + staleTime: 2000, + }); + const importAlarmMutation = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData(); + fd.append("file", file); + return await apiPostForm("/v1/alarms/import", fd); + }, + onMutate: () => setAlarmImportStatus("上传中..."), + onSuccess: async (data) => { + const bid = String(data?.batch_id || "").trim(); + setAlarmImportStatus(`导入完成 batch_id=${bid} success=${data?.success_rows ?? ""} failed=${data?.failed_rows ?? ""}`); + showOk("告警导入完成"); + await queryClient.invalidateQueries({ queryKey: ["jobs"] }); + await queryClient.invalidateQueries({ queryKey: ["batches"] }); + if (bid) { + setSelectedBatch(bid); + // Clear filters to ensure the new batch is visible immediately. + setAlarmCode(""); + setNeName(""); + setSeverity(""); + // Ensure alarms list refreshes after state changes and navigation. + await queryClient.invalidateQueries({ queryKey: ["alarms"] }); + navigate("/alarms"); + await queryClient.invalidateQueries({ queryKey: ["alarms"] }); + } + }, + onError: async (e) => { + setAlarmImportStatus(`导入失败: ${String(e)}`); + showError(`告警导入失败: ${String(e)}`); + await queryClient.invalidateQueries({ queryKey: ["jobs"] }); + }, + }); + + const importLogsMutation = useMutation({ + mutationFn: async (file: File) => { + const fd = new FormData(); + fd.append("file", file); + return await apiPostForm("/v1/logs/import", fd); + }, + onMutate: () => setLogsImportStatus("上传中..."), + onSuccess: async () => { + setLogsImportStatus("导入完成(占位)"); + showOk("日志导入完成"); + await queryClient.invalidateQueries({ queryKey: ["jobs"] }); + }, + onError: async (e) => { + setLogsImportStatus(`导入失败: ${String(e)}`); + showError(`日志导入失败: ${String(e)}`); + await queryClient.invalidateQueries({ queryKey: ["jobs"] }); + }, + }); + + const deleteBatchMutation = useMutation({ + mutationFn: async (batchId: string) => apiDelete(`/v1/batches/${encodeURIComponent(batchId)}`), + onSuccess: async (_data, batchId) => { + showOk(`已删除批次 ${batchId}`); + setSelectedBatch(""); + await queryClient.invalidateQueries({ queryKey: ["batches"] }); + await queryClient.invalidateQueries({ queryKey: ["alarms"] }); + await queryClient.invalidateQueries({ queryKey: ["jobs"] }); + }, + onError: (e) => { + showError(`删除批次失败: ${String(e)}`); + }, + }); + + const deleteAllBatchesMutation = useMutation({ + mutationFn: async () => apiDelete("/v1/batches"), + onSuccess: async () => { + showOk("已清空全部批次数据"); + setSelectedBatch(""); + setAlarmCode(""); + setNeName(""); + setSeverity(""); + setPage(1); + await queryClient.invalidateQueries({ queryKey: ["batches"] }); + await queryClient.invalidateQueries({ queryKey: ["alarms"] }); + await queryClient.invalidateQueries({ queryKey: ["jobs"] }); + }, + onError: (e) => { + showError(`清空全部批次失败: ${String(e)}`); + }, + }); + + useEffect(() => { + const next = new URLSearchParams(); + if (selectedBatch) next.set("batch_id", selectedBatch); + if (alarmCode) next.set("alarm_code", alarmCode); + if (neName) next.set("ne_name", neName); + if (severity) next.set("severity", severity); + if (aiAlarmCode) next.set("ai_alarm_code", aiAlarmCode); + if (aiNeName) next.set("ai_ne_name", aiNeName); + if (aiSeverity) next.set("ai_severity", aiSeverity); + next.set("page", String(page)); + next.set("page_size", String(pageSize)); + setSearchParams(next, { replace: true }); + }, [selectedBatch, alarmCode, neName, severity, aiAlarmCode, aiNeName, aiSeverity, page, pageSize, setSearchParams]); + + useEffect(() => { + if (!selectedBatch && batchesQuery.data?.items?.length) { + setSelectedBatch(batchesQuery.data.items[0].batch_id); + } + }, [selectedBatch, batchesQuery.data]); + + useEffect(() => { + // Any filter change should reset pagination to first page. + setPage(1); + }, [selectedBatch, alarmCode, neName, severity]); + + useEffect(() => { + if (batchesQuery.error) { + setStatus(String(batchesQuery.error)); + showError(`批次加载失败: ${String(batchesQuery.error)}`); + } else if (alarmsQuery.error) { + setStatus(String(alarmsQuery.error)); + showError(`告警加载失败: ${String(alarmsQuery.error)}`); + } + }, [batchesQuery.error, alarmsQuery.error, batchesQuery.isSuccess, alarmsQuery.isSuccess, showError]); + + return ( + queryClient.invalidateQueries({ queryKey: ["batches"] })} + onRefreshAlarms={() => queryClient.invalidateQueries({ queryKey: ["alarms"] })} + > + + } /> + f && importAlarmMutation.mutate(f)} + onImportLogs={(f) => f && importLogsMutation.mutate(f)} + onOpenBatch={(bid) => { + setSelectedBatch(String(bid || "").trim()); + setAlarmCode(""); + setNeName(""); + setSeverity(""); + navigate("/alarms"); + }} + /> + } + /> + queryClient.invalidateQueries({ queryKey: ["alarms"] })} + onResetFilters={() => { + setAlarmCode(""); + setNeName(""); + setSeverity(""); + setPage(1); + }} + isDeletingBatch={deleteBatchMutation.isPending} + onDeleteBatch={() => { + const bid = String(selectedBatch || "").trim(); + if (!bid) { + showError("请先选择批次"); + return; + } + if (!window.confirm(`确认删除批次 ${bid} 吗?将删除该批次告警、错误记录与作业记录。`)) return; + deleteBatchMutation.mutate(bid); + }} + isDeletingAllBatches={deleteAllBatchesMutation.isPending} + onDeleteAllBatches={() => { + if (!window.confirm("确认清空全部批次吗?这会删除所有告警、错误明细和导入作业记录。")) return; + if (!window.confirm("这是高危操作,无法恢复。请再次确认执行清空全部批次。")) return; + deleteAllBatchesMutation.mutate(); + }} + /> + } + /> + } /> + { + setAiAlarmCode(""); + setAiNeName(""); + setAiSeverity(""); + }} + question={question} + answer={answer} + isRunning={analyzeMutation.isPending} + enabled={capabilities.aiAnalysis && Boolean(selectedBatch)} + onChangeQuestion={setQuestion} + onRunAnalyze={() => analyzeMutation.mutate()} + /> + } + /> + } /> + + {toast &&
{toast.text}
} +
+ ); +} + +export default App; diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/web/src/assets/hero.png differ diff --git a/web/src/assets/react.svg b/web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/web/src/hooks/useToast.ts b/web/src/hooks/useToast.ts new file mode 100644 index 0000000..66382cc --- /dev/null +++ b/web/src/hooks/useToast.ts @@ -0,0 +1,20 @@ +import { useCallback, useEffect, useState } from "react"; + +export type ToastState = { type: "ok" | "error"; text: string } | null; + +export function useToast(autoHideMs = 2600) { + const [toast, setToast] = useState(null); + + const showOk = useCallback((text: string) => setToast({ type: "ok", text }), []); + const showError = useCallback((text: string) => setToast({ type: "error", text }), []); + const clear = useCallback(() => setToast(null), []); + + useEffect(() => { + if (!toast) return; + const t = setTimeout(() => setToast(null), autoHideMs); + return () => clearTimeout(t); + }, [toast, autoHideMs]); + + return { toast, showOk, showError, clear }; +} + diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..b3864bc --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,371 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Inter, "Segoe UI", Arial, sans-serif; + background: #0a1220; + color: #d7e1f3; +} + +#root { + min-height: 100vh; +} + +.app { + display: grid; + grid-template-columns: 240px 1fr; + min-height: 100vh; +} + +.sidebar { + border-right: 1px solid #203149; + background: linear-gradient(180deg, #0f1e34 0%, #0a1220 100%); + padding: 20px 14px; +} + +.logo { + font-size: 20px; + font-weight: 700; + margin-bottom: 18px; + letter-spacing: 0.5px; +} + +.nav-item { + display: block; + padding: 10px 12px; + border-radius: 8px; + color: #98abc6; + margin-bottom: 6px; + text-decoration: none; +} + +.nav-item.active, +.nav-item:hover { + background: rgba(61, 130, 247, 0.18); + color: #dce8ff; +} + +.nav-item.disabled { + opacity: 0.45; +} + +.main { + padding: 18px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; +} + +.topbar h1 { + margin: 0; + font-size: 26px; +} + +.topbar p { + margin: 6px 0 0; + color: #8ca2c2; +} + +.top-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +button, +select, +input, +textarea { + border: 1px solid #2a3f5e; + background: #0f1e34; + color: #d7e1f3; + border-radius: 8px; + padding: 8px 10px; +} + +textarea { + width: 100%; + resize: vertical; +} + +button { + cursor: pointer; +} + +.cards { + display: grid; + grid-template-columns: repeat(3, minmax(220px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.card, +.panel { + border: 1px solid #233855; + background: rgba(13, 27, 46, 0.9); + border-radius: 12px; + padding: 12px; +} + +.card h3, +.panel h2 { + margin: 0 0 10px; + font-size: 16px; +} + +.card input, +.card select, +.card textarea { + width: 100%; + margin-bottom: 8px; +} + +.actions-row { + margin-top: 8px; +} + +.actions-row--inline { + display: flex; + gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; +} + +.card--full { + grid-column: 1 / -1; +} + +.filter-inline { + display: grid; + grid-template-columns: minmax(280px, 2fr) minmax(180px, 1fr) minmax(180px, 1fr) 180px; + gap: 8px; + align-items: center; +} + +.filter-inline input, +.filter-inline select { + width: 100%; + margin-bottom: 0; +} + +a { + color: #73b1ff; + text-decoration: none; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + border-bottom: 1px solid #223651; + padding: 8px; + text-align: left; + font-size: 13px; + vertical-align: top; +} + +.split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +pre { + white-space: pre-wrap; + margin: 0; + background: #091425; + border: 1px solid #223651; + border-radius: 8px; + padding: 10px; +} + +.pill { + margin-top: 8px; + padding: 4px 8px; + border-radius: 999px; + width: fit-content; + font-size: 12px; + background: #29405f; +} + +.pill--high { + background: #5f1f2a; +} + +.pill--medium { + background: #664813; +} + +.pill--low { + background: #1f5a3a; +} + +.link-btn { + border: none; + background: transparent; + color: #73b1ff; + padding: 0; + cursor: pointer; +} + +.status { + margin-top: 12px; + color: #9db2cf; + font-size: 12px; +} + +.conn-pill { + font-size: 12px; + border: 1px solid #2a3f5e; + border-radius: 999px; + padding: 4px 10px; + background: #0f1e34; +} + +.conn-pill--up { + border-color: #1f5a3a; + color: #8de3b0; +} + +.conn-pill--down { + border-color: #5f1f2a; + color: #f5a9b3; +} + +.conn-pill--unknown { + border-color: #4a5e7e; + color: #c1cde0; +} + +.toast { + position: fixed; + right: 20px; + bottom: 20px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #2c405f; + background: #0f1e34; + z-index: 60; + max-width: 420px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + +.toast--ok { + border-color: #1f5a3a; +} + +.toast--error { + border-color: #5f1f2a; +} + +.skeleton-line { + height: 32px; + border-radius: 8px; + margin-bottom: 10px; + background: linear-gradient(90deg, #14253e 25%, #1d3558 50%, #14253e 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; +} + +.mini-chart { + margin-top: 10px; +} + +.mini-chart__row { + display: grid; + grid-template-columns: 70px 1fr 36px; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.mini-chart__label, +.mini-chart__value { + font-size: 12px; + color: #a8bad5; +} + +.mini-chart__bar-wrap { + height: 10px; + border-radius: 999px; + background: #10213a; + border: 1px solid #223651; + overflow: hidden; +} + +.mini-chart__bar { + height: 100%; + background: linear-gradient(90deg, #3973d2, #5aa1ff); +} + +.pager { + margin-top: 12px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.pager__meta { + color: #9db2cf; + font-size: 12px; +} + +.pager__controls { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.pager__btn, +.pager__num { + min-width: 34px; + padding: 6px 10px; +} + +.pager__num.active { + border-color: #4f7ec7; + background: rgba(61, 130, 247, 0.24); + color: #dce8ff; +} + +.pager__size { + width: auto; + min-width: 88px; +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@media (max-width: 1200px) { + .cards { + grid-template-columns: 1fr; + } + .filter-inline { + grid-template-columns: 1fr; + } + .actions-row--inline { + flex-wrap: wrap; + } + .split { + grid-template-columns: 1fr; + } +} diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx new file mode 100644 index 0000000..b7445ae --- /dev/null +++ b/web/src/layout/AppLayout.tsx @@ -0,0 +1,81 @@ +import { NavLink } from "react-router-dom"; +import type { ReactNode } from "react"; + +type Props = { + status: string; + connections: { + netxApi: "up" | "down" | "unknown"; + oclawBridge: "up" | "down" | "unknown"; + netxApiLatencyMs?: number; + oclawBridgeLatencyMs?: number; + oclawBridgeErrorKind?: string; + }; + onRefreshBatches: () => void; + onRefreshAlarms: () => void; + capabilities: { + alarms: boolean; + diagnostics: boolean; + aiAnalysis: boolean; + jobs: boolean; + }; + children: ReactNode; +}; + +export function AppLayout({ status, connections, onRefreshBatches, onRefreshAlarms, capabilities, children }: Props) { + const netxSuffix = + typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : ""; + const oclawSuffix = + typeof connections.oclawBridgeLatencyMs === "number" + ? ` (${connections.oclawBridgeLatencyMs}ms)` + : connections.oclawBridgeErrorKind + ? ` (${connections.oclawBridgeErrorKind})` + : ""; + return ( +
+ + +
+
+
+

运维中心

+
+
+ netx api: {connections.netxApi}{netxSuffix} + oclaw bridge: {connections.oclawBridge}{oclawSuffix} + + +
+
+ + {children} +
status: {status}
+
+
+ ); +} diff --git a/web/src/layout/ErrorBoundary.tsx b/web/src/layout/ErrorBoundary.tsx new file mode 100644 index 0000000..2242da7 --- /dev/null +++ b/web/src/layout/ErrorBoundary.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +type State = { + hasError: boolean; + message: string; +}; + +export class ErrorBoundary extends React.Component { + state: State = { hasError: false, message: "" }; + + static getDerivedStateFromError(error: unknown): State { + return { hasError: true, message: String(error) }; + } + + componentDidCatch(error: unknown) { + console.error("UI crashed:", error); + } + + render() { + if (!this.state.hasError) return this.props.children; + return ( +
+

页面发生错误

+

请刷新页面,若持续出现请联系运维并附上错误信息。

+
{this.state.message}
+
+ ); + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..9096385 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import './index.css' +import App from './App.tsx' +import { ErrorBoundary } from './layout/ErrorBoundary' + +const queryClient = new QueryClient() + +createRoot(document.getElementById('root')!).render( + + + + + + + + + , +) diff --git a/web/src/pages/AiAnalysisPage.tsx b/web/src/pages/AiAnalysisPage.tsx new file mode 100644 index 0000000..57ed3fe --- /dev/null +++ b/web/src/pages/AiAnalysisPage.tsx @@ -0,0 +1,194 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { AiAnalyzeHistoryItem, Batch } from "../types"; +import { fetchAiAnalyzeHistory } from "../services/api"; +import { formatSystemTime } from "../utils/time"; + +type Props = { + batches: Batch[]; + selectedBatch: string; + onSelectBatch: (v: string) => void; + aiAlarmCode: string; + aiNeName: string; + aiSeverity: string; + onChangeAiAlarmCode: (v: string) => void; + onChangeAiNeName: (v: string) => void; + onChangeAiSeverity: (v: string) => void; + onResetAiFilters: () => void; + question: string; + answer: string; + isRunning: boolean; + enabled: boolean; + onChangeQuestion: (v: string) => void; + onRunAnalyze: () => void; +}; + +export function AiAnalysisPage({ + batches, + selectedBatch, + onSelectBatch, + aiAlarmCode, + aiNeName, + aiSeverity, + onChangeAiAlarmCode, + onChangeAiNeName, + onChangeAiSeverity, + onResetAiFilters, + question, + answer, + isRunning, + enabled, + onChangeQuestion, + onRunAnalyze, +}: Props) { + const [histPage, setHistPage] = useState(1); + const [histPageSize, setHistPageSize] = useState(10); + const [selectedHistId, setSelectedHistId] = useState(null); + + const historyQuery = useQuery({ + queryKey: ["aiAnalyzeHistory", selectedBatch, histPage, histPageSize], + queryFn: () => fetchAiAnalyzeHistory({ batchId: selectedBatch, page: histPage, pageSize: histPageSize }), + enabled: Boolean(selectedBatch), + staleTime: 3000, + }); + + const historyItems: AiAnalyzeHistoryItem[] = useMemo(() => (historyQuery.data?.items || []) as AiAnalyzeHistoryItem[], [historyQuery.data]); + const total = Number(historyQuery.data?.total || 0); + const totalPages = Math.max(1, Math.ceil(Math.max(0, total) / Math.max(1, histPageSize))); + const selectedItem = useMemo( + () => historyItems.find((x) => Number(x.id) === Number(selectedHistId)), + [historyItems, selectedHistId], + ); + + return ( +
+
+

分析范围(前提条件)

+

+ 此处筛选仅作用于 AI 分析请求中的 dataset_ref.filters,与告警列表页的过滤相互独立。 +

+
+ + onChangeAiAlarmCode(e.target.value)} + disabled={!selectedBatch} + /> + onChangeAiNeName(e.target.value)} + disabled={!selectedBatch} + /> + +
+
+ +
+
+ +
+
+

AI 分析输入

+