chatter
This commit is contained in:
1 parent
145a3468c9
commit
5a8a268a92
24 files changed
+458
-43
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
+115
-3
@@ -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)
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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.
@@ -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:]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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": [],
|
||||||
|
|||||||
@@ -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: "{}"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user