- 中央: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
268 lines
7.2 KiB
JavaScript
268 lines
7.2 KiB
JavaScript
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');
|
|
}
|
|
});
|