refactor: config/apps 目录重组、文档重构、架构图收窄

- 中央:config/(prometheus,alertmanager,grafana)、apps/(tile-cache,topology-editor)
- 边缘:config/(vmagent,blackbox,targets)、apps/(onvif-exporter)
- env: TRAEFIK_PROVIDER、prometheus/env.example 详细说明
- 文档:README/doc 重构,EDGE_CACHE 合并到 EDGE_AGENT_CONFIG
- targets.csv 更新流程说明,ARCHITECTURE 图收窄

Made-with: Cursor
This commit is contained in:
2026-02-28 22:05:43 -05:00
parent 650e5145f1
commit ab1515dffb
48 changed files with 2071 additions and 509 deletions

View File

@@ -0,0 +1,267 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const http = require('http');
const { exec } = require('child_process');
const multer = require('multer');
const app = express();
const PORT = process.env.PORT || 4080;
const TILE_CACHE_URL = (process.env.TILE_CACHE_URL || '').trim();
const ROOT_DIR = path.join(__dirname, '..');
const CONFIG_DIR = process.env.CONFIG_DIR || null;
const TARGETS_CSV = CONFIG_DIR ? path.join(CONFIG_DIR, 'targets.csv') : null;
const upload = multer({ storage: multer.memoryStorage() });
// 内存模式(与 central-server 一起跑、无挂载时):存一份 targets 的解析结果
const DEFAULT_HEADER = ['type', 'ip', 'name', 'role', 'parent', 'uplink_type', 'network', 'device_type', 'model', 'location', 'username', 'password', 'onvif_port', 'lat', 'lon'];
let memoryStore = {
comments: ['# 统一监控 + 拓扑目标配置文件\n', '# 格式: type,ip,name,role,parent,uplink_type,...\n'],
header: DEFAULT_HEADER,
rows: [],
};
function isFileMode() {
return CONFIG_DIR && TARGETS_CSV && fs.existsSync(TARGETS_CSV);
}
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
function parseCsv(text) {
const lines = text.split(/\r?\n/);
const comments = [];
let headerLineIndex = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('#')) {
comments.push(lines[i]);
continue;
}
headerLineIndex = i;
break;
}
if (headerLineIndex === -1) {
return { comments: lines, header: DEFAULT_HEADER, rows: [] };
}
const header = lines[headerLineIndex].split(',').map(s => s.trim());
const rows = [];
for (let i = headerLineIndex + 1; i < lines.length; i++) {
const raw = lines[i];
if (!raw || raw.trim().startsWith('#')) continue;
const parts = raw.split(',');
const obj = {};
header.forEach((h, idx) => {
obj[h] = (parts[idx] || '').trim();
});
if (Object.values(obj).every(v => v === '')) continue;
rows.push(obj);
}
return { comments, header, rows };
}
function stringifyCsv(comments, header, rows) {
const lines = [];
if (comments && comments.length) {
lines.push(...comments);
}
if (header && header.length) {
lines.push(header.join(','));
}
for (const row of rows) {
const line = header.map(h => (row[h] || '').replace(/,/g, ' ')).join(',');
lines.push(line);
}
return lines.join('\n') + '\n';
}
function loadTargets() {
if (isFileMode()) {
const text = fs.readFileSync(TARGETS_CSV, 'utf8');
return parseCsv(text);
}
return { ...memoryStore, rows: [...memoryStore.rows] };
}
function saveTargets(parsed) {
if (isFileMode()) {
const text = stringifyCsv(parsed.comments, parsed.header, parsed.rows);
fs.writeFileSync(TARGETS_CSV, text, 'utf8');
return;
}
memoryStore = {
comments: parsed.comments || memoryStore.comments,
header: parsed.header || memoryStore.header,
rows: parsed.rows || memoryStore.rows,
};
}
function rebuildConfigs() {
if (!CONFIG_DIR) return;
const cmd = `cd "${CONFIG_DIR}" && ./update-configs.sh`;
exec(cmd, (err, stdout, stderr) => {
if (err) {
console.error('重建配置失败:', err.message);
if (stderr) console.error(stderr);
return;
}
if (stdout) console.log(stdout);
});
}
app.get('/api/targets', (req, res) => {
try {
const parsed = loadTargets();
res.json(parsed.rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: '读取 targets 失败' });
}
});
app.get('/api/mode', (req, res) => {
res.json({ mode: isFileMode() ? 'file' : 'memory' });
});
app.get('/api/map-config', (req, res) => {
res.json({ cacheBaseUrl: TILE_CACHE_URL ? '/tiles' : '' });
});
function proxyToTileCache(pathname, res) {
const url = TILE_CACHE_URL + pathname;
const u = new URL(url);
const client = u.protocol === 'https:' ? require('https') : require('http');
client.get(url, (proxyRes) => {
res.status(proxyRes.statusCode || 200);
if (proxyRes.headers['content-type']) res.setHeader('Content-Type', proxyRes.headers['content-type']);
if (proxyRes.headers['cache-control']) res.setHeader('Cache-Control', proxyRes.headers['cache-control']);
proxyRes.pipe(res);
}).on('error', (err) => {
res.status(502).json({ error: 'Tile cache unreachable: ' + err.message });
});
}
app.get('/tiles/vec/:z/:x/:y', (req, res) => {
if (!TILE_CACHE_URL) return res.status(503).send('Tile cache not configured');
proxyToTileCache(`/vec/${req.params.z}/${req.params.x}/${req.params.y}`, res);
});
app.get('/tiles/cva/:z/:x/:y', (req, res) => {
if (!TILE_CACHE_URL) return res.status(503).send('Tile cache not configured');
proxyToTileCache(`/cva/${req.params.z}/${req.params.x}/${req.params.y}`, res);
});
app.get('/api/download', (req, res) => {
try {
const parsed = loadTargets();
const text = stringifyCsv(parsed.comments, parsed.header, parsed.rows);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename=targets.csv');
res.send(text);
} catch (e) {
console.error(e);
res.status(500).json({ error: '生成下载失败' });
}
});
app.post('/api/upload', upload.single('file'), (req, res) => {
try {
if (!req.file || !req.file.buffer) {
return res.status(400).json({ error: '请选择 CSV 文件上传' });
}
const text = req.file.buffer.toString('utf8');
const parsed = parseCsv(text);
if (!isFileMode()) {
memoryStore = parsed;
} else {
saveTargets(parsed);
}
res.json({ ok: true, rows: parsed.rows.length });
} catch (e) {
console.error(e);
res.status(500).json({ error: '解析 CSV 失败' });
}
});
app.post('/api/targets', (req, res) => {
try {
const {
type = '',
ip = '',
name = '',
role = '',
parent = '',
uplink_type = '',
network = '',
device_type = '',
model = '',
location = '',
username = '',
password = '',
onvif_port = '',
lat = '',
lon = '',
} = req.body || {};
if (!type || !name) {
return res.status(400).json({ error: 'type 与 name 为必填字段' });
}
const parsed = loadTargets();
const { header, rows } = parsed;
const idx = rows.findIndex((r) => r.name === name);
const row = {};
header.forEach((h) => {
row[h] = '';
});
Object.assign(row, {
type,
ip,
name,
role,
parent,
uplink_type,
network,
device_type,
model,
location,
username,
password,
onvif_port,
lat,
lon,
});
if (idx >= 0) {
rows[idx] = row;
} else {
rows.push(row);
}
saveTargets({ ...parsed, rows });
rebuildConfigs();
res.json({ ok: true, row });
} catch (e) {
console.error(e);
res.status(500).json({ error: '写入失败' });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Topology editor listening on http://0.0.0.0:${PORT}`);
if (CONFIG_DIR) {
console.log(`CONFIG_DIR=${CONFIG_DIR} (file mode)`);
} else {
console.log('Running in memory mode: use Upload CSV / Download CSV');
}
});