From 3122facaba4297ff5d1ac1edb8e3131f6f94c68c Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Mon, 26 Jan 2026 15:11:03 -0300 Subject: [PATCH] deployment, frontend updates --- Dockerfile | 12 + dmapp/dmdb/sync.py | 97 +++++++ dmapp/dmweb/dm.py | 82 ++++-- dmapp/dmweb/get_period_times.py | 3 +- dmapp/dmweb/templates/main.html | 156 +++++----- dmapp/dmweb/templates/main_content.html | 52 ++-- docker-compose.yml | 32 +++ docs/architecture/01-system-overview.dot | 76 +++++ docs/architecture/01-system-overview.svg | 173 +++++++++++ docs/architecture/02-data-sync.dot | 76 +++++ docs/architecture/02-data-sync.svg | 162 +++++++++++ docs/architecture/03-deployment.dot | 83 ++++++ docs/architecture/03-deployment.svg | 158 ++++++++++ docs/architecture/graph.html | 115 ++++++++ docs/architecture/index.html | 180 ++++++++++++ docs/architecture/styles.css | 348 +++++++++++++++++++++++ 16 files changed, 1692 insertions(+), 113 deletions(-) create mode 100644 Dockerfile create mode 100644 dmapp/dmdb/sync.py create mode 100644 docker-compose.yml create mode 100644 docs/architecture/01-system-overview.dot create mode 100644 docs/architecture/01-system-overview.svg create mode 100644 docs/architecture/02-data-sync.dot create mode 100644 docs/architecture/02-data-sync.svg create mode 100644 docs/architecture/03-deployment.dot create mode 100644 docs/architecture/03-deployment.svg create mode 100644 docs/architecture/graph.html create mode 100644 docs/architecture/index.html create mode 100644 docs/architecture/styles.css diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cff80ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt gunicorn + +COPY dmapp/dmweb /app/dmweb + +EXPOSE 10000 + +CMD ["gunicorn", "-b", "0.0.0.0:10000", "-w", "2", "dmweb:create_app()"] diff --git a/dmapp/dmdb/sync.py b/dmapp/dmdb/sync.py new file mode 100644 index 0000000..0c41188 --- /dev/null +++ b/dmapp/dmdb/sync.py @@ -0,0 +1,97 @@ +""" +dmsync - MongoDB Change Streams sync daemon +Watches local deskmeter database and pushes changes to remote MongoDB + +Requires local MongoDB to be configured as a replica set. +Uses resume tokens to continue from last position after restart. +""" + +import json +import logging +import os +from pathlib import Path + +from pymongo import MongoClient +from pymongo.errors import PyMongoError + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" +) +log = logging.getLogger("dmsync") + +RESUME_TOKEN_FILE = Path.home() / ".dmsync-resume-token" +REMOTE_HOST = os.environ.get("DMSYNC_REMOTE_HOST", "mcrn.ar") +REMOTE_PORT = int(os.environ.get("DMSYNC_REMOTE_PORT", 27017)) +COLLECTIONS = ("switch", "task", "task_history", "state") + + +def load_resume_token(): + """Load resume token from file if exists.""" + if RESUME_TOKEN_FILE.exists(): + try: + return json.loads(RESUME_TOKEN_FILE.read_text()) + except (json.JSONDecodeError, IOError) as e: + log.warning(f"Failed to load resume token: {e}") + return None + + +def save_resume_token(token): + """Persist resume token to file.""" + try: + RESUME_TOKEN_FILE.write_text(json.dumps(token)) + except IOError as e: + log.error(f"Failed to save resume token: {e}") + + +def sync(): + """Main sync loop using Change Streams.""" + log.info(f"Connecting to local MongoDB...") + local = MongoClient() + + log.info(f"Connecting to remote MongoDB at {REMOTE_HOST}:{REMOTE_PORT}...") + remote = MongoClient(REMOTE_HOST, REMOTE_PORT) + + local_db = local.deskmeter + remote_db = remote.deskmeter + + resume_token = load_resume_token() + if resume_token: + log.info("Resuming from saved token") + + watch_kwargs = {"resume_after": resume_token} if resume_token else {} + + # Watch for inserts, updates, and replaces on the database + pipeline = [{"$match": {"operationType": {"$in": ["insert", "update", "replace"]}}}] + + log.info(f"Watching collections: {', '.join(COLLECTIONS)}") + + try: + with local_db.watch(pipeline, **watch_kwargs) as stream: + for change in stream: + collection = change["ns"]["coll"] + + if collection not in COLLECTIONS: + continue + + doc = change.get("fullDocument") + if not doc: + continue + + # Upsert to remote + result = remote_db[collection].replace_one( + {"_id": doc["_id"]}, doc, upsert=True + ) + + action = "inserted" if result.upserted_id else "updated" + log.info(f"{collection}: {action} {doc['_id']}") + + save_resume_token(stream.resume_token) + + except PyMongoError as e: + log.error(f"MongoDB error: {e}") + raise + + +if __name__ == "__main__": + log.info("Starting dmsync daemon") + sync() diff --git a/dmapp/dmweb/dm.py b/dmapp/dmweb/dm.py index c3feb41..cd0335f 100644 --- a/dmapp/dmweb/dm.py +++ b/dmapp/dmweb/dm.py @@ -1,8 +1,18 @@ from datetime import datetime, timedelta -from flask import Blueprint, render_template, jsonify +from flask import Blueprint, jsonify, render_template -from .get_period_times import get_period_totals, task_or_none, timezone, get_work_period_totals, get_current_task_info, convert_seconds, get_task_time_seconds, get_task_blocks_calendar, get_raw_switches +from .get_period_times import ( + convert_seconds, + get_current_task_info, + get_period_totals, + get_raw_switches, + get_task_blocks_calendar, + get_task_time_seconds, + get_work_period_totals, + task_or_none, + timezone, +) dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates") @@ -22,7 +32,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None): from flask import request task = None - grid = int(request.args.get('grid', 1)) # Grid hours: 1, 3, or 6 + grid = int(request.args.get("grid", 1)) # Grid hours: 1, 3, or 6 if grid not in [1, 3, 6]: grid = 1 @@ -33,12 +43,16 @@ def calendar_view(scope="daily", year=None, month=None, day=None): if not day: day = datetime.today().day - base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone) + base_date = datetime(year, month, day).replace( + hour=0, minute=0, second=0, tzinfo=timezone + ) if scope == "daily": start = base_date end = base_date.replace(hour=23, minute=59, second=59) - blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60, grid_hours=grid) + blocks = get_task_blocks_calendar( + start, end, task, min_block_seconds=60, grid_hours=grid + ) prev_date = base_date - timedelta(days=1) next_date = base_date + timedelta(days=1) days = [base_date] @@ -46,7 +60,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None): elif scope == "weekly": start = base_date - timedelta(days=base_date.weekday()) end = start + timedelta(days=6, hours=23, minutes=59, seconds=59) - blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=300, grid_hours=grid) + blocks = get_task_blocks_calendar( + start, end, task, min_block_seconds=300, grid_hours=grid + ) prev_date = start - timedelta(days=7) next_date = start + timedelta(days=7) days = [start + timedelta(days=i) for i in range(7)] @@ -57,7 +73,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None): end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1) else: end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1) - blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=600, grid_hours=grid) + blocks = get_task_blocks_calendar( + start, end, task, min_block_seconds=600, grid_hours=grid + ) if month == 1: prev_date = datetime(year - 1, 12, 1, tzinfo=timezone) else: @@ -75,7 +93,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None): scope = "daily" start = base_date end = base_date.replace(hour=23, minute=59, second=59) - blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60, grid_hours=grid) + blocks = get_task_blocks_calendar( + start, end, task, min_block_seconds=60, grid_hours=grid + ) prev_date = base_date - timedelta(days=1) next_date = base_date + timedelta(days=1) days = [base_date] @@ -91,7 +111,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None): next_date=next_date, days=days, grid=grid, - auto_refresh=False + auto_refresh=False, ) @@ -111,7 +131,9 @@ def switches_view(scope="daily", year=None, month=None, day=None): if not day: day = datetime.today().day - base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone) + base_date = datetime(year, month, day).replace( + hour=0, minute=0, second=0, tzinfo=timezone + ) if scope == "daily": start = base_date @@ -157,7 +179,7 @@ def switches_view(scope="daily", year=None, month=None, day=None): base_date=base_date, prev_date=prev_date, next_date=next_date, - auto_refresh=False + auto_refresh=False, ) @@ -176,13 +198,17 @@ def index(task=None): # Get current task info current_task_id, current_task_path = get_current_task_info() - current_task_time = None - if current_task_id: - total_seconds = get_task_time_seconds(start, end, current_task_id) - if total_seconds > 0: - current_task_time = convert_seconds(total_seconds) - return render_template("main.html", rows=rows, current_task_path=current_task_path, current_task_time=current_task_time, auto_refresh=True) + # Get all tasks worked on today + task_rows = get_work_period_totals(start, end) + + return render_template( + "main.html", + rows=rows, + current_task_path=current_task_path, + task_rows=task_rows, + auto_refresh=True, + ) @dmbp.route("/api/current_task") @@ -191,10 +217,9 @@ def api_current_task(): JSON API endpoint returning current task information """ current_task_id, current_task_path = get_current_task_info() - return jsonify({ - "task_id": current_task_id, - "task_path": current_task_path or "no task" - }) + return jsonify( + {"task_id": current_task_id, "task_path": current_task_path or "no task"} + ) @dmbp.route("/api/today") @@ -212,13 +237,16 @@ def api_today(task=None): # Get current task info current_task_id, current_task_path = get_current_task_info() - current_task_time = None - if current_task_id: - total_seconds = get_task_time_seconds(start, end, current_task_id) - if total_seconds > 0: - current_task_time = convert_seconds(total_seconds) - return render_template("main_content.html", rows=rows, current_task_path=current_task_path, current_task_time=current_task_time) + # Get all tasks worked on today + task_rows = get_work_period_totals(start, end) + + return render_template( + "main_content.html", + rows=rows, + current_task_path=current_task_path, + task_rows=task_rows, + ) @dmbp.route("/day//") diff --git a/dmapp/dmweb/get_period_times.py b/dmapp/dmweb/get_period_times.py index b7f50ee..4a8653f 100644 --- a/dmapp/dmweb/get_period_times.py +++ b/dmapp/dmweb/get_period_times.py @@ -1,3 +1,4 @@ +import os from collections import Counter, defaultdict from datetime import datetime, timedelta from pathlib import Path @@ -8,7 +9,7 @@ from pymongo import MongoClient timezone = ZoneInfo("America/Argentina/Buenos_Aires") utctz = ZoneInfo("UTC") -client = MongoClient() +client = MongoClient(os.environ.get("MONGODB_HOST", "localhost")) db = client.deskmeter switches = db.switch tasks = db.task diff --git a/dmapp/dmweb/templates/main.html b/dmapp/dmweb/templates/main.html index a38cf0a..2d98a8b 100644 --- a/dmapp/dmweb/templates/main.html +++ b/dmapp/dmweb/templates/main.html @@ -1,80 +1,104 @@ - - {% block head %} - {% endblock %} + + {% block head %} {% endblock %} - + table { + font-size: clamp(14pt, 3vw, 28pt); + border-collapse: collapse; + } - {% if auto_refresh %} - - {% endif %} + td:first-child { + text-align: right; + padding-right: 0.3em; + } + td:last-child { + text-align: left; + font-variant-numeric: tabular-nums; + } + + .workspace-table { + font-size: clamp(10pt, 2vw, 18pt); + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid #444; + } + + .workspace-table td { + padding: 0.05em 0.4em; + } + + + {% if auto_refresh %} + + {% endif %} - @@ -87,9 +111,7 @@
- {% block content %} - {% include 'main_content.html' %} - {% endblock %} + {% block content %} {% include 'main_content.html' %} {% endblock %}
- \ No newline at end of file + diff --git a/dmapp/dmweb/templates/main_content.html b/dmapp/dmweb/templates/main_content.html index dc5724d..d66ca27 100644 --- a/dmapp/dmweb/templates/main_content.html +++ b/dmapp/dmweb/templates/main_content.html @@ -1,23 +1,39 @@ -{% if current_task_path and current_task_time %} -
-
{{ current_task_path }}
-
{{ current_task_time }}
+{% if current_task_path %} +
+ Current: {{ current_task_path }} +
+{% endif %} {% if task_rows %} +
+ + + {% for row in task_rows %} + + + + + {% endfor %} + +
{{ row["ws"] }}{{ row["total"] }}
{% endif %} - - -{% for row in rows %} - {% if row["ws"] in ['Away', 'Other'] %} - {% set my_class = 'grey' %} - {% elif row["ws"] in ['Active', 'Idle'] %} - {% set my_class = 'blue' %} - {% else %} - {% set my_class = '' %} - {% endif %} + +
+ + {% for row in rows %} {% if row["ws"] in ['Away', 'Other'] %} {% set + my_class = 'grey' %} {% elif row["ws"] in ['Active', 'Idle'] %} {% set + my_class = 'blue' %} {% else %} {% set my_class = '' %} {% endif %} - - + + -{% endfor %} - + {% endfor %} +
{{ row["ws"] }}{{ row["total"] }}{{ row["ws"] }}{{ row["total"] }}
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9cf32d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + dmweb: + build: . + image: registry.mcrn.ar/dmweb:latest + container_name: dmweb + restart: unless-stopped + depends_on: + - mongo + environment: + - MONGODB_HOST=mongo + networks: + - gateway + - internal + + mongo: + image: mongo:7 + container_name: dmweb-mongo + restart: unless-stopped + volumes: + - mongo-data:/data/db + ports: + - "27017:27017" + networks: + - internal + +volumes: + mongo-data: + +networks: + gateway: + external: true + internal: diff --git a/docs/architecture/01-system-overview.dot b/docs/architecture/01-system-overview.dot new file mode 100644 index 0000000..316080b --- /dev/null +++ b/docs/architecture/01-system-overview.dot @@ -0,0 +1,76 @@ +digraph SystemOverview { + // Graph settings + rankdir=TB; + compound=true; + fontname="Helvetica"; + node [fontname="Helvetica", fontsize=11]; + edge [fontname="Helvetica", fontsize=10]; + + // Title + labelloc="t"; + label="Deskmeter - System Architecture"; + fontsize=16; + + // Styling + node [shape=box, style="rounded,filled"]; + + // Local Machine + subgraph cluster_local { + label="Local Machine (Bare Metal)"; + style=filled; + color="#E8F5E9"; + fillcolor="#E8F5E9"; + + dmcore [label="dmcore\n(Workspace Tracker)", fillcolor="#C8E6C9"]; + dmweb_local [label="dmweb\n(Dev Server)", fillcolor="#C8E6C9"]; + dmsync [label="dmsync\n(Change Streams)", fillcolor="#DCEDC8"]; + mongo_local [label="MongoDB\n(Replica Set)", fillcolor="#FFECB3", shape=cylinder]; + } + + // OS Integration + subgraph cluster_os { + label="OS Integration"; + style=dashed; + color=gray; + + wmctrl [label="wmctrl\n(X11 Workspaces)", fillcolor="#E3F2FD"]; + gnome_ext [label="GNOME Extension\n(Panel Indicator)", fillcolor="#E3F2FD"]; + } + + // Remote (AWS) + subgraph cluster_remote { + label="AWS EC2 (mcrn.ar)"; + style=filled; + color="#FFF3E0"; + fillcolor="#FFF3E0"; + + subgraph cluster_docker { + label="Docker Compose"; + style=dashed; + color="#F57C00"; + + dmweb_remote [label="dmweb\n(Flask + Gunicorn)", fillcolor="#FFE0B2"]; + mongo_remote [label="MongoDB\n(Docker)", fillcolor="#FFECB3", shape=cylinder]; + } + + nginx [label="Nginx\n(Gateway)", fillcolor="#BBDEFB"]; + } + + // External + browser [label="Browser\n(Portfolio Viewer)", fillcolor="#F3E5F5"]; + + // Local connections + wmctrl -> dmcore [label="workspace\ndetection", color="#388E3C"]; + dmcore -> mongo_local [label="write\nswitches", color="#FFA000"]; + dmweb_local -> mongo_local [label="read", style=dashed, color="#666"]; + gnome_ext -> dmweb_local [label="API poll", color="#1976D2", style=dashed]; + + // Sync connection + mongo_local -> dmsync [label="Change\nStreams", color="#7B1FA2"]; + dmsync -> mongo_remote [label="push\nchanges", color="#7B1FA2", style=bold]; + + // Remote connections + dmweb_remote -> mongo_remote [label="read", style=dashed, color="#666"]; + nginx -> dmweb_remote [label="proxy", color="#1976D2"]; + browser -> nginx [label="HTTPS", color="#1976D2"]; +} diff --git a/docs/architecture/01-system-overview.svg b/docs/architecture/01-system-overview.svg new file mode 100644 index 0000000..7ed8448 --- /dev/null +++ b/docs/architecture/01-system-overview.svg @@ -0,0 +1,173 @@ + + + + + + +SystemOverview + +Deskmeter - System Architecture + +cluster_local + +Local Machine (Bare Metal) + + +cluster_os + +OS Integration + + +cluster_remote + +AWS EC2 (mcrn.ar) + + +cluster_docker + +Docker Compose + + + +dmcore + +dmcore +(Workspace Tracker) + + + +mongo_local + + +MongoDB +(Replica Set) + + + +dmcore->mongo_local + + +write +switches + + + +dmweb_local + +dmweb +(Dev Server) + + + +dmweb_local->mongo_local + + +read + + + +dmsync + +dmsync +(Change Streams) + + + +mongo_remote + + +MongoDB +(Docker) + + + +dmsync->mongo_remote + + +push +changes + + + +mongo_local->dmsync + + +Change +Streams + + + +wmctrl + +wmctrl +(X11 Workspaces) + + + +wmctrl->dmcore + + +workspace +detection + + + +gnome_ext + +GNOME Extension +(Panel Indicator) + + + +gnome_ext->dmweb_local + + +API poll + + + +dmweb_remote + +dmweb +(Flask + Gunicorn) + + + +dmweb_remote->mongo_remote + + +read + + + +nginx + +Nginx +(Gateway) + + + +nginx->dmweb_remote + + +proxy + + + +browser + +Browser +(Portfolio Viewer) + + + +browser->nginx + + +HTTPS + + + diff --git a/docs/architecture/02-data-sync.dot b/docs/architecture/02-data-sync.dot new file mode 100644 index 0000000..93a804a --- /dev/null +++ b/docs/architecture/02-data-sync.dot @@ -0,0 +1,76 @@ +digraph DataSync { + // Graph settings + rankdir=LR; + compound=true; + fontname="Helvetica"; + node [fontname="Helvetica", fontsize=11]; + edge [fontname="Helvetica", fontsize=10]; + + // Title + labelloc="t"; + label="Deskmeter - Change Streams Data Sync"; + fontsize=16; + + // Styling + node [shape=box, style="rounded,filled"]; + + // Local MongoDB + subgraph cluster_local_mongo { + label="Local MongoDB (Replica Set)"; + style=filled; + color="#E8F5E9"; + fillcolor="#E8F5E9"; + + oplog [label="Oplog", fillcolor="#C8E6C9", shape=cylinder]; + + subgraph cluster_collections { + label="Collections"; + style=dashed; + color="#388E3C"; + + switch_coll [label="switch\n(workspace events)", fillcolor="#DCEDC8"]; + task_coll [label="task\n(current tasks)", fillcolor="#DCEDC8"]; + state_coll [label="state\n(current state)", fillcolor="#DCEDC8"]; + history_coll [label="task_history\n(path cache)", fillcolor="#DCEDC8"]; + } + } + + // dmsync daemon + subgraph cluster_dmsync { + label="dmsync Daemon"; + style=filled; + color="#F3E5F5"; + fillcolor="#F3E5F5"; + + watcher [label="Change Stream\nWatcher", fillcolor="#E1BEE7"]; + resume_token [label="Resume Token\n(~/.dmsync-resume-token)", fillcolor="#E1BEE7", shape=note]; + } + + // Remote MongoDB + subgraph cluster_remote_mongo { + label="Remote MongoDB (Docker)"; + style=filled; + color="#FFF3E0"; + fillcolor="#FFF3E0"; + + remote_switch [label="switch", fillcolor="#FFE0B2"]; + remote_task [label="task", fillcolor="#FFE0B2"]; + remote_state [label="state", fillcolor="#FFE0B2"]; + remote_history [label="task_history", fillcolor="#FFE0B2"]; + } + + // Flow + switch_coll -> oplog [style=invis]; + task_coll -> oplog [style=invis]; + state_coll -> oplog [style=invis]; + history_coll -> oplog [style=invis]; + + oplog -> watcher [label="watch()", color="#7B1FA2", style=bold]; + watcher -> resume_token [label="persist", color="#666", style=dashed]; + resume_token -> watcher [label="resume_after", color="#666", style=dashed]; + + watcher -> remote_switch [label="upsert", color="#F57C00"]; + watcher -> remote_task [label="upsert", color="#F57C00"]; + watcher -> remote_state [label="upsert", color="#F57C00"]; + watcher -> remote_history [label="upsert", color="#F57C00"]; +} diff --git a/docs/architecture/02-data-sync.svg b/docs/architecture/02-data-sync.svg new file mode 100644 index 0000000..718687e --- /dev/null +++ b/docs/architecture/02-data-sync.svg @@ -0,0 +1,162 @@ + + + + + + +DataSync + +Deskmeter - Change Streams Data Sync + +cluster_local_mongo + +Local MongoDB (Replica Set) + + +cluster_collections + +Collections + + +cluster_dmsync + +dmsync Daemon + + +cluster_remote_mongo + +Remote MongoDB (Docker) + + + +oplog + + +Oplog + + + +watcher + +Change Stream +Watcher + + + +oplog->watcher + + +watch() + + + +switch_coll + +switch +(workspace events) + + + + +task_coll + +task +(current tasks) + + + + +state_coll + +state +(current state) + + + + +history_coll + +task_history +(path cache) + + + + +resume_token + + + +Resume Token +(~/.dmsync-resume-token) + + + +watcher->resume_token + + +persist + + + +remote_switch + +switch + + + +watcher->remote_switch + + +upsert + + + +remote_task + +task + + + +watcher->remote_task + + +upsert + + + +remote_state + +state + + + +watcher->remote_state + + +upsert + + + +remote_history + +task_history + + + +watcher->remote_history + + +upsert + + + +resume_token->watcher + + +resume_after + + + diff --git a/docs/architecture/03-deployment.dot b/docs/architecture/03-deployment.dot new file mode 100644 index 0000000..9ec9737 --- /dev/null +++ b/docs/architecture/03-deployment.dot @@ -0,0 +1,83 @@ +digraph Deployment { + // Graph settings + rankdir=TB; + compound=true; + fontname="Helvetica"; + node [fontname="Helvetica", fontsize=11]; + edge [fontname="Helvetica", fontsize=10]; + + // Title + labelloc="t"; + label="Deskmeter - Deployment Architecture"; + fontsize=16; + + // Styling + node [shape=box, style="rounded,filled"]; + + // Development + subgraph cluster_dev { + label="Development (Local)"; + style=filled; + color="#E3F2FD"; + fillcolor="#E3F2FD"; + + source [label="Source Code\n(/home/mariano/wdir/dm)", fillcolor="#BBDEFB"]; + docker_build [label="docker build", fillcolor="#BBDEFB", shape=parallelogram]; + } + + // Registry + subgraph cluster_registry { + label="Private Registry"; + style=filled; + color="#F3E5F5"; + fillcolor="#F3E5F5"; + + registry [label="registry.mcrn.ar:5000\n(Docker Registry)", fillcolor="#E1BEE7", shape=cylinder]; + } + + // AWS EC2 + subgraph cluster_aws { + label="AWS EC2 (mcrn.ar)"; + style=filled; + color="#FFF3E0"; + fillcolor="#FFF3E0"; + + // Gateway Network + subgraph cluster_gateway { + label="gateway network"; + style=dashed; + color="#F57C00"; + + nginx [label="Nginx\n(SSL termination)", fillcolor="#FFE0B2"]; + dmweb_container [label="dmweb:latest\n(port 10000)", fillcolor="#FFE0B2"]; + } + + // Internal Network + subgraph cluster_internal { + label="internal network"; + style=dashed; + color="#388E3C"; + + mongo_container [label="mongo:7\n(port 27017)", fillcolor="#C8E6C9", shape=cylinder]; + mongo_volume [label="mongo-data\n(volume)", fillcolor="#DCEDC8", shape=folder]; + } + } + + // DNS + dns [label="deskmeter.mcrn.ar\n(DNS)", fillcolor="#FFCDD2", shape=diamond]; + + // Internet + internet [label="Internet\n(Portfolio Visitors)", fillcolor="#F5F5F5"]; + + // Flow + source -> docker_build [label="Dockerfile"]; + docker_build -> registry [label="docker push", color="#7B1FA2"]; + registry -> dmweb_container [label="docker pull", color="#7B1FA2"]; + + dmweb_container -> mongo_container [label="MONGODB_HOST=mongo", color="#388E3C"]; + mongo_container -> mongo_volume [label="persist", style=dashed]; + + internet -> dns [color="#1976D2"]; + dns -> nginx [label="HTTPS", color="#1976D2"]; + nginx -> dmweb_container [label="proxy_pass", color="#F57C00"]; +} diff --git a/docs/architecture/03-deployment.svg b/docs/architecture/03-deployment.svg new file mode 100644 index 0000000..be1ffe6 --- /dev/null +++ b/docs/architecture/03-deployment.svg @@ -0,0 +1,158 @@ + + + + + + +Deployment + +Deskmeter - Deployment Architecture + +cluster_dev + +Development (Local) + + +cluster_registry + +Private Registry + + +cluster_aws + +AWS EC2 (mcrn.ar) + + +cluster_gateway + +gateway network + + +cluster_internal + +internal network + + + +source + +Source Code +(/home/mariano/wdir/dm) + + + +docker_build + +docker build + + + +source->docker_build + + +Dockerfile + + + +registry + + +registry.mcrn.ar:5000 +(Docker Registry) + + + +docker_build->registry + + +docker push + + + +dmweb_container + +dmweb:latest +(port 10000) + + + +registry->dmweb_container + + +docker pull + + + +nginx + +Nginx +(SSL termination) + + + +nginx->dmweb_container + + +proxy_pass + + + +mongo_container + + +mongo:7 +(port 27017) + + + +dmweb_container->mongo_container + + +MONGODB_HOST=mongo + + + +mongo_volume + +mongo-data +(volume) + + + +mongo_container->mongo_volume + + +persist + + + +dns + +deskmeter.mcrn.ar +(DNS) + + + +dns->nginx + + +HTTPS + + + +internet + +Internet +(Portfolio Visitors) + + + +internet->dns + + + + + diff --git a/docs/architecture/graph.html b/docs/architecture/graph.html new file mode 100644 index 0000000..93b1bc2 --- /dev/null +++ b/docs/architecture/graph.html @@ -0,0 +1,115 @@ + + + + + + Graph Viewer - Deskmeter + + + +
+ ← Index + +

Loading...

+
+ + + + + +
+
+ +
+ Graph +
+ + + + diff --git a/docs/architecture/index.html b/docs/architecture/index.html new file mode 100644 index 0000000..4a10339 --- /dev/null +++ b/docs/architecture/index.html @@ -0,0 +1,180 @@ + + + + + + Deskmeter - Architecture & Design + + + +
+

Deskmeter

+

Productivity Tracking - Architecture Documentation

+
+ +
+
+
+

System Overview

+ View Full +
+ + System Overview + +
+

High-level architecture showing local tracking, data sync, and remote portfolio deployment.

+

Key Components

+
    +
  • dmcore: Workspace tracking daemon using wmctrl (X11)
  • +
  • dmweb: Flask web dashboard with calendar and switches views
  • +
  • dmsync: Change Streams daemon pushing data to remote MongoDB
  • +
  • GNOME Extension: Panel indicator showing current task
  • +
+
+
+ +
+
+

Data Sync Architecture

+ View Full +
+ + Data Sync + +
+

Real-time sync using MongoDB Change Streams with resume token persistence.

+

Collections Synced

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CollectionPurposeSize
switchWorkspace switch events with timestamps and durations~33k docs
taskCurrent task definitions from task file~50 docs
task_historyCached historic task path lookups~200 docs
stateCurrent workspace and task state1 doc
+

Why Change Streams?

+
    +
  • Reacts to actual changes (not polling)
  • +
  • Resume token ensures no data loss on restart
  • +
  • Native MongoDB feature (requires replica set)
  • +
  • Clean, intentional architecture for portfolio
  • +
+
+
+ +
+
+

Deployment Architecture

+ View Full +
+ + Deployment + +
+

Docker Compose deployment on AWS EC2 with Nginx reverse proxy.

+

Networks

+
    +
  • gateway: External network connecting Nginx to dmweb
  • +
  • internal: Private network for dmweb ↔ MongoDB communication
  • +
+

Deployment Flow

+
    +
  • Build Docker image locally
  • +
  • Push to private registry (registry.mcrn.ar)
  • +
  • Pull and deploy on AWS EC2
  • +
+
+
+ +
+

Design Decisions

+
+
+

Bare Metal Core

+

dmcore runs on bare metal (not Docker) because it requires direct OS access for workspace detection via wmctrl and X11.

+
+
+

Single-Node Replica Set

+

Local MongoDB configured as replica set (rs0) to enable Change Streams. No actual replication, just API requirement.

+
+
+

Push Sync Model

+

Local machine pushes to remote (not pull). MongoDB on remote is not exposed to internet - only accessible from the sync daemon.

+
+
+

Portfolio-Ready

+

Remote instance shows historical data. "Current task" reflects last-synced state, not real-time.

+
+
+
+ +
+

Technology Stack

+
+
+

Core

+
    +
  • Python 3.11
  • +
  • Flask
  • +
  • PyMongo
  • +
  • Jinja2 Templates
  • +
+
+
+

Data

+
    +
  • MongoDB 7
  • +
  • Change Streams
  • +
  • Replica Set
  • +
+
+
+

Infrastructure

+
    +
  • Docker
  • +
  • Docker Compose
  • +
  • Nginx
  • +
  • AWS EC2
  • +
+
+
+

OS Integration

+
    +
  • wmctrl (X11)
  • +
  • GNOME Shell Extension
  • +
  • systemd
  • +
+
+
+
+
+ + + + diff --git a/docs/architecture/styles.css b/docs/architecture/styles.css new file mode 100644 index 0000000..27b534c --- /dev/null +++ b/docs/architecture/styles.css @@ -0,0 +1,348 @@ +:root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-card: #0f3460; + --text-primary: #eee; + --text-secondary: #a0a0a0; + --accent: #e94560; + --accent-secondary: #533483; + --border: #2a2a4a; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; +} + +header { + background: linear-gradient(135deg, var(--bg-secondary), var(--accent-secondary)); + padding: 2rem; + text-align: center; + border-bottom: 2px solid var(--accent); +} + +header h1 { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +header .subtitle { + color: var(--text-secondary); + font-size: 1rem; +} + +main { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +/* Graph sections */ +.graph-section { + background: var(--bg-secondary); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border); +} + +.graph-header-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.graph-header-row h2 { + font-size: 1.25rem; + color: var(--accent); +} + +.view-btn { + background: var(--accent); + color: white; + padding: 0.5rem 1rem; + border-radius: 4px; + text-decoration: none; + font-size: 0.875rem; + transition: opacity 0.2s; +} + +.view-btn:hover { + opacity: 0.8; +} + +.graph-preview { + display: block; + background: white; + border-radius: 4px; + padding: 1rem; + margin-bottom: 1rem; + overflow: auto; + max-height: 400px; +} + +.graph-preview img { + max-width: 100%; + height: auto; +} + +.graph-details { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.graph-details h4 { + color: var(--text-primary); + margin: 1rem 0 0.5rem; +} + +.graph-details ul { + margin-left: 1.5rem; +} + +.graph-details li { + margin-bottom: 0.25rem; +} + +/* Tech section */ +.tech-section { + background: var(--bg-secondary); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + border: 1px solid var(--border); +} + +.tech-section h2 { + color: var(--accent); + margin-bottom: 1rem; +} + +.tech-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; +} + +.tech-column h3 { + color: var(--text-primary); + font-size: 1rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.tech-column ul { + list-style: none; +} + +.tech-column li { + padding: 0.25rem 0; + color: var(--text-secondary); +} + +/* Findings */ +.findings-section { + margin-bottom: 2rem; +} + +.findings-section h2 { + color: var(--accent); + margin-bottom: 1rem; +} + +.findings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; +} + +.finding-card { + background: var(--bg-secondary); + border-radius: 8px; + padding: 1.25rem; + border: 1px solid var(--border); +} + +.finding-card h3 { + color: var(--accent); + font-size: 1rem; + margin-bottom: 0.75rem; +} + +.finding-card p { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.finding-card ul { + margin-left: 1rem; + color: var(--text-secondary); +} + +.finding-card code { + background: var(--bg-primary); + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.85em; +} + +/* Footer */ +footer { + text-align: center; + padding: 2rem; + color: var(--text-secondary); + border-top: 1px solid var(--border); +} + +footer .date { + font-size: 0.85rem; +} + +/* Graph viewer page */ +body.graph-viewer { + display: flex; + flex-direction: column; + height: 100vh; +} + +.graph-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.back-link { + color: var(--accent); + text-decoration: none; +} + +.nav-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.nav-controls button { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); + padding: 0.25rem 0.75rem; + border-radius: 4px; + cursor: pointer; +} + +.nav-controls button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +#nav-position { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.graph-header h1 { + flex: 1; + font-size: 1rem; + text-align: center; +} + +.graph-controls { + display: flex; + gap: 0.5rem; +} + +.graph-controls button { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); + padding: 0.375rem 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} + +.graph-controls button:hover { + background: var(--accent); +} + +.graph-container { + flex: 1; + overflow: auto; + background: white; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1rem; +} + +.graph-container.fit img { + max-width: 100%; + max-height: calc(100vh - 60px); + object-fit: contain; +} + +.graph-container.fit-width img { + width: 100%; + height: auto; +} + +.graph-container.fit-height img { + height: calc(100vh - 60px); + width: auto; +} + +.graph-container.actual-size img { + /* No constraints */ +} + +/* Tables */ +.details-table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-size: 0.85rem; +} + +.details-table th, +.details-table td { + padding: 0.5rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.details-table th { + color: var(--text-primary); + background: var(--bg-primary); +} + +.details-table td { + color: var(--text-secondary); +} + +.details-table code { + background: var(--bg-primary); + padding: 0.125rem 0.375rem; + border-radius: 3px; +} + +.note { + font-style: italic; + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 0.5rem; +}