Some checks failed
Build / Test on ubuntu-latest (push) Successful in 2m4s
Examples / Test examples (ubuntu-latest, 3.10) (push) Successful in 2m16s
Lint / lint (push) Successful in 1m25s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
333 lines
9.4 KiB
Python
Executable File
333 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import json
|
|
import logging
|
|
import textwrap
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import List, Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import click
|
|
from aw_core import Event
|
|
from tabulate import tabulate
|
|
|
|
import aw_client
|
|
|
|
from . import queries
|
|
from .classes import default_classes, get_classes
|
|
|
|
now = datetime.now(timezone.utc)
|
|
td1day = timedelta(days=1)
|
|
td1yr = timedelta(days=365)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _Context:
|
|
client: aw_client.ActivityWatchClient
|
|
|
|
|
|
@click.group(
|
|
help="CLI utility for aw-client to aid in interacting with the ActivityWatch server"
|
|
)
|
|
@click.option(
|
|
"--host",
|
|
default="127.0.0.1",
|
|
help="Address of host",
|
|
)
|
|
@click.option(
|
|
"--port",
|
|
default=None,
|
|
type=int,
|
|
help="Port to use (default: profile config, 5600 / 5666)",
|
|
)
|
|
@click.option(
|
|
"-v",
|
|
"--verbose",
|
|
is_flag=True,
|
|
help="Verbosity",
|
|
)
|
|
@click.option("--testing", is_flag=True, help="Set to use testing ports by default")
|
|
@click.option(
|
|
"--profile",
|
|
default=None,
|
|
help="Named instance profile. --testing is an alias for --profile testing.",
|
|
)
|
|
@click.pass_context
|
|
def main(
|
|
ctx, testing: bool, verbose: bool, host: str, port: int, profile: Optional[str]
|
|
):
|
|
ctx.obj = _Context()
|
|
# default=None so `--port 5600` is a real override, not discarded as
|
|
# "the Click default". None lets ActivityWatchClient read the profile
|
|
# config (5600 / 5666 / baked research port).
|
|
ctx.obj.client = aw_client.ActivityWatchClient(
|
|
host=host,
|
|
port=port,
|
|
testing=testing,
|
|
profile=profile,
|
|
)
|
|
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)
|
|
|
|
|
|
@main.command(help="Send a heartbeat to bucket with ID `bucket_id` with JSON `data`")
|
|
@click.argument("bucket_id")
|
|
@click.argument("data")
|
|
@click.option("--pulsetime", default=60, help="pulsetime to use for merging heartbeats")
|
|
@click.pass_obj
|
|
def heartbeat(obj: _Context, bucket_id: str, data: str, pulsetime: int):
|
|
now = datetime.now(timezone.utc)
|
|
e = Event(duration=0, data=json.loads(data), timestamp=now)
|
|
print(e)
|
|
obj.client.heartbeat(bucket_id, e, pulsetime)
|
|
|
|
|
|
@main.command(help="List all buckets")
|
|
@click.pass_obj
|
|
def buckets(obj: _Context):
|
|
buckets = obj.client.get_buckets()
|
|
print("Buckets:")
|
|
for bucket in buckets:
|
|
print(f" - {bucket}")
|
|
|
|
|
|
@main.command(help="Query events from bucket with ID `bucket_id`")
|
|
@click.argument("bucket_id")
|
|
@click.pass_obj
|
|
def events(obj: _Context, bucket_id: str):
|
|
events = obj.client.get_events(bucket_id)
|
|
print("events:")
|
|
for e in events:
|
|
print(
|
|
" - {} ({}) {}".format(
|
|
e.timestamp.replace(tzinfo=None, microsecond=0),
|
|
str(e.duration).split(".")[0],
|
|
e.data,
|
|
)
|
|
)
|
|
|
|
|
|
@main.command(help="Run a query in file at `path` on the server")
|
|
@click.argument("path")
|
|
@click.option("--name")
|
|
@click.option("--cache", is_flag=True)
|
|
@click.option("--json", "_json", is_flag=True)
|
|
@click.option("--start", default=now - td1day, type=click.DateTime())
|
|
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
|
@click.option(
|
|
"--timezone",
|
|
help="Time zone for start and stop options."
|
|
" Must be a valid IANA identifier like e.g. 'Europe/Warsaw'.",
|
|
)
|
|
@click.pass_obj
|
|
def query(
|
|
obj: _Context,
|
|
path: str,
|
|
cache: bool,
|
|
_json: bool,
|
|
start: datetime,
|
|
stop: datetime,
|
|
timezone: str,
|
|
name: Optional[str] = None,
|
|
):
|
|
with open(path) as f:
|
|
query = f.read()
|
|
|
|
if timezone:
|
|
zone_info = ZoneInfo(timezone)
|
|
start = start.replace(tzinfo=zone_info)
|
|
stop = stop.replace(tzinfo=zone_info)
|
|
|
|
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
|
if _json:
|
|
print(json.dumps(result))
|
|
else:
|
|
for period in result:
|
|
print(f"Showing 10 out of {len(period)} events:")
|
|
for event in period[:10]:
|
|
event.pop("id")
|
|
event.pop("timestamp")
|
|
print(
|
|
" - Duration: {} \tData: {}".format(
|
|
str(timedelta(seconds=event["duration"])).split(".")[0],
|
|
event["data"],
|
|
)
|
|
)
|
|
print(
|
|
"Total duration:\t",
|
|
timedelta(seconds=sum(e["duration"] for e in period)),
|
|
)
|
|
|
|
|
|
@main.command(help="Generate an activity report")
|
|
@click.argument("hostname")
|
|
@click.option("--cache", is_flag=True)
|
|
@click.option("--start", default=now - td1day, type=click.DateTime())
|
|
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
|
@click.option("--limit", default=10)
|
|
@click.pass_obj
|
|
def report(
|
|
obj: _Context,
|
|
hostname: str,
|
|
cache: bool,
|
|
start: datetime,
|
|
stop: datetime,
|
|
name: Optional[str] = None,
|
|
limit: int = 10,
|
|
):
|
|
logger.info(f"Querying between {start} and {stop}")
|
|
bid_window = f"aw-watcher-window_{hostname}"
|
|
bid_afk = f"aw-watcher-afk_{hostname}"
|
|
|
|
if not start.tzinfo:
|
|
start = start.astimezone()
|
|
if not stop.tzinfo:
|
|
stop = stop.astimezone()
|
|
|
|
bid_browsers: List[str] = []
|
|
|
|
classes = get_classes()
|
|
params = queries.DesktopQueryParams(
|
|
bid_browsers=bid_browsers,
|
|
classes=classes,
|
|
filter_classes=[],
|
|
filter_afk=True,
|
|
include_audible=True,
|
|
bid_window=bid_window,
|
|
bid_afk=bid_afk,
|
|
)
|
|
query = queries.fullDesktopQuery(params)
|
|
logger.debug("Query: \n" + queries.pretty_query(query))
|
|
|
|
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
|
|
|
# TODO: Print titles, apps, categories, with most time
|
|
for period in result:
|
|
print()
|
|
# print(period["window"]["cat_events"])
|
|
|
|
cat_events = _parse_events(period["window"]["cat_events"])
|
|
print_top(
|
|
cat_events,
|
|
lambda e: " > ".join(e.data["$category"]),
|
|
title="Categories",
|
|
n=limit,
|
|
)
|
|
|
|
title_events = _parse_events(period["window"]["title_events"])
|
|
print_top(title_events, lambda e: e.data["title"], title="Titles", n=limit)
|
|
|
|
active_events = _parse_events(period["window"]["title_events"])
|
|
print(
|
|
"Total duration:\t",
|
|
sum((e.duration for e in active_events), timedelta()),
|
|
)
|
|
|
|
|
|
def _parse_events(events: List[dict]) -> List[Event]:
|
|
return [Event(**event) for event in events]
|
|
|
|
|
|
def print_top(events: List[Event], key=lambda e: e.data, title="Events", n=10):
|
|
print(f"Top {n} {title}" + (f" (out of {len(events)})" if len(events) > 10 else ""))
|
|
print(
|
|
tabulate(
|
|
[
|
|
(event.duration, key(event))
|
|
for event in sorted(events, key=lambda e: e.duration, reverse=True)[:10]
|
|
],
|
|
headers=["Duration", "Key"],
|
|
)
|
|
)
|
|
print()
|
|
|
|
|
|
@main.command(help="Query 'canonical events' for a single host (filtered, classified)")
|
|
@click.argument("hostname")
|
|
@click.option("--cache", is_flag=True)
|
|
@click.option("--start", default=now - td1day, type=click.DateTime())
|
|
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
|
@click.option(
|
|
"--format",
|
|
type=click.Choice(["table", "json"]),
|
|
default="table",
|
|
help="Output format (table or json)",
|
|
)
|
|
@click.pass_obj
|
|
def canonical(
|
|
obj: _Context,
|
|
hostname: str,
|
|
cache: bool,
|
|
start: datetime,
|
|
stop: datetime,
|
|
format: str,
|
|
name: Optional[str] = None,
|
|
):
|
|
logger.info(f"Querying between {start} and {stop}")
|
|
bid_window = f"aw-watcher-window_{hostname}"
|
|
bid_afk = f"aw-watcher-afk_{hostname}"
|
|
|
|
if not start.tzinfo:
|
|
start = start.astimezone()
|
|
if not stop.tzinfo:
|
|
stop = stop.astimezone()
|
|
|
|
classes = default_classes
|
|
|
|
query = queries.canonicalEvents(
|
|
queries.DesktopQueryParams(
|
|
bid_window=bid_window,
|
|
bid_afk=bid_afk,
|
|
classes=classes,
|
|
)
|
|
)
|
|
query = f"""{query}\n RETURN = events;"""
|
|
logger.debug("Query: \n" + queries.pretty_query(query))
|
|
|
|
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
|
|
|
# TODO: Print titles, apps, categories, with most time
|
|
for period in result:
|
|
events = _parse_events(period)
|
|
|
|
if format == "json":
|
|
# Output as JSON array with ISO-8601 timestamps
|
|
json_events = [
|
|
{
|
|
"timestamp": e.timestamp.isoformat(),
|
|
"duration": e.duration.total_seconds(),
|
|
"data": e.data,
|
|
}
|
|
for e in events
|
|
]
|
|
print(json.dumps(json_events))
|
|
else:
|
|
# Table format (original behavior)
|
|
print()
|
|
print(f"Showing last 10 out of {len(events)} events:")
|
|
|
|
print(
|
|
tabulate(
|
|
[
|
|
(
|
|
str(e.timestamp).split(".")[0],
|
|
str(e.duration).split(".")[0],
|
|
f'[{e.data["app"]}] {textwrap.shorten(e.data["title"], 60, placeholder="...")}',
|
|
)
|
|
for e in events[-10:]
|
|
],
|
|
headers=["Timestamp", "Duration", "Data"],
|
|
)
|
|
)
|
|
|
|
print()
|
|
print(
|
|
"Total duration:\t",
|
|
timedelta(seconds=sum(e["duration"] for e in period)),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|