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:
oliver 2026-05-03 23:43:21 +08:00
commit f49c0b5bad
56 changed files with 6974 additions and 0 deletions

9
.env.example Normal file
View 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
View file

@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
.env
build/
dist/

28
PROD_MIN_CHECKLIST.md Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

50
netx_api/ap_client.py Normal file
View 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
View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
37360

0
scripts/.run/web.err.log Normal file
View file

9
scripts/.run/web.out.log Normal file
View file

@ -0,0 +1,9 @@
> web@0.0.0 dev
> vite --host 127.0.0.1 --port 5173
VITE v8.0.10 ready in 269 ms
➜ Local: http://127.0.0.1:5173/
 ➜ press h + enter to show help

1
scripts/.run/web.pid Normal file
View file

@ -0,0 +1 @@
38188

35
scripts/reset_db.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

32
web/package.json Normal file
View 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

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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
web/src/assets/react.svg Normal file
View 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

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
View 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
View 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;
}
}

View 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>
);
}

View 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
View 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>,
)

View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
web/tsconfig.node.json Normal file
View 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
View 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,
},
},
},
})