329 lines
9.7 KiB
JavaScript
329 lines
9.7 KiB
JavaScript
const els = {
|
|
statusBadge: document.querySelector('#statusBadge'),
|
|
appVersion: document.querySelector('#appVersion'),
|
|
serverFilter: document.querySelector('#serverFilter'),
|
|
visibleCount: document.querySelector('#visibleCount'),
|
|
totalCount: document.querySelector('#totalCount'),
|
|
lastUpdated: document.querySelector('#lastUpdated'),
|
|
nextRefresh: document.querySelector('#nextRefresh'),
|
|
errorBox: document.querySelector('#errorBox'),
|
|
rows: document.querySelector('#lobbyRows'),
|
|
qFilter: document.querySelector('#qFilter'),
|
|
gameFilter: document.querySelector('#gameFilter'),
|
|
mapFilter: document.querySelector('#mapFilter'),
|
|
regionFilter: document.querySelector('#regionFilter'),
|
|
passFilter: document.querySelector('#passFilter'),
|
|
sortBy: document.querySelector('#sortBy'),
|
|
openOnly: document.querySelector('#openOnly'),
|
|
resetBtn: document.querySelector('#resetBtn'),
|
|
refreshBtn: document.querySelector('#refreshBtn'),
|
|
};
|
|
|
|
let snapshot = null;
|
|
let regionsReady = false;
|
|
|
|
const dateTimeFormat = new Intl.DateTimeFormat('en-GB', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
});
|
|
|
|
function text(value) {
|
|
return String(value || '').toLowerCase().trim();
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return '-';
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return '-';
|
|
return dateTimeFormat.format(date);
|
|
}
|
|
|
|
function formatCountdown(iso) {
|
|
if (!iso) return snapshot?.refreshInProgress ? 'refreshing' : '-';
|
|
const ms = new Date(iso).getTime() - Date.now();
|
|
if (!Number.isFinite(ms) || ms <= 0) return snapshot?.refreshInProgress ? 'refreshing' : 'soon';
|
|
const seconds = Math.ceil(ms / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const rest = String(seconds % 60).padStart(2, '0');
|
|
return `${minutes}:${rest}`;
|
|
}
|
|
|
|
function statusInfo(data) {
|
|
if (!data) return ['starting', 'badge-muted'];
|
|
if (data.refreshInProgress) return ['refreshing', 'badge-work'];
|
|
if (data.connection?.steam === 'auth-required') return ['auth required', 'badge-warn'];
|
|
if (data.connection?.steam === 'steam-guard') return ['steam guard', 'badge-warn'];
|
|
if (data.status === 'ready') return ['online', 'badge-ok'];
|
|
if (data.status === 'stale') return ['stale', 'badge-warn'];
|
|
if (data.status === 'error') return ['error', 'badge-error'];
|
|
if (data.connection?.steam !== 'online' || data.connection?.gc !== 'ready') return ['connecting', 'badge-work'];
|
|
return [data.status || 'starting', 'badge-muted'];
|
|
}
|
|
|
|
function setBadge(label, klass) {
|
|
els.statusBadge.textContent = label;
|
|
els.statusBadge.className = `badge ${klass}`;
|
|
}
|
|
|
|
function initRegions(regions) {
|
|
if (regionsReady || !regions) return;
|
|
const current = els.regionFilter.value || 'all';
|
|
els.regionFilter.replaceChildren();
|
|
|
|
const all = document.createElement('option');
|
|
all.value = 'all';
|
|
all.textContent = 'all';
|
|
els.regionFilter.append(all);
|
|
|
|
Object.entries(regions)
|
|
.sort(([a], [b]) => Number(a) - Number(b))
|
|
.forEach(([id, name]) => {
|
|
const option = document.createElement('option');
|
|
option.value = id;
|
|
option.textContent = name;
|
|
els.regionFilter.append(option);
|
|
});
|
|
|
|
els.regionFilter.value = current;
|
|
regionsReady = true;
|
|
}
|
|
|
|
function filterLobbies(lobbies) {
|
|
const q = text(els.qFilter.value);
|
|
const game = text(els.gameFilter.value);
|
|
const map = text(els.mapFilter.value);
|
|
const region = els.regionFilter.value;
|
|
const pass = els.passFilter.value;
|
|
const openOnly = els.openOnly.checked;
|
|
|
|
return lobbies.filter((row) => {
|
|
const haystack = text([
|
|
row.game,
|
|
row.lobby,
|
|
row.map,
|
|
row.region,
|
|
row.leader,
|
|
row.customGameId,
|
|
row.leaderAccountId,
|
|
].join(' '));
|
|
|
|
if (q && !haystack.includes(q)) return false;
|
|
if (game && !text(row.game).includes(game)) return false;
|
|
if (map && !text(row.map).includes(map)) return false;
|
|
if (region !== 'all' && String(row.regionId) !== region) return false;
|
|
if (pass === 'yes' && !row.hasPassKey) return false;
|
|
if (pass === 'no' && row.hasPassKey) return false;
|
|
if (openOnly && Number(row.openSlots) <= 0) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function sortLobbies(rows) {
|
|
const sorted = [...rows];
|
|
const byName = (a, b, key) => String(a[key] || '').localeCompare(String(b[key] || ''), 'en');
|
|
|
|
sorted.sort((a, b) => {
|
|
if (els.sortBy.value === 'open-desc') {
|
|
return Number(b.openSlots) - Number(a.openSlots) || byName(a, b, 'game');
|
|
}
|
|
if (els.sortBy.value === 'game-asc') {
|
|
return byName(a, b, 'game') || Number(b.memberCount) - Number(a.memberCount);
|
|
}
|
|
if (els.sortBy.value === 'region-asc') {
|
|
return byName(a, b, 'region') || byName(a, b, 'game');
|
|
}
|
|
return Number(b.memberCount) - Number(a.memberCount) || byName(a, b, 'game');
|
|
});
|
|
|
|
return sorted;
|
|
}
|
|
|
|
function makeCell(value, className) {
|
|
const td = document.createElement('td');
|
|
if (className) td.className = className;
|
|
td.textContent = value || '-';
|
|
return td;
|
|
}
|
|
|
|
function renderRows(rows, totalLoaded) {
|
|
els.rows.replaceChildren();
|
|
|
|
if (!rows.length) {
|
|
const tr = document.createElement('tr');
|
|
const td = document.createElement('td');
|
|
td.colSpan = 7;
|
|
td.className = 'empty';
|
|
td.textContent = totalLoaded ? 'No lobbies match the selected filters' : 'No data yet';
|
|
tr.append(td);
|
|
els.rows.append(tr);
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (const row of rows) {
|
|
const tr = document.createElement('tr');
|
|
|
|
const game = document.createElement('td');
|
|
game.className = 'game';
|
|
const gameName = document.createElement('div');
|
|
gameName.textContent = row.game || row.customGameId || '-';
|
|
game.append(gameName);
|
|
if (row.customGameId && row.game !== row.customGameId) {
|
|
const id = document.createElement('div');
|
|
id.className = 'muted';
|
|
id.textContent = row.customGameId;
|
|
game.append(id);
|
|
}
|
|
|
|
const players = document.createElement('td');
|
|
players.className = 'num';
|
|
const playersPill = document.createElement('span');
|
|
playersPill.className = 'pill';
|
|
playersPill.textContent = row.players || '-';
|
|
players.append(playersPill);
|
|
|
|
const pass = document.createElement('td');
|
|
const passPill = document.createElement('span');
|
|
passPill.className = row.hasPassKey ? 'pill pass' : 'muted';
|
|
passPill.textContent = row.hasPassKey ? 'yes' : 'no';
|
|
pass.append(passPill);
|
|
|
|
tr.append(
|
|
game,
|
|
makeCell(row.lobby),
|
|
makeCell(row.map),
|
|
players,
|
|
makeCell(row.region),
|
|
pass,
|
|
makeCell(row.leader),
|
|
);
|
|
fragment.append(tr);
|
|
}
|
|
|
|
els.rows.append(fragment);
|
|
}
|
|
|
|
function render() {
|
|
const data = snapshot;
|
|
const lobbies = data?.lobbies || [];
|
|
const filtered = sortLobbies(filterLobbies(lobbies));
|
|
const [label, klass] = statusInfo(data);
|
|
|
|
setBadge(label, klass);
|
|
els.appVersion.textContent = data?.version ? `v${data.version}` : '';
|
|
els.visibleCount.textContent = String(filtered.length);
|
|
els.totalCount.textContent = String(data?.totalLobbies || lobbies.length || 0);
|
|
els.lastUpdated.textContent = formatDate(data?.lastUpdatedAt);
|
|
els.nextRefresh.textContent = formatCountdown(data?.nextRefreshAt);
|
|
els.refreshBtn.disabled = Boolean(data?.refreshInProgress);
|
|
els.refreshBtn.textContent = data?.refreshInProgress ? 'Refreshing' : 'Refresh';
|
|
|
|
if (data?.serverFilters) {
|
|
const parts = [`GC: ${data.serverFilters.regionName}`];
|
|
if (data.serverFilters.customGameId) parts.push(`game ${data.serverFilters.customGameId}`);
|
|
els.serverFilter.textContent = parts.join(', ');
|
|
}
|
|
|
|
if (data?.lastError) {
|
|
els.errorBox.hidden = false;
|
|
els.errorBox.textContent = data.lastError;
|
|
} else {
|
|
els.errorBox.hidden = true;
|
|
els.errorBox.textContent = '';
|
|
}
|
|
|
|
renderRows(filtered, lobbies.length);
|
|
}
|
|
|
|
function setSnapshot(data) {
|
|
snapshot = data;
|
|
initRegions(data?.regions);
|
|
render();
|
|
}
|
|
|
|
async function fetchState() {
|
|
const res = await fetch('/api/state', { cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
setSnapshot(await res.json());
|
|
}
|
|
|
|
async function refreshNow() {
|
|
els.refreshBtn.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/refresh', { method: 'POST', cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
setSnapshot(await res.json());
|
|
} catch (err) {
|
|
if (snapshot) {
|
|
snapshot.lastError = err.message;
|
|
render();
|
|
} else {
|
|
els.errorBox.hidden = false;
|
|
els.errorBox.textContent = err.message;
|
|
}
|
|
} finally {
|
|
els.refreshBtn.disabled = Boolean(snapshot?.refreshInProgress);
|
|
}
|
|
}
|
|
|
|
function connectEvents() {
|
|
if (!window.EventSource) return;
|
|
const events = new EventSource('/events');
|
|
events.onmessage = (event) => {
|
|
try {
|
|
setSnapshot(JSON.parse(event.data));
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
}
|
|
|
|
[
|
|
els.qFilter,
|
|
els.gameFilter,
|
|
els.mapFilter,
|
|
els.regionFilter,
|
|
els.passFilter,
|
|
els.sortBy,
|
|
els.openOnly,
|
|
].forEach((el) => {
|
|
el.addEventListener('input', render);
|
|
el.addEventListener('change', render);
|
|
});
|
|
|
|
els.resetBtn.addEventListener('click', () => {
|
|
els.qFilter.value = '';
|
|
els.gameFilter.value = '';
|
|
els.mapFilter.value = '';
|
|
els.regionFilter.value = 'all';
|
|
els.passFilter.value = 'all';
|
|
els.sortBy.value = 'players-desc';
|
|
els.openOnly.checked = false;
|
|
render();
|
|
});
|
|
|
|
els.refreshBtn.addEventListener('click', refreshNow);
|
|
|
|
setInterval(() => {
|
|
if (snapshot) els.nextRefresh.textContent = formatCountdown(snapshot.nextRefreshAt);
|
|
}, 1000);
|
|
|
|
setInterval(() => {
|
|
fetchState().catch((err) => {
|
|
if (!snapshot) {
|
|
els.errorBox.hidden = false;
|
|
els.errorBox.textContent = err.message;
|
|
}
|
|
});
|
|
}, 30000);
|
|
|
|
connectEvents();
|
|
fetchState().catch((err) => {
|
|
els.errorBox.hidden = false;
|
|
els.errorBox.textContent = err.message;
|
|
});
|