From 05f5a3519e0516af402bb91fcd41da1f44804359 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 21 Mar 2026 19:29:59 -0400 Subject: [PATCH] Add texture proxy and caching mechanism: implement upstream fetching and local caching for texture assets --- web/client/src/components/AnalyticsPanel.jsx | 439 +++++++++++++++---- web/server/server.js | 97 ++++ 2 files changed, 462 insertions(+), 74 deletions(-) diff --git a/web/client/src/components/AnalyticsPanel.jsx b/web/client/src/components/AnalyticsPanel.jsx index 969cb39..3fca00c 100644 --- a/web/client/src/components/AnalyticsPanel.jsx +++ b/web/client/src/components/AnalyticsPanel.jsx @@ -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 ( -
+
+ 📉 Not enough data yet History is recorded every 5 minutes
); } - 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 ( - + + + + + + + + + + + + + + + {/* Grid lines */} {[0, 0.25, 0.5, 0.75, 1].map((frac) => ( ))} + {/* Y axis labels */} {yLabels.map((l, i) => ( - + {l.text} ))} + {/* X axis labels */} {xLabels.map((l, i) => ( - + {l.text} ))} - {/* Area fill */} - - {/* Line */} - + + {/* Area fill with gradient */} + + + {/* Line with glow */} + + + + {/* 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) => ( + + ))} + + {/* Latest value highlight */} + + + {formatCount(currVal)} + + {/* Label */} {label && ( - + {label} )} @@ -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 ( -
+
No data
); } 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 (
{data.map((d, i) => ( -
+
+
{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.map((seg, i) => ( + seg.angle > 0.5 && ( + + ) + ))} + + {categories.length} + + + TYPES + + +
+ {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 ( +
+ + + + + + + + + + + {/* Track */} + + {/* Fill */} + + + {pct}% + + + CAPACITY + + +
+ {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({