Add DATA chart with daily X axis and show 2 charts per row
Track data size history daily (up to 30 days) in memory, with a dedicated /api/data-history/:name endpoint. The Data Size chart uses day labels (e.g. "Feb 12") instead of minute-based timestamps. Charts are now displayed in a 2-column grid. https://claude.ai/code/session_013agP8J1cCfrWZkueZ33jQB
This commit is contained in:
parent
eb8ef0dd4d
commit
7a79133d78
|
|
@ -9,6 +9,7 @@ import type { Child } from 'hono/jsx'
|
||||||
// Configuration
|
// Configuration
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
const DATA_HISTORY_MAX_DAYS = 30 // Keep 30 days of data size history
|
||||||
const SAMPLE_INTERVAL_MS = 10_000 // How often to sample process metrics
|
const SAMPLE_INTERVAL_MS = 10_000 // How often to sample process metrics
|
||||||
const HISTORY_MAX_SAMPLES = 60 // Keep 10 minutes of history at 10s intervals
|
const HISTORY_MAX_SAMPLES = 60 // Keep 10 minutes of history at 10s intervals
|
||||||
|
|
||||||
|
|
@ -41,6 +42,11 @@ interface HistorySample {
|
||||||
rss: number
|
rss: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DataSample {
|
||||||
|
date: string
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
interface ProcessMetrics {
|
interface ProcessMetrics {
|
||||||
pid: number
|
pid: number
|
||||||
cpu: number
|
cpu: number
|
||||||
|
|
@ -53,8 +59,13 @@ interface ProcessMetrics {
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const appHistory = new Map<string, HistorySample[]>() // app name -> history
|
const appHistory = new Map<string, HistorySample[]>() // app name -> history
|
||||||
|
const dataHistory = new Map<string, DataSample[]>() // app name -> daily data size
|
||||||
const metricsCache = new Map<number, ProcessMetrics>()
|
const metricsCache = new Map<number, ProcessMetrics>()
|
||||||
|
|
||||||
|
function getDataHistory(appName: string): DataSample[] {
|
||||||
|
return dataHistory.get(appName) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
function getHistory(appName: string): HistorySample[] {
|
function getHistory(appName: string): HistorySample[] {
|
||||||
return appHistory.get(appName) ?? []
|
return appHistory.get(appName) ?? []
|
||||||
}
|
}
|
||||||
|
|
@ -110,15 +121,36 @@ async function sampleProcessMetrics(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordDataSize(appName: string): void {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const history = dataHistory.get(appName) ?? []
|
||||||
|
const bytes = getDataSize(appName)
|
||||||
|
|
||||||
|
// Update today's entry or add new one
|
||||||
|
const existing = history.find(s => s.date === today)
|
||||||
|
if (existing) {
|
||||||
|
existing.bytes = bytes
|
||||||
|
} else {
|
||||||
|
history.push({ date: today, bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the last N days
|
||||||
|
while (history.length > DATA_HISTORY_MAX_DAYS) {
|
||||||
|
history.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
dataHistory.set(appName, history)
|
||||||
|
}
|
||||||
|
|
||||||
async function sampleAndRecordHistory(): Promise<void> {
|
async function sampleAndRecordHistory(): Promise<void> {
|
||||||
await sampleProcessMetrics()
|
await sampleProcessMetrics()
|
||||||
|
|
||||||
// Record history for all running apps
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${TOES_URL}/api/apps`)
|
const res = await fetch(`${TOES_URL}/api/apps`)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const apps = await res.json() as App[]
|
const apps = await res.json() as App[]
|
||||||
|
|
||||||
|
// Record process history for running apps
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
if (app.pid && app.state === 'running') {
|
if (app.pid && app.state === 'running') {
|
||||||
const metrics = getProcessMetrics(app.pid)
|
const metrics = getProcessMetrics(app.pid)
|
||||||
|
|
@ -127,6 +159,11 @@ async function sampleAndRecordHistory(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record data size history for all apps (filesystem-based, not process-based)
|
||||||
|
for (const app of apps) {
|
||||||
|
recordDataSize(app.name)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +338,7 @@ const EmptyState = define('EmptyState', {
|
||||||
const ChartsContainer = define('ChartsContainer', {
|
const ChartsContainer = define('ChartsContainer', {
|
||||||
marginTop: '24px',
|
marginTop: '24px',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
gap: '20px',
|
gap: '20px',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -428,6 +465,12 @@ app.get('/api/metrics/:name', async c => {
|
||||||
return c.json(metrics)
|
return c.json(metrics)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/data-history/:name', c => {
|
||||||
|
const name = c.req.param('name')
|
||||||
|
const history = getDataHistory(name)
|
||||||
|
return c.json(history)
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/history/:name', c => {
|
app.get('/api/history/:name', c => {
|
||||||
const name = c.req.param('name')
|
const name = c.req.param('name')
|
||||||
const history = getHistory(name)
|
const history = getHistory(name)
|
||||||
|
|
@ -507,6 +550,15 @@ app.get('/', async c => {
|
||||||
</NoDataMessage>
|
</NoDataMessage>
|
||||||
</ChartWrapper>
|
</ChartWrapper>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
<ChartCard>
|
||||||
|
<ChartTitle>Data Size</ChartTitle>
|
||||||
|
<ChartWrapper>
|
||||||
|
<canvas id="dataChart"></canvas>
|
||||||
|
<NoDataMessage id="dataNoData" style="display: none">
|
||||||
|
Collecting data...
|
||||||
|
</NoDataMessage>
|
||||||
|
</ChartWrapper>
|
||||||
|
</ChartCard>
|
||||||
</ChartsContainer>
|
</ChartsContainer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
|
@ -517,6 +569,7 @@ app.get('/', async c => {
|
||||||
const textColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
|
const textColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
|
||||||
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
|
const gridColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
|
||||||
const cpuColor = 'rgb(59, 130, 246)';
|
const cpuColor = 'rgb(59, 130, 246)';
|
||||||
|
const dataColor = 'rgb(245, 158, 11)';
|
||||||
const memColor = 'rgb(168, 85, 247)';
|
const memColor = 'rgb(168, 85, 247)';
|
||||||
const rssColor = 'rgb(34, 197, 94)';
|
const rssColor = 'rgb(34, 197, 94)';
|
||||||
|
|
||||||
|
|
@ -557,18 +610,76 @@ app.get('/', async c => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let cpuChart, memChart, rssChart;
|
let cpuChart, dataChart, memChart, rssChart;
|
||||||
|
|
||||||
function formatTime(ts) {
|
function formatTime(ts) {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
function formatRss(kb) {
|
function formatRss(kb) {
|
||||||
if (kb < 1024) return kb + ' KB';
|
if (kb < 1024) return kb + ' KB';
|
||||||
return (kb / 1024).toFixed(1) + ' MB';
|
return (kb / 1024).toFixed(1) + ' MB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDataChart(history) {
|
||||||
|
if (history.length === 0) {
|
||||||
|
document.getElementById('dataChart').style.display = 'none';
|
||||||
|
document.getElementById('dataNoData').style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('dataChart').style.display = 'block';
|
||||||
|
document.getElementById('dataNoData').style.display = 'none';
|
||||||
|
|
||||||
|
const labels = history.map(h => formatDate(h.date));
|
||||||
|
const data = history.map(h => h.bytes / (1024 * 1024)); // Convert to MB
|
||||||
|
|
||||||
|
if (!dataChart) {
|
||||||
|
dataChart = new Chart(document.getElementById('dataChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: data,
|
||||||
|
borderColor: dataColor,
|
||||||
|
backgroundColor: dataColor.replace('rgb', 'rgba').replace(')', ', 0.1)'),
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...commonOptions,
|
||||||
|
scales: {
|
||||||
|
...commonOptions.scales,
|
||||||
|
y: {
|
||||||
|
...commonOptions.scales.y,
|
||||||
|
ticks: {
|
||||||
|
...commonOptions.scales.y.ticks,
|
||||||
|
callback: v => v.toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dataChart.data.labels = labels;
|
||||||
|
dataChart.data.datasets[0].data = data;
|
||||||
|
dataChart.update('none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateCharts(history) {
|
function updateCharts(history) {
|
||||||
const labels = history.map(h => formatTime(h.timestamp));
|
const labels = history.map(h => formatTime(h.timestamp));
|
||||||
const cpuData = history.map(h => h.cpu);
|
const cpuData = history.map(h => h.cpu);
|
||||||
|
|
@ -689,6 +800,18 @@ app.get('/', async c => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchDataHistory() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/data-history/' + encodeURIComponent(appName));
|
||||||
|
if (res.ok) {
|
||||||
|
const history = await res.json();
|
||||||
|
updateDataChart(history);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch data history:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHistory() {
|
async function fetchHistory() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/history/' + encodeURIComponent(appName));
|
const res = await fetch('/api/history/' + encodeURIComponent(appName));
|
||||||
|
|
@ -703,9 +826,11 @@ app.get('/', async c => {
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
|
fetchDataHistory();
|
||||||
|
|
||||||
// Update every 10 seconds
|
// Update every 10 seconds
|
||||||
setInterval(fetchHistory, 10000);
|
setInterval(fetchHistory, 10000);
|
||||||
|
setInterval(fetchDataHistory, 60000); // Data size changes slowly
|
||||||
})();
|
})();
|
||||||
`}} />
|
`}} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user