Add SQLite persistence + official Minecraft item icons
Database (better-sqlite3): - Persist items, furnaces, alerts, recipes, settings to SQLite - Auto-restore last known state when server restarts or bridge disconnects - Item count history tracking (5-min snapshots, 7-day retention) - /api/history/:itemName endpoint for item count history - Docker volume for database file persistence - Graceful shutdown with DB connection cleanup Icons: - Replace mc-heads.net with official Minecraft game textures via CDN - Cascading fallback: item texture -> block texture -> emoji - In-memory URL cache to avoid redundant network requests - Block texture suffix mapping (furnace_front, barrel_top, etc.) - Crisp pixel-art rendering with image-rendering: pixelated
This commit is contained in:
@@ -1,14 +1,24 @@
|
||||
# Node.js backend
|
||||
FROM node:18-alpine
|
||||
|
||||
# Build tools needed for better-sqlite3 native compilation
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Remove build tools after install to keep image small
|
||||
RUN apk del python3 make g++
|
||||
|
||||
COPY . .
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
|
||||
388
web/server/db.js
Normal file
388
web/server/db.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/data/inventory.db';
|
||||
|
||||
// Ensure the directory exists
|
||||
const dbDir = dirname(DB_PATH);
|
||||
if (!existsSync(dbDir)) {
|
||||
mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Performance pragmas
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// ========== Schema ==========
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
name TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS furnaces (
|
||||
name TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'minecraft:furnace',
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
input TEXT,
|
||||
fuel TEXT,
|
||||
output TEXT,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
item TEXT PRIMARY KEY,
|
||||
triggered INTEGER NOT NULL DEFAULT 0,
|
||||
current INTEGER NOT NULL DEFAULT 0,
|
||||
threshold INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
count INTEGER NOT NULL,
|
||||
recorded_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_item_history_name ON item_history(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_item_history_time ON item_history(recorded_at);
|
||||
`);
|
||||
|
||||
// ========== Prepared Statements ==========
|
||||
|
||||
const upsertItem = db.prepare(`
|
||||
INSERT INTO items (name, count, display_name, updated_at)
|
||||
VALUES (@name, @count, @displayName, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
count = @count,
|
||||
display_name = @displayName,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const upsertFurnace = db.prepare(`
|
||||
INSERT INTO furnaces (name, type, active, input, fuel, output, updated_at)
|
||||
VALUES (@name, @type, @active, @input, @fuel, @output, @updatedAt)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
type = @type,
|
||||
active = @active,
|
||||
input = @input,
|
||||
fuel = @fuel,
|
||||
output = @output,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const upsertAlert = db.prepare(`
|
||||
INSERT INTO alerts (item, triggered, current, threshold, updated_at)
|
||||
VALUES (@item, @triggered, @current, @threshold, @updatedAt)
|
||||
ON CONFLICT(item) DO UPDATE SET
|
||||
triggered = @triggered,
|
||||
current = @current,
|
||||
threshold = @threshold,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const upsertState = db.prepare(`
|
||||
INSERT INTO state (key, value, updated_at)
|
||||
VALUES (@key, @value, @updatedAt)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = @value,
|
||||
updated_at = @updatedAt
|
||||
`);
|
||||
|
||||
const insertHistory = db.prepare(`
|
||||
INSERT INTO item_history (name, count, recorded_at)
|
||||
VALUES (@name, @count, @recordedAt)
|
||||
`);
|
||||
|
||||
const getState = db.prepare(`SELECT value FROM state WHERE key = ?`);
|
||||
const getAllItems = db.prepare(`SELECT name, count, display_name as displayName FROM items ORDER BY count DESC`);
|
||||
const getAllFurnaces = db.prepare(`SELECT * FROM furnaces`);
|
||||
const getAllAlerts = db.prepare(`SELECT item, triggered, current, threshold FROM alerts WHERE triggered = 1`);
|
||||
|
||||
const getItemHistory = db.prepare(`
|
||||
SELECT count, recorded_at as recordedAt
|
||||
FROM item_history
|
||||
WHERE name = ?
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
// Cleanup old history (keep last 7 days)
|
||||
const cleanupHistory = db.prepare(`
|
||||
DELETE FROM item_history WHERE recorded_at < ?
|
||||
`);
|
||||
|
||||
// ========== Batch Operations ==========
|
||||
|
||||
const saveItemsBatch = db.transaction((items, now) => {
|
||||
for (const item of items) {
|
||||
upsertItem.run({
|
||||
name: item.name || '',
|
||||
count: item.count || 0,
|
||||
displayName: item.displayName || item.name || '',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const saveHistoryBatch = db.transaction((items, now) => {
|
||||
for (const item of items) {
|
||||
insertHistory.run({
|
||||
name: item.name || '',
|
||||
count: item.count || 0,
|
||||
recordedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const saveFurnacesBatch = db.transaction((furnaces, now) => {
|
||||
for (const [name, f] of Object.entries(furnaces)) {
|
||||
upsertFurnace.run({
|
||||
name,
|
||||
type: f.type || 'minecraft:furnace',
|
||||
active: f.active ? 1 : 0,
|
||||
input: f.input || null,
|
||||
fuel: f.fuel || null,
|
||||
output: f.output || null,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const saveAlertsBatch = db.transaction((alerts, now) => {
|
||||
// Clear old triggered status
|
||||
db.prepare('UPDATE alerts SET triggered = 0').run();
|
||||
for (const alert of alerts) {
|
||||
upsertAlert.run({
|
||||
item: alert.item || '',
|
||||
triggered: alert.triggered ? 1 : 0,
|
||||
current: alert.current || 0,
|
||||
threshold: alert.threshold || 0,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Public API ==========
|
||||
|
||||
/**
|
||||
* Save inventory items to the database
|
||||
*/
|
||||
export function saveItems(items) {
|
||||
const now = Date.now();
|
||||
saveItemsBatch(items, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a snapshot of item counts for history tracking
|
||||
*/
|
||||
let lastHistoryRecord = 0;
|
||||
const HISTORY_INTERVAL = 5 * 60 * 1000; // Record history every 5 minutes
|
||||
|
||||
export function recordItemHistory(items) {
|
||||
const now = Date.now();
|
||||
if (now - lastHistoryRecord < HISTORY_INTERVAL) return;
|
||||
lastHistoryRecord = now;
|
||||
saveHistoryBatch(items, now);
|
||||
|
||||
// Cleanup entries older than 7 days
|
||||
cleanupHistory.run(now - 7 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save furnace status to the database
|
||||
*/
|
||||
export function saveFurnaces(furnaceStatus) {
|
||||
const now = Date.now();
|
||||
saveFurnacesBatch(furnaceStatus, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save alerts to the database
|
||||
*/
|
||||
export function saveAlerts(alerts) {
|
||||
const now = Date.now();
|
||||
saveAlertsBatch(alerts, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a key-value state entry (JSON serialized)
|
||||
*/
|
||||
export function saveState(key, value) {
|
||||
upsertState.run({
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a key-value state entry
|
||||
*/
|
||||
export function loadState(key, defaultValue = null) {
|
||||
const row = getState.get(key);
|
||||
if (!row) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all persisted items
|
||||
*/
|
||||
export function loadItems() {
|
||||
return getAllItems.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all persisted furnace statuses
|
||||
*/
|
||||
export function loadFurnaces() {
|
||||
const rows = getAllFurnaces.all();
|
||||
const result = {};
|
||||
for (const row of rows) {
|
||||
result[row.name] = {
|
||||
active: !!row.active,
|
||||
type: row.type,
|
||||
input: row.input,
|
||||
fuel: row.fuel,
|
||||
output: row.output,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all triggered alerts
|
||||
*/
|
||||
export function loadAlerts() {
|
||||
return getAllAlerts.all().map(row => ({
|
||||
...row,
|
||||
triggered: !!row.triggered,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item count history for a specific item
|
||||
*/
|
||||
export function getHistory(itemName, limit = 100) {
|
||||
return getItemHistory.all(itemName, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the full last-known inventory state from DB
|
||||
*/
|
||||
export function loadFullState() {
|
||||
const items = loadItems();
|
||||
const furnaces = loadFurnaces();
|
||||
const alerts = loadAlerts();
|
||||
const smeltingPaused = loadState('smeltingPaused', false);
|
||||
const disabledRecipes = loadState('disabledRecipes', {});
|
||||
const smeltableRecipes = loadState('smeltableRecipes', {});
|
||||
const craftableRecipes = loadState('craftableRecipes', []);
|
||||
const craftTurtleOk = loadState('craftTurtleOk', false);
|
||||
const activity = loadState('activity', {});
|
||||
const lastUpdate = loadState('lastUpdate', 0);
|
||||
|
||||
// Reconstruct inventoryState
|
||||
const inventoryMeta = loadState('inventoryMeta', {});
|
||||
const inventoryState = {
|
||||
itemList: items,
|
||||
grandTotal: inventoryMeta.grandTotal || 0,
|
||||
chestCount: inventoryMeta.chestCount || 0,
|
||||
totalSlots: inventoryMeta.totalSlots || 0,
|
||||
usedSlots: inventoryMeta.usedSlots || 0,
|
||||
freeSlots: inventoryMeta.freeSlots || 0,
|
||||
usedRatio: inventoryMeta.usedRatio || 0,
|
||||
dropperOk: inventoryMeta.dropperOk || false,
|
||||
barrelOk: inventoryMeta.barrelOk || false,
|
||||
furnaceCount: inventoryMeta.furnaceCount || 0,
|
||||
furnaceStatus: furnaces,
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryState,
|
||||
activityState: activity,
|
||||
alertsState: alerts,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltableRecipes,
|
||||
craftableRecipes,
|
||||
craftTurtleOk,
|
||||
lastUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the full state to DB in one transaction
|
||||
*/
|
||||
export const saveFullState = db.transaction((state) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Items
|
||||
if (state.inventoryState?.itemList?.length) {
|
||||
saveItemsBatch(state.inventoryState.itemList, now);
|
||||
}
|
||||
|
||||
// Inventory metadata (totals, slot info, etc.)
|
||||
if (state.inventoryState) {
|
||||
const { itemList, furnaceStatus, ...meta } = state.inventoryState;
|
||||
upsertState.run({ key: 'inventoryMeta', value: JSON.stringify(meta), updatedAt: now });
|
||||
}
|
||||
|
||||
// Furnaces
|
||||
if (state.inventoryState?.furnaceStatus && typeof state.inventoryState.furnaceStatus === 'object') {
|
||||
saveFurnacesBatch(state.inventoryState.furnaceStatus, now);
|
||||
}
|
||||
|
||||
// Alerts
|
||||
if (Array.isArray(state.alertsState)) {
|
||||
saveAlertsBatch(state.alertsState, now);
|
||||
}
|
||||
|
||||
// Key-value state
|
||||
if (state.activityState !== undefined) {
|
||||
upsertState.run({ key: 'activity', value: JSON.stringify(state.activityState), updatedAt: now });
|
||||
}
|
||||
if (state.smeltingPaused !== undefined) {
|
||||
upsertState.run({ key: 'smeltingPaused', value: JSON.stringify(state.smeltingPaused), updatedAt: now });
|
||||
}
|
||||
if (state.disabledRecipes !== undefined) {
|
||||
upsertState.run({ key: 'disabledRecipes', value: JSON.stringify(state.disabledRecipes), updatedAt: now });
|
||||
}
|
||||
if (state.smeltableRecipes !== undefined) {
|
||||
upsertState.run({ key: 'smeltableRecipes', value: JSON.stringify(state.smeltableRecipes), updatedAt: now });
|
||||
}
|
||||
if (state.craftableRecipes !== undefined) {
|
||||
upsertState.run({ key: 'craftableRecipes', value: JSON.stringify(state.craftableRecipes), updatedAt: now });
|
||||
}
|
||||
if (state.craftTurtleOk !== undefined) {
|
||||
upsertState.run({ key: 'craftTurtleOk', value: JSON.stringify(state.craftTurtleOk), updatedAt: now });
|
||||
}
|
||||
upsertState.run({ key: 'lastUpdate', value: JSON.stringify(now), updatedAt: now });
|
||||
});
|
||||
|
||||
/**
|
||||
* Close the database connection gracefully
|
||||
*/
|
||||
export function closeDb() {
|
||||
db.close();
|
||||
}
|
||||
|
||||
export default db;
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -2,6 +2,11 @@ import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { createServer } from 'http';
|
||||
import {
|
||||
loadFullState, saveFullState, recordItemHistory,
|
||||
saveItems, saveFurnaces, saveAlerts, saveState,
|
||||
getHistory, closeDb,
|
||||
} from './db.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
@@ -14,29 +19,26 @@ app.use(express.json({ limit: '5mb' }));
|
||||
const webClients = new Set();
|
||||
const bridgeClients = new Set();
|
||||
|
||||
// Latest inventory state from the CC:Tweaked bridge
|
||||
let inventoryState = {
|
||||
itemList: [],
|
||||
grandTotal: 0,
|
||||
chestCount: 0,
|
||||
totalSlots: 0,
|
||||
usedSlots: 0,
|
||||
freeSlots: 0,
|
||||
usedRatio: 0,
|
||||
dropperOk: false,
|
||||
barrelOk: false,
|
||||
furnaceCount: 0,
|
||||
furnaceStatus: {},
|
||||
};
|
||||
// Load persisted state from SQLite on startup
|
||||
console.log('💾 Loading persisted state from database...');
|
||||
const persisted = loadFullState();
|
||||
|
||||
let activityState = {};
|
||||
let alertsState = [];
|
||||
let smeltingPaused = false;
|
||||
let disabledRecipes = {};
|
||||
let smeltableRecipes = {};
|
||||
let craftableRecipes = [];
|
||||
let craftTurtleOk = false;
|
||||
let lastUpdate = 0;
|
||||
let inventoryState = persisted.inventoryState;
|
||||
let activityState = persisted.activityState;
|
||||
let alertsState = persisted.alertsState;
|
||||
let smeltingPaused = persisted.smeltingPaused;
|
||||
let disabledRecipes = persisted.disabledRecipes;
|
||||
let smeltableRecipes = persisted.smeltableRecipes;
|
||||
let craftableRecipes = persisted.craftableRecipes;
|
||||
let craftTurtleOk = persisted.craftTurtleOk;
|
||||
let lastUpdate = persisted.lastUpdate;
|
||||
|
||||
if (lastUpdate > 0) {
|
||||
const ago = Math.round((Date.now() - lastUpdate) / 1000);
|
||||
console.log(`💾 Restored state from ${ago}s ago (${inventoryState.itemList.length} items)`);
|
||||
} else {
|
||||
console.log('💾 No previous state found, starting fresh');
|
||||
}
|
||||
|
||||
// Pending commands for bridge HTTP polling (fallback)
|
||||
let pendingCommands = [];
|
||||
@@ -109,6 +111,13 @@ app.get('/api/alerts', (req, res) => {
|
||||
res.json({ alerts: alertsState });
|
||||
});
|
||||
|
||||
// Get item count history
|
||||
app.get('/api/history/:itemName', (req, res) => {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const history = getHistory(req.params.itemName, Math.min(limit, 1000));
|
||||
res.json({ item: req.params.itemName, history });
|
||||
});
|
||||
|
||||
// Get recipes (smeltable + craftable)
|
||||
app.get('/api/recipes', (req, res) => {
|
||||
res.json({
|
||||
@@ -368,7 +377,27 @@ function updateStateFromBridge(data) {
|
||||
if (data.craftTurtleOk !== undefined) craftTurtleOk = data.craftTurtleOk;
|
||||
|
||||
lastUpdate = Date.now();
|
||||
|
||||
|
||||
// Persist to SQLite
|
||||
try {
|
||||
saveFullState({
|
||||
inventoryState,
|
||||
activityState,
|
||||
alertsState,
|
||||
smeltingPaused,
|
||||
disabledRecipes,
|
||||
smeltableRecipes,
|
||||
craftableRecipes,
|
||||
craftTurtleOk,
|
||||
});
|
||||
// Record history snapshot (throttled to every 5 min internally)
|
||||
if (inventoryState.itemList?.length) {
|
||||
recordItemHistory(inventoryState.itemList);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ DB save error:', err.message);
|
||||
}
|
||||
|
||||
// Broadcast to all web clients
|
||||
broadcastToClients({
|
||||
type: 'state_update',
|
||||
@@ -490,7 +519,16 @@ server.listen(PORT, HOST, () => {
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
function shutdown() {
|
||||
console.log('\n🛑 Shutting down server...');
|
||||
try {
|
||||
closeDb();
|
||||
console.log('💾 Database closed');
|
||||
} catch (err) {
|
||||
console.error('❌ Error closing database:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
Reference in New Issue
Block a user