Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5012e8b16d | ||
|
|
1744ff3f73 | ||
|
|
d560c15fef | ||
|
|
364c0b57c2 | ||
|
|
9f2e810fa1 | ||
|
|
dea0618b69 |
No files matched your search
@@ -357,6 +357,45 @@ Jede Meldung enthält einen Link auf die Match-Übersicht (`/stream/match/<id>`)
|
|||||||
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.
|
||||||
|
|
||||||
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
|
Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.
|
||||||
|
### Hinter einem Reverse-Proxy
|
||||||
|
|
||||||
|
Die Webseite lässt sich hinter einen Reverse-Proxy wie nginx, Caddy oder Traefik stellen, etwa um sie per HTTPS unter einer eigenen Adresse anzubieten.
|
||||||
|
|
||||||
|
- **Eigene (Sub-)Domain, kein Unterpfad.** Die Anwendung nutzt absolute Pfade (`/api`, `/socket.io`, `/stream`, `/admin`). Unter einem Unterpfad wie `example.de/veto/` funktioniert sie nicht. Nimm eine eigene Subdomain, z. B. `veto.example.de`.
|
||||||
|
- **WebSockets durchreichen.** Die Live-Updates laufen über Socket.IO. Der Proxy muss den `Upgrade`-Header weitergeben, sonst bleibt die Seite bei „Verbinde mit Lobby“ hängen.
|
||||||
|
- **`.env` anpassen:**
|
||||||
|
- `PUBLIC_URL=https://veto.example.de`, damit Einladungs- und Stream-Links stimmen
|
||||||
|
- `TRUST_PROXY=true`, damit die Begrenzung der Anmeldeversuche die echte Client-IP sieht und nicht die des Proxys
|
||||||
|
- **Nicht über den Proxy:**
|
||||||
|
- Die CS2-Spielverbindungen laufen per UDP direkt zum Docker-Host. Für Spieler aus dem Internet braucht es dort Portfreigaben und einen GSLT (`CS2_GSLT`).
|
||||||
|
- Die Logs der CS2-Server können weiter über das LAN kommen. `LOG_RECEIVER_URL` bleibt dann z. B. `http://192.168.178.31:3000`.
|
||||||
|
- **Vor dem Freigeben absichern:** Öffentlich erreichbar sind dann auch `/admin` und, ohne Schlüssel, die Stream-Übersicht. Setze ein starkes `ADMIN_PASSWORD` und einen `STREAM_KEY`.
|
||||||
|
|
||||||
|
Beispiel für nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name veto.example.de;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/veto.example.de/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/veto.example.de/privkey.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://192.168.178.31:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein `502 Bad Gateway` vom Proxy heißt, dass er die Anwendung unter `proxy_pass` nicht erreicht: Sie läuft nicht, oder Adresse bzw. Port stimmen nicht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
@@ -383,6 +422,7 @@ Alle Variablen mit Standardwerten stehen in [`.env.example`](.env.example). Die
|
|||||||
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
| `LOG_DEBUG` | `false` | empfangene Logzeilen im Terminal ausgeben |
|
||||||
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
| `STREAM_KEY` | leer | Schlüssel für die Stream-Übersicht aller Matches (`/stream?key=…`); leer = offen |
|
||||||
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
| `DISCORD_WEBHOOK_URL` | leer | Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus |
|
||||||
|
| `TRUST_PROXY` | `false` | `true`, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche) |
|
||||||
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
| `SERIES_AUTO_ADVANCE_SECONDS` | `30` | Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; `-1` = manuell |
|
||||||
|
|
||||||
Benötigte Zugangsdaten für den Echtbetrieb:
|
Benötigte Zugangsdaten für den Echtbetrieb:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
const { current } = veto;
|
const { current } = veto;
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
useEffect(() => setSelected(null), [current?.index]);
|
useEffect(() => setSelected(null), [current?.index]);
|
||||||
|
const left = useCountdown(veto.remainingMs);
|
||||||
|
|
||||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||||
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
||||||
@@ -38,6 +39,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
|
|||||||
? 'Live-Ansicht – nur Captains können wählen.'
|
? 'Live-Ansicht – nur Captains können wählen.'
|
||||||
: 'Das andere Team ist am Zug.'}
|
: 'Das andere Team ist am Zug.'}
|
||||||
</span>
|
</span>
|
||||||
|
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||||||
@@ -129,3 +131,28 @@ function statusText(m, teams) {
|
|||||||
default: return 'Verfügbar';
|
default: return 'Verfügbar';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
|
||||||
|
function TimerBar({ left, totalMs }) {
|
||||||
|
const seconds = Math.ceil(left / 1000);
|
||||||
|
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
|
||||||
|
return (
|
||||||
|
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
|
||||||
|
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
|
||||||
|
<span className="turn-seconds">{seconds} s</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
|
||||||
|
function useCountdown(ms) {
|
||||||
|
const [left, setLeft] = useState(ms ?? null);
|
||||||
|
useEffect(() => {
|
||||||
|
setLeft(ms ?? null);
|
||||||
|
if (ms == null) return undefined;
|
||||||
|
const started = Date.now();
|
||||||
|
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [ms]);
|
||||||
|
return left;
|
||||||
|
}
|
||||||
@@ -523,3 +523,9 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
|
|||||||
.admin-tab:hover { color: var(--text); }
|
.admin-tab:hover { color: var(--text); }
|
||||||
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
.admin-tab.is-active { color: var(--text); border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
|
.turn-timer { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||||
|
.turn-bar { flex: 1; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--team) 18%, transparent); overflow: hidden; }
|
||||||
|
.turn-bar span { display: block; height: 100%; background: var(--team); transition: width .25s linear; }
|
||||||
|
.turn-seconds { min-width: 3ch; text-align: right; color: var(--muted); font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.turn-timer.is-low .turn-bar span { background: var(--error); }
|
||||||
|
.turn-timer.is-low .turn-seconds { color: var(--error); font-weight: 600; }
|
||||||
@@ -23,6 +23,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
const adapter = adapterOverride ?? createServerAdapter(config, { getUsedPorts: () => manager.usedPorts() });
|
||||||
manager = new ServerManager({ db, service, adapter, config, logger });
|
manager = new ServerManager({ db, service, adapter, config, logger });
|
||||||
manager.init();
|
manager.init();
|
||||||
|
service.startVetoTimer();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
if (config.trustProxy) app.set('trust proxy', 1);
|
if (config.trustProxy) app.set('trust proxy', 1);
|
||||||
@@ -54,6 +55,7 @@ export function createApp(config, { adapter: adapterOverride, logger = console }
|
|||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
manager.stopLoops();
|
manager.stopLoops();
|
||||||
|
service.stopVetoTimer();
|
||||||
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
// Laufende Server-Aktionen (Start, Konfiguration) kurz abwarten, bevor die Datenbank schließt
|
||||||
await manager.idle(5000);
|
await manager.idle(5000);
|
||||||
io.disposeRealtime();
|
io.disposeRealtime();
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export function loadConfig(overrides = {}) {
|
|||||||
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
maxLifetimeHours: int('SERVER_MAX_LIFETIME_HOURS', 8),
|
||||||
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
statusPollSeconds: int('SERVER_STATUS_POLL_SECONDS', 5),
|
||||||
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
reaperIntervalSeconds: int('SERVER_REAPER_INTERVAL_SECONDS', 60),
|
||||||
|
// Frist pro Veto-Schritt in Sekunden, danach führt das Backend den Schritt automatisch aus. 0 = keine Frist
|
||||||
|
vetoStepSeconds: int('VETO_STEP_SECONDS', 30),
|
||||||
|
|
||||||
match: {
|
match: {
|
||||||
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
tvDelaySeconds: int('CS2_TV_DELAY', 90),
|
||||||
|
|||||||
@@ -351,6 +351,58 @@ export class MatchService extends EventEmitter {
|
|||||||
this.changed(viewer.matchId);
|
this.changed(viewer.matchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ablaufzeitpunkt des aktuellen Veto-Schritts (ms), null wenn keine Frist gilt. */
|
||||||
|
vetoDeadline(match) {
|
||||||
|
if (!this.config.vetoStepSeconds || match.status !== 'veto') return null;
|
||||||
|
const actions = this.vetoActions(match.id);
|
||||||
|
const since = actions.length ? actions[actions.length - 1].created_at : match.veto_started_at;
|
||||||
|
return since == null ? null : since + this.config.vetoStepSeconds * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
vetoRemainingMs(match) {
|
||||||
|
const deadline = this.vetoDeadline(match);
|
||||||
|
return deadline === null ? null : Math.max(0, deadline - this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Führt den aktuellen Veto-Schritt für das Team am Zug aus: zufällige Map bzw. zufällige Seite. */
|
||||||
|
autoVetoStep(match) {
|
||||||
|
const state = this.vetoState(match);
|
||||||
|
if (state.done || !state.current) return;
|
||||||
|
const { type, team, index } = state.current;
|
||||||
|
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
||||||
|
const viewer = {
|
||||||
|
matchId: match.id, role: 'captain', isOrganizer: false, isCaptain: true, isSpectator: false, playerId: null, team,
|
||||||
|
};
|
||||||
|
const action = type === 'side'
|
||||||
|
? { stepIndex: index, side: pick(['CT', 'T']) }
|
||||||
|
: { stepIndex: index, map: pick(state.maps.filter((m) => m.status === 'available')).id };
|
||||||
|
this.log(match.id, 'veto', `Zeit abgelaufen – ${this.teamName(match, team)} wird automatisch vertreten`);
|
||||||
|
this.vetoAction(viewer, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Läuft die Frist eines Veto-Schritts ab, führt das Backend den Schritt aus. */
|
||||||
|
autoVetoTimeouts() {
|
||||||
|
for (const match of this.db.prepare("SELECT * FROM matches WHERE status = 'veto'").all()) {
|
||||||
|
const deadline = this.vetoDeadline(match);
|
||||||
|
if (deadline === null || this.now() < deadline) continue;
|
||||||
|
try {
|
||||||
|
this.autoVetoStep(match);
|
||||||
|
} catch (err) {
|
||||||
|
this.log(match.id, 'veto', `Automatischer Schritt fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startVetoTimer() {
|
||||||
|
if (!this.config.vetoStepSeconds || this.vetoTimer) return;
|
||||||
|
this.vetoTimer = setInterval(() => this.autoVetoTimeouts(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopVetoTimer() {
|
||||||
|
clearInterval(this.vetoTimer);
|
||||||
|
this.vetoTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
vetoAction(viewer, { stepIndex, map, side }) {
|
vetoAction(viewer, { stepIndex, map, side }) {
|
||||||
this.require(viewer, 'captain');
|
this.require(viewer, 'captain');
|
||||||
let finished = false;
|
let finished = false;
|
||||||
@@ -841,6 +893,8 @@ export class MatchService extends EventEmitter {
|
|||||||
steps: veto.steps,
|
steps: veto.steps,
|
||||||
maps: veto.maps,
|
maps: veto.maps,
|
||||||
current: match.status === 'veto' ? veto.current : null,
|
current: match.status === 'veto' ? veto.current : null,
|
||||||
|
remainingMs: this.vetoRemainingMs(match),
|
||||||
|
stepSeconds: this.config.vetoStepSeconds,
|
||||||
done: veto.done,
|
done: veto.done,
|
||||||
history: actions.map((a) => ({
|
history: actions.map((a) => ({
|
||||||
index: a.idx,
|
index: a.idx,
|
||||||
|
|||||||
Reference in new issue
Block a user