+
{i < 3 ? medals[i] : #{i + 1}}
-
+
{formatItemName(d.name)}
@@ -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)`,
}}
/>
@@ -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 (
+
+
+
+ {segments.filter(s => s.count > 0).map((seg) => (
+
+
+ {seg.label}
+ {formatCount(seg.count)}
+
+ ))}
+
+
+ );
+}
+
+// ===== 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 (
+
+
+
+ {formatCount(used)} / {formatCount(total)} slots
+
+
+ );
+}
+
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 (
-
-
📊 Analytics
-
-
- {/* Quick Stats */}
-
-
-
Total Items
-
{(inventory.grandTotal || 0).toLocaleString()}
+ {/* Hero Stats Row */}
+
+
+
📦
+
+ {(inventory.grandTotal || 0).toLocaleString()}
+ Total Items
+ {storageDelta !== null && (
+ = 0 ? 'positive' : 'negative'}`}>
+ {storageDelta >= 0 ? '▲' : '▼'} {Math.abs(storageDelta).toLocaleString()}
+
+ )}
+
-
-
Item Types
-
{(inventory.itemList || []).length}
+
+
🧊
+
+ {(inventory.itemList || []).length}
+ Unique Types
+
-
-
Capacity
-
- {inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0}%
-
+
+
🗄️
+
+ {inventory.chestCount || 0}
+ Chests
+
-
-
Furnaces
-
- {smeltingStats.activeFurnaces}/{smeltingStats.totalFurnaces}
-
+
+
🔥
+
+
+ {smeltingStats.activeFurnaces}/{smeltingStats.totalFurnaces}
+
+ Furnaces
+
-
-
Recipes
-
- {smeltingStats.enabledRecipes}/{smeltingStats.totalRecipes}
-
+
+
📖
+
+
+ {smeltingStats.enabledRecipes}/{smeltingStats.totalRecipes}
+
+ Recipes
+
+ {/* Main Grid */}
- {/* Storage Over Time */}
-
-
📈 Storage Over Time
-
+ {/* Storage Over Time — full width */}
+
+
+
📈 Storage Over Time
+ {storageChartData.length >= 2 && (
+ {storageChartData.length} snapshots
+ )}
+
+
+
+
+
+
+ {/* Capacity + Category Breakdown */}
+
+
+
⚡ Storage Health
+
+
+
+
+
+ Used Slots
+ {(inventory.usedSlots || 0).toLocaleString()}
+
+
+ Free Slots
+ {(inventory.freeSlots || 0).toLocaleString()}
+
+
+ Total Slots
+ {(inventory.totalSlots || 0).toLocaleString()}
+
+
+
+
+
+ {/* Category Breakdown */}
+
+
+
📊 Category Breakdown
+
+
{/* Top Items */}
-
-
🏆 Top Items
-
+
+
+
🏆 Top Items
+ {(inventory.itemList || []).length} total
+
+
{/* Item Trend Lookup */}
-
-
🔍 Item Trend
+
+
+
🔍 Item Trend
+
-
setHistorySearch(e.target.value)}
- className="search-input"
- />
+
+ 🔎
+ setHistorySearch(e.target.value)}
+ className="search-input"
+ />
+
{filteredItems.length > 0 && historySearch && (
{filteredItems.map((item) => (
@@ -310,14 +599,16 @@ function AnalyticsPanel() {
{selectedHistoryItem ? (
-
+
{formatItemName(selectedHistoryItem)}
-
+
) : (
-
+
+ 📋
Select an item above
+ Track item quantity over time
)}
diff --git a/web/server/server.js b/web/server/server.js
index 75217bb..bf3c556 100644
--- a/web/server/server.js
+++ b/web/server/server.js
@@ -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({