@@ -0,0 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Steam Auth Admin</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Steam auth admin</h1>
|
||||
<div class="subline">
|
||||
<span id="adminStatus" class="badge badge-muted">locked</span>
|
||||
<a class="admin-link" href="/">Lobby list</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics" aria-label="Auth status">
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">credentials</span>
|
||||
<strong id="credentialsSource">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">refresh token</span>
|
||||
<strong id="tokenStatus">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">steam</span>
|
||||
<strong id="steamStatus">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">game coordinator</span>
|
||||
<strong id="gcStatus">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-main">
|
||||
<section id="loginPanel" class="auth-panel">
|
||||
<h2>Admin password</h2>
|
||||
<div class="admin-grid">
|
||||
<label>
|
||||
<span>password</span>
|
||||
<input id="adminPassword" type="password" autocomplete="current-password">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="unlockBtn" class="primary" type="button">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p id="adminError" class="error" hidden></p>
|
||||
<p id="adminMessage" class="notice" hidden></p>
|
||||
|
||||
<section id="credentialsPanel" class="auth-panel" hidden>
|
||||
<h2>Steam credentials</h2>
|
||||
<div class="admin-grid">
|
||||
<label>
|
||||
<span>account</span>
|
||||
<input id="steamAccount" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>password</span>
|
||||
<input id="steamPassword" type="text" autocomplete="current-password">
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="clearToken" type="checkbox" checked>
|
||||
<span>reset saved refresh token</span>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="reloadBtn" type="button">Reload</button>
|
||||
<button id="saveBtn" class="primary" type="button">Save and reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="guardPanel" class="auth-panel" hidden>
|
||||
<h2>Steam Guard</h2>
|
||||
<div class="admin-grid">
|
||||
<label>
|
||||
<span>code</span>
|
||||
<input id="steamGuardCode" type="text" autocomplete="one-time-code" inputmode="numeric">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="submitGuardBtn" class="primary" type="button">Submit code</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="guardHint" class="hint"></p>
|
||||
</section>
|
||||
|
||||
<section id="tokenPanel" class="auth-panel" hidden>
|
||||
<h2>Saved token</h2>
|
||||
<dl class="details">
|
||||
<div>
|
||||
<dt>SteamID</dt>
|
||||
<dd id="tokenSteamId">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Account</dt>
|
||||
<dd id="tokenAccount">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd id="tokenUpdated">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Expires</dt>
|
||||
<dd id="tokenExpires">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="actions left">
|
||||
<button id="clearTokenBtn" type="button">Clear refresh token</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
const els = {
|
||||
adminStatus: document.querySelector('#adminStatus'),
|
||||
credentialsSource: document.querySelector('#credentialsSource'),
|
||||
tokenStatus: document.querySelector('#tokenStatus'),
|
||||
steamStatus: document.querySelector('#steamStatus'),
|
||||
gcStatus: document.querySelector('#gcStatus'),
|
||||
loginPanel: document.querySelector('#loginPanel'),
|
||||
credentialsPanel: document.querySelector('#credentialsPanel'),
|
||||
guardPanel: document.querySelector('#guardPanel'),
|
||||
tokenPanel: document.querySelector('#tokenPanel'),
|
||||
adminPassword: document.querySelector('#adminPassword'),
|
||||
unlockBtn: document.querySelector('#unlockBtn'),
|
||||
adminError: document.querySelector('#adminError'),
|
||||
adminMessage: document.querySelector('#adminMessage'),
|
||||
steamAccount: document.querySelector('#steamAccount'),
|
||||
steamPassword: document.querySelector('#steamPassword'),
|
||||
clearToken: document.querySelector('#clearToken'),
|
||||
reloadBtn: document.querySelector('#reloadBtn'),
|
||||
saveBtn: document.querySelector('#saveBtn'),
|
||||
steamGuardCode: document.querySelector('#steamGuardCode'),
|
||||
submitGuardBtn: document.querySelector('#submitGuardBtn'),
|
||||
guardHint: document.querySelector('#guardHint'),
|
||||
tokenSteamId: document.querySelector('#tokenSteamId'),
|
||||
tokenAccount: document.querySelector('#tokenAccount'),
|
||||
tokenUpdated: document.querySelector('#tokenUpdated'),
|
||||
tokenExpires: document.querySelector('#tokenExpires'),
|
||||
clearTokenBtn: document.querySelector('#clearTokenBtn'),
|
||||
};
|
||||
|
||||
let adminPassword = sessionStorage.getItem('dokaAdminPassword') || '';
|
||||
let credentialsDirty = false;
|
||||
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return dateTimeFormat.format(date);
|
||||
}
|
||||
|
||||
function setBadge(label, klass) {
|
||||
els.adminStatus.textContent = label;
|
||||
els.adminStatus.className = `badge ${klass}`;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
els.adminError.hidden = !message;
|
||||
els.adminError.textContent = message || '';
|
||||
}
|
||||
|
||||
function showMessage(message) {
|
||||
els.adminMessage.hidden = !message;
|
||||
els.adminMessage.textContent = message || '';
|
||||
}
|
||||
|
||||
async function adminFetch(url, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Password': adminPassword,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function unlocked() {
|
||||
return Boolean(adminPassword);
|
||||
}
|
||||
|
||||
function render(data, options = {}) {
|
||||
const preserveEditedInputs = options.preserveEditedInputs !== false;
|
||||
const connection = data.connection || {};
|
||||
const credentials = data.credentials || {};
|
||||
const token = data.refreshToken || {};
|
||||
const steamGuard = connection.steamGuard || {};
|
||||
|
||||
els.loginPanel.hidden = unlocked();
|
||||
els.credentialsPanel.hidden = !unlocked();
|
||||
els.tokenPanel.hidden = !unlocked();
|
||||
els.guardPanel.hidden = !unlocked() || !steamGuard.required;
|
||||
|
||||
setBadge(unlocked() ? 'unlocked' : 'locked', unlocked() ? 'badge-ok' : 'badge-muted');
|
||||
els.credentialsSource.textContent = credentials.source || '-';
|
||||
els.tokenStatus.textContent = token.exists ? 'saved' : 'missing';
|
||||
els.steamStatus.textContent = connection.steam || '-';
|
||||
els.gcStatus.textContent = connection.gc || '-';
|
||||
|
||||
const editingCredentials = document.activeElement === els.steamAccount
|
||||
|| document.activeElement === els.steamPassword
|
||||
|| credentialsDirty;
|
||||
|
||||
if (!preserveEditedInputs || !editingCredentials) {
|
||||
els.steamAccount.value = credentials.accountName || '';
|
||||
els.steamPassword.value = credentials.password || '';
|
||||
credentialsDirty = false;
|
||||
}
|
||||
|
||||
els.tokenSteamId.textContent = token.steamID || '-';
|
||||
els.tokenAccount.textContent = token.accountName || '-';
|
||||
els.tokenUpdated.textContent = formatDate(token.updatedAt);
|
||||
els.tokenExpires.textContent = formatDate(token.expiresAt);
|
||||
|
||||
if (steamGuard.required) {
|
||||
const where = steamGuard.domain ? `Email domain: ${steamGuard.domain}` : 'Mobile authenticator code required';
|
||||
els.guardHint.textContent = steamGuard.lastCodeWrong ? `Last code was rejected. ${where}.` : where;
|
||||
} else {
|
||||
els.guardHint.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuth(options = {}) {
|
||||
if (!unlocked()) {
|
||||
render({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showError('');
|
||||
const data = await adminFetch('/api/admin/auth');
|
||||
render(data, options);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
if (err.message.includes('Invalid admin password')) {
|
||||
adminPassword = '';
|
||||
sessionStorage.removeItem('dokaAdminPassword');
|
||||
}
|
||||
render({});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCredentials() {
|
||||
try {
|
||||
showError('');
|
||||
showMessage('');
|
||||
const data = await adminFetch('/api/admin/auth', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
accountName: els.steamAccount.value,
|
||||
password: els.steamPassword.value,
|
||||
clearRefreshToken: els.clearToken.checked,
|
||||
}),
|
||||
});
|
||||
credentialsDirty = false;
|
||||
render(data);
|
||||
showMessage('Credentials saved. Reconnect started.');
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSteamGuard() {
|
||||
try {
|
||||
showError('');
|
||||
showMessage('');
|
||||
const data = await adminFetch('/api/admin/steam-guard', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: els.steamGuardCode.value }),
|
||||
});
|
||||
els.steamGuardCode.value = '';
|
||||
render(data);
|
||||
showMessage('Steam Guard code submitted.');
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRefreshToken() {
|
||||
try {
|
||||
showError('');
|
||||
showMessage('');
|
||||
const data = await adminFetch('/api/admin/clear-token', { method: 'POST', body: '{}' });
|
||||
render(data);
|
||||
showMessage('Refresh token cleared.');
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
els.unlockBtn.addEventListener('click', () => {
|
||||
adminPassword = els.adminPassword.value;
|
||||
sessionStorage.setItem('dokaAdminPassword', adminPassword);
|
||||
loadAuth({ preserveEditedInputs: false });
|
||||
});
|
||||
|
||||
els.adminPassword.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') els.unlockBtn.click();
|
||||
});
|
||||
|
||||
els.reloadBtn.addEventListener('click', () => {
|
||||
credentialsDirty = false;
|
||||
loadAuth({ preserveEditedInputs: false });
|
||||
});
|
||||
els.saveBtn.addEventListener('click', saveCredentials);
|
||||
els.submitGuardBtn.addEventListener('click', submitSteamGuard);
|
||||
els.clearTokenBtn.addEventListener('click', clearRefreshToken);
|
||||
|
||||
els.steamAccount.addEventListener('input', () => {
|
||||
credentialsDirty = true;
|
||||
});
|
||||
|
||||
els.steamPassword.addEventListener('input', () => {
|
||||
credentialsDirty = true;
|
||||
});
|
||||
|
||||
els.steamGuardCode.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') els.submitGuardBtn.click();
|
||||
});
|
||||
|
||||
if (adminPassword) {
|
||||
els.adminPassword.value = adminPassword;
|
||||
}
|
||||
|
||||
loadAuth({ preserveEditedInputs: false });
|
||||
setInterval(loadAuth, 5000);
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
const els = {
|
||||
statusBadge: document.querySelector('#statusBadge'),
|
||||
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.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;
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dota 2 Lobbies</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title">
|
||||
<h1>Dota 2 lobbies</h1>
|
||||
<div class="subline">
|
||||
<span id="statusBadge" class="badge badge-muted">starting</span>
|
||||
<span id="serverFilter">GC: Auto</span>
|
||||
<a class="admin-link" href="/admin">Admin auth</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics" aria-label="Status">
|
||||
<div class="metric">
|
||||
<span class="metric-label">shown</span>
|
||||
<strong id="visibleCount">0</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">total</span>
|
||||
<strong id="totalCount">0</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">last update</span>
|
||||
<strong id="lastUpdated">-</strong>
|
||||
</div>
|
||||
<div class="metric wide">
|
||||
<span class="metric-label">next update</span>
|
||||
<strong id="nextRefresh">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="filters" aria-label="Filters">
|
||||
<label>
|
||||
<span>search</span>
|
||||
<input id="qFilter" type="search" autocomplete="off" placeholder="game, map, leader">
|
||||
</label>
|
||||
<label>
|
||||
<span>game</span>
|
||||
<input id="gameFilter" type="search" autocomplete="off" placeholder="hero clash">
|
||||
</label>
|
||||
<label>
|
||||
<span>map</span>
|
||||
<input id="mapFilter" type="search" autocomplete="off" placeholder="ffa">
|
||||
</label>
|
||||
<label>
|
||||
<span>region</span>
|
||||
<select id="regionFilter"></select>
|
||||
</label>
|
||||
<label>
|
||||
<span>password</span>
|
||||
<select id="passFilter">
|
||||
<option value="all">any</option>
|
||||
<option value="no">no password</option>
|
||||
<option value="yes">has password</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>sort</span>
|
||||
<select id="sortBy">
|
||||
<option value="players-desc">most players</option>
|
||||
<option value="open-desc">most open slots</option>
|
||||
<option value="game-asc">game A-Z</option>
|
||||
<option value="region-asc">region A-Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="openOnly" type="checkbox">
|
||||
<span>open slots</span>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="resetBtn" type="button">Reset</button>
|
||||
<button id="refreshBtn" type="button" class="primary">Refresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p id="errorBox" class="error" hidden></p>
|
||||
|
||||
<section class="table-wrap" aria-label="Lobbies">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Game</th>
|
||||
<th>Lobby</th>
|
||||
<th>Map</th>
|
||||
<th class="num">Players</th>
|
||||
<th>Region</th>
|
||||
<th>Password</th>
|
||||
<th>Leader</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lobbyRows">
|
||||
<tr>
|
||||
<td colspan="7" class="empty">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,517 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #eef2f4;
|
||||
--text: #18202a;
|
||||
--muted: #667384;
|
||||
--line: #d8dee6;
|
||||
--accent: #246b5a;
|
||||
--accent-strong: #15513f;
|
||||
--blue: #275f9f;
|
||||
--warn: #956a13;
|
||||
--danger: #a33b36;
|
||||
--shadow: 0 8px 24px rgba(17, 24, 39, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, Segoe UI, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(440px, 1.4fr);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 18px 24px;
|
||||
background: rgba(246, 247, 249, 0.94);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.title h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
line-height: 1.1;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
color: var(--accent-strong);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
border-color: rgba(36, 107, 90, 0.3);
|
||||
background: #e8f4ef;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.badge-work {
|
||||
border-color: rgba(39, 95, 159, 0.28);
|
||||
background: #eaf1fa;
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
border-color: rgba(149, 106, 19, 0.3);
|
||||
background: #fff3d8;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
border-color: rgba(163, 59, 54, 0.28);
|
||||
background: #fdecea;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(92px, 0.7fr) minmax(92px, 0.7fr) minmax(150px, 1fr) minmax(150px, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metric.wide strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1600px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 18px 24px 28px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.1fr) minmax(160px, 1fr) minmax(140px, 0.8fr) minmax(130px, 0.75fr) minmax(120px, 0.7fr) minmax(150px, 0.8fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-bottom: 14px;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.filters label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filters label > span,
|
||||
.check span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filters input[type='search'],
|
||||
.filters select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filters input:focus,
|
||||
.filters select:focus {
|
||||
border-color: rgba(36, 107, 90, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding-bottom: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 36px;
|
||||
padding: 0 13px;
|
||||
color: var(--text);
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f0f4f5;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
color: #ffffff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
color: var(--danger);
|
||||
background: #fdecea;
|
||||
border: 1px solid rgba(163, 59, 54, 0.28);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
color: var(--accent-strong);
|
||||
background: #e8f4ef;
|
||||
border: 1px solid rgba(36, 107, 90, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
margin-bottom: 14px;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-panel h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-grid label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-grid label > span,
|
||||
.details dt,
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-grid input[type='text'],
|
||||
.admin-grid input[type='password'] {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-grid input:focus {
|
||||
border-color: rgba(36, 107, 90, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(36, 107, 90, 0.12);
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.details div {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
background: #fbfcfd;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.details dt {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.details dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.actions.left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 214px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 10px 12px;
|
||||
color: #3a4654;
|
||||
background: #eef2f4;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #edf0f3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f7faf9;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.game {
|
||||
max-width: 460px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: #354150;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pill.pass {
|
||||
background: #fff3d8;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.empty {
|
||||
height: 160px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.topbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.admin-grid,
|
||||
.details {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metrics,
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.metric.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
max-height: calc(100vh - 318px);
|
||||
}
|
||||
|
||||
.admin-grid,
|
||||
.details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.metrics,
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric.wide,
|
||||
.actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user