mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
commit
f49c0b5bad
56 changed files with 6974 additions and 0 deletions
9
.env.example
Normal file
9
.env.example
Normal file
|
|
@ -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
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
28
PROD_MIN_CHECKLIST.md
Normal file
28
PROD_MIN_CHECKLIST.md
Normal file
|
|
@ -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.
|
||||||
|
|
||||||
127
README.md
Normal file
127
README.md
Normal file
|
|
@ -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=<token>`
|
||||||
|
- `netx` env: `NETX_OCLAW_ANALYZE_TOKEN=<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`
|
||||||
15
mcp_install_payload.json
Normal file
15
mcp_install_payload.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
3
netx_api/__init__.py
Normal file
3
netx_api/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
50
netx_api/ap_client.py
Normal file
50
netx_api/ap_client.py
Normal file
|
|
@ -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()}
|
||||||
25
netx_api/config.py
Normal file
25
netx_api/config.py
Normal file
|
|
@ -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()
|
||||||
95
netx_api/config/parsers/zte_alarm_monitor_v1.yaml
Normal file
95
netx_api/config/parsers/zte_alarm_monitor_v1.yaml
Normal file
|
|
@ -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
|
||||||
14
netx_api/db.py
Normal file
14
netx_api/db.py
Normal file
|
|
@ -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)
|
||||||
202
netx_api/importer.py
Normal file
202
netx_api/importer.py
Normal file
|
|
@ -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()]
|
||||||
670
netx_api/main.py
Normal file
670
netx_api/main.py
Normal file
|
|
@ -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)
|
||||||
207
netx_api/mcp_server.py
Normal file
207
netx_api/mcp_server.py
Normal file
|
|
@ -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()
|
||||||
101
netx_api/models.py
Normal file
101
netx_api/models.py
Normal file
|
|
@ -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)
|
||||||
46
netx_api/parser_config.py
Normal file
46
netx_api/parser_config.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
77
netx_api/schemas.py
Normal file
77
netx_api/schemas.py
Normal file
|
|
@ -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]
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
|
|
@ -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
|
||||||
13
scripts/.run/netx.err.log
Normal file
13
scripts/.run/netx.err.log
Normal file
|
|
@ -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")
|
||||||
206
scripts/.run/netx.out.log
Normal file
206
scripts/.run/netx.out.log
Normal file
|
|
@ -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
|
||||||
1
scripts/.run/netx.pid
Normal file
1
scripts/.run/netx.pid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
37360
|
||||||
0
scripts/.run/web.err.log
Normal file
0
scripts/.run/web.err.log
Normal file
9
scripts/.run/web.out.log
Normal file
9
scripts/.run/web.out.log
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
|
||||||
|
> web@0.0.0 dev
|
||||||
|
> vite --host 127.0.0.1 --port 5173
|
||||||
|
|
||||||
|
|
||||||
|
[32m[1mVITE[22m v8.0.10[39m [2mready in [0m[1m269[22m[2m[0m ms[22m
|
||||||
|
|
||||||
|
[32m➜[39m [1mLocal[22m: [36mhttp://127.0.0.1:[1m5173[22m/[39m
|
||||||
|
[2m[32m ➜[39m[22m[2m press [22m[1mh + enter[22m[2m to show help[22m
|
||||||
1
scripts/.run/web.pid
Normal file
1
scripts/.run/web.pid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
38188
|
||||||
35
scripts/reset_db.py
Normal file
35
scripts/reset_db.py
Normal file
|
|
@ -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()
|
||||||
|
|
||||||
92
scripts/start_netx.ps1
Normal file
92
scripts/start_netx.ps1
Normal file
|
|
@ -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
|
||||||
|
|
||||||
76
scripts/stop_netx.ps1
Normal file
76
scripts/stop_netx.ps1
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
|
|
@ -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?
|
||||||
73
web/README.md
Normal file
73
web/README.md
Normal file
|
|
@ -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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
22
web/eslint.config.js
Normal file
22
web/eslint.config.js
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>web</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2827
web/package-lock.json
generated
Normal file
2827
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
web/package.json
Normal file
32
web/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/public/favicon.svg
Normal file
1
web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
web/public/icons.svg
Normal file
24
web/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
184
web/src/App.css
Normal file
184
web/src/App.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
346
web/src/App.tsx
Normal file
346
web/src/App.tsx
Normal file
|
|
@ -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<any>("/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<any>("/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<any>("/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<any>(`/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<any>("/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 (
|
||||||
|
<AppLayout
|
||||||
|
status={status}
|
||||||
|
connections={{
|
||||||
|
netxApi: integrationsQuery.data?.netx_api?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
||||||
|
oclawBridge: integrationsQuery.data?.oclaw_bridge?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
|
||||||
|
netxApiLatencyMs: typeof integrationsQuery.data?.db?.latency_ms === "number" ? integrationsQuery.data.db.latency_ms : undefined,
|
||||||
|
oclawBridgeLatencyMs:
|
||||||
|
typeof integrationsQuery.data?.oclaw_bridge?.latency_ms === "number"
|
||||||
|
? integrationsQuery.data.oclaw_bridge.latency_ms
|
||||||
|
: undefined,
|
||||||
|
oclawBridgeErrorKind:
|
||||||
|
typeof integrationsQuery.data?.oclaw_bridge?.error_kind === "string"
|
||||||
|
? integrationsQuery.data.oclaw_bridge.error_kind
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
capabilities={capabilities}
|
||||||
|
onRefreshBatches={() => queryClient.invalidateQueries({ queryKey: ["batches"] })}
|
||||||
|
onRefreshAlarms={() => queryClient.invalidateQueries({ queryKey: ["alarms"] })}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/diagnostics" replace />} />
|
||||||
|
<Route
|
||||||
|
path="/ingest"
|
||||||
|
element={
|
||||||
|
<IngestPage
|
||||||
|
isImportingAlarm={importAlarmMutation.isPending}
|
||||||
|
alarmImportStatus={alarmImportStatus}
|
||||||
|
isImportingLogs={importLogsMutation.isPending}
|
||||||
|
logsImportStatus={logsImportStatus}
|
||||||
|
history={(jobsQuery.data?.items || []) as ImportHistoryItem[]}
|
||||||
|
onImportAlarmExcel={(f) => f && importAlarmMutation.mutate(f)}
|
||||||
|
onImportLogs={(f) => f && importLogsMutation.mutate(f)}
|
||||||
|
onOpenBatch={(bid) => {
|
||||||
|
setSelectedBatch(String(bid || "").trim());
|
||||||
|
setAlarmCode("");
|
||||||
|
setNeName("");
|
||||||
|
setSeverity("");
|
||||||
|
navigate("/alarms");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/alarms"
|
||||||
|
element={
|
||||||
|
<AlarmsPage
|
||||||
|
batches={(batchesQuery.data?.items || []) as Batch[]}
|
||||||
|
alarms={(alarmsQuery.data?.items || []) as Alarm[]}
|
||||||
|
total={Number(alarmsQuery.data?.total || 0)}
|
||||||
|
isLoading={alarmsQuery.isLoading || alarmsQuery.isFetching}
|
||||||
|
selectedBatch={selectedBatch}
|
||||||
|
alarmCode={alarmCode}
|
||||||
|
neName={neName}
|
||||||
|
severity={severity}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onSelectBatch={setSelectedBatch}
|
||||||
|
onChangeAlarmCode={setAlarmCode}
|
||||||
|
onChangeNeName={setNeName}
|
||||||
|
onChangeSeverity={setSeverity}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePageSize={setPageSize}
|
||||||
|
onQueryNow={() => 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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/diagnostics" element={<DiagnosticsPage diagnostics={(diagnosticsQuery.data || null) as Diagnostics | null} />} />
|
||||||
|
<Route
|
||||||
|
path="/ai-analysis"
|
||||||
|
element={
|
||||||
|
<AiAnalysisPage
|
||||||
|
batches={(batchesQuery.data?.items || []) as Batch[]}
|
||||||
|
selectedBatch={selectedBatch}
|
||||||
|
onSelectBatch={setSelectedBatch}
|
||||||
|
aiAlarmCode={aiAlarmCode}
|
||||||
|
aiNeName={aiNeName}
|
||||||
|
aiSeverity={aiSeverity}
|
||||||
|
onChangeAiAlarmCode={setAiAlarmCode}
|
||||||
|
onChangeAiNeName={setAiNeName}
|
||||||
|
onChangeAiSeverity={setAiSeverity}
|
||||||
|
onResetAiFilters={() => {
|
||||||
|
setAiAlarmCode("");
|
||||||
|
setAiNeName("");
|
||||||
|
setAiSeverity("");
|
||||||
|
}}
|
||||||
|
question={question}
|
||||||
|
answer={answer}
|
||||||
|
isRunning={analyzeMutation.isPending}
|
||||||
|
enabled={capabilities.aiAnalysis && Boolean(selectedBatch)}
|
||||||
|
onChangeQuestion={setQuestion}
|
||||||
|
onRunAnalyze={() => analyzeMutation.mutate()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/jobs" element={<JobsPage />} />
|
||||||
|
</Routes>
|
||||||
|
{toast && <div className={`toast toast--${toast.type}`}>{toast.text}</div>}
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
web/src/assets/hero.png
Normal file
BIN
web/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
web/src/assets/react.svg
Normal file
1
web/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4 KiB |
1
web/src/assets/vite.svg
Normal file
1
web/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
20
web/src/hooks/useToast.ts
Normal file
20
web/src/hooks/useToast.ts
Normal file
|
|
@ -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<ToastState>(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 };
|
||||||
|
}
|
||||||
|
|
||||||
371
web/src/index.css
Normal file
371
web/src/index.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
web/src/layout/AppLayout.tsx
Normal file
81
web/src/layout/AppLayout.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="app">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="logo">netx</div>
|
||||||
|
<NavLink
|
||||||
|
to="/diagnostics"
|
||||||
|
className={({ isActive }) => `nav-item${isActive ? " active" : ""}${!capabilities.diagnostics ? " disabled" : ""}`}
|
||||||
|
>
|
||||||
|
诊断中心
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/ingest" className={({ isActive }) => `nav-item${isActive ? " active" : ""}`}>
|
||||||
|
数据接入
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/alarms" className={({ isActive }) => `nav-item${isActive ? " active" : ""}`}>
|
||||||
|
告警中心
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/ai-analysis"
|
||||||
|
className={({ isActive }) => `nav-item${isActive ? " active" : ""}${!capabilities.aiAnalysis ? " disabled" : ""}`}
|
||||||
|
>
|
||||||
|
AI 分析
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/jobs"
|
||||||
|
className={({ isActive }) => `nav-item${isActive ? " active" : ""}${!capabilities.jobs ? " disabled" : ""}`}
|
||||||
|
>
|
||||||
|
作业中心
|
||||||
|
</NavLink>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>运维中心</h1>
|
||||||
|
</div>
|
||||||
|
<div className="top-actions">
|
||||||
|
<span className={`conn-pill conn-pill--${connections.netxApi}`}>netx api: {connections.netxApi}{netxSuffix}</span>
|
||||||
|
<span className={`conn-pill conn-pill--${connections.oclawBridge}`}>oclaw bridge: {connections.oclawBridge}{oclawSuffix}</span>
|
||||||
|
<button onClick={onRefreshBatches} disabled={!capabilities.alarms}>刷新批次</button>
|
||||||
|
<button onClick={onRefreshAlarms} disabled={!capabilities.alarms}>刷新告警</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
<footer className="status">status: {status}</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
web/src/layout/ErrorBoundary.tsx
Normal file
29
web/src/layout/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
hasError: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ErrorBoundary extends React.Component<React.PropsWithChildren, State> {
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 24, fontFamily: "Inter, Segoe UI, Arial, sans-serif" }}>
|
||||||
|
<h2>页面发生错误</h2>
|
||||||
|
<p>请刷新页面,若持续出现请联系运维并附上错误信息。</p>
|
||||||
|
<pre>{this.state.message}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/src/main.tsx
Normal file
21
web/src/main.tsx
Normal file
|
|
@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
194
web/src/pages/AiAnalysisPage.tsx
Normal file
194
web/src/pages/AiAnalysisPage.tsx
Normal file
|
|
@ -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<number | null>(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 (
|
||||||
|
<section className="panel">
|
||||||
|
<article className="card card--full">
|
||||||
|
<h3>分析范围(前提条件)</h3>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
此处筛选仅作用于 AI 分析请求中的 <code>dataset_ref.filters</code>,与告警列表页的过滤相互独立。
|
||||||
|
</p>
|
||||||
|
<div className="filter-inline">
|
||||||
|
<select value={selectedBatch} onChange={(e) => onSelectBatch(e.target.value)}>
|
||||||
|
{batches.length === 0 && <option value="">暂无批次</option>}
|
||||||
|
{batches.map((b) => (
|
||||||
|
<option key={b.batch_id} value={b.batch_id}>
|
||||||
|
{b.batch_id} · success={b.success_rows} · failed={b.failed_rows}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
placeholder="告警码 contains"
|
||||||
|
value={aiAlarmCode}
|
||||||
|
onChange={(e) => onChangeAiAlarmCode(e.target.value)}
|
||||||
|
disabled={!selectedBatch}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="网元 contains"
|
||||||
|
value={aiNeName}
|
||||||
|
onChange={(e) => onChangeAiNeName(e.target.value)}
|
||||||
|
disabled={!selectedBatch}
|
||||||
|
/>
|
||||||
|
<select value={aiSeverity} onChange={(e) => onChangeAiSeverity(e.target.value)} disabled={!selectedBatch}>
|
||||||
|
<option value="">全部级别</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
<option value="major">major</option>
|
||||||
|
<option value="minor">minor</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="actions-row actions-row--inline" style={{ marginTop: 10 }}>
|
||||||
|
<button type="button" onClick={onResetAiFilters} disabled={!selectedBatch}>
|
||||||
|
重置 AI 筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="panel split" style={{ marginTop: 16 }}>
|
||||||
|
<article>
|
||||||
|
<h2>AI 分析输入</h2>
|
||||||
|
<textarea value={question} onChange={(e) => onChangeQuestion(e.target.value)} rows={6} disabled={!enabled} />
|
||||||
|
<button type="button" onClick={onRunAnalyze} disabled={!enabled || isRunning}>
|
||||||
|
{isRunning ? "分析中..." : "运行分析"}
|
||||||
|
</button>
|
||||||
|
{!selectedBatch && <p className="muted">请先选择批次。</p>}
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h2>AI 回答</h2>
|
||||||
|
<pre>{answer || "暂无结果"}</pre>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article className="card card--full" style={{ marginTop: 16 }}>
|
||||||
|
<h3>AI 运维问答历史</h3>
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
默认展示当前批次的历史记录(分页)。点击“查看”可在下方展开回答内容。
|
||||||
|
</p>
|
||||||
|
{historyQuery.isLoading && <div className="skeleton-line">历史记录加载中...</div>}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>time</th>
|
||||||
|
<th>status</th>
|
||||||
|
<th>question</th>
|
||||||
|
<th style={{ width: 120 }}>action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{historyItems.map((h) => (
|
||||||
|
<tr key={h.id}>
|
||||||
|
<td>{formatSystemTime(h.created_at)}</td>
|
||||||
|
<td>{h.ok ? "ok" : "error"}</td>
|
||||||
|
<td style={{ maxWidth: 780, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{h.question}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" className="link-btn" onClick={() => setSelectedHistId(Number(h.id))}>
|
||||||
|
查看
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!historyQuery.isLoading && historyItems.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>暂无历史记录</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<div className="pager__meta">
|
||||||
|
共 {total} 条 · 第 {histPage}/{totalPages} 页
|
||||||
|
</div>
|
||||||
|
<div className="pager__controls">
|
||||||
|
<button className="pager__btn" onClick={() => setHistPage(Math.max(1, histPage - 1))} disabled={histPage <= 1}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pager__btn"
|
||||||
|
onClick={() => setHistPage(Math.min(totalPages, histPage + 1))}
|
||||||
|
disabled={histPage >= totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<select value={histPageSize} onChange={(e) => setHistPageSize(Number(e.target.value) || 10)}>
|
||||||
|
<option value={10}>10/页</option>
|
||||||
|
<option value={20}>20/页</option>
|
||||||
|
<option value={50}>50/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<h4 style={{ marginBottom: 6 }}>回答预览</h4>
|
||||||
|
{!selectedItem && <div className="muted">请选择一条历史记录查看回答。</div>}
|
||||||
|
{selectedItem && (
|
||||||
|
<>
|
||||||
|
{!selectedItem.ok && <div className="muted">错误:{String(selectedItem.error || "")}</div>}
|
||||||
|
<pre style={{ maxHeight: 420, overflow: "auto" }}>{String(selectedItem.answer || "").trim() || "(空)"}</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
web/src/pages/AlarmsPage.tsx
Normal file
167
web/src/pages/AlarmsPage.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import type { Alarm, Batch } from "../types";
|
||||||
|
import { formatSystemTime } from "../utils/time";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
batches: Batch[];
|
||||||
|
alarms: Alarm[];
|
||||||
|
total: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
selectedBatch: string;
|
||||||
|
alarmCode: string;
|
||||||
|
neName: string;
|
||||||
|
severity: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
onSelectBatch: (v: string) => void;
|
||||||
|
onChangeAlarmCode: (v: string) => void;
|
||||||
|
onChangeNeName: (v: string) => void;
|
||||||
|
onChangeSeverity: (v: string) => void;
|
||||||
|
onChangePage: (v: number) => void;
|
||||||
|
onChangePageSize: (v: number) => void;
|
||||||
|
onQueryNow: () => void;
|
||||||
|
onResetFilters: () => void;
|
||||||
|
onDeleteBatch: () => void;
|
||||||
|
isDeletingBatch: boolean;
|
||||||
|
onDeleteAllBatches: () => void;
|
||||||
|
isDeletingAllBatches: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlarmsPage(props: Props) {
|
||||||
|
const {
|
||||||
|
batches,
|
||||||
|
alarms,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
selectedBatch,
|
||||||
|
alarmCode,
|
||||||
|
neName,
|
||||||
|
severity,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onSelectBatch,
|
||||||
|
onChangeAlarmCode,
|
||||||
|
onChangeNeName,
|
||||||
|
onChangeSeverity,
|
||||||
|
onChangePage,
|
||||||
|
onChangePageSize,
|
||||||
|
onQueryNow,
|
||||||
|
onResetFilters,
|
||||||
|
onDeleteBatch,
|
||||||
|
isDeletingBatch,
|
||||||
|
onDeleteAllBatches,
|
||||||
|
isDeletingAllBatches,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(Math.max(0, total) / Math.max(1, pageSize)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="cards">
|
||||||
|
<article className="card card--full">
|
||||||
|
<h3>批次与过滤</h3>
|
||||||
|
<div className="filter-inline">
|
||||||
|
<select value={selectedBatch} onChange={(e) => onSelectBatch(e.target.value)}>
|
||||||
|
{batches.map((b) => (
|
||||||
|
<option key={b.batch_id} value={b.batch_id}>
|
||||||
|
{b.batch_id} · success={b.success_rows} · failed={b.failed_rows}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input placeholder="告警码 contains" value={alarmCode} onChange={(e) => onChangeAlarmCode(e.target.value)} />
|
||||||
|
<input placeholder="网元 contains" value={neName} onChange={(e) => onChangeNeName(e.target.value)} />
|
||||||
|
<select value={severity} onChange={(e) => onChangeSeverity(e.target.value)}>
|
||||||
|
<option value="">全部级别</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
<option value="major">major</option>
|
||||||
|
<option value="minor">minor</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="unknown">unknown</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="actions-row actions-row--inline">
|
||||||
|
<button onClick={onQueryNow}>查询</button>
|
||||||
|
<button onClick={onResetFilters}>重置过滤</button>
|
||||||
|
<button onClick={() => window.open(`/v1/batches/${encodeURIComponent(selectedBatch)}/errors.csv`, "_blank")} disabled={!selectedBatch}>
|
||||||
|
下载导入失败明细(CSV)
|
||||||
|
</button>
|
||||||
|
<button onClick={onDeleteBatch} disabled={!selectedBatch || isDeletingBatch}>
|
||||||
|
{isDeletingBatch ? "删除中..." : "删除当前批次"}
|
||||||
|
</button>
|
||||||
|
<button onClick={onDeleteAllBatches} disabled={isDeletingAllBatches}>
|
||||||
|
{isDeletingAllBatches ? "清空中..." : "清空全部批次"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>
|
||||||
|
告警列表 ({alarms.length}/{total})
|
||||||
|
</h2>
|
||||||
|
{isLoading && <div className="skeleton-line">告警数据加载中...</div>}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>time</th>
|
||||||
|
<th>severity</th>
|
||||||
|
<th>ne</th>
|
||||||
|
<th>alarm</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{alarms.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{formatSystemTime(a.alarm_time)}</td>
|
||||||
|
<td>{a.severity_norm}</td>
|
||||||
|
<td>{a.ne_name}</td>
|
||||||
|
<td>
|
||||||
|
<button className="link-btn" onClick={() => onChangeAlarmCode(a.alarm_code)}>
|
||||||
|
{a.alarm_code}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && alarms.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<div className="pager__meta">共 {total} 条 · 第 {page}/{totalPages} 页</div>
|
||||||
|
<div className="pager__controls">
|
||||||
|
<button className="pager__btn" onClick={() => onChangePage(Math.max(1, page - 1))} disabled={page <= 1}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(7, totalPages) }).map((_, i) => {
|
||||||
|
const start = Math.max(1, Math.min(totalPages - 6, page - 3));
|
||||||
|
const p = start + i;
|
||||||
|
if (p > totalPages) return null;
|
||||||
|
return (
|
||||||
|
<button key={p} className={`pager__num${p === page ? " active" : ""}`} onClick={() => onChangePage(p)}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
className="pager__btn"
|
||||||
|
onClick={() => onChangePage(Math.min(totalPages, page + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<select className="pager__size" value={String(pageSize)} onChange={(e) => onChangePageSize(Number(e.target.value) || 80)}>
|
||||||
|
<option value="50">50/页</option>
|
||||||
|
<option value="80">80/页</option>
|
||||||
|
<option value="100">100/页</option>
|
||||||
|
<option value="200">200/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
web/src/pages/DiagnosticsPage.tsx
Normal file
46
web/src/pages/DiagnosticsPage.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { Diagnostics } from "../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
diagnostics: Diagnostics | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MiniChart({ title, items }: { title: string; items: Array<{ key: string; count: number }> }) {
|
||||||
|
const total = Math.max(1, Number(items.reduce((n, x) => n + Number(x.count || 0), 0)));
|
||||||
|
return (
|
||||||
|
<article className="card">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<div className="mini-chart">
|
||||||
|
{items.map((x) => (
|
||||||
|
<div className="mini-chart__row" key={`${title}-${x.key}-${x.count}`}>
|
||||||
|
<span className="mini-chart__label">{x.key || "unknown"}</span>
|
||||||
|
<div className="mini-chart__bar-wrap">
|
||||||
|
<div className="mini-chart__bar" style={{ width: `${Math.max(2, Math.round((x.count / total) * 100))}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="mini-chart__value">{x.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <div className="muted">暂无数据</div>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiagnosticsPage({ diagnostics }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="panel">
|
||||||
|
<h2>统计看板(最新批次)</h2>
|
||||||
|
<div className="muted">
|
||||||
|
batch_id: {diagnostics?.batch_id || "-"} · total_alarms: {diagnostics?.total_alarms ?? "-"}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="cards">
|
||||||
|
<MiniChart title="级别分布" items={(diagnostics?.severity_summary || []) as any} />
|
||||||
|
<MiniChart title="告警类型 Top" items={(diagnostics?.top_alarm_codes || []) as any} />
|
||||||
|
<MiniChart title="网元 Top" items={(diagnostics?.top_ne || []) as any} />
|
||||||
|
<MiniChart title="协议/领域 Top" items={(diagnostics?.protocol_summary || []) as any} />
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
web/src/pages/IngestPage.tsx
Normal file
119
web/src/pages/IngestPage.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { ImportHistoryItem } from "../types";
|
||||||
|
import { formatSystemTime } from "../utils/time";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isImportingAlarm: boolean;
|
||||||
|
alarmImportStatus: string;
|
||||||
|
isImportingLogs: boolean;
|
||||||
|
logsImportStatus: string;
|
||||||
|
history: ImportHistoryItem[];
|
||||||
|
onImportAlarmExcel: (file: File | null) => void;
|
||||||
|
onImportLogs: (file: File | null) => void;
|
||||||
|
onOpenBatch: (batchId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IngestPage({
|
||||||
|
isImportingAlarm,
|
||||||
|
alarmImportStatus,
|
||||||
|
isImportingLogs,
|
||||||
|
logsImportStatus,
|
||||||
|
history,
|
||||||
|
onImportAlarmExcel,
|
||||||
|
onImportLogs,
|
||||||
|
onOpenBatch,
|
||||||
|
}: Props) {
|
||||||
|
const [alarmFileName, setAlarmFileName] = useState("");
|
||||||
|
const [logFileName, setLogFileName] = useState("");
|
||||||
|
const items = useMemo(() => history.slice().sort((a, b) => b.ts_ms - a.ts_ms).slice(0, 10), [history]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="panel">
|
||||||
|
<h2>数据接入中心</h2>
|
||||||
|
<p className="muted">这里是 netx 的主页入口:导入告警 / 日志 / 配置等数据,后续扩展作业化能力。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="cards">
|
||||||
|
<article className="card">
|
||||||
|
<h3>告警导入(Excel)</h3>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
disabled={isImportingAlarm}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0] || null;
|
||||||
|
setAlarmFileName(f?.name || "");
|
||||||
|
onImportAlarmExcel(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="muted">{alarmFileName ? `已选择:${alarmFileName}` : "选择 Excel 后自动上传导入"}</div>
|
||||||
|
<div className="muted">{alarmImportStatus}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="card">
|
||||||
|
<h3>日志导入(预留)</h3>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
disabled={isImportingLogs}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0] || null;
|
||||||
|
setLogFileName(f?.name || "");
|
||||||
|
onImportLogs(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="muted">{logFileName ? `已选择:${logFileName}` : "选择日志文件后上传(当前仅占位接口)"}</div>
|
||||||
|
<div className="muted">{logsImportStatus}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="card">
|
||||||
|
<h3>配置导入(预留)</h3>
|
||||||
|
<button disabled={true} title="后续支持:配置文件导入、解析、对比与风险评估">
|
||||||
|
配置导入(规划中)
|
||||||
|
</button>
|
||||||
|
<div className="muted">后续接入:配置采集/割接对比/配置诊断。</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>最近导入</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>time</th>
|
||||||
|
<th>kind</th>
|
||||||
|
<th>file</th>
|
||||||
|
<th>result</th>
|
||||||
|
<th>batch</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((x) => (
|
||||||
|
<tr key={`${x.kind}-${x.ts_ms}-${x.file_name}`}>
|
||||||
|
<td>{formatSystemTime(x.ts_ms)}</td>
|
||||||
|
<td>{x.kind}</td>
|
||||||
|
<td>{x.file_name}</td>
|
||||||
|
<td>{x.ok ? "ok" : "error"}</td>
|
||||||
|
<td>
|
||||||
|
{x.batch_id ? (
|
||||||
|
<button className="link-btn" onClick={() => onOpenBatch(x.batch_id!)}>
|
||||||
|
{x.batch_id}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>暂无导入记录</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
9
web/src/pages/JobsPage.tsx
Normal file
9
web/src/pages/JobsPage.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function JobsPage() {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<h2>作业中心(预留)</h2>
|
||||||
|
<p>后续接入:网管同步作业、配置解析作业、日志解析作业、抓包分析作业。</p>
|
||||||
|
<p>当前已具备:批次导入统计、错误 CSV 下载、诊断与 AI 分析闭环。</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
web/src/services/api.ts
Normal file
91
web/src/services/api.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { AiAnalyzeHistoryResponse, AlarmQueryResponse, Batch, Diagnostics, ImportHistoryItem, IntegrationStatus } from "../types";
|
||||||
|
|
||||||
|
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||||
|
const res = await fetch(path, { headers: { accept: "application/json" } });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
||||||
|
return data as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiPostForm = async <T,>(path: string, form: FormData): Promise<T> => {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
||||||
|
return data as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { accept: "application/json" },
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const data = text ? JSON.parse(text) : {};
|
||||||
|
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
||||||
|
return data as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchBatches = () => apiGet<{ items: Batch[] }>("/v1/batches?limit=30");
|
||||||
|
|
||||||
|
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
||||||
|
|
||||||
|
export const fetchJobs = () =>
|
||||||
|
apiGet<{ items: Array<{ id: number; kind: string; file_name: string; batch_id?: string | null; ok: boolean; summary: string; created_at: string }> }>(
|
||||||
|
"/v1/jobs?limit=20",
|
||||||
|
).then((x) => {
|
||||||
|
const items: ImportHistoryItem[] = (x.items || []).map((j) => ({
|
||||||
|
kind: (j.kind as ImportHistoryItem["kind"]) || "alarms",
|
||||||
|
ts_ms: Date.parse(j.created_at) || Date.now(),
|
||||||
|
file_name: j.file_name || "",
|
||||||
|
batch_id: j.batch_id || undefined,
|
||||||
|
ok: Boolean(j.ok),
|
||||||
|
summary: j.summary || "",
|
||||||
|
}));
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchDiagnostics = (batchId: string) =>
|
||||||
|
apiGet<Diagnostics>(`/v1/diagnostics?batch_id=${encodeURIComponent(batchId)}`);
|
||||||
|
|
||||||
|
export const fetchAiAnalyzeHistory = (params: { batchId?: string; page: number; pageSize: number }) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params.batchId) p.set("batch_id", params.batchId);
|
||||||
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||||
|
return apiGet<AiAnalyzeHistoryResponse>(`/v1/ap/history?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAlarms = (params: {
|
||||||
|
batchId: string;
|
||||||
|
alarmCode: string;
|
||||||
|
neName: string;
|
||||||
|
severity: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params.batchId) p.set("batch_id", params.batchId);
|
||||||
|
if (params.alarmCode) p.set("alarm_code", params.alarmCode);
|
||||||
|
if (params.neName) p.set("ne_name", params.neName);
|
||||||
|
if (params.severity) p.set("severity", params.severity);
|
||||||
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 80)))));
|
||||||
|
return apiGet<AlarmQueryResponse>(`/v1/alarms?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
75
web/src/types.ts
Normal file
75
web/src/types.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
export type Batch = {
|
||||||
|
batch_id: string;
|
||||||
|
source_file: string;
|
||||||
|
status: string;
|
||||||
|
total_rows: number;
|
||||||
|
success_rows: number;
|
||||||
|
failed_rows: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Alarm = {
|
||||||
|
id: number;
|
||||||
|
alarm_time: string;
|
||||||
|
severity_norm: string;
|
||||||
|
ne_name: string;
|
||||||
|
alarm_code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlarmQueryResponse = {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: Alarm[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Diagnostics = {
|
||||||
|
batch_id: string;
|
||||||
|
total_alarms: number;
|
||||||
|
severity_summary?: Array<{ key: string; count: number }>;
|
||||||
|
top_alarm_codes?: Array<{ key: string; count: number }>;
|
||||||
|
top_ne?: Array<{ key: string; count: number }>;
|
||||||
|
protocol_summary?: Array<{ key: string; count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntegrationStatus = {
|
||||||
|
netx_api: { status: "up" | "down" | "unknown"; [k: string]: unknown };
|
||||||
|
db: { status: "up" | "down" | "unknown"; latency_ms?: number; error?: string; [k: string]: unknown };
|
||||||
|
oclaw_bridge: {
|
||||||
|
status: "up" | "down" | "unknown";
|
||||||
|
latency_ms?: number;
|
||||||
|
http_status?: number | null;
|
||||||
|
error_kind?: string;
|
||||||
|
error?: string;
|
||||||
|
detail?: unknown;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportHistoryItem = {
|
||||||
|
kind: "alarms" | "logs" | "config";
|
||||||
|
ts_ms: number;
|
||||||
|
file_name: string;
|
||||||
|
batch_id?: string;
|
||||||
|
ok: boolean;
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiAnalyzeHistoryItem = {
|
||||||
|
id: number;
|
||||||
|
analysis_request_id?: string;
|
||||||
|
batch_id: string;
|
||||||
|
question: string;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
ok: boolean;
|
||||||
|
answer?: string;
|
||||||
|
error?: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiAnalyzeHistoryResponse = {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
items: AiAnalyzeHistoryItem[];
|
||||||
|
};
|
||||||
12
web/src/utils/time.ts
Normal file
12
web/src/utils/time.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
export function formatSystemTime(value: string | number | Date | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
const d = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(value);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
hour12: false,
|
||||||
|
timeZone: systemTimeZone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
25
web/tsconfig.app.json
Normal file
25
web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
web/tsconfig.node.json
Normal file
24
web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
16
web/vite.config.ts
Normal file
16
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/v1": {
|
||||||
|
target: "http://127.0.0.1:8890",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue