├── .editorconfig ├── .env.sample ├── .gitignore ├── LICENSE.md ├── README.md ├── app.py ├── assets ├── config.js ├── index.js └── main.css ├── demo.py ├── demo_aio.py ├── index.html └── requirements.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.go] 9 | indent_style = tab 10 | indent_size = 4 11 | 12 | [*.{js, html}] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # GLANCE_HOST sets the IPv4 that sanic will mount its socket to 2 | GLANCE_HOST=0.0.0.0 3 | 4 | # GLANCE_PORT sets the port that sanic will mount its socket to 5 | GLANCE_PORT=3000 6 | 7 | # GLANCE_BOTS is a semicolon-delimited list of cluster:shards pairings. Each cluster 8 | # defines the nicknamed cluster that bots are running on. 9 | # 10 | # In my usage, I would consider a cluster to be representative of the entire bot user; e.g. 11 | # dabBot, dabBot Demo, dabBot Patron 1, dabBot Patron 2 would be considered clusters. 12 | # 13 | # Each cluster should be followed by the total shards running on that cluster. 14 | # 15 | # Example: GLANCE_BOTS=main:360;demo:1;patron_1:6;patron_2:6 16 | GLANCE_BOTS=main:60 17 | 18 | # GLANCE_PASSPHRASE sets the passphrase that must be present on incoming status 19 | # messages. 20 | # 21 | # If you are running a public dashboard, you will probably want to set this value, or else 22 | # your dashboard could be griefed. 23 | GLANCE_PASSPHRASE= 24 | 25 | # GLANCE_HEALTH_TIMEOUT sets the interval (in seconds) allowed between health checks 26 | # before a shard will be marked as unhealthy. While 30 seconds is the default specified 27 | # here, you may want to adjust this value to `45` or higher, if your bot sends out health 28 | # checks in accordance with your bot's heartbeating task. 29 | # 30 | # You may need to adjust this based on realtime data on your dashboard. 31 | GLANCE_HEALTH_TIMEOUT=30 32 | 33 | # GLANCE_HEALTH_TIMER_INTERVAL sets the interval (in seconds) that the health timer 34 | # will be ran on. Every interval, the shards known to Glance will be iterated over 35 | # and checked for the last timestamp that a health check was received. 36 | # 37 | # If you find that Glance is causing unreasonable load on your server, you may want 38 | # to raise this value. 39 | GLANCE_HEALTH_TIMER_INTERVAL=15 40 | 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.dat 3 | 4 | # Created by https://www.gitignore.io/api/go 5 | 6 | ### Go ### 7 | # Binaries for programs and plugins 8 | *.exe 9 | *.exe~ 10 | *.dll 11 | *.so 12 | *.dylib 13 | 14 | # Test binary, build with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | 21 | # End of https://www.gitignore.io/api/go 22 | 23 | assets/config.js 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # ISC License (ISC) 2 | 3 | Copyright 2018-2019, foxbot 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # glance 2 | The at-a-glance Discord bot status dashboard. 3 | 4 | ## Features 5 | 6 | Glance is intended to be used by large, multi-process Discord bots, where keeping 7 | tabs on bot status becomes more complicated than just checking the user's status 8 | on Discord. 9 | 10 | The simple user-interface provided by Glance makes it a compelling status page to 11 | offer as part of your bot's public status page, allowing your end users to quickly 12 | check the bot's status in their guild. 13 | 14 | Shards are shown in a grid, with the status of the shard being indicated by universally 15 | understood traffic-light colors[1](https://pdfs.semanticscholar.org/738b/fe8606a556e0a1fe85686c5c20616a1013dd.pdf). 16 | 17 | A search bar is provided at the top of the page where a user may enter their Guild ID. 18 | Glance will then locate that guild in each bot cluster using Discord's standard guild 19 | selection algorithm, and highlight the shard in the list. This allows users to easily 20 | locate the status of the bot in a multi-sharded, multi-cluster configuration. 21 | 22 | ![Glance Screenshot](https://i.foxbot.me/9v5ijLF.png) 23 | 24 | ### Non-features 25 | 26 | Glance provides a very basic level of data-collection using push-based metrics. This 27 | allows the status page to determine the live status of the shard, and mark a shard as 28 | unhealthy when it has not been regularly publishing its status. 29 | 30 | The limited nature of Glance's data collection means that Glance is not a replacement 31 | for a more complete data-visualization package, such as Grafana. While Glance can be 32 | very helpful for your bot's support team, or even for your developers/ops team to reference, 33 | it does not replace the infinitely more powerful visualization and alerting that a 34 | time-series database paired with Grafana is capable of. 35 | 36 | ## Installation 37 | 38 | Glance requires the following: 39 | 40 | - Python 3.7 41 | - Optionally, an ASGI-compatible Web Server 42 | 43 | `$ pip -r requirements.txt` 44 | 45 | ### Configuration 46 | 47 | Copy the `.env.sample` file to `.env`, or, export the environment variables yourself. 48 | 49 | See [`.env.sample`](./.env.sample) for documentation on how to set configuration flags. Additionally, 50 | you may [configure the integrated server](https://sanic.readthedocs.io/en/latest/sanic/config.html#builtin-configuration-values) 51 | with environment variables. 52 | 53 | ### Deployment 54 | 55 | Glance is composed of a Python web-server and a single static-page site. The static-page, located 56 | at `./index.html` and `assets/` can be served directly, with minimal changes (see below). The API 57 | must be served through a WSGI-compatible web-server, or using the out-of-box integrated server. 58 | 59 | #### Interated Server 60 | 61 | `$ python3 app.py` 62 | 63 | Glance and its API will be accessible at the address specified in the configuration. 64 | 65 | #### WSGI/ASGI Server 66 | 67 | See [Sanic's documentation](https://sanic.readthedocs.io/en/latest/sanic/deploying.html#running-via-asgi) 68 | on this subject. 69 | 70 | Yes, WSGI through Gunicorn is supported. 71 | 72 | **Note:** you may need to configure the frontend differently when using an external server, 73 | as it will try to find the socket from a fixed location. The `noscript` link also assumes 74 | use of the integrated server, if that's an edge case you care to fix. 75 | 76 | ## Usage 77 | 78 | Bots wishing to push data to Glance must post data to the Glance API both when the shard 79 | state is updated, and at (around) the specified health interval. 80 | 81 | By default, the health interval is set to 30 seconds (i.e., the bot must contact Glance every 82 | 30 seconds, lest it be considered unhealthy). It may make more sense in your environment 83 | to configure this value as 45 seconds, and post a health check every heartbeat. 84 | 85 | You may also want to account for other data before sending a health check, such as whether 86 | or not the shard is receiving a normal amount of messages from the gateway. This might help 87 | you to identify if a shard is in a zombie state (i.e., your library has deadlocked, or has lost 88 | track of its connection state) and display a status update accordingly. 89 | 90 | Note: If a shard is marked as unhealthy, and Glance receives a status update for it, the shard 91 | will then be marked as ONLINE; not whichever state it was in prior to going unhealthy. This may 92 | be something to consider before sending health checks. 93 | 94 | ### Frontend Configuration 95 | 96 | You are free* to modify the header of `index.html` to include your branding (the intended location 97 | is where `foxbot.me` is located in `.header-author`, though you can place it anywhere). 98 | 99 | Class names and element IDs are how the site's pure-vanilla scripting works, so be aware that changing 100 | them will likely break the site. 101 | 102 | All colors and fonts used on the frontend may be adjusted via `assets/main.css`. I don't load any fonts 103 | from the internet by default, so you can work that out on your own. 104 | 105 | **If you are running Glance outside of the integrated server**, you will need to adjust the `getSocketUrl` 106 | method under `assets/index.js`. The frontend will not be able to connect to the API if you don't 107 | adjust this. 108 | 109 | *"free" as in "encouraged". This statement carries no implications on licensing; please see the 110 | `LICENSE` for licensing guarantees. 111 | 112 | ### API Documentation 113 | 114 | If Glance is configured with `GLANCE_PASSPHRASE`, requests to the following endpoints must have 115 | their `Authorization` header set accordingly. 116 | 117 | ##### Shard State Enumeration 118 | | Status Name | Value | 119 | | --- | --- | 120 | | Unknown | 0 | 121 | | Unhealthy | 1 | 122 | | Offline | 2 | 123 | | Online | 3 | 124 | | Starting | 4 | 125 | | Stopping | 5 | 126 | | Resuming | 6 | 127 | 128 | Note: shard states generally have no real meaning, and are generally only used to 129 | set the color of the shard on the Glance grid. 130 | 131 | The unknown, unhealthy, and online states are used internally by Glance. Shards are 132 | initialized with the unknown state, will toggle to unhealthy when the health check 133 | interval has been exceeded, and will toggle back to online if a health check is 134 | received for an unhealthy shard. 135 | 136 | #### `POST /api/status///` 137 | This endpoint sets the state for a given shard. 138 | 139 | ##### Parameters 140 | `cluster`: The name of the cluster this shard is on; this should match up with the cluster names in the configuration. 141 | 142 | `id`: The zero-indexed ID of this shard. 143 | 144 | `state`: The numeric Shard State of this shard; see above. 145 | 146 | #### `POST /api/health//` 147 | This endpoint marks a shard as healthy. 148 | 149 | ##### Parameters 150 | `cluster`: The name of the cluster this shard is on; this should match up with the cluster names in the configuration. 151 | 152 | `id`: The zero-indexed ID of this shard. 153 | 154 | ## License 155 | Glance is licensed under ISC. See the [LICENSE](./LICENSE.md), located at `LICENSE.md` 156 | 157 | ## Contributing 158 | 1. Please open an issue or contact me before opening a merge request. 159 | 2. Adhere to the standards set in the `.editorconfig`. 160 | 3. All changes should make the code better in some way. See http://suckless.org/philosophy/ 161 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | from dotenv import load_dotenv 4 | from sanic import Sanic 5 | from sanic.response import json, file, text 6 | from sanic.websocket import WebSocketProtocol 7 | 8 | import asyncio 9 | import logging 10 | import os 11 | import time 12 | import ujson 13 | 14 | 15 | class ShardState(IntEnum): 16 | UNKNOWN = 0 17 | UNHEALTHY = 1 18 | OFFLINE = 2 19 | ONLINE = 3 20 | STARTING = 4 21 | STOPPING = 5 22 | RESUMING = 6 23 | # TODO: are these good states, needs to check with discord.py 24 | 25 | # { 26 | # "bot": { 27 | # "len": 1, 28 | # "0": { 29 | # "state": 0 30 | # "last_healthy": 0 31 | # } 32 | # } 33 | # } 34 | State = dict() 35 | Feeds = list() 36 | app = Sanic() 37 | HealthTask = None 38 | health_timeout = None 39 | health_check_interval = None 40 | authorization = None 41 | 42 | app.static('/assets/', './assets/') 43 | 44 | def validate_shard_in_cluster(cluster: str, id: int): 45 | if cluster not in State: 46 | return json({ 47 | 'error': f'cluster \'{cluster}\' does not exist' 48 | }, status=400) 49 | 50 | if id < 0 or id > State[cluster]['len']-1: 51 | return json({ 52 | 'error': f'shard \'{id}\' falls outside the range of valid shards on cluster \'{cluster}\'' 53 | }, status=400) 54 | 55 | return None 56 | 57 | def require_auth(request): 58 | if authorization and request.headers['Authorization'] != authorization: 59 | return json({'error': 'this endpoint requires authorization'}, status=401) 60 | 61 | return None 62 | 63 | 64 | @app.route('/api/health//', methods=['POST']) 65 | async def health(request, cluster: str, id: int): 66 | auth = require_auth(request) 67 | if auth is not None: return auth 68 | 69 | valid = validate_shard_in_cluster(cluster, id) 70 | if valid is not None: return valid 71 | 72 | if str(id) not in State[cluster]: 73 | return json({ 74 | 'error': f'shard \'{id}\' is not known to the system yet, please populate your status' 75 | }, status=409) 76 | 77 | State[cluster][str(id)]['last_healthy'] = time.time() 78 | msg = { 79 | 'op': 'health_ping', 80 | 'payload': { 81 | 'cluster': cluster, 82 | 'shard': id 83 | } 84 | } 85 | 86 | if State[cluster][str(id)]['state'] == ShardState.UNHEALTHY: 87 | # assume shards won't health-check unless they are online :) 88 | State[cluster][str(id)]['state'] = ShardState.ONLINE 89 | msg['payload']['state'] = ShardState.ONLINE 90 | 91 | payload = ujson.dumps(msg) 92 | deliver(payload) 93 | return text('', status=204) 94 | 95 | 96 | @app.route('/api/status///', methods=['POST']) 97 | async def status(request, cluster: str, id: int, state: int): 98 | auth = require_auth(request) 99 | if auth is not None: return auth 100 | 101 | valid = validate_shard_in_cluster(cluster, id) 102 | if valid is not None: return valid 103 | 104 | State[cluster][str(id)] = { 105 | 'state': state, 106 | 'last_healthy': time.time() 107 | } 108 | payload = ujson.dumps({ 109 | 'op': 'state_update', 110 | 'payload': { 111 | 'cluster': cluster, 112 | 'shard': id, 113 | 'state': state 114 | } 115 | }) 116 | deliver(payload) 117 | return text('', status=204) 118 | 119 | 120 | @app.websocket('/api/feed') 121 | async def feed(request, ws): 122 | Feeds.append(ws) 123 | ws.connection_lost = lambda exc: Feeds.remove(ws) 124 | hello = { 125 | 'op': 'hello', 126 | 'payload': State 127 | } 128 | await ws.send(ujson.dumps(hello)) 129 | while True: 130 | # Keep the socket open indefinitely 131 | # TODO: is there a better solution to this 132 | await asyncio.sleep(15) 133 | 134 | 135 | @app.route('/') 136 | async def index(request): 137 | return await file('./index.html') 138 | 139 | 140 | @app.route('/plain') 141 | async def noscript(request): 142 | return text(str(State)) 143 | 144 | 145 | def deliver(payload): 146 | for client in Feeds: 147 | asyncio.ensure_future(client.send(payload)) 148 | 149 | 150 | @app.listener('after_server_start') 151 | async def hook_health(app, loop): 152 | asyncio.ensure_future(_health_loop()) 153 | logging.info('health loop running') 154 | 155 | 156 | async def _health_loop(): 157 | while True: 158 | logging.debug('running health checks') 159 | await _run_health_checks() 160 | logging.debug('health checks completed, sleeping') 161 | await asyncio.sleep(health_check_interval) 162 | 163 | async def _run_health_checks(): 164 | now = time.time() 165 | for (cluster, shards) in State.items(): 166 | for (id, shard) in shards.items(): 167 | if id == 'len': continue 168 | 169 | if shard['state'] == ShardState.UNHEALTHY: # this shard is already sick 170 | continue # TODO: alerting for continued sickness? 171 | 172 | if (now - shard['last_healthy']) > health_timeout: 173 | shard['state'] = ShardState.UNHEALTHY 174 | payload = ujson.dumps({ 175 | 'op': 'state_update', 176 | 'payload': { 177 | 'cluster': cluster, 178 | 'shard': id, 179 | 'state': shard['state'] 180 | } 181 | }) 182 | deliver(payload) 183 | 184 | 185 | def configure_state(): 186 | # GLANCE_BOTS=main:100;patron_1:6;patron_2:6 187 | bots_raw = os.getenv('GLANCE_BOTS') 188 | for bot in bots_raw.split(';'): 189 | (cluster, shards) = bot.split(':') 190 | shards = int(shards) 191 | State[cluster] = dict() 192 | State[cluster]['len'] = shards 193 | 194 | 195 | if __name__ == '__main__': 196 | load_dotenv() 197 | configure_state() 198 | health_timeout = int(os.getenv('GLANCE_HEALTH_TIMEOUT') or '30') 199 | health_check_interval = int(os.getenv('GLANCE_HEALTH_TIMER_INTERVAL') or '15') 200 | authorization = os.getenv('GLANCE_PASSPHRASE') 201 | 202 | app.run(host=os.getenv('GLANCE_HOST') or '0.0.0.0', 203 | port=int(os.getenv('GLANCE_PORT') or '3000') 204 | ) 205 | -------------------------------------------------------------------------------- /assets/config.js: -------------------------------------------------------------------------------- 1 | // modify this object for git-proof frontend config as needed 2 | // uncomment commented lines as needed 3 | 4 | let conf = { 5 | //'url': '' // custom url override 6 | //'branding': '' // custom branding 7 | }; 8 | 9 | //window.config = conf; 10 | -------------------------------------------------------------------------------- /assets/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * glance2 big brains 3 | * 4 | * this code uses: 5 | * - absolutely no jQuery (yes it's possible o.o) 6 | */ 7 | 8 | /** --- BEGIN CONFIGURATION --- */ 9 | 10 | /** 11 | * getSocketUrl is a factory that should be extended to locate your 12 | * glance API server. 13 | * 14 | * If you are running glance out-of-box, the frontend will be served from the 15 | * same host as the API, so this will work fine. You also won't have to worry 16 | * about ws-on-https security breakage. 17 | * 18 | * Otherwise, this will need to be changed to reflect your API server. 19 | */ 20 | function getSocketUrl() { 21 | if (window.config && 'url' in window.config) { 22 | return window.config.url; 23 | } 24 | 25 | let protocol = (window.location.protocol === 'https:') ? 'wss://' : 'ws://'; 26 | return protocol + window.location.host + '/api/feed'; 27 | } 28 | 29 | /** --- END CONFIGURATION --- */ 30 | 31 | let version = "glance2-f.1.3 // by foxbot"; 32 | 33 | /** @type Element */ 34 | let dataConnectionStatus; 35 | /** @type Element */ 36 | let appMount; 37 | /** @type Element */ 38 | let appClusterMount; 39 | /** @type Element */ 40 | let loadingMount; 41 | /** @type Element */ 42 | let settingsMount; 43 | /** @type Element */ 44 | let guildFinderMount; 45 | 46 | /** @type WebSocket */ 47 | let feed; 48 | 49 | let state; 50 | let pulseHealth = true; 51 | 52 | function init() { 53 | console.log('glance// initializing.'); 54 | dataConnectionStatus = document.querySelector('#data-connection-status'); 55 | dataConnectionStatus.textContent = '(js loaded)'; 56 | 57 | if (window.config && 'branding' in window.config) { 58 | document.querySelector('#header-brand').innerHTML = window.config.branding; 59 | } 60 | 61 | appMount = document.querySelector('#app'); 62 | appClusterMount = document.querySelector('#app-cluster') 63 | loadingMount = document.querySelector('#loading'); 64 | settingsMount = document.querySelector('#settings'); 65 | 66 | feed = new WebSocket(getSocketUrl()); 67 | 68 | feed.addEventListener('open', feedOpen); 69 | feed.addEventListener('close', feedClose); 70 | feed.addEventListener('message', feedMessage); 71 | feed.addEventListener('error', feedError); 72 | 73 | guildFinderMount = document.querySelector('#header-guild'); 74 | guildFinderMount.addEventListener('input', event => { 75 | findGuilds(); 76 | }); 77 | 78 | document.querySelector('#settings-open').addEventListener('click', event => { 79 | settingsMount.classList.remove('hide'); 80 | }); 81 | document.querySelector('#settings-close').addEventListener('click', event => { 82 | settingsMount.classList.add('hide'); 83 | }); 84 | 85 | if (!localStorage.getItem('pulse_health')) { 86 | localStorage.setItem('pulse_health', true); 87 | pulseHealth = true; 88 | } else { 89 | pulseHealth = localStorage.getItem('pulse_health') === 'true'; 90 | localStorage.setItem('pulse-health', pulseHealth); 91 | } 92 | const settingsPulseHealth = document.querySelector('#settings-pulse-health'); 93 | settingsPulseHealth.setAttribute('data-checked', pulseHealth); 94 | settingsPulseHealth.addEventListener('click', event => { 95 | pulseHealth = !pulseHealth; 96 | settingsPulseHealth.setAttribute('data-checked', pulseHealth); 97 | }); 98 | 99 | document.querySelector('#settings-glance-version').innerText = version; 100 | } 101 | 102 | function feedOpen(_) { 103 | dataConnectionStatus.textContent = 'connected'; 104 | 105 | loadingMount.classList.add('hide'); 106 | appMount.classList.remove('hide'); 107 | guildFinderMount.classList.remove('hide'); 108 | } 109 | function feedClose(_) { 110 | dataConnectionStatus.textContent = 'disconnected, reload.'; 111 | 112 | appMount.classList.add('hide'); 113 | guildFinderMount.classList.add('hide'); 114 | loadingMount.classList.remove('hide'); 115 | 116 | // TODO: reconnection logic 117 | } 118 | function feedError(error) { 119 | dataConnectionStatus.textContent = 'socket error, reload or see console.'; 120 | console.error(error) 121 | } 122 | /** @param {MessageEvent} event */ 123 | function feedMessage(event) { 124 | const data = JSON.parse(event.data); 125 | if (!'op' in data) { 126 | console.error('event arrived without opcode'); 127 | return; 128 | } 129 | switch (data.op) { 130 | case 'hello': { 131 | hello(data.payload); 132 | break; 133 | } 134 | case 'state_update': { 135 | update(data.payload); 136 | break; 137 | } 138 | case 'health_ping': { 139 | health(data.payload); 140 | break; 141 | } 142 | default: { 143 | console.error('unhandled opcode: \'' + data.op + '\''); 144 | } 145 | } 146 | } 147 | 148 | function hello(data) { 149 | reset(); 150 | state = {} 151 | 152 | // initial render 153 | for (let cluster in data) { 154 | state[cluster] = {}; 155 | 156 | const clusterEl = document.createElement('div'); 157 | clusterEl.classList.add('cluster'); 158 | 159 | const clusterTitle = document.createElement('p'); 160 | clusterTitle.textContent = cluster; 161 | clusterTitle.classList.add('cluster-title'); 162 | 163 | const clusterContainer = document.createElement('div'); 164 | clusterContainer.classList.add('cluster-container'); 165 | 166 | clusterEl.appendChild(clusterTitle); 167 | clusterEl.appendChild(clusterContainer); 168 | 169 | for (let i = 0; i < data[cluster]['len']; i++) { 170 | const shard = document.createElement('div'); 171 | shard.classList.add('shard'); 172 | shard.setAttribute('data-status', '0'); 173 | 174 | if (i.toString() in data[cluster]) { 175 | //console.log(data[cluster][i.toString()]); 176 | shard.setAttribute('data-status', data[cluster][i.toString()]['state']); 177 | } 178 | 179 | const shardId = document.createElement('p'); 180 | shardId.classList.add('shard-id'); 181 | shardId.textContent = i.toString(); 182 | 183 | shard.appendChild(shardId); 184 | 185 | clusterContainer.appendChild(shard); 186 | state[cluster][i.toString()] = shard; 187 | } 188 | 189 | appClusterMount.appendChild(clusterEl); 190 | state[cluster]['el'] = clusterEl; 191 | } 192 | } 193 | 194 | function update(data) { 195 | if (!data.cluster in state) { 196 | console.error('invalid data received for current state (cluster not found)', data); 197 | return; 198 | } 199 | if (!data.shard in state[data.cluster]) { 200 | console.error('invalid data received for current state (shard not found)', data); 201 | return; 202 | } 203 | state[data.cluster][data.shard].setAttribute('data-status', data.state.toString()); 204 | } 205 | 206 | function health(data) { 207 | if (!data.cluster in state) { 208 | console.error('invalid data received for current state (cluster not found)', data); 209 | return; 210 | } 211 | if (!data.shard in state[data.cluster]) { 212 | console.error('invalid data received for current state (shard not found)', data); 213 | return; 214 | } 215 | 216 | let el = state[data.cluster][data.shard]; 217 | 218 | if ('state' in data) { 219 | el.setAttribute('data-status', data.state.toString()); 220 | } 221 | 222 | if (!pulseHealth) { 223 | return; 224 | } 225 | 226 | el.classList.add('health-ping'); 227 | 228 | setTimeout(function() { 229 | el.classList.remove('health-ping'); 230 | }, 2500); 231 | } 232 | 233 | function reset() { 234 | while (appClusterMount.lastChild) { 235 | appMount.removeChild(appMount.lastChild); 236 | } 237 | } 238 | 239 | const RightShift = 4194304; 240 | let taggedGuilds = []; 241 | function findGuilds() { 242 | for (let tagged of taggedGuilds) { 243 | if (tagged.classList.contains('guild-tagged')) { 244 | tagged.classList.remove('guild-tagged'); 245 | } 246 | } 247 | taggedGuilds = []; 248 | 249 | let id = +guildFinderMount.value; 250 | if (id === 0) { 251 | return; 252 | } 253 | 254 | let discrim = Math.floor(id / RightShift); 255 | for (let cluster in state) { 256 | let shards = Object.keys(state[cluster]).length - 1; 257 | let shard = discrim % shards; 258 | let el = state[cluster][shard]; 259 | el.classList.add('guild-tagged'); 260 | taggedGuilds.push(el); 261 | } 262 | } 263 | 264 | // --- script all loaded 265 | init(); 266 | -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | /* yeah this would be better with SASS */ 2 | 3 | :root { 4 | --font: 'Hack'; 5 | font-family: var(--font); 6 | 7 | --bg: hsl(200, 80%, 5%); 8 | --header-bg: hsl(200, 100%, 5%); 9 | --header-accent: hsl(200, 25%, 10%); 10 | 11 | --status-unknown: hsl(46, 22%, 15%); 12 | --status-unhealthy: hsl(66, 47%, 31%); 13 | --status-offline: hsl(359, 83%, 24%); 14 | --status-online: hsl(124, 73%, 31%); 15 | --status-starting: hsl(175, 73%, 31%); 16 | --status-stopping: hsl(328, 79%, 34%); 17 | --status-resuming: hsl(217, 100%, 54%); 18 | 19 | --fg: #DDD; 20 | --fg-text-shadow-color: #FFFFFF99; 21 | --cluster-container-border: #AAA; 22 | --shard-hover-border: grey; 23 | --shard-text-color: #FFF; 24 | 25 | --guild-tag-color: red; 26 | --health-pulse-color: rgb(188, 188, 56); 27 | 28 | --checkbox-unchecked-color: #999; 29 | --checkbox-checked-color: #FFF; 30 | --version-color: #888; 31 | 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | body { 37 | background-color: var(--bg); 38 | color: var(--fg); 39 | 40 | margin: 0; 41 | padding: 0; 42 | } 43 | 44 | .hide { 45 | display: none !important; /* !important is bad but we need to overrule the flexboxes */ 46 | } 47 | 48 | .header { 49 | background-color: var(--header-bg); 50 | 51 | height: 22px; 52 | border-bottom: 1px solid var(--header-accent); 53 | box-shadow: 0px -5px 5px var(--fg); 54 | 55 | padding: 0; 56 | margin: 0; 57 | 58 | display: flex; 59 | flex-direction: row; 60 | flex-wrap: nowrap; 61 | align-items: center; 62 | } 63 | .header-title { 64 | padding: 0; 65 | margin: 0; 66 | 67 | flex: 1; 68 | } 69 | #header-guild { 70 | padding: 0; 71 | margin: 0; 72 | 73 | background-color: var(--bg); 74 | color: var(--fg); 75 | border: 1px solid var(--header-accent); 76 | text-align: center; 77 | font-family: var(--font); 78 | width: auto; 79 | min-width: 40px; 80 | 81 | flex: 1; 82 | } 83 | .header-author { 84 | padding: 0; 85 | margin: 0 8 0 0; 86 | 87 | flex: 1; 88 | text-align: right; 89 | } 90 | .header-author > a { 91 | color: var(--fg); 92 | } 93 | 94 | #loading { 95 | display:flex; 96 | flex-direction: column; 97 | align-items: center; 98 | } 99 | .loading-title { 100 | margin: 2vh 0 0 0; 101 | } 102 | .loading-subtitle { 103 | margin: 0; 104 | } 105 | 106 | .cluster-title::before { 107 | content: 'cluster: '; 108 | } 109 | .cluster-title { 110 | margin: 5px 5px 3px 5px; 111 | font-weight: bold; 112 | text-shadow: var(--fg-text-shadow-color) 0px 0px 4px; 113 | } 114 | .cluster-key-title { 115 | text-shadow: var(--fg-text-shadow-color) 0px 0px 4px; 116 | } 117 | .cluster-container { 118 | display: flex; 119 | flex-wrap: wrap; 120 | 121 | margin: 0px 5px; 122 | border: 0.1px solid var(--cluster-container-border); 123 | } 124 | 125 | .shard { 126 | box-sizing: border-box; 127 | flex-grow: 1; 128 | 129 | width: 58px; 130 | height: 40px; 131 | } 132 | .shard:hover { 133 | border: 2px solid var(--shard-hover-border); 134 | } 135 | .shard-id { 136 | margin: auto; 137 | font-size: 12px; 138 | } 139 | i.shard { 140 | border: none; 141 | margin: 0 5px 0 0; 142 | padding: 0 8px; 143 | } 144 | 145 | .shard[data-status="0"] { 146 | background-color: var(--status-unknown); 147 | color: var(--shard-text-color); 148 | } 149 | .shard[data-status="1"] { 150 | background-color: var(--status-unhealthy); 151 | color: var(--shard-text-color); 152 | } 153 | .shard[data-status="2"] { 154 | background-color: var(--status-offline); 155 | color: var(--shard-text-color); 156 | } 157 | .shard[data-status="3"] { 158 | background-color: var(--status-online); 159 | color: var(--shard-text-color); 160 | } 161 | .shard[data-status="4"] { 162 | background-color: var(--status-starting); 163 | color: var(--shard-text-color); 164 | } 165 | .shard[data-status="5"] { 166 | background-color: var(--status-stopping); 167 | color: var(--shard-text-color); 168 | } 169 | .shard[data-status="6"] { 170 | background-color: var(--status-resuming); 171 | color: var(--shard-text-color); 172 | } 173 | 174 | .health-ping { 175 | animation-name: pulse; 176 | animation-duration: 2500ms; 177 | animation-iteration-count: 1; 178 | z-index: 1; 179 | } 180 | .guild-tagged { 181 | border: 5px solid var(--guild-tag-color); 182 | } 183 | 184 | @keyframes pulse { 185 | 0% { 186 | box-shadow: 0px 0px 0px 0px var(--health-pulse-color); 187 | } 188 | 50% { 189 | box-shadow: 0px 0px 4px 4px var(--health-pulse-color); 190 | } 191 | 100% { 192 | box-shadow: 0px 0px 0px 0px var(--health-pulse-color); 193 | } 194 | } 195 | 196 | 197 | #settings { 198 | display: block; 199 | position: fixed; 200 | z-index: 1; 201 | left: 0; 202 | top: 0; 203 | width: 100%; 204 | height: 100%; 205 | overflow: auto; 206 | background-color: rgba(0,0,0,0.4); 207 | } 208 | .settings-container { 209 | background-color: var(--bg); 210 | margin: 15% auto; 211 | padding: 5px; 212 | border: 1px solid var(--header-accent); 213 | width: 40%; 214 | } 215 | .settings-title { 216 | margin: 0 0 10px 0; 217 | } 218 | .settings-link { 219 | text-decoration: dotted underline; 220 | cursor: pointer; 221 | } 222 | .settings-checkbox[data-checked="false"]::before { 223 | content: "[ ]"; 224 | color: var(--checkbox-unchecked-color); 225 | cursor: pointer; 226 | } 227 | .settings-checkbox[data-checked="true"]::before { 228 | content: "[X]"; 229 | color: var(--checkbox-checked-color); 230 | text-shadow: var(--checkbox-checked-color) 0px 0px 5px; 231 | cursor: pointer; 232 | } 233 | #settings-glance-version { 234 | margin: 2px 2px 0px 2px; 235 | font-weight: lighter; 236 | color: var(--version-color); 237 | } 238 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | from requests_threads import AsyncSession 2 | 3 | session = AsyncSession(n=40) 4 | 5 | 6 | BASE = 'http://localhost:3050' 7 | SHARDS = 112 8 | BOT = 'main' 9 | 10 | async def _main(): 11 | # populate online initially 12 | for x in range(SHARDS): 13 | session.post(f'{BASE}/api/status/{BOT}/{x}/2') 14 | 15 | session.run(_main) 16 | 17 | 18 | print('ok') 19 | -------------------------------------------------------------------------------- /demo_aio.py: -------------------------------------------------------------------------------- 1 | from aiohttp import ClientSession 2 | import asyncio 3 | import random 4 | 5 | base = 'http://localhost:3050' 6 | limit = 260 7 | shards = 280 8 | 9 | async def bound_post(sem, url, sesion): 10 | async with sem: 11 | headers = {'Authorization': 'bigdog'} 12 | await session.post(url, headers=headers) 13 | 14 | async def run_init(session): 15 | tasks = [] 16 | sem = asyncio.Semaphore(limit) 17 | for i in range(shards): 18 | url = f'{base}/api/status/main/{i}/3' 19 | task = asyncio.ensure_future(bound_post(sem, url, session)) 20 | tasks.append(task) 21 | await asyncio.gather(*tasks) 22 | 23 | async def run_healthy(session): 24 | healthy = list(range(shards)) 25 | sem = asyncio.Semaphore(limit) 26 | while True: 27 | if len(healthy) > 90: 28 | healthy.remove(random.choice(healthy)) 29 | random.shuffle(healthy) 30 | tasks = [] 31 | 32 | for s in healthy: 33 | url = f'{base}/api/health/main/{s}' 34 | task = asyncio.ensure_future(bound_post(sem, url, session)) 35 | await asyncio.sleep(random.random() / 4) 36 | tasks.append(task) 37 | await asyncio.gather(*tasks) 38 | 39 | async def run_demo(): 40 | while True: 41 | shard = random.randint(0, shards-1) 42 | state = random.randint(0, 6) 43 | url = f'{base}/api/status/main/{shard}/{state}' 44 | del url 45 | 46 | async def main(session): 47 | await run_init(session) 48 | await run_healthy(session) 49 | 50 | loop = asyncio.get_event_loop() 51 | with ClientSession() as session: 52 | loop.run_until_complete(main(session)) 53 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | glance2 4 | 5 | 6 | 7 | 8 | 13 |
14 |

glance2 | (js unloaded)

15 | 16 |

17 | settings | 18 | foxbot.me | 19 | source 20 |

21 |
22 |
23 |
24 |

color key: 25 | Unknown 26 | Unhealthy 27 | Offline 28 | Online 29 | Starting 30 | Stopping 31 | Resuming 32 |

33 |
34 |
35 |
36 |
37 |

loading

38 |

(awaiting feed connection)

39 |
40 |
41 |
42 |

display settings

43 | 44 |
48 | 49 |
50 | okay 51 | 52 |
53 |
54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sanic 2 | python-dotenv 3 | --------------------------------------------------------------------------------