UNPKG

perf-audit-cli

Version:

CLI tool for continuous performance monitoring and analysis

352 lines 13.3 kB
import cors from 'cors'; import express from 'express'; import http from 'http'; import path from 'path'; import { fileURLToPath } from 'url'; import { WebSocketServer } from 'ws'; import { PerformanceDatabaseService } from "../core/database/index.js"; import { loadConfig } from "../utils/config.js"; import { Logger } from "../utils/logger.js"; import { formatSize, normalizeSize } from "../utils/size.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export async function dashboardCommand(options) { Logger.section('Starting Performance Dashboard...'); try { const config = await loadConfig(); const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, '../dashboard/public'))); setupAPIRoutes(app, config); setupWebSocket(wss); app.get('/', (_, res) => { res.sendFile(path.join(__dirname, '../dashboard/public/index.html')); }); server.listen(options.port, options.host, () => { Logger.success(`Dashboard running at http://${options.host}:${options.port}`); Logger.info('Open in your browser to view performance metrics'); if (options.open) { openBrowser(options.host, options.port); } }); process.on('SIGINT', () => { Logger.info('Shutting down dashboard...'); server.close(() => { process.exit(0); }); }); } catch (error) { Logger.error(`Failed to start dashboard: ${error instanceof Error ? error.message : 'Unknown error'}`); process.exit(1); } } const openBrowser = (host, port) => { const openUrl = host === '0.0.0.0' ? `http://localhost:${port}` : `http://${host}:${port}`; import('open').then(open => { Logger.info(`Opening browser: ${openUrl}`); open.default(openUrl); }).catch(() => { Logger.warn('Could not open browser automatically'); }); }; const setupAPIRoutes = async (app, config) => { const db = await PerformanceDatabaseService.instance(); app.get('/api/builds', handleGetBuilds(db)); app.get('/api/builds/:id', handleGetBuild(db)); app.get('/api/compare/:id1/:id2', handleCompareBuild(db)); app.get('/api/stats', handleGetStats(db)); app.get('/api/config', handleGetConfig(config)); app.get('/api/trends/client', handleGetClientTrends(db)); app.get('/api/trends/server', handleGetServerTrends(db)); app.get('/api/trends/total', handleGetTotalTrends(db)); }; const handleGetBuilds = (db) => async (req, res) => { try { const limit = parseInt(req.query.limit) || 50; const builds = await db.getRecentBuilds({ limit, orderBy: 'DESC' }); res.json(builds); } catch { res.status(500).json({ error: 'Failed to fetch builds' }); } }; const handleGetBuild = (db) => async (req, res) => { try { const build = await db.getBuild(parseInt(req.params.id)); if (!build) { res.status(404).json({ error: 'Build not found' }); return; } res.json(build); } catch { res.status(500).json({ error: 'Failed to fetch build' }); } }; const handleCompareBuild = (db) => async (req, res) => { try { const comparison = await db.getBuildComparison(parseInt(req.params.id1), parseInt(req.params.id2)); res.json(comparison); } catch { res.status(500).json({ error: 'Failed to compare builds' }); } }; const handleGetStats = (db) => async (_, res) => { try { const stats = await getDashboardStats(db); res.json(stats); } catch { res.status(500).json({ error: 'Failed to fetch stats' }); } }; const handleGetConfig = (config) => (_, res) => { res.json({ budgets: config.budgets, analysis: config.analysis, reports: config.reports, }); }; const handleGetClientTrends = (db) => async (req, res) => { try { const query = validateTrendQuery(req.query); const builds = await getFilteredBuilds(db, query); const clientTrends = await getClientTotalTrends(builds); res.json(clientTrends); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch client trends'; res.status(500).json({ error: `Failed to fetch client trends: ${message}` }); } }; const handleGetServerTrends = (db) => async (req, res) => { try { const query = validateTrendQuery(req.query); const builds = await getFilteredBuilds(db, query); const serverTrends = await getServerTotalTrends(builds); res.json(serverTrends); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch server trends'; res.status(500).json({ error: message }); } }; const handleGetTotalTrends = (db) => async (req, res) => { try { const query = validateTrendQuery(req.query); const builds = await getFilteredBuilds(db, query); const totalTrends = await getTotalBundleTrends(builds); res.json(totalTrends); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch total trends'; res.status(500).json({ error: message }); } }; const validateTrendQuery = (query) => { if (typeof query.days !== 'string' && query.days !== undefined) { throw new Error('Invalid query parameters ["days"]'); } if ((typeof query.startDate !== 'string' && query.startDate !== undefined) || (typeof query.endDate !== 'string' && query.endDate !== undefined)) { throw new Error('Invalid query parameters ["startDate", "endDate"]'); } return { days: parseInt(query.days ?? '30', 10), startDate: query.startDate !== undefined ? `${query.startDate} 00:00:00` : undefined, endDate: query.endDate !== undefined ? `${query.endDate} 23:59:59` : undefined, }; }; const setupWebSocket = (wss) => { wss.on('connection', ws => { Logger.debug('WebSocket client connected'); ws.on('message', message => { try { const data = JSON.parse(message.toString()); switch (data.type) { case 'subscribe': ws.send(JSON.stringify({ type: 'subscribed', message: 'Subscribed to performance updates', })); break; } } catch (error) { Logger.error(`WebSocket message error: ${error}`); } }); ws.on('close', () => { Logger.debug('WebSocket client disconnected'); }); }); }; const getDashboardStats = async (db) => { const recentBuilds = await db.getRecentBuilds({ limit: 30, orderBy: 'ASC' }); if (recentBuilds.length === 0) { return createEmptyStats(); } const totalBuilds = recentBuilds.length; const totalSizes = recentBuilds.map(build => build.bundles.reduce((sum, bundle) => sum + bundle.size, 0)); const averageSize = totalSizes.reduce((sum, size) => sum + size, 0) / totalSizes.length; const lastBuild = recentBuilds[0]; const lastTimestamp = lastBuild.timestamp; const lastBuildStatus = getBuildStatus(lastBuild); const { clientStats, serverStats } = calculateBundleStats(recentBuilds, lastTimestamp); return { totalBuilds, averageSize: Math.round(averageSize), lastBuildStatus, trendsCount: recentBuilds.length, formattedAverageSize: formatSize(averageSize), clientStats: { ...clientStats, formattedTotalSize: formatSize(clientStats.totalSize), formattedAverageSize: formatSize(clientStats.averageSize), }, serverStats: { ...serverStats, formattedTotalSize: formatSize(serverStats.totalSize), formattedAverageSize: formatSize(serverStats.averageSize), }, }; }; const createEmptyStats = () => ({ totalBuilds: 0, averageSize: 0, lastBuildStatus: 'ok', trendsCount: 0, clientStats: { totalSize: 0, averageSize: 0, bundleCount: 0, formattedTotalSize: '0B', formattedAverageSize: '0B' }, serverStats: { totalSize: 0, averageSize: 0, bundleCount: 0, formattedTotalSize: '0B', formattedAverageSize: '0B' }, }); const calculateBundleStats = (builds, lastTimestamp) => { const lastBuildBundles = builds .filter(build => build.timestamp === lastTimestamp) .flatMap(build => build.bundles); const clientBundles = lastBuildBundles.filter(b => b.type === 'client'); const serverBundles = lastBuildBundles.filter(b => b.type === 'server'); const clientStats = calculateStatsForBundles(clientBundles); const serverStats = calculateStatsForBundles(serverBundles); return { clientStats, serverStats }; }; const calculateStatsForBundles = (bundles) => { const totalSize = bundles.reduce((sum, b) => sum + b.size, 0); const averageSize = bundles.length > 0 ? Math.round(totalSize / bundles.length) : 0; return { totalSize, averageSize, bundleCount: bundles.length, }; }; const getBuildStatus = (build) => { const hasError = build.bundles.some((b) => b.status === 'error'); const hasWarning = build.bundles.some((b) => b.status === 'warning'); if (hasError) return 'error'; if (hasWarning) return 'warning'; return 'ok'; }; const getFilteredBuilds = async (db, param) => { return db.getRecentBuilds({ startDate: param.startDate, endDate: param.endDate, limit: param.days * 4, orderBy: 'ASC', }); }; const formatDateLabel = (timestamp) => { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}/${month}/${day}`; }; const getClientTotalTrends = (builds) => { if (builds.length === 0) { return { labels: [], datasets: [] }; } const labels = builds.map(build => formatDateLabel(build.timestamp)); const data = builds.map(build => { const clientBundles = build.bundles.filter(b => b.type === 'client'); const totalSize = clientBundles.reduce((total, bundle) => total + bundle.size, 0); return normalizeSize(totalSize); }); return { labels, datasets: [{ label: 'クライアントサイド合計バンドルサイズ', data, borderColor: 'rgba(54, 162, 235, 1)', backgroundColor: 'rgba(54, 162, 235, 0.1)', fill: false, }], }; }; const getServerTotalTrends = (builds) => { if (builds.length === 0) { return { labels: [], datasets: [] }; } const labels = builds.map(build => formatDateLabel(build.timestamp)); const data = builds.map(build => { const serverBundles = build.bundles.filter(b => b.type === 'server'); const totalSize = serverBundles.reduce((total, bundle) => total + bundle.size, 0); return normalizeSize(totalSize); }); return { labels, datasets: [{ label: 'サーバーサイド合計バンドルサイズ', data, borderColor: 'rgba(255, 99, 132, 1)', backgroundColor: 'rgba(255, 99, 132, 0.1)', fill: false, borderDash: [], }], }; }; const getTotalBundleTrends = (builds) => { if (builds.length === 0) { return { labels: [], datasets: [] }; } const labels = builds.map(build => formatDateLabel(build.timestamp)); const clientData = builds.map(build => { const clientBundles = build.bundles.filter(b => b.type === 'client'); const totalSize = clientBundles.reduce((total, bundle) => total + bundle.size, 0); return normalizeSize(totalSize); }); const serverData = builds.map(build => { const serverBundles = build.bundles.filter(b => b.type === 'server'); const totalSize = serverBundles.reduce((total, bundle) => total + bundle.size, 0); return normalizeSize(totalSize); }); return { labels, datasets: [ { label: 'クライアントサイド合計', data: clientData, borderColor: 'rgba(54, 162, 235, 1)', backgroundColor: 'rgba(54, 162, 235, 0.1)', fill: false, borderDash: [], }, { label: 'サーバーサイド合計', data: serverData, borderColor: 'rgba(255, 99, 132, 1)', backgroundColor: 'rgba(255, 99, 132, 0.1)', fill: false, borderDash: [], }, ], }; }; //# sourceMappingURL=dashboard.js.map