370 lines
11 KiB
JavaScript
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);
|
|
}
|
|
},
|
|
}));
|