diff --git a/client/package.json b/client/package.json index f13a5aa3..b9603041 100644 --- a/client/package.json +++ b/client/package.json @@ -22,7 +22,7 @@ "package": "NODE_ENV=production npm-run-all clean build package:forge", "make:forge": "electron-forge make", "package:forge": "electron-forge package", - "format": "prettier --plugin prettier-plugin-organize-imports --plugin prettier-plugin-svelte --plugin prettier-plugin-tailwindcss --write . ../server/constants.json" + "format": "prettier --config .prettierrc --plugin prettier-plugin-organize-imports --plugin prettier-plugin-svelte --plugin prettier-plugin-tailwindcss --write . ../server/constants.json ../server/tomato/static/admin/tomato/configure_live_clients/configure_live_clients.js ../server/tomato/templates/admin/extra/configure_live_clients_iframe.html" }, "repository": { "type": "git", diff --git a/client/src/main/Player.svelte b/client/src/main/Player.svelte index 81649287..a3f370f1 100644 --- a/client/src/main/Player.svelte +++ b/client/src/main/Player.svelte @@ -9,7 +9,7 @@ import SinglePlayRotators from "./player/SinglePlayRotators.svelte" import { IS_DEV } from "../utils" - import { reloadPlaylistCallback } from "../stores/connection" + import { registerMessageHandler, messageServer, conn } from "../stores/connection" import { config, userConfig } from "../stores/config" import { singlePlayRotators, stop as stopSinglePlayRotator } from "../stores/single-play-rotators" import { db } from "../stores/db" @@ -140,7 +140,91 @@ } } - $reloadPlaylistCallback = reloadPlaylist + let subscriptionConnectionId = null + let subscriptionLastItems = null + let subscriptionInterval + + registerMessageHandler("reload-playlist", ({ notify, connection_id }) => { + console.warn("Received playlist reload from server") + if (notify) { + alert("An administrator forced a playlist refresh!", "info", 4000) + } + reloadPlaylist() + if (connection_id) { + messageServer("ack-action", { connection_id, msg: "Successfully reloaded playlist!" }) + } + }) + + const unsubscribe = () => { + if (subscriptionConnectionId) { + if ($conn.connected) { + messageServer("unsubscribe") + } + console.log(`Admin ${subscriptionConnectionId} unsubscribed`) + clearInterval(subscriptionInterval) + subscriptionConnectionId = subscriptionLastItems = null + } + } + + $: if (subscriptionConnectionId && !$conn.connected) { + console.log("Disconnected while admin was subscribed. Unsubscribing.") + unsubscribe() + } + + const sendClientDataToSubscriber = () => { + if (subscriptionConnectionId) { + const serialized = items.map((item) => item.serializeForSubscriber()) + messageServer("client-data", { connection_id: subscriptionConnectionId, items: serialized }) + } + } + + registerMessageHandler("subscribe", ({ connection_id }) => { + unsubscribe() // Unsubscribe any existing connections + console.log(`Admin ${connection_id} subscribed`) + subscriptionConnectionId = connection_id + sendClientDataToSubscriber() + subscriptionInterval = setInterval(sendClientDataToSubscriber, 15000) // Update 3 times per sec + }) + + registerMessageHandler("unsubscribe", unsubscribe) + + registerMessageHandler("swap", ({ action, asset_id, rotator_id, generated_id, subindex, connection_id }) => { + const stopset = items.find((item) => item.type === "stopset" && item.generatedId === generated_id) + if (!stopset) { + console.warn("An swap action was requested on a stopset that doesn't exist!") + messageServer("ack-action", { connection_id, msg: `An action was requested on a stopset that doesn't exist!` }) + return + } + + let success = false + if (action === "delete") { + success = stopset.deleteAsset(subindex) + } else { + const asset = $db.assets.find((asset) => asset.id === asset_id) + const rotator = $db.rotators.get(rotator_id) + + if (!asset || !rotator) { + console.warn("An swap action was requested on an asset/rotator that doesn't exist!") + messageServer("ack-action", { + connection_id, + msg: `An action was requested on a asset/rotator that doesn't exist!` + }) + return + } + + if (action === "swap") { + success = stopset.swapAsset(subindex, asset, rotator) + } else { + success = stopset.insertAsset(subindex, asset, rotator, action === "before") + } + } + + updateUI() + messageServer("ack-action", { + connection_id, + msg: `${success ? "Successfully performed" : "FAILED to perform"} action of type: ${action}!` + }) + }) const regenerateNextStopset = () => { let nextStopset @@ -212,13 +296,10 @@ const doAssetSwap = (stopset, subindex, asset, swapAsset) => { if (stopset.destroyed) { alert(`Stop set ${stopset.name} no longer active in the playlist. Can't perform swap!`, "warning") - } else if (stopset.startedPlaying && stopset.current >= subindex) { - alert( - `Asset in stop set ${stopset.name}'s index ${subindex + 1} has already been played. Can't perform swap!`, - "warning" - ) } else { - stopset.swapAsset(subindex, asset, swapAsset.rotator) + if (!stopset.swapAsset(subindex, asset, swapAsset.rotator)) { + alert(`Asset in stop set ${stopset.name}'s index ${subindex + 1} can no longer be swapped.`, "warning") + } updateUI() } swap = null diff --git a/client/src/main/player/Buttons.svelte b/client/src/main/player/Buttons.svelte index ec704673..887e79fa 100644 --- a/client/src/main/player/Buttons.svelte +++ b/client/src/main/player/Buttons.svelte @@ -8,6 +8,7 @@ import { userConfig } from "../../stores/config" import { blockSpacebarPlay } from "../../stores/player" + import { registerMessageHandler, messageServer } from "../../stores/connection" import { midiSetLED, midiButtonPresses, @@ -36,7 +37,7 @@ $: playDisabled = !items.some((item) => item.type === "stopset") || (firstItem.type === "stopset" && firstItem.playing) $: pauseDisabled = firstItem.type !== "stopset" || !firstItem.playing - $: isPaused = firstItem.type === "stopset" && !firstItem.playing + $: isPaused = firstItem.type === "stopset" && firstItem.startedPlaying && !firstItem.playing $: skipCurrentEnabled = firstItem.type === "stopset" && firstItem.startedPlaying let ledState @@ -63,6 +64,17 @@ play() } }) + + registerMessageHandler("play", ({ connection_id }) => { + if (playDisabled) { + console.log("Got play message, but currently not eligible to play!") + messageServer("ack-action", { connection_id, msg: "Got play command, but currently not eligble to play!" }) + } else { + console.log("Got play command from server") + messageServer("ack-action", { connection_id, msg: "Successfully started playing!" }) + play() + } + }) diff --git a/client/src/stores/connection.js b/client/src/stores/connection.js index 6f2b32ae..e91bc7bf 100644 --- a/client/src/stores/connection.js +++ b/client/src/stores/connection.js @@ -1,7 +1,6 @@ import { ipcRenderer } from "electron" import ReconnectingWebSocket from "reconnecting-websocket" import { persisted } from "svelte-local-storage-store" -import { noop } from "svelte/internal" import { derived, get, writable } from "svelte/store" import { protocol_version } from "../../../server/constants.json" import { alert } from "./alerts" @@ -86,30 +85,30 @@ export const logout = (error) => { } } -export const reloadPlaylistCallback = writable(noop) - // Functions defined for various message types we get from server after authentication const handleMessages = { - data: async (data) => { - const { config, ...jsonData } = data + data: async ({ config, ...data }) => { setServerConfig(config) - await syncAssetsDB(jsonData) + await syncAssetsDB(data) updateConn({ didFirstSync: true }) }, - "ack-log": (data) => { - const { success, id } = data + "ack-log": ({ success, id }) => { if (success) { console.log(`Acknowledged log ${id}`) acknowledgeLog(id) } }, - "reload-playlist": (data) => { - const { notify } = data - if (notify) { - alert("An administrator forced a playlist refresh!", "info", 4000) - } - get(reloadPlaylistCallback)() + notify: ({ msg, level, timeout, connection_id }) => { + alert(msg, level, timeout) + messageServer("ack-action", { connection_id, msg: "Successfully notified user!" }) + } +} + +export const registerMessageHandler = (name, handler) => { + if (Object.hasOwn(handleMessages, name)) { + console.warn(`Message handler ${name} already registered`) } + handleMessages[name] = handler } export const messageServer = (type, data) => { @@ -123,7 +122,7 @@ export const messageServer = (type, data) => { return false } } else { - console.error("Tried to send a message when websocket wasn't created") + console.error(`Tried to send a ${type} message when websocket wasn't created`) return false } } diff --git a/client/src/stores/player.js b/client/src/stores/player.js index ec805c37..1541fb24 100644 --- a/client/src/stores/player.js +++ b/client/src/stores/player.js @@ -54,6 +54,19 @@ class GeneratedStopsetAssetBase { this.isSwapped = isSwapped } + serializeForSubscriber() { + return { + playable: this.playable, + beforeActive: this.beforeActive, + afterActive: this.afterActive, + active: this.active, + rotator: { + id: this.rotator.id, + name: this.rotator.name + } + } + } + updateCallback() { this.generatedStopset.updateCallback() } @@ -134,6 +147,17 @@ class PlayableAsset extends GeneratedStopsetAssetBase { this.queueForSkip = false } + serializeForSubscriber() { + return { + name: this.name, + id: this.id, + duration: this.duration, + elapsed: this.elapsed, + remaining: this.remaining, + ...super.serializeForSubscriber() + } + } + static getAudioObject() { let audio = PlayableAsset._reusableAudioObjects.find((item) => !item.__tomato_used) @@ -204,7 +228,7 @@ class PlayableAsset extends GeneratedStopsetAssetBase { } get remaining() { - return this.duration - this.elapsed + return Math.max(this.duration - this.elapsed, 0) } get percentDone() { @@ -306,6 +330,21 @@ export class GeneratedStopset { this.destroyed = false } + serializeForSubscriber() { + return { + name: this.name, + id: this.generatedId, + type: this.type, + startedPlaying: this.startedPlaying, + current: this.current, + duration: this.duration, + elapsed: this.elapsed, + remaining: this.remaining, + playing: this.playing, + items: this.items.map((item) => item.serializeForSubscriber()) + } + } + get duration() { return this.playableItems.reduce((s, item) => s + item.duration, 0) } @@ -359,12 +398,57 @@ export class GeneratedStopset { } swapAsset(index, asset, rotator) { + if (!this._validateIndexForAssetAction(index, "swap")) { + return false + } const oldItem = this.items[index] const newItem = new PlayableAsset(asset, rotator, this, index, false, true) oldItem.unloadAudio() // Don't forget to unload its audio before we nuke it - newItem.loadAudio() // If stopset was already loaded, load audio for swapped in item - this.items[index] = newItem + if (this.loaded) { + newItem.loadAudio() // If stopset was already loaded, load audio for swapped in item + } + this.items[index] = newItem // Swap it this.updateCallback() + return true + } + + insertAsset(index, asset, rotator, before) { + if (!this._validateIndexForAssetAction(index, `insert ${before ? "before" : "after"}`)) { + return false + } + const newItem = new PlayableAsset(asset, rotator, this, index, false, true) + if (this.loaded) { + newItem.loadAudio() + } + const insertIndex = index + (before ? 0 : 1) + this.items.splice(insertIndex, 0, newItem) // Splice it in + this.items.forEach((item, i) => (item.index = i)) // Fix item indexes + return true + } + + deleteAsset(index) { + if (!this._validateIndexForAssetAction(index, "delete")) { + return false + } + const oldItem = this.items[index] + oldItem.unloadAudio() // Don't forget to unload its audio before we nuke it + this.items.splice(index, 1) // Nuke it + this.items.forEach((item, i) => (item.index = i)) // Fix item indexes + return true + } + + _validateIndexForAssetAction(index, description) { + if (this.startedPlaying && index <= this.current) { + console.warn( + `Stopset action ${description} on ${this.name} index ${index} cannot occur since that item is playing/played!` + ) + return false + } else if (index >= this.items.length) { + console.warn(`Stopset action ${description} on ${this.name} index ${index} cannot occur since that index invalid`) + return false + } else { + return true + } } skip() { @@ -462,8 +546,22 @@ export class Wait { this.didLog = false } + serializeForSubscriber() { + return { + id: this.generatedId, + type: this.type, + active: this.active, + duration: this.duration, + elapsed: this.elapsed, + overdue: this.overdue, + overtime: this.overtime, + overtimeElapsed: this.overtimeElapsed, + remaining: this.remaining + } + } + get remaining() { - return this.duration - this.elapsed + return Math.max(this.duration - this.elapsed, 0) } get percentDone() { diff --git a/client/src/stores/single-play-rotators.js b/client/src/stores/single-play-rotators.js index e3bd094c..794f4dd3 100644 --- a/client/src/stores/single-play-rotators.js +++ b/client/src/stores/single-play-rotators.js @@ -98,6 +98,6 @@ export const playFromRotator = (rotator, mediumIgnoreIds = new Set()) => { if (asset) { play(asset, rotator) } else { - error(`No assets to play from ${rotator.name}!`) + error(`No assets currently airing to play from ${rotator.name}!`) } } diff --git a/controller/test/deps/alpinejs.js b/controller/test/deps/alpinejs.js index df2bc8e5..6794fdcb 120000 --- a/controller/test/deps/alpinejs.js +++ b/controller/test/deps/alpinejs.js @@ -1 +1 @@ -alpinejs-3.14.1.min.js \ No newline at end of file +../../../server/tomato/static/admin/tomato/configure_live_clients/deps/alpinejs.js \ No newline at end of file diff --git a/controller/test/deps/simple.css b/controller/test/deps/simple.css index 2793c8cb..714c423b 120000 --- a/controller/test/deps/simple.css +++ b/controller/test/deps/simple.css @@ -1 +1 @@ -simple-2.3.1.min.css \ No newline at end of file +../../../server/tomato/static/admin/tomato/configure_live_clients/deps/simple.css \ No newline at end of file diff --git a/server/api/base.py b/server/api/base.py index 7bcb74de..00f1e381 100644 --- a/server/api/base.py +++ b/server/api/base.py @@ -2,7 +2,9 @@ from importlib import import_module from inspect import iscoroutinefunction import logging -from weakref import WeakSet +import uuid + +from asgiref.sync import sync_to_async from django.conf import settings from django.contrib.auth import HASH_SESSION_KEY, SESSION_KEY @@ -25,6 +27,7 @@ class Connection: def __init__(self, websocket: WebSocket, user: User): self._ws: WebSocket = websocket + self.id = str(uuid.uuid4()) self.user: User = user @property @@ -73,8 +76,8 @@ async def process(self, message_type, message): class ConnectionsBase(MessagesBase): def __init__(self): - self.connections: WeakSet[Connection] = WeakSet() - self.user_ids_to_connections: defaultdict[int, WeakSet[Connection]] = defaultdict(WeakSet) + self.connections: dict[str, Connection] = {} + self.user_ids_to_connections: defaultdict[int, set[Connection]] = defaultdict(set) super().__init__() @property @@ -86,15 +89,13 @@ def num_connections(self): return len(self.connections) async def hello(self, connection: Connection): - raise NotImplementedError() + pass - async def authorize_extra(self, user: User) -> bool: - raise NotImplementedError() + async def on_connect(self, connection: Connection): + pass - def cleanup_user_ids_to_connections(self): - for user_id in list(self.user_ids_to_connections.keys()): - if len(self.user_ids_to_connections[user_id]) == 0: - del self.user_ids_to_connections[user_id] + async def on_disconnect(self, connection: Connection): + pass async def authorize(self, greeting: dict, websocket: WebSocket) -> Connection: if greeting["protocol_version"] != PROTOCOL_VERSION: @@ -103,13 +104,11 @@ async def authorize(self, greeting: dict, websocket: WebSocket) -> Connection: ) raise TomatoAuthError(f"Server running {what} protocol than you. You'll need to {action} Tomato.") - if greeting["method"] == "secret-key": - lookup = {"id": greeting["user_id"]} - elif greeting["method"] == "session": + if is_session := greeting["method"] == "session": if "sessionid" not in websocket.cookies: raise TomatoAuthError("No sessionid cookie, can't complete session auth!") store = SessionStore(session_key=websocket.cookies["sessionid"]) - lookup = {"id": store.get(SESSION_KEY)} + lookup = {"id": await sync_to_async(store.get)(SESSION_KEY)} else: lookup = {"username": greeting["username"]} @@ -118,16 +117,16 @@ async def authorize(self, greeting: dict, websocket: WebSocket) -> Connection: except User.DoesNotExist: pass else: - if user.is_active and (not self.is_admin or user.is_superuser or greeting["method"] == "secret-key"): - if greeting["method"] == "session": + if user.is_active and ( + not self.is_admin or await sync_to_async(user.has_perm)("tomato.configure_live_clients") + ): + if is_session: session_hash = store.get(HASH_SESSION_KEY) - if session_hash and constant_time_compare(session_hash, user.get_session_auth_hash()): + if session_hash and await sync_to_async(constant_time_compare)( + session_hash, await sync_to_async(user.get_session_auth_hash)() + ): logger.info(f"Authorized admin session for {user}") return Connection(websocket, user) - elif greeting["method"] == "secret-key": - if constant_time_compare(greeting["key"], settings.SECRET_KEY): - logger.info(f"Authorized admin via secret key for {user}") - return Connection(websocket, user) elif await user.acheck_password(greeting["password"]): logger.info(f"Authorized user connection for {user}") return Connection(websocket, user) @@ -150,23 +149,28 @@ async def refresh_user(self, user: User): async def broadcast(self, message_type, message=None): raw_message = django_json_dumps({"type": message_type, "data": message}) # No sense serializing more than once - count = 0 - for count, connection in enumerate(self.connections, 1): + for connection in self.connections.values(): try: await connection.send_raw(raw_message) except Exception: - logger.exception("Error sending to websocket") - logger.info(f"Broadcasted {message_type} message to {count} {'admins' if self.is_admin else 'users'}") + logger.exception("Recoverable error while sending to websocket") - async def message(self, user_id: int, message_type, message=None): + async def message_user(self, user_id: int, message_type, message=None): raw_message = django_json_dumps({"type": message_type, "data": message}) # No sense serializing more than once if user_id in self.user_ids_to_connections: for connection in self.user_ids_to_connections[user_id]: try: await connection.send_raw(raw_message) except Exception: - logger.exception("Error sending to websocket") - logger.info(f"Sent {message_type} message to {user_id=}") + logger.exception("Recoverable error while sending to websocket") + + async def message(self, connection_id: int, message_type, message=None): + raw_message = django_json_dumps({"type": message_type, "data": message}) # No sense serializing more than once + try: + connection = self.connections[connection_id] + await connection.send_raw(raw_message) + except Exception: + logger.exception("Recoverable error while sending to websocket") async def authorize_and_process_new_websocket(self, greeting: dict, websocket: WebSocket): connection: Connection = await self.authorize(greeting, websocket) @@ -174,14 +178,23 @@ async def authorize_and_process_new_websocket(self, greeting: dict, websocket: W {"success": True, "admin_mode": self.is_admin, "user": connection.user.username, **SERVER_STATUS} ) await self.hello(connection) - self.connections.add(connection) + self.connections[connection.id] = connection self.user_ids_to_connections[connection.user.id].add(connection) - - self.cleanup_user_ids_to_connections() - await self.run_for_connection(connection) + await self.on_connect(connection) + try: + await self.run_for_connection(connection) + finally: + del self.connections[connection.id] + user_id = connection.user.id + self.user_ids_to_connections[user_id].remove(connection) + if len(self.user_ids_to_connections[user_id]) == 0: + del self.user_ids_to_connections[user_id] + await self.on_disconnect(connection) async def process(self, connection, message_type, message): - assert message_type in self.Types, f"Invalid message type: {message_type}" + assert ( + message_type in self.Types + ), f"Invalid message type: process_{message_type}() needed on {type(self).__name__}" return await self.process_methods[message_type](connection, message) async def run_for_connection(self, connection: Connection): diff --git a/server/api/connections.py b/server/api/connections.py index 7cf02b92..decc1bf5 100644 --- a/server/api/connections.py +++ b/server/api/connections.py @@ -13,19 +13,63 @@ class AdminConnections(ConnectionsBase): Types = AdminMessageTypes + OutgoingTypes = OutgoingAdminMessageTypes is_admin = True async def hello(self, connection: Connection): - await connection.message(OutgoingAdminMessageTypes.HELLO, {"num_connected_users": users.num_connections}) + await self.update_user_connections(connection) + + async def on_disconnect(self, connection: Connection): + await users.broadcast(OutgoingAdminMessageTypes.UNSUBSCRIBE, {"connection_id": connection.id}) + + async def update_user_connections(self, connection: Connection | None = None): + msg = [ + {"username": conn.user.username, "user_id": conn.user.id, "connection_id": id, "addr": conn.addr} + for id, conn in users.connections.items() + ] + if connection is None: + await self.broadcast(self.OutgoingTypes.USER_CONNECTIONS, msg) + else: + await connection.message(self.OutgoingTypes.USER_CONNECTIONS, msg) async def process_reload_playlist(self, connection: Connection, data): - logger.info("Reloading all playlist via admin request") - await users.broadcast(OutgoingUserMessageTypes.RELOAD_PLAYLIST, {"notify": True}) - await connection.message(OutgoingAdminMessageTypes.RELOAD_PLAYLIST, {"success": True}) + if data and (connection_id := data.get("connection_id")): + logger.info(f"Reloading {connection_id=} playlists via admin request") + await users.message( + connection_id, + OutgoingUserMessageTypes.RELOAD_PLAYLIST, + {"notify": True, "connection_id": connection.id}, + ) + else: + logger.info("Reloading all playlists via admin request") + await users.broadcast( + OutgoingUserMessageTypes.RELOAD_PLAYLIST, {"notify": True, "connection_id": connection.id} + ) + + async def process_notify(self, connection: Connection, data): + connection_id = data.pop("connection_id") + await users.message(connection_id, OutgoingUserMessageTypes.NOTIFY, {"connection_id": connection.id, **data}) + + async def process_subscribe(self, connection: Connection, data): + # Tell specific user to subscribe to this connection + await users.message(data["connection_id"], OutgoingUserMessageTypes.SUBSCRIBE, {"connection_id": connection.id}) + + async def process_unsubscribe(self, connection: Connection, data): + # Tell specific user to unsubscribe to all connections + await users.message(data["connection_id"], OutgoingUserMessageTypes.UNSUBSCRIBE) + + async def process_swap(self, connection: Connection, data): + await users.message( + data.pop("connection_id"), OutgoingUserMessageTypes.SWAP, {"connection_id": connection.id, **data} + ) + + async def process_play(self, connection: Connection, data): + await users.message(data.pop("connection_id"), OutgoingUserMessageTypes.PLAY, {"connection_id": connection.id}) class UserConnections(ConnectionsBase): Types = UserMessageTypes + OutgoingTypes = OutgoingUserMessageTypes is_admin = False def __init__(self): @@ -33,7 +77,13 @@ def __init__(self): super().__init__() async def hello(self, connection: Connection): - await connection.message(OutgoingUserMessageTypes.DATA, self.last_serialized_data) + await connection.message(self.OutgoingTypes.DATA, self.last_serialized_data) + + async def on_connect(self, connection: Connection): + await admins.update_user_connections() + + async def on_disconnect(self, connection: Connection): + await admins.update_user_connections() async def init_last_serialized_data(self): logger.info("Initializing serialized data for clients") @@ -41,10 +91,11 @@ async def init_last_serialized_data(self): async def broadcast_data_change(self, force=False): serialized_data = await serialize_for_api() - if force or serialized_data != self.last_serialized_data: - await self.broadcast(OutgoingUserMessageTypes.DATA, serialized_data) - if await get_config_async("RELOAD_PLAYLIST_AFTER_DATA_CHANGES"): - await self.broadcast(OutgoingUserMessageTypes.RELOAD_PLAYLIST, {"notify": False}) + has_changed = serialized_data != self.last_serialized_data + if force or has_changed: + await self.broadcast(self.OutgoingTypes.DATA, serialized_data) + if has_changed and await get_config_async("RELOAD_PLAYLIST_AFTER_DATA_CHANGES"): + await self.broadcast(self.OutgoingTypes.RELOAD_PLAYLIST, {"notify": False}) self.last_serialized_data = serialized_data else: logger.debug("No change to DB data. Not broadcasting.") @@ -65,7 +116,22 @@ async def process_log(self, connection: Connection, data): else: logger.info(f"Ignored {data['type']} log {uuid} for {connection.user}") response = {"success": True, "id": uuid, "updated_existing": False, "ignored": True} - return (OutgoingUserMessageTypes.ACKNOWLEDGE_LOG, response) + return (self.OutgoingTypes.ACKNOWLEDGE_LOG, response) + + async def process_unsubscribe(self, connection: Connection, data): + # Tell all admins user has unsubscribed + await admins.broadcast(OutgoingAdminMessageTypes.UNSUBSCRIBE, {"connection_id": connection.id}) + + async def process_client_data(self, connection: Connection, data): + # Receive client data (which means we're subscribed) + await admins.message( + data.pop("connection_id"), OutgoingAdminMessageTypes.CLIENT_DATA, {"connection_id": connection.id, **data} + ) + + async def process_ack_action(self, connection: Connection, data): + await admins.message( + data.pop("connection_id"), OutgoingAdminMessageTypes.ACK_ACTION, {"connection_id": connection.id, **data} + ) admins: AdminConnections = AdminConnections() diff --git a/server/api/schemas.py b/server/api/schemas.py index aeae5552..009741eb 100644 --- a/server/api/schemas.py +++ b/server/api/schemas.py @@ -5,28 +5,44 @@ class ServerMessageTypes(enum.StrEnum): DB_CHANGE = "db-change" - DB_CHANGES = "db-changes" # de-duped DB_CHANGE_FORCE_UPDATE = "db-changes-force" # forced - RELOAD_PLAYLIST = "reload-playlist" + DB_CHANGES = "db-changes" # de-duped class UserMessageTypes(enum.StrEnum): + CLIENT_DATA = "client-data" SEND_LOG = "log" + UNSUBSCRIBE = "unsubscribe" + ACK_ACTION = "ack-action" class OutgoingUserMessageTypes(enum.StrEnum): - DATA = "data" ACKNOWLEDGE_LOG = "ack-log" + DATA = "data" + NOTIFY = "notify" RELOAD_PLAYLIST = "reload-playlist" + SUBSCRIBE = "subscribe" + SWAP = "swap" + UNSUBSCRIBE = "unsubscribe" + PLAY = "play" class AdminMessageTypes(enum.StrEnum): + NOTIFY = "notify" RELOAD_PLAYLIST = "reload-playlist" + SUBSCRIBE = "subscribe" + SWAP = "swap" + UNSUBSCRIBE = "unsubscribe" + PLAY = "play" class OutgoingAdminMessageTypes(enum.StrEnum): + ACK_ACTION = "ack-action" + CLIENT_DATA = "client-data" RELOAD_PLAYLIST = "reload-playlist" - HELLO = "hello" + SUBSCRIBE = "subscribe" + UNSUBSCRIBE = "unsubscribe" + USER_CONNECTIONS = "user-connections" greeting_schema = Schema( @@ -45,13 +61,5 @@ class OutgoingAdminMessageTypes(enum.StrEnum): Optional("admin_mode", default=False): Use(bool), "method": "session", }, - { - "key": str, - "user_id": int, - "tomato": "radio-automation", - "protocol_version": Use(int), - "admin_mode": True, - "method": "secret-key", - }, ) ) diff --git a/server/api/server_messages.py b/server/api/server_messages.py index ceaf998c..d12b4481 100644 --- a/server/api/server_messages.py +++ b/server/api/server_messages.py @@ -12,7 +12,7 @@ from .base import MessagesBase from .connections import admins, users -from .schemas import OutgoingUserMessageTypes, ServerMessageTypes +from .schemas import ServerMessageTypes from .utils import task @@ -74,13 +74,6 @@ async def process_db_changes_force(self, message): logger.info("Forcing broadcast of data message") await self.process_db_changes(message, force_broadcast=True) - async def process_reload_playlist(self, message, notify=False): - user_id = message.get("user_id") if message else None - if user_id is None: - await users.broadcast(OutgoingUserMessageTypes.RELOAD_PLAYLIST, {"notify": notify}) - else: - await users.message(user_id, OutgoingUserMessageTypes.RELOAD_PLAYLIST, {"notify": notify}) - @task async def consume_db_notifications_debouncer(self): messages = [] diff --git a/server/tomato/admin/__init__.py b/server/tomato/admin/__init__.py index 78569281..9cc96d59 100644 --- a/server/tomato/admin/__init__.py +++ b/server/tomato/admin/__init__.py @@ -90,7 +90,7 @@ def each_context(self, request): "app_list_extra": [ {"url": f"admin:extra_{view.name}", "title": view.title} for view in extra_views - if view.check_perms(request) + if view.check_perms(request) and not view.hide_from_app_list ], "app_list_extra_highlight": request.resolver_match.view_name in [ f"admin:extra_{view.name}" for view in extra_views diff --git a/server/tomato/admin/views/__init__.py b/server/tomato/admin/views/__init__.py index 9791339c..e9ac6109 100644 --- a/server/tomato/admin/views/__init__.py +++ b/server/tomato/admin/views/__init__.py @@ -1,8 +1,8 @@ from .asset_data import AdminAssetDataView -from .configure_live_clients import AdminConfigureLiveClientsView +from .configure_live_clients import AdminConfigureLiveClientsIFrameView, AdminConfigureLiveClientsView -extra_views = (AdminAssetDataView, AdminConfigureLiveClientsView) +extra_views = (AdminAssetDataView, AdminConfigureLiveClientsView, AdminConfigureLiveClientsIFrameView) __all__ = (extra_views,) diff --git a/server/tomato/admin/views/asset_data.py b/server/tomato/admin/views/asset_data.py index 018bc3f6..62c92d9e 100644 --- a/server/tomato/admin/views/asset_data.py +++ b/server/tomato/admin/views/asset_data.py @@ -28,13 +28,7 @@ class ImportUploadForm(FileFormMixin, forms.Form): class AdminAssetDataView(AdminViewMixin, TemplateView): name = "asset_data" - perms = ( - "tomato.add_asset", - "tomato.add_assetalternate", - "tomato.add_rotator", - "tomato.add_stopset", - "tomato.add_stopsetrotator", - ) + perms = ("tomato.export_import",) title = "Manage asset data" def get_context_data(self, **kwargs): diff --git a/server/tomato/admin/views/base.py b/server/tomato/admin/views/base.py index 24367e3a..777013fa 100644 --- a/server/tomato/admin/views/base.py +++ b/server/tomato/admin/views/base.py @@ -7,6 +7,7 @@ class AdminViewMixin: path = None perms = None template_name = None + hide_from_app_list = False def __init__(self, admin_site, *args, **kwargs): self.admin_site = admin_site diff --git a/server/tomato/admin/views/configure_live_clients.py b/server/tomato/admin/views/configure_live_clients.py index 0c2e5033..7a7f7f86 100644 --- a/server/tomato/admin/views/configure_live_clients.py +++ b/server/tomato/admin/views/configure_live_clients.py @@ -1,14 +1,12 @@ -import json import logging -from websockets.sync.client import connect as websocket_connect - from django.conf import settings -from django.contrib import messages -from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.generic import TemplateView from ...constants import PROTOCOL_VERSION +from ...models import serialize_for_api_sync from .base import AdminViewMixin @@ -17,54 +15,25 @@ class AdminConfigureLiveClientsView(AdminViewMixin, TemplateView): name = "configure_live_clients" - perms = ("tomato.immediate_play_asset",) + perms = ("tomato.configure_live_clients",) title = "Configure live clients" - def do_ws_api_request(self, request, *, reload=False): - # Probably needs a refactor but okay for now - try: - with websocket_connect("ws://api:8000/api") as ws: - ws.send( - json.dumps({ - "user_id": request.user.id, - "tomato": "radio-automation", - "protocol_version": PROTOCOL_VERSION, - "admin_mode": True, - "method": "secret-key", - "key": settings.SECRET_KEY, - }) - ) - response = json.loads(ws.recv()) - if not response["success"]: - raise Exception(f"Error connecting: {response}") - - response = json.loads(ws.recv()) - if not response["type"] == "hello": - raise Exception(f"Invalid hello response type: {response}") - num_connected_users = response["data"]["num_connected_users"] - - if reload: - ws.send(json.dumps({"type": "reload-playlist"})) - response = json.loads(ws.recv()) - if not response["type"] == "reload-playlist": - raise Exception(f"Invalid reload-playlist response type: {response}") - if not response["data"]["success"]: - raise Exception(f"Failure reloading playlist: {response}") - except Exception: - logger.exception("Error while connecting to api") - self.message_user( - "An error occurred while connecting to the server. Check logs for more information.", messages.ERROR - ) - return None - else: - return num_connected_users +@method_decorator(xframe_options_sameorigin, name="dispatch") +class AdminConfigureLiveClientsIFrameView(AdminViewMixin, TemplateView): + hide_from_app_list = True + name = "configure_live_clients_iframe" + perms = ("tomato.configure_live_clients",) + title = "Configure live clients" def get_context_data(self, **kwargs): - return {"num_connected_users": self.do_ws_api_request(self.request), **super().get_context_data(**kwargs)} - - def post(self, request, *args, **kwargs): - num_connected_users = self.do_ws_api_request(request, reload=True) - if num_connected_users is not None: - self.message_user(f"Reloaded the playlist of {num_connected_users} connected desktop client(s)!") - return redirect("admin:extra_configure_live_clients") + return { + "configure_live_clients_data": { + "is_secure": self.request.is_secure(), + "debug": settings.DEBUG, + "protocol_version": PROTOCOL_VERSION, + "serialized_data": serialize_for_api_sync(skip_config=True), + "admin_username": self.request.user.username, + }, + **super().get_context_data(**kwargs), + } diff --git a/server/tomato/apps.py b/server/tomato/apps.py index e698ca97..2f40c996 100644 --- a/server/tomato/apps.py +++ b/server/tomato/apps.py @@ -42,6 +42,8 @@ def create_groups(self, using=None, *args, **kwargs): stopset_rotator = ContentType.objects.get_for_model(StopsetRotator) client_log_entry = ContentType.objects.get_for_model(ClientLogEntry) + extra_perms = Asset._meta.permissions + for name, content_types in ( (EDIT_ALL_GROUP_NAME, (asset, asset_alternate, rotator, stopset, stopset_rotator)), (EDIT_ONLY_ASSETS_GROUP_NAME, (asset,)), @@ -49,7 +51,9 @@ def create_groups(self, using=None, *args, **kwargs): ): group, _ = Group.objects.get_or_create(name=name) group.permissions.add( - *Permission.objects.filter(content_type__in=content_types).exclude(codename="immediate_play_asset") + *Permission.objects.filter(content_type__in=content_types).exclude( + codename__in=[codename for codename, _ in extra_perms] + ) ) all_groups.append(group) @@ -58,8 +62,9 @@ def create_groups(self, using=None, *args, **kwargs): group.permissions.add(Permission.objects.get(codename="change_config")) all_groups.append(group) - group, _ = Group.objects.get_or_create(name="Can manage connected desktop clients") - group.permissions.add(Permission.objects.get(codename="immediate_play_asset")) - all_groups.append(group) + for codename, perm_name in extra_perms: + group, _ = Group.objects.get_or_create(name=perm_name) + group.permissions.add(Permission.objects.get(codename=codename)) + all_groups.append(group) Group.objects.exclude(id__in=[group.id for group in all_groups]).delete() diff --git a/server/tomato/migrations/0009_extra_perms.py b/server/tomato/migrations/0009_extra_perms.py new file mode 100644 index 00000000..6777a7c3 --- /dev/null +++ b/server/tomato/migrations/0009_extra_perms.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0.7 on 2024-08-08 18:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("tomato", "0008_rotator_evenly_cycle"), + ] + + operations = [ + migrations.AlterModelOptions( + name="asset", + options={ + "ordering": ("-created_at",), + "permissions": [ + ("configure_live_clients", "Can configure live desktop clients"), + ("export_import", "Can manage (import/export/delete all) asset data"), + ], + "verbose_name": "audio asset", + }, + ), + ] diff --git a/server/tomato/models/asset.py b/server/tomato/models/asset.py index dfb6d6cc..e0d4cd46 100644 --- a/server/tomato/models/asset.py +++ b/server/tomato/models/asset.py @@ -156,7 +156,10 @@ class Meta(TomatoModelBase.Meta): db_table = "assets" verbose_name = "audio asset" ordering = ("-created_at",) - permissions = [("immediate_play_asset", "Can immediately play audio assets")] + permissions = [ + ("configure_live_clients", "Can configure live desktop clients"), + ("export_import", "Can manage (import/export/delete all) asset data"), + ] def __str__(self): return f"{self.name}{' (archived)' if self.archived else ''}" diff --git a/server/tomato/settings.py b/server/tomato/settings.py index 74cb4cec..40324ce7 100644 --- a/server/tomato/settings.py +++ b/server/tomato/settings.py @@ -57,8 +57,9 @@ ALLOWED_HOSTS = list(ALLOWED_HOSTS) if DEBUG: + DENIED_PATHS = ("/utils/configure_live_clients_iframe/",) # Show debug toolbar - DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: True} + DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda request: request.path not in DENIED_PATHS} INSTALLED_APPS = [ # Django diff --git a/server/tomato/static/admin/tomato/configure_live_clients/configure_live_clients.js b/server/tomato/static/admin/tomato/configure_live_clients/configure_live_clients.js new file mode 100644 index 00000000..9c4f1996 --- /dev/null +++ b/server/tomato/static/admin/tomato/configure_live_clients/configure_live_clients.js @@ -0,0 +1,155 @@ +dayjs.extend(dayjs_plugin_duration) + +const DATA = JSON.parse(document.getElementById("tomato-configure-live-clients-data").textContent) +const db = DATA.serialized_data +console.log(db) + +const prettyDuration = (item, max) => { + item = dayjs.duration(Math.round(item), "seconds") + max = max ? dayjs.duration(Math.round(max), "seconds") : item + if (max.hours() > 0) { + return `${item.hours()}:${item.format("mm:ss")}` + } else if (max.minutes() >= 10) { + return item.format("mm:ss") + } else { + return item.format("m:ss") + } +} + +document.addEventListener("alpine:init", () => { + let ws = null + + Alpine.data("tomato", () => ({ + connected: false, + error: null, + connections: [], + logs: [], + notifyMsg: "", + notifyLevel: "info", + subscribed: null, + asset: null, + rotator: null, + items: [], + + send(msgType, data) { + if (ws) { + console.log("Sending:", { type: msgType, data }) + ws.send(JSON.stringify({ type: msgType, data })) + } + }, + + assetNameShort() { + if (this.asset) { + return this.asset.name.length > 20 ? this.asset.name.substring(0, 19) + "\u2026" : this.asset.name + } else { + return "Select from above" + } + }, + + swapAsset(action, generated_id, subindex) { + if (this.asset && this.subscribed) { + this.send("swap", { + action, + asset_id: this.asset.id, + rotator_id: this.rotator.id, + connection_id: this.subscribed, + generated_id, + subindex + }) + } + }, + + deleteAsset(generated_id, subindex) { + console.log(generated_id, subindex) + if (this.subscribed) { + this.send("swap", { + action: "delete", + connection_id: this.subscribed, + generated_id, + subindex + }) + } + }, + + log(message, connection_id = null) { + let s = `[${dayjs().format("MMM D YYYY HH:mm:ss.SSS")}]` + if (connection_id) { + s += ` [user=${this.getConn(connection_id)?.username || "unknown"}]` + } else { + s += " [global]" + } + this.logs.unshift(`${s} ${message}`) + }, + + getConn(connection_id) { + return connection_id && this.connections.find((c) => c.connection_id === connection_id) + }, + + init() { + this.log("Attempting to connect to server websocket.") + ws = new ReconnectingWebSocket( + DATA.debug && !DATA.is_secure + ? "ws://localhost:8001/api" + : `${document.location.protocol.replace("http", "ws")}//${document.location.host}/api` + ) + ws.onopen = () => { + ws.send( + JSON.stringify({ + method: "session", + tomato: "radio-automation", + protocol_version: DATA.protocol_version, + admin_mode: true + }) + ) + } + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data) + if (this.connected) { + const { type, data } = msg + if (type === "user-connections") { + this.connections = data + // User no longer connected + if (this.subscribed && !this.getConn(this.subscribed)) { + this.log("User no longer connected! Unsubscribing.") + this.subscribed = null + this.items = [] + } + } else if (type === "ack-action") { + this.log(data.msg, data.connection_id) + } else if (type === "client-data") { + if (this.subscribed === null) { + this.log("Successfully subscribed to client!", data.connection_id) + } + this.subscribed = data.connection_id + this.items = data.items + } else if (type === "unsubscribe") { + if (this.subscribed === data.connection_id) { + this.log("Got unsubscribe from user. Disconnecting.") + this.subscribed = null + this.items = [] + } else { + console.warn(`Got unsubscribe from ${data.connection_id}, but we weren't subscribed`) + } + } else { + console.log(`Unrecognized ${type} msg`, data) + } + } else if (msg.success) { + this.log("Successfully connected!") + this.connected = true + } else { + this.log(`An error occurred: ${msg.error}`) + } + } + + ws.onclose = () => { + if (this.connected) { + this.log("Disconnected!") + this.connected = false + this.subscribed = null + this.items = [] + } + } + } + })) +}) diff --git a/server/tomato/static/admin/tomato/configure_live_clients/alpinejs-3.14.1.min.js b/server/tomato/static/admin/tomato/configure_live_clients/deps/alpinejs-3.14.1.min.js similarity index 100% rename from server/tomato/static/admin/tomato/configure_live_clients/alpinejs-3.14.1.min.js rename to server/tomato/static/admin/tomato/configure_live_clients/deps/alpinejs-3.14.1.min.js diff --git a/server/tomato/static/admin/tomato/configure_live_clients/deps/alpinejs.js b/server/tomato/static/admin/tomato/configure_live_clients/deps/alpinejs.js new file mode 120000 index 00000000..df2bc8e5 --- /dev/null +++ b/server/tomato/static/admin/tomato/configure_live_clients/deps/alpinejs.js @@ -0,0 +1 @@ +alpinejs-3.14.1.min.js \ No newline at end of file diff --git a/server/tomato/static/admin/tomato/configure_live_clients/deps/choices-9.0.1.min.css b/server/tomato/static/admin/tomato/configure_live_clients/deps/choices-9.0.1.min.css new file mode 100644 index 00000000..19adabab --- /dev/null +++ b/server/tomato/static/admin/tomato/configure_live_clients/deps/choices-9.0.1.min.css @@ -0,0 +1 @@ +.choices{position:relative;margin-bottom:24px;font-size:16px}.choices:focus{outline:0}.choices:last-child{margin-bottom:0}.choices.is-disabled .choices__inner,.choices.is-disabled .choices__input{background-color:#eaeaea;cursor:not-allowed;-webkit-user-select:none;-ms-user-select:none;user-select:none}.choices.is-disabled .choices__item{cursor:not-allowed}.choices [hidden]{display:none!important}.choices[data-type*=select-one]{cursor:pointer}.choices[data-type*=select-one] .choices__inner{padding-bottom:7.5px}.choices[data-type*=select-one] .choices__input{display:block;width:100%;padding:10px;border-bottom:1px solid #ddd;background-color:#fff;margin:0}.choices[data-type*=select-one] .choices__button{background-image:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);padding:0;background-size:8px;position:absolute;top:50%;right:0;margin-top:-10px;margin-right:25px;height:20px;width:20px;border-radius:10em;opacity:.5}.choices[data-type*=select-one] .choices__button:focus,.choices[data-type*=select-one] .choices__button:hover{opacity:1}.choices[data-type*=select-one] .choices__button:focus{box-shadow:0 0 0 2px #00bcd4}.choices[data-type*=select-one] .choices__item[data-value=''] .choices__button{display:none}.choices[data-type*=select-one]:after{content:'';height:0;width:0;border-style:solid;border-color:#333 transparent transparent;border-width:5px;position:absolute;right:11.5px;top:50%;margin-top:-2.5px;pointer-events:none}.choices[data-type*=select-one].is-open:after{border-color:transparent transparent #333;margin-top:-7.5px}.choices[data-type*=select-one][dir=rtl]:after{left:11.5px;right:auto}.choices[data-type*=select-one][dir=rtl] .choices__button{right:auto;left:0;margin-left:25px;margin-right:0}.choices[data-type*=select-multiple] .choices__inner,.choices[data-type*=text] .choices__inner{cursor:text}.choices[data-type*=select-multiple] .choices__button,.choices[data-type*=text] .choices__button{position:relative;display:inline-block;margin:0 -4px 0 8px;padding-left:16px;border-left:1px solid #008fa1;background-image:url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);background-size:8px;width:8px;line-height:1;opacity:.75;border-radius:0}.choices[data-type*=select-multiple] .choices__button:focus,.choices[data-type*=select-multiple] .choices__button:hover,.choices[data-type*=text] .choices__button:focus,.choices[data-type*=text] .choices__button:hover{opacity:1}.choices__inner{display:inline-block;vertical-align:top;width:100%;background-color:#f9f9f9;padding:7.5px 7.5px 3.75px;border:1px solid #ddd;border-radius:2.5px;font-size:14px;min-height:44px;overflow:hidden}.is-focused .choices__inner,.is-open .choices__inner{border-color:#b7b7b7}.is-open .choices__inner{border-radius:2.5px 2.5px 0 0}.is-flipped.is-open .choices__inner{border-radius:0 0 2.5px 2.5px}.choices__list{margin:0;padding-left:0;list-style:none}.choices__list--single{display:inline-block;padding:4px 16px 4px 4px;width:100%}[dir=rtl] .choices__list--single{padding-right:4px;padding-left:16px}.choices__list--single .choices__item{width:100%}.choices__list--multiple{display:inline}.choices__list--multiple .choices__item{display:inline-block;vertical-align:middle;border-radius:20px;padding:4px 10px;font-size:12px;font-weight:500;margin-right:3.75px;margin-bottom:3.75px;background-color:#00bcd4;border:1px solid #00a5bb;color:#fff;word-break:break-all;box-sizing:border-box}.choices__list--multiple .choices__item[data-deletable]{padding-right:5px}[dir=rtl] .choices__list--multiple .choices__item{margin-right:0;margin-left:3.75px}.choices__list--multiple .choices__item.is-highlighted{background-color:#00a5bb;border:1px solid #008fa1}.is-disabled .choices__list--multiple .choices__item{background-color:#aaa;border:1px solid #919191}.choices__list--dropdown{visibility:hidden;z-index:1;position:absolute;width:100%;background-color:#fff;border:1px solid #ddd;top:100%;margin-top:-1px;border-bottom-left-radius:2.5px;border-bottom-right-radius:2.5px;overflow:hidden;word-break:break-all;will-change:visibility}.choices__list--dropdown.is-active{visibility:visible}.is-open .choices__list--dropdown{border-color:#b7b7b7}.is-flipped .choices__list--dropdown{top:auto;bottom:100%;margin-top:0;margin-bottom:-1px;border-radius:.25rem .25rem 0 0}.choices__list--dropdown .choices__list{position:relative;max-height:300px;overflow:auto;-webkit-overflow-scrolling:touch;will-change:scroll-position}.choices__list--dropdown .choices__item{position:relative;padding:10px;font-size:14px}[dir=rtl] .choices__list--dropdown .choices__item{text-align:right}@media (min-width:640px){.choices__list--dropdown .choices__item--selectable{padding-right:100px}.choices__list--dropdown .choices__item--selectable:after{content:attr(data-select-text);font-size:12px;opacity:0;position:absolute;right:10px;top:50%;transform:translateY(-50%)}[dir=rtl] .choices__list--dropdown .choices__item--selectable{text-align:right;padding-left:100px;padding-right:10px}[dir=rtl] .choices__list--dropdown .choices__item--selectable:after{right:auto;left:10px}}.choices__list--dropdown .choices__item--selectable.is-highlighted{background-color:#f2f2f2}.choices__list--dropdown .choices__item--selectable.is-highlighted:after{opacity:.5}.choices__item{cursor:default}.choices__item--selectable{cursor:pointer}.choices__item--disabled{cursor:not-allowed;-webkit-user-select:none;-ms-user-select:none;user-select:none;opacity:.5}.choices__heading{font-weight:600;font-size:12px;padding:10px;border-bottom:1px solid #f7f7f7;color:gray}.choices__button{text-indent:-9999px;-webkit-appearance:none;-moz-appearance:none;appearance:none;border:0;background-color:transparent;background-repeat:no-repeat;background-position:center;cursor:pointer}.choices__button:focus,.choices__input:focus{outline:0}.choices__input{display:inline-block;vertical-align:baseline;background-color:#f9f9f9;font-size:14px;margin-bottom:5px;border:0;border-radius:0;max-width:100%;padding:4px 0 4px 2px}[dir=rtl] .choices__input{padding-right:2px;padding-left:0}.choices__placeholder{opacity:.5} \ No newline at end of file diff --git a/server/tomato/static/admin/tomato/configure_live_clients/deps/choices-9.0.1.min.js b/server/tomato/static/admin/tomato/configure_live_clients/deps/choices-9.0.1.min.js new file mode 100644 index 00000000..c3eabfa2 --- /dev/null +++ b/server/tomato/static/admin/tomato/configure_live_clients/deps/choices-9.0.1.min.js @@ -0,0 +1,11 @@ +/*! choices.js v9.0.1 | © 2019 Josh Johnson | /~https://github.com/jshjohnson/Choices#readme */ +window.Choices=function(e){var t={};function i(n){if(t[n])return t[n].exports;var s=t[n]={i:n,l:!1,exports:{}};return e[n].call(s.exports,s,s.exports,i),s.l=!0,s.exports}return i.m=e,i.c=t,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var s in e)i.d(n,s,function(t){return e[t]}.bind(null,s));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="/public/assets/scripts/",i(i.s=4)}([function(e,t,i){"use strict";var n=function(e){return function(e){return!!e&&"object"==typeof e}(e)&&!function(e){var t=Object.prototype.toString.call(e);return"[object RegExp]"===t||"[object Date]"===t||function(e){return e.$$typeof===s}(e)}(e)};var s="function"==typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;function r(e,t){return!1!==t.clone&&t.isMergeableObject(e)?l((i=e,Array.isArray(i)?[]:{}),e,t):e;var i}function o(e,t,i){return e.concat(t).map((function(e){return r(e,i)}))}function a(e){return Object.keys(e).concat(function(e){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(e).filter((function(t){return e.propertyIsEnumerable(t)})):[]}(e))}function c(e,t,i){var n={};return i.isMergeableObject(e)&&a(e).forEach((function(t){n[t]=r(e[t],i)})),a(t).forEach((function(s){(function(e,t){try{return t in e&&!(Object.hasOwnProperty.call(e,t)&&Object.propertyIsEnumerable.call(e,t))}catch(e){return!1}})(e,s)||(i.isMergeableObject(t[s])&&e[s]?n[s]=function(e,t){if(!t.customMerge)return l;var i=t.customMerge(e);return"function"==typeof i?i:l}(s,i)(e[s],t[s],i):n[s]=r(t[s],i))})),n}function l(e,t,i){(i=i||{}).arrayMerge=i.arrayMerge||o,i.isMergeableObject=i.isMergeableObject||n,i.cloneUnlessOtherwiseSpecified=r;var s=Array.isArray(t);return s===Array.isArray(e)?s?i.arrayMerge(e,t,i):c(e,t,i):r(t,i)}l.all=function(e,t){if(!Array.isArray(e))throw new Error("first argument should be an array");return e.reduce((function(e,i){return l(e,i,t)}),{})};var h=l;e.exports=h},function(e,t,i){"use strict";(function(e,n){var s,r=i(3);s="undefined"!=typeof self?self:"undefined"!=typeof window?window:void 0!==e?e:n;var o=Object(r.a)(s);t.a=o}).call(this,i(5),i(6)(e))},function(e,t,i){ +/*! + * Fuse.js v3.4.5 - Lightweight fuzzy-search (http://fusejs.io) + * + * Copyright (c) 2012-2017 Kirollos Risk (http://kiro.me) + * All Rights Reserved. Apache Software License 2.0 + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +e.exports=function(e){var t={};function i(n){if(t[n])return t[n].exports;var s=t[n]={i:n,l:!1,exports:{}};return e[n].call(s.exports,s,s.exports,i),s.l=!0,s.exports}return i.m=e,i.c=t,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var s in e)i.d(n,s,function(t){return e[t]}.bind(null,s));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="",i(i.s=1)}([function(e,t){e.exports=function(e){return Array.isArray?Array.isArray(e):"[object Array]"===Object.prototype.toString.call(e)}},function(e,t,i){function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function s(e,t){for(var i=0;i1&&void 0!==arguments[1]?arguments[1]:{limit:!1};this._log('---------\nSearch pattern: "'.concat(e,'"'));var i=this._prepareSearchers(e),n=i.tokenSearchers,s=i.fullSearcher,r=this._search(n,s),o=r.weights,a=r.results;return this._computeScore(o,a),this.options.shouldSort&&this._sort(a),t.limit&&"number"==typeof t.limit&&(a=a.slice(0,t.limit)),this._format(a)}},{key:"_prepareSearchers",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=[];if(this.options.tokenize)for(var i=e.split(this.options.tokenSeparator),n=0,s=i.length;n0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1?arguments[1]:void 0,i=this.list,n={},s=[];if("string"==typeof i[0]){for(var r=0,o=i.length;r1)throw new Error("Key weight has to be > 0 and <= 1");p=p.name}else a[p]={weight:1};this._analyze({key:p,value:this.options.getFn(h,p),record:h,index:c},{resultMap:n,results:s,tokenSearchers:e,fullSearcher:t})}return{weights:a,results:s}}},{key:"_analyze",value:function(e,t){var i=e.key,n=e.arrayIndex,s=void 0===n?-1:n,r=e.value,o=e.record,c=e.index,l=t.tokenSearchers,h=void 0===l?[]:l,u=t.fullSearcher,d=void 0===u?[]:u,p=t.resultMap,m=void 0===p?{}:p,f=t.results,v=void 0===f?[]:f;if(null!=r){var g=!1,_=-1,b=0;if("string"==typeof r){this._log("\nKey: ".concat(""===i?"-":i));var y=d.search(r);if(this._log('Full text: "'.concat(r,'", score: ').concat(y.score)),this.options.tokenize){for(var E=r.split(this.options.tokenSeparator),I=[],S=0;S-1&&(P=(P+_)/2),this._log("Score average:",P);var D=!this.options.tokenize||!this.options.matchAllTokens||b>=h.length;if(this._log("\nCheck Matches: ".concat(D)),(g||y.isMatch)&&D){var M=m[c];M?M.output.push({key:i,arrayIndex:s,value:r,score:P,matchedIndices:y.matchedIndices}):(m[c]={item:o,output:[{key:i,arrayIndex:s,value:r,score:P,matchedIndices:y.matchedIndices}]},v.push(m[c]))}}else if(a(r))for(var N=0,F=r.length;N-1&&(o.arrayIndex=r.arrayIndex),t.matches.push(o)}}})),this.options.includeScore&&s.push((function(e,t){t.score=e.score}));for(var r=0,o=e.length;ri)return s(e,this.pattern,n);var o=this.options,a=o.location,c=o.distance,l=o.threshold,h=o.findAllMatches,u=o.minMatchCharLength;return r(e,this.pattern,this.patternAlphabet,{location:a,distance:c,threshold:l,findAllMatches:h,minMatchCharLength:u})}}])&&n(t.prototype,i),e}();e.exports=a},function(e,t){var i=/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g;e.exports=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:/ +/g,s=new RegExp(t.replace(i,"\\$&").replace(n,"|")),r=e.match(s),o=!!r,a=[];if(o)for(var c=0,l=r.length;c=P;N-=1){var F=N-1,j=i[e.charAt(F)];if(j&&(E[F]=1),M[N]=(M[N+1]<<1|1)&j,0!==T&&(M[N]|=(O[N+1]|O[N])<<1|1|O[N+1]),M[N]&L&&(C=n(t,{errors:T,currentLocation:F,expectedLocation:v,distance:l}))<=_){if(_=C,(b=F)<=v)break;P=Math.max(1,2*v-b)}}if(n(t,{errors:T+1,currentLocation:v,expectedLocation:v,distance:l})>_)break;O=M}return{isMatch:b>=0,score:0===C?.001:C,matchedIndices:s(E,f)}}},function(e,t){e.exports=function(e,t){var i=t.errors,n=void 0===i?0:i,s=t.currentLocation,r=void 0===s?0:s,o=t.expectedLocation,a=void 0===o?0:o,c=t.distance,l=void 0===c?100:c,h=n/e.length,u=Math.abs(a-r);return l?h+u/l:u?1:h}},function(e,t){e.exports=function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1,i=[],n=-1,s=-1,r=0,o=e.length;r=t&&i.push([n,s]),n=-1)}return e[r-1]&&r-n>=t&&i.push([n,r-1]),i}},function(e,t){e.exports=function(e){for(var t={},i=e.length,n=0;n/g,"&rt;").replace(/-1?e.map((function(e){var i=e;return i.id===parseInt(t.choiceId,10)&&(i.selected=!0),i})):e;case"REMOVE_ITEM":return t.choiceId>-1?e.map((function(e){var i=e;return i.id===parseInt(t.choiceId,10)&&(i.selected=!1),i})):e;case"FILTER_CHOICES":return e.map((function(e){var i=e;return i.active=t.results.some((function(e){var t=e.item,n=e.score;return t.id===i.id&&(i.score=n,!0)})),i}));case"ACTIVATE_CHOICES":return e.map((function(e){var i=e;return i.active=t.active,i}));case"CLEAR_CHOICES":return f;default:return e}},general:_}),T=function(e,t){var i=e;if("CLEAR_ALL"===t.type)i=void 0;else if("RESET_TO"===t.type)return C(t.state);return L(i,t)};function x(e,t){for(var i=0;i"'+S(e)+'"'},maxItemText:function(e){return"Only "+e+" values can be added"},valueComparer:function(e,t){return e===t},fuseOptions:{includeScore:!0},callbackOnInit:null,callbackOnCreateTemplates:null,classNames:{containerOuter:"choices",containerInner:"choices__inner",input:"choices__input",inputCloned:"choices__input--cloned",list:"choices__list",listItems:"choices__list--multiple",listSingle:"choices__list--single",listDropdown:"choices__list--dropdown",item:"choices__item",itemSelectable:"choices__item--selectable",itemDisabled:"choices__item--disabled",itemChoice:"choices__item--choice",placeholder:"choices__placeholder",group:"choices__group",groupHeading:"choices__heading",button:"choices__button",activeState:"is-active",focusState:"is-focused",openState:"is-open",disabledState:"is-disabled",highlightedState:"is-highlighted",selectedState:"is-selected",flippedState:"is-flipped",loadingState:"is-loading",noResults:"has-no-results",noChoices:"has-no-choices"}},N="showDropdown",F="hideDropdown",j="change",K="choice",R="search",H="addItem",B="removeItem",V="highlightItem",G="highlightChoice",q="ADD_CHOICE",U="FILTER_CHOICES",z="ACTIVATE_CHOICES",W="CLEAR_CHOICES",X="ADD_GROUP",$="ADD_ITEM",J="REMOVE_ITEM",Y="HIGHLIGHT_ITEM",Z=46,Q=8,ee=13,te=65,ie=27,ne=38,se=40,re=33,oe=34,ae="text",ce="select-one",le="select-multiple",he=function(){function e(e){var t=e.element,i=e.type,n=e.classNames,s=e.position;this.element=t,this.classNames=n,this.type=i,this.position=s,this.isOpen=!1,this.isFlipped=!1,this.isFocussed=!1,this.isDisabled=!1,this.isLoading=!1,this._onFocus=this._onFocus.bind(this),this._onBlur=this._onBlur.bind(this)}var t=e.prototype;return t.addEventListeners=function(){this.element.addEventListener("focus",this._onFocus),this.element.addEventListener("blur",this._onBlur)},t.removeEventListeners=function(){this.element.removeEventListener("focus",this._onFocus),this.element.removeEventListener("blur",this._onBlur)},t.shouldFlip=function(e){if("number"!=typeof e)return!1;var t=!1;return"auto"===this.position?t=!window.matchMedia("(min-height: "+(e+1)+"px)").matches:"top"===this.position&&(t=!0),t},t.setActiveDescendant=function(e){this.element.setAttribute("aria-activedescendant",e)},t.removeActiveDescendant=function(){this.element.removeAttribute("aria-activedescendant")},t.open=function(e){this.element.classList.add(this.classNames.openState),this.element.setAttribute("aria-expanded","true"),this.isOpen=!0,this.shouldFlip(e)&&(this.element.classList.add(this.classNames.flippedState),this.isFlipped=!0)},t.close=function(){this.element.classList.remove(this.classNames.openState),this.element.setAttribute("aria-expanded","false"),this.removeActiveDescendant(),this.isOpen=!1,this.isFlipped&&(this.element.classList.remove(this.classNames.flippedState),this.isFlipped=!1)},t.focus=function(){this.isFocussed||this.element.focus()},t.addFocusState=function(){this.element.classList.add(this.classNames.focusState)},t.removeFocusState=function(){this.element.classList.remove(this.classNames.focusState)},t.enable=function(){this.element.classList.remove(this.classNames.disabledState),this.element.removeAttribute("aria-disabled"),this.type===ce&&this.element.setAttribute("tabindex","0"),this.isDisabled=!1},t.disable=function(){this.element.classList.add(this.classNames.disabledState),this.element.setAttribute("aria-disabled","true"),this.type===ce&&this.element.setAttribute("tabindex","-1"),this.isDisabled=!0},t.wrap=function(e){!function(e,t){void 0===t&&(t=document.createElement("div")),e.nextSibling?e.parentNode.insertBefore(t,e.nextSibling):e.parentNode.appendChild(t),t.appendChild(e)}(e,this.element)},t.unwrap=function(e){this.element.parentNode.insertBefore(e,this.element),this.element.parentNode.removeChild(this.element)},t.addLoadingState=function(){this.element.classList.add(this.classNames.loadingState),this.element.setAttribute("aria-busy","true"),this.isLoading=!0},t.removeLoadingState=function(){this.element.classList.remove(this.classNames.loadingState),this.element.removeAttribute("aria-busy"),this.isLoading=!1},t._onFocus=function(){this.isFocussed=!0},t._onBlur=function(){this.isFocussed=!1},e}();function ue(e,t){for(var i=0;i0?this.element.scrollTop+o-s:e.offsetTop;requestAnimationFrame((function(){i._animateScroll(a,t)}))}},t._scrollDown=function(e,t,i){var n=(i-e)/t,s=n>1?n:1;this.element.scrollTop=e+s},t._scrollUp=function(e,t,i){var n=(e-i)/t,s=n>1?n:1;this.element.scrollTop=e-s},t._animateScroll=function(e,t){var i=this,n=this.element.scrollTop,s=!1;t>0?(this._scrollDown(n,4,e),ne&&(s=!0)),s&&requestAnimationFrame((function(){i._animateScroll(e,t)}))},e}();function me(e,t){for(var i=0;i0?"treeitem":"option"),Object.assign(g.dataset,{choice:"",id:l,value:h,selectText:i}),m?(g.classList.add(a),g.dataset.choiceDisabled="",g.setAttribute("aria-disabled","true")):(g.classList.add(r),g.dataset.choiceSelectable=""),g},input:function(e,t){var i=e.input,n=e.inputCloned,s=Object.assign(document.createElement("input"),{type:"text",className:i+" "+n,autocomplete:"off",autocapitalize:"off",spellcheck:!1});return s.setAttribute("role","textbox"),s.setAttribute("aria-autocomplete","list"),s.setAttribute("aria-label",t),s},dropdown:function(e){var t=e.list,i=e.listDropdown,n=document.createElement("div");return n.classList.add(t,i),n.setAttribute("aria-expanded","false"),n},notice:function(e,t,i){var n=e.item,s=e.itemChoice,r=e.noResults,o=e.noChoices;void 0===i&&(i="");var a=[n,s];return"no-choices"===i?a.push(o):"no-results"===i&&a.push(r),Object.assign(document.createElement("div"),{innerHTML:t,className:a.join(" ")})},option:function(e){var t=e.label,i=e.value,n=e.customProperties,s=e.active,r=e.disabled,o=new Option(t,i,!1,s);return n&&(o.dataset.customProperties=n),o.disabled=r,o}},Ee=function(e){return void 0===e&&(e=!0),{type:z,active:e}},Ie=function(e,t){return{type:Y,id:e,highlighted:t}},Se=function(e){var t=e.value,i=e.id,n=e.active,s=e.disabled;return{type:X,value:t,id:i,active:n,disabled:s}},we=function(e){return{type:"SET_IS_LOADING",isLoading:e}};function Oe(e,t){for(var i=0;i=0?this._store.getGroupById(s):null;return this._store.dispatch(Ie(i,!0)),t&&this.passedElement.triggerEvent(V,{id:i,value:o,label:c,groupValue:l&&l.value?l.value:null}),this},r.unhighlightItem=function(e){if(!e)return this;var t=e.id,i=e.groupId,n=void 0===i?-1:i,s=e.value,r=void 0===s?"":s,o=e.label,a=void 0===o?"":o,c=n>=0?this._store.getGroupById(n):null;return this._store.dispatch(Ie(t,!1)),this.passedElement.triggerEvent(V,{id:t,value:r,label:a,groupValue:c&&c.value?c.value:null}),this},r.highlightAll=function(){var e=this;return this._store.items.forEach((function(t){return e.highlightItem(t)})),this},r.unhighlightAll=function(){var e=this;return this._store.items.forEach((function(t){return e.unhighlightItem(t)})),this},r.removeActiveItemsByValue=function(e){var t=this;return this._store.activeItems.filter((function(t){return t.value===e})).forEach((function(e){return t._removeItem(e)})),this},r.removeActiveItems=function(e){var t=this;return this._store.activeItems.filter((function(t){return t.id!==e})).forEach((function(e){return t._removeItem(e)})),this},r.removeHighlightedItems=function(e){var t=this;return void 0===e&&(e=!1),this._store.highlightedActiveItems.forEach((function(i){t._removeItem(i),e&&t._triggerChange(i.value)})),this},r.showDropdown=function(e){var t=this;return this.dropdown.isActive?this:(requestAnimationFrame((function(){t.dropdown.show(),t.containerOuter.open(t.dropdown.distanceFromTopWindow),!e&&t._canSearch&&t.input.focus(),t.passedElement.triggerEvent(N,{})})),this)},r.hideDropdown=function(e){var t=this;return this.dropdown.isActive?(requestAnimationFrame((function(){t.dropdown.hide(),t.containerOuter.close(),!e&&t._canSearch&&(t.input.removeActiveDescendant(),t.input.blur()),t.passedElement.triggerEvent(F,{})})),this):this},r.getValue=function(e){void 0===e&&(e=!1);var t=this._store.activeItems.reduce((function(t,i){var n=e?i.value:i;return t.push(n),t}),[]);return this._isSelectOneElement?t[0]:t},r.setValue=function(e){var t=this;return this.initialised?(e.forEach((function(e){return t._setChoiceOrItem(e)})),this):this},r.setChoiceByValue=function(e){var t=this;return!this.initialised||this._isTextElement?this:((Array.isArray(e)?e:[e]).forEach((function(e){return t._findAndSelectChoiceByValue(e)})),this)},r.setChoices=function(e,t,i,n){var s=this;if(void 0===e&&(e=[]),void 0===t&&(t="value"),void 0===i&&(i="label"),void 0===n&&(n=!1),!this.initialised)throw new ReferenceError("setChoices was called on a non-initialized instance of Choices");if(!this._isSelectElement)throw new TypeError("setChoices can't be used with INPUT based Choices");if("string"!=typeof t||!t)throw new TypeError("value parameter must be a name of 'value' field in passed objects");if(n&&this.clearChoices(),"function"==typeof e){var r=e(this);if("function"==typeof Promise&&r instanceof Promise)return new Promise((function(e){return requestAnimationFrame(e)})).then((function(){return s._handleLoadingState(!0)})).then((function(){return r})).then((function(e){return s.setChoices(e,t,i,n)})).catch((function(e){s.config.silent||console.error(e)})).then((function(){return s._handleLoadingState(!1)})).then((function(){return s}));if(!Array.isArray(r))throw new TypeError(".setChoices first argument function must return either array of choices or Promise, got: "+typeof r);return this.setChoices(r,t,i,!1)}if(!Array.isArray(e))throw new TypeError(".setChoices must be called either with array of choices with a function resulting into Promise of array of choices");return this.containerOuter.removeLoadingState(),this._startLoading(),e.forEach((function(e){e.choices?s._addGroup({id:parseInt(e.id,10)||null,group:e,valueKey:t,labelKey:i}):s._addChoice({value:e[t],label:e[i],isSelected:e.selected,isDisabled:e.disabled,customProperties:e.customProperties,placeholder:e.placeholder})})),this._stopLoading(),this},r.clearChoices=function(){return this._store.dispatch({type:W}),this},r.clearStore=function(){return this._store.dispatch({type:"CLEAR_ALL"}),this},r.clearInput=function(){var e=!this._isSelectOneElement;return this.input.clear(e),!this._isTextElement&&this._canSearch&&(this._isSearching=!1,this._store.dispatch(Ee(!0))),this},r._render=function(){if(!this._store.isLoading()){this._currentState=this._store.state;var e=this._currentState.choices!==this._prevState.choices||this._currentState.groups!==this._prevState.groups||this._currentState.items!==this._prevState.items,t=this._isSelectElement,i=this._currentState.items!==this._prevState.items;e&&(t&&this._renderChoices(),i&&this._renderItems(),this._prevState=this._currentState)}},r._renderChoices=function(){var e=this,t=this._store,i=t.activeGroups,n=t.activeChoices,s=document.createDocumentFragment();if(this.choiceList.clear(),this.config.resetScrollPosition&&requestAnimationFrame((function(){return e.choiceList.scrollToTop()})),i.length>=1&&!this._isSearching){var r=n.filter((function(e){return!0===e.placeholder&&-1===e.groupId}));r.length>=1&&(s=this._createChoicesFragment(r,s)),s=this._createGroupsFragment(i,n,s)}else n.length>=1&&(s=this._createChoicesFragment(n,s));if(s.childNodes&&s.childNodes.length>0){var o=this._store.activeItems,a=this._canAddItem(o,this.input.value);a.response?(this.choiceList.append(s),this._highlightChoice()):this.choiceList.append(this._getTemplate("notice",a.notice))}else{var c,l;this._isSearching?(l="function"==typeof this.config.noResultsText?this.config.noResultsText():this.config.noResultsText,c=this._getTemplate("notice",l,"no-results")):(l="function"==typeof this.config.noChoicesText?this.config.noChoicesText():this.config.noChoicesText,c=this._getTemplate("notice",l,"no-choices")),this.choiceList.append(c)}},r._renderItems=function(){var e=this._store.activeItems||[];this.itemList.clear();var t=this._createItemsFragment(e);t.childNodes&&this.itemList.append(t)},r._createGroupsFragment=function(e,t,i){var n=this;void 0===i&&(i=document.createDocumentFragment());return this.config.shouldSort&&e.sort(this.config.sorter),e.forEach((function(e){var s=function(e){return t.filter((function(t){return n._isSelectOneElement?t.groupId===e.id:t.groupId===e.id&&("always"===n.config.renderSelectedChoices||!t.selected)}))}(e);if(s.length>=1){var r=n._getTemplate("choiceGroup",e);i.appendChild(r),n._createChoicesFragment(s,i,!0)}})),i},r._createChoicesFragment=function(e,t,i){var n=this;void 0===t&&(t=document.createDocumentFragment()),void 0===i&&(i=!1);var s=this.config,r=s.renderSelectedChoices,o=s.searchResultLimit,a=s.renderChoiceLimit,c=this._isSearching?O:this.config.sorter,l=function(e){if("auto"!==r||(n._isSelectOneElement||!e.selected)){var i=n._getTemplate("choice",e,n.config.itemSelectText);t.appendChild(i)}},h=e;"auto"!==r||this._isSelectOneElement||(h=e.filter((function(e){return!e.selected})));var u=h.reduce((function(e,t){return t.placeholder?e.placeholderChoices.push(t):e.normalChoices.push(t),e}),{placeholderChoices:[],normalChoices:[]}),d=u.placeholderChoices,p=u.normalChoices;(this.config.shouldSort||this._isSearching)&&p.sort(c);var m=h.length,f=this._isSelectOneElement?[].concat(d,p):p;this._isSearching?m=o:a&&a>0&&!i&&(m=a);for(var v=0;v=n){var o=s?this._searchChoices(e):0;this.passedElement.triggerEvent(R,{value:e,resultCount:o})}else r&&(this._isSearching=!1,this._store.dispatch(Ee(!0)))}},r._canAddItem=function(e,t){var i=!0,n="function"==typeof this.config.addItemText?this.config.addItemText(t):this.config.addItemText;if(!this._isSelectOneElement){var s=function(e,t,i){return void 0===i&&(i="value"),e.some((function(e){return"string"==typeof t?e[i]===t.trim():e[i]===t}))}(e,t);this.config.maxItemCount>0&&this.config.maxItemCount<=e.length&&(i=!1,n="function"==typeof this.config.maxItemText?this.config.maxItemText(this.config.maxItemCount):this.config.maxItemText),!this.config.duplicateItemsAllowed&&s&&i&&(i=!1,n="function"==typeof this.config.uniqueItemText?this.config.uniqueItemText(t):this.config.uniqueItemText),this._isTextElement&&this.config.addItems&&i&&"function"==typeof this.config.addItemFilter&&!this.config.addItemFilter(t)&&(i=!1,n="function"==typeof this.config.customAddItemText?this.config.customAddItemText(t):this.config.customAddItemText)}return{response:i,notice:n}},r._searchChoices=function(e){var t="string"==typeof e?e.trim():e,i="string"==typeof this._currentValue?this._currentValue.trim():this._currentValue;if(t.length<1&&t===i+" ")return 0;var n=this._store.searchableChoices,r=t,o=[].concat(this.config.searchFields),a=Object.assign(this.config.fuseOptions,{keys:o}),c=new s.a(n,a).search(r);return this._currentValue=t,this._highlightPosition=0,this._isSearching=!0,this._store.dispatch(function(e){return{type:U,results:e}}(c)),c.length},r._addEventListeners=function(){var e=document.documentElement;e.addEventListener("touchend",this._onTouchEnd,!0),this.containerOuter.element.addEventListener("keydown",this._onKeyDown,!0),this.containerOuter.element.addEventListener("mousedown",this._onMouseDown,!0),e.addEventListener("click",this._onClick,{passive:!0}),e.addEventListener("touchmove",this._onTouchMove,{passive:!0}),this.dropdown.element.addEventListener("mouseover",this._onMouseOver,{passive:!0}),this._isSelectOneElement&&(this.containerOuter.element.addEventListener("focus",this._onFocus,{passive:!0}),this.containerOuter.element.addEventListener("blur",this._onBlur,{passive:!0})),this.input.element.addEventListener("keyup",this._onKeyUp,{passive:!0}),this.input.element.addEventListener("focus",this._onFocus,{passive:!0}),this.input.element.addEventListener("blur",this._onBlur,{passive:!0}),this.input.element.form&&this.input.element.form.addEventListener("reset",this._onFormReset,{passive:!0}),this.input.addEventListeners()},r._removeEventListeners=function(){var e=document.documentElement;e.removeEventListener("touchend",this._onTouchEnd,!0),this.containerOuter.element.removeEventListener("keydown",this._onKeyDown,!0),this.containerOuter.element.removeEventListener("mousedown",this._onMouseDown,!0),e.removeEventListener("click",this._onClick),e.removeEventListener("touchmove",this._onTouchMove),this.dropdown.element.removeEventListener("mouseover",this._onMouseOver),this._isSelectOneElement&&(this.containerOuter.element.removeEventListener("focus",this._onFocus),this.containerOuter.element.removeEventListener("blur",this._onBlur)),this.input.element.removeEventListener("keyup",this._onKeyUp),this.input.element.removeEventListener("focus",this._onFocus),this.input.element.removeEventListener("blur",this._onBlur),this.input.element.form&&this.input.element.form.removeEventListener("reset",this._onFormReset),this.input.removeEventListeners()},r._onKeyDown=function(e){var t,i=e.target,n=e.keyCode,s=e.ctrlKey,r=e.metaKey,o=this._store.activeItems,a=this.input.isFocussed,c=this.dropdown.isActive,l=this.itemList.hasChildren(),h=String.fromCharCode(n),u=Z,d=Q,p=ee,m=te,f=ie,v=ne,g=se,_=re,b=oe,y=s||r;!this._isTextElement&&/[a-zA-Z0-9-_ ]/.test(h)&&this.showDropdown();var E=((t={})[m]=this._onAKey,t[p]=this._onEnterKey,t[f]=this._onEscapeKey,t[v]=this._onDirectionKey,t[_]=this._onDirectionKey,t[g]=this._onDirectionKey,t[b]=this._onDirectionKey,t[d]=this._onDeleteKey,t[u]=this._onDeleteKey,t);E[n]&&E[n]({event:e,target:i,keyCode:n,metaKey:r,activeItems:o,hasFocusedInput:a,hasActiveDropdown:c,hasItems:l,hasCtrlDownKeyPressed:y})},r._onKeyUp=function(e){var t=e.target,i=e.keyCode,n=this.input.value,s=this._store.activeItems,r=this._canAddItem(s,n),o=Z,a=Q;if(this._isTextElement){if(r.notice&&n){var c=this._getTemplate("notice",r.notice);this.dropdown.element.innerHTML=c.outerHTML,this.showDropdown(!0)}else this.hideDropdown(!0)}else{var l=(i===o||i===a)&&!t.value,h=!this._isTextElement&&this._isSearching,u=this._canSearch&&r.response;l&&h?(this._isSearching=!1,this._store.dispatch(Ee(!0))):u&&this._handleSearch(this.input.value)}this._canSearch=this.config.searchEnabled},r._onAKey=function(e){var t=e.hasItems;e.hasCtrlDownKeyPressed&&t&&(this._canSearch=!1,this.config.removeItems&&!this.input.value&&this.input.element===document.activeElement&&this.highlightAll())},r._onEnterKey=function(e){var t=e.event,i=e.target,n=e.activeItems,s=e.hasActiveDropdown,r=ee,o=i.hasAttribute("data-button");if(this._isTextElement&&i.value){var a=this.input.value;this._canAddItem(n,a).response&&(this.hideDropdown(!0),this._addItem({value:a}),this._triggerChange(a),this.clearInput())}if(o&&(this._handleButtonAction(n,i),t.preventDefault()),s){var c=this.dropdown.getChild("."+this.config.classNames.highlightedState);c&&(n[0]&&(n[0].keyCode=r),this._handleChoiceAction(n,c)),t.preventDefault()}else this._isSelectOneElement&&(this.showDropdown(),t.preventDefault())},r._onEscapeKey=function(e){e.hasActiveDropdown&&(this.hideDropdown(!0),this.containerOuter.focus())},r._onDirectionKey=function(e){var t,i,n,s=e.event,r=e.hasActiveDropdown,o=e.keyCode,a=e.metaKey,c=se,l=re,h=oe;if(r||this._isSelectOneElement){this.showDropdown(),this._canSearch=!1;var u,d=o===c||o===h?1:-1;if(a||o===h||o===l)u=d>0?this.dropdown.element.querySelector("[data-choice-selectable]:last-of-type"):this.dropdown.element.querySelector("[data-choice-selectable]");else{var p=this.dropdown.element.querySelector("."+this.config.classNames.highlightedState);u=p?function(e,t,i){if(void 0===i&&(i=1),e instanceof Element&&"string"==typeof t){for(var n=(i>0?"next":"previous")+"ElementSibling",s=e[n];s;){if(s.matches(t))return s;s=s[n]}return s}}(p,"[data-choice-selectable]",d):this.dropdown.element.querySelector("[data-choice-selectable]")}u&&(t=u,i=this.choiceList.element,void 0===(n=d)&&(n=1),t&&(n>0?i.scrollTop+i.offsetHeight>=t.offsetTop+t.offsetHeight:t.offsetTop>=i.scrollTop)||this.choiceList.scrollToChildElement(u,d),this._highlightChoice(u)),s.preventDefault()}},r._onDeleteKey=function(e){var t=e.event,i=e.target,n=e.hasFocusedInput,s=e.activeItems;!n||i.value||this._isSelectOneElement||(this._handleBackspace(s),t.preventDefault())},r._onTouchMove=function(){this._wasTap&&(this._wasTap=!1)},r._onTouchEnd=function(e){var t=(e||e.touches[0]).target;this._wasTap&&this.containerOuter.element.contains(t)&&((t===this.containerOuter.element||t===this.containerInner.element)&&(this._isTextElement?this.input.focus():this._isSelectMultipleElement&&this.showDropdown()),e.stopPropagation());this._wasTap=!0},r._onMouseDown=function(e){var t=e.target;if(t instanceof HTMLElement){if(Ce&&this.choiceList.element.contains(t)){var i=this.choiceList.element.firstElementChild,n="ltr"===this._direction?e.offsetX>=i.offsetWidth:e.offsetX0&&this.unhighlightAll(),this.containerOuter.removeFocusState(),this.hideDropdown(!0))},r._onFocus=function(e){var t,i=this,n=e.target;this.containerOuter.element.contains(n)&&((t={})[ae]=function(){n===i.input.element&&i.containerOuter.addFocusState()},t[ce]=function(){i.containerOuter.addFocusState(),n===i.input.element&&i.showDropdown(!0)},t[le]=function(){n===i.input.element&&(i.showDropdown(!0),i.containerOuter.addFocusState())},t)[this.passedElement.element.type]()},r._onBlur=function(e){var t=this,i=e.target;if(this.containerOuter.element.contains(i)&&!this._isScrollingOnIe){var n,s=this._store.activeItems.some((function(e){return e.highlighted}));((n={})[ae]=function(){i===t.input.element&&(t.containerOuter.removeFocusState(),s&&t.unhighlightAll(),t.hideDropdown(!0))},n[ce]=function(){t.containerOuter.removeFocusState(),(i===t.input.element||i===t.containerOuter.element&&!t._canSearch)&&t.hideDropdown(!0)},n[le]=function(){i===t.input.element&&(t.containerOuter.removeFocusState(),t.hideDropdown(!0),s&&t.unhighlightAll())},n)[this.passedElement.element.type]()}else this._isScrollingOnIe=!1,this.input.element.focus()},r._onFormReset=function(){this._store.dispatch({type:"RESET_TO",state:this._initialState})},r._highlightChoice=function(e){var t=this;void 0===e&&(e=null);var i=Array.from(this.dropdown.element.querySelectorAll("[data-choice-selectable]"));if(i.length){var n=e;Array.from(this.dropdown.element.querySelectorAll("."+this.config.classNames.highlightedState)).forEach((function(e){e.classList.remove(t.config.classNames.highlightedState),e.setAttribute("aria-selected","false")})),n?this._highlightPosition=i.indexOf(n):(n=i.length>this._highlightPosition?i[this._highlightPosition]:i[i.length-1])||(n=i[0]),n.classList.add(this.config.classNames.highlightedState),n.setAttribute("aria-selected","true"),this.passedElement.triggerEvent(G,{el:n}),this.dropdown.isActive&&(this.input.setActiveDescendant(n.id),this.containerOuter.setActiveDescendant(n.id))}},r._addItem=function(e){var t=e.value,i=e.label,n=void 0===i?null:i,s=e.choiceId,r=void 0===s?-1:s,o=e.groupId,a=void 0===o?-1:o,c=e.customProperties,l=void 0===c?null:c,h=e.placeholder,u=void 0!==h&&h,d=e.keyCode,p=void 0===d?null:d,m="string"==typeof t?t.trim():t,f=p,v=l,g=this._store.items,_=n||m,b=r||-1,y=a>=0?this._store.getGroupById(a):null,E=g?g.length+1:1;return this.config.prependValue&&(m=this.config.prependValue+m.toString()),this.config.appendValue&&(m+=this.config.appendValue.toString()),this._store.dispatch(function(e){var t=e.value,i=e.label,n=e.id,s=e.choiceId,r=e.groupId,o=e.customProperties,a=e.placeholder,c=e.keyCode;return{type:$,value:t,label:i,id:n,choiceId:s,groupId:r,customProperties:o,placeholder:a,keyCode:c}}({value:m,label:_,id:E,choiceId:b,groupId:a,customProperties:l,placeholder:u,keyCode:f})),this._isSelectOneElement&&this.removeActiveItems(E),this.passedElement.triggerEvent(H,{id:E,value:m,label:_,customProperties:v,groupValue:y&&y.value?y.value:void 0,keyCode:f}),this},r._removeItem=function(e){if(!e||!I("Object",e))return this;var t=e.id,i=e.value,n=e.label,s=e.choiceId,r=e.groupId,o=r>=0?this._store.getGroupById(r):null;return this._store.dispatch(function(e,t){return{type:J,id:e,choiceId:t}}(t,s)),o&&o.value?this.passedElement.triggerEvent(B,{id:t,value:i,label:n,groupValue:o.value}):this.passedElement.triggerEvent(B,{id:t,value:i,label:n}),this},r._addChoice=function(e){var t=e.value,i=e.label,n=void 0===i?null:i,s=e.isSelected,r=void 0!==s&&s,o=e.isDisabled,a=void 0!==o&&o,c=e.groupId,l=void 0===c?-1:c,h=e.customProperties,u=void 0===h?null:h,d=e.placeholder,p=void 0!==d&&d,m=e.keyCode,f=void 0===m?null:m;if(null!=t){var v=this._store.choices,g=n||t,_=v?v.length+1:1,b=this._baseId+"-"+this._idNames.itemChoice+"-"+_;this._store.dispatch(function(e){var t=e.value,i=e.label,n=e.id,s=e.groupId,r=e.disabled,o=e.elementId,a=e.customProperties,c=e.placeholder,l=e.keyCode;return{type:q,value:t,label:i,id:n,groupId:s,disabled:r,elementId:o,customProperties:a,placeholder:c,keyCode:l}}({id:_,groupId:l,elementId:b,value:t,label:g,disabled:a,customProperties:u,placeholder:p,keyCode:f})),r&&this._addItem({value:t,label:g,choiceId:_,customProperties:u,placeholder:p,keyCode:f})}},r._addGroup=function(e){var t=this,i=e.group,n=e.id,s=e.valueKey,r=void 0===s?"value":s,o=e.labelKey,a=void 0===o?"label":o,c=I("Object",i)?i.choices:Array.from(i.getElementsByTagName("OPTION")),l=n||Math.floor((new Date).valueOf()*Math.random()),h=!!i.disabled&&i.disabled;if(c){this._store.dispatch(Se({value:i.label,id:l,active:!0,disabled:h}));c.forEach((function(e){var i=e.disabled||e.parentNode&&e.parentNode.disabled;t._addChoice({value:e[r],label:I("Object",e)?e[a]:e.innerHTML,isSelected:e.selected,isDisabled:i,groupId:l,customProperties:e.customProperties,placeholder:e.placeholder})}))}else this._store.dispatch(Se({value:i.label,id:i.id,active:!1,disabled:i.disabled}))},r._getTemplate=function(e){var t;if(!e)return null;for(var i=this.config.classNames,n=arguments.length,s=new Array(n>1?n-1:0),r=1;r=e?t:""+Array(e+1-r.length).join(n)+t},v={s:m,z:function(t){var e=-t.utcOffset(),n=Math.abs(e),r=Math.floor(n/60),i=n%60;return(e<=0?"+":"-")+m(r,2,"0")+":"+m(i,2,"0")},m:function t(e,n){if(e.date()1)return t(u[0])}else{var a=e.name;D[a]=e,i=a}return!r&&i&&(g=i),i||!r&&g},O=function(t,e){if(S(t))return t.clone();var n="object"==typeof e?e:{};return n.date=t,n.args=arguments,new _(n)},b=v;b.l=w,b.i=S,b.w=function(t,e){return O(t,{locale:e.$L,utc:e.$u,x:e.$x,$offset:e.$offset})};var _=function(){function M(t){this.$L=w(t.locale,null,!0),this.parse(t),this.$x=this.$x||t.x||{},this[p]=!0}var m=M.prototype;return m.parse=function(t){this.$d=function(t){var e=t.date,n=t.utc;if(null===e)return new Date(NaN);if(b.u(e))return new Date;if(e instanceof Date)return new Date(e);if("string"==typeof e&&!/Z$/i.test(e)){var r=e.match($);if(r){var i=r[2]-1||0,s=(r[7]||"0").substring(0,3);return n?new Date(Date.UTC(r[1],i,r[3]||1,r[4]||0,r[5]||0,r[6]||0,s)):new Date(r[1],i,r[3]||1,r[4]||0,r[5]||0,r[6]||0,s)}}return new Date(e)}(t),this.init()},m.init=function(){var t=this.$d;this.$y=t.getFullYear(),this.$M=t.getMonth(),this.$D=t.getDate(),this.$W=t.getDay(),this.$H=t.getHours(),this.$m=t.getMinutes(),this.$s=t.getSeconds(),this.$ms=t.getMilliseconds()},m.$utils=function(){return b},m.isValid=function(){return!(this.$d.toString()===l)},m.isSame=function(t,e){var n=O(t);return this.startOf(e)<=n&&n<=this.endOf(e)},m.isAfter=function(t,e){return O(t)0)&&!(o=i.next()).done;)s.push(o.value)}catch(e){r={error:e}}finally{try{o&&!o.done&&(n=i.return)&&n.call(i)}finally{if(r)throw r.error}}return s}var o=function(){return function(e,t){this.target=t,this.type=e}}(),r=function(e){function n(t,n){var o=e.call(this,"error",n)||this;return o.message=t.message,o.error=t,o}return t(n,e),n}(o),i=function(e){function n(t,n,o){void 0===t&&(t=1e3),void 0===n&&(n="");var r=e.call(this,"close",o)||this;return r.wasClean=!0,r.code=t,r.reason=n,r}return t(n,e),n}(o),s=function(){if("undefined"!=typeof WebSocket)return WebSocket},c={maxReconnectionDelay:1e4,minReconnectionDelay:1e3+4e3*Math.random(),minUptime:5e3,reconnectionDelayGrowFactor:1.3,connectionTimeout:4e3,maxRetries:1/0,maxEnqueuedMessages:1/0,startClosed:!1,debug:!1};return function(){function e(e,t,n){var o=this;void 0===n&&(n={}),this._listeners={error:[],message:[],open:[],close:[]},this._retryCount=-1,this._shouldReconnect=!0,this._connectLock=!1,this._binaryType="blob",this._closeCalled=!1,this._messageQueue=[],this.onclose=null,this.onerror=null,this.onmessage=null,this.onopen=null,this._handleOpen=function(e){o._debug("open event");var t=o._options.minUptime,n=void 0===t?c.minUptime:t;clearTimeout(o._connectTimeout),o._uptimeTimeout=setTimeout(function(){return o._acceptOpen()},n),o._ws.binaryType=o._binaryType,o._messageQueue.forEach(function(e){return o._ws.send(e)}),o._messageQueue=[],o.onopen&&o.onopen(e),o._listeners.open.forEach(function(t){return o._callEventListener(e,t)})},this._handleMessage=function(e){o._debug("message event"),o.onmessage&&o.onmessage(e),o._listeners.message.forEach(function(t){return o._callEventListener(e,t)})},this._handleError=function(e){o._debug("error event",e.message),o._disconnect(void 0,"TIMEOUT"===e.message?"timeout":void 0),o.onerror&&o.onerror(e),o._debug("exec error listeners"),o._listeners.error.forEach(function(t){return o._callEventListener(e,t)}),o._connect()},this._handleClose=function(e){o._debug("close event"),o._clearTimeouts(),o._shouldReconnect&&o._connect(),o.onclose&&o.onclose(e),o._listeners.close.forEach(function(t){return o._callEventListener(e,t)})},this._url=e,this._protocols=t,this._options=n,this._options.startClosed&&(this._shouldReconnect=!1),this._connect()}return Object.defineProperty(e,"CONNECTING",{get:function(){return 0},enumerable:!0,configurable:!0}),Object.defineProperty(e,"OPEN",{get:function(){return 1},enumerable:!0,configurable:!0}),Object.defineProperty(e,"CLOSING",{get:function(){return 2},enumerable:!0,configurable:!0}),Object.defineProperty(e,"CLOSED",{get:function(){return 3},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"CONNECTING",{get:function(){return e.CONNECTING},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"OPEN",{get:function(){return e.OPEN},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"CLOSING",{get:function(){return e.CLOSING},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"CLOSED",{get:function(){return e.CLOSED},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"binaryType",{get:function(){return this._ws?this._ws.binaryType:this._binaryType},set:function(e){this._binaryType=e,this._ws&&(this._ws.binaryType=e)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"retryCount",{get:function(){return Math.max(this._retryCount,0)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"bufferedAmount",{get:function(){return this._messageQueue.reduce(function(e,t){return"string"==typeof t?e+=t.length:t instanceof Blob?e+=t.size:e+=t.byteLength,e},0)+(this._ws?this._ws.bufferedAmount:0)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"extensions",{get:function(){return this._ws?this._ws.extensions:""},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"protocol",{get:function(){return this._ws?this._ws.protocol:""},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"readyState",{get:function(){return this._ws?this._ws.readyState:this._options.startClosed?e.CLOSED:e.CONNECTING},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"url",{get:function(){return this._ws?this._ws.url:""},enumerable:!0,configurable:!0}),e.prototype.close=function(e,t){void 0===e&&(e=1e3),this._closeCalled=!0,this._shouldReconnect=!1,this._clearTimeouts(),this._ws?this._ws.readyState!==this.CLOSED?this._ws.close(e,t):this._debug("close: already closed"):this._debug("close enqueued: no ws instance")},e.prototype.reconnect=function(e,t){this._shouldReconnect=!0,this._closeCalled=!1,this._retryCount=-1,this._ws&&this._ws.readyState!==this.CLOSED?(this._disconnect(e,t),this._connect()):this._connect()},e.prototype.send=function(e){if(this._ws&&this._ws.readyState===this.OPEN)this._debug("send",e),this._ws.send(e);else{var t=this._options.maxEnqueuedMessages,n=void 0===t?c.maxEnqueuedMessages:t;this._messageQueue.length=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}}}(o),i=r.next();!i.done;i=r.next()){var s=i.value;this._callEventListener(e,s)}}catch(e){t={error:e}}finally{try{i&&!i.done&&(n=r.return)&&n.call(r)}finally{if(t)throw t.error}}return!0},e.prototype.removeEventListener=function(e,t){this._listeners[e]&&(this._listeners[e]=this._listeners[e].filter(function(e){return e!==t}))},e.prototype._debug=function(){for(var e=[],t=0;t"],e))},e.prototype._getNextDelay=function(){var e=this._options,t=e.reconnectionDelayGrowFactor,n=void 0===t?c.reconnectionDelayGrowFactor:t,o=e.minReconnectionDelay,r=void 0===o?c.minReconnectionDelay:o,i=e.maxReconnectionDelay,s=void 0===i?c.maxReconnectionDelay:i,u=0;return this._retryCount>0&&(u=r*Math.pow(n,this._retryCount-1))>s&&(u=s),this._debug("next delay",u),u},e.prototype._wait=function(){var e=this;return new Promise(function(t){setTimeout(t,e._getNextDelay())})},e.prototype._getNextUrl=function(e){if("string"==typeof e)return Promise.resolve(e);if("function"==typeof e){var t=e();if("string"==typeof t)return Promise.resolve(t);if(t.then)return t}throw Error("Invalid URL")},e.prototype._connect=function(){var e=this;if(!this._connectLock&&this._shouldReconnect){this._connectLock=!0;var t=this._options,n=t.maxRetries,o=void 0===n?c.maxRetries:n,r=t.connectionTimeout,i=void 0===r?c.connectionTimeout:r,u=t.WebSocket,a=void 0===u?s():u;if(this._retryCount>=o)this._debug("max retries reached",this._retryCount,">=",o);else{if(this._retryCount++,this._debug("connect",this._retryCount),this._removeListeners(),void 0===(l=a)||!l||2!==l.CLOSING)throw Error("No valid WebSocket class provided");var l;this._wait().then(function(){return e._getNextUrl(e._url)}).then(function(t){e._closeCalled||(e._debug("connect",{url:t,protocols:e._protocols}),e._ws=e._protocols?new a(t,e._protocols):new a(t),e._ws.binaryType=e._binaryType,e._connectLock=!1,e._addListeners(),e._connectTimeout=setTimeout(function(){return e._handleTimeout()},i))})}}},e.prototype._handleTimeout=function(){this._debug("timeout event"),this._handleError(new r(Error("TIMEOUT"),this))},e.prototype._disconnect=function(e,t){if(void 0===e&&(e=1e3),this._clearTimeouts(),this._ws){this._removeListeners();try{this._ws.close(e,t),this._handleClose(new i(e,t,this))}catch(e){}}},e.prototype._acceptOpen=function(){this._debug("accept open"),this._retryCount=0},e.prototype._callEventListener=function(e,t){"handleEvent"in t?t.handleEvent(e):t(e)},e.prototype._removeListeners=function(){this._ws&&(this._debug("removeListeners"),this._ws.removeEventListener("open",this._handleOpen),this._ws.removeEventListener("close",this._handleClose),this._ws.removeEventListener("message",this._handleMessage),this._ws.removeEventListener("error",this._handleError))},e.prototype._addListeners=function(){this._ws&&(this._debug("addListeners"),this._ws.addEventListener("open",this._handleOpen),this._ws.addEventListener("close",this._handleClose),this._ws.addEventListener("message",this._handleMessage),this._ws.addEventListener("error",this._handleError))},e.prototype._clearTimeouts=function(){clearTimeout(this._connectTimeout),clearTimeout(this._uptimeTimeout)},e}()}(); \ No newline at end of file diff --git a/server/tomato/static/admin/tomato/configure_live_clients/deps/reconnecting-websocket.js b/server/tomato/static/admin/tomato/configure_live_clients/deps/reconnecting-websocket.js new file mode 120000 index 00000000..3317c58e --- /dev/null +++ b/server/tomato/static/admin/tomato/configure_live_clients/deps/reconnecting-websocket.js @@ -0,0 +1 @@ +reconnecting-websocket-iife-4.0.0.min.js \ No newline at end of file diff --git a/server/tomato/static/admin/tomato/configure_live_clients/deps/simple.css b/server/tomato/static/admin/tomato/configure_live_clients/deps/simple.css new file mode 120000 index 00000000..10d06b22 --- /dev/null +++ b/server/tomato/static/admin/tomato/configure_live_clients/deps/simple.css @@ -0,0 +1 @@ +simpledotcss-2.3.1.min.css \ No newline at end of file diff --git a/server/tomato/static/admin/tomato/configure_live_clients/simple-2.3.1.min.css b/server/tomato/static/admin/tomato/configure_live_clients/deps/simpledotcss-2.3.1.min.css similarity index 100% rename from server/tomato/static/admin/tomato/configure_live_clients/simple-2.3.1.min.css rename to server/tomato/static/admin/tomato/configure_live_clients/deps/simpledotcss-2.3.1.min.css diff --git a/server/tomato/templates/admin/extra/configure_live_clients.html b/server/tomato/templates/admin/extra/configure_live_clients.html index cce9ce30..84a6b704 100644 --- a/server/tomato/templates/admin/extra/configure_live_clients.html +++ b/server/tomato/templates/admin/extra/configure_live_clients.html @@ -1,21 +1,31 @@ {% extends 'admin/extra/base.html' %} +{% block extrahead %} + {{ block.super }} + + +{% endblock %} + {% block content %} -
- {% csrf_token %} -
-

Reload all connected desktop clients

-

- Current number of connected desktop client(s): {{ num_connected_users|default_if_none:"Unknown" }} - (Number not refreshed. Reload page to update.) -

- -
-

Click the button below to reload the plalists in all connected clients.

-
-
-
- -
-
+ {% endblock %} diff --git a/server/tomato/templates/admin/extra/configure_live_clients_iframe.html b/server/tomato/templates/admin/extra/configure_live_clients_iframe.html new file mode 100644 index 00000000..28612d63 --- /dev/null +++ b/server/tomato/templates/admin/extra/configure_live_clients_iframe.html @@ -0,0 +1,275 @@ + + + {% load static %} + + + + + Configure Live Client + + + + + + + + + {{ configure_live_clients_data|json_script:'tomato-configure-live-clients-data' }} + + + +
+

An error occurred:

+
+ Log is empty! + +
+

+ + +

+ +
+ +