This commit is contained in:
1 parent
48349569e7
commit
4bfaed6184
19 files changed
+760
-46
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"]
|
||||
|
||||
EXPOSE 65334/tcp 28149/udp
|
||||
EXPOSE 65334/tcp 28149/udp 8080/tcp
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
+38
-16
@@ -16,15 +16,6 @@ CLEAR_SCREEN = "\033[2J"
|
||||
CLEAR_LINE = "\033[K"
|
||||
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):
|
||||
payload = CommandRequest.form(Unit.COMMANDS.SHOW_CLUSTER).collapse()
|
||||
resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout)
|
||||
@@ -50,8 +41,42 @@ def table(headers, rows):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_score(value):
|
||||
return "-" if value is None else f"{value:.1f}"
|
||||
def format_size(gb):
|
||||
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):
|
||||
@@ -126,16 +151,13 @@ def render_status(payload):
|
||||
names = ", ".join(labels[holder] for holder in holders) or "-"
|
||||
role_rows.append([role["name"], role["priority"], state, names])
|
||||
|
||||
score_rows = [
|
||||
[labels[address(member)]] + [format_score(member["scores"].get(key)) for key, _ in SCORE_COLUMNS]
|
||||
for member in alive
|
||||
]
|
||||
hardware_rows = [[labels[address(member)]] + describe_stats(member.get("stats")) for member in alive]
|
||||
|
||||
sections = [
|
||||
title,
|
||||
table(node_headers, node_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)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from colony.core.network.hardware import HardwareStatistics, HardwareScorer
|
||||
|
||||
|
||||
def collect_scores():
|
||||
def collect_hardware():
|
||||
try:
|
||||
return HardwareScorer.from_object(HardwareStatistics.collect())
|
||||
hardware = HardwareStatistics.collect()
|
||||
return HardwareScorer.from_object(hardware), hardware.summary()
|
||||
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 = [
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -38,6 +38,34 @@ class HardwareStatistics:
|
||||
"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):
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
@@ -93,7 +121,11 @@ class HardwareStatistics:
|
||||
|
||||
def _get_disk_stats(self):
|
||||
disk_info = []
|
||||
seen_devices = set()
|
||||
for d in psutil.disk_partitions():
|
||||
if d.device in seen_devices:
|
||||
continue
|
||||
seen_devices.add(d.device)
|
||||
try:
|
||||
usage = psutil.disk_usage(d.mountpoint)
|
||||
except OSError:
|
||||
|
||||
@@ -51,6 +51,7 @@ class MemberRecord:
|
||||
scores: dict = field(default_factory=dict)
|
||||
incarnation: int = 0
|
||||
seq: int = 0
|
||||
stats: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
@@ -76,6 +77,7 @@ class MemberRecord:
|
||||
declined = _text_list(data.get("declined", []), "declined")
|
||||
role_since = data.get("role_since", {})
|
||||
scores = data.get("scores", {})
|
||||
stats = data.get("stats", {})
|
||||
incarnation = data["incarnation"]
|
||||
seq = data["seq"]
|
||||
except KeyError as e:
|
||||
@@ -96,8 +98,11 @@ class MemberRecord:
|
||||
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()):
|
||||
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):
|
||||
@@ -146,7 +151,7 @@ class Membership:
|
||||
with self._lock:
|
||||
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:
|
||||
if roles is not None:
|
||||
now_ms = int(self.wall() * 1000)
|
||||
@@ -154,6 +159,8 @@ class Membership:
|
||||
self._self.roles = list(roles)
|
||||
if scores is not None:
|
||||
self._self.scores = dict(scores)
|
||||
if stats is not None:
|
||||
self._self.stats = dict(stats)
|
||||
self._self.seq += 1
|
||||
|
||||
def decline(self, role, cooldown):
|
||||
|
||||
@@ -22,7 +22,7 @@ class Foreman(Role):
|
||||
self.proposal_cooldown = setting("foreman.proposal_cooldown")
|
||||
|
||||
self._needed_since = {}
|
||||
self._last_proposal = {}
|
||||
self._proposals = {}
|
||||
|
||||
def loop(self):
|
||||
self.tick()
|
||||
@@ -39,6 +39,11 @@ class Foreman(Role):
|
||||
unmet = advice.unmet_roles(records, roles)
|
||||
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
|
||||
rank = advice.rank_of(membership.key, records)
|
||||
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:
|
||||
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:
|
||||
self.logger.warning(f"No node is willing to take role {role_name}")
|
||||
if not recent:
|
||||
self.logger.warning(f"No node is willing to take role {role_name}")
|
||||
continue
|
||||
|
||||
last = self._last_proposal.get((role_name, target.key))
|
||||
if last is not None and now - last < self.proposal_cooldown:
|
||||
continue
|
||||
|
||||
self._last_proposal[(role_name, target.key)] = now
|
||||
return self.propose(role_name, target)
|
||||
result = self.propose(role_name, target)
|
||||
self._proposals[(role_name, target.key)] = (now, result[2] is True)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import random
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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.membership import MemberRecord
|
||||
from colony.core.roles.role import Role, Command
|
||||
@@ -26,7 +26,7 @@ class Gossip(Role):
|
||||
|
||||
BOOTSTRAP_REPROBE = 5
|
||||
DENIED_BACKOFF_CAP = 60
|
||||
SCORES_TTL = 30
|
||||
HARDWARE_TTL = 5
|
||||
MAX_SEEDS = 64
|
||||
|
||||
class COMMANDS(Enum):
|
||||
@@ -53,7 +53,8 @@ class Gossip(Role):
|
||||
self.next_reprobe = 0.0
|
||||
self.denied_attempts = 0
|
||||
self._scores = {}
|
||||
self._scores_at = None
|
||||
self._stats = {}
|
||||
self._hardware_at = None
|
||||
self._persisted_peers = None
|
||||
|
||||
@property
|
||||
@@ -153,19 +154,20 @@ class Gossip(Role):
|
||||
return FAILED
|
||||
return ADMITTED
|
||||
|
||||
def scores(self):
|
||||
def hardware(self):
|
||||
now = self.membership.now()
|
||||
if self._scores_at is None or now - self._scores_at >= self.SCORES_TTL:
|
||||
self._scores = self.collect_scores()
|
||||
if self._hardware_at is None or now - self._hardware_at >= self.HARDWARE_TTL:
|
||||
self._scores, self._stats = self.collect_hardware()
|
||||
if self._scores:
|
||||
self._scores_at = now
|
||||
return self._scores
|
||||
self._hardware_at = now
|
||||
return self._scores, self._stats
|
||||
|
||||
def collect_scores(self):
|
||||
return collect_scores()
|
||||
def collect_hardware(self):
|
||||
return collect_hardware()
|
||||
|
||||
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):
|
||||
if not addresses:
|
||||
|
||||
@@ -38,7 +38,10 @@ class RoleManager:
|
||||
continue
|
||||
|
||||
self.logger.debug(f"Creating instance of role {role_name}. ")
|
||||
roles[role_name] = role_cls(context=context)
|
||||
try:
|
||||
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.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.proposal_stagger": 2,
|
||||
"foreman.proposal_cooldown": 30,
|
||||
"web.host": "0.0.0.0",
|
||||
"web.port": 8080,
|
||||
"role_policy.max_roles": None,
|
||||
"role_policy.deny": [],
|
||||
"role_policy.min_scores": {},
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
"proposal_stagger": 2,
|
||||
"proposal_cooldown": 30
|
||||
},
|
||||
"web": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080
|
||||
},
|
||||
"role_policy": {
|
||||
"max_roles": null,
|
||||
"deny": [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
dependency-injector==4.48.3
|
||||
dynaconf==3.2.12
|
||||
Flask==3.1.3
|
||||
GPUtil==1.4.0
|
||||
psutil==7.2.1
|
||||
setuptools==80.9.0
|
||||
+11
-1
@@ -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.gossip.role import Gossip
|
||||
from colony.core.roles.vaultwarden.role import VaultWarden
|
||||
from colony.core.roles.webview.role import WebView
|
||||
|
||||
available_roles = [
|
||||
RoleStats(
|
||||
@@ -31,5 +32,14 @@ available_roles = [
|
||||
RoleStats(
|
||||
cls=Explorer,
|
||||
priority=RolePriority.HIGH,
|
||||
)
|
||||
),
|
||||
RoleStats(
|
||||
cls=WebView,
|
||||
priority=RolePriority.LOW,
|
||||
hardware_preferences=HardwarePreferences(
|
||||
{
|
||||
HardwareType.LONGEVITY: 3,
|
||||
}
|
||||
)
|
||||
),
|
||||
]
|
||||
Reference in new issue
Block a user