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

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