Sunshine/src_assets/common/assets/web/troubleshooting.html
Joey Yakimowich-Payne 718c45b76a
Some checks failed
ci-bundle.yml / Improve visibility of live input activity dots (push) Failing after 0s
ci-copr.yml / Improve visibility of live input activity dots (push) Failing after 0s
ci-homebrew.yml / Improve visibility of live input activity dots (push) Failing after 0s
Improve visibility of live input activity dots
2026-02-11 16:22:40 -07:00

757 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<%- header %>
</head>
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<h1 class="my-4">{{ $t('troubleshooting.troubleshooting') }}</h1>
<!-- ViGEmBus Installation -->
<div class="card my-4" v-if="platform === 'windows' && controllerEnabled">
<div class="card-body">
<h2 id="vigembus">{{ $t('troubleshooting.vigembus_install') }}</h2>
<p style="white-space: pre-line">{{ $t('troubleshooting.vigembus_desc') }}</p>
<div v-if="vigembus.installed && vigembus.version">
<p><strong>{{ $t('troubleshooting.vigembus_current_version') }}:</strong> {{ vigembus.version }}</p>
<div class="alert alert-success" v-if="vigembus.version_compatible">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.vigembus_compatible') }}
</div>
<div class="alert alert-danger" v-if="!vigembus.version_compatible">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.vigembus_incompatible') }}
</div>
</div>
<div class="alert alert-warning" v-else-if="!vigembus.installed">
<alert-triangle :size="18" class="icon"></alert-triangle>
{{ $t('troubleshooting.vigembus_not_installed') }}
</div>
<div class="alert alert-success" v-if="vigemBusInstallStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.vigembus_install_success') }}
</div>
<div class="alert alert-danger" v-if="vigemBusInstallStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ vigemBusInstallError || $t('troubleshooting.vigembus_install_error') }}
</div>
<div>
<button
:class="vigembus.installed && vigembus.version === vigembus.packaged_version ? 'btn btn-danger' : 'btn btn-primary'"
:disabled="vigemBusInstallPressed"
@click="installViGEmBus">
<download :size="18" class="icon" v-if="!(vigembus.installed && vigembus.version === vigembus.packaged_version)"></download>
<refresh-cw :size="18" class="icon" v-else></refresh-cw>
{{ vigembus.installed && vigembus.version === vigembus.packaged_version ? $t('troubleshooting.vigembus_force_reinstall_button', { version: vigembus.packaged_version }) : $t('troubleshooting.vigembus_install_button', { version: vigembus.packaged_version }) }}
</button>
</div>
</div>
</div>
<!-- Force Close App -->
<div class="card my-4">
<div class="card-body">
<h2 id="close_apps">{{ $t('troubleshooting.force_close') }}</h2>
<p>{{ $t('troubleshooting.force_close_desc') }}</p>
<div class="alert alert-success" v-if="closeAppStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.force_close_success') }}
</div>
<div class="alert alert-danger" v-if="closeAppStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.force_close_error') }}
</div>
<div>
<button class="btn btn-warning" :disabled="closeAppPressed" @click="closeApp">
<x-circle :size="18" class="icon"></x-circle>
{{ $t('troubleshooting.force_close') }}
</button>
</div>
</div>
</div>
<!-- Restart Sunshine -->
<div class="card my-4">
<div class="card-body">
<h2 id="restart">{{ $t('troubleshooting.restart_sunshine') }}</h2>
<p>{{ $t('troubleshooting.restart_sunshine_desc') }}</p>
<div class="alert alert-success" v-if="restartPressed === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.restart_sunshine_success') }}
</div>
<div>
<button class="btn btn-warning" :disabled="restartPressed" @click="restart">
<refresh-cw :size="18" class="icon"></refresh-cw>
{{ $t('troubleshooting.restart_sunshine') }}
</button>
</div>
</div>
</div>
<!-- Reset persistent display device settings -->
<div class="card my-4" v-if="platform === 'windows'">
<div class="card-body">
<h2 id="dd_reset">{{ $t('troubleshooting.dd_reset') }}</h2>
<p style="white-space: pre-line">{{ $t('troubleshooting.dd_reset_desc') }}</p>
<div class="alert alert-success" v-if="ddResetStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.dd_reset_success') }}
</div>
<div class="alert alert-danger" v-if="ddResetStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.dd_reset_error') }}
</div>
<div>
<button class="btn btn-warning" :disabled="ddResetPressed" @click="ddResetPersistence">
<rotate-ccw :size="18" class="icon"></rotate-ccw>
{{ $t('troubleshooting.dd_reset') }}
</button>
</div>
</div>
</div>
<!-- Unpair Clients -->
<div class="card my-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 id="unpair" class="mb-0">{{ $t('troubleshooting.unpair_title') }}</h2>
<button class="btn btn-danger" :disabled="unpairAllPressed" @click="unpairAll">
<trash-2 :size="18" class="icon"></trash-2>
{{ $t('troubleshooting.unpair_all') }}
</button>
</div>
<p>{{ $t('troubleshooting.unpair_desc') }}</p>
<div class="alert alert-success d-flex align-items-center" v-if="showApplyMessage">
<check-circle :size="18" class="icon"></check-circle>
<div><b>{{ $t('_common.success') }}</b> {{ $t('troubleshooting.unpair_single_success') }}</div>
<button class="btn btn-success ms-auto" @click="clickedApplyBanner">{{ $t('_common.dismiss') }}</button>
</div>
<div class="alert alert-success" v-if="unpairAllStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.unpair_all_success') }}
</div>
<div class="alert alert-danger" v-if="unpairAllStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.unpair_all_error') }}
</div>
</div>
<ul class="list-group list-group-flush" v-if="clients && clients.length > 0">
<li v-for="client in clients" :key="client.uuid" class="list-group-item d-flex align-items-center">
<div class="flex-grow-1">{{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}</div>
<button class="btn btn-danger ms-auto" @click="unpairSingle(client.uuid)">
<trash-2 :size="18" class="icon"></trash-2>
</button>
</li>
</ul>
<ul v-else class="list-group list-group-flush">
<li class="list-group-item p-3 text-center">
<em>{{ $t('troubleshooting.unpair_single_no_devices') }}</em>
</li>
</ul>
</div>
<!-- Live Input Status -->
<div class="card my-4">
<div class="card-body">
<h2 id="input_status">Live Input Status</h2>
<p>Per-session input activity and policy. Indicators update twice per second.</p>
</div>
<div v-if="activeSessions.length === 0" class="card-body pt-0">
<em>No active streaming sessions.</em>
</div>
<ul class="list-group list-group-flush" v-else>
<li v-for="s in activeSessions" :key="s.session_id" class="list-group-item">
<div class="d-flex align-items-center justify-content-between">
<div>
<strong>{{ s.client_name || s.client_uuid || 'Unknown' }}</strong>
<span v-if="s.is_owner_session" class="badge bg-primary ms-2">Owner</span>
</div>
<div class="d-flex gap-3 align-items-center">
<span class="d-flex align-items-center gap-1" title="Keyboard">
<span :class="['activity-dot', s.activity.keyboard_active ? 'dot-green' : 'dot-gray']"></span>
KB
<span :class="['badge', s.policy.allow_keyboard ? 'bg-success' : 'bg-secondary']" style="font-size: 0.7em">
{{ s.policy.allow_keyboard ? 'ON' : 'OFF' }}
</span>
</span>
<span class="d-flex align-items-center gap-1" title="Mouse">
<span :class="['activity-dot', s.activity.mouse_active ? 'dot-green' : 'dot-gray']"></span>
Mouse
<span :class="['badge', s.policy.allow_mouse ? 'bg-success' : 'bg-secondary']" style="font-size: 0.7em">
{{ s.policy.allow_mouse ? 'ON' : 'OFF' }}
</span>
</span>
<span class="d-flex align-items-center gap-1" title="Gamepad">
<span :class="['activity-dot', s.activity.gamepad_active ? 'dot-green' : 'dot-gray']"></span>
Gamepad
<span :class="['badge', s.policy.allow_gamepad ? 'bg-success' : 'bg-secondary']" style="font-size: 0.7em">
{{ s.policy.allow_gamepad ? 'ON' : 'OFF' }}
</span>
</span>
</div>
</div>
</li>
</ul>
</div>
<!-- Logs -->
<div class="card my-4">
<div class="card-body">
<h2 id="logs">{{ $t('troubleshooting.logs') }}</h2>
<div class="d-flex justify-content-between align-items-baseline py-2">
<p>{{ $t('troubleshooting.logs_desc') }}</p>
<div class="input-group" style="max-width: 300px">
<span class="input-group-text">
<search :size="18" class="icon"></search>
</span>
<input type="text" class="form-control" v-model="logFilter" :placeholder="$t('troubleshooting.logs_find')" />
</div>
</div>
<div>
<div class="troubleshooting-logs" ref="logsContainer">
<div class="log-nav-overlay">
<div class="log-nav-controls">
<button class="log-nav-btn" @click="scrollLogsTo('top')" title="Jump to Top">
<chevrons-up :size="18" class="icon"></chevrons-up>
</button>
<button class="log-nav-btn" @click="navigateToLog('prev')" :disabled="!hasPrevLog" title="Previous Warning/Error">
<chevron-up :size="18" class="icon"></chevron-up>
</button>
<button class="log-nav-btn" @click="navigateToLog('next')" :disabled="!hasNextLog" title="Next Warning/Error">
<chevron-down :size="18" class="icon"></chevron-down>
</button>
<button class="log-nav-btn" @click="scrollLogsTo('bottom')" title="Jump to Bottom">
<chevrons-down :size="18" class="icon"></chevrons-down>
</button>
<button class="log-nav-btn" @click="copyLogs" title="Copy Logs">
<copy :size="18" class="icon"></copy>
</button>
</div>
</div>
<pre class="mb-0" v-html="highlightedLogs"></pre>
</div>
</div>
</div>
</div>
</div>
<script type="module">
import { createApp } from 'vue'
import { initApp } from './init'
import Navbar from './Navbar.vue'
import {
AlertCircle,
AlertTriangle,
CheckCircle,
ChevronDown,
ChevronUp,
ChevronsDown,
ChevronsUp,
Copy,
Download,
RefreshCw,
RotateCcw,
Search,
Trash2,
XCircle,
} from 'lucide-vue-next'
const app = createApp({
components: {
Navbar,
AlertCircle,
AlertTriangle,
CheckCircle,
ChevronDown,
ChevronUp,
ChevronsDown,
ChevronsUp,
Copy,
Download,
RefreshCw,
RotateCcw,
Search,
Trash2,
XCircle,
},
data() {
return {
activeSessions: [],
clients: [],
closeAppPressed: false,
closeAppStatus: null,
ddResetPressed: false,
ddResetStatus: null,
logs: 'Loading...',
logFilter: null,
logInterval: null,
sessionInterval: null,
sessionSocket: null,
sessionReconnectTimer: null,
restartPressed: false,
showApplyMessage: false,
platform: "",
controllerEnabled: false,
unpairAllPressed: false,
unpairAllStatus: null,
vigembus: {
installed: false,
version: '',
version_compatible: false,
packaged_version: '',
},
vigemBusInstallPressed: false,
vigemBusInstallStatus: null,
vigemBusInstallError: null,
currentLogIndex: -1,
logLines: [],
};
},
computed: {
actualLogs() {
if (!this.logFilter) return this.logs;
const filterLower = this.logFilter.toLowerCase();
return this.logs
.split("\n")
.filter((x) => x.toLowerCase().includes(filterLower))
.join("\n");
},
/**
* Parse the (possibly multi-line) log output into timestamp-prefixed entries.
* Each entry starts with: [YYYY-MM-DD HH:MM:SS.mmm]:
*/
parsedLogEntries() {
const text = this.actualLogs || '';
// Match on timestamp tokens, but keep everything between them as the entry body.
// Using a global exec loop lets us split without losing delimiters and works
// even when entries span multiple lines.
const tsRegex = /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]:/g;
const entries = [];
const matches = Array.from(text.matchAll(tsRegex));
// If no timestamps are found, treat everything as a single entry.
if (matches.length === 0) {
const raw = text.trimEnd();
if (!raw) return [];
return [
{
index: 0,
raw,
level: 'Info',
cssClass: 'log-line-info',
},
];
}
for (let i = 0; i < matches.length; i++) {
const start = matches[i].index;
const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
const raw = text.slice(start, end).trimEnd();
if (!raw) continue;
// Determine level based on the *first* level token in the entry.
// Sunshine logs are typically: "[ts]: Level: message".
// Some messages may contain additional embedded timestamps, but we treat
// those as part of the entry content.
let level = 'Info';
let cssClass = 'log-line-info';
if (/\]:\s*Fatal:/i.test(raw)) {
level = 'Fatal';
cssClass = 'log-line-fatal';
} else if (/\]:\s*(Error|Critical):/i.test(raw)) {
level = 'Error';
cssClass = 'log-line-error';
} else if (/\]:\s*Warning:/i.test(raw)) {
level = 'Warning';
cssClass = 'log-line-warning';
} else if (/\]:\s*Debug:/i.test(raw)) {
level = 'Debug';
cssClass = 'log-line-debug';
}
entries.push({
index: entries.length,
raw,
level,
cssClass,
});
}
return entries;
},
highlightedLogs() {
const escapeHtml = (s) =>
s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
return this.parsedLogEntries
.map((entry) => {
const safe = escapeHtml(entry.raw);
const isSelected = entry.index === this.currentLogIndex;
const selectedClass = isSelected ? ' log-entry-selected' : '';
return `<span data-entry-index="${entry.index}" data-log-level="${entry.cssClass}" class="${entry.cssClass}${selectedClass}">${safe}</span>`;
})
// Separate entries visually with a newline.
.join("\n");
},
errorWarningEntries() {
// Only navigate between warnings/errors/fatal/critical
return this.parsedLogEntries
.filter((e) => e.level === 'Warning' || e.level === 'Error' || e.level === 'Fatal')
.map((e) => e.index);
},
hasNextLog() {
const indices = this.errorWarningEntries;
if (indices.length === 0) return false;
if (this.currentLogIndex === -1) return true;
return indices.some((i) => i > this.currentLogIndex);
},
hasPrevLog() {
const indices = this.errorWarningEntries;
if (indices.length === 0) return false;
if (this.currentLogIndex === -1) return false;
return indices.some((i) => i < this.currentLogIndex);
}
},
created() {
fetch("/api/config")
.then((r) => r.json())
.then((r) => {
this.platform = r.platform;
this.controllerEnabled = r.controller !== "disabled";
// Fetch ViGEmBus status only on Windows when gamepad is enabled
if (this.platform === 'windows' && this.controllerEnabled) {
this.refreshViGEmBusStatus();
}
});
this.logInterval = setInterval(() => {
this.refreshLogs();
}, 5000);
this.sessionInterval = setInterval(() => {
this.refreshActiveSessions();
}, 500);
this.refreshLogs();
this.refreshClients();
this.refreshActiveSessions();
this.connectActiveSessionsSocket();
},
beforeDestroy() {
clearInterval(this.logInterval);
clearInterval(this.sessionInterval);
clearTimeout(this.sessionReconnectTimer);
if (this.sessionSocket) {
this.sessionSocket.onclose = null;
this.sessionSocket.close();
this.sessionSocket = null;
}
},
methods: {
connectActiveSessionsSocket() {
fetch("./api/sessions/ws-token")
.then((r) => r.json())
.then((r) => {
if (!r || r.status !== true || !r.token) {
throw new Error("No websocket token");
}
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${window.location.host}/api/sessions/active/ws?token=${encodeURIComponent(r.token)}`;
this.sessionSocket = new WebSocket(wsUrl);
this.sessionSocket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload && payload.status === true && payload.sessions) {
this.activeSessions = payload.sessions;
}
} catch (_e) {
return;
}
};
this.sessionSocket.onclose = () => {
this.sessionSocket = null;
clearTimeout(this.sessionReconnectTimer);
this.sessionReconnectTimer = setTimeout(() => {
this.connectActiveSessionsSocket();
}, 1000);
};
this.sessionSocket.onerror = () => {
if (this.sessionSocket) {
this.sessionSocket.close();
}
};
})
.catch(() => {
clearTimeout(this.sessionReconnectTimer);
this.sessionReconnectTimer = setTimeout(() => {
this.connectActiveSessionsSocket();
}, 1000);
});
},
refreshActiveSessions() {
if (this.sessionSocket && this.sessionSocket.readyState === WebSocket.OPEN) {
return;
}
fetch("./api/sessions/active")
.then((r) => r.json())
.then((r) => {
if (r.status === true && r.sessions) {
this.activeSessions = r.sessions;
} else {
this.activeSessions = [];
}
})
.catch(() => {
this.activeSessions = [];
});
},
refreshLogs() {
fetch("./api/logs",)
.then((r) => r.text())
.then((r) => {
this.logs = r;
});
},
closeApp() {
this.closeAppPressed = true;
fetch("./api/apps/close", {
method: "POST",
headers: {
"Content-Type": "application/json"
} })
.then((r) => r.json())
.then((r) => {
this.closeAppPressed = false;
this.closeAppStatus = r.status;
setTimeout(() => {
this.closeAppStatus = null;
}, 5000);
});
},
unpairAll() {
this.unpairAllPressed = true;
fetch("./api/clients/unpair-all", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
.then((r) => r.json())
.then((r) => {
this.unpairAllPressed = false;
this.unpairAllStatus = r.status;
setTimeout(() => {
this.unpairAllStatus = null;
}, 5000);
this.refreshClients();
});
},
unpairSingle(uuid) {
fetch("./api/clients/unpair", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ uuid })
}).then(() => {
this.showApplyMessage = true;
this.refreshClients();
});
},
refreshClients() {
fetch("./api/clients/list")
.then((response) => response.json())
.then((response) => {
const clientList = document.querySelector("#client-list");
if (response.status === true && response.named_certs && response.named_certs.length) {
this.clients = response.named_certs.sort((a, b) => {
return (a.name.toLowerCase() > b.name.toLowerCase() || a.name === "" ? 1 : -1)
});
} else {
this.clients = [];
}
});
},
clickedApplyBanner() {
this.showApplyMessage = false;
},
copyLogs() {
// Copy the filtered view if a filter is active.
navigator.clipboard.writeText(this.actualLogs);
},
restart() {
this.restartPressed = true;
setTimeout(() => {
this.restartPressed = false;
}, 5000);
fetch("./api/restart", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
},
ddResetPersistence() {
this.ddResetPressed = true;
fetch("/api/reset-display-device-persistence", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
.then((r) => r.json())
.then((r) => {
this.ddResetPressed = false;
this.ddResetStatus = r.status;
setTimeout(() => {
this.ddResetStatus = null;
}, 5000);
});
},
refreshViGEmBusStatus() {
fetch("/api/vigembus/status")
.then((r) => r.json())
.then((r) => {
this.vigembus = {
installed: r.installed || false,
version: r.version || '',
version_compatible: r.version_compatible || false,
packaged_version: r.packaged_version,
};
})
.catch((err) => {
console.error("Failed to fetch ViGEmBus status:", err);
});
},
installViGEmBus() {
this.vigemBusInstallPressed = true;
this.vigemBusInstallStatus = null;
this.vigemBusInstallError = null;
fetch("/api/vigembus/install", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
})
.then((r) => r.json())
.then((r) => {
this.vigemBusInstallPressed = false;
this.vigemBusInstallStatus = r.status;
if (!r.status && r.error) {
this.vigemBusInstallError = r.error;
}
setTimeout(() => {
this.vigemBusInstallStatus = null;
this.vigemBusInstallError = null;
}, 10000);
// Refresh status after installation attempt
this.refreshViGEmBusStatus();
})
.catch((err) => {
this.vigemBusInstallPressed = false;
this.vigemBusInstallStatus = false;
this.vigemBusInstallError = err.message;
setTimeout(() => {
this.vigemBusInstallStatus = null;
this.vigemBusInstallError = null;
}, 10000);
});
},
navigateToLog(direction) {
const indices = this.errorWarningEntries;
if (indices.length === 0) return;
let targetIndex;
if (direction === 'next') {
if (this.currentLogIndex === -1) {
targetIndex = indices[0];
} else {
const nextIndices = indices.filter((i) => i > this.currentLogIndex);
if (nextIndices.length === 0) return;
targetIndex = nextIndices[0];
}
} else if (direction === 'prev') {
if (this.currentLogIndex === -1) return;
const prevIndices = indices.filter((i) => i < this.currentLogIndex);
if (prevIndices.length === 0) return;
targetIndex = prevIndices[prevIndices.length - 1];
} else {
return;
}
this.currentLogIndex = targetIndex;
this.$nextTick(() => {
const container = this.$refs.logsContainer;
if (!container) return;
const el = container.querySelector(`[data-entry-index="${targetIndex}"]`);
if (!el) return;
// Ensure it's visible even for tall multi-line entries.
const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const relativeTop = elRect.top - containerRect.top;
container.scrollTop = container.scrollTop + relativeTop - containerRect.height * 0.15;
});
},
scrollLogsTo(where) {
const container = this.$refs.logsContainer;
if (!container) return;
// Reset the selected error/warning index when jumping to top or bottom
this.currentLogIndex = -1;
if (where === 'top') {
container.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
if (where === 'bottom') {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
},
},
});
initApp(app);
</script>
<style>
.activity-dot {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
flex-shrink: 0;
vertical-align: middle;
margin-right: 3px;
}
.dot-green {
background-color: #22c55e;
box-shadow: 0 0 6px #22c55e;
border: 1px solid #16a34a;
}
.dot-gray {
background-color: #6b7280;
border: 1px solid #d1d5db;
}
</style>
</body>