lonoboard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lonoboard/__init__.py +3 -0
- lonoboard/__main__.py +3 -0
- lonoboard/board/__init__.py +2 -0
- lonoboard/board/hub.py +290 -0
- lonoboard/board/logo.py +109 -0
- lonoboard/board/model.py +48 -0
- lonoboard/board/render.py +335 -0
- lonoboard/board/screens.py +1080 -0
- lonoboard/board/session.py +655 -0
- lonoboard/board/telnet.py +154 -0
- lonoboard/cli.py +218 -0
- lonoboard/collector.py +400 -0
- lonoboard/config.py +181 -0
- lonoboard/demo.py +288 -0
- lonoboard/hardware.py +164 -0
- lonoboard/network.py +79 -0
- lonoboard/runtime.py +179 -0
- lonoboard/sources.py +330 -0
- lonoboard/static/fonts/LICENSE-CC-BY-SA-4.0.txt +428 -0
- lonoboard/static/fonts/PxPlus_IBM_VGA_8x16.ttf +0 -0
- lonoboard/static/fonts/PxPlus_IBM_VGA_9x16.ttf +0 -0
- lonoboard/static/terminal.html +297 -0
- lonoboard/usage.py +139 -0
- lonoboard/util.py +137 -0
- lonoboard/web.py +218 -0
- lonoboard-0.1.0.dist-info/METADATA +193 -0
- lonoboard-0.1.0.dist-info/RECORD +32 -0
- lonoboard-0.1.0.dist-info/WHEEL +5 -0
- lonoboard-0.1.0.dist-info/entry_points.txt +2 -0
- lonoboard-0.1.0.dist-info/licenses/LICENSE +29 -0
- lonoboard-0.1.0.dist-info/licenses/src/lonoboard/static/fonts/LICENSE-CC-BY-SA-4.0.txt +428 -0
- lonoboard-0.1.0.dist-info/top_level.txt +1 -0
lonoboard/__init__.py
ADDED
lonoboard/__main__.py
ADDED
lonoboard/board/hub.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Shared data for sessions.
|
|
2
|
+
|
|
3
|
+
A hub holds what every session on a board renders from: the latest snapshot, telemetry history,
|
|
4
|
+
recent requests, usage, the event log and fleet summaries. Hub is fed by this machine's
|
|
5
|
+
collector; RemoteHub by another machine's lonoboard API, so a session can view any board in the fleet
|
|
6
|
+
just by switching hubs.
|
|
7
|
+
"""
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
|
|
12
|
+
from lonoboard.board.model import expected, healthy, status
|
|
13
|
+
from lonoboard.board.render import SVC_COLORS, dur
|
|
14
|
+
from lonoboard.util import get_json, stream_chat
|
|
15
|
+
|
|
16
|
+
HISTORY_LEN = 450
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseHub:
|
|
20
|
+
remote = False
|
|
21
|
+
label = None
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.cond = threading.Condition()
|
|
25
|
+
self.sessions = set()
|
|
26
|
+
self.data = None
|
|
27
|
+
self.history = deque(maxlen=HISTORY_LEN)
|
|
28
|
+
self.callers, self.usage, self.fleet = [], None, {}
|
|
29
|
+
self.events = deque(maxlen=300)
|
|
30
|
+
self.colors = {}
|
|
31
|
+
self.busy_prev, self.busy_start = {}, {}
|
|
32
|
+
self.last_t = 0
|
|
33
|
+
self.wake = threading.Event()
|
|
34
|
+
self.force = False
|
|
35
|
+
self.started = time.time()
|
|
36
|
+
self.thread = None
|
|
37
|
+
self.error = None
|
|
38
|
+
self.auth = {} # Authorization header for password-protected peers
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def hist(self):
|
|
42
|
+
return list(self.history)
|
|
43
|
+
|
|
44
|
+
def color(self, name):
|
|
45
|
+
if name not in self.colors:
|
|
46
|
+
self.colors[name] = SVC_COLORS[len(self.colors) % len(SVC_COLORS)]
|
|
47
|
+
return self.colors[name]
|
|
48
|
+
|
|
49
|
+
def join(self, sess):
|
|
50
|
+
with self.cond:
|
|
51
|
+
self.sessions.add(sess)
|
|
52
|
+
self.force = True
|
|
53
|
+
self.wake.set()
|
|
54
|
+
self.ensure_running()
|
|
55
|
+
|
|
56
|
+
def leave(self, sess):
|
|
57
|
+
with self.cond:
|
|
58
|
+
self.sessions.discard(sess)
|
|
59
|
+
|
|
60
|
+
def notify(self):
|
|
61
|
+
with self.cond:
|
|
62
|
+
for s in self.sessions:
|
|
63
|
+
s.dirty.set()
|
|
64
|
+
|
|
65
|
+
def ensure_running(self):
|
|
66
|
+
if not self.thread or not self.thread.is_alive():
|
|
67
|
+
self.thread = threading.Thread(target=self.run, daemon=True, name=f"lonoboard-{type(self).__name__}")
|
|
68
|
+
self.thread.start()
|
|
69
|
+
|
|
70
|
+
def log(self, segs, t=None):
|
|
71
|
+
self.events.append((t or time.time(), segs))
|
|
72
|
+
|
|
73
|
+
def events_json(self):
|
|
74
|
+
return [[t, [list(s) for s in segs]] for t, segs in self.events]
|
|
75
|
+
|
|
76
|
+
# --- turning snapshots into events ---
|
|
77
|
+
def ingest(self, d, extend_history=True):
|
|
78
|
+
for s in d["services"]:
|
|
79
|
+
self.color(s["name"])
|
|
80
|
+
pts = d.pop("history", [])
|
|
81
|
+
if extend_history:
|
|
82
|
+
self.history.extend(pts)
|
|
83
|
+
for p in pts:
|
|
84
|
+
names = set(p.get("busy", {})) | set(self.busy_prev)
|
|
85
|
+
for n in sorted(names):
|
|
86
|
+
b, was = p.get("busy", {}).get(n, 0), self.busy_prev.get(n, 0)
|
|
87
|
+
if not was and b:
|
|
88
|
+
self.busy_start[n] = p["t"]
|
|
89
|
+
self.log([(n, self.color(n)), (f" started generating · {b} slot{'s' if b > 1 else ''} busy", 7)], p["t"])
|
|
90
|
+
elif was and not b:
|
|
91
|
+
self.log([(n, self.color(n)), (f' finished after {dur(p["t"] - self.busy_start.get(n, p["t"]))}', 7)], p["t"])
|
|
92
|
+
elif b > was:
|
|
93
|
+
self.log([(n, self.color(n)), (f" now {b} slots busy", 7)], p["t"])
|
|
94
|
+
self.busy_prev[n] = b
|
|
95
|
+
if pts:
|
|
96
|
+
self.last_t = pts[-1]["t"]
|
|
97
|
+
if self.data is None:
|
|
98
|
+
self.log([("monitoring started", 10), (f' on {d["host"]["board"]["name"]} · {len(healthy(d))} of {len(expected(d))} services healthy', 7)])
|
|
99
|
+
else:
|
|
100
|
+
self.diff(self.data, d)
|
|
101
|
+
self.data = d
|
|
102
|
+
|
|
103
|
+
def diff(self, a, b):
|
|
104
|
+
hist = self.hist
|
|
105
|
+
prev = {s["name"]: s for s in a["services"]}
|
|
106
|
+
bucket = lambda k: "ok" if k == "busy" else k
|
|
107
|
+
for s in b["services"]:
|
|
108
|
+
p = prev.get(s["name"])
|
|
109
|
+
n = (s["name"], self.color(s["name"]))
|
|
110
|
+
if not p:
|
|
111
|
+
self.log([("new service ", 7), n, (f' on :{s["port"]}', 7)])
|
|
112
|
+
continue
|
|
113
|
+
k0, k1 = status(p, hist), status(s, hist)
|
|
114
|
+
if bucket(k0[0]) != bucket(k1[0]):
|
|
115
|
+
if k1[0] in ("ok", "busy"):
|
|
116
|
+
self.log([n, (" is back online", 10)])
|
|
117
|
+
else:
|
|
118
|
+
self.log([n, (f' is {k1[1] or "down"}', 12)])
|
|
119
|
+
if (s["run"].get("restarts") or 0) > (p["run"].get("restarts") or 0):
|
|
120
|
+
self.log([n, (f' restarted (#{s["run"]["restarts"]})', 14)])
|
|
121
|
+
for name in set(prev) - {s["name"] for s in b["services"]}:
|
|
122
|
+
self.log([("service ", 7), (name, self.color(name)), (" went away", 12)])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Hub(BaseHub):
|
|
126
|
+
"""This machine's board, fed by the collector (or the demo)."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, collector):
|
|
129
|
+
super().__init__()
|
|
130
|
+
self.collector = collector
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def hist(self):
|
|
134
|
+
return list(self.collector.history)
|
|
135
|
+
|
|
136
|
+
def run(self):
|
|
137
|
+
due = {"snap": 0, "callers": 0, "usage": 0, "fleet": 0}
|
|
138
|
+
every = {"callers": 10, "usage": 60, "fleet": 15}
|
|
139
|
+
while True:
|
|
140
|
+
now = time.time()
|
|
141
|
+
watching = bool(self.sessions)
|
|
142
|
+
if self.force:
|
|
143
|
+
due = dict.fromkeys(due, 0)
|
|
144
|
+
self.force = False
|
|
145
|
+
snap_every = 2.5 if watching else 15
|
|
146
|
+
try:
|
|
147
|
+
if now >= due["snap"]:
|
|
148
|
+
self.ingest(self.collector.snapshot(since=self.last_t, max_age=1.0), extend_history=False)
|
|
149
|
+
due["snap"] = now + snap_every
|
|
150
|
+
if watching:
|
|
151
|
+
for k, fn in (("callers", self.load_callers), ("usage", self.load_usage), ("fleet", self.load_fleet)):
|
|
152
|
+
if now >= due[k]:
|
|
153
|
+
fn()
|
|
154
|
+
due[k] = now + every[k]
|
|
155
|
+
except Exception as e: # noqa: BLE001 - keep the board alive whatever a source does
|
|
156
|
+
self.log([("collector error: ", 12), (str(e)[:80], 7)])
|
|
157
|
+
self.notify()
|
|
158
|
+
self.wake.wait(max(0.2, min(due.values()) - time.time()) if watching else snap_every)
|
|
159
|
+
self.wake.clear()
|
|
160
|
+
|
|
161
|
+
def load_callers(self):
|
|
162
|
+
self.callers = self.collector.callers(30)
|
|
163
|
+
|
|
164
|
+
def load_usage(self):
|
|
165
|
+
self.usage = self.collector.usage_summary(24)
|
|
166
|
+
|
|
167
|
+
def load_fleet(self):
|
|
168
|
+
for p in (self.data or {}).get("fleet", []):
|
|
169
|
+
before = self.fleet.get(p["name"])
|
|
170
|
+
auth = self.auth if p.get("source") == "config" else {} # the password only goes to listed peers
|
|
171
|
+
summary = get_json(f"{p['url']}/api/summary", 3, auth)
|
|
172
|
+
self.fleet[p["name"]] = dict(summary, ok=True) if isinstance(summary, dict) else {"ok": False}
|
|
173
|
+
after = self.fleet[p["name"]]
|
|
174
|
+
if before is not None and before.get("ok") != after.get("ok"):
|
|
175
|
+
self.log([(p["name"], 15), (" is reachable", 10) if after.get("ok") else (" is unreachable", 12)])
|
|
176
|
+
|
|
177
|
+
def logs(self, name, n=30):
|
|
178
|
+
return self.collector.logs(name, n)
|
|
179
|
+
|
|
180
|
+
def chat_stream(self, service, model, messages, cancelled):
|
|
181
|
+
return self.collector.chat_stream(service, model, messages, cancelled)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class RemoteHub(BaseHub):
|
|
185
|
+
"""Another machine's board, read through its lonoboard API."""
|
|
186
|
+
remote = True
|
|
187
|
+
|
|
188
|
+
def __init__(self, url, name):
|
|
189
|
+
super().__init__()
|
|
190
|
+
self.url, self.label = url.rstrip("/"), name
|
|
191
|
+
|
|
192
|
+
def run(self):
|
|
193
|
+
due = {"snap": 0, "callers": 0, "usage": 0, "events": 0}
|
|
194
|
+
every = {"snap": 2.5, "callers": 10, "usage": 60, "events": 5}
|
|
195
|
+
idle_since = None
|
|
196
|
+
while True:
|
|
197
|
+
if not self.sessions: # nobody's looking: stop after a while
|
|
198
|
+
idle_since = idle_since or time.time()
|
|
199
|
+
if time.time() - idle_since > 60:
|
|
200
|
+
return
|
|
201
|
+
else:
|
|
202
|
+
idle_since = None
|
|
203
|
+
if self.force:
|
|
204
|
+
due = dict.fromkeys(due, 0)
|
|
205
|
+
self.force = False
|
|
206
|
+
now = time.time()
|
|
207
|
+
try:
|
|
208
|
+
if now >= due["snap"]:
|
|
209
|
+
d = get_json(f"{self.url}/api?since={self.last_t}", 5, self.auth)
|
|
210
|
+
self.error = None if d is not None else f"can't reach {self.label}"
|
|
211
|
+
if d is not None:
|
|
212
|
+
for s in d["services"]:
|
|
213
|
+
self.color(s["name"])
|
|
214
|
+
pts = d.pop("history", [])
|
|
215
|
+
self.history.extend(pts)
|
|
216
|
+
self.last_t = pts[-1]["t"] if pts else self.last_t
|
|
217
|
+
self.data = d
|
|
218
|
+
due["snap"] = now + every["snap"]
|
|
219
|
+
if now >= due["callers"]:
|
|
220
|
+
self.callers = (get_json(f"{self.url}/api/callers", 5, self.auth) or {}).get("callers") or self.callers
|
|
221
|
+
due["callers"] = now + every["callers"]
|
|
222
|
+
if now >= due["usage"]:
|
|
223
|
+
self.usage = get_json(f"{self.url}/api/usage", 5, self.auth) or self.usage
|
|
224
|
+
due["usage"] = now + every["usage"]
|
|
225
|
+
if now >= due["events"]:
|
|
226
|
+
ev = (get_json(f"{self.url}/api/events", 5, self.auth) or {}).get("events")
|
|
227
|
+
if ev is not None:
|
|
228
|
+
self.events = deque(((t, [tuple(s) for s in segs]) for t, segs in ev), maxlen=300)
|
|
229
|
+
due["events"] = now + every["events"]
|
|
230
|
+
except Exception as e: # noqa: BLE001
|
|
231
|
+
self.error = str(e)[:80]
|
|
232
|
+
self.notify()
|
|
233
|
+
self.wake.wait(max(0.2, min(due.values()) - time.time()))
|
|
234
|
+
self.wake.clear()
|
|
235
|
+
|
|
236
|
+
def logs(self, name, n=30):
|
|
237
|
+
return (get_json(f"{self.url}/api/logs/{name}?n={n}", 5, self.auth) or {}).get("lines") or []
|
|
238
|
+
|
|
239
|
+
def chat_stream(self, service, model, messages, cancelled):
|
|
240
|
+
sv = next((s for s in (self.data or {}).get("services", []) if s["name"] == service), None)
|
|
241
|
+
urls = [u for u in ((sv or {}).get("urls") or {}).get("remote", []) if u.startswith("http")]
|
|
242
|
+
if not sv or not sv.get("chat") or not urls:
|
|
243
|
+
return iter([("error", f"{service} on {self.label} isn't reachable for chat from here")])
|
|
244
|
+
return stream_chat(urls[0], model, messages, cancelled=cancelled)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Chat:
|
|
248
|
+
"""One conversation with one model, streamed through a hub."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, service, model):
|
|
251
|
+
self.service, self.model = service, model
|
|
252
|
+
self.msgs = [] # {'role', 'content', 'reasoning', 'stats', 'error', 't0', 'think_s'}
|
|
253
|
+
self.busy = False
|
|
254
|
+
self.cancel = False
|
|
255
|
+
self.scroll = 0 # lines up from the bottom
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def name(self):
|
|
259
|
+
return self.service if not self.model or self.model == self.service else f"{self.service} · {self.model}"
|
|
260
|
+
|
|
261
|
+
def send(self, text, sess):
|
|
262
|
+
self.msgs.append({"role": "user", "content": text})
|
|
263
|
+
reply = {"role": "assistant", "content": "", "reasoning": "", "stats": None, "error": None,
|
|
264
|
+
"t0": time.time(), "think_s": None}
|
|
265
|
+
self.msgs.append(reply)
|
|
266
|
+
self.busy, self.cancel, self.scroll = True, False, 0
|
|
267
|
+
threading.Thread(target=self._stream, args=(reply, sess.hub, sess), daemon=True).start()
|
|
268
|
+
|
|
269
|
+
def _stream(self, reply, hub, sess):
|
|
270
|
+
history = [{"role": m["role"], "content": m["content"]} for m in self.msgs[:-1] if m["content"]]
|
|
271
|
+
last_paint = 0
|
|
272
|
+
for kind, value in hub.chat_stream(self.service, self.model, history, lambda: self.cancel or not sess.alive):
|
|
273
|
+
if kind == "reasoning":
|
|
274
|
+
reply["reasoning"] += value
|
|
275
|
+
elif kind == "content":
|
|
276
|
+
if reply["think_s"] is None and reply["reasoning"]:
|
|
277
|
+
reply["think_s"] = time.time() - reply["t0"]
|
|
278
|
+
reply["content"] += value
|
|
279
|
+
elif kind == "stats":
|
|
280
|
+
reply["stats"] = value
|
|
281
|
+
elif kind == "error":
|
|
282
|
+
reply["error"] = value
|
|
283
|
+
break
|
|
284
|
+
if time.time() - last_paint > 0.06:
|
|
285
|
+
sess.dirty.set()
|
|
286
|
+
last_paint = time.time()
|
|
287
|
+
if reply["think_s"] is None and reply["reasoning"]:
|
|
288
|
+
reply["think_s"] = time.time() - reply["t0"]
|
|
289
|
+
self.busy = False
|
|
290
|
+
sess.dirty.set()
|
lonoboard/board/logo.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The board's logo: hand-drawn 8x10 pixel glyphs rendered as half-block cells, with
|
|
2
|
+
dithered shading and an outside-only drop shadow."""
|
|
3
|
+
|
|
4
|
+
# Logo: per-pixel ramps (used where a cell holds one lit pixel) and per-cell ramps with
|
|
5
|
+
# ░▒▓ shading (used where both pixels of a cell are lit) - the dithered blends ANSI
|
|
6
|
+
# artists used between colours, instead of hard bands.
|
|
7
|
+
PX_A = [15, 15, 11, 11, 11, 3, 3, 9, 9, 1]
|
|
8
|
+
CELL_A = [('█', 15, 0), ('▓', 11, 15), ('▒', 3, 11), ('▒', 9, 3), ('▓', 1, 9)]
|
|
9
|
+
PX_B = [15, 15, 14, 14, 14, 12, 12, 4, 4, 4]
|
|
10
|
+
CELL_B = [('█', 15, 0), ('▓', 14, 15), ('▒', 12, 14), ('▓', 4, 12), ('█', 4, 0)]
|
|
11
|
+
PX_D = [7, 7, 7, 7, 7, 7, 8, 8, 8, 8]
|
|
12
|
+
CELL_D = [('█', 7, 0), ('█', 7, 0), ('▓', 8, 7), ('█', 8, 0), ('█', 8, 0)]
|
|
13
|
+
SHADOW = 8
|
|
14
|
+
|
|
15
|
+
# 8x10 glyphs, two-pixel strokes; every hostname character gets one (lowercase is drawn as upper)
|
|
16
|
+
G = {
|
|
17
|
+
'A': ['.######.', '########', '##....##', '##....##', '########', '########', '##....##', '##....##', '##....##', '##....##'],
|
|
18
|
+
'B': ['#######.', '########', '##....##', '##....##', '#######.', '#######.', '##....##', '##....##', '########', '#######.'],
|
|
19
|
+
'C': ['.######.', '########', '##......', '##......', '##......', '##......', '##......', '##......', '########', '.######.'],
|
|
20
|
+
'D': ['######..', '#######.', '##...###', '##....##', '##....##', '##....##', '##....##', '##...###', '#######.', '######..'],
|
|
21
|
+
'E': ['########', '########', '##......', '##......', '######..', '######..', '##......', '##......', '########', '########'],
|
|
22
|
+
'F': ['########', '########', '##......', '##......', '######..', '######..', '##......', '##......', '##......', '##......'],
|
|
23
|
+
'G': ['.######.', '########', '##......', '##......', '##..####', '##..####', '##....##', '##....##', '########', '.######.'],
|
|
24
|
+
'H': ['##....##', '##....##', '##....##', '##....##', '########', '########', '##....##', '##....##', '##....##', '##....##'],
|
|
25
|
+
'I': ['######', '######', '..##..', '..##..', '..##..', '..##..', '..##..', '..##..', '######', '######'],
|
|
26
|
+
'J': ['......##', '......##', '......##', '......##', '......##', '......##', '##....##', '##....##', '########', '.######.'],
|
|
27
|
+
'K': ['##...###', '##..###.', '##.###..', '#####...', '####....', '#####...', '##.###..', '##..###.', '##...###', '##....##'],
|
|
28
|
+
'L': ['##......', '##......', '##......', '##......', '##......', '##......', '##......', '##......', '########', '########'],
|
|
29
|
+
'M': ['##....##', '###..###', '########', '##.##.##', '##.##.##', '##....##', '##....##', '##....##', '##....##', '##....##'],
|
|
30
|
+
'N': ['##....##', '###...##', '####..##', '##.##.##', '##..####', '##...###', '##....##', '##....##', '##....##', '##....##'],
|
|
31
|
+
'O': ['.######.', '########', '##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '########', '.######.'],
|
|
32
|
+
'P': ['#######.', '########', '##....##', '##....##', '########', '#######.', '##......', '##......', '##......', '##......'],
|
|
33
|
+
'Q': ['.######.', '########', '##....##', '##....##', '##....##', '##....##', '##..####', '##...###', '########', '.#######'],
|
|
34
|
+
'R': ['#######.', '########', '##....##', '##....##', '########', '#######.', '##..##..', '##...##.', '##....##', '##....##'],
|
|
35
|
+
'S': ['.######.', '########', '##......', '##......', '#######.', '.#######', '......##', '......##', '########', '.######.'],
|
|
36
|
+
'T': ['########', '########', '...##...', '...##...', '...##...', '...##...', '...##...', '...##...', '...##...', '...##...'],
|
|
37
|
+
'U': ['##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '##....##', '########', '.######.'],
|
|
38
|
+
'V': ['##....##', '##....##', '##....##', '##....##', '##....##', '###..###', '.##..##.', '.######.', '..####..', '...##...'],
|
|
39
|
+
'W': ['##....##', '##....##', '##....##', '##....##', '##.##.##', '##.##.##', '########', '########', '###..###', '##....##'],
|
|
40
|
+
'X': ['##....##', '###..###', '.######.', '..####..', '...##...', '...##...', '..####..', '.######.', '###..###', '##....##'],
|
|
41
|
+
'Y': ['##....##', '##....##', '###..###', '.######.', '..####..', '...##...', '...##...', '...##...', '...##...', '...##...'],
|
|
42
|
+
'Z': ['########', '########', '.....###', '....###.', '...###..', '..###...', '.###....', '###.....', '########', '########'],
|
|
43
|
+
'-': ['......', '......', '......', '......', '######', '######', '......', '......', '......', '......'],
|
|
44
|
+
'.': ['..', '..', '..', '..', '..', '..', '..', '..', '##', '##'],
|
|
45
|
+
'_': ['........', '........', '........', '........', '........', '........', '........', '........', '########', '########'],
|
|
46
|
+
'0': ['.######.', '########', '##...###', '##..####', '##.##.##', '####..##', '###...##', '##....##', '########', '.######.'],
|
|
47
|
+
'1': ['...##...', '.####...', '#####...', '...##...', '...##...', '...##...', '...##...', '...##...', '########', '########'],
|
|
48
|
+
'2': ['.######.', '########', '##....##', '......##', '.######.', '#######.', '##......', '##......', '########', '########'],
|
|
49
|
+
'3': ['#######.', '########', '......##', '......##', '.######.', '.#######', '......##', '......##', '########', '#######.'],
|
|
50
|
+
'4': ['##....##', '##....##', '##....##', '##....##', '########', '.#######', '......##', '......##', '......##', '......##'],
|
|
51
|
+
'5': ['########', '########', '##......', '##......', '#######.', '.#######', '......##', '......##', '########', '#######.'],
|
|
52
|
+
'6': ['.######.', '#######.', '##......', '##......', '#######.', '########', '##....##', '##....##', '########', '.######.'],
|
|
53
|
+
'7': ['########', '########', '......##', '.....##.', '....##..', '...##...', '...##...', '...##...', '...##...', '...##...'],
|
|
54
|
+
'8': ['.######.', '########', '##....##', '##....##', '.######.', '########', '##....##', '##....##', '########', '.######.'],
|
|
55
|
+
'9': ['.######.', '########', '##....##', '##....##', '########', '.#######', '......##', '......##', '.#######', '.######.'],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# ---------- the logo ----------
|
|
59
|
+
|
|
60
|
+
def logo(parts, gap=2):
|
|
61
|
+
"""Hand-drawn 8x10 glyphs -> pixel grid with an outside-only drop shadow."""
|
|
62
|
+
glyphs = [(G[ch], ramp) for text, ramp in parts for ch in text.upper()]
|
|
63
|
+
w = sum(len(g[0]) + gap for g, _ in glyphs) - gap + 1
|
|
64
|
+
h = 11
|
|
65
|
+
P = [[None] * w for _ in range(h + 1)]
|
|
66
|
+
x = 0
|
|
67
|
+
for g, ramp in glyphs:
|
|
68
|
+
for y, row in enumerate(g):
|
|
69
|
+
for i, c in enumerate(row):
|
|
70
|
+
if c == '#':
|
|
71
|
+
P[y][x + i] = ('L', ramp, y)
|
|
72
|
+
x += len(g[0]) + gap
|
|
73
|
+
# flood-fill the background from the edges so closed counters (P, A, R...) stay unshaded
|
|
74
|
+
outside = [[False] * w for _ in range(h + 1)]
|
|
75
|
+
stack = [(0, i) for i in range(w)] + [(h, i) for i in range(w)] + [(y, 0) for y in range(h + 1)] + [(y, w - 1) for y in range(h + 1)]
|
|
76
|
+
while stack:
|
|
77
|
+
y, i = stack.pop()
|
|
78
|
+
if 0 <= y <= h and 0 <= i < w and not outside[y][i] and P[y][i] is None:
|
|
79
|
+
outside[y][i] = True
|
|
80
|
+
stack += [(y + 1, i), (y - 1, i), (y, i + 1), (y, i - 1)]
|
|
81
|
+
for y in range(h):
|
|
82
|
+
for i in range(w - 1):
|
|
83
|
+
if P[y][i] and P[y][i][0] == 'L' and P[y + 1][i + 1] is None and outside[y + 1][i + 1]:
|
|
84
|
+
P[y + 1][i + 1] = ('S',)
|
|
85
|
+
return w, h + 1, P
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
RAMPS = {'A': (PX_A, CELL_A), 'B': (PX_B, CELL_B), 'D': (PX_D, CELL_D)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def draw_logo(s, x0, y0, px):
|
|
92
|
+
w, h, P = px
|
|
93
|
+
for cy in range(h // 2):
|
|
94
|
+
for cx in range(w):
|
|
95
|
+
t, b = P[cy * 2][cx], P[cy * 2 + 1][cx]
|
|
96
|
+
if t is None and b is None:
|
|
97
|
+
continue
|
|
98
|
+
if t and b and t[0] == 'L' and b[0] == 'L' and t[1] == b[1]:
|
|
99
|
+
ch, f, bg = RAMPS[t[1]][1][cy] # fully lit cell: dithered blend
|
|
100
|
+
s.put(x0 + cx, y0 + cy, ch, f, bg)
|
|
101
|
+
continue
|
|
102
|
+
# a lone lit pixel takes the dominant colour of its row's blend, so edges match the fill
|
|
103
|
+
col = lambda p: SHADOW if p[0] == 'S' else RAMPS[p[1]][1][p[2] // 2][1]
|
|
104
|
+
if t and b:
|
|
105
|
+
s.put(x0 + cx, y0 + cy, '▀', col(t), col(b))
|
|
106
|
+
elif t:
|
|
107
|
+
s.put(x0 + cx, y0 + cy, '▀', col(t))
|
|
108
|
+
else:
|
|
109
|
+
s.put(x0 + cx, y0 + cy, '▄', col(b))
|
lonoboard/board/model.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Rules over a snapshot: what state a service is in and which ones should be up."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def busy_of(sv, hist):
|
|
5
|
+
b = (sv.get("model") or {}).get("slots_busy")
|
|
6
|
+
if b is None and hist:
|
|
7
|
+
b = hist[-1].get("busy", {}).get(sv["name"], 0)
|
|
8
|
+
return b or 0
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def status(sv, hist):
|
|
12
|
+
"""(kind, text): kind is busy | ok | warn | starting | bad | off."""
|
|
13
|
+
run, health = sv.get("run") or {}, sv.get("health")
|
|
14
|
+
st = run.get("state")
|
|
15
|
+
if health == "ok":
|
|
16
|
+
return ("busy", None) if busy_of(sv, hist) > 0 else ("ok", None)
|
|
17
|
+
if st in ("activating", "reloading") or health == "loading":
|
|
18
|
+
return "starting", None
|
|
19
|
+
if st == "failed":
|
|
20
|
+
return "bad", "failed"
|
|
21
|
+
if st == "active":
|
|
22
|
+
return "warn", "not responding" if health in (None, "unreachable") else str(health).lower()
|
|
23
|
+
if not run.get("manager"): # something we only know by its URL
|
|
24
|
+
return "bad", "unreachable"
|
|
25
|
+
return ("bad", "down") if run.get("enabled") else ("off", None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def expected(d):
|
|
29
|
+
"""Services that should be running: enabled, active, or simply listed (no manager to ask)."""
|
|
30
|
+
return [s for s in d["services"]
|
|
31
|
+
if s["run"].get("enabled") or s["run"].get("state") == "active" or not s["run"].get("manager")]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def healthy(d):
|
|
35
|
+
return [s for s in expected(d) if s.get("health") == "ok"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def chat_capable(sv):
|
|
39
|
+
return bool((sv.get("chat") or {}).get("models"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def chat_targets(d):
|
|
43
|
+
"""[(service, model)] that can be chatted with."""
|
|
44
|
+
return [(s["name"], m) for s in d["services"] for m in (s.get("chat") or {}).get("models") or []]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def requests_today(d):
|
|
48
|
+
return sum((s.get("model") or {}).get("requests_today") or 0 for s in d["services"])
|