694 lines
26 KiB
HTML
694 lines
26 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 every 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,
|
|
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
|
|
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();
|
|
}, 1000);
|
|
this.refreshLogs();
|
|
this.refreshClients();
|
|
this.refreshActiveSessions();
|
|
},
|
|
beforeDestroy() {
|
|
clearInterval(this.logInterval);
|
|
clearInterval(this.sessionInterval);
|
|
},
|
|
methods: {
|
|
refreshActiveSessions() {
|
|
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: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
transition: background-color 0.2s;
|
|
}
|
|
.dot-green {
|
|
background-color: #22c55e;
|
|
box-shadow: 0 0 4px #22c55e;
|
|
}
|
|
.dot-gray {
|
|
background-color: #6b7280;
|
|
}
|
|
</style>
|
|
|
|
</body>
|