continuous-integration/drone/push Build is failing
- html/css split into tokens.css (custom properties), base.css (reset, typography, focus, skip link), components.css (buttons, inputs, tables, status cards, popups, menu, log lines) and layout.css (shell, sidebar, header, breakpoints); screen.css removed. Selectors and declarations moved as-is; three pairs of conflicting rules resolved in favour of the one that already won the cascade, two empty rules dropped. - Every literal colour outside tokens.css is now a token (category badge borders excepted). color-scheme set so native controls follow. - Light theme under prefers-color-scheme: light, with explicit data-theme="light"/"dark" overrides on :root for a future toggle. Body text 13.6:1, muted text 5.4:1, accent 5.1:1 on the light background. - HTML: lang="en" everywhere, one h1 per page, maintenance page uses <main>, an id typo fixed.
306 lines
7.6 KiB
TypeScript
306 lines
7.6 KiB
TypeScript
// Websocket transport between the web UI and xTeVe.
|
|
//
|
|
// One socket stays open for the life of the page. Requests are queued and
|
|
// sent one at a time; each carries an id that the server echoes back, and
|
|
// the response is matched on it (or, for older servers that close after a
|
|
// single reply, to the oldest in-flight request). Connection loss triggers
|
|
// a reconnect with exponential backoff, and queued requests survive it.
|
|
|
|
interface PendingRequest {
|
|
id: string
|
|
payload: Object
|
|
resolve: (response: any) => void
|
|
reject: (err: Error) => void
|
|
timer: number
|
|
sent: boolean
|
|
}
|
|
|
|
class WSClient {
|
|
private socket: WebSocket = null
|
|
private queue: PendingRequest[] = []
|
|
private inFlight: PendingRequest = null
|
|
private nextID: number = 1
|
|
private reconnectDelay: number = 500
|
|
private reconnectTimer: number = 0
|
|
private closedByUs: boolean = false
|
|
|
|
readonly requestTimeoutMs: number = 12000
|
|
|
|
private url(): string {
|
|
var protocol = window.location.protocol == "https:" ? "wss://" : "ws://"
|
|
var host = window.location.host
|
|
if (host == undefined || host.length < 1) {
|
|
host = window.location.hostname
|
|
}
|
|
return protocol + host + "/data/"
|
|
}
|
|
|
|
// send queues a command and resolves with the parsed response.
|
|
send(payload: Object): Promise<any> {
|
|
var client = this
|
|
return new Promise(function(resolve, reject) {
|
|
var req: PendingRequest = {
|
|
id: String(client.nextID++),
|
|
payload: payload,
|
|
resolve: resolve,
|
|
reject: reject,
|
|
timer: 0,
|
|
sent: false
|
|
}
|
|
client.queue.push(req)
|
|
client.pump()
|
|
})
|
|
}
|
|
|
|
// hasQueued reports whether a command of this kind is already waiting.
|
|
hasQueued(cmd: string): boolean {
|
|
if (this.inFlight != null && this.inFlight.payload["cmd"] == cmd) {
|
|
return true
|
|
}
|
|
for (var i = 0; i < this.queue.length; i++) {
|
|
if (this.queue[i].payload["cmd"] == cmd) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private connect(): void {
|
|
if (this.socket != null && (this.socket.readyState == WebSocket.OPEN || this.socket.readyState == WebSocket.CONNECTING)) {
|
|
return
|
|
}
|
|
|
|
var client = this
|
|
var socket = new WebSocket(this.url())
|
|
this.socket = socket
|
|
|
|
socket.onopen = function() {
|
|
WS_AVAILABLE = true
|
|
client.reconnectDelay = 500
|
|
client.pump()
|
|
}
|
|
|
|
socket.onmessage = function(e: MessageEvent) {
|
|
client.handleMessage(e.data)
|
|
}
|
|
|
|
socket.onerror = function() {
|
|
// onclose follows; nothing to do here.
|
|
}
|
|
|
|
socket.onclose = function() {
|
|
if (client.socket == socket) {
|
|
client.socket = null
|
|
}
|
|
// An in-flight request that never got its answer goes back to the
|
|
// front of the queue and is retried on the next connection.
|
|
if (client.inFlight != null) {
|
|
var req = client.inFlight
|
|
client.inFlight = null
|
|
req.sent = false
|
|
client.queue.unshift(req)
|
|
}
|
|
client.scheduleReconnect()
|
|
}
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
if (this.closedByUs || this.reconnectTimer != 0) {
|
|
return
|
|
}
|
|
if (this.queue.length == 0) {
|
|
// Idle: reconnect lazily on the next request.
|
|
return
|
|
}
|
|
var client = this
|
|
this.reconnectTimer = window.setTimeout(function() {
|
|
client.reconnectTimer = 0
|
|
client.connect()
|
|
}, this.reconnectDelay)
|
|
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000)
|
|
}
|
|
|
|
// pump sends the next queued request if the socket is open and idle.
|
|
private pump(): void {
|
|
if (this.inFlight != null || this.queue.length == 0) {
|
|
return
|
|
}
|
|
if (this.socket == null || this.socket.readyState != WebSocket.OPEN) {
|
|
this.connect()
|
|
return
|
|
}
|
|
|
|
var req = this.queue.shift()
|
|
this.inFlight = req
|
|
req.sent = true
|
|
req.payload["id"] = req.id
|
|
|
|
var client = this
|
|
req.timer = window.setTimeout(function() {
|
|
if (client.inFlight == req) {
|
|
client.inFlight = null
|
|
req.reject(new Error("timeout"))
|
|
// A socket that stopped answering is not trusted any more.
|
|
client.resetSocket()
|
|
client.pump()
|
|
}
|
|
}, this.requestTimeoutMs)
|
|
|
|
try {
|
|
this.socket.send(JSON.stringify(req.payload))
|
|
} catch (err) {
|
|
window.clearTimeout(req.timer)
|
|
this.inFlight = null
|
|
req.sent = false
|
|
this.queue.unshift(req)
|
|
this.resetSocket()
|
|
}
|
|
}
|
|
|
|
private resetSocket(): void {
|
|
var socket = this.socket
|
|
this.socket = null
|
|
if (socket != null) {
|
|
socket.onclose = null
|
|
try { socket.close() } catch (err) { /* ignore */ }
|
|
}
|
|
this.scheduleReconnect()
|
|
}
|
|
|
|
private handleMessage(raw: string): void {
|
|
var response: any
|
|
try {
|
|
response = JSON.parse(raw)
|
|
} catch (err) {
|
|
return
|
|
}
|
|
|
|
var req = this.inFlight
|
|
if (req == null) {
|
|
return
|
|
}
|
|
// Servers that echo the id let us drop stray late answers.
|
|
if (response.hasOwnProperty("id") && response["id"] != req.id) {
|
|
return
|
|
}
|
|
|
|
window.clearTimeout(req.timer)
|
|
this.inFlight = null
|
|
req.resolve(response)
|
|
this.pump()
|
|
}
|
|
}
|
|
|
|
var WS = new WSClient()
|
|
|
|
class Server {
|
|
cmd: string
|
|
|
|
constructor(cmd: string) {
|
|
this.cmd = cmd
|
|
}
|
|
|
|
request(data: Object): any {
|
|
|
|
var isLogUpdate: boolean = this.cmd == "updateLog"
|
|
|
|
// Log polling must never pile up behind a stalled connection.
|
|
if (isLogUpdate && WS.hasQueued("updateLog")) {
|
|
return
|
|
}
|
|
|
|
if (!isLogUpdate) {
|
|
showElement("loading", true)
|
|
UNDO = new Object()
|
|
setConnectionState("busy")
|
|
}
|
|
|
|
data["cmd"] = this.cmd
|
|
var requestCmd: string = this.cmd
|
|
|
|
WS.send(data).then(function(response) {
|
|
|
|
WS_FAILURE_COUNT = 0
|
|
if (!isLogUpdate) {
|
|
showElement("loading", false)
|
|
}
|
|
setConnectionState("online")
|
|
|
|
if (response["status"] == false) {
|
|
setConnectionState("offline")
|
|
alert(response["err"])
|
|
if (response.hasOwnProperty("reload")) {
|
|
location.reload()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (response.hasOwnProperty("logoURL")) {
|
|
var div = (document.getElementById("channel-icon") as HTMLInputElement)
|
|
div.value = response["logoURL"]
|
|
div.className = "changed"
|
|
return
|
|
}
|
|
|
|
switch (requestCmd) {
|
|
case "updateLog":
|
|
SERVER["log"] = response["log"]
|
|
if (document.getElementById("content_log")) {
|
|
showLogs(false)
|
|
}
|
|
return
|
|
|
|
default:
|
|
SERVER = response
|
|
break
|
|
}
|
|
|
|
if (response.hasOwnProperty("openMenu")) {
|
|
var menu = document.getElementById(response["openMenu"])
|
|
menu.click()
|
|
showElement("popup", false)
|
|
}
|
|
|
|
if (response.hasOwnProperty("openLink")) {
|
|
window.location = response["openLink"]
|
|
}
|
|
|
|
if (response.hasOwnProperty("alert")) {
|
|
alert(response["alert"])
|
|
}
|
|
|
|
if (response.hasOwnProperty("reload")) {
|
|
location.reload()
|
|
}
|
|
|
|
if (response.hasOwnProperty("wizard")) {
|
|
createLayout()
|
|
configurationWizard[response["wizard"]].createWizard()
|
|
return
|
|
}
|
|
|
|
createLayout()
|
|
|
|
}, function(err) {
|
|
|
|
WS_FAILURE_COUNT++
|
|
if (!isLogUpdate) {
|
|
showElement("loading", false)
|
|
}
|
|
|
|
var state: string = "offline"
|
|
if (isLogUpdate && WS_FAILURE_COUNT < 2) {
|
|
state = "idle"
|
|
}
|
|
setConnectionState(state)
|
|
|
|
if (WS_AVAILABLE == false && !isLogUpdate && requestCmd != "getServerConfig") {
|
|
alert("No websocket connection to xTeVe could be established. Check your network configuration.")
|
|
}
|
|
|
|
})
|
|
}
|
|
}
|
|
|
|
var WS_FAILURE_COUNT: number = 0
|