Compare commits

...

4 Commits

4 changed files with 97 additions and 24 deletions

View File

@@ -181,6 +181,13 @@ body {
50% { opacity: 0.3; }
}
.last-update-text {
font-size: 0.65rem;
color: var(--mc-text-gray, #aaa);
text-shadow: 1px 1px 0 var(--mc-dark);
white-space: nowrap;
}
/* === Command Toast === */
.command-toast {
position: fixed;

View File

@@ -12,10 +12,25 @@ import './App.css';
function App() {
const connect = useInventoryStore((state) => state.connect);
const connected = useInventoryStore((state) => state.connected);
const bridgeConnected = useInventoryStore((state) => state.bridgeConnected);
const lastUpdate = useInventoryStore((state) => state.lastUpdate);
const commandResult = useInventoryStore((state) => state.commandResult);
const alerts = useInventoryStore((state) => state.alerts) || [];
const [panelTab, setPanelTab] = useState('inventory');
const [showAlerts, setShowAlerts] = useState(false);
const [, forceRender] = useState(0);
useEffect(() => {
connect();
}, [connect]);
// Re-render every 5s so the "last updated" text stays fresh
useEffect(() => {
const id = setInterval(() => forceRender((n) => n + 1), 5000);
return () => clearInterval(id);
}, []);
const staleSecs = lastUpdate ? Math.floor((Date.now() - lastUpdate) / 1000) : null;
useEffect(() => {
connect();
@@ -58,6 +73,17 @@ function App() {
<span className="status-dot"></span>
{connected ? 'Connected' : 'Disconnected'}
</div>
{connected && (
<div className={`connection-status ${bridgeConnected ? 'connected' : 'disconnected'}`} title="Minecraft CC:Tweaked bridge">
<span className="status-dot"></span>
{bridgeConnected ? 'Bridge OK' : 'Bridge Off'}
</div>
)}
{staleSecs !== null && staleSecs > 0 && (
<span className="last-update-text" title="Time since last data update">
{staleSecs < 60 ? `${staleSecs}s ago` : `${Math.floor(staleSecs / 60)}m ago`}
</span>
)}
</div>
</div>

View File

@@ -8,6 +8,29 @@ const API_URL = import.meta.env.VITE_API_URL ||
console.log('🔌 WebSocket URL:', WS_URL);
console.log('📡 API URL:', API_URL);
// ========== Timers (module-level so they survive store re-creates) ==========
let _httpPollTimer = null;
let _wsHealthTimer = null;
let _lastWsMessage = 0;
const HTTP_POLL_INTERVAL = 10_000; // Poll HTTP every 10 s as fallback
const WS_STALE_TIMEOUT = 35_000; // If no WS message for 35 s → reconnect
function _applyStateData(data, current) {
return {
inventory: data.inventory || current.inventory,
activity: data.activity || current.activity,
alerts: data.alerts || current.alerts,
smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused,
disabledRecipes: data.disabledRecipes || current.disabledRecipes,
smeltable: data.smeltable || current.smeltable,
craftable: data.craftable || current.craftable,
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
bridgeConnected: data.bridgeConnected !== undefined ? data.bridgeConnected : current.bridgeConnected,
lastUpdate: data.lastUpdate || Date.now(),
};
}
export const useInventoryStore = create((set, get) => ({
// State
inventory: {
@@ -30,6 +53,7 @@ export const useInventoryStore = create((set, get) => ({
smeltable: {},
craftable: [],
craftTurtleOk: false,
bridgeConnected: false,
connected: false,
ws: null,
lastUpdate: 0,
@@ -47,23 +71,35 @@ export const useInventoryStore = create((set, get) => ({
const current = get();
// Only apply if we have actual data with items
if (data.inventory?.itemList?.length || !current.inventory.itemList?.length) {
set({
inventory: data.inventory || current.inventory,
activity: data.activity || current.activity,
alerts: data.alerts || current.alerts,
smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused,
disabledRecipes: data.disabledRecipes || current.disabledRecipes,
smeltable: data.smeltable || current.smeltable,
craftable: data.craftable || current.craftable,
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
lastUpdate: data.lastUpdate || current.lastUpdate,
});
set(_applyStateData(data, current));
}
} catch (error) {
// Silent fail — WebSocket will provide data
}
},
// Start periodic HTTP polling (runs alongside WS for redundancy)
_startHttpPoll: () => {
if (_httpPollTimer) return;
_httpPollTimer = setInterval(() => {
get().fetchState();
}, HTTP_POLL_INTERVAL);
},
// Client-side WS health check — reconnect if no message received recently
_startWsHealthCheck: () => {
if (_wsHealthTimer) return;
_wsHealthTimer = setInterval(() => {
const ws = get().ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (_lastWsMessage > 0 && Date.now() - _lastWsMessage > WS_STALE_TIMEOUT) {
console.warn('⚠️ WebSocket stale (no messages for', Math.round((Date.now() - _lastWsMessage) / 1000), 's) — reconnecting');
ws.close(); // triggers onclose → reconnect
_lastWsMessage = 0; // reset so we don't loop
}
}, 10_000);
},
// WebSocket connection
connect: () => {
// Prevent duplicate connections
@@ -75,30 +111,25 @@ export const useInventoryStore = create((set, get) => ({
// Fetch state via HTTP immediately (don't wait for WS)
get().fetchState();
// Ensure background timers are running
get()._startHttpPoll();
get()._startWsHealthCheck();
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('✅ Connected to server');
_lastWsMessage = Date.now();
set({ connected: true, ws });
};
ws.onmessage = (event) => {
_lastWsMessage = Date.now();
try {
const data = JSON.parse(event.data);
if (data.type === 'initial_state' || data.type === 'state_update') {
const current = get();
set({
inventory: data.inventory || current.inventory,
activity: data.activity || current.activity,
alerts: data.alerts || current.alerts,
smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : current.smeltingPaused,
disabledRecipes: data.disabledRecipes || current.disabledRecipes,
smeltable: data.smeltable || current.smeltable,
craftable: data.craftable || current.craftable,
craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : current.craftTurtleOk,
lastUpdate: data.lastUpdate || Date.now(),
});
set(_applyStateData(data, get()));
} else if (data.type === 'command_result') {
set({ commandResult: data });
// Clear after 5s

View File

@@ -74,7 +74,13 @@ const server = createServer(app);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', lastUpdate, uptime: process.uptime() });
res.json({
status: 'ok',
lastUpdate,
uptime: process.uptime(),
bridgeConnected: bridgeClients.size > 0,
webClients: webClients.size,
});
});
// Get current inventory state
@@ -89,6 +95,7 @@ app.get('/api/inventory', (req, res) => {
craftable: craftableRecipes,
craftTurtleOk,
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
});
});
@@ -396,6 +403,7 @@ function updateStateFromBridge(data) {
craftable: craftableRecipes,
craftTurtleOk,
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
});
// Persist to SQLite (debounced — won't block the broadcast)
@@ -489,6 +497,7 @@ wss.on('connection', (ws, req) => {
craftable: craftableRecipes,
craftTurtleOk,
lastUpdate,
bridgeConnected: bridgeClients.size > 0,
}));
ws.on('pong', () => { ws.isAlive = true; });