Files
Inventory-Manager-CC/web/client/src/store/inventoryStore.js

370 lines
11 KiB
JavaScript

import { create } from 'zustand';
const _baseWsUrl = import.meta.env.VITE_WS_URL ||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
const API_URL = import.meta.env.VITE_API_URL ||
`${window.location.protocol}//${window.location.host}/api`;
const API_KEY = import.meta.env.VITE_API_KEY || '';
// Append API key to WebSocket URL as query param
const WS_URL = API_KEY ? `${_baseWsUrl}?key=${encodeURIComponent(API_KEY)}` : _baseWsUrl;
// Build common headers for authenticated requests
function authHeaders(extra = {}) {
const headers = { 'Content-Type': 'application/json', ...extra };
if (API_KEY) headers['Authorization'] = `Bearer ${API_KEY}`;
return headers;
}
console.log('🔌 WebSocket URL:', _baseWsUrl);
console.log('📡 API URL:', API_URL);
if (API_KEY) console.log('🔒 API key configured');
// Generate a unique command ID for idempotent requests
function newCommandId() {
return crypto.randomUUID();
}
// ========== Timers (module-level so they survive store re-creates) ==========
let _httpPollTimer = null;
let _wsHealthTimer = null;
let _lastWsMessage = 0;
let _reconnectDelay = 1000;
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,
dropperNicknames: data.dropperNicknames || current.dropperNicknames,
lastUpdate: data.lastUpdate != null ? data.lastUpdate : Date.now(),
};
}
export const useInventoryStore = create((set, get) => ({
// State
inventory: {
itemList: [],
grandTotal: 0,
chestCount: 0,
totalSlots: 0,
usedSlots: 0,
freeSlots: 0,
usedRatio: 0,
dropperOk: false,
barrelOk: false,
furnaceCount: 0,
furnaceStatus: {},
droppers: [],
},
activity: {},
alerts: [],
smeltingPaused: false,
disabledRecipes: {},
smeltable: {},
craftable: [],
craftTurtleOk: false,
bridgeConnected: false,
dropperNicknames: {},
connected: false,
ws: null,
lastUpdate: 0,
searchQuery: '',
commandResult: null,
historySummary: [],
itemHistory: {},
// Fetch state via HTTP API (fallback / initial load)
fetchState: async () => {
try {
const response = await fetch(`${API_URL}/inventory`);
if (!response.ok) return;
const data = await response.json();
const current = get();
// Only apply if we have actual data with items
if (data.inventory?.itemList?.length || !current.inventory.itemList?.length) {
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
const existing = get().ws;
if (existing && (existing.readyState === WebSocket.CONNECTING || existing.readyState === WebSocket.OPEN)) {
return;
}
// 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();
_reconnectDelay = 1000;
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') {
set(_applyStateData(data, get()));
} else if (data.type === 'command_result') {
set({ commandResult: data });
// Clear after 5s
setTimeout(() => {
set((state) => {
if (state.commandResult === data) {
return { commandResult: null };
}
return {};
});
}, 5000);
}
} catch (error) {
console.error('Error processing message:', error);
}
};
ws.onclose = () => {
console.log('❌ Disconnected from server');
set({ connected: false, ws: null });
const delay = _reconnectDelay;
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
setTimeout(() => {
get().connect();
}, delay);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
},
// Search
setSearchQuery: (query) => set({ searchQuery: query }),
getFilteredItems: () => {
const { inventory, searchQuery } = get();
const items = inventory.itemList || [];
if (!searchQuery) return items;
const q = searchQuery.toLowerCase();
return items.filter((item) => {
const name = (item.displayName || item.name || '').toLowerCase();
return name.includes(q);
});
},
// Actions via REST API
orderItem: async (itemName, amount, dropperName) => {
try {
const response = await fetch(`${API_URL}/order`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ itemName, amount, dropperName, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error ordering item:', error);
return { success: false, error: error.message };
}
},
requestScan: async () => {
try {
const response = await fetch(`${API_URL}/scan`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error requesting scan:', error);
return { success: false, error: error.message };
}
},
toggleSmelting: async () => {
try {
const response = await fetch(`${API_URL}/smelting/toggle`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error toggling smelting:', error);
return { success: false, error: error.message };
}
},
toggleRecipe: async (recipe) => {
try {
const response = await fetch(`${API_URL}/recipes/toggle`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ recipe, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error toggling recipe:', error);
return { success: false, error: error.message };
}
},
enableAllRecipes: async () => {
try {
const response = await fetch(`${API_URL}/recipes/enable-all`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error enabling all recipes:', error);
return { success: false, error: error.message };
}
},
disableAllRecipes: async () => {
try {
const response = await fetch(`${API_URL}/recipes/disable-all`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error disabling all recipes:', error);
return { success: false, error: error.message };
}
},
craftItem: async (recipeIdx) => {
try {
const response = await fetch(`${API_URL}/craft`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ recipeIdx, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error crafting item:', error);
return { success: false, error: error.message };
}
},
sortBarrel: async (barrelName) => {
try {
const response = await fetch(`${API_URL}/sort-barrel`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ barrelName, commandId: newCommandId() }),
});
return await response.json();
} catch (error) {
console.error('❌ Error sorting barrel:', error);
return { success: false, error: error.message };
}
},
// Dropper nicknames
setDropperNickname: async (dropperName, nickname) => {
try {
const response = await fetch(`${API_URL}/dropper-nicknames`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({ dropperName, nickname, commandId: newCommandId() }),
});
const result = await response.json();
if (result.nicknames) {
set({ dropperNicknames: result.nicknames });
}
return result;
} catch (error) {
console.error('❌ Error setting dropper nickname:', error);
return { success: false, error: error.message };
}
},
fetchDropperNicknames: async () => {
try {
const response = await fetch(`${API_URL}/dropper-nicknames`);
if (!response.ok) return;
const data = await response.json();
set({ dropperNicknames: data.nicknames || {} });
} catch (error) {
console.error('❌ Error fetching dropper nicknames:', error);
}
},
// Analytics - fetch aggregate storage history
fetchHistorySummary: async () => {
try {
const response = await fetch(`${API_URL}/history-summary`);
if (!response.ok) return;
const data = await response.json();
set({ historySummary: data.history || [] });
} catch (error) {
console.error('❌ Error fetching history summary:', error);
}
},
// Analytics - fetch single item history
fetchItemHistory: async (itemName) => {
try {
const response = await fetch(`${API_URL}/history/${encodeURIComponent(itemName)}?limit=200`);
if (!response.ok) return;
const data = await response.json();
set((state) => ({
itemHistory: { ...state.itemHistory, [itemName]: data.history || [] },
}));
} catch (error) {
console.error('❌ Error fetching item history:', error);
}
},
}));