Compare commits
4 Commits
39a04cc5e9
...
b5ae28944d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5ae28944d | ||
|
|
d1c9256ed8 | ||
|
|
854fcaf66f | ||
|
|
62984b5d18 |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; });
|
||||
|
||||
Reference in New Issue
Block a user