Add texture proxy and caching mechanism: implement upstream fetching and local caching for texture assets
This commit is contained in:
@@ -1,21 +1,26 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName, formatCount } from '../utils/itemUtils';
|
||||
import { formatItemName, formatCount, getItemCategory, ITEM_CATEGORIES } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './AnalyticsPanel.css';
|
||||
|
||||
// ===== Simple SVG Line Chart =====
|
||||
function LineChart({ data, width = 400, height = 140, color = '#55ff55', label = '' }) {
|
||||
// Unique gradient ID counter
|
||||
let _gradientId = 0;
|
||||
|
||||
// ===== Smooth SVG Line Chart with gradient fill & data points =====
|
||||
function LineChart({ data, width = 400, height = 180, color = '#55ff55', label = '', id = 'chart' }) {
|
||||
if (!data || data.length < 2) {
|
||||
return (
|
||||
<div className="chart-empty" style={{ width, height }}>
|
||||
<div className="chart-empty" style={{ height }}>
|
||||
<span className="chart-empty-icon">📉</span>
|
||||
<span>Not enough data yet</span>
|
||||
<span className="chart-empty-sub">History is recorded every 5 minutes</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = { top: 16, right: 12, bottom: 24, left: 50 };
|
||||
const gradId = `grad-${id}-${color.replace('#', '')}`;
|
||||
const pad = { top: 20, right: 16, bottom: 28, left: 50 };
|
||||
const w = width - pad.left - pad.right;
|
||||
const h = height - pad.top - pad.bottom;
|
||||
|
||||
@@ -27,29 +32,72 @@ function LineChart({ data, width = 400, height = 140, color = '#55ff55', label =
|
||||
const points = data.map((d, i) => ({
|
||||
x: pad.left + (i / (data.length - 1 || 1)) * w,
|
||||
y: pad.top + h - ((d.value - minY) / range) * h,
|
||||
value: d.value,
|
||||
}));
|
||||
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
// Smooth curve using cardinal spline
|
||||
const smoothPath = (pts) => {
|
||||
if (pts.length < 3) {
|
||||
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
}
|
||||
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(0, i - 1)];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
const pathD = smoothPath(points);
|
||||
const areaD =
|
||||
pathD +
|
||||
` L ${points[points.length - 1].x.toFixed(1)} ${(pad.top + h).toFixed(1)} L ${points[0].x.toFixed(1)} ${(pad.top + h).toFixed(1)} Z`;
|
||||
|
||||
// Y axis labels
|
||||
const yLabels = [0, 0.5, 1].map((frac) => ({
|
||||
// Y axis labels (4 ticks)
|
||||
const yLabels = [0, 0.33, 0.66, 1].map((frac) => ({
|
||||
y: pad.top + h * (1 - frac),
|
||||
text: formatCount(Math.round(minY + range * frac)),
|
||||
}));
|
||||
|
||||
// X axis labels (first, middle, last timestamps)
|
||||
const xLabels = [0, Math.floor(data.length / 2), data.length - 1]
|
||||
// X axis labels
|
||||
const xCount = Math.min(5, data.length);
|
||||
const xIndices = Array.from({ length: xCount }, (_, i) => Math.round(i * (data.length - 1) / (xCount - 1)));
|
||||
const xLabels = xIndices
|
||||
.filter((idx) => data[idx]?.time)
|
||||
.map((idx) => ({
|
||||
x: pad.left + (idx / (data.length - 1 || 1)) * w,
|
||||
text: formatTime(data[idx].time),
|
||||
}));
|
||||
|
||||
// Latest value indicator
|
||||
const lastPt = points[points.length - 1];
|
||||
const prevVal = data.length > 1 ? data[data.length - 2].value : data[0].value;
|
||||
const currVal = data[data.length - 1].value;
|
||||
const delta = currVal - prevVal;
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="svg-chart">
|
||||
<svg width={width} height={height} className="svg-chart" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
<filter id={`glow-${gradId}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||
<line
|
||||
@@ -58,29 +106,47 @@ function LineChart({ data, width = 400, height = 140, color = '#55ff55', label =
|
||||
y1={pad.top + h * (1 - frac)}
|
||||
x2={pad.left + w}
|
||||
y2={pad.top + h * (1 - frac)}
|
||||
stroke="#333"
|
||||
stroke="#2a2a2a"
|
||||
strokeWidth="1"
|
||||
strokeDasharray={frac === 0 || frac === 1 ? 'none' : '3,3'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Y axis labels */}
|
||||
{yLabels.map((l, i) => (
|
||||
<text key={i} x={pad.left - 4} y={l.y + 3} fill="#888" fontSize="8" textAnchor="end" fontFamily="'Silkscreen', monospace">
|
||||
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X axis labels */}
|
||||
{xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={pad.top + h + 14} fill="#666" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
||||
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
{/* Area fill */}
|
||||
<path d={areaD} fill={color} opacity="0.12" />
|
||||
{/* Line */}
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="2" />
|
||||
|
||||
{/* Area fill with gradient */}
|
||||
<path d={areaD} fill={`url(#${gradId})`} />
|
||||
|
||||
{/* Line with glow */}
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="2.5" filter={`url(#glow-${gradId})`} strokeLinecap="round" />
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
|
||||
{/* Data points (show fewer if many data points) */}
|
||||
{points.filter((_, i) => data.length <= 20 || i % Math.ceil(data.length / 15) === 0 || i === points.length - 1).map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="2.5" fill={color} stroke="#1a1a1a" strokeWidth="1" opacity="0.8" />
|
||||
))}
|
||||
|
||||
{/* Latest value highlight */}
|
||||
<circle cx={lastPt.x} cy={lastPt.y} r="4" fill={color} stroke="#fff" strokeWidth="1.5" filter={`url(#glow-${gradId})`} />
|
||||
<text x={lastPt.x} y={lastPt.y - 8} fill="#fff" fontSize="8" fontWeight="700" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
||||
{formatCount(currVal)}
|
||||
</text>
|
||||
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<text x={pad.left + 4} y={pad.top - 4} fill="#aaa" fontSize="8" fontFamily="'Silkscreen', monospace">
|
||||
<text x={pad.left + 4} y={pad.top - 6} fill="#777" fontSize="7" fontFamily="'Silkscreen', monospace" textTransform="uppercase">
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
@@ -88,25 +154,30 @@ function LineChart({ data, width = 400, height = 140, color = '#55ff55', label =
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Horizontal Bar Chart =====
|
||||
function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) {
|
||||
// ===== Horizontal Bar Chart with rank medals =====
|
||||
function BarChart({ data, width = 400, height = 200, color = '#ffaa00' }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="chart-empty" style={{ width, height }}>
|
||||
<div className="chart-empty" style={{ height }}>
|
||||
<span>No data</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxVal = Math.max(...data.map((d) => d.value)) || 1;
|
||||
const barHeight = Math.min(20, (height - 8) / data.length);
|
||||
const medals = ['🥇', '🥈', '🥉'];
|
||||
const barColors = [
|
||||
'#ffcc00', '#e8b800', '#d4a800', '#c09800', '#aa8800',
|
||||
'#997a00', '#886c00', '#775e00', '#665100', '#554400',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bar-chart" style={{ width }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.name || i} className="bar-row">
|
||||
<div key={d.name || i} className="bar-row" style={{ animationDelay: `${i * 50}ms` }}>
|
||||
<div className="bar-rank">{i < 3 ? medals[i] : <span className="bar-rank-num">#{i + 1}</span>}</div>
|
||||
<div className="bar-label">
|
||||
<ItemIcon itemName={d.name} size={14} />
|
||||
<ItemIcon itemName={d.name} size={16} />
|
||||
<span>{formatItemName(d.name)}</span>
|
||||
</div>
|
||||
<div className="bar-track">
|
||||
@@ -114,8 +185,7 @@ function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) {
|
||||
className="bar-fill"
|
||||
style={{
|
||||
width: `${(d.value / maxVal) * 100}%`,
|
||||
background: color,
|
||||
height: barHeight,
|
||||
background: `linear-gradient(90deg, ${barColors[i] || color}, ${barColors[i] || color}88)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -126,6 +196,140 @@ function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Category Donut Chart =====
|
||||
function CategoryDonut({ categories, size = 140 }) {
|
||||
const total = categories.reduce((s, c) => s + c.count, 0) || 1;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size * 0.36;
|
||||
const strokeWidth = size * 0.15;
|
||||
|
||||
const categoryColors = {
|
||||
blocks: '#8b6d3c',
|
||||
tools: '#9d9d9d',
|
||||
combat: '#ff5555',
|
||||
food: '#80b94e',
|
||||
redstone: '#ff0000',
|
||||
materials: '#4aedd9',
|
||||
misc: '#a0a0a0',
|
||||
};
|
||||
|
||||
let cumAngle = -90; // start from top
|
||||
const segments = categories.map((c) => {
|
||||
const angle = (c.count / total) * 360;
|
||||
const seg = { ...c, startAngle: cumAngle, angle, color: categoryColors[c.id] || '#666' };
|
||||
cumAngle += angle;
|
||||
return seg;
|
||||
});
|
||||
|
||||
// Convert angle to SVG arc
|
||||
const describeArc = (startAngle, endAngle) => {
|
||||
const start = polarToCartesian(cx, cy, r, endAngle);
|
||||
const end = polarToCartesian(cx, cy, r, startAngle);
|
||||
const largeArcFlag = endAngle - startAngle > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
|
||||
};
|
||||
|
||||
const polarToCartesian = (cx, cy, r, angleDeg) => {
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="donut-container">
|
||||
<svg width={size} height={size} className="donut-chart">
|
||||
{segments.map((seg, i) => (
|
||||
seg.angle > 0.5 && (
|
||||
<path
|
||||
key={i}
|
||||
d={describeArc(seg.startAngle, seg.startAngle + Math.max(seg.angle - 1, 0.5))}
|
||||
fill="none"
|
||||
stroke={seg.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="butt"
|
||||
opacity="0.85"
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<text x={cx} y={cy - 4} textAnchor="middle" fill="#fff" fontSize="12" fontWeight="700" fontFamily="'Silkscreen', monospace">
|
||||
{categories.length}
|
||||
</text>
|
||||
<text x={cx} y={cy + 10} textAnchor="middle" fill="#888" fontSize="7" fontFamily="'Silkscreen', monospace">
|
||||
TYPES
|
||||
</text>
|
||||
</svg>
|
||||
<div className="donut-legend">
|
||||
{segments.filter(s => s.count > 0).map((seg) => (
|
||||
<div key={seg.id} className="donut-legend-item">
|
||||
<span className="donut-dot" style={{ background: seg.color }} />
|
||||
<span className="donut-legend-label">{seg.label}</span>
|
||||
<span className="donut-legend-value">{formatCount(seg.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Capacity Ring Gauge =====
|
||||
function CapacityGauge({ used, total, size = 100 }) {
|
||||
const pct = total > 0 ? Math.round((used / total) * 100) : 0;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size * 0.38;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const offset = circ - (pct / 100) * circ;
|
||||
|
||||
const getColor = (p) => {
|
||||
if (p >= 90) return '#ff5555';
|
||||
if (p >= 70) return '#ffaa00';
|
||||
return '#55ff55';
|
||||
};
|
||||
const color = getColor(pct);
|
||||
|
||||
return (
|
||||
<div className="capacity-gauge">
|
||||
<svg width={size} height={size}>
|
||||
<defs>
|
||||
<filter id="gauge-glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Track */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#2a2a2a" strokeWidth="8" />
|
||||
{/* Fill */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
filter="url(#gauge-glow)"
|
||||
style={{ transition: 'stroke-dashoffset 1s ease' }}
|
||||
/>
|
||||
<text x={cx} y={cy - 2} textAnchor="middle" fill={color} fontSize="16" fontWeight="700" fontFamily="'Silkscreen', monospace">
|
||||
{pct}%
|
||||
</text>
|
||||
<text x={cx} y={cy + 12} textAnchor="middle" fill="#666" fontSize="6" fontFamily="'Silkscreen', monospace">
|
||||
CAPACITY
|
||||
</text>
|
||||
</svg>
|
||||
<div className="capacity-gauge-detail">
|
||||
{formatCount(used)} / {formatCount(total)} slots
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts);
|
||||
@@ -159,7 +363,6 @@ function AnalyticsPanel() {
|
||||
// Fetch summary on mount
|
||||
useEffect(() => {
|
||||
fetchHistorySummary();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchHistorySummary, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHistorySummary]);
|
||||
@@ -180,6 +383,20 @@ function AnalyticsPanel() {
|
||||
.map((item) => ({ name: item.name, value: item.count || 0 }));
|
||||
}, [inventory.itemList]);
|
||||
|
||||
// Category breakdown
|
||||
const categoryData = useMemo(() => {
|
||||
const items = inventory.itemList || [];
|
||||
const catCounts = {};
|
||||
items.forEach((item) => {
|
||||
const cat = getItemCategory(item.name);
|
||||
catCounts[cat] = (catCounts[cat] || 0) + (item.count || 0);
|
||||
});
|
||||
return ITEM_CATEGORIES
|
||||
.filter(c => c.id !== 'all')
|
||||
.map(c => ({ ...c, count: catCounts[c.id] || 0 }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [inventory.itemList]);
|
||||
|
||||
// Storage history chart data
|
||||
const storageChartData = useMemo(() => {
|
||||
if (!historySummary || historySummary.length === 0) return [];
|
||||
@@ -228,66 +445,138 @@ function AnalyticsPanel() {
|
||||
};
|
||||
}, [smeltable, disabledRecipes, inventory.furnaceStatus]);
|
||||
|
||||
// Storage delta (from history)
|
||||
const storageDelta = useMemo(() => {
|
||||
if (!historySummary || historySummary.length < 2) return null;
|
||||
const latest = historySummary[historySummary.length - 1];
|
||||
const prev = historySummary[historySummary.length - 2];
|
||||
return latest.total - prev.total;
|
||||
}, [historySummary]);
|
||||
|
||||
const capacityPct = inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-header">
|
||||
<h2>📊 Analytics</h2>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="analytics-stats">
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Total Items</span>
|
||||
<span className="stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
|
||||
{/* Hero Stats Row */}
|
||||
<div className="analytics-hero">
|
||||
<div className="hero-stat hero-stat--items">
|
||||
<div className="hero-stat-icon">📦</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
|
||||
<span className="hero-stat-label">Total Items</span>
|
||||
{storageDelta !== null && (
|
||||
<span className={`hero-stat-delta ${storageDelta >= 0 ? 'positive' : 'negative'}`}>
|
||||
{storageDelta >= 0 ? '▲' : '▼'} {Math.abs(storageDelta).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Item Types</span>
|
||||
<span className="stat-value">{(inventory.itemList || []).length}</span>
|
||||
<div className="hero-stat hero-stat--types">
|
||||
<div className="hero-stat-icon">🧊</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">{(inventory.itemList || []).length}</span>
|
||||
<span className="hero-stat-label">Unique Types</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Capacity</span>
|
||||
<span className="stat-value">
|
||||
{inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0}%
|
||||
</span>
|
||||
<div className="hero-stat hero-stat--chests">
|
||||
<div className="hero-stat-icon">🗄️</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">{inventory.chestCount || 0}</span>
|
||||
<span className="hero-stat-label">Chests</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Furnaces</span>
|
||||
<span className="stat-value">
|
||||
{smeltingStats.activeFurnaces}/{smeltingStats.totalFurnaces}
|
||||
</span>
|
||||
<div className="hero-stat hero-stat--furnaces">
|
||||
<div className="hero-stat-icon">🔥</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">
|
||||
<span className="hero-active">{smeltingStats.activeFurnaces}</span>/{smeltingStats.totalFurnaces}
|
||||
</span>
|
||||
<span className="hero-stat-label">Furnaces</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="analytics-stat">
|
||||
<span className="stat-label">Recipes</span>
|
||||
<span className="stat-value">
|
||||
{smeltingStats.enabledRecipes}/{smeltingStats.totalRecipes}
|
||||
</span>
|
||||
<div className="hero-stat hero-stat--recipes">
|
||||
<div className="hero-stat-icon">📖</div>
|
||||
<div className="hero-stat-content">
|
||||
<span className="hero-stat-value">
|
||||
<span className="hero-active">{smeltingStats.enabledRecipes}</span>/{smeltingStats.totalRecipes}
|
||||
</span>
|
||||
<span className="hero-stat-label">Recipes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="analytics-grid">
|
||||
{/* Storage Over Time */}
|
||||
<div className="analytics-card analytics-card-wide">
|
||||
<h3>📈 Storage Over Time</h3>
|
||||
<LineChart data={storageChartData} width={600} height={150} color="#55ff55" label="Total Items" />
|
||||
{/* Storage Over Time — full width */}
|
||||
<div className="analytics-card analytics-card-wide analytics-card--chart">
|
||||
<div className="card-header">
|
||||
<h3>📈 Storage Over Time</h3>
|
||||
{storageChartData.length >= 2 && (
|
||||
<span className="card-badge">{storageChartData.length} snapshots</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="chart-wrapper">
|
||||
<LineChart data={storageChartData} width={700} height={200} color="#55ff55" label="Total Items" id="storage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capacity + Category Breakdown */}
|
||||
<div className="analytics-card analytics-card--overview">
|
||||
<div className="card-header">
|
||||
<h3>⚡ Storage Health</h3>
|
||||
</div>
|
||||
<div className="overview-split">
|
||||
<CapacityGauge used={inventory.usedSlots || 0} total={inventory.totalSlots || 0} size={120} />
|
||||
<div className="overview-stats-col">
|
||||
<div className="mini-stat">
|
||||
<span className="mini-stat-label">Used Slots</span>
|
||||
<span className="mini-stat-value">{(inventory.usedSlots || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<span className="mini-stat-label">Free Slots</span>
|
||||
<span className="mini-stat-value" style={{color: 'var(--mc-text-green)'}}>{(inventory.freeSlots || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<span className="mini-stat-label">Total Slots</span>
|
||||
<span className="mini-stat-value">{(inventory.totalSlots || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<div className="analytics-card analytics-card--categories">
|
||||
<div className="card-header">
|
||||
<h3>📊 Category Breakdown</h3>
|
||||
</div>
|
||||
<CategoryDonut categories={categoryData} size={130} />
|
||||
</div>
|
||||
|
||||
{/* Top Items */}
|
||||
<div className="analytics-card">
|
||||
<h3>🏆 Top Items</h3>
|
||||
<BarChart data={topItems} width={340} height={260} color="#ffaa00" />
|
||||
<div className="analytics-card analytics-card--ranking">
|
||||
<div className="card-header">
|
||||
<h3>🏆 Top Items</h3>
|
||||
<span className="card-badge">{(inventory.itemList || []).length} total</span>
|
||||
</div>
|
||||
<BarChart data={topItems} width={440} height={300} color="#ffaa00" />
|
||||
</div>
|
||||
|
||||
{/* Item Trend Lookup */}
|
||||
<div className="analytics-card">
|
||||
<h3>🔍 Item Trend</h3>
|
||||
<div className="analytics-card analytics-card--trend">
|
||||
<div className="card-header">
|
||||
<h3>🔍 Item Trend</h3>
|
||||
</div>
|
||||
<div className="item-history-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for an item..."
|
||||
value={historySearch}
|
||||
onChange={(e) => setHistorySearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
<div className="search-input-wrapper">
|
||||
<span className="search-icon">🔎</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for an item..."
|
||||
value={historySearch}
|
||||
onChange={(e) => setHistorySearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
{filteredItems.length > 0 && historySearch && (
|
||||
<div className="item-history-dropdown">
|
||||
{filteredItems.map((item) => (
|
||||
@@ -310,14 +599,16 @@ function AnalyticsPanel() {
|
||||
{selectedHistoryItem ? (
|
||||
<div className="item-history-chart">
|
||||
<div className="item-history-label">
|
||||
<ItemIcon itemName={selectedHistoryItem} size={20} />
|
||||
<ItemIcon itemName={selectedHistoryItem} size={22} />
|
||||
<span>{formatItemName(selectedHistoryItem)}</span>
|
||||
</div>
|
||||
<LineChart data={itemChartData} width={340} height={140} color="#55ffff" label="Count" />
|
||||
<LineChart data={itemChartData} width={400} height={160} color="#55ffff" label="Count" id="item-trend" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty" style={{ height: 160 }}>
|
||||
<div className="chart-empty chart-empty--compact">
|
||||
<span className="chart-empty-icon">📋</span>
|
||||
<span>Select an item above</span>
|
||||
<span className="chart-empty-sub">Track item quantity over time</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,18 @@ import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
loadFullState, saveFullState, recordItemHistory,
|
||||
saveItems, saveFurnaces, saveAlerts, saveState,
|
||||
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
||||
} from './db.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
@@ -72,6 +78,97 @@ function pushCommandToBridge(command) {
|
||||
// ========== HTTP Server ==========
|
||||
const server = createServer(app);
|
||||
|
||||
// ========== Texture Proxy / Cache ==========
|
||||
const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache');
|
||||
const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s
|
||||
|
||||
// Upstream CDN mapping
|
||||
const TEXTURE_UPSTREAMS = {
|
||||
minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures',
|
||||
computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures',
|
||||
create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures',
|
||||
};
|
||||
|
||||
// Ensure cache directory exists
|
||||
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
|
||||
|
||||
app.get('/api/texture/:namespace/*', async (req, res) => {
|
||||
const { namespace } = req.params;
|
||||
const texturePath = req.params[0]; // e.g. "item/diamond.png"
|
||||
|
||||
const upstream = TEXTURE_UPSTREAMS[namespace];
|
||||
if (!upstream) {
|
||||
return res.status(404).send('Unknown namespace');
|
||||
}
|
||||
|
||||
// Sanitize path to prevent directory traversal
|
||||
const safePath = path.normalize(texturePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
if (safePath.includes('..')) {
|
||||
return res.status(400).send('Invalid path');
|
||||
}
|
||||
|
||||
const cacheFile = path.join(TEXTURE_CACHE_DIR, namespace, safePath);
|
||||
const cacheDir = path.dirname(cacheFile);
|
||||
const missFile = cacheFile + '.miss';
|
||||
|
||||
// Check positive cache
|
||||
try {
|
||||
if (fs.existsSync(cacheFile)) {
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.set('Cache-Control', 'public, max-age=604800'); // 7 days
|
||||
res.set('X-Texture-Cache', 'HIT');
|
||||
return res.sendFile(cacheFile);
|
||||
}
|
||||
} catch (_) { /* proceed to fetch */ }
|
||||
|
||||
// Check negative cache (cached 404s)
|
||||
try {
|
||||
if (fs.existsSync(missFile)) {
|
||||
const stat = fs.statSync(missFile);
|
||||
const age = Date.now() - stat.mtimeMs;
|
||||
if (age < NEGATIVE_CACHE_TTL) {
|
||||
res.set('X-Texture-Cache', 'MISS-HIT');
|
||||
return res.status(404).send('Not found (cached)');
|
||||
}
|
||||
// Expired negative cache, remove it
|
||||
fs.unlinkSync(missFile);
|
||||
}
|
||||
} catch (_) { /* proceed to fetch */ }
|
||||
|
||||
// Fetch from upstream
|
||||
const upstreamUrl = `${upstream}/${safePath}`;
|
||||
try {
|
||||
const response = await fetch(upstreamUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
// Cache the 404 so we don't keep hitting upstream
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(missFile, `${response.status}`);
|
||||
} catch (_) { /* ignore cache write errors */ }
|
||||
res.set('X-Texture-Cache', 'UPSTREAM-MISS');
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
// Save to disk cache
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cacheFile, buffer);
|
||||
// Remove any stale negative cache
|
||||
if (fs.existsSync(missFile)) fs.unlinkSync(missFile);
|
||||
} catch (_) { /* ignore cache write errors */ }
|
||||
|
||||
res.set('Content-Type', response.headers.get('content-type') || 'image/png');
|
||||
res.set('Cache-Control', 'public, max-age=604800');
|
||||
res.set('X-Texture-Cache', 'UPSTREAM-HIT');
|
||||
return res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error(`[Texture Proxy] Error fetching ${upstreamUrl}:`, err.message);
|
||||
return res.status(502).send('Upstream error');
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
|
||||
Reference in New Issue
Block a user