web view
Build and Push Docker Image / build-and-push (push) Successful in 20s

This commit is contained in:
ciomek committed 2026-09-19 22:08:09 +02:00
1 parent 48349569e7
commit 4bfaed6184
19 files changed
+757 -43

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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)
+4 -3
View File
@@ -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 {}, {}
+2 -2
View File
@@ -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
+32
View File
@@ -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:
+9 -2
View File
@@ -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):
+15 -8
View File
@@ -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:
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
+13 -11
View File
@@ -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:
+3
View File
@@ -38,7 +38,10 @@ class RoleManager:
continue
self.logger.debug(f"Creating instance of role {role_name}. ")
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.
+30
View File
@@ -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
+47
View File
@@ -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()
+399
View File
@@ -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>
+114
View File
@@ -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; }
+2
View File
@@ -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": {},
+4
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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,
}
)
),
]