chatter + cli rewrite

This commit is contained in:
ciomek committed 2026-10-03 10:04:45 +02:00
1 parent 5ea93344e2
commit 275a2f5956
14 files changed
+699 -436

No files matched your search

+130 -364
View File
@@ -1,403 +1,169 @@
import argparse import argparse
import datetime
import json import json
import sys import sys
import time import time
from datetime import datetime
from colony.core.network.requests import CommandRequest
from colony.core.roles.chatter.role import Chatter
from colony.core.storages.chat import ChatStorage from colony.core.storages.chat import ChatStorage
from colony.core.roles.unit.role import Unit from colony.settings import setting
from colony.settings import configuration, setting from colony.transport import control
from colony.transport.whisperer import Whisperer
DEFAULT_TIMEOUT = 5 CLEAR = "\033[H\033[2J"
DEFAULT_INTERVAL = 0.5
HOME = "\033[H"
CLEAR_SCREEN = "\033[2J"
CLEAR_LINE = "\033[K"
CLEAR_BELOW = "\033[J"
def call(host, port, command, *args, sender=None, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
payload = CommandRequest.form(command, *args).collapse(sender=sender)
resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout)
if not isinstance(resp, dict) or resp.get("status") != "ok" or "result" not in resp:
reason = resp.get("error") if isinstance(resp, dict) else resp
raise RuntimeError(f"the node answered with an error: {reason}")
return resp["result"]
def fetch_cluster(host, port, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None): class CliError(Exception):
return call(host, port, Unit.COMMANDS.SHOW_CLUSTER, timeout=timeout, cert_file=cert_file, keyfile=keyfile) pass
def request_role(host, port, role, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None): def call(args, action, **params):
return call(host, port, Unit.COMMANDS.REQUEST_ROLE, role, timeout=timeout, cert_file=cert_file, keyfile=keyfile)
def chat_history(peer_id):
return ChatStorage().messages(peer_id)
def send_chat(own, target, text, timeout=DEFAULT_TIMEOUT):
call(target["ip"], target["port"], Chatter.COMMANDS.RECEIVE, text, sender=member_id(own), timeout=timeout)
ChatStorage().append(member_id(target), "out", text)
def member_id(member):
return f"{member['hostname']}@{member['ip']}"
def resolve_chat_target(payload, name):
members = payload["members"]
own = next(member for member in members if member["self"])
if "Chatter" not in own["roles"]:
raise LookupError(f"{own['hostname']} is not running Chatter. Start it with: colonyctl role add Chatter")
matches = [member for member in members if name in (address(member), member_id(member))]
if not matches:
matches = [member for member in members if member["hostname"].lower() == name.lower()]
chatters = [member["hostname"] for member in members if "Chatter" in member["roles"] and not member["self"]]
if not matches:
raise LookupError(f"unknown node {name!r}. Nodes running Chatter: {', '.join(chatters) or 'none'}")
if len(matches) > 1:
raise LookupError(f"{name!r} matches more than one node, use an address: {', '.join(address(member) for member in matches)}")
target = matches[0]
if target["self"]:
raise LookupError("that is the node you are connected to, pick another node")
if "Chatter" not in target["roles"]:
raise LookupError(f"{target['hostname']} is not running Chatter. Nodes running it: {', '.join(chatters) or 'none'}")
return target
def format_chat_time(at):
try: try:
moment = datetime.datetime.fromisoformat(at).astimezone() reply = control.request({"action": action, "args": params}, path=args.socket, timeout=args.timeout)
except (TypeError, ValueError): except OSError as e:
return "?" raise CliError(f"cannot reach the node at {args.socket}: {e}")
if moment.date() == datetime.date.today(): if reply.get("status") != "ok":
return moment.strftime("%H:%M:%S") raise CliError(f"the node answered with an error: {reply.get('error')}")
return moment.strftime("%Y-%m-%d %H:%M") return reply["result"]
def render_chat(messages, own_label, peer_label): def table(rows):
if not messages: widths = [max(len(str(row[i])) for row in rows) for i in range(len(rows[0]))]
return f"No messages with {peer_label} yet." return "\n".join(" ".join(str(cell).ljust(width) for cell, width in zip(row, widths)).rstrip() for row in rows)
return "\n".join(
f"{format_chat_time(message.get('at'))} {own_label if message.get('direction') == 'out' else peer_label}: {message.get('text', '')}"
for message in messages
)
def resolve_on_demand_role(payload, name): def usage(stats, name):
roles = payload["roles"] used, total = stats.get(f"{name}_used_gb"), stats.get(f"{name}_total_gb")
match = next((role for role in roles if role["name"].lower() == name.lower()), None) return f"{used:.1f}/{total:.1f} GB" if used is not None and total is not None else "-"
if match is None:
raise LookupError(f"unknown role {name!r}. Roles: {', '.join(role['name'] for role in roles)}")
if not match.get("on_demand"):
on_demand = [role["name"] for role in roles if role.get("on_demand")]
raise LookupError(
f"{match['name']} is assigned automatically, only on-demand roles can be added by hand"
+ (f" ({', '.join(on_demand)})" if on_demand else "")
)
return match["name"]
def format_age(seconds): def render_status(cluster):
seconds = int(seconds) members = cluster["members"]
if seconds < 60: nodes = [["NODE", "ADDRESS", "STATE", "CPU", "RAM", "DISK", "ROLES"]]
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m{seconds % 60:02d}s"
return f"{seconds // 3600}h{seconds % 3600 // 60:02d}m"
def table(headers, rows):
widths = [max(len(str(cell)) for cell in column) for column in zip(headers, *rows)]
lines = [" ".join(str(cell).ljust(width) for cell, width in zip(row, widths)).rstrip() for row in [headers, *rows]]
return "\n".join(lines)
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):
return f"{member['ip']}:{member['port']}"
def role_states(payload):
alive = [member for member in payload["members"] if member["alive"]]
states = []
for role in payload["roles"]:
holders = sorted(address(member) for member in alive if role["name"] in member["roles"])
required = role["required"]
limit = role.get("max", required)
if role.get("on_demand"):
state = "held" if holders else "off"
elif required is None:
state = "general"
elif len(holders) < required:
state = "unmet"
elif limit is not None and len(holders) > limit:
state = "duplicate"
else:
state = "held"
states.append({**role, "holders": holders, "state": state})
return states
def address_of(key):
return f"{key[0]}:{key[1]}" if key else ""
def render_status(payload):
members = payload["members"]
hostnames = [member["hostname"] for member in members]
labels = {
address(member): member["hostname"] if hostnames.count(member["hostname"]) == 1 else address(member)
for member in members
}
alive = [member for member in members if member["alive"]]
down = len(members) - len(alive)
self_member = next(member for member in members if member["self"])
title = f"Cluster as seen by {labels[address(self_member)]}: {len(alive)} node(s) alive"
if down:
title += f", {down} down"
show_declined = any(member["declined"] for member in members)
show_route = any(member.get("via") for member in members)
node_headers = ["NODE", "ADDRESS", "STATE", "SEEN", "ROLES"] + (["DECLINED"] if show_declined else []) + (["ROUTE"] if show_route else [])
node_rows = []
for member in members: for member in members:
label = labels[address(member)] + (" (this node)" if member["self"] else "") stats = member.get("stats") or {}
row = [ nodes.append([
label, member["hostname"] + (" (this node)" if member["self"] else ""),
address(member), f"{member['ip']}:{member['port']}",
"alive" if member["alive"] else "DOWN", ("alive" if member["alive"] else "DOWN") + (" via gateway" if member.get("via") else ""),
"-" if member["self"] else format_age(member["seen_ago"]), f"{stats['cpu_percent']:.0f}%" if "cpu_percent" in stats else "-",
usage(stats, "mem"),
usage(stats, "disk"),
" ".join(member["roles"]) or "-", " ".join(member["roles"]) or "-",
] ])
if show_declined:
row.append(" ".join(member["declined"]) or "-")
if show_route:
via = member.get("via")
if via:
row.append(f"via {labels.get(address_of(via), address_of(via))}")
else:
row.append("direct" if member.get("direct") or member["self"] else "unknown")
node_rows.append(row)
alive_addresses = {address(member) for member in alive} roles = [["ROLE", "PRIORITY", "HOLDERS"]]
role_rows = [] for role in cluster["roles"]:
for role in role_states(payload): holders = [member["hostname"] for member in members if member["alive"] and role["name"] in member["roles"]]
holders = role["holders"] empty = "off (on demand)" if role.get("on_demand") else "UNMET"
if role["state"] == "general": roles.append([role["name"], role["priority"], ", ".join(holders) or empty])
state = "ok"
else:
state = {
"held": "ok",
"off": "off (on demand)",
"unmet": f"UNMET ({len(holders)}/{role['required']})",
"duplicate": f"DUPLICATE ({len(holders)}/{role['required']})",
}[role["state"]]
if role["state"] == "general" and set(holders) == alive_addresses:
names = "all nodes"
else:
names = ", ".join(labels[holder] for holder in holders) or "-"
role_rows.append([role["name"], role["priority"], state, names])
hardware_rows = [[labels[address(member)]] + describe_stats(member.get("stats")) for member in alive] return table(nodes) + "\n\n" + table(roles)
off = [role["name"] for role in role_states(payload) if role["state"] == "off"]
sections = [ def render_chat(messages, peer):
title, if not messages:
table(node_headers, node_rows), return f"No messages with {peer} yet."
table(["ROLE", "PRIORITY", "STATE", "HOLDERS"], role_rows), return "\n".join(f"{datetime.fromisoformat(m['at']).astimezone():%H:%M} {'you' if m['direction'] == 'out' else peer}: {m['text']}" for m in messages)
table(["NODE", "CPU", "RAM", "DISK", "GPU", "UP"], hardware_rows),
def find_node(cluster, name):
matches = [
member for member in cluster["members"]
if name.lower() in (member["hostname"].lower(), f"{member['ip']}:{member['port']}", f"{member['hostname']}@{member['ip']}".lower())
] ]
if off: if len(matches) != 1:
sections.append(f"Not running (on demand): {', '.join(off)}. Start one with: colonyctl role add {off[0]}") raise CliError(f"{'no node' if not matches else 'more than one node'} matches {name!r}")
return "\n\n".join(sections) return matches[0]
def show(args, render):
if args.json or args.raw or not sys.stdout.isatty():
print(render())
return
try:
while True:
try:
text = render()
except CliError as e:
text = f"colonyctl: {e}\nretrying..."
print(CLEAR + text + "\n\nCtrl-C to quit.", flush=True)
time.sleep(args.interval)
except KeyboardInterrupt:
print()
def status(args):
def render():
cluster = call(args, "status")
return json.dumps(cluster, indent=2) if args.json else render_status(cluster)
show(args, render)
def add_role(args, name):
cluster = call(args, "status")
on_demand = {role["name"].lower(): role["name"] for role in cluster["roles"] if role.get("on_demand")}
if name.lower() not in on_demand:
raise CliError(f"{name!r} is not an on-demand role, pick one of: {', '.join(on_demand.values())}")
role = on_demand[name.lower()]
reply = call(args, "add_role", role=role)
if not reply["accepted"]:
raise CliError(f"the node did not take {role}: {reply['reason']}")
print(f"{role} is running ({reply['reason']})")
if role == "WebView":
me = next(member for member in cluster["members"] if member["self"])
print(f"Web view: http://{me['ip']}:{setting('web.port')}")
def chat(args):
peer = find_node(call(args, "status"), args.node)
peer_id = f"{peer['hostname']}@{peer['ip']}"
if args.text is not None:
call(args, "send_chat", peer=peer_id, text=args.text)
print(f"Sent to {peer['hostname']}")
else:
history = lambda: ChatStorage().messages(peer_id)
show(args, lambda: json.dumps(history(), indent=2) if args.json else render_chat(history(), peer["hostname"]))
def build_parser(): def build_parser():
parser = argparse.ArgumentParser(prog="colonyctl", description="Inspect and control a running colony node.") common = argparse.ArgumentParser(add_help=False)
common.add_argument("--socket", default=str(control.CONTROL_SOCKET), help="control socket of the local node")
common.add_argument("--timeout", type=float, default=5)
live = argparse.ArgumentParser(add_help=False)
live.add_argument("--interval", type=float, default=1, help="seconds between refreshes")
live.add_argument("--raw", action="store_true", help="print once instead of refreshing")
live.add_argument("--json", action="store_true", help="print the raw data as JSON once")
parser = argparse.ArgumentParser(prog="colonyctl", description="Inspect and control the local colony node.")
commands = parser.add_subparsers(dest="command", required=True) commands = parser.add_subparsers(dest="command", required=True)
connection = argparse.ArgumentParser(add_help=False) commands.add_parser("status", parents=[common, live], help="show nodes and roles")
connection.add_argument("--host", default="127.0.0.1", help="node to ask (default: this machine)")
connection.add_argument("--port", type=int, default=None, help="node port (default: listener.port from config)")
connection.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
status = commands.add_parser("status", parents=[connection], help="show members, roles and hardware (live in a terminal)") role = commands.add_parser("role", help="manage roles").add_subparsers(dest="role_command", required=True)
status.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="seconds between refreshes") role.add_parser("add", parents=[common], help="run an on-demand role on this node").add_argument("name")
status.add_argument("--raw", action="store_true", help="print once and exit instead of refreshing")
status.add_argument("--json", action="store_true", help="print the raw data as JSON once and exit")
role = commands.add_parser("role", help="manage roles on a node").add_subparsers(dest="role_command", required=True) commands.add_parser("webview", parents=[common], help="same as: role add WebView")
add = role.add_parser("add", parents=[connection], help="ask the node to run an on-demand role")
add.add_argument("name", help="role name, e.g. WebView (case does not matter)")
commands.add_parser("webview", parents=[connection], help="ask the node to run the web view (same as: role add WebView)") chat = commands.add_parser("chat", parents=[common, live], help="show the chat with a node, or send it a message")
chat.add_argument("node", help="hostname, ip:port or hostname@ip")
chat = commands.add_parser("chat", parents=[connection], help="show the chat with a node (live in a terminal), or send it a message; the history is read from this machine's storages/chat") chat.add_argument("text", nargs="?", help="message to send")
chat.add_argument("node", help="hostname, ip:port or hostname@ip of the node to chat with")
chat.add_argument("text", nargs="?", help="message to send; without it the history is shown")
chat.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="seconds between refreshes")
chat.add_argument("--raw", action="store_true", help="print the history once and exit instead of refreshing")
chat.add_argument("--json", action="store_true", help="print the history as JSON once and exit")
return parser return parser
def draw(out, text): def main(argv=None):
out.write(HOME + text.replace("\n", CLEAR_LINE + "\n") + CLEAR_LINE + CLEAR_BELOW)
out.flush()
def watch(host, port, args, out):
out.write(CLEAR_SCREEN)
try:
while True:
try:
text = render_status(fetch_cluster(host, port, timeout=args.timeout))
except (OSError, ValueError, RuntimeError) as e:
text = f"colonyctl: cannot get status from {host}:{port}: {e}\nretrying..."
footer = f"Every {args.interval:g}s. Updated {time.strftime('%H:%M:%S')}. Ctrl-C to quit."
draw(out, f"{text}\n\n{footer}")
time.sleep(args.interval)
except KeyboardInterrupt:
out.write("\n")
return 0
def watch_chat(args, port, target, own, out):
peer_label, own_label = target["hostname"], f"{own['hostname']} (you)"
out.write(CLEAR_SCREEN)
try:
while True:
try:
text = render_chat(chat_history(member_id(target)), own_label, peer_label)
except (OSError, ValueError, RuntimeError) as e:
text = f"colonyctl: cannot read the chat: {e}\nretrying..."
footer = f"Chat with {peer_label}. Every {args.interval:g}s. Updated {time.strftime('%H:%M:%S')}. Ctrl-C to quit."
footer += f'\nSend with: colonyctl chat {target["hostname"]} "message"'
draw(out, f"{text}\n\n{footer}")
time.sleep(args.interval)
except KeyboardInterrupt:
out.write("\n")
return 0
def chat_command(args, port, out):
try:
payload = fetch_cluster(args.host, port, timeout=args.timeout)
target = resolve_chat_target(payload, args.node)
own = next(member for member in payload["members"] if member["self"])
if args.text is not None:
send_chat(own, target, args.text, timeout=args.timeout)
elif not args.raw and not args.json and out.isatty():
return watch_chat(args, port, target, own, out)
else:
history = chat_history(member_id(target))
except LookupError as e:
print(f"colonyctl: {e}", file=sys.stderr)
return 1
except (OSError, ValueError, RuntimeError) as e:
print(f"colonyctl: cannot reach {args.host}:{port}: {e}", file=sys.stderr)
return 1
if args.text is not None:
print(f"Sent to {target['hostname']}", file=out)
elif args.json:
print(json.dumps(history, indent=2), file=out)
else:
print(render_chat(history, f"{own['hostname']} (you)", target["hostname"]), file=out)
return 0
def add_role(args, port, name, out):
try:
payload = fetch_cluster(args.host, port, timeout=args.timeout)
role = resolve_on_demand_role(payload, name)
reply = request_role(args.host, port, role, timeout=args.timeout)
except LookupError as e:
print(f"colonyctl: {e}", file=sys.stderr)
return 1
except (OSError, ValueError, RuntimeError) as e:
print(f"colonyctl: cannot reach {args.host}:{port}: {e}", file=sys.stderr)
return 1
node = next(member for member in payload["members"] if member["self"])
if not reply.get("accepted"):
print(f"colonyctl: {node['hostname']} did not take {role}: {reply.get('reason')}", file=sys.stderr)
return 1
print(f"{role} is running on {node['hostname']} ({reply.get('reason')})", file=out)
if role == "WebView":
print(f"Web view: http://{node['ip']}:{setting('web.port')}", file=out)
return 0
def main(argv=None, out=None):
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
out = sys.stdout if out is None else out
port = args.port if args.port is not None else configuration.listener.port
if args.command == "role":
return add_role(args, port, args.name, out)
if args.command == "webview":
return add_role(args, port, "WebView", out)
if args.command == "chat":
return chat_command(args, port, out)
if not args.raw and not args.json and out.isatty():
return watch(args.host, port, args, out)
try: try:
payload = fetch_cluster(args.host, port, timeout=args.timeout) if args.command == "status":
except (OSError, ValueError, RuntimeError) as e: status(args)
print(f"colonyctl: cannot get status from {args.host}:{port}: {e}", file=sys.stderr) elif args.command == "role":
add_role(args, args.name)
elif args.command == "webview":
add_role(args, "WebView")
elif args.command == "chat":
chat(args)
except CliError as e:
print(f"colonyctl: {e}", file=sys.stderr)
return 1 return 1
print(json.dumps(payload, indent=2) if args.json else render_status(payload), file=out)
return 0 return 0
+34
View File
@@ -0,0 +1,34 @@
class ControlActions:
def __init__(self, network):
self.network = network
self.actions = {
"status": self.status,
"add_role": self.add_role,
"send_chat": self.send_chat,
}
def run(self, payload):
action = payload.get("action") if isinstance(payload, dict) else None
handler = self.actions.get(action)
if handler is None:
raise ValueError(f"Unknown action {action!r}")
args = payload.get("args", {})
if not isinstance(args, dict):
raise ValueError("Action args must be a dict")
return handler(**args)
def _role(self, name):
role = self.network.get_self().get_role_instances().get(name)
if role is None:
raise LookupError(f"{name} is not running on this node")
return role
def status(self):
return self._role("Unit").show_cluster()
def add_role(self, role):
return self._role("Unit").request_role(role)
def send_chat(self, peer, text):
self._role("Chatter").send(peer, text)
+13 -3
View File
@@ -2,6 +2,7 @@ from enum import Enum
from colony.core.roles.role import Role, Command from colony.core.roles.role import Role, Command
from colony.core.storages.chat import ChatStorage from colony.core.storages.chat import ChatStorage
from colony.settings import setting
MAX_MESSAGE_LENGTH = 4000 MAX_MESSAGE_LENGTH = 4000
@@ -16,6 +17,7 @@ class Chatter(Role):
} }
self.storage = storage or ChatStorage() self.storage = storage or ChatStorage()
self.max_messages = setting("chat.max_messages")
super().__init__(commands, context) super().__init__(commands, context)
@@ -28,14 +30,22 @@ class Chatter(Role):
if response.get("status") != "ok": if response.get("status") != "ok":
raise ValueError(f"{peer_id} refused the message") raise ValueError(f"{peer_id} refused the message")
self.storage.append(peer_id, "out", text) self.storage.append(peer_id, "out", text, self.max_messages)
def receive(self, text, caller_id): def receive(self, text, caller_id):
if not isinstance(text, str) or not text.strip() or len(text) > MAX_MESSAGE_LENGTH: if not isinstance(text, str) or not text.strip() or len(text) > MAX_MESSAGE_LENGTH:
raise ValueError("Invalid message") raise ValueError("Invalid message")
self.storage.append(caller_id, "in", text) self.storage.append(caller_id, "in", text, self.max_messages)
return True return True
def history(self, peer_id, after=0): def history(self, peer_id, after=0):
return self.storage.messages(peer_id)[after:] return [message for message in self.storage.messages(peer_id) if message["seq"] > after]
def inbox(self):
peers = {}
for peer_id in self.storage.peers():
messages = self.storage.messages(peer_id)
incoming = [message for message in messages if message["direction"] == "in"]
peers[peer_id] = {"incoming": messages[-1]["received"] if messages else 0, "last": incoming[-1] if incoming else None}
return peers
+26 -1
View File
@@ -1,3 +1,4 @@
import hmac
import logging import logging
from pathlib import Path from pathlib import Path
@@ -8,13 +9,29 @@ STATIC_DIR = Path(__file__).parent / "static"
logging.getLogger("werkzeug").setLevel(logging.WARNING) logging.getLogger("werkzeug").setLevel(logging.WARNING)
def create_app(get_status, get_chatter): def create_app(get_status, get_chatter, credentials=None):
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static") app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static")
def no_store(response): def no_store(response):
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
return response return response
def authorized():
auth = request.authorization
if auth is None or auth.type != "basic":
return False
username, password = credentials
return hmac.compare_digest((auth.username or "").encode(), username.encode()) & \
hmac.compare_digest((auth.password or "").encode(), password.encode())
@app.before_request
def require_login():
if credentials is not None and not authorized():
response = jsonify(error="login required")
response.status_code = 401
response.headers["WWW-Authenticate"] = 'Basic realm="Colony", charset="UTF-8"'
return response
@app.get("/") @app.get("/")
def index(): def index():
return app.send_static_file("index.html") return app.send_static_file("index.html")
@@ -60,4 +77,12 @@ def create_app(get_status, get_chatter):
return jsonify(error=f"{body['peer']} cannot be reached"), 502 return jsonify(error=f"{body['peer']} cannot be reached"), 502
return jsonify(sent=True) return jsonify(sent=True)
@app.get("/api/chat/inbox")
def chat_inbox():
chatter = get_chatter()
if chatter is None:
return jsonify(error="Chatter is not running on this node"), 409
return no_store(jsonify(chatter.inbox()))
return app return app
+4 -1
View File
@@ -21,7 +21,8 @@ class WebView(Role):
def __init__(self, context): def __init__(self, context):
host, port = setting("web.host"), setting("web.port") host, port = setting("web.host"), setting("web.port")
self.app = create_app(self._status, self._chatter) username, password = setting("web.username"), setting("web.password")
self.app = create_app(self._status, self._chatter, (username, password) if password else None)
try: try:
self._server = make_server(host, port, self.app, threaded=True) self._server = make_server(host, port, self.app, threaded=True)
except SystemExit: except SystemExit:
@@ -143,6 +144,8 @@ class WebView(Role):
threading.Thread(target=self._refresh_loop, daemon=True, name="WebViewTopology").start() threading.Thread(target=self._refresh_loop, daemon=True, name="WebViewTopology").start()
self.logger.info(f"Web view at http://{self.context.self().ip}:{self.port}") self.logger.info(f"Web view at http://{self.context.self().ip}:{self.port}")
if not setting("web.password"):
self.logger.warning("The web view has no password, set web.password to require one")
def stop(self): def stop(self):
super().stop() super().stop()
+269 -11
View File
@@ -299,15 +299,21 @@
const texts = { name: svg("text", { class: "l-name" }), roles: [svg("text", { class: "l-roles" }), svg("text", { class: "l-roles" })], caption: svg("text", { class: "l-caption" }) }; const texts = { name: svg("text", { class: "l-name" }), roles: [svg("text", { class: "l-roles" }), svg("text", { class: "l-roles" })], caption: svg("text", { class: "l-caption" }) };
[title, halo, shape, dot, texts.name, texts.roles[0], texts.roles[1], texts.caption].forEach((part) => group.appendChild(part)); [title, halo, shape, dot, texts.name, texts.roles[0], texts.roles[1], texts.caption].forEach((part) => group.appendChild(part));
const badge = svg("g", { class: "badge" });
const badgeText = svg("text", { "text-anchor": "middle", y: 3.5 });
badge.appendChild(svg("circle", { r: 9 }));
badge.appendChild(badgeText);
/* Hardware: three bars with their numbers, and a footer. */ /* Hardware: three bars with their numbers, and a footer. */
const stats = svg("g", { class: "stats" }); const stats = svg("g", { class: "stats" });
const rows = Array.from({ length: C.STAT_COUNT }, () => statBar(stats, C.STAT_W)); const rows = Array.from({ length: C.STAT_COUNT }, () => statBar(stats, C.STAT_W));
const footer = svg("text", { class: "st-foot" }); const footer = svg("text", { class: "st-foot" });
stats.appendChild(footer); stats.appendChild(footer);
group.appendChild(stats); group.appendChild(stats);
group.appendChild(badge);
layers.nodes.appendChild(group); layers.nodes.appendChild(group);
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, gateway: node.gateway }; return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, badge: badge, badgeText: badgeText, gateway: node.gateway };
} }
function drawNodes() { function drawNodes() {
@@ -364,11 +370,28 @@
put(record.footer, "y", C.STAT_COUNT * C.STAT_ROW + 10); put(record.footer, "y", C.STAT_COUNT * C.STAT_ROW + 10);
}); });
prune(nodeRecords, present); prune(nodeRecords, present);
drawUnread();
}
function drawUnread() {
let total = 0;
nodeRecords.forEach((record, key) => {
const info = model.byKey.get(key);
const count = info ? unreadOf(info.id) : 0;
total += count;
const corner = record.node.r * 0.8;
put(record.badge, "transform", "translate(" + corner + "," + -corner + ")");
put(record.badge, "display", count ? "inline" : "none");
putText(record.badgeText, count > 9 ? "9+" : String(count));
});
document.title = total ? "(" + total + ") Colony" : "Colony";
drawBell(total);
} }
/* The tooltip and the screen reader's text carry the numbers too, so they follow them. */ /* The tooltip and the screen reader's text carry the numbers too, so they follow them. */
function describe(record, info) { function describe(record, info) {
const text = C.describeNode(model, info, routes); const unread = unreadOf(info.id);
const text = C.describeNode(model, info, routes) + (unread ? "\n" + C.plural(unread, "unread message") : "");
putText(record.title, text); putText(record.title, text);
put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node."); put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node.");
} }
@@ -851,7 +874,7 @@
}); });
const [chatPanel, chatTitle, chatStatus, chatLog, chatEmpty, chatError, chatForm, chatInput, chatSend, chatClose, chatLive] = ["chat", "chat-title", "chat-status", "chat-log", "chat-empty", "chat-error", "chat-form", "chat-input", "chat-send", "chat-close", "chat-live"].map((id) => document.getElementById(id)); const [chatPanel, chatTitle, chatStatus, chatLog, chatEmpty, chatError, chatForm, chatInput, chatSend, chatClose, chatLive] = ["chat", "chat-title", "chat-status", "chat-log", "chat-empty", "chat-error", "chat-form", "chat-input", "chat-send", "chat-close", "chat-live"].map((id) => document.getElementById(id));
let chat = { peer: null, count: 0, loaded: false, loading: false }; let chat = { peer: null, after: 0, loaded: false, loading: false };
const chatTarget = () => (model && selectionLocks() && selection.id !== model.selfKey ? model.byKey.get(selection.id) : null); const chatTarget = () => (model && selectionLocks() && selection.id !== model.selfKey ? model.byKey.get(selection.id) : null);
const chatTime = (at) => { const chatTime = (at) => {
@@ -864,11 +887,11 @@
chatPanel.hidden = !peer; chatPanel.hidden = !peer;
stage.classList.toggle("chatting", Boolean(peer)); stage.classList.toggle("chatting", Boolean(peer));
if (!peer) { if (!peer) {
chat = { peer: null, count: 0, loaded: false, loading: false }; chat = { peer: null, after: 0, loaded: false, loading: false };
return; return;
} }
if (chat.peer !== peer.id) { if (chat.peer !== peer.id) {
chat = { peer: peer.id, hostname: peer.hostname, count: 0, loaded: false, loading: false }; chat = { peer: peer.id, hostname: peer.hostname, after: 0, loaded: false, loading: false };
chatTitle.textContent = "Chat with " + peer.hostname; chatTitle.textContent = "Chat with " + peer.hostname;
chatLog.replaceChildren(); chatLog.replaceChildren();
chatEmpty.hidden = true; chatEmpty.hidden = true;
@@ -876,9 +899,15 @@
chatInput.value = ""; chatInput.value = "";
} }
const state = C.chatState(model, peer.key); const state = C.chatState(model, peer.key);
chatStatus.textContent = state.text; const ignored = isIgnored(peer.id);
chatStatus.hidden = !state.text; const status = [ignored ? "You are ignoring " + peer.hostname + " in this browser: no notifications or unread counts for them." : "", state.text].filter(Boolean).join(" ");
chatStatus.textContent = status;
chatStatus.hidden = !status;
chatForm.hidden = !state.send; chatForm.hidden = !state.send;
chatIgnore.hidden = !inbox || !state.read;
chatIgnore.textContent = ignored ? "Unignore" : "Ignore";
chatIgnore.title = ignored ? "Get notified about " + peer.hostname + " again" : "Stop notifications and unread counts for " + peer.hostname;
chatIgnore.setAttribute("aria-pressed", ignored ? "true" : "false");
if (state.read) loadChat(); if (state.read) loadChat();
} }
@@ -903,14 +932,15 @@
if (current.loading || !current.peer) return; if (current.loading || !current.peer) return;
current.loading = true; current.loading = true;
try { try {
const response = await fetch("/api/chat?peer=" + encodeURIComponent(current.peer) + "&after=" + current.count, { cache: "no-store" }); const response = await fetch("/api/chat?peer=" + encodeURIComponent(current.peer) + "&after=" + current.after, { cache: "no-store" });
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status); if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
if (chat !== current) return; if (chat !== current) return;
paintChatMessages(body.messages, current.loaded); paintChatMessages(body.messages, current.loaded);
current.count += body.messages.length; if (body.messages.length) current.after = body.messages[body.messages.length - 1].seq;
current.loaded = true; current.loaded = true;
chatEmpty.hidden = current.count > 0; if (!document.hidden) markSeen(current.peer);
chatEmpty.hidden = chatLog.children.length > 0;
if (chatError.dataset.from === "load") chatError.hidden = true; if (chatError.dataset.from === "load") chatError.hidden = true;
} catch (error) { } catch (error) {
if (chat === current) { if (chat === current) {
@@ -954,6 +984,229 @@
} }
}); });
/* ------------------------------------------------------------ notifications */
const SEEN_KEY = "colony.chat.seen.v1";
const IGNORED_KEY = "colony.chat.ignored.v1";
const TOAST_MS = 8000;
const TOAST_LIMIT = 3;
const [notifyButton, toastBox, chatIgnore, bellWrap, bell, bellCount, inboxPanel, inboxList, inboxEmpty, inboxRead] = ["notify", "toasts", "chat-ignore", "bell-wrap", "bell", "bell-count", "inbox-panel", "inbox-list", "inbox-empty", "inbox-read"].map((id) => document.getElementById(id));
let inbox = null;
let inboxText = "";
let inboxBusy = false;
let inboxSignature = "";
let seen = loadStored(SEEN_KEY);
const ignoredPeers = new Set(Object.keys(loadStored(IGNORED_KEY) || {}));
function loadStored(key) {
try {
const value = JSON.parse(window.localStorage.getItem(key));
return value && typeof value === "object" ? value : null;
} catch (error) {
return null;
}
}
function saveStored(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
/* private mode: this is forgotten with the page */
}
}
const saveSeen = () => saveStored(SEEN_KEY, seen);
const isIgnored = (peerId) => ignoredPeers.has(peerId);
const unreadOf = (peerId) => {
const entry = inbox && !isIgnored(peerId) ? inbox[peerId] : null;
return entry ? Math.max(0, entry.incoming - ((seen && seen[peerId]) || 0)) : 0;
};
const peerName = (peerId) => {
const node = model && model.nodes.find((entry) => entry.id === peerId);
return node ? node.hostname : peerId.split("@")[0];
};
function markSeen(peerId) {
const entry = inbox && inbox[peerId];
if (!entry || !seen || seen[peerId] === entry.incoming) return;
seen[peerId] = entry.incoming;
saveSeen();
if (model) drawUnread();
}
function drawBell(total) {
bellWrap.hidden = !inbox;
if (!inbox) toggleInbox(false);
bellCount.hidden = !total;
bellCount.textContent = total > 99 ? "99+" : String(total);
bell.classList.toggle("unread", total > 0);
bell.setAttribute("aria-label", total ? "Messages, " + C.plural(total, "unread message") : "Messages");
drawInbox();
}
function drawInbox() {
if (inboxPanel.hidden || !inbox) return;
const conversations = Object.keys(inbox)
.filter((peerId) => inbox[peerId].last && !isIgnored(peerId))
.sort((a, b) => (inbox[b].last.at || "").localeCompare(inbox[a].last.at || ""));
const signature = JSON.stringify(conversations.map((peerId) => [peerId, peerName(peerId), unreadOf(peerId), inbox[peerId].last]));
if (signature === inboxSignature) return;
inboxSignature = signature;
inboxList.replaceChildren();
conversations.forEach((peerId) => {
const unread = unreadOf(peerId);
const last = inbox[peerId].last;
const row = html("button", undefined, "inbox-row" + (unread ? " unread" : ""));
row.type = "button";
row.appendChild(html("span", peerName(peerId), "inbox-name"));
const time = html("time", chatTime(last.at), "inbox-time");
time.dateTime = last.at;
row.appendChild(time);
row.appendChild(html("span", last.text, "inbox-text"));
if (unread) row.appendChild(html("span", unread > 99 ? "99+" : String(unread), "inbox-count"));
row.setAttribute("aria-label", peerName(peerId) + (unread ? ", " + C.plural(unread, "unread message") : "") + ": " + last.text);
row.addEventListener("click", () => openChat(peerId));
const item = html("li");
item.appendChild(row);
inboxList.appendChild(item);
});
inboxEmpty.hidden = conversations.length > 0;
inboxRead.disabled = !conversations.some((peerId) => unreadOf(peerId));
}
function toggleInbox(open) {
if (open === !inboxPanel.hidden) return;
inboxPanel.hidden = !open;
inboxSignature = "";
bell.setAttribute("aria-expanded", open ? "true" : "false");
if (open) {
toastBox.replaceChildren();
updateNotifyButton();
drawInbox();
}
}
function markAllRead() {
Object.keys(inbox).forEach((peerId) => (seen[peerId] = inbox[peerId].incoming));
saveSeen();
drawUnread();
drawStats();
}
function openChat(peerId) {
const node = model && model.nodes.find((entry) => entry.id === peerId);
toggleInbox(false);
if (!node) return;
focusKey = node.key;
if (!isPinned(node.key)) select({ kind: "node", id: node.key });
chatInput.focus();
}
function showToast(peerId, name, text) {
const toast = html("button", undefined, "toast");
toast.type = "button";
toast.appendChild(html("strong", name));
toast.appendChild(html("span", text));
toast.addEventListener("click", () => {
toast.remove();
openChat(peerId);
});
toastBox.appendChild(toast);
while (toastBox.children.length > TOAST_LIMIT) toastBox.firstChild.remove();
setTimeout(() => toast.remove(), TOAST_MS);
}
function notifyMessage(peerId, message, count) {
const name = peerName(peerId);
const text = count > 1 ? C.plural(count, "new message") + ", last: " + message.text : message.text;
if (inboxPanel.hidden) showToast(peerId, name, text);
if (!("Notification" in window) || Notification.permission !== "granted" || (!document.hidden && document.hasFocus())) return;
const notification = new Notification("Message from " + name, { body: text, tag: "colony-chat-" + peerId });
notification.addEventListener("click", () => {
window.focus();
openChat(peerId);
notification.close();
});
}
function handleInbox(next) {
const previous = inbox;
inbox = next;
if (!seen) {
seen = {};
Object.keys(next).forEach((peerId) => (seen[peerId] = next[peerId].incoming));
saveSeen();
}
Object.keys(next).forEach((peerId) => {
const entry = next[peerId];
const before = previous ? (previous[peerId] || { incoming: 0 }).incoming : entry.incoming;
if (entry.incoming <= before || isIgnored(peerId) || !entry.last) return;
if (chat.peer === peerId && !document.hidden) return;
notifyMessage(peerId, entry.last, entry.incoming - before);
});
if (model) {
drawUnread();
drawStats();
updateChat();
}
}
async function pollInbox() {
if (inboxBusy) return;
inboxBusy = true;
try {
const response = await fetch("/api/chat/inbox", { cache: "no-store" });
if (response.status === 409 && inbox) {
inboxText = "";
handleInbox(null);
}
if (!response.ok) return;
const text = await response.text();
if (text === inboxText) return;
inboxText = text;
handleInbox(JSON.parse(text));
} catch (error) {
/* the status refresh already shows when the node cannot be reached */
} finally {
inboxBusy = false;
}
}
function updateNotifyButton() {
notifyButton.hidden = !("Notification" in window) || !window.isSecureContext || Notification.permission !== "default";
}
bell.addEventListener("click", () => toggleInbox(inboxPanel.hidden));
inboxRead.addEventListener("click", markAllRead);
bellWrap.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || inboxPanel.hidden) return;
event.preventDefault();
toggleInbox(false);
bell.focus();
});
document.addEventListener("pointerdown", (event) => {
if (!inboxPanel.hidden && !bellWrap.contains(event.target)) toggleInbox(false);
});
notifyButton.addEventListener("click", async () => {
await Notification.requestPermission();
updateNotifyButton();
});
chatIgnore.addEventListener("click", () => {
const peerId = chat.peer;
if (!peerId) return;
const ignored = !isIgnored(peerId);
if (ignored) ignoredPeers.add(peerId);
else ignoredPeers.delete(peerId);
saveStored(IGNORED_KEY, Object.fromEntries(Array.from(ignoredPeers, (id) => [id, true])));
if (!ignored) markSeen(peerId);
announce((ignored ? "Ignoring " : "No longer ignoring ") + chat.hostname);
drawUnread();
drawStats();
updateChat();
});
chatClose.addEventListener("click", () => select(null)); chatClose.addEventListener("click", () => select(null));
chatPanel.addEventListener("keydown", (event) => { chatPanel.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
@@ -988,10 +1241,15 @@
} }
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!document.hidden) refresh(); if (document.hidden) return;
refresh();
if (chat.peer && chat.loaded) markSeen(chat.peer);
}); });
svgRoot.setAttribute("viewBox", "0 0 " + size.w + " " + size.h); svgRoot.setAttribute("viewBox", "0 0 " + size.w + " " + size.h);
updateControls(); updateControls();
updateNotifyButton();
refresh(); refresh();
setInterval(refresh, REFRESH_MS); setInterval(refresh, REFRESH_MS);
pollInbox();
setInterval(pollInbox, REFRESH_MS);
})(); })();
@@ -16,6 +16,22 @@
<div class="headline"> <div class="headline">
<h1>Colony</h1> <h1>Colony</h1>
<p id="summary" class="summary">Loading...</p> <p id="summary" class="summary">Loading...</p>
<div id="bell-wrap" class="bell-wrap" hidden>
<button id="bell" type="button" class="bell" aria-expanded="false" aria-controls="inbox-panel" aria-label="Messages" title="Messages">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 14V9a5 5 0 0 1 10 0v5l1.5 1.5h-13zM8.2 17.5a2 2 0 0 0 3.6 0"/></svg>
<span id="bell-count" class="bell-count" hidden></span>
</button>
<div id="toasts" class="toasts" aria-live="polite"></div>
<section id="inbox-panel" class="inbox-panel" aria-labelledby="inbox-title" hidden>
<div class="inbox-head">
<h2 id="inbox-title">Messages</h2>
<button id="inbox-read" type="button">Mark all read</button>
</div>
<ol id="inbox-list" class="inbox-list"></ol>
<p id="inbox-empty" class="inbox-empty muted" hidden>No messages yet.</p>
<button id="notify" type="button" class="notify" hidden>Turn on desktop notifications</button>
</section>
</div>
</div> </div>
<div id="alerts" class="alerts"></div> <div id="alerts" class="alerts"></div>
<p id="error" class="error" hidden></p> <p id="error" class="error" hidden></p>
@@ -69,6 +85,7 @@
<aside id="chat" class="chat" aria-labelledby="chat-title" hidden> <aside id="chat" class="chat" aria-labelledby="chat-title" hidden>
<div class="chat-head"> <div class="chat-head">
<h2 id="chat-title"></h2> <h2 id="chat-title"></h2>
<button id="chat-ignore" type="button" class="chat-ignore" aria-pressed="false" hidden>Ignore</button>
<button id="chat-close" type="button" aria-label="Close the chat" title="Close the chat (Escape)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg></button> <button id="chat-close" type="button" aria-label="Close the chat" title="Close the chat (Escape)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg></button>
</div> </div>
<p id="chat-status" class="chat-status" hidden></p> <p id="chat-status" class="chat-status" hidden></p>
@@ -242,6 +242,54 @@ p { margin: 4px 0; }
#stage.chatting .controls { right: 364px; } #stage.chatting .controls { right: 364px; }
@media (pointer: coarse) { .chat-form input, .chat-form button, .chat-head button { min-height: 44px; } .chat-head button { min-width: 44px; } } @media (pointer: coarse) { .chat-form input, .chat-form button, .chat-head button { min-height: 44px; } .chat-head button { min-width: 44px; } }
.chat-head .chat-ignore { min-width: 0; padding: 0 10px; border: 1px solid var(--line); font: inherit; font-size: 13px; color: var(--muted); }
.chat-head .chat-ignore[aria-pressed="true"] { border-color: var(--crit); color: var(--crit); }
.chat-head button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.badge { pointer-events: none; }
.badge circle { fill: var(--crit); stroke: var(--card); stroke-width: 2; vector-effect: non-scaling-stroke; }
.badge text { fill: #fff; font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums; }
.bell-wrap { position: relative; z-index: 5; margin-left: auto; align-self: center; margin-top: 8px; }
.bell { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; padding: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--card); color: var(--text); cursor: pointer; }
.bell:hover, .bell[aria-expanded="true"] { border-color: var(--muted); }
.bell:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.bell svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.bell.unread svg { stroke: var(--n0); }
.bell-count { position: absolute; top: -6px; right: -6px; min-width: 19px; height: 19px; padding: 0 5px; border: 2px solid var(--bg); border-radius: 999px; background: var(--crit); color: #fff; font-size: 11px; font-weight: 700; line-height: 15px; text-align: center; font-variant-numeric: tabular-nums; }
.toasts, .inbox-panel { position: absolute; top: calc(100% + 8px); right: 0; width: min(340px, calc(100vw - 24px)); }
.toasts { display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { display: block; width: 100%; padding: 10px 14px; border: 1px solid var(--line); border-left: 4px solid var(--n0); border-radius: 10px; background: var(--card); color: var(--text); font: inherit; text-align: left; cursor: pointer; pointer-events: auto; box-shadow: 0 6px 24px color-mix(in srgb, var(--text) 14%, transparent); }
.toast:hover { border-color: var(--muted); border-left-color: var(--n0); }
.toast:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.toast strong { display: block; font-size: 13px; }
.toast span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: 13px; }
.inbox-panel { display: flex; flex-direction: column; max-height: min(480px, 70dvh); border: 1px solid var(--line); border-radius: 10px; background: var(--card); box-shadow: 0 10px 32px color-mix(in srgb, var(--text) 16%, transparent); }
.inbox-head { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--line); }
.inbox-head h2 { flex: 1; margin: 0; font-size: 15px; }
.inbox-head button, .notify { height: 30px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--muted); font: inherit; font-size: 13px; cursor: pointer; }
.inbox-head button:hover, .notify:hover { border-color: var(--muted); color: var(--text); }
.inbox-head button:disabled { opacity: 0.5; cursor: default; }
.inbox-head button:focus-visible, .notify:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.inbox-list { flex: 1; min-height: 0; overflow-y: auto; margin: 0; padding: 4px; list-style: none; }
.inbox-row { display: grid; grid-template-columns: 1fr auto; gap: 1px 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
.inbox-row:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
.inbox-row:focus-visible { outline: 2px solid var(--text); outline-offset: -2px; }
.inbox-name { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inbox-time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.inbox-text { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.inbox-count { justify-self: end; min-width: 19px; height: 19px; padding: 0 5px; border-radius: 999px; background: var(--n0); color: var(--card); font-size: 11px; font-weight: 700; line-height: 19px; text-align: center; }
.inbox-row.unread .inbox-text { color: var(--text); }
.inbox-empty { margin: 0; padding: 16px 14px; text-align: center; }
.notify { margin: 0 10px 10px; color: var(--n0); border-color: color-mix(in srgb, var(--n0) 50%, transparent); }
@media (prefers-reduced-motion: no-preference) {
.toast, .inbox-panel { animation: drop-in 0.18s ease-out; }
}
@keyframes drop-in { from { opacity: 0; transform: translateY(-6px); } }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Phones: the desktop sizes are small on a 400px screen, so the page text and touch targets grow. */ /* Phones: the desktop sizes are small on a 400px screen, so the page text and touch targets grow. */
+25 -4
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import os import os
import threading import threading
from urllib.parse import quote from urllib.parse import quote, unquote
from colony.core.storages.core import Storage, STORAGES_DIR from colony.core.storages.core import Storage, STORAGES_DIR
@@ -19,11 +19,32 @@ class ChatStorage(Storage):
return quote(peer_id, safe="") + ".json" return quote(peer_id, safe="") + ".json"
def messages(self, peer_id): def messages(self, peer_id):
return self.open(self._file(peer_id)) or [] messages = self.open(self._file(peer_id)) or []
received = 0
for seq, message in enumerate(messages, 1):
received = message.setdefault("received", received + (message["direction"] == "in"))
message.setdefault("seq", seq)
return messages
def append(self, peer_id, direction, text): def append(self, peer_id, direction, text, keep=None):
at = datetime.datetime.now(datetime.timezone.utc).isoformat() at = datetime.datetime.now(datetime.timezone.utc).isoformat()
with self._lock: with self._lock:
messages = self.messages(peer_id) messages = self.messages(peer_id)
messages.append({"direction": direction, "text": text, "at": at}) last = messages[-1] if messages else {"seq": 0, "received": 0}
messages.append({
"direction": direction,
"text": text,
"at": at,
"seq": last["seq"] + 1,
"received": last["received"] + (direction == "in"),
})
if keep:
messages = messages[-keep:]
self.save(self._file(peer_id), messages) self.save(self._file(peer_id), messages)
def peers(self):
peers = []
for filename in self.ls(""):
peer_id = unquote(filename.removesuffix(".json"))
peers.append(peer_id)
return sorted(peers)
+5 -46
View File
@@ -6,54 +6,13 @@ configuration = Dynaconf(
load_dotenv=False, load_dotenv=False,
) )
DEFAULTS = { MISSING = object()
"discovery.range_start": "192.168.65.110",
"discovery.range_end": "192.168.65.120",
"discovery.discover_port": 28149,
"discovery.probe_timeout": 1.0,
"discovery.probe_workers": 64,
"membership.gossip_interval": 5,
"membership.member_timeout": 20,
"membership.reprobe_interval": 15,
"membership.fanout": 3,
"membership.forget_after": 600,
"membership.hello_timeout": 3,
"membership.max_members": 1024,
"membership.admission": "open",
"foreman.interval": 5,
"foreman.acquire_delay": 10,
"foreman.proposal_stagger": 2,
"foreman.proposal_cooldown": 30,
"gateway.client_fallback": True,
"gateway.connect_timeout": 2,
"gateway.direct_retry": 30,
"gateway.max_attempts": 4,
"gateway.forward_timeout": 8,
"gateway.max_concurrent": 16,
"gateway.max_payload_bytes": 4 * 1024 * 1024,
"gateway.allow_commands": [
"Gossip.Hello",
"Unit.ShowStats",
"Unit.ShowRoles",
"Unit.ShowHardware",
"Unit.ShowCluster",
"Unit.RequestRole",
"Replicator.HashSum",
"Replicator.Retrieve",
"Replicator.Replicate",
"Chatter.Receive",
],
"web.host": "0.0.0.0",
"web.port": 8080,
"roles.on_demand": [],
"role_policy.max_roles": None,
"role_policy.deny": [],
"role_policy.min_scores": {},
"role_policy.decline_cooldown": 60,
}
def setting(key, override=None): def setting(key, override=None):
if override is not None: if override is not None:
return override return override
return configuration.get(key, DEFAULTS[key]) value = configuration.get(key, MISSING)
if value is MISSING:
raise KeyError(f"Setting {key} is not configured")
return value
+101
View File
@@ -0,0 +1,101 @@
import json
import os
import queue
import socket
import threading
from pathlib import Path
from colony.core.logger.logging import get_logger
from colony.transport.framing import send_frame, recv_frame
CONTROL_SOCKET = Path(__file__).resolve().parents[2] / "data" / "colony.sock"
def request(payload, path=CONTROL_SOCKET, timeout=10):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(timeout)
sock.connect(str(path))
send_frame(sock, json.dumps(payload).encode())
return json.loads(recv_frame(sock).decode())
class ControlQueue:
def __init__(self, actions):
self.logger = get_logger(self.__class__.__name__)
self.actions = actions
self.tasks = queue.Queue()
def submit(self, payload, timeout):
reply = queue.Queue(maxsize=1)
self.tasks.put((payload, reply))
return reply.get(timeout=timeout)
def run_once(self):
payload, reply = self.tasks.get()
try:
body = json.dumps({"status": "ok", "result": self.actions.run(payload)})
except Exception as e:
self.logger.exception("Control command failed")
body = json.dumps({"status": "error", "error": f"{type(e).__name__}: {e}"})
reply.put(body.encode())
def run_forever(self):
while True:
self.run_once()
class ControlSocket:
CLIENT_TIMEOUT = 30
def __init__(self, control_queue, path=CONTROL_SOCKET):
self.logger = get_logger(self.__class__.__name__)
self.control_queue = control_queue
self.path = Path(path)
self._sock = None
def _remove_stale_socket(self):
if not self.path.exists():
return
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
try:
probe.connect(str(self.path))
except OSError:
self.path.unlink()
return
raise OSError(f"another node is already running on {self.path}")
def bind(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self._remove_stale_socket()
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.bind(str(self.path))
os.chmod(self.path, 0o600)
sock.listen()
except OSError:
sock.close()
raise
self._sock = sock
def start(self):
self.logger.info(f"Control socket on {self.path}")
while True:
conn, _ = self._sock.accept()
threading.Thread(target=self.handle, args=(conn,), daemon=True, name="HandleControl").start()
def handle(self, conn):
with conn:
try:
conn.settimeout(self.CLIENT_TIMEOUT)
payload = json.loads(recv_frame(conn).decode())
body = self.control_queue.submit(payload, self.CLIENT_TIMEOUT)
except queue.Empty:
body = json.dumps({"status": "error", "error": "timed out waiting for the node"}).encode()
except (OSError, ValueError) as e:
self.logger.warning(f"Bad control request: {e}")
return
try:
send_frame(conn, body)
except OSError as e:
self.logger.warning(f"Could not answer control request: {e}")
+6 -1
View File
@@ -34,9 +34,14 @@
"proposal_stagger": 2, "proposal_stagger": 2,
"proposal_cooldown": 30 "proposal_cooldown": 30
}, },
"chat": {
"max_messages": 1000
},
"web": { "web": {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8080 "port": 8080,
"username": "colony",
"password": ""
}, },
"explorer": { "explorer": {
"hosts": [] "hosts": []
+4
View File
@@ -35,8 +35,12 @@ services:
COLONY_FOREMAN__PROPOSAL_STAGGER: 2 COLONY_FOREMAN__PROPOSAL_STAGGER: 2
COLONY_FOREMAN__PROPOSAL_COOLDOWN: 30 COLONY_FOREMAN__PROPOSAL_COOLDOWN: 30
COLONY_CHAT__MAX_MESSAGES: 1000
COLONY_WEB__HOST: 0.0.0.0 COLONY_WEB__HOST: 0.0.0.0
COLONY_WEB__PORT: 8271 COLONY_WEB__PORT: 8271
COLONY_WEB__USERNAME: colony
COLONY_WEB__PASSWORD: change-me
COLONY_EXPLORER__HOSTS: "[]" COLONY_EXPLORER__HOSTS: "[]"
+17 -5
View File
@@ -3,6 +3,7 @@ import threading
from colony.core.logger.logging import get_logger from colony.core.logger.logging import get_logger
from colony.core.network.core import Network from colony.core.network.core import Network
from colony.core.control import ControlActions
from colony.core.dispatcher import Dispatcher from colony.core.dispatcher import Dispatcher
from colony.core.roles.foreman.role import Foreman from colony.core.roles.foreman.role import Foreman
from colony.core.roles.gossip.role import Gossip, BOOTSTRAPPED from colony.core.roles.gossip.role import Gossip, BOOTSTRAPPED
@@ -10,6 +11,7 @@ from colony.core.roles.manager import RoleManager
from colony.core.state_manager import StateManager from colony.core.state_manager import StateManager
from colony.role_registry import RoleRegistry from colony.role_registry import RoleRegistry
from colony.settings import configuration, setting from colony.settings import configuration, setting
from colony.transport.control import ControlQueue, ControlSocket
from colony.transport.discover import Discover from colony.transport.discover import Discover
from colony.transport.listener import Listener from colony.transport.listener import Listener
from colony.utils.network import hosts_in_range from colony.utils.network import hosts_in_range
@@ -49,7 +51,20 @@ def main():
logger.error(f"Cannot listen on {listener.host}:{listener.port}: {e}") logger.error(f"Cannot listen on {listener.host}:{listener.port}: {e}")
sys.exit(1) sys.exit(1)
threads = [] control = ControlQueue(ControlActions(network))
control_socket = ControlSocket(control)
try:
control_socket.bind()
except OSError as e:
logger.error(f"Cannot open the control socket {control_socket.path}: {e}")
sys.exit(1)
t = threading.Thread(
target=control_socket.start,
daemon=True,
name="ControlSocket"
)
t.start()
t = threading.Thread( t = threading.Thread(
target=listener.start, target=listener.start,
@@ -57,7 +72,6 @@ def main():
name="Listener" name="Listener"
) )
t.start() t.start()
threads.append(t)
t = threading.Thread( t = threading.Thread(
target=Discover.listen, target=Discover.listen,
@@ -66,7 +80,6 @@ def main():
name="Discover" name="Discover"
) )
t.start() t.start()
threads.append(t)
roles = role_manager.get_role_instances() roles = role_manager.get_role_instances()
gossip: Gossip = roles["Gossip"] gossip: Gossip = roles["Gossip"]
@@ -81,8 +94,7 @@ def main():
role_manager.start_all() role_manager.start_all()
for t in threads: control.run_forever()
t.join()
if __name__ == "__main__": if __name__ == "__main__":