Add a troubleshooting panel (#2951)

## Description:

Add a troobleshooting panel with the most common problems, and a button
to copy the infos for better sharing

<img width="893" height="583" alt="image"
src="https://github.com/user-attachments/assets/7a37f88c-45b2-448c-86fc-6a3736bc9b25"
/>
<img width="654" height="697" alt="image"
src="https://github.com/user-attachments/assets/11dc1898-579b-42c0-953f-f8237eca2922"
/>

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Mr. Box
This commit is contained in:
Vivacious Box
2026-01-28 00:51:11 +01:00
committed by GitHub
parent 6cca96b545
commit db745dcf4a
6 changed files with 497 additions and 1 deletions
+5
View File
@@ -199,6 +199,11 @@
inline
class="hidden w-full h-full page-content"
></user-setting>
<troubleshooting-modal
id="page-troubleshooting"
inline
class="hidden w-full h-full page-content"
></troubleshooting-modal>
<stats-modal
id="page-stats"
inline
+33
View File
@@ -7,6 +7,7 @@
},
"common": {
"close": "Close",
"copy": "Copy",
"back": "Back",
"available": "Available",
"preset_max": "Max",
@@ -51,12 +52,43 @@
"account": "Account",
"help": "Help",
"menu": "Menu",
"troubleshooting": "Troubleshooting",
"go_to_troubleshooting": "Go to our troubleshooting page",
"pick_pattern": "Pick a pattern!"
},
"news": {
"github_link": "on GitHub",
"title": "Release Notes"
},
"troubleshooting": {
"title": "Troubleshooting",
"loading": "Loading...",
"environment": "Environment",
"rendering": "Rendering",
"power": "Power",
"browser": "Browser",
"platform": "Platform",
"copied_to_clipboard": "Info copied to the clipboard! Feel free to share it on our Discord if you need help.",
"os": "OS",
"device_pixel_ratio": "Device Pixel Ratio",
"chromium_tip": "OpenFront runs best on Chromium-based browsers.",
"hardware_acceleration_tip": "Make sure hardware acceleration is enabled in your browser settings for optimal performance.",
"renderer": "Renderer",
"max_texture_size": "Max Texture Size",
"high_precision_shaders": "High Precision Shaders",
"gpu": "GPU",
"unavailable": "Unavailable",
"gpu_tip": "Verify that this is the dedicated GPU, if one is available.",
"battery": "Battery",
"charging": "Charging",
"battery_level": "Battery Level",
"power_saving_tip": "Make sure that your browser is not set to power saving mode.",
"yes": "Yes",
"no": "No",
"unknown": "Unknown",
"software_rendering": "Software rendering",
"canvas_2d_no_gpu": "Canvas 2D (no GPU)"
},
"help_modal": {
"hotkeys": "Hotkeys",
"table_key": "Key",
@@ -141,6 +173,7 @@
"build_mirv": "MIRV",
"build_mirv_desc": "The most powerful bomb in the game. Splits up into smaller bombs that will cover a huge range of territory. Only damages the player that you first clicked on to build it. Spawns from the nearest Missile Silo and lands in the area you first clicked to build it.",
"player_icons": "Player icons",
"troubleshooting_desc": "If you experience performance issues, crashes, or other problems while playing OpenFront, please visit our Troubleshooting page for help diagnosing and fixing common issues:",
"icon_desc": "Examples of some of the ingame icons you will encounter and what they mean:",
"icon_crown": "Crown - Number 1. This is the top player in the leaderboard.",
"icon_traitor": "Broken shield - Traitor. This player attacked an ally.",
+63 -1
View File
@@ -5,6 +5,7 @@ import { BaseModal } from "./components/BaseModal";
import "./components/Difficulties";
import "./components/Maps";
import { modalHeader } from "./components/ui/ModalHeader";
import { TroubleshootingModal } from "./TroubleshootingModal";
@customElement("help-modal")
export class HelpModal extends BaseModal {
@@ -104,7 +105,7 @@ export class HelpModal extends BaseModal {
: ""}"
>
${modalHeader({
title: translateText("main.instructions"),
title: translateText("main.help"),
onBack: this.close,
ariaLabel: translateText("common.back"),
})}
@@ -120,6 +121,53 @@ export class HelpModal extends BaseModal {
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
>
<!-- Troubleshooting Section -->
<div class="flex items-center gap-3 mb-3">
<div class="text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 20 L12 0 L22 20 L2 20"></path>
<line x1="12" y1="8" x2="12" y2="14"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</div>
<h3
class="text-xl font-bold uppercase tracking-widest text-white/90"
>
${translateText("main.troubleshooting")}
</h3>
<div
class="flex-1 h-px bg-gradient-to-r from-blue-500/50 to-transparent"
></div>
</div>
<section>
<div class="w-full flex flex-col items-center">
<p class="mb-6 text-white/70 text-sm">
${translateText("help_modal.troubleshooting_desc")}
</p>
<button
id="troubleshooting-button"
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
data-page="page-troubleshooting"
@click="${this.openTroubleshooting}"
data-i18n="main.go_to_troubleshooting"
>
<span
class="relative z-10 text-2xl"
data-i18n="main.go_to_troubleshooting"
></span>
</button>
</div>
</section>
<!-- Hotkeys Section -->
<div class="flex items-center gap-3 mb-3">
<div class="text-blue-400">
@@ -1137,6 +1185,20 @@ export class HelpModal extends BaseModal {
`;
}
openTroubleshooting() {
const troubleshootingModal = document.querySelector(
"troubleshooting-modal",
) as TroubleshootingModal;
if (
!troubleshootingModal ||
!(troubleshootingModal instanceof TroubleshootingModal)
) {
console.warn("Troubleshooting modal element not found");
return;
}
troubleshootingModal.open();
}
protected onOpen(): void {
this.keybinds = this.getKeybinds();
}
+1
View File
@@ -813,6 +813,7 @@ class Client {
"game-top-bar",
"help-modal",
"user-setting",
"troubleshooting-modal",
"territory-patterns-modal",
"language-modal",
"news-modal",
+254
View File
@@ -0,0 +1,254 @@
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "./Utils";
import { BaseModal } from "./components/BaseModal";
import "./components/baseComponents/Modal";
import { modalHeader } from "./components/ui/ModalHeader";
import {
collectGraphicsDiagnostics,
GraphicsDiagnostics,
} from "./utilities/Diagnostic";
import infoIcon from "/images/InfoIcon.svg?url";
@customElement("troubleshooting-modal")
export class TroubleshootingModal extends BaseModal {
@property({ type: String }) markdown = "Loading...";
@property({ type: Object })
diagnostics?: GraphicsDiagnostics;
@property({ type: Boolean }) loading = true;
private initialized: boolean = false;
private async loadDiagnostics() {
const canvas = document.createElement("canvas");
this.diagnostics = await collectGraphicsDiagnostics(canvas);
this.loading = false;
this.initialized = true;
}
render() {
const content = html`
<div
class="h-full select-text flex flex-col ${this.inline
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
: ""}"
>
${modalHeader({
titleContent: html` <div
class="w-full flex flex-col sm:flex-row justify-between gap-2"
>
<span
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
>
<a
class="hover:text-blue-200 text-blue-400 cursor-pointer"
@click=${this.close}
>${translateText("main.help")}</a
>
/ ${translateText("troubleshooting.title")}
</span>
<button
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
@click=${this.copyDiagnostics}
>
${translateText("common.copy")}
</button>
</div>`,
onBack: this.close,
ariaLabel: translateText("common.back"),
})}
${this.loading
? ""
: html`
<div
class="flex-1 overflow-y-auto px-1 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
>
${this.section(
"",
html`${this.infoTip(
translateText("troubleshooting.hardware_acceleration_tip"),
true,
)}`,
)}
${this.section(
translateText("troubleshooting.environment"),
html`
${this.row(
translateText("troubleshooting.browser"),
this.diagnostics!.browser.engine,
)}
${this.row(
translateText("troubleshooting.platform"),
this.diagnostics!.browser.platform,
)}
${this.row(
translateText("troubleshooting.os"),
this.diagnostics!.browser.os,
)}
${this.row(
translateText("troubleshooting.device_pixel_ratio"),
this.diagnostics!.browser.dpr,
)}
${this.infoTip(
translateText("troubleshooting.chromium_tip"),
)}
`,
)}
${this.section(
translateText("troubleshooting.rendering"),
html`
${this.row(
translateText("troubleshooting.renderer"),
this.describeRenderer(this.diagnostics!.rendering),
)}
${this.row(
translateText("troubleshooting.max_texture_size"),
this.diagnostics!.rendering.maxTextureSize ??
translateText("troubleshooting.unknown"),
)}
${this.row(
translateText("troubleshooting.high_precision_shaders"),
this.diagnostics!.rendering.shaderHighp === true
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}${this.row(
translateText("troubleshooting.gpu"),
!this.diagnostics!.rendering.gpu ||
this.diagnostics!.rendering.gpu.unavailable
? translateText("troubleshooting.unavailable")
: `${this.diagnostics!.rendering.gpu.vendor}${this.diagnostics!.rendering.gpu.renderer}`,
)}
${this.infoTip(translateText("troubleshooting.gpu_tip"))}
`,
)}
${this.section(
translateText("troubleshooting.power"),
html`
${this.diagnostics!.power.unavailable
? this.row(
translateText("troubleshooting.battery"),
translateText("troubleshooting.unavailable"),
)
: html`
${this.row(
translateText("troubleshooting.charging"),
this.diagnostics!.power.charging
? translateText("troubleshooting.yes")
: translateText("troubleshooting.no"),
)}
${this.row(
translateText("troubleshooting.battery_level"),
this.diagnostics!.power.level,
)}
`}
${this.infoTip(
translateText("troubleshooting.power_saving_tip"),
)}
`,
)}
</div>
`}
</div>
`;
if (this.inline) {
return content;
}
return html`
<o-modal
title=${translateText("troubleshooting.title")}
?inline=${this.inline}
hideCloseButton
hideHeader
>
${content}
</o-modal>
`;
}
private infoTip(text: string, warning?: boolean): unknown {
return html`
<div
class="mt-2 ${warning
? "bg-orange-500/10"
: "bg-white/10"} flex gap-2 text-white py-1 px-3 rounded-sm border-1 ${warning
? "border-orange-400"
: "border-white/40"}"
>
<img src="${infoIcon}" class="w-4" />
${text}
</div>
`;
}
protected onOpen(): void {
if (!this.initialized) {
this.initialized = true;
this.loadDiagnostics();
}
}
private section(title: string, content: unknown) {
return html`
<div class="px-4 py-3">
<h4
class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400"
>
${title}
</h4>
<div class="space-y-1">${content}</div>
</div>
`;
}
private row(label: string, value: unknown) {
return html`
<div class="flex justify-between gap-4 text-sm">
<span class="text-slate-400">${label}</span>
<span class="text-right text-white max-w-100">${value}</span>
</div>
`;
}
private async copyDiagnostics() {
if (!this.diagnostics) return;
const formatted =
"```json\n" + JSON.stringify(this.diagnostics, null, 2) + "\n```";
await navigator.clipboard.writeText(formatted);
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: html`${translateText("troubleshooting.copied_to_clipboard")}`,
type: "info",
duration: 3000,
},
}),
);
}
private describeRenderer(rendering: any): string {
if (rendering.gpu?.software) {
return translateText("troubleshooting.software_rendering");
}
if (rendering.type === "Canvas2D") {
return translateText("troubleshooting.canvas_2d_no_gpu");
}
return `${rendering.type}`;
}
public close(): void {
this.unregisterEscapeHandler();
if (this.inline) {
this.style.pointerEvents = "none";
if (window.showPage) {
window.showPage?.("page-help");
}
} else {
this.modalEl?.close();
}
}
}
+141
View File
@@ -0,0 +1,141 @@
export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2";
export interface BrowserInfo {
engine: string;
platform: string;
os: string;
dpr: number;
}
export interface GraphicsDiagnostics {
browser: BrowserInfo;
rendering: RenderingInfo;
power: PowerInfo;
}
export interface GPUInfo {
vendor?: string;
renderer?: string;
software?: boolean;
unavailable?: boolean;
}
export interface RenderingInfo {
type: RendererType;
antialias?: boolean;
maxTextureSize?: number;
shaderHighp?: boolean;
gpu?: GPUInfo;
}
export interface PerformanceInfo {
fps: number;
worstFrameMs: number;
jankPercent: number;
throttlingLikely: boolean;
}
export interface PowerInfo {
charging?: boolean;
level?: string;
unavailable?: boolean;
}
export async function collectGraphicsDiagnostics(
canvas: HTMLCanvasElement,
): Promise<GraphicsDiagnostics> {
/* ---------- Browser / OS ---------- */
const uaData = (navigator as any).userAgentData;
const os = uaData?.platform ?? detectOS(navigator.userAgent);
const browser: BrowserInfo = {
engine: uaData?.brands
? uaData.brands.map((b: any) => b.brand).join(", ")
: navigator.userAgent,
platform: navigator.platform,
os,
dpr: window.devicePixelRatio,
};
/* ---------- Rendering ---------- */
let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
let type: RendererType = "Canvas2D";
gl =
canvas.getContext("webgl2", { antialias: true }) ??
canvas.getContext("webgl", { antialias: true });
if (gl) {
const isWebGL2 =
typeof WebGL2RenderingContext !== "undefined" &&
gl instanceof WebGL2RenderingContext;
type = isWebGL2 ? "WebGL2" : "WebGL1";
}
const rendering: RenderingInfo = { type };
if (gl) {
rendering.antialias = gl.getContextAttributes()?.antialias ?? false;
rendering.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
const precision = gl.getShaderPrecisionFormat(
gl.FRAGMENT_SHADER,
gl.HIGH_FLOAT,
);
rendering.shaderHighp = precision !== null && precision.precision > 0;
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
if (debugInfo) {
const renderer = gl.getParameter(
(debugInfo as any).UNMASKED_RENDERER_WEBGL,
) as string;
const vendor = gl.getParameter(
(debugInfo as any).UNMASKED_VENDOR_WEBGL,
) as string;
rendering.gpu = {
vendor,
renderer,
software: /swiftshader|llvmpipe|software/i.test(renderer),
};
} else {
rendering.gpu = { unavailable: true };
}
}
/* ---------- Power ---------- */
let power: PowerInfo = {};
if ("getBattery" in navigator) {
try {
const battery = await (navigator as any).getBattery();
power = {
charging: battery.charging,
level: Math.round(battery.level * 100) + "%",
};
} catch {
power = { unavailable: true };
}
} else {
power = { unavailable: true };
}
return {
browser,
rendering,
power,
};
}
function detectOS(ua: string): string {
if (/windows nt/i.test(ua)) return "Windows";
if (/mac os x/i.test(ua)) return "macOS";
if (/android/i.test(ua)) return "Android";
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
if (/linux/i.test(ua)) return "Linux";
return "Unknown";
}