feat(worktime): add linux remote worker and pve headless tracking
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
SERVER_HOST="10.10.10.13"
|
||||
SERVER_PORT="5600"
|
||||
POLL_INTERVAL="5"
|
||||
AW_VERSION="0.13.2"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: install_aw_linux_remote_worker.sh [options]
|
||||
|
||||
Options:
|
||||
--server-host HOST Remote AW server host (default: 10.10.10.13)
|
||||
--server-port PORT Remote AW server port (default: 5600)
|
||||
--poll-interval SEC Poll interval for Linux loggers (default: 5)
|
||||
--version VERSION ActivityWatch version for GUI watcher bundle (default: 0.13.2)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--server-host)
|
||||
SERVER_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--server-port)
|
||||
SERVER_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--poll-interval)
|
||||
POLL_INTERVAL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
AW_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
sh "${SCRIPT_DIR}/install_aw_linux_client.sh" \
|
||||
--server-host "${SERVER_HOST}" \
|
||||
--server-port "${SERVER_PORT}" \
|
||||
--version "${AW_VERSION}"
|
||||
|
||||
sh "${SCRIPT_DIR}/install_aw_console_ssh_logger.sh" \
|
||||
--server-host "${SERVER_HOST}" \
|
||||
--server-port "${SERVER_PORT}" \
|
||||
--poll-interval "${POLL_INTERVAL}"
|
||||
|
||||
sh "${SCRIPT_DIR}/install_aw_linux_web_category_logger.sh" \
|
||||
--server-host "${SERVER_HOST}" \
|
||||
--server-port "${SERVER_PORT}" \
|
||||
--poll-interval "${POLL_INTERVAL}"
|
||||
|
||||
echo "Linux remote worker full-stack install completed."
|
||||
echo "Expected buckets on AW server:"
|
||||
echo " - aw-watcher-window_$(hostname -s)"
|
||||
echo " - aw-watcher-afk_$(hostname -s)"
|
||||
echo " - aw-console-commands_$(hostname -s)"
|
||||
echo " - aw-ssh-sessions_$(hostname -s)"
|
||||
echo " - aw-linux-web-context_$(hostname -s)"
|
||||
echo " - aw-detmir-web-category_$(hostname -s)"
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
SERVER_HOST="10.10.10.13"
|
||||
SERVER_PORT="5600"
|
||||
POLL_INTERVAL="5"
|
||||
INSTALL_ROOT="${HOME}/.local/opt/aw-linux-web-category"
|
||||
BIN_DIR="${HOME}/.local/bin"
|
||||
STATE_DIR="${HOME}/.local/state/aw-linux-web-category"
|
||||
LOG_DIR="${HOME}/.local/state/aw-linux-web-category/logs"
|
||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/aw-linux-web-category"
|
||||
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: install_aw_linux_web_category_logger.sh [options]
|
||||
|
||||
Options:
|
||||
--server-host HOST AW server host (default: 10.10.10.13)
|
||||
--server-port PORT AW server port (default: 5600)
|
||||
--poll-interval SEC Poll interval in seconds (default: 5)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--server-host)
|
||||
SERVER_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
--server-port)
|
||||
SERVER_PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--poll-interval)
|
||||
POLL_INTERVAL="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "${INSTALL_ROOT}" "${BIN_DIR}" "${STATE_DIR}" "${LOG_DIR}" "${CONFIG_DIR}" "${SYSTEMD_USER_DIR}"
|
||||
|
||||
write_file() {
|
||||
target="$1"
|
||||
mkdir -p "$(dirname "${target}")"
|
||||
cat > "${target}"
|
||||
}
|
||||
|
||||
write_file "${CONFIG_DIR}/rules.json" <<'EOF'
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "proxmox-webui",
|
||||
"categoryGroup": "work",
|
||||
"category": "Администрирование",
|
||||
"service": "proxmox",
|
||||
"interface": "https",
|
||||
"port": 8006,
|
||||
"rootDomain": "proxmox-webui",
|
||||
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
|
||||
"titleRegex": "(?i)proxmox\\s+virtual\\s+environment|\\bproxmox\\b|\\bnode\\b.*\\bsummary\\b|\\bvirtual machine\\b"
|
||||
},
|
||||
{
|
||||
"id": "pfsense-webui",
|
||||
"categoryGroup": "work",
|
||||
"category": "Администрирование",
|
||||
"service": "pfsense",
|
||||
"interface": "https",
|
||||
"port": 443,
|
||||
"rootDomain": "pfsense-webui",
|
||||
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
|
||||
"titleRegex": "(?i)\\bpfsense\\b|\\bfirewall\\b"
|
||||
},
|
||||
{
|
||||
"id": "grafana-webui",
|
||||
"categoryGroup": "work",
|
||||
"category": "Администрирование",
|
||||
"service": "grafana",
|
||||
"interface": "https",
|
||||
"port": 3000,
|
||||
"rootDomain": "grafana-webui",
|
||||
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
|
||||
"titleRegex": "(?i)\\bgrafana\\b|dashboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
write_file "${INSTALL_ROOT}/config.json" <<EOF
|
||||
{
|
||||
"server_host": "${SERVER_HOST}",
|
||||
"server_port": ${SERVER_PORT},
|
||||
"poll_interval_seconds": ${POLL_INTERVAL},
|
||||
"hostname": "$(hostname -s)",
|
||||
"username": "$(id -un)",
|
||||
"state_dir": "${STATE_DIR}",
|
||||
"rules_path": "${CONFIG_DIR}/rules.json",
|
||||
"raw_bucket": "aw-linux-web-context_$(hostname -s)",
|
||||
"category_bucket": "aw-detmir-web-category_$(hostname -s)"
|
||||
}
|
||||
EOF
|
||||
|
||||
write_file "${INSTALL_ROOT}/collector.py" <<'EOF'
|
||||
#!/usr/bin/env python3
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def iso_now():
|
||||
return dt.datetime.now(tz=dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
class Collector:
|
||||
def __init__(self, cfg):
|
||||
self.cfg = cfg
|
||||
self.server = f"http://{cfg['server_host']}:{cfg['server_port']}/api/0"
|
||||
self.host = cfg.get("hostname") or socket.gethostname().split(".")[0]
|
||||
self.user = cfg.get("username") or os.environ.get("USER", "unknown")
|
||||
self.poll_interval = max(1, int(cfg.get("poll_interval_seconds", 5)))
|
||||
self.state_dir = pathlib.Path(cfg["state_dir"]).expanduser()
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.raw_bucket = cfg.get("raw_bucket", f"aw-linux-web-context_{self.host}")
|
||||
self.category_bucket = cfg.get("category_bucket", f"aw-detmir-web-category_{self.host}")
|
||||
self.rules = self._load_rules(pathlib.Path(cfg["rules_path"]).expanduser())
|
||||
self.ensured = set()
|
||||
self.last_raw_key = None
|
||||
self.last_category_key = None
|
||||
|
||||
def _load_rules(self, path):
|
||||
if not path.exists():
|
||||
return []
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
rules = []
|
||||
for item in payload.get("rules", []):
|
||||
rules.append({
|
||||
"id": item.get("id", "rule"),
|
||||
"categoryGroup": item.get("categoryGroup", "work"),
|
||||
"category": item.get("category", "Работа"),
|
||||
"service": item.get("service", ""),
|
||||
"interface": item.get("interface", "https"),
|
||||
"port": item.get("port"),
|
||||
"rootDomain": item.get("rootDomain", item.get("service", "web-ui")),
|
||||
"windowClassRegex": re.compile(item.get("windowClassRegex", ".*")),
|
||||
"titleRegex": re.compile(item.get("titleRegex", ".*"))
|
||||
})
|
||||
return rules
|
||||
|
||||
def _run(self, *cmd):
|
||||
try:
|
||||
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def get_active_window(self):
|
||||
win_id_line = self._run("xprop", "-root", "_NET_ACTIVE_WINDOW")
|
||||
match = re.search(r"0x[0-9a-fA-F]+", win_id_line)
|
||||
if not match:
|
||||
return None
|
||||
win_id = match.group(0)
|
||||
title = self._run("xprop", "-id", win_id, "_NET_WM_NAME")
|
||||
if not title:
|
||||
title = self._run("xprop", "-id", win_id, "WM_NAME")
|
||||
klass = self._run("xprop", "-id", win_id, "WM_CLASS")
|
||||
|
||||
title_match = re.search(r'=\s*"(?P<value>.*)"\s*$', title)
|
||||
if title_match:
|
||||
title = title_match.group("value")
|
||||
else:
|
||||
title = title.split("=", 1)[-1].strip().strip('"')
|
||||
|
||||
class_values = re.findall(r'"([^"]+)"', klass)
|
||||
window_class = " ".join(class_values) if class_values else klass.split("=", 1)[-1].strip()
|
||||
|
||||
if not title and not window_class:
|
||||
return None
|
||||
|
||||
return {
|
||||
"windowId": win_id,
|
||||
"title": title,
|
||||
"windowClass": window_class
|
||||
}
|
||||
|
||||
def ensure_bucket(self, bucket_id, bucket_type):
|
||||
if bucket_id in self.ensured:
|
||||
return True
|
||||
payload = {"client": "aw-linux-web-category", "type": bucket_type, "hostname": self.host}
|
||||
req = urllib.request.Request(
|
||||
f"{self.server}/buckets/{bucket_id}",
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10):
|
||||
self.ensured.add(bucket_id)
|
||||
return True
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code in (304, 409):
|
||||
self.ensured.add(bucket_id)
|
||||
return True
|
||||
return False
|
||||
except urllib.error.URLError:
|
||||
return False
|
||||
|
||||
def heartbeat(self, bucket_id, payload, bucket_type, pulse=60):
|
||||
if not self.ensure_bucket(bucket_id, bucket_type):
|
||||
return False
|
||||
req = urllib.request.Request(
|
||||
f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime={pulse}",
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10):
|
||||
return True
|
||||
except urllib.error.URLError:
|
||||
return False
|
||||
|
||||
def match_rule(self, window):
|
||||
title = window.get("title", "")
|
||||
window_class = window.get("windowClass", "")
|
||||
for rule in self.rules:
|
||||
if rule["windowClassRegex"].search(window_class) and rule["titleRegex"].search(title):
|
||||
return rule
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
window = self.get_active_window()
|
||||
if window:
|
||||
raw_key = f"{window.get('windowClass','')}|{window.get('title','')}"
|
||||
if raw_key != self.last_raw_key:
|
||||
raw_event = {
|
||||
"timestamp": iso_now(),
|
||||
"duration": 0,
|
||||
"data": {
|
||||
"source": "x11_active_window",
|
||||
"username": self.user,
|
||||
"host": self.host,
|
||||
"windowId": window.get("windowId", ""),
|
||||
"windowClass": window.get("windowClass", ""),
|
||||
"title": window.get("title", "")
|
||||
}
|
||||
}
|
||||
self.heartbeat(self.raw_bucket, raw_event, "aw.linux.web.context", pulse=20)
|
||||
self.last_raw_key = raw_key
|
||||
|
||||
rule = self.match_rule(window)
|
||||
if rule:
|
||||
category_key = rule["id"] + "|" + window.get("title", "")
|
||||
if category_key != self.last_category_key:
|
||||
event = {
|
||||
"timestamp": iso_now(),
|
||||
"duration": 0,
|
||||
"data": {
|
||||
"source": "linux_window_title_rule",
|
||||
"username": self.user,
|
||||
"host": self.host,
|
||||
"windowClass": window.get("windowClass", ""),
|
||||
"title": window.get("title", ""),
|
||||
"categoryGroup": rule["categoryGroup"],
|
||||
"category": rule["category"],
|
||||
"categoryRule": rule["id"],
|
||||
"service": rule["service"],
|
||||
"interface": rule["interface"],
|
||||
"port": rule["port"],
|
||||
"rootDomain": rule["rootDomain"]
|
||||
}
|
||||
}
|
||||
self.heartbeat(self.category_bucket, event, "aw.web.category", pulse=30)
|
||||
self.last_category_key = category_key
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
|
||||
def main():
|
||||
config_path = pathlib.Path(os.environ.get("AW_LINUX_WEB_CATEGORY_CONFIG", "~/.local/opt/aw-linux-web-category/config.json")).expanduser()
|
||||
cfg = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
Collector(cfg).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
EOF
|
||||
chmod 0755 "${INSTALL_ROOT}/collector.py"
|
||||
|
||||
write_file "${BIN_DIR}/aw-linux-web-category-start" <<EOF
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
export AW_LINUX_WEB_CATEGORY_CONFIG="${INSTALL_ROOT}/config.json"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
if pgrep -u "$(id -u)" -f "aw-linux-web-category/collector.py" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
nohup "${INSTALL_ROOT}/collector.py" >> "${LOG_DIR}/collector.log" 2>&1 &
|
||||
EOF
|
||||
chmod 0755 "${BIN_DIR}/aw-linux-web-category-start"
|
||||
|
||||
write_file "${BIN_DIR}/aw-linux-web-category-stop" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
pkill -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
|
||||
EOF
|
||||
chmod 0755 "${BIN_DIR}/aw-linux-web-category-stop"
|
||||
|
||||
write_file "${BIN_DIR}/aw-linux-web-category-status" <<'EOF'
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
pgrep -a -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
|
||||
EOF
|
||||
chmod 0755 "${BIN_DIR}/aw-linux-web-category-status"
|
||||
|
||||
write_file "${SYSTEMD_USER_DIR}/aw-linux-web-category.service" <<EOF
|
||||
[Unit]
|
||||
Description=AW Linux web-category logger (user space)
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=AW_LINUX_WEB_CATEGORY_CONFIG=${INSTALL_ROOT}/config.json
|
||||
ExecStart=${INSTALL_ROOT}/collector.py
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
started_with_systemd="0"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
||||
systemctl --user enable --now aw-linux-web-category.service >/dev/null 2>&1 || true
|
||||
if systemctl --user is-active --quiet aw-linux-web-category.service >/dev/null 2>&1; then
|
||||
started_with_systemd="1"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${started_with_systemd}" != "1" ]; then
|
||||
"${BIN_DIR}/aw-linux-web-category-start"
|
||||
fi
|
||||
|
||||
echo "Installed AW Linux web-category logger"
|
||||
echo "Server target: ${SERVER_HOST}:${SERVER_PORT}"
|
||||
echo "Raw bucket: aw-linux-web-context_$(hostname -s)"
|
||||
echo "Category bucket: aw-detmir-web-category_$(hostname -s)"
|
||||
echo "Rules: ${CONFIG_DIR}/rules.json"
|
||||
echo "Log file: ${LOG_DIR}/collector.log"
|
||||
@@ -67,7 +67,8 @@ cat > "$CONFIG_PATH" <<EOF
|
||||
"access_log": "/var/log/pveproxy/access.log",
|
||||
"tasks_index": "/var/log/pve/tasks/index",
|
||||
"web_bucket": "aw-pve-webadmin-events_${HOST_SHORT}",
|
||||
"task_bucket": "aw-pve-task-events_${HOST_SHORT}"
|
||||
"task_bucket": "aw-pve-task-events_${HOST_SHORT}",
|
||||
"category_bucket": "aw-detmir-web-category_${HOST_SHORT}"
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -133,6 +134,7 @@ class Collector:
|
||||
self.poll = max(1, int(cfg.get("poll_interval_seconds", 5)))
|
||||
self.web_bucket = cfg["web_bucket"]
|
||||
self.task_bucket = cfg["task_bucket"]
|
||||
self.category_bucket = cfg.get("category_bucket", f"aw-detmir-web-category_{self.host}")
|
||||
self.access_log = pathlib.Path(cfg["access_log"])
|
||||
self.tasks_index = pathlib.Path(cfg["tasks_index"])
|
||||
self.state_dir = pathlib.Path(cfg["state_dir"])
|
||||
@@ -235,6 +237,9 @@ class Collector:
|
||||
},
|
||||
}
|
||||
self.heartbeat(self.web_bucket, event, "app.pve.webadmin.event")
|
||||
category_event = self.classify_access_event(event)
|
||||
if category_event:
|
||||
self.heartbeat(self.category_bucket, category_event, "web.tab.current")
|
||||
|
||||
def process_tasks(self):
|
||||
for line in self.read_new_lines(self.tasks_index, self.tasks_state):
|
||||
@@ -260,6 +265,44 @@ class Collector:
|
||||
}
|
||||
self.heartbeat(self.task_bucket, event, "app.pve.task.event")
|
||||
|
||||
def classify_access_event(self, source_event: dict):
|
||||
data = source_event.get("data", {})
|
||||
path = str(data.get("path") or "")
|
||||
user = str(data.get("user") or "")
|
||||
remote_ip = str(data.get("remote_ip") or "")
|
||||
method = str(data.get("method") or "")
|
||||
status = int(data.get("status") or 0)
|
||||
# Keep only meaningful API/UI actions for worktime.
|
||||
if not path.startswith("/api2/"):
|
||||
return None
|
||||
if method == "GET" and ("/cluster/resources" in path or "/status/current" in path):
|
||||
return None
|
||||
category = "Администрирование"
|
||||
category_group = "work"
|
||||
if status in (401, 403):
|
||||
category = "Безопасность"
|
||||
title = f"Proxmox API {method} {path}"
|
||||
return {
|
||||
"timestamp": source_event.get("timestamp") or iso_now(),
|
||||
"duration": 0,
|
||||
"data": {
|
||||
"source": "pve-webadmin-bridge",
|
||||
"service": "proxmox",
|
||||
"host": self.host,
|
||||
"app": "proxmox-webui",
|
||||
"title": title[:512],
|
||||
"url": f"https://{self.host}:8006{path}",
|
||||
"domain": self.host,
|
||||
"rootDomain": "proxmox-webui",
|
||||
"categoryGroup": category_group,
|
||||
"category": category,
|
||||
"user": user,
|
||||
"remote_ip": remote_ip,
|
||||
"method": method,
|
||||
"status": status,
|
||||
},
|
||||
}
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self.process_access()
|
||||
@@ -303,4 +346,4 @@ systemctl --no-pager --full status aw-pve-webadmin-logger.service || true
|
||||
|
||||
echo "Installed aw-pve-webadmin-logger"
|
||||
echo "Config: ${CONFIG_PATH}"
|
||||
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}"
|
||||
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}, aw-detmir-web-category_${HOST_SHORT}"
|
||||
|
||||
Reference in New Issue
Block a user