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 && ./csv-to-topology-geojson.sh targets.csv topology.geojson`; 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'); } });