This commit is contained in:
ciomek committed 2026-09-26 18:13:02 +02:00
1 parent 459188346e
commit 5ea93344e2
24 files changed
+462 -47

No files matched your search

+1
View File
@@ -0,0 +1 @@
3.14
+115 -3
View File
@@ -1,9 +1,12 @@
import argparse import argparse
import datetime
import json import json
import sys import sys
import time import time
from colony.core.network.requests import CommandRequest 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.roles.unit.role import Unit from colony.core.roles.unit.role import Unit
from colony.settings import configuration, setting from colony.settings import configuration, setting
from colony.transport.whisperer import Whisperer from colony.transport.whisperer import Whisperer
@@ -16,11 +19,11 @@ CLEAR_SCREEN = "\033[2J"
CLEAR_LINE = "\033[K" CLEAR_LINE = "\033[K"
CLEAR_BELOW = "\033[J" CLEAR_BELOW = "\033[J"
def call(host, port, command, *args, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None): def call(host, port, command, *args, sender=None, timeout=DEFAULT_TIMEOUT, cert_file=None, keyfile=None):
payload = CommandRequest.form(command, *args).collapse() payload = CommandRequest.form(command, *args).collapse(sender=sender)
resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout) resp = Whisperer(host, port, cert_file, keyfile).send(payload, timeout=timeout)
if not isinstance(resp, dict) or resp.get("status") != "ok" or not isinstance(resp.get("result"), dict): 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 reason = resp.get("error") if isinstance(resp, dict) else resp
raise RuntimeError(f"the node answered with an error: {reason}") raise RuntimeError(f"the node answered with an error: {reason}")
return resp["result"] return resp["result"]
@@ -34,6 +37,61 @@ def request_role(host, port, role, timeout=DEFAULT_TIMEOUT, cert_file=None, keyf
return call(host, port, Unit.COMMANDS.REQUEST_ROLE, role, timeout=timeout, cert_file=cert_file, keyfile=keyfile) 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:
moment = datetime.datetime.fromisoformat(at).astimezone()
except (TypeError, ValueError):
return "?"
if moment.date() == datetime.date.today():
return moment.strftime("%H:%M:%S")
return moment.strftime("%Y-%m-%d %H:%M")
def render_chat(messages, own_label, peer_label):
if not messages:
return f"No messages with {peer_label} yet."
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 resolve_on_demand_role(payload, name):
roles = payload["roles"] roles = payload["roles"]
match = next((role for role in roles if role["name"].lower() == name.lower()), None) match = next((role for role in roles if role["name"].lower() == name.lower()), None)
@@ -221,6 +279,13 @@ def build_parser():
add.add_argument("name", help="role name, e.g. WebView (case does not matter)") 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)") 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=[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("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
@@ -245,6 +310,51 @@ def watch(host, port, args, out):
return 0 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): def add_role(args, port, name, out):
try: try:
payload = fetch_cluster(args.host, port, timeout=args.timeout) payload = fetch_cluster(args.host, port, timeout=args.timeout)
@@ -277,6 +387,8 @@ def main(argv=None, out=None):
return add_role(args, port, args.name, out) return add_role(args, port, args.name, out)
if args.command == "webview": if args.command == "webview":
return add_role(args, port, "WebView", out) 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(): if not args.raw and not args.json and out.isatty():
return watch(args.host, port, args, out) return watch(args.host, port, args, out)
+3 -6
View File
@@ -11,15 +11,9 @@ class RoleContext:
return self._network.execute(command, *args, **kwargs) return self._network.execute(command, *args, **kwargs)
def execute_on(self, target, command, *args, **kwargs): def execute_on(self, target, command, *args, **kwargs):
"""Run a command on one particular node, going through a gateway if it cannot be dialled.
`target` is a Node or a MemberRecord, anything with `ip` and `port`, for example an item of
`nodes_with_role(...)`. `execute` picks the node for you; this one does not.
"""
return self._network.send_command(target.ip, target.port, command, *args, **kwargs) return self._network.send_command(target.ip, target.port, command, *args, **kwargs)
def execute_to_unregistered_node(self, ip, port, command, *args, **kwargs): def execute_to_unregistered_node(self, ip, port, command, *args, **kwargs):
"""The same as `execute_on` for a caller that only has an address."""
return self._network.send_command(ip, port, command, *args, **kwargs) return self._network.send_command(ip, port, command, *args, **kwargs)
def send_direct(self, ip, port, payload, timeout=None, connect_timeout=None): def send_direct(self, ip, port, payload, timeout=None, connect_timeout=None):
@@ -37,6 +31,9 @@ class RoleContext:
def peers(self): def peers(self):
return self._network.get_all_nodes() return self._network.get_all_nodes()
def node_by_id(self, node_id):
return self._network.get_node_by_id(node_id)
def nodes_with_role(self, role): def nodes_with_role(self, role):
return self._network.get_nodes_of_role(role if isinstance(role, str) else role.__name__) return self._network.get_nodes_of_role(role if isinstance(role, str) else role.__name__)
+7 -3
View File
@@ -20,7 +20,11 @@ def parse_command(payload):
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict): if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
raise ValueError("Command args must be a list and kwargs a dict") raise ValueError("Command args must be a list and kwargs a dict")
return role_name, command_name, tuple(args), kwargs caller_id = command.get("from")
if not isinstance(caller_id, str):
caller_id = None
return role_name, command_name, tuple(args), kwargs, caller_id
class Dispatcher: class Dispatcher:
@@ -31,7 +35,7 @@ class Dispatcher:
self.role_registry = role_registry self.role_registry = role_registry
def dispatch(self, payload): def dispatch(self, payload):
role_name, command_name, args, kwargs = parse_command(payload) role_name, command_name, args, kwargs, caller_id = parse_command(payload)
self.logger.debug(f"Dispatching payload for command {role_name}.{command_name}") self.logger.debug(f"Dispatching payload for command {role_name}.{command_name}")
role_entry = self.role_registry.get_role_class_by_name(role_name) role_entry = self.role_registry.get_role_class_by_name(role_name)
@@ -44,6 +48,6 @@ class Dispatcher:
node = self.network.get_self() node = self.network.get_self()
self.logger.info(f"Executing {role_name}.{command_name} on local node {node.id}") self.logger.info(f"Executing {role_name}.{command_name} on local node {node.id}")
result = node.execute_local(command, *args, **kwargs) result = node.execute_local(command, *args, caller_id=caller_id, **kwargs)
self.logger.debug(f"Passing the results from {role_name}.{command_name}") self.logger.debug(f"Passing the results from {role_name}.{command_name}")
return result return result
+5 -10
View File
@@ -8,7 +8,7 @@ from colony.core.network.relay import GATEWAY_ROLE, RelayError, build_forward, r
from colony.core.network.requests import CommandRequest from colony.core.network.requests import CommandRequest
from colony.core.logger.logging import get_logger from colony.core.logger.logging import get_logger
from colony.settings import configuration, setting from colony.settings import configuration, setting
from colony.transport.whisperer import ConnectFailed, Whisperer from colony.transport.whisperer import Whisperer, ConnectFailed
from colony.utils.network import get_local_ip, get_hostname from colony.utils.network import get_local_ip, get_hostname
@@ -56,6 +56,9 @@ class Network:
def get_all_nodes(self): def get_all_nodes(self):
return self.nodes return self.nodes
def get_node_by_id(self, node_id: str) -> Optional[Node]:
return next((node for node in self.nodes if node.id == node_id), None)
def get_nodes_of_role(self, role_name: str) -> List[Node]: def get_nodes_of_role(self, role_name: str) -> List[Node]:
return [node for node in self.nodes if role_name in node.roles] return [node for node in self.nodes if role_name in node.roles]
@@ -125,7 +128,7 @@ class Network:
return None return None
def send_command(self, ip, port, command, *args, timeout=None, **kwargs): def send_command(self, ip, port, command, *args, timeout=None, **kwargs):
payload = CommandRequest.form(command, *args, **kwargs).collapse() payload = CommandRequest.form(command, *args, **kwargs).collapse(sender=self.self_node.id)
key = (ip, port) key = (ip, port)
now = self.membership.now() now = self.membership.now()
gateways = self._gateways(key, now) gateways = self._gateways(key, now)
@@ -184,14 +187,6 @@ class Network:
self._direct_ok[key] = self.membership.now() self._direct_ok[key] = self.membership.now()
return response return response
@staticmethod
def execute_to_unregistered_node(ip, port, command: Enum, *args, timeout=None, **kwargs):
request = CommandRequest.form(command, *args, **kwargs)
options = {} if timeout is None else {"timeout": timeout}
return Whisperer(ip, port).send(request.collapse(), **options)
def load_network_from_snapshot(self, file_path: str = "storages/default/colony_snapshot.json"): def load_network_from_snapshot(self, file_path: str = "storages/default/colony_snapshot.json"):
self.logger.info(f"Loading network snapshot from {file_path}") self.logger.info(f"Loading network snapshot from {file_path}")
nodes = [] nodes = []
+2 -7
View File
@@ -3,7 +3,6 @@ from typing import List
from colony.core.network.hardware import HardwareStatistics from colony.core.network.hardware import HardwareStatistics
from colony.core.network.requests import CommandRequest from colony.core.network.requests import CommandRequest
from colony.transport.whisperer import Whisperer
class Node: class Node:
@@ -14,11 +13,6 @@ class Node:
self.roles = roles self.roles = roles
self.port = port self.port = port
def execute(self, command: Enum, *args, **kwargs):
request = CommandRequest.form(command, *args, **kwargs)
return Whisperer(self.ip, self.port).send(request.collapse())
def to_dict(self): def to_dict(self):
return { return {
"id": self.id, "id": self.id,
@@ -42,7 +36,7 @@ class LocalNode(Node):
def set_role_instances(self, roles): def set_role_instances(self, roles):
self.__roles = dict(roles) self.__roles = dict(roles)
def execute_local(self, command: Enum, *args, **kwargs): def execute_local(self, command: Enum, *args, caller_id=None, **kwargs):
request = CommandRequest.form(command, *args, **kwargs) request = CommandRequest.form(command, *args, **kwargs)
role = self.__roles.get(request.role) role = self.__roles.get(request.role)
@@ -52,5 +46,6 @@ class LocalNode(Node):
return role.execute_local( return role.execute_local(
request.id, request.id,
*request.args, *request.args,
caller_id=caller_id,
**request.kwargs **request.kwargs
) )
+9 -8
View File
@@ -31,12 +31,13 @@ class CommandRequest:
def form(cls, id: Enum, *args: Any, **kwargs: Any): def form(cls, id: Enum, *args: Any, **kwargs: Any):
return cls(id, args, kwargs) return cls(id, args, kwargs)
def collapse(self): def collapse(self, sender=None):
return { command = {
"command": { "role": self.role,
"role": self.role, "name": self.name,
"name": self.name, "args": self.args,
"args": self.args, "kwargs": self.kwargs
"kwargs": self.kwargs
}
} }
if sender is not None:
command["from"] = sender
return {"command": command}
Whitespace-only changes.
+41
View File
@@ -0,0 +1,41 @@
from enum import Enum
from colony.core.roles.role import Role, Command
from colony.core.storages.chat import ChatStorage
MAX_MESSAGE_LENGTH = 4000
class Chatter(Role):
class COMMANDS(Enum):
RECEIVE = 'Chatter.Receive'
def __init__(self, context, storage=None):
commands = {
self.COMMANDS.RECEIVE.value: Command(self.receive, with_caller=True),
}
self.storage = storage or ChatStorage()
super().__init__(commands, context)
def send(self, peer_id, text):
node = self.context.node_by_id(peer_id)
if node is None or "Chatter" not in node.roles:
raise ValueError(f"{peer_id} is not running Chatter")
response = self.context.execute_on(node, self.COMMANDS.RECEIVE, text)
if response.get("status") != "ok":
raise ValueError(f"{peer_id} refused the message")
self.storage.append(peer_id, "out", text)
def receive(self, text, caller_id):
if not isinstance(text, str) or not text.strip() or len(text) > MAX_MESSAGE_LENGTH:
raise ValueError("Invalid message")
self.storage.append(caller_id, "in", text)
return True
def history(self, peer_id, after=0):
return self.storage.messages(peer_id)[after:]
+13
View File
@@ -25,6 +25,19 @@ class RoleManager:
if wanted != current: if wanted != current:
self.state_manager.set(StateManager.Data.Roles, wanted) self.state_manager.set(StateManager.Data.Roles, wanted)
def ensure_on_demand_roles(self, role_names):
roles = self.role_registry.roles
on_demand = []
for role_name in role_names:
if role_name in roles and roles[role_name].on_demand:
on_demand.append(role_name)
else:
self.logger.warning(f"Ignoring {role_name} in roles.on_demand, it is not an on-demand role")
current = self.state_manager.get(StateManager.Data.Roles)
wanted = list(dict.fromkeys(current + on_demand))
if wanted != current:
self.state_manager.set(StateManager.Data.Roles, wanted)
def initialize_roles(self): def initialize_roles(self):
self.logger.info("Initializing roles...") self.logger.info("Initializing roles...")
context = RoleContext(self.network, self) context = RoleContext(self.network, self)
-1
View File
@@ -27,7 +27,6 @@ class Replicator(Role):
super().__init__(commands, context) super().__init__(commands, context)
def start(self): def start(self):
# catch up
replicator_checksums = dict() replicator_checksums = dict()
for node in self.context.nodes_with_role(Replicator): for node in self.context.nodes_with_role(Replicator):
+6 -2
View File
@@ -5,8 +5,9 @@ from colony.core.logger.logging import get_logger
class Command: class Command:
def __init__(self, callback): def __init__(self, callback, with_caller=False):
self.callback = callback self.callback = callback
self.with_caller = with_caller
class Role: class Role:
CONTINUOUS = None CONTINUOUS = None
@@ -56,10 +57,13 @@ class Role:
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
def execute_local(self, name, *args, **kwargs): def execute_local(self, name, *args, caller_id=None, **kwargs):
command = self.commands.get(name) command = self.commands.get(name)
if not command: if not command:
raise ValueError(f"No command {name}") raise ValueError(f"No command {name}")
if command.with_caller:
kwargs["caller_id"] = caller_id
return command.callback(*args, **kwargs) return command.callback(*args, **kwargs)
+37 -4
View File
@@ -8,9 +8,13 @@ STATIC_DIR = Path(__file__).parent / "static"
logging.getLogger("werkzeug").setLevel(logging.WARNING) logging.getLogger("werkzeug").setLevel(logging.WARNING)
def create_app(get_status): def create_app(get_status, get_chatter):
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):
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/") @app.get("/")
def index(): def index():
return app.send_static_file("index.html") return app.send_static_file("index.html")
@@ -23,8 +27,37 @@ def create_app(get_status):
app.logger.exception("Could not build the status") app.logger.exception("Could not build the status")
return jsonify(error="status unavailable"), 500 return jsonify(error="status unavailable"), 500
response = jsonify(payload) return no_store(jsonify(payload))
response.headers["Cache-Control"] = "no-store"
return response @app.get("/api/chat")
def chat_history():
chatter = get_chatter()
if chatter is None:
return jsonify(error="Chatter is not running on this node"), 409
peer = request.args.get("peer")
after = request.args.get("after", "0")
if not peer or not after.isdigit():
return jsonify(error="peer and a numeric after are required"), 400
return no_store(jsonify(messages=chatter.history(peer, int(after))))
@app.post("/api/chat")
def chat_send():
chatter = get_chatter()
if chatter is None:
return jsonify(error="Chatter is not running on this node"), 409
body = request.get_json(silent=True)
if not isinstance(body, dict) or not isinstance(body.get("peer"), str) or not isinstance(body.get("text"), str):
return jsonify(error="peer and text are required"), 400
try:
chatter.send(body["peer"], body["text"])
except ValueError as e:
return jsonify(error=str(e)), 400
except OSError:
return jsonify(error=f"{body['peer']} cannot be reached"), 502
return jsonify(sent=True)
return app return app
+4 -1
View File
@@ -21,7 +21,7 @@ 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.app = create_app(self._status, self._chatter)
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:
@@ -39,6 +39,9 @@ class WebView(Role):
raise RuntimeError("The Unit role is not available") raise RuntimeError("The Unit role is not available")
return unit return unit
def _chatter(self):
return self.context.self().get_role_instances().get("Chatter")
def _status(self, compact=False): def _status(self, compact=False):
payload = self._unit().show_cluster() payload = self._unit().show_cluster()
own_key = next((member["ip"], member["port"]) for member in payload["members"] if member["self"]) own_key = next((member["ip"], member["port"]) for member in payload["members"] if member["self"])
+115
View File
@@ -528,6 +528,7 @@
} else { } else {
drawStats(); drawStats();
} }
updateChat();
} }
/* ----------------------------------------------------------------- controls */ /* ----------------------------------------------------------------- controls */
@@ -597,6 +598,7 @@
else announce("Selection cleared"); else announce("Selection cleared");
drawRegions(); drawRegions();
drawTrace(); drawTrace();
updateChat();
} }
const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id); const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id);
@@ -848,6 +850,119 @@
drawTrace(); drawTrace();
}); });
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 };
const chatTarget = () => (model && selectionLocks() && selection.id !== model.selfKey ? model.byKey.get(selection.id) : null);
const chatTime = (at) => {
const date = new Date(at);
return isNaN(date) ? "" : date.toLocaleString([], { dateStyle: "short", timeStyle: "short" });
};
function updateChat() {
const peer = chatTarget();
chatPanel.hidden = !peer;
stage.classList.toggle("chatting", Boolean(peer));
if (!peer) {
chat = { peer: null, count: 0, loaded: false, loading: false };
return;
}
if (chat.peer !== peer.id) {
chat = { peer: peer.id, hostname: peer.hostname, count: 0, loaded: false, loading: false };
chatTitle.textContent = "Chat with " + peer.hostname;
chatLog.replaceChildren();
chatEmpty.hidden = true;
chatError.hidden = true;
chatInput.value = "";
}
const state = C.chatState(model, peer.key);
chatStatus.textContent = state.text;
chatStatus.hidden = !state.text;
chatForm.hidden = !state.send;
if (state.read) loadChat();
}
function paintChatMessages(messages, fresh) {
const atBottom = chatLog.scrollTop + chatLog.clientHeight >= chatLog.scrollHeight - 8;
messages.forEach((message) => {
const item = html("li", undefined, "msg " + (message.direction === "out" ? "out" : "in"));
item.appendChild(html("span", message.direction === "out" ? "You" : chat.hostname, "msg-from sr-only"));
item.appendChild(html("p", message.text, "msg-text"));
const time = html("time", chatTime(message.at), "msg-time");
time.dateTime = message.at;
item.appendChild(time);
chatLog.appendChild(item);
});
if (atBottom || !fresh) chatLog.scrollTop = chatLog.scrollHeight;
const incoming = messages.filter((message) => message.direction === "in");
if (fresh && incoming.length) chatLive.textContent = chat.hostname + " says: " + incoming[incoming.length - 1].text;
}
async function loadChat() {
const current = chat;
if (current.loading || !current.peer) return;
current.loading = true;
try {
const response = await fetch("/api/chat?peer=" + encodeURIComponent(current.peer) + "&after=" + current.count, { cache: "no-store" });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
if (chat !== current) return;
paintChatMessages(body.messages, current.loaded);
current.count += body.messages.length;
current.loaded = true;
chatEmpty.hidden = current.count > 0;
if (chatError.dataset.from === "load") chatError.hidden = true;
} catch (error) {
if (chat === current) {
chatError.textContent = "Cannot load the messages: " + error.message;
chatError.dataset.from = "load";
chatError.hidden = false;
}
} finally {
current.loading = false;
}
}
chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const text = chatInput.value.trim();
const current = chat;
if (!text || !current.peer || chatSend.disabled) return;
chatInput.disabled = chatSend.disabled = true;
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ peer: current.peer, text: text }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || "the node answered with HTTP " + response.status);
if (chat === current) {
chatInput.value = "";
chatError.hidden = true;
loadChat();
}
} catch (error) {
if (chat === current) {
chatError.textContent = "Not sent: " + error.message;
chatError.dataset.from = "send";
chatError.hidden = false;
}
} finally {
chatInput.disabled = chatSend.disabled = false;
if (!chatPanel.hidden) chatInput.focus();
}
});
chatClose.addEventListener("click", () => select(null));
chatPanel.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
const key = selection && selection.id;
select(null);
if (key && model.byKey.has(key)) focusNode(key);
});
/* --------------------------------------------------------------------- data */ /* --------------------------------------------------------------------- data */
if (typeof ResizeObserver !== "undefined") new ResizeObserver(resize).observe(stage); if (typeof ResizeObserver !== "undefined") new ResizeObserver(resize).observe(stage);
@@ -65,7 +65,24 @@
</div> </div>
<button id="legend-open" type="button" class="legend-open" aria-label="Show the legend" title="Show the legend" hidden>?</button> <button id="legend-open" type="button" class="legend-open" aria-label="Show the legend" title="Show the legend" hidden>?</button>
<p id="updated" class="muted stamp"></p> <p id="updated" class="muted stamp"></p>
<aside id="chat" class="chat" aria-labelledby="chat-title" hidden>
<div class="chat-head">
<h2 id="chat-title"></h2>
<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>
<p id="chat-status" class="chat-status" hidden></p>
<ol id="chat-log" class="chat-log" role="log" aria-label="Messages" tabindex="0"></ol>
<p id="chat-empty" class="chat-empty muted" hidden>No messages yet.</p>
<p id="chat-error" class="error chat-error" role="alert" hidden></p>
<form id="chat-form" class="chat-form">
<label for="chat-input" class="sr-only">Message</label>
<input id="chat-input" type="text" maxlength="4000" autocomplete="off" placeholder="Message">
<button id="chat-send" type="submit">Send</button>
</form>
</aside>
</main> </main>
<div id="chat-live" class="sr-only" aria-live="polite"></div>
<div id="live" class="sr-only" role="status" aria-live="polite"></div> <div id="live" class="sr-only" role="status" aria-live="polite"></div>
<ul id="reader" class="sr-only" aria-label="Networks and their nodes"></ul> <ul id="reader" class="sr-only" aria-label="Networks and their nodes"></ul>
+14 -1
View File
@@ -101,6 +101,7 @@
index: index, index: index,
key: address(member), key: address(member),
net: networkOf(member.ip), net: networkOf(member.ip),
id: member.hostname + "@" + member.ip,
hostname: member.hostname, hostname: member.hostname,
ip: member.ip, ip: member.ip,
port: member.port, port: member.port,
@@ -236,6 +237,18 @@
return off.length ? "Not running (on demand): " + off.join(", ") + " · start one with: colonyctl role add " + off[0] : ""; return off.length ? "Not running (on demand): " + off.join(", ") + " · start one with: colonyctl role add " + off[0] : "";
} }
function chatState(model, key) {
const peer = model.byKey.get(key);
if (!peer || peer.self) return null;
const own = model.byKey.get(model.selfKey);
if (!own || !own.roles.includes("Chatter")) {
return { read: false, send: false, text: "Chat needs Chatter on this node too. Start it with: colonyctl role add Chatter" };
}
if (!peer.roles.includes("Chatter")) return { read: true, send: false, text: peer.hostname + " is not running Chatter, so it cannot receive messages." };
if (!peer.alive) return { read: true, send: false, text: peer.hostname + " is down." };
return { read: true, send: true, text: "" };
}
function summarize(model) { function summarize(model) {
const alive = model.nodes.filter((node) => node.alive).length; const alive = model.nodes.filter((node) => node.alive).length;
const down = model.nodes.length - alive; const down = model.nodes.length - alive;
@@ -367,7 +380,7 @@
return lines.join("\n"); return lines.join("\n");
} }
const api = { describeStats, describeRows, networkStats, plural, barLevel, OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, summarize, describeNode }; const api = { describeStats, describeRows, networkStats, plural, barLevel, OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, chatState, summarize, describeNode };
if (typeof module !== "undefined") module.exports = api; if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api); else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
})(); })();
@@ -216,6 +216,32 @@ p { margin: 4px 0; }
.swatch.flow { width: 20px; height: 0; border-radius: 0; border: 0; border-top: 3.5px dashed var(--text); vertical-align: 3px; } .swatch.flow { width: 20px; height: 0; border-radius: 0; border: 0; border-top: 3.5px dashed var(--text); vertical-align: 3px; }
.swatch.none { background: transparent; border: 2px dashed var(--muted); } .swatch.none { background: transparent; border: 2px dashed var(--muted); }
.chat { position: absolute; top: 12px; right: 12px; bottom: 12px; width: min(340px, calc(100% - 24px)); display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 10px; background: var(--card); box-shadow: 0 6px 24px color-mix(in srgb, var(--text) 12%, transparent); }
.chat-head { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--line); }
.chat-head h2 { flex: 1; margin: 0; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-head button { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text); cursor: pointer; }
.chat-head button:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
.chat-head svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.chat-status { margin: 0; padding: 8px 14px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 13px; }
.chat-log { flex: 1; min-height: 0; overflow-y: auto; margin: 0; padding: 12px; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.chat-log:focus-visible { outline: 2px solid var(--text); outline-offset: -2px; }
.chat-empty { margin: 0; padding: 0 14px 12px; text-align: center; }
.chat-error { margin: 0; padding: 6px 14px; font-size: 13px; }
.msg { max-width: 85%; padding: 6px 10px; border-radius: 12px; overflow-wrap: anywhere; }
.msg.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--line); border-bottom-left-radius: 4px; }
.msg.out { align-self: flex-end; background: color-mix(in srgb, var(--n0) 16%, var(--card)); border: 1px solid color-mix(in srgb, var(--n0) 40%, transparent); border-bottom-right-radius: 4px; }
.msg-text { margin: 0; white-space: pre-wrap; }
.msg-time { display: block; margin-top: 2px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.msg.out .msg-time { text-align: right; }
.chat-form { display: flex; gap: 6px; padding: 10px; border-top: 1px solid var(--line); }
.chat-form input { flex: 1; min-width: 0; height: 36px; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--text); font: inherit; }
.chat-form input:focus-visible { outline: 2px solid var(--n0); outline-offset: 0; border-color: transparent; }
.chat-form button { height: 36px; padding: 0 14px; border: 0; border-radius: 8px; background: var(--n0); color: var(--card); font: inherit; font-weight: 600; cursor: pointer; }
.chat-form button:disabled, .chat-form input:disabled { opacity: 0.6; cursor: default; }
.chat-form button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
#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; } }
.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. */
@@ -226,4 +252,6 @@ p { margin: 4px 0; }
.alert, .route, .muted, .legend { font-size: 14px; } .alert, .route, .muted, .legend { font-size: 14px; }
.legend { max-width: calc(100% - 24px); } .legend { max-width: calc(100% - 24px); }
.legend, .legend-open { bottom: 64px; } .legend, .legend-open { bottom: 64px; }
.chat { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: min(70%, 520px); border-radius: 14px 14px 0 0; border-bottom: 0; }
#stage.chatting .controls, #stage.chatting .legend, #stage.chatting .legend-open { display: none; }
} }
+29
View File
@@ -0,0 +1,29 @@
import datetime
import os
import threading
from urllib.parse import quote
from colony.core.storages.core import Storage, STORAGES_DIR
CHAT_DIR = os.path.join(STORAGES_DIR, "chat")
class ChatStorage(Storage):
_lock = threading.Lock()
def __init__(self, storage_dir=None):
super().__init__(storage_dir or CHAT_DIR)
@staticmethod
def _file(peer_id):
return quote(peer_id, safe="") + ".json"
def messages(self, peer_id):
return self.open(self._file(peer_id)) or []
def append(self, peer_id, direction, text):
at = datetime.datetime.now(datetime.timezone.utc).isoformat()
with self._lock:
messages = self.messages(peer_id)
messages.append({"direction": direction, "text": text, "at": at})
self.save(self._file(peer_id), messages)
+2
View File
@@ -41,9 +41,11 @@ DEFAULTS = {
"Replicator.HashSum", "Replicator.HashSum",
"Replicator.Retrieve", "Replicator.Retrieve",
"Replicator.Replicate", "Replicator.Replicate",
"Chatter.Receive",
], ],
"web.host": "0.0.0.0", "web.host": "0.0.0.0",
"web.port": 8080, "web.port": 8080,
"roles.on_demand": [],
"role_policy.max_roles": None, "role_policy.max_roles": None,
"role_policy.deny": [], "role_policy.deny": [],
"role_policy.min_scores": {}, "role_policy.min_scores": {},
+5 -1
View File
@@ -58,9 +58,13 @@
"Unit.RequestRole", "Unit.RequestRole",
"Replicator.HashSum", "Replicator.HashSum",
"Replicator.Retrieve", "Replicator.Retrieve",
"Replicator.Replicate" "Replicator.Replicate",
"Chatter.Receive"
] ]
}, },
"roles": {
"on_demand": []
},
"role_policy": { "role_policy": {
"max_roles": null, "max_roles": null,
"deny": [], "deny": [],
+2
View File
@@ -52,6 +52,8 @@ services:
"Unit.ShowCluster","Unit.RequestRole","Replicator.HashSum", "Unit.ShowCluster","Unit.RequestRole","Replicator.HashSum",
"Replicator.Retrieve","Replicator.Replicate"] "Replicator.Retrieve","Replicator.Replicate"]
COLONY_ROLES__ON_DEMAND: "[]"
COLONY_ROLE_POLICY__MAX_ROLES: null COLONY_ROLE_POLICY__MAX_ROLES: null
COLONY_ROLE_POLICY__DENY: "[]" COLONY_ROLE_POLICY__DENY: "[]"
COLONY_ROLE_POLICY__MIN_SCORES: "{}" COLONY_ROLE_POLICY__MIN_SCORES: "{}"
+1
View File
@@ -39,6 +39,7 @@ def main():
) )
role_manager.ensure_general_roles() role_manager.ensure_general_roles()
role_manager.ensure_on_demand_roles(setting("roles.on_demand"))
role_manager.initialize_roles() role_manager.initialize_roles()
listener = Listener(dispatcher=dispatcher) listener = Listener(dispatcher=dispatcher)
+6
View File
@@ -1,3 +1,4 @@
from colony.core.roles.chatter.role import Chatter
from colony.core.roles.explorer.role import Explorer from colony.core.roles.explorer.role import Explorer
from colony.core.roles.replicator.role import Replicator from colony.core.roles.replicator.role import Replicator
from colony.core.roles.unit.role import Unit from colony.core.roles.unit.role import Unit
@@ -59,5 +60,10 @@ available_roles = [
} }
), ),
minimal_percentage=51 minimal_percentage=51
),
RoleStats(
cls=Chatter,
priority=RolePriority.LOW,
on_demand=True,
) )
] ]