web view
This commit is contained in:
1 parent
bbe01f3eae
commit
7f2ed59798
19 files changed
+757
-43
No files matched your search
+1
-1
@@ -14,6 +14,6 @@ RUN chmod +x colonyctl.py && ln -s /app/colonyctl.py /usr/local/bin/colonyctl
|
|||||||
|
|
||||||
VOLUME ["/app/storage", "/app/logs"]
|
VOLUME ["/app/storage", "/app/logs"]
|
||||||
|
|
||||||
EXPOSE 65334/tcp 28149/udp
|
EXPOSE 65334/tcp 28149/udp 8080/tcp
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
+38
-16
@@ -16,15 +16,6 @@ CLEAR_SCREEN = "\033[2J"
|
|||||||
CLEAR_LINE = "\033[K"
|
CLEAR_LINE = "\033[K"
|
||||||
CLEAR_BELOW = "\033[J"
|
CLEAR_BELOW = "\033[J"
|
||||||
|
|
||||||
SCORE_COLUMNS = (
|
|
||||||
("cpu", "CPU"),
|
|
||||||
("memory", "MEM GB"),
|
|
||||||
("gpu", "GPU GB"),
|
|
||||||
("disk", "DISK GB"),
|
|
||||||
("longevity", "UP H"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_cluster(host, port, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
|
def fetch_cluster(host, port, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
|
||||||
payload = CommandRequest.form(Unit.COMMANDS.SHOW_CLUSTER).collapse()
|
payload = CommandRequest.form(Unit.COMMANDS.SHOW_CLUSTER).collapse()
|
||||||
resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout)
|
resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout)
|
||||||
@@ -50,8 +41,42 @@ def table(headers, rows):
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def format_score(value):
|
def format_size(gb):
|
||||||
return "-" if value is None else f"{value:.1f}"
|
if gb >= 1000:
|
||||||
|
return f"{gb / 1024:.1f} TB"
|
||||||
|
if gb >= 100:
|
||||||
|
return f"{gb:.0f} GB"
|
||||||
|
return f"{gb:.1f} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def format_uptime(hours):
|
||||||
|
if hours < 1:
|
||||||
|
return f"{round(hours * 60)}m"
|
||||||
|
if hours < 48:
|
||||||
|
return f"{round(hours)}h"
|
||||||
|
return f"{int(hours // 24)}d {round(hours % 24)}h"
|
||||||
|
|
||||||
|
|
||||||
|
def describe_stats(stats):
|
||||||
|
stats = stats or {}
|
||||||
|
|
||||||
|
def usage(used_key, total_key):
|
||||||
|
if used_key in stats and total_key in stats:
|
||||||
|
return f"{format_size(stats[used_key])} / {format_size(stats[total_key])}"
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
cpu = "-"
|
||||||
|
if "cpu_percent" in stats:
|
||||||
|
cpu = f"{stats['cpu_percent']:.0f}%"
|
||||||
|
if stats.get("cpu_cores") and stats.get("cpu_threads"):
|
||||||
|
cpu += f" · {stats['cpu_cores']}C/{stats['cpu_threads']}T"
|
||||||
|
|
||||||
|
gpu = usage("gpu_mem_used_gb", "gpu_mem_total_gb")
|
||||||
|
if gpu != "-" and stats.get("gpu_count", 1) > 1:
|
||||||
|
gpu = f"{stats['gpu_count']}× {gpu}"
|
||||||
|
|
||||||
|
uptime = format_uptime(stats["uptime_hours"]) if "uptime_hours" in stats else "-"
|
||||||
|
return [cpu, usage("mem_used_gb", "mem_total_gb"), usage("disk_used_gb", "disk_total_gb"), gpu, uptime]
|
||||||
|
|
||||||
|
|
||||||
def address(member):
|
def address(member):
|
||||||
@@ -126,16 +151,13 @@ def render_status(payload):
|
|||||||
names = ", ".join(labels[holder] for holder in holders) or "-"
|
names = ", ".join(labels[holder] for holder in holders) or "-"
|
||||||
role_rows.append([role["name"], role["priority"], state, names])
|
role_rows.append([role["name"], role["priority"], state, names])
|
||||||
|
|
||||||
score_rows = [
|
hardware_rows = [[labels[address(member)]] + describe_stats(member.get("stats")) for member in alive]
|
||||||
[labels[address(member)]] + [format_score(member["scores"].get(key)) for key, _ in SCORE_COLUMNS]
|
|
||||||
for member in alive
|
|
||||||
]
|
|
||||||
|
|
||||||
sections = [
|
sections = [
|
||||||
title,
|
title,
|
||||||
table(node_headers, node_rows),
|
table(node_headers, node_rows),
|
||||||
table(["ROLE", "PRIORITY", "STATE", "HOLDERS"], role_rows),
|
table(["ROLE", "PRIORITY", "STATE", "HOLDERS"], role_rows),
|
||||||
table(["NODE"] + [header for _, header in SCORE_COLUMNS], score_rows),
|
table(["NODE", "CPU", "RAM", "DISK", "GPU", "UP"], hardware_rows),
|
||||||
]
|
]
|
||||||
return "\n\n".join(sections)
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from colony.core.network.hardware import HardwareStatistics, HardwareScorer
|
from colony.core.network.hardware import HardwareStatistics, HardwareScorer
|
||||||
|
|
||||||
|
|
||||||
def collect_scores():
|
def collect_hardware():
|
||||||
try:
|
try:
|
||||||
return HardwareScorer.from_object(HardwareStatistics.collect())
|
hardware = HardwareStatistics.collect()
|
||||||
|
return HardwareScorer.from_object(hardware), hardware.summary()
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return {}, {}
|
||||||
@@ -40,10 +40,10 @@ def hardware_score(record, role_stat):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def pick_candidate(role_name, role_stat, records):
|
def pick_candidate(role_name, role_stat, records, exclude=()):
|
||||||
candidates = [
|
candidates = [
|
||||||
record for record in records
|
record for record in records
|
||||||
if role_name not in record.roles and role_name not in record.declined
|
if role_name not in record.roles and role_name not in record.declined and record.key not in exclude
|
||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -38,6 +38,34 @@ class HardwareStatistics:
|
|||||||
"boot_time": self.boot_time
|
"boot_time": self.boot_time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def summary(self):
|
||||||
|
gib = 1024 ** 3
|
||||||
|
cpu, mem = self.cpu or {}, self.mem or {}
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
if isinstance(cpu.get("usage_percent"), (int, float)):
|
||||||
|
stats["cpu_percent"] = round(cpu["usage_percent"], 1)
|
||||||
|
if cpu.get("cores"):
|
||||||
|
stats["cpu_cores"] = cpu["cores"]
|
||||||
|
if cpu.get("threads"):
|
||||||
|
stats["cpu_threads"] = cpu["threads"]
|
||||||
|
|
||||||
|
if mem.get("total"):
|
||||||
|
stats["mem_total_gb"] = round(mem["total"] / gib, 2)
|
||||||
|
stats["mem_used_gb"] = round((mem["total"] - mem.get("available", 0)) / gib, 2)
|
||||||
|
|
||||||
|
if self.disk:
|
||||||
|
stats["disk_total_gb"] = round(sum(d["total"] for d in self.disk) / gib, 1)
|
||||||
|
stats["disk_used_gb"] = round(sum(d["used"] for d in self.disk) / gib, 1)
|
||||||
|
|
||||||
|
if self.gpu:
|
||||||
|
stats["gpu_count"] = len(self.gpu)
|
||||||
|
stats["gpu_mem_total_gb"] = round(sum(g["memory_total"] for g in self.gpu) / 1024, 2)
|
||||||
|
stats["gpu_mem_used_gb"] = round(sum(g["memory_used"] for g in self.gpu) / 1024, 2)
|
||||||
|
|
||||||
|
stats["uptime_hours"] = HardwareScorer.longevity_score(self)
|
||||||
|
return stats
|
||||||
|
|
||||||
def to_json(self):
|
def to_json(self):
|
||||||
return json.dumps(self.to_dict(), indent=2)
|
return json.dumps(self.to_dict(), indent=2)
|
||||||
|
|
||||||
@@ -93,7 +121,11 @@ class HardwareStatistics:
|
|||||||
|
|
||||||
def _get_disk_stats(self):
|
def _get_disk_stats(self):
|
||||||
disk_info = []
|
disk_info = []
|
||||||
|
seen_devices = set()
|
||||||
for d in psutil.disk_partitions():
|
for d in psutil.disk_partitions():
|
||||||
|
if d.device in seen_devices:
|
||||||
|
continue
|
||||||
|
seen_devices.add(d.device)
|
||||||
try:
|
try:
|
||||||
usage = psutil.disk_usage(d.mountpoint)
|
usage = psutil.disk_usage(d.mountpoint)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class MemberRecord:
|
|||||||
scores: dict = field(default_factory=dict)
|
scores: dict = field(default_factory=dict)
|
||||||
incarnation: int = 0
|
incarnation: int = 0
|
||||||
seq: int = 0
|
seq: int = 0
|
||||||
|
stats: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def key(self):
|
def key(self):
|
||||||
@@ -76,6 +77,7 @@ class MemberRecord:
|
|||||||
declined = _text_list(data.get("declined", []), "declined")
|
declined = _text_list(data.get("declined", []), "declined")
|
||||||
role_since = data.get("role_since", {})
|
role_since = data.get("role_since", {})
|
||||||
scores = data.get("scores", {})
|
scores = data.get("scores", {})
|
||||||
|
stats = data.get("stats", {})
|
||||||
incarnation = data["incarnation"]
|
incarnation = data["incarnation"]
|
||||||
seq = data["seq"]
|
seq = data["seq"]
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
@@ -96,8 +98,11 @@ class MemberRecord:
|
|||||||
if not isinstance(scores, dict) or len(scores) > MAX_LIST or \
|
if not isinstance(scores, dict) or len(scores) > MAX_LIST or \
|
||||||
not all(isinstance(k, str) and _is_number(v) for k, v in scores.items()):
|
not all(isinstance(k, str) and _is_number(v) for k, v in scores.items()):
|
||||||
raise ValueError("Member record has bad scores")
|
raise ValueError("Member record has bad scores")
|
||||||
|
if not isinstance(stats, dict) or len(stats) > MAX_LIST or \
|
||||||
|
not all(isinstance(k, str) and _is_number(v) for k, v in stats.items()):
|
||||||
|
raise ValueError("Member record has bad stats")
|
||||||
|
|
||||||
return cls(ip, port, hostname, roles, dict(role_since), declined, dict(scores), incarnation, seq)
|
return cls(ip, port, hostname, roles, dict(role_since), declined, dict(scores), incarnation, seq, dict(stats))
|
||||||
|
|
||||||
|
|
||||||
def resolve_admission(admission):
|
def resolve_admission(admission):
|
||||||
@@ -146,7 +151,7 @@ class Membership:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._current_self()
|
return self._current_self()
|
||||||
|
|
||||||
def update_self(self, roles=None, scores=None):
|
def update_self(self, roles=None, scores=None, stats=None):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if roles is not None:
|
if roles is not None:
|
||||||
now_ms = int(self.wall() * 1000)
|
now_ms = int(self.wall() * 1000)
|
||||||
@@ -154,6 +159,8 @@ class Membership:
|
|||||||
self._self.roles = list(roles)
|
self._self.roles = list(roles)
|
||||||
if scores is not None:
|
if scores is not None:
|
||||||
self._self.scores = dict(scores)
|
self._self.scores = dict(scores)
|
||||||
|
if stats is not None:
|
||||||
|
self._self.stats = dict(stats)
|
||||||
self._self.seq += 1
|
self._self.seq += 1
|
||||||
|
|
||||||
def decline(self, role, cooldown):
|
def decline(self, role, cooldown):
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class Foreman(Role):
|
|||||||
self.proposal_cooldown = setting("foreman.proposal_cooldown")
|
self.proposal_cooldown = setting("foreman.proposal_cooldown")
|
||||||
|
|
||||||
self._needed_since = {}
|
self._needed_since = {}
|
||||||
self._last_proposal = {}
|
self._proposals = {}
|
||||||
|
|
||||||
def loop(self):
|
def loop(self):
|
||||||
self.tick()
|
self.tick()
|
||||||
@@ -39,6 +39,11 @@ class Foreman(Role):
|
|||||||
unmet = advice.unmet_roles(records, roles)
|
unmet = advice.unmet_roles(records, roles)
|
||||||
self._needed_since = {name: self._needed_since.get(name, now) for name in unmet}
|
self._needed_since = {name: self._needed_since.get(name, now) for name in unmet}
|
||||||
|
|
||||||
|
self._proposals = {
|
||||||
|
key: (at, accepted) for key, (at, accepted) in self._proposals.items()
|
||||||
|
if now - at < self.proposal_cooldown
|
||||||
|
}
|
||||||
|
|
||||||
alone = len(records) == 1
|
alone = len(records) == 1
|
||||||
rank = advice.rank_of(membership.key, records)
|
rank = advice.rank_of(membership.key, records)
|
||||||
wait = 0 if alone else self.acquire_delay + min(rank, 3) * self.proposal_stagger
|
wait = 0 if alone else self.acquire_delay + min(rank, 3) * self.proposal_stagger
|
||||||
@@ -47,17 +52,19 @@ class Foreman(Role):
|
|||||||
if now - self._needed_since[role_name] < wait:
|
if now - self._needed_since[role_name] < wait:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
target = advice.pick_candidate(role_name, roles[role_name], records)
|
recent = {key: accepted for (name, key), (_, accepted) in self._proposals.items() if name == role_name}
|
||||||
|
if any(recent.values()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
target = advice.pick_candidate(role_name, roles[role_name], records, exclude=set(recent))
|
||||||
if target is None:
|
if target is None:
|
||||||
|
if not recent:
|
||||||
self.logger.warning(f"No node is willing to take role {role_name}")
|
self.logger.warning(f"No node is willing to take role {role_name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
last = self._last_proposal.get((role_name, target.key))
|
result = self.propose(role_name, target)
|
||||||
if last is not None and now - last < self.proposal_cooldown:
|
self._proposals[(role_name, target.key)] = (now, result[2] is True)
|
||||||
continue
|
return result
|
||||||
|
|
||||||
self._last_proposal[(role_name, target.key)] = now
|
|
||||||
return self.propose(role_name, target)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import random
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from colony.core.hardware_scores import collect_scores
|
from colony.core.hardware_scores import collect_hardware
|
||||||
from colony.core.network import advice
|
from colony.core.network import advice
|
||||||
from colony.core.network.membership import MemberRecord
|
from colony.core.network.membership import MemberRecord
|
||||||
from colony.core.roles.role import Role, Command
|
from colony.core.roles.role import Role, Command
|
||||||
@@ -26,7 +26,7 @@ class Gossip(Role):
|
|||||||
|
|
||||||
BOOTSTRAP_REPROBE = 5
|
BOOTSTRAP_REPROBE = 5
|
||||||
DENIED_BACKOFF_CAP = 60
|
DENIED_BACKOFF_CAP = 60
|
||||||
SCORES_TTL = 30
|
HARDWARE_TTL = 5
|
||||||
MAX_SEEDS = 64
|
MAX_SEEDS = 64
|
||||||
|
|
||||||
class COMMANDS(Enum):
|
class COMMANDS(Enum):
|
||||||
@@ -53,7 +53,8 @@ class Gossip(Role):
|
|||||||
self.next_reprobe = 0.0
|
self.next_reprobe = 0.0
|
||||||
self.denied_attempts = 0
|
self.denied_attempts = 0
|
||||||
self._scores = {}
|
self._scores = {}
|
||||||
self._scores_at = None
|
self._stats = {}
|
||||||
|
self._hardware_at = None
|
||||||
self._persisted_peers = None
|
self._persisted_peers = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -153,19 +154,20 @@ class Gossip(Role):
|
|||||||
return FAILED
|
return FAILED
|
||||||
return ADMITTED
|
return ADMITTED
|
||||||
|
|
||||||
def scores(self):
|
def hardware(self):
|
||||||
now = self.membership.now()
|
now = self.membership.now()
|
||||||
if self._scores_at is None or now - self._scores_at >= self.SCORES_TTL:
|
if self._hardware_at is None or now - self._hardware_at >= self.HARDWARE_TTL:
|
||||||
self._scores = self.collect_scores()
|
self._scores, self._stats = self.collect_hardware()
|
||||||
if self._scores:
|
if self._scores:
|
||||||
self._scores_at = now
|
self._hardware_at = now
|
||||||
return self._scores
|
return self._scores, self._stats
|
||||||
|
|
||||||
def collect_scores(self):
|
def collect_hardware(self):
|
||||||
return collect_scores()
|
return collect_hardware()
|
||||||
|
|
||||||
def refresh_self(self):
|
def refresh_self(self):
|
||||||
self.membership.update_self(scores=self.scores())
|
scores, stats = self.hardware()
|
||||||
|
self.membership.update_self(scores=scores, stats=stats)
|
||||||
|
|
||||||
def exchange_all(self, addresses):
|
def exchange_all(self, addresses):
|
||||||
if not addresses:
|
if not addresses:
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ class RoleManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self.logger.debug(f"Creating instance of role {role_name}. ")
|
self.logger.debug(f"Creating instance of role {role_name}. ")
|
||||||
|
try:
|
||||||
roles[role_name] = role_cls(context=context)
|
roles[role_name] = role_cls(context=context)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(f"Could not create role {role_name}, skipping it")
|
||||||
|
|
||||||
self._publish(roles)
|
self._publish(roles)
|
||||||
self.logger.info(f"Roles initialized: {list(roles.keys())}. ")
|
self.logger.info(f"Roles initialized: {list(roles.keys())}. ")
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,30 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(get_status):
|
||||||
|
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static")
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return app.send_static_file("index.html")
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def status():
|
||||||
|
try:
|
||||||
|
payload = get_status()
|
||||||
|
except Exception:
|
||||||
|
app.logger.exception("Could not build the status")
|
||||||
|
return jsonify(error="status unavailable"), 500
|
||||||
|
|
||||||
|
response = jsonify(payload)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import threading
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
|
from colony.core.roles.role import Role
|
||||||
|
from colony.core.roles.webview.app import create_app
|
||||||
|
from colony.settings import setting
|
||||||
|
|
||||||
|
|
||||||
|
class WebView(Role):
|
||||||
|
class COMMANDS(Enum):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __init__(self, context):
|
||||||
|
host, port = setting("web.host"), setting("web.port")
|
||||||
|
self.app = create_app(self._status)
|
||||||
|
try:
|
||||||
|
self._server = make_server(host, port, self.app, threaded=True)
|
||||||
|
except SystemExit:
|
||||||
|
raise OSError(f"Web view cannot listen on {host}:{port}") from None
|
||||||
|
self.port = self._server.server_port
|
||||||
|
|
||||||
|
super().__init__({}, context)
|
||||||
|
|
||||||
|
def _status(self):
|
||||||
|
unit = self.context.self().get_role_instances().get("Unit")
|
||||||
|
if unit is None:
|
||||||
|
raise RuntimeError("The Unit role is not available")
|
||||||
|
return unit.show_cluster()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
with self._start_lock:
|
||||||
|
if self._thread is not None:
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True, name=str(self))
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
self.logger.info(f"Web view at http://{self.context.self().ip}:{self.port}")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
super().stop()
|
||||||
|
with self._start_lock:
|
||||||
|
started = self._thread is not None
|
||||||
|
if started:
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server.server_close()
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const REFRESH_MS = 1000;
|
||||||
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
const PAD = 24;
|
||||||
|
const CARD_W = 280;
|
||||||
|
const CARD_H = 168;
|
||||||
|
const CARD_GAP = 16;
|
||||||
|
const BUS_GUTTER = 14;
|
||||||
|
const NARROW_WIDTH = 360;
|
||||||
|
const NARROW_BELOW = 640;
|
||||||
|
const CHIP_HEIGHT = 17;
|
||||||
|
const CHIP_GAP = 5;
|
||||||
|
const CHIP_LINES = 2;
|
||||||
|
const CENTER_W = 170;
|
||||||
|
const CENTER_MAX_ROWS = 2;
|
||||||
|
const CENTER_ROW = 24;
|
||||||
|
|
||||||
|
function address(member) {
|
||||||
|
return member.ip + ":" + member.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleStates(payload) {
|
||||||
|
const alive = payload.members.filter((member) => member.alive);
|
||||||
|
return payload.roles.map((role) => {
|
||||||
|
const holders = alive.filter((member) => member.roles.includes(role.name)).map(address).sort();
|
||||||
|
let state;
|
||||||
|
if (role.required === null) {
|
||||||
|
state = "general";
|
||||||
|
} else if (holders.length < role.required) {
|
||||||
|
state = "unmet";
|
||||||
|
} else if (holders.length > role.required) {
|
||||||
|
state = "duplicate";
|
||||||
|
} else {
|
||||||
|
state = "held";
|
||||||
|
}
|
||||||
|
return Object.assign({}, role, { holders: holders, state: state });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shorten(text, limit) {
|
||||||
|
return text.length > limit ? text.slice(0, limit - 1) + "…" : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAge(seconds) {
|
||||||
|
seconds = Math.floor(seconds);
|
||||||
|
if (seconds < 60) return seconds + "s";
|
||||||
|
if (seconds < 3600) return Math.floor(seconds / 60) + "m" + String(seconds % 60).padStart(2, "0") + "s";
|
||||||
|
return Math.floor(seconds / 3600) + "h" + String(Math.floor((seconds % 3600) / 60)).padStart(2, "0") + "m";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(gb) {
|
||||||
|
if (gb >= 1000) return (gb / 1024).toFixed(1) + " TB";
|
||||||
|
if (gb >= 100) return Math.round(gb) + " GB";
|
||||||
|
return gb.toFixed(1) + " GB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(hours) {
|
||||||
|
if (hours < 1) return Math.round(hours * 60) + "m";
|
||||||
|
if (hours < 48) return Math.round(hours) + "h";
|
||||||
|
return Math.floor(hours / 24) + "d " + Math.round(hours % 24) + "h";
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(used, total) {
|
||||||
|
return total > 0 ? Math.max(0, Math.min(100, Math.round((100 * used) / total))) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function barLevel(pct) {
|
||||||
|
return pct >= 90 ? "crit" : pct >= 75 ? "warn" : "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeStats(stats) {
|
||||||
|
stats = stats || {};
|
||||||
|
const num = (key) => (typeof stats[key] === "number" ? stats[key] : null);
|
||||||
|
|
||||||
|
const cpuPercent = num("cpu_percent");
|
||||||
|
const cores = num("cpu_cores");
|
||||||
|
const threads = num("cpu_threads");
|
||||||
|
let cpuText = null;
|
||||||
|
if (cpuPercent !== null) {
|
||||||
|
cpuText = Math.round(cpuPercent) + "%";
|
||||||
|
if (cores && threads) cpuText += " · " + cores + "C/" + threads + "T";
|
||||||
|
else if (threads) cpuText += " · " + threads + "T";
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = (usedKey, totalKey) => {
|
||||||
|
const used = num(usedKey);
|
||||||
|
const total = num(totalKey);
|
||||||
|
if (used === null || total === null) return null;
|
||||||
|
return { pct: percent(used, total), text: formatSize(used) + " / " + formatSize(total) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const gpuCount = num("gpu_count");
|
||||||
|
const gpuUsage = usage("gpu_mem_used_gb", "gpu_mem_total_gb");
|
||||||
|
const uptime = num("uptime_hours");
|
||||||
|
|
||||||
|
const cpu = cpuText === null ? null : { pct: Math.round(cpuPercent), text: cpuText };
|
||||||
|
const ram = usage("mem_used_gb", "mem_total_gb");
|
||||||
|
const disk = usage("disk_used_gb", "disk_total_gb");
|
||||||
|
const gpu = gpuCount && gpuUsage ? (gpuCount > 1 ? gpuCount + "× " : "") + "GPU " + gpuUsage.text : null;
|
||||||
|
|
||||||
|
const footer = [uptime === null ? null : "up " + formatUptime(uptime), gpu].filter(Boolean).join(" · ");
|
||||||
|
return { cpu: cpu, ram: ram, disk: disk, gpu: gpu, uptime: uptime, footer: cpu || ram || disk ? footer : "no stats reported (older version?)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alerts(payload) {
|
||||||
|
const states = roleStates(payload);
|
||||||
|
const out = [];
|
||||||
|
const unmet = states.filter((role) => role.state === "unmet").map((role) => role.name);
|
||||||
|
const twice = states.filter((role) => role.state === "duplicate").map((role) => role.name);
|
||||||
|
const down = payload.members.filter((member) => !member.alive).map((member) => member.hostname);
|
||||||
|
if (unmet.length) out.push({ level: "bad", text: "Unmet: " + unmet.join(", ") });
|
||||||
|
if (twice.length) out.push({ level: "warn", text: "Held twice: " + twice.join(", ") });
|
||||||
|
if (down.length) out.push({ level: "info", text: "Down: " + down.join(", ") });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(payload) {
|
||||||
|
const states = roleStates(payload);
|
||||||
|
const alive = payload.members.filter((member) => member.alive).length;
|
||||||
|
const down = payload.members.length - alive;
|
||||||
|
const assigned = states.filter((role) => role.state !== "general");
|
||||||
|
const held = assigned.filter((role) => role.state !== "unmet");
|
||||||
|
let text = alive + " node" + (alive === 1 ? "" : "s") + " alive";
|
||||||
|
if (down) text += " · " + down + " down";
|
||||||
|
text += " · " + held.length + "/" + assigned.length + " roles held";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeRoles(payload, member) {
|
||||||
|
const states = new Map(roleStates(payload).map((role) => [role.name, role]));
|
||||||
|
const known = payload.roles.map((role) => role.name).filter((name) => member.roles.includes(name));
|
||||||
|
const unknown = member.roles.filter((name) => !states.has(name)).sort();
|
||||||
|
const kind = (name) => (states.has(name) ? states.get(name).state : "unknown");
|
||||||
|
const rank = (name) => (kind(name) === "general" ? 1 : 0);
|
||||||
|
return known
|
||||||
|
.concat(unknown)
|
||||||
|
.map((name, index) => ({ text: name, kind: kind(name), order: index }))
|
||||||
|
.sort((a, b) => rank(a.text) - rank(b.text) || a.order - b.order)
|
||||||
|
.map((entry) => ({ text: entry.text, kind: entry.kind }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipWidth(text) {
|
||||||
|
return Math.round(text.length * 6.3) + 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipLayout(labels, maxWidth, maxLines) {
|
||||||
|
const chips = [];
|
||||||
|
let line = 0;
|
||||||
|
let x = 0;
|
||||||
|
let shown = 0;
|
||||||
|
|
||||||
|
for (const label of labels) {
|
||||||
|
const width = chipWidth(label.text);
|
||||||
|
const wraps = x > 0 && x + width > maxWidth;
|
||||||
|
if ((wraps ? line + 1 : line) >= maxLines) break;
|
||||||
|
if (wraps) {
|
||||||
|
line += 1;
|
||||||
|
x = 0;
|
||||||
|
}
|
||||||
|
chips.push({ text: label.text, kind: label.kind, x: x, line: line, w: width });
|
||||||
|
x += width + CHIP_GAP;
|
||||||
|
shown += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hidden = labels.length - shown;
|
||||||
|
if (hidden > 0) {
|
||||||
|
for (;;) {
|
||||||
|
const text = "+" + hidden;
|
||||||
|
const width = chipWidth(text);
|
||||||
|
const last = chips[chips.length - 1];
|
||||||
|
const at = last ? last.x + last.w + CHIP_GAP : 0;
|
||||||
|
if (!last || at + width <= maxWidth) {
|
||||||
|
chips.push({ text: text, kind: "more", x: at, line: maxLines - 1, w: width });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chips.pop();
|
||||||
|
hidden += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layout(payload, narrow) {
|
||||||
|
const members = payload.members;
|
||||||
|
const count = members.length;
|
||||||
|
const unmet = roleStates(payload).filter((role) => role.state === "unmet").map((role) => role.name);
|
||||||
|
const cards = [];
|
||||||
|
const links = [];
|
||||||
|
let width;
|
||||||
|
let height;
|
||||||
|
let center = null;
|
||||||
|
|
||||||
|
if (narrow) {
|
||||||
|
width = NARROW_WIDTH;
|
||||||
|
const cardW = width - 2 * PAD - BUS_GUTTER;
|
||||||
|
members.forEach((member, index) => {
|
||||||
|
const y = PAD + index * (CARD_H + CARD_GAP);
|
||||||
|
cards.push({ member: member, key: address(member), x: PAD + BUS_GUTTER, y: y, w: cardW, h: CARD_H, cx: PAD + BUS_GUTTER + cardW / 2, cy: y + CARD_H / 2 });
|
||||||
|
});
|
||||||
|
height = PAD * 2 + count * CARD_H + (count - 1) * CARD_GAP;
|
||||||
|
|
||||||
|
const busX = PAD / 2;
|
||||||
|
if (count > 1) {
|
||||||
|
links.push({ x1: busX, y1: cards[0].cy, x2: busX, y2: cards[count - 1].cy, kind: "link" });
|
||||||
|
}
|
||||||
|
cards.forEach((card) => {
|
||||||
|
links.push({ x1: busX, y1: card.cy, x2: card.x, y2: card.cy, kind: card.member.alive ? "link" : "down" });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const rx = count <= 1 ? 0 : Math.max(280, count * 70);
|
||||||
|
const ry = count <= 2 ? 0 : Math.max(200, count * 50);
|
||||||
|
const start = count === 2 ? Math.PI : count % 2 === 0 ? -Math.PI / 2 + Math.PI / count : -Math.PI / 2;
|
||||||
|
|
||||||
|
members.forEach((member, index) => {
|
||||||
|
const angle = start + (2 * Math.PI * index) / count;
|
||||||
|
const centerX = rx * Math.cos(angle);
|
||||||
|
const centerY = ry * Math.sin(angle);
|
||||||
|
cards.push({ member: member, key: address(member), x: centerX - CARD_W / 2, y: centerY - CARD_H / 2, w: CARD_W, h: CARD_H, cx: centerX, cy: centerY });
|
||||||
|
});
|
||||||
|
|
||||||
|
const left = Math.min.apply(null, cards.map((card) => card.x)) - PAD;
|
||||||
|
const top = Math.min.apply(null, cards.map((card) => card.y)) - PAD;
|
||||||
|
width = Math.max.apply(null, cards.map((card) => card.x + card.w)) + PAD - left;
|
||||||
|
height = Math.max.apply(null, cards.map((card) => card.y + card.h)) + PAD - top;
|
||||||
|
cards.forEach((card) => {
|
||||||
|
card.x -= left;
|
||||||
|
card.y -= top;
|
||||||
|
card.cx -= left;
|
||||||
|
card.cy -= top;
|
||||||
|
});
|
||||||
|
const cx = -left;
|
||||||
|
const cy = -top;
|
||||||
|
|
||||||
|
const alive = cards.filter((card) => card.member.alive);
|
||||||
|
for (let i = 0; i < alive.length; i++) {
|
||||||
|
for (let j = i + 1; j < alive.length; j++) {
|
||||||
|
links.push({ x1: alive[i].cx, y1: alive[i].cy, x2: alive[j].cx, y2: alive[j].cy, kind: "link" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const viewer = cards.find((card) => card.member.self);
|
||||||
|
cards.filter((card) => !card.member.alive).forEach((card) => {
|
||||||
|
if (viewer) links.push({ x1: viewer.cx, y1: viewer.cy, x2: card.cx, y2: card.cy, kind: "down" });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count >= 3) {
|
||||||
|
const rows = unmet.length > CENTER_MAX_ROWS ? [unmet[0], "+" + (unmet.length - 1) + " more"] : unmet;
|
||||||
|
const h = 34 + Math.max(rows.length, 1) * CENTER_ROW;
|
||||||
|
center = { x: cx - CENTER_W / 2, y: cy - h / 2, w: CENTER_W, h: h, unmet: unmet, rows: rows };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: width, height: height, narrow: narrow, cards: cards, links: links, center: center };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const svg = (name, attributes, text) => {
|
||||||
|
const element = document.createElementNS(SVG_NS, name);
|
||||||
|
Object.keys(attributes || {}).forEach((key) => element.setAttribute(key, attributes[key]));
|
||||||
|
if (text !== undefined) element.textContent = text;
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = (name, text, className) => {
|
||||||
|
const element = document.createElement(name);
|
||||||
|
if (text !== undefined) element.textContent = text;
|
||||||
|
if (className) element.className = className;
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawChip = (parent, chip, x, y) => {
|
||||||
|
const group = svg("g", { class: "chip " + chip.kind, transform: "translate(" + x + "," + y + ")" });
|
||||||
|
group.appendChild(svg("rect", { width: chip.w, height: CHIP_HEIGHT, rx: 8.5 }));
|
||||||
|
group.appendChild(svg("text", { x: chip.w / 2, y: 12 }, chip.text));
|
||||||
|
parent.appendChild(group);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawBar = (group, y, cardW, label, usage) => {
|
||||||
|
const trackX = 44;
|
||||||
|
const trackW = cardW - trackX - 14 - 118;
|
||||||
|
group.appendChild(svg("text", { class: "t-label", x: 14, y: y + 8 }, label));
|
||||||
|
group.appendChild(svg("rect", { class: "track", x: trackX, y: y, width: trackW, height: 8, rx: 4 }));
|
||||||
|
if (usage) {
|
||||||
|
const width = usage.pct ? Math.max((trackW * usage.pct) / 100, 4) : 0;
|
||||||
|
if (width) {
|
||||||
|
group.appendChild(svg("rect", { class: "fill " + barLevel(usage.pct), x: trackX, y: y, width: width, height: 8, rx: 4 }));
|
||||||
|
}
|
||||||
|
group.appendChild(svg("text", { class: "t-value", x: cardW - 14, y: y + 8 }, usage.text));
|
||||||
|
} else {
|
||||||
|
group.appendChild(svg("text", { class: "t-value na", x: cardW - 14, y: y + 8 }, "n/a"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawCard = (parent, card, payload) => {
|
||||||
|
const member = card.member;
|
||||||
|
const stats = describeStats(member.stats);
|
||||||
|
const classes = ["node", member.alive ? "alive" : "down"];
|
||||||
|
if (member.self) classes.push("self");
|
||||||
|
|
||||||
|
const group = svg("g", { class: classes.join(" "), transform: "translate(" + card.x + "," + card.y + ")" });
|
||||||
|
group.appendChild(svg("title", {}, member.hostname + " " + card.key + "\nroles: " + (member.roles.join(", ") || "none") +
|
||||||
|
(member.declined.length ? "\ndeclined: " + member.declined.join(", ") : "")));
|
||||||
|
group.appendChild(svg("rect", { class: "card", width: card.w, height: card.h, rx: 12 }));
|
||||||
|
group.appendChild(svg("circle", { class: "dot " + (member.alive ? "alive" : "down"), cx: 20, cy: 22, r: 5 }));
|
||||||
|
group.appendChild(svg("text", { class: "t-title", x: 32, y: 26 }, shorten(member.hostname, 22)));
|
||||||
|
|
||||||
|
const tag = member.self ? "this node" : member.alive ? "" : "down " + formatAge(member.seen_ago);
|
||||||
|
if (tag) group.appendChild(svg("text", { class: "t-tag", x: card.w - 14, y: 26 }, tag));
|
||||||
|
group.appendChild(svg("text", { class: "t-sub", x: 32, y: 43 }, card.key));
|
||||||
|
|
||||||
|
chipLayout(nodeRoles(payload, member), card.w - 28, CHIP_LINES).forEach((chip) => {
|
||||||
|
drawChip(group, chip, 14 + chip.x, 54 + chip.line * (CHIP_HEIGHT + 4));
|
||||||
|
});
|
||||||
|
|
||||||
|
drawBar(group, 104, card.w, "CPU", stats.cpu);
|
||||||
|
drawBar(group, 120, card.w, "RAM", stats.ram);
|
||||||
|
drawBar(group, 136, card.w, "DISK", stats.disk);
|
||||||
|
group.appendChild(svg("text", { class: "t-foot", x: 14, y: card.h - 8 }, shorten(stats.footer, 56)));
|
||||||
|
parent.appendChild(group);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawCenter = (parent, center) => {
|
||||||
|
const group = svg("g", { class: "center", transform: "translate(" + center.x + "," + center.y + ")" });
|
||||||
|
group.appendChild(svg("rect", { class: "center-box" + (center.unmet.length ? " bad" : ""), width: center.w, height: center.h, rx: 12 }));
|
||||||
|
group.appendChild(svg("text", { class: "t-label center-title", x: center.w / 2, y: 20 }, center.unmet.length ? "UNMET ROLES" : "ALL ROLES HELD"));
|
||||||
|
center.rows.forEach((name, index) => {
|
||||||
|
const chip = { text: shorten(name, 18), kind: name.startsWith("+") ? "more" : "unmet", w: center.w - 28 };
|
||||||
|
drawChip(group, chip, 14, 30 + index * CENTER_ROW);
|
||||||
|
});
|
||||||
|
parent.appendChild(group);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawGraph = (payload, narrow) => {
|
||||||
|
const graph = document.getElementById("graph");
|
||||||
|
const geometry = layout(payload, narrow);
|
||||||
|
graph.setAttribute("viewBox", "0 0 " + geometry.width + " " + geometry.height);
|
||||||
|
graph.style.maxWidth = Math.round(geometry.width) + "px";
|
||||||
|
graph.replaceChildren();
|
||||||
|
|
||||||
|
geometry.links.forEach((link) => {
|
||||||
|
graph.appendChild(svg("line", { class: "link " + link.kind, x1: link.x1, y1: link.y1, x2: link.x2, y2: link.y2 }));
|
||||||
|
});
|
||||||
|
if (geometry.center) drawCenter(graph, geometry.center);
|
||||||
|
geometry.cards.forEach((card) => drawCard(graph, card, payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawHeader = (payload) => {
|
||||||
|
document.getElementById("summary").textContent = summarize(payload);
|
||||||
|
|
||||||
|
const box = document.getElementById("alerts");
|
||||||
|
box.replaceChildren();
|
||||||
|
alerts(payload).forEach((alert) => box.appendChild(html("span", alert.text, "alert " + alert.level)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let latest = null;
|
||||||
|
let narrow = window.innerWidth < NARROW_BELOW;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
if (!latest) return;
|
||||||
|
drawHeader(latest);
|
||||||
|
drawGraph(latest, narrow);
|
||||||
|
document.getElementById("error").hidden = true;
|
||||||
|
document.getElementById("updated").textContent = "Updated " + new Date().toLocaleTimeString() + " · refreshing every " + REFRESH_MS / 1000 + "s";
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
const now = window.innerWidth < NARROW_BELOW;
|
||||||
|
if (now !== narrow) {
|
||||||
|
narrow = now;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let busy = false;
|
||||||
|
const refresh = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/status", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error("the node answered with HTTP " + response.status);
|
||||||
|
latest = await response.json();
|
||||||
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
const banner = document.getElementById("error");
|
||||||
|
banner.textContent = "Cannot reach the node: " + error.message + ". Retrying...";
|
||||||
|
banner.hidden = false;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined") {
|
||||||
|
module.exports = { address, roleStates, layout, nodeRoles, chipLayout, chipWidth, shorten, formatAge, formatSize, formatUptime, describeStats, barLevel, alerts, summarize };
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Colony</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Colony</h1>
|
||||||
|
<p id="summary" class="summary">Loading...</p>
|
||||||
|
<div id="alerts" class="alerts"></div>
|
||||||
|
<p id="error" class="error" hidden></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<svg id="graph" viewBox="0 0 788 556" role="img" aria-label="Nodes with their hardware and the roles they hold"></svg>
|
||||||
|
<ul class="legend">
|
||||||
|
<li><span class="swatch alive"></span>node alive</li>
|
||||||
|
<li><span class="swatch down"></span>node down</li>
|
||||||
|
<li><span class="swatch link"></span>same network</li>
|
||||||
|
<li><span class="swatch held"></span>role held</li>
|
||||||
|
<li><span class="swatch unmet"></span>role unmet</li>
|
||||||
|
<li><span class="swatch duplicate"></span>held twice</li>
|
||||||
|
<li><span class="swatch general"></span>runs on every node</li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="muted"><span id="updated"></span></footer>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f4f5f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1c2430;
|
||||||
|
--muted: #6b7686;
|
||||||
|
--line: #d3d9e1;
|
||||||
|
--track: #e6eaf0;
|
||||||
|
--alive: #2f9e62;
|
||||||
|
--down: #98a2b3;
|
||||||
|
--ok: #3b6fd4;
|
||||||
|
--warn: #e0961e;
|
||||||
|
--crit: #d64545;
|
||||||
|
--held: #3b6fd4;
|
||||||
|
--unmet: #d64545;
|
||||||
|
--duplicate: #e0961e;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #12161c;
|
||||||
|
--card: #1b2129;
|
||||||
|
--text: #e6eaf0;
|
||||||
|
--muted: #93a0b3;
|
||||||
|
--line: #313a47;
|
||||||
|
--track: #2a3340;
|
||||||
|
--alive: #4cc38a;
|
||||||
|
--down: #6b7686;
|
||||||
|
--ok: #6c9cff;
|
||||||
|
--warn: #ffb04a;
|
||||||
|
--crit: #ff6b6b;
|
||||||
|
--held: #6c9cff;
|
||||||
|
--unmet: #ff6b6b;
|
||||||
|
--duplicate: #ffb04a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 16px 28px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 15px/1.45 system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
header, main, footer { max-width: 900px; margin: 0 auto; }
|
||||||
|
|
||||||
|
h1 { margin: 22px 0 2px; font-size: 24px; }
|
||||||
|
p { margin: 4px 0; }
|
||||||
|
.summary { color: var(--muted); }
|
||||||
|
.muted { color: var(--muted); font-size: 13px; }
|
||||||
|
.error { color: var(--crit); font-weight: 600; }
|
||||||
|
|
||||||
|
.alerts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.alerts:empty { display: none; }
|
||||||
|
.alert { padding: 2px 10px; border-radius: 999px; font-size: 13px; font-weight: 600; border: 1.5px solid; }
|
||||||
|
.alert.bad { color: var(--crit); border-color: var(--crit); }
|
||||||
|
.alert.warn { color: var(--warn); border-color: var(--warn); }
|
||||||
|
.alert.info { color: var(--muted); border-color: var(--line); }
|
||||||
|
|
||||||
|
main { margin-top: 14px; }
|
||||||
|
#graph { width: 100%; height: auto; display: block; margin: 0 auto; }
|
||||||
|
|
||||||
|
.link { stroke: var(--line); stroke-width: 2.5; }
|
||||||
|
.link.down { stroke-dasharray: 6 6; opacity: 0.7; }
|
||||||
|
|
||||||
|
.card { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
|
||||||
|
.node.self .card { stroke: var(--alive); stroke-width: 2; }
|
||||||
|
.node.down > :not(.card) { opacity: 0.6; }
|
||||||
|
.node.down .card { stroke-dasharray: 6 4; }
|
||||||
|
.dot.alive { fill: var(--alive); }
|
||||||
|
.dot.down { fill: var(--down); }
|
||||||
|
|
||||||
|
.t-title { fill: var(--text); font-size: 14px; font-weight: 600; }
|
||||||
|
.t-tag { fill: var(--muted); font-size: 11px; text-anchor: end; }
|
||||||
|
.t-sub { fill: var(--muted); font-size: 11px; }
|
||||||
|
.t-label { fill: var(--muted); font-size: 10.5px; letter-spacing: 0.04em; }
|
||||||
|
.t-value { fill: var(--text); font-size: 11px; text-anchor: end; }
|
||||||
|
.t-value.na { fill: var(--muted); }
|
||||||
|
.t-foot { fill: var(--muted); font-size: 10.5px; }
|
||||||
|
|
||||||
|
.track { fill: var(--track); }
|
||||||
|
.fill.ok { fill: var(--ok); }
|
||||||
|
.fill.warn { fill: var(--warn); }
|
||||||
|
.fill.crit { fill: var(--crit); }
|
||||||
|
|
||||||
|
.chip rect { fill: transparent; stroke: var(--line); stroke-width: 1.5; }
|
||||||
|
.chip text { fill: var(--muted); font-size: 10.5px; text-anchor: middle; }
|
||||||
|
.chip.held rect { stroke: var(--held); fill: color-mix(in srgb, var(--held) 14%, transparent); }
|
||||||
|
.chip.held text { fill: var(--text); font-weight: 600; }
|
||||||
|
.chip.duplicate rect { stroke: var(--duplicate); fill: color-mix(in srgb, var(--duplicate) 16%, transparent); }
|
||||||
|
.chip.duplicate text { fill: var(--text); font-weight: 600; }
|
||||||
|
.chip.unmet rect { stroke: var(--unmet); stroke-dasharray: 5 3; fill: color-mix(in srgb, var(--unmet) 12%, transparent); }
|
||||||
|
.chip.unmet text { fill: var(--unmet); font-weight: 600; }
|
||||||
|
.chip.unknown rect { stroke-dasharray: 2 3; }
|
||||||
|
.chip.more text { fill: var(--muted); }
|
||||||
|
|
||||||
|
.center-box { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
|
||||||
|
.center-box.bad { stroke: var(--unmet); }
|
||||||
|
.center-title { text-anchor: middle; }
|
||||||
|
|
||||||
|
.legend { display: flex; flex-wrap: wrap; gap: 4px 16px; list-style: none; margin: 10px 0 0; padding: 0; font-size: 12.5px; color: var(--muted); }
|
||||||
|
.swatch { display: inline-block; width: 11px; height: 11px; margin-right: 6px; border-radius: 50%; vertical-align: -1px; }
|
||||||
|
.swatch.alive { background: var(--alive); }
|
||||||
|
.swatch.down { background: var(--down); }
|
||||||
|
.swatch.held, .swatch.unmet, .swatch.duplicate, .swatch.general { border-radius: 6px; background: transparent; border: 2px solid; }
|
||||||
|
.swatch.general { border-color: var(--line); }
|
||||||
|
.swatch.link { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--line); vertical-align: 3px; }
|
||||||
|
.swatch.held { border-color: var(--held); }
|
||||||
|
.swatch.unmet { border-color: var(--unmet); border-style: dashed; }
|
||||||
|
.swatch.duplicate { border-color: var(--duplicate); }
|
||||||
|
|
||||||
|
footer { margin-top: 10px; }
|
||||||
@@ -24,6 +24,8 @@ DEFAULTS = {
|
|||||||
"foreman.acquire_delay": 10,
|
"foreman.acquire_delay": 10,
|
||||||
"foreman.proposal_stagger": 2,
|
"foreman.proposal_stagger": 2,
|
||||||
"foreman.proposal_cooldown": 30,
|
"foreman.proposal_cooldown": 30,
|
||||||
|
"web.host": "0.0.0.0",
|
||||||
|
"web.port": 8080,
|
||||||
"role_policy.max_roles": None,
|
"role_policy.max_roles": None,
|
||||||
"role_policy.deny": [],
|
"role_policy.deny": [],
|
||||||
"role_policy.min_scores": {},
|
"role_policy.min_scores": {},
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
"proposal_stagger": 2,
|
"proposal_stagger": 2,
|
||||||
"proposal_cooldown": 30
|
"proposal_cooldown": 30
|
||||||
},
|
},
|
||||||
|
"web": {
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
"role_policy": {
|
"role_policy": {
|
||||||
"max_roles": null,
|
"max_roles": null,
|
||||||
"deny": [],
|
"deny": [],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
dependency-injector==4.48.3
|
dependency-injector==4.48.3
|
||||||
dynaconf==3.2.12
|
dynaconf==3.2.12
|
||||||
|
Flask==3.1.3
|
||||||
GPUtil==1.4.0
|
GPUtil==1.4.0
|
||||||
psutil==7.2.1
|
psutil==7.2.1
|
||||||
setuptools==80.9.0
|
setuptools==80.9.0
|
||||||
@@ -4,6 +4,7 @@ from colony.role_registry import RoleStats, HardwarePreferences, RolePriority, H
|
|||||||
from colony.core.roles.foreman.role import Foreman
|
from colony.core.roles.foreman.role import Foreman
|
||||||
from colony.core.roles.gossip.role import Gossip
|
from colony.core.roles.gossip.role import Gossip
|
||||||
from colony.core.roles.vaultwarden.role import VaultWarden
|
from colony.core.roles.vaultwarden.role import VaultWarden
|
||||||
|
from colony.core.roles.webview.role import WebView
|
||||||
|
|
||||||
available_roles = [
|
available_roles = [
|
||||||
RoleStats(
|
RoleStats(
|
||||||
@@ -31,5 +32,14 @@ available_roles = [
|
|||||||
RoleStats(
|
RoleStats(
|
||||||
cls=Explorer,
|
cls=Explorer,
|
||||||
priority=RolePriority.HIGH,
|
priority=RolePriority.HIGH,
|
||||||
|
),
|
||||||
|
RoleStats(
|
||||||
|
cls=WebView,
|
||||||
|
priority=RolePriority.LOW,
|
||||||
|
hardware_preferences=HardwarePreferences(
|
||||||
|
{
|
||||||
|
HardwareType.LONGEVITY: 3,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
),
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user