Compare commits
14 Commits
e15e89d122
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 99826be6aa | |||
| c97ef63756 | |||
|
|
de2ea3b7cb | ||
|
|
3122facaba | ||
|
|
bf7bcbc37a | ||
|
|
88caa3dc96 | ||
|
|
f5ddcad45c | ||
|
|
ac475b9a5a | ||
|
|
f684da5288 | ||
|
|
2307e9c5b2 | ||
|
|
c7ddfa6af5 | ||
|
|
a1ef79ad05 | ||
|
|
4ebc47d79b | ||
|
|
23b4341842 |
9
.env
Normal file
9
.env
Normal file
@@ -0,0 +1,9 @@
|
||||
# Deskmeter Configuration
|
||||
|
||||
# dmweb API port (auto-detects if not set)
|
||||
# DESKMETER_PORT=10001
|
||||
|
||||
# Window position for right monitor (1920x1080)
|
||||
# Upper-right area (near red circle position)
|
||||
WINDOW_X=3360
|
||||
WINDOW_Y=100
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Deskmeter Configuration
|
||||
|
||||
# Override dmweb API port (default: auto-detect 10001, then 10000)
|
||||
# DESKMETER_PORT=10001
|
||||
|
||||
# Example for different worktree
|
||||
# DESKMETER_PORT=10002
|
||||
|
||||
# Window position (X,Y coordinates)
|
||||
# Example: middle of upper-right quadrant of left monitor (1920x1080)
|
||||
# WINDOW_X=1440
|
||||
# WINDOW_Y=270
|
||||
112
CLAUDE.md
112
CLAUDE.md
@@ -7,9 +7,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
Deskmeter is a productivity tool that measures time spent across desktop workspaces. It consists of four main components:
|
||||
|
||||
- **dmcore**: Core tracking daemon (`dmapp/dmcore/main.py`) that monitors active workspace and task changes
|
||||
- **dmweb**: Flask web application (`dmapp/dmweb/`) for viewing productivity data
|
||||
- **dmfnt**: Angular frontend (`dmapp/dmfnt/`) for enhanced UI (in development)
|
||||
- **gnome-extension**: GNOME Shell extension (`gnome-extension/deskmeter-indicator@local/`) that displays current task in the top panel
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -32,21 +29,21 @@ Deskmeter is a productivity tool that measures time spent across desktop workspa
|
||||
### Web Interface Structure
|
||||
|
||||
**Flask Routes** (`dmapp/dmweb/dm.py`):
|
||||
- `/` - Today's productivity summary
|
||||
- `/day/<month>/<day>` - Single day view
|
||||
- `/calendar` - Google Calendar-style task timeline view (daily/weekly/monthly)
|
||||
- `/switches` - Raw switch documents view (daily/weekly/monthly)
|
||||
- `/workmonth` - Monthly calendar showing task totals via `dmapp/dmweb/dmcal.py`
|
||||
- `/` - Today's productivity summary (large display with auto-refresh)
|
||||
- `/calendar/<scope>/<year>/<month>/<day>?grid=<1|3|6>` - Google Calendar-style view with aggregated task blocks
|
||||
- Scopes: `daily`, `weekly`, `monthly`
|
||||
- Grid: Hour aggregation (1h, 3h, or 6h blocks)
|
||||
- Shows only active workspaces (Plan/Think/Work) with gaps for idle time
|
||||
- Cascading overlapping blocks for multiple tasks per time period
|
||||
- `/switches/<scope>/<year>/<month>/<day>` - Raw switches view with proportional cell heights
|
||||
- Shows all workspace switches with time-based height visualization
|
||||
- `/work` - Work projects breakdown for today
|
||||
- `/totals` - All-time statistics
|
||||
- `/api/current_task` - JSON API endpoint returning current task info (for GNOME extension)
|
||||
- `/day/<month>/<day>` - Single day view
|
||||
- `/period/<start>/<end>` - Custom date range
|
||||
- `/api/current_task` - JSON endpoint for current task info (used by GNOME extension)
|
||||
- `/api/today` - HTML fragment for AJAX updates
|
||||
|
||||
**Task Info API** (`dmapp/dmweb/get_period_times.py`):
|
||||
- `get_current_task_info()` - Retrieves current task ID and path from MongoDB state collection
|
||||
- `get_task_path()` - Resolves task ID to path, with automatic task_history fallback
|
||||
- `get_task_blocks_calendar()` - Groups consecutive switches by task, tracks active/idle time
|
||||
- `get_raw_switches()` - Returns all switch documents for a period
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Python Backend
|
||||
@@ -71,24 +68,26 @@ cd dmapp/tests
|
||||
python3 test_dmapp.py
|
||||
```
|
||||
|
||||
### Angular Frontend
|
||||
### Running the Web Interface
|
||||
|
||||
Use the `dmweb.sh` script for flexible deployment:
|
||||
|
||||
```bash
|
||||
cd dmapp/dmfnt
|
||||
cd /home/mariano/wdir/run
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
# Syntax: ./dmweb.sh <worktree> <port> <debug>
|
||||
./dmweb.sh dm-fend-updates 10002 1
|
||||
|
||||
# Development server
|
||||
npm run start
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Run tests
|
||||
npm run test
|
||||
# Parameters:
|
||||
# - worktree: directory name in /home/mariano/wdir/ (default: dm)
|
||||
# - port: Flask server port (default: 10000)
|
||||
# - debug: 1 for debug mode with auto-reload, 0 for production (default: 0)
|
||||
```
|
||||
|
||||
### Angular Frontend (Not Yet Implemented)
|
||||
|
||||
The Angular frontend (`dmapp/dmfnt/`) mentioned in the architecture is planned but not currently implemented. All current functionality is in the Flask templates.
|
||||
|
||||
### GNOME Extension
|
||||
|
||||
```bash
|
||||
@@ -243,7 +242,7 @@ Both support daily, weekly, and monthly scopes with navigation.
|
||||
|
||||
- The system enforces task constraints within work desktops - switching to a non-work task automatically reverts to the designated work task
|
||||
- Task files are monitored for changes to enable automatic context switching
|
||||
- Web interface runs on port 10000 by default
|
||||
- Web interface runs on port 10000 by default (configurable via `dmweb.sh` script)
|
||||
- Core daemon sleeps for 2 seconds between workspace checks
|
||||
- GNOME extension debounce delay (2.2s) must be > dmcore polling interval (2s) to ensure MongoDB is updated before API query
|
||||
|
||||
@@ -285,8 +284,63 @@ Both support daily, weekly, and monthly scopes with navigation.
|
||||
4. **Gradient indicates work quality** - Visual distinction between active focus and idle time
|
||||
5. **Multiple thresholds** - Different minimum block durations for different scopes reduces noise
|
||||
|
||||
### UI/UX Design
|
||||
|
||||
**Dark Mode Theme** (applied across all views):
|
||||
- Background: `#1a1a1a`
|
||||
- Text: `#e0e0e0`
|
||||
- Accent/links: `#6b9bd1`
|
||||
- Borders: `#444`
|
||||
- Component backgrounds: `#2a2a2a`
|
||||
|
||||
**Calendar View Features**:
|
||||
- Hour-based aggregation (configurable: 1h, 3h, 6h grids)
|
||||
- Overlapping task blocks cascade right and down (8% horizontal, 4px vertical per task)
|
||||
- Only active workspaces shown (Plan/Think/Work) - idle time appears as gaps
|
||||
- Task colors generated via hash-based HSL for consistency
|
||||
- Height proportional to time spent in each grid period
|
||||
|
||||
**Switches View Features**:
|
||||
- Cell heights proportional to switch duration (30px min, 200px max)
|
||||
- Color-coded by task with hash-based HSL
|
||||
- Dark backgrounds with reduced opacity for idle workspaces
|
||||
|
||||
**Navigation**:
|
||||
- Consistent nav bar across all views: Today | Calendar | Switches | Work | All Time
|
||||
- All views interconnected for easy movement between different data perspectives
|
||||
|
||||
## GNOME Extension
|
||||
|
||||
A GNOME Shell extension displays the current task in the top panel:
|
||||
- Location: `gnome-extension/deskmeter-indicator@local/`
|
||||
- Polls `/api/current_task` endpoint
|
||||
- Updates on workspace switch with 2.2s debounce
|
||||
- Automatically truncates long task paths
|
||||
- Installation: `./gnome-extension/install.sh` or `./gnome-extension/update.sh`
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
**Calendar Aggregation** (`dmapp/dmweb/get_period_times.py:get_task_blocks_calendar()`):
|
||||
- Aggregates switches by configurable time grid (1h, 3h, or 6h)
|
||||
- Only includes active workspaces: Plan, Think, Work
|
||||
- Returns blocks with task_id, task_path, start time, duration, and hour
|
||||
- Filters out blocks shorter than `min_block_seconds`
|
||||
|
||||
**Task Path Resolution**:
|
||||
- Primary: `tasks` collection (current tasks)
|
||||
- Fallback: `task_history` collection (cached historical tasks)
|
||||
- Last resort: On-demand file search in task directory
|
||||
- Caching prevents repeated file I/O
|
||||
|
||||
**Switch Height Calculation** (switches view):
|
||||
- Finds max delta in current view
|
||||
- Heights scale linearly: `base_height + (ratio * (max_height - base_height))`
|
||||
- Base: 30px, Max: 200px
|
||||
|
||||
## Tool Development Guidelines
|
||||
|
||||
- This is a personal tool, not professionally developed
|
||||
- reuse as it is as much as possible unless refactor is required explicitly, suggest improvements
|
||||
- Reuse existing code as much as possible unless refactor is required explicitly
|
||||
- Only modify existing code if it absolutely doesn't make sense to add a new flow
|
||||
- When adding features, maintain the dark mode theme consistency
|
||||
- All new views should include the standard navigation bar
|
||||
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -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()"]
|
||||
91
README.md
91
README.md
@@ -1,26 +1,85 @@
|
||||
# Deskmeter
|
||||
# Deskmeter GNOME Integration
|
||||
|
||||
Deskmeter is a productivity tool to measure how much time you spend doing stuff in your computer.
|
||||
More precisely how much time passes while a given workspace is active.
|
||||
Display your current deskmeter task in the GNOME panel or in a standalone window.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Task Window (No logout required)
|
||||
|
||||
```bash
|
||||
# Run the task window with auto port detection
|
||||
python3 task_window.py
|
||||
|
||||
# Make it always-on-top and visible on all workspaces
|
||||
wmctrl -r "Deskmeter Task" -b add,above,sticky
|
||||
```
|
||||
|
||||
### GNOME Extension (Requires logout)
|
||||
|
||||
Extension is already installed and enabled. Just log out and back in to activate it.
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
gnome-extensions list --enabled | grep deskmeter
|
||||
|
||||
# View logs after login
|
||||
journalctl --user -u org.gnome.Shell@wayland.service -f | grep deskmeter
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Auto port detection** - Finds dmweb on ports 10001 or 10000
|
||||
- ✅ **Workspace change detection** - Updates when you switch workspaces
|
||||
- ✅ **Error handling** - Won't crash GNOME Shell if something fails
|
||||
- ✅ **Minimal UI** - Clean display without window decorations
|
||||
|
||||
## Requirements
|
||||
|
||||
- MongoDB
|
||||
- wmctrl
|
||||
- Flask
|
||||
- GNOME Shell 49+ (Wayland or X11)
|
||||
- Python 3 with GTK4 (`python3-gi`)
|
||||
- dmweb running on port 10000 or 10001
|
||||
- wmctrl (for workspace detection and window properties)
|
||||
|
||||
install those first, also wmctrl doesn't work on wayland, until an equivalent is found. you have to switch to XORG.
|
||||
## Documentation
|
||||
|
||||
## Define Workspace Labels
|
||||
See [docs/](docs/) directory for detailed documentation:
|
||||
|
||||
edit the desktops variable in `dmmain.py` to your needs, first is workspace 1, second 2 and so on
|
||||
- **[READY_TO_TEST.md](docs/READY_TO_TEST.md)** - Complete testing guide (START HERE)
|
||||
- **[TASK_WINDOW_README.md](docs/TASK_WINDOW_README.md)** - Task window usage and configuration
|
||||
- **[INSTALL_STATUS.md](docs/INSTALL_STATUS.md)** - Extension installation and troubleshooting
|
||||
- **[PORT_DETECTION_README.md](docs/PORT_DETECTION_README.md)** - How auto port detection works
|
||||
|
||||
## How to run
|
||||
## Project Structure
|
||||
|
||||
leave the dmmain.py running in the background, use the dmapp flask application to see the data.
|
||||
the homepage shows current day result,
|
||||
`/calendar` shows the current month with week totals
|
||||
`/calendar/<month_number>` shows the select month of current year with week totals
|
||||
`/calendar/<month_number>` shows the select month of current year with week totals
|
||||
```
|
||||
dm-gnomeext/
|
||||
├── task_window.py # Standalone GTK window app
|
||||
├── run_task_window.sh # Helper script for window
|
||||
├── gnome-extension/ # Extension source
|
||||
│ └── deskmeter-indicator@local/
|
||||
│ ├── extension.js
|
||||
│ ├── metadata.json
|
||||
│ └── stylesheet.css
|
||||
├── docs/ # Documentation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
adapt `deskmeter.sh` and `dmapp.sh` to your needs. I put those in my home directory. Add starting the mongo service
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
# Test task window
|
||||
python3 task_window.py
|
||||
|
||||
# Test API
|
||||
curl http://localhost:10001/api/current_task
|
||||
|
||||
# Check extension status
|
||||
gnome-extensions list --enabled | grep deskmeter
|
||||
|
||||
# Make window always-on-top
|
||||
wmctrl -r "Deskmeter Task" -b add,above,sticky
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For troubleshooting and detailed information, see the [docs/](docs/) directory.
|
||||
|
||||
19
ctrl/deploy.sh
Normal file
19
ctrl/deploy.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Deploy deskmeter to server
|
||||
# Usage: ./ctrl/deploy.sh
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "Building dmweb image..."
|
||||
docker build -t registry.mcrn.ar/dmweb:latest .
|
||||
|
||||
echo "Pushing to registry..."
|
||||
docker push registry.mcrn.ar/dmweb:latest
|
||||
|
||||
echo "Deploying on server..."
|
||||
rsync -avz docker-compose.yml mcrn.ar:~/dm/
|
||||
ssh mcrn.ar "cd ~/dm && docker compose pull && docker compose up -d --remove-orphans"
|
||||
|
||||
echo "Deploy complete"
|
||||
ssh mcrn.ar "cd ~/dm && docker compose ps"
|
||||
@@ -4,11 +4,11 @@ import os
|
||||
import subprocess
|
||||
import time
|
||||
from pprint import pprint
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import state
|
||||
import task
|
||||
from config import logger, switches
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
desktops = ("Plan", "Think", "Work", "Other", "Away", "Work", "Work", "Work")
|
||||
unlabeled = "Away"
|
||||
@@ -39,22 +39,18 @@ def now():
|
||||
return datetime.datetime.now(ZoneInfo(cfg["timezone"]))
|
||||
|
||||
|
||||
def handle_task_file_changes(current_task):
|
||||
"""Check if task file changed and update task if needed. Returns (new_task, file_changed)"""
|
||||
def handle_task_file_changes():
|
||||
"""Check if task file changed and sync definitions to DB. Does not change current task."""
|
||||
current_mtime = state.retrieve("current").get("filetime")
|
||||
file_mtime = task.get_file_mtime(None)
|
||||
|
||||
if current_mtime != file_mtime:
|
||||
task_id = task.read_and_extract(None)
|
||||
logger.debug(f"task_id:{task_id}")
|
||||
task.file_to_db(None)
|
||||
if task_id != current_task:
|
||||
state.save("current", task=task_id)
|
||||
current_task = task_id
|
||||
state.save("current", filetime=file_mtime)
|
||||
logger.info("Task file changed, definitions synced to DB")
|
||||
return True # File changed
|
||||
|
||||
return current_task, True # File changed
|
||||
|
||||
return current_task, False # No change
|
||||
return False # No change
|
||||
|
||||
|
||||
def update_workspace_state():
|
||||
@@ -65,14 +61,19 @@ def update_workspace_state():
|
||||
|
||||
|
||||
def enforce_desktop_task(current_workspace, work_desktop_tasks, current_task):
|
||||
"""Enforce assigned task for work desktops"""
|
||||
if current_workspace in work_desktop_tasks and work_desktop_tasks[current_workspace]:
|
||||
"""Enforce assigned task for work desktops. Updates MongoDB state only, never writes to file."""
|
||||
if (
|
||||
current_workspace in work_desktop_tasks
|
||||
and work_desktop_tasks[current_workspace]
|
||||
):
|
||||
assigned_task = work_desktop_tasks[current_workspace]
|
||||
|
||||
if current_task != assigned_task:
|
||||
current_task = assigned_task
|
||||
state.save("current", task=current_task)
|
||||
task.db_to_file_as_is(None)
|
||||
logger.debug(
|
||||
f"Enforced task {assigned_task} for workspace {current_workspace}"
|
||||
)
|
||||
|
||||
return current_task
|
||||
|
||||
@@ -121,8 +122,9 @@ def desktop(workspace_index):
|
||||
return unlabeled
|
||||
|
||||
|
||||
task.read_and_extract(None)
|
||||
# Sync task definitions from file to DB on startup
|
||||
task.file_to_db(None)
|
||||
state.save("current", filetime=task.get_file_mtime(None))
|
||||
|
||||
current_workspace = active_workspace()
|
||||
current_task = state.retrieve("current").get("task")
|
||||
@@ -144,18 +146,21 @@ while True:
|
||||
# Load work_desktop_tasks from state
|
||||
work_desktop_tasks = state.retrieve_desktop_state()
|
||||
|
||||
# Handle task file changes
|
||||
current_task, file_changed = handle_task_file_changes(current_task)
|
||||
# Sync task definitions if file changed (does not change current task)
|
||||
handle_task_file_changes()
|
||||
|
||||
# Update current task and workspace
|
||||
# Get current state
|
||||
current_task = state.retrieve("current").get("task")
|
||||
current_workspace = update_workspace_state()
|
||||
|
||||
# Enforce desktop task assignments (but skip if file just changed - user's manual change takes priority)
|
||||
if not file_changed:
|
||||
current_task = enforce_desktop_task(current_workspace, work_desktop_tasks, current_task)
|
||||
# Enforce work desktop task assignments
|
||||
current_task = enforce_desktop_task(
|
||||
current_workspace, work_desktop_tasks, current_task
|
||||
)
|
||||
|
||||
# Track workspace switches
|
||||
last_switch_time = track_workspace_switch(current_workspace, current_task, last_switch_time)
|
||||
last_switch_time = track_workspace_switch(
|
||||
current_workspace, current_task, last_switch_time
|
||||
)
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import datetime
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import state
|
||||
from bson import ObjectId
|
||||
from config import logger, tasks
|
||||
|
||||
@@ -59,7 +57,13 @@ def file_to_db(filepath: str):
|
||||
if task_id:
|
||||
tasks.update_one(
|
||||
{"task_id": task_id},
|
||||
{"$set": {"path": full_path, "task_id": task_id, "historic": False}},
|
||||
{
|
||||
"$set": {
|
||||
"path": full_path,
|
||||
"task_id": task_id,
|
||||
"historic": False,
|
||||
}
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
elif full_path not in seen_paths:
|
||||
@@ -68,96 +72,10 @@ def file_to_db(filepath: str):
|
||||
seen_paths.add(full_path)
|
||||
|
||||
|
||||
def format_task_line(
|
||||
path_parts: list, indent_level: int, task_id: str, current_task: str
|
||||
) -> str:
|
||||
line = " " * (4 * indent_level) + path_parts[-1]
|
||||
if task_id:
|
||||
padding = max(1, 64 - len(line))
|
||||
line = f"{line}{' ' * padding}|{task_id}"
|
||||
if task_id == current_task:
|
||||
line += " *"
|
||||
return line
|
||||
|
||||
|
||||
def db_to_file_as_is(filepath: str):
|
||||
"""Write tasks from MongoDB to file exactly as they were read."""
|
||||
if filepath is None:
|
||||
filepath = task_file
|
||||
|
||||
current_task = state.retrieve("current").get("task")
|
||||
all_tasks = list(tasks.find())
|
||||
|
||||
with open(filepath, "w") as f:
|
||||
for task in all_tasks:
|
||||
if not task["path"]:
|
||||
f.write("\n")
|
||||
continue
|
||||
|
||||
path_parts = task["path"].split("/")
|
||||
line = format_task_line(
|
||||
path_parts, len(path_parts) - 1, task.get("task_id", ""), current_task
|
||||
)
|
||||
f.write(f"{line}\n")
|
||||
|
||||
|
||||
def get_all_tasks(prefix):
|
||||
return list(tasks.find({"path": {"$ne": ""}}).sort("path", 1))
|
||||
|
||||
|
||||
def db_to_file_consolidated(filepath: str):
|
||||
"""Write tasks from MongoDB to file as a consolidated tree."""
|
||||
current_task = state.retrieve("current").get("task")
|
||||
all_tasks = list(tasks.find({"path": {"$ne": ""}}).sort("path", 1))
|
||||
|
||||
with open(filepath, "w") as f:
|
||||
prev_parts = []
|
||||
for task in all_tasks:
|
||||
path_parts = task["path"].split("/")
|
||||
common = 0
|
||||
for i, (prev, curr) in enumerate(zip(prev_parts, path_parts)):
|
||||
if prev != curr:
|
||||
break
|
||||
common = i + 1
|
||||
|
||||
for i, part in enumerate(path_parts[common:], common):
|
||||
path_segment = path_parts[: i + 1]
|
||||
task_id = task.get("task_id", "") if path_segment == path_parts else ""
|
||||
line = format_task_line([part], i, task_id, current_task)
|
||||
f.write(f"{line}\n")
|
||||
|
||||
prev_parts = path_parts
|
||||
|
||||
|
||||
def extract(line: str) -> Optional[str]:
|
||||
"""Extract task ID if line ends with * and has a valid ID."""
|
||||
line = line.rstrip()
|
||||
if line.endswith("*"):
|
||||
pipe_index = line.find("|")
|
||||
if pipe_index != -1:
|
||||
# Extract everything between | and * and strip spaces
|
||||
id_part = line[pipe_index + 1 : -1].strip()
|
||||
if len(id_part) == 8:
|
||||
return id_part
|
||||
return None
|
||||
|
||||
|
||||
def read_and_extract(filepath: str) -> Optional[str]:
|
||||
"""Read file and update state if current task is found."""
|
||||
if filepath is None:
|
||||
filepath = task_file
|
||||
|
||||
mtime = get_file_mtime(filepath)
|
||||
state.save("current", filetime=mtime)
|
||||
|
||||
with open(filepath, "r") as file:
|
||||
for line in file:
|
||||
task_id = extract(line)
|
||||
if task_id:
|
||||
return task_id
|
||||
return None
|
||||
|
||||
|
||||
def get_file_mtime(filepath: str) -> str:
|
||||
"""Get file modification time as ISO format string."""
|
||||
if filepath is None:
|
||||
|
||||
134
dmapp/dmdb/sync.py
Normal file
134
dmapp/dmdb/sync.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
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, ReplaceOne
|
||||
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 bulk_sync(local_db, remote_db):
|
||||
"""Bulk sync all missing documents from local to remote."""
|
||||
total_synced = 0
|
||||
|
||||
for coll_name in COLLECTIONS:
|
||||
local_coll = local_db[coll_name]
|
||||
remote_coll = remote_db[coll_name]
|
||||
|
||||
# Get all local docs and remote IDs
|
||||
local_docs = {doc["_id"]: doc for doc in local_coll.find()}
|
||||
remote_ids = set(doc["_id"] for doc in remote_coll.find({}, {"_id": 1}))
|
||||
|
||||
# Find missing docs
|
||||
missing_ids = set(local_docs.keys()) - remote_ids
|
||||
|
||||
if missing_ids:
|
||||
# Bulk insert missing docs
|
||||
ops = [
|
||||
ReplaceOne({"_id": _id}, local_docs[_id], upsert=True)
|
||||
for _id in missing_ids
|
||||
]
|
||||
result = remote_coll.bulk_write(ops)
|
||||
count = result.upserted_count + result.modified_count
|
||||
log.info(f"{coll_name}: bulk synced {count} documents")
|
||||
total_synced += count
|
||||
|
||||
return total_synced
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Bulk sync first to catch up
|
||||
log.info("Performing bulk sync to catch up...")
|
||||
synced = bulk_sync(local_db, remote_db)
|
||||
log.info(f"Bulk sync complete: {synced} documents")
|
||||
|
||||
# Clear resume token to start fresh with Change Streams
|
||||
# (we're now caught up, don't need to replay old changes)
|
||||
if RESUME_TOKEN_FILE.exists():
|
||||
RESUME_TOKEN_FILE.unlink()
|
||||
log.info("Cleared old resume token")
|
||||
|
||||
# Now watch for new changes only (no resume token)
|
||||
watch_kwargs = {"full_document": "updateLookup"}
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
if result.upserted_id:
|
||||
log.info(f"{collection}: inserted {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()
|
||||
85
dmapp/dmos/SETUP-KEYBOARD-SHORTCUTS.md
Normal file
85
dmapp/dmos/SETUP-KEYBOARD-SHORTCUTS.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Date/Time Clipboard Shortcuts Setup
|
||||
|
||||
This replaces espanso with a cleaner clipboard-based solution that won't trigger "Updated keyboard layout" notifications.
|
||||
|
||||
## How It Works
|
||||
|
||||
Instead of text expansion, keyboard shortcuts copy the formatted date/time to clipboard. You then paste with `Ctrl+V`.
|
||||
|
||||
## Available Formats
|
||||
|
||||
- **wd**: ISO week.day (e.g., `52.4`)
|
||||
- **3t**: 360-time division (e.g., `331`)
|
||||
- **uid**: Random UUID 8 chars (e.g., `a8b9bf0a`)
|
||||
|
||||
## GNOME Keyboard Shortcuts Setup
|
||||
|
||||
1. Open Settings → Keyboard → Keyboard Shortcuts (or run: `gnome-control-center keyboard`)
|
||||
|
||||
2. Scroll to bottom and click "+ Add Custom Shortcut"
|
||||
|
||||
3. Add three shortcuts:
|
||||
|
||||
### Shortcut 1: Week Day
|
||||
- **Name**: `Copy Week.Day`
|
||||
- **Command**: `/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py wd`
|
||||
- **Shortcut**: `Ctrl+Super+W`
|
||||
|
||||
### Shortcut 2: 360-Time
|
||||
- **Name**: `Copy 360-Time`
|
||||
- **Command**: `/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py 3t`
|
||||
- **Shortcut**: `Ctrl+Super+T`
|
||||
|
||||
### Shortcut 3: UUID
|
||||
- **Name**: `Copy UUID`
|
||||
- **Command**: `/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py uid`
|
||||
- **Shortcut**: `Ctrl+Super+U`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Press the keyboard shortcut (e.g., `Ctrl+Super+W`)
|
||||
2. Paste with `Ctrl+V` wherever you need it
|
||||
|
||||
## Advantages Over Espanso
|
||||
|
||||
- No keyboard layout notifications
|
||||
- Works reliably on Wayland
|
||||
- Simpler architecture (no daemon running)
|
||||
- More control over when to paste
|
||||
- Works in password fields and other protected inputs
|
||||
|
||||
## Removing Espanso (Optional)
|
||||
|
||||
If you want to fully switch away from espanso:
|
||||
|
||||
```bash
|
||||
# Stop espanso service
|
||||
espanso stop
|
||||
|
||||
# Disable autostart
|
||||
espanso service unregister
|
||||
|
||||
# Uninstall (if desired)
|
||||
# sudo apt remove espanso # or however you installed it
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test each format
|
||||
/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py wd
|
||||
/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py 3t
|
||||
/home/mariano/wdir/dm/dmapp/dmos/datetime-clipboard.py uid
|
||||
|
||||
# Then paste to verify clipboard contents
|
||||
```
|
||||
|
||||
## Cross-Platform Notes
|
||||
|
||||
This solution is part of the deskmeter cross-platform configuration effort:
|
||||
|
||||
- **Linux (Wayland/X11)**: Uses `wl-copy` or `xclip` for clipboard
|
||||
- **Windows**: Would require AutoHotkey or similar for global shortcuts
|
||||
- **macOS**: Would use `pbcopy` for clipboard
|
||||
|
||||
The core datetime logic is platform-independent Python. Only clipboard mechanism needs platform-specific handling.
|
||||
85
dmapp/dmos/cyclework.py
Normal file
85
dmapp/dmos/cyclework.py
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Define the list of workspaces to cycle through
|
||||
WORKSPACES = [3, 6, 7, 8]
|
||||
|
||||
LAST_WS_FILE = "/tmp/lastws"
|
||||
|
||||
|
||||
def get_current_workspace():
|
||||
result = subprocess.run(["wmctrl", "-d"], capture_output=True, text=True)
|
||||
lines = result.stdout.splitlines()
|
||||
|
||||
for line in lines:
|
||||
if "*" in line: # The active workspace has a '*' symbol
|
||||
try:
|
||||
current_workspace = int(line.split()[0]) + 1
|
||||
print(f"Current workspace detected: {current_workspace}")
|
||||
return current_workspace
|
||||
except (IndexError, ValueError):
|
||||
print("Error parsing current workspace.")
|
||||
return None
|
||||
|
||||
print("No active workspace found.")
|
||||
return None
|
||||
|
||||
|
||||
def switch_to_workspace(workspace):
|
||||
print(f"Switching to workspace {workspace}")
|
||||
subprocess.run(["wmctrl", "-s", str(workspace - 1)])
|
||||
save_last_used_workspace(workspace)
|
||||
|
||||
|
||||
def save_last_used_workspace(workspace):
|
||||
with open(LAST_WS_FILE, "w") as f:
|
||||
f.write(str(workspace))
|
||||
|
||||
|
||||
def get_last_used_workspace():
|
||||
if os.path.exists(LAST_WS_FILE):
|
||||
with open(LAST_WS_FILE, "r") as f:
|
||||
try:
|
||||
return int(f.read().strip())
|
||||
except ValueError:
|
||||
return WORKSPACES[0]
|
||||
return WORKSPACES[0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ["up", "down", "current"]:
|
||||
print("Usage: python cyclework.py [up|down|current]")
|
||||
sys.exit(1)
|
||||
|
||||
direction = sys.argv[1]
|
||||
current = get_current_workspace()
|
||||
|
||||
if current is None:
|
||||
sys.exit(1)
|
||||
|
||||
if direction == "current":
|
||||
if current in WORKSPACES:
|
||||
print("Already in a workspace in the cycle.")
|
||||
sys.exit(0)
|
||||
|
||||
last_used = get_last_used_workspace()
|
||||
print(f"Switching to last used workspace in cycle: {last_used}")
|
||||
switch_to_workspace(last_used)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
index = WORKSPACES.index(current)
|
||||
except ValueError:
|
||||
print("Current workspace is not in the list.")
|
||||
sys.exit(1)
|
||||
|
||||
if direction == "up":
|
||||
index = (index + 1) % len(WORKSPACES)
|
||||
else: # direction == 'down'
|
||||
index = (index - 1) % len(WORKSPACES)
|
||||
|
||||
next_workspace = WORKSPACES[index]
|
||||
switch_to_workspace(next_workspace)
|
||||
84
dmapp/dmos/datetime-clipboard.py
Normal file
84
dmapp/dmos/datetime-clipboard.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Date/time clipboard utility for GNOME keyboard shortcuts.
|
||||
Copies various date/time formats to clipboard for quick pasting.
|
||||
|
||||
Usage:
|
||||
datetime-clipboard.py wd # ISO week.day (e.g., 51.2)
|
||||
datetime-clipboard.py 3t # 360-time format (0-359)
|
||||
datetime-clipboard.py uid # UUID first 8 chars
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
def copy_to_clipboard(text):
|
||||
"""Copy text to both clipboard and primary selection (for Shift+Insert in terminals)."""
|
||||
try:
|
||||
# Try wl-copy first (Wayland)
|
||||
subprocess.run(["wl-copy"], input=text.encode(), check=True)
|
||||
subprocess.run(["wl-copy", "--primary"], input=text.encode(), check=True)
|
||||
except FileNotFoundError:
|
||||
# Fallback to xclip (X11)
|
||||
try:
|
||||
subprocess.run(
|
||||
["xclip", "-selection", "clipboard"], input=text.encode(), check=True
|
||||
)
|
||||
subprocess.run(
|
||||
["xclip", "-selection", "primary"], input=text.encode(), check=True
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"Error: Neither wl-copy nor xclip found. Install wl-clipboard or xclip.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_weekday():
|
||||
"""ISO week.day format (e.g., 51.2)"""
|
||||
ywd = datetime.now().isocalendar()
|
||||
return f"{ywd[1]}.{ywd[2]}"
|
||||
|
||||
|
||||
def get_360time():
|
||||
"""Day divided into 360 parts (0-359)"""
|
||||
t = datetime.now()
|
||||
return str((t.hour * 60 + t.minute) // (1440 // 360))
|
||||
|
||||
|
||||
def get_uid():
|
||||
"""First 8 characters of UUID"""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
format_type = sys.argv[1].lower()
|
||||
|
||||
formatters = {
|
||||
"wd": get_weekday,
|
||||
"3t": get_360time,
|
||||
"uid": get_uid,
|
||||
}
|
||||
|
||||
if format_type not in formatters:
|
||||
print(f"Unknown format: {format_type}")
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
text = formatters[format_type]()
|
||||
copy_to_clipboard(text)
|
||||
|
||||
# Optional: print to stdout for verification
|
||||
print(f"Copied to clipboard: {text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
dmapp/dmos/gnome-extension/deskmeter-indicator@local.zip
Normal file
BIN
dmapp/dmos/gnome-extension/deskmeter-indicator@local.zip
Normal file
Binary file not shown.
@@ -0,0 +1,235 @@
|
||||
import GObject from "gi://GObject";
|
||||
import St from "gi://St";
|
||||
import Gio from "gi://Gio";
|
||||
import GLib from "gi://GLib";
|
||||
import Clutter from "gi://Clutter";
|
||||
|
||||
import * as Main from "resource:///org/gnome/shell/ui/main.js";
|
||||
import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js";
|
||||
|
||||
// Try common ports - worktree (10001) first, then default (10000)
|
||||
const DEFAULT_PORTS = [10001, 10000];
|
||||
const DEBOUNCE_DELAY = 2200; // Wait 2.2s after workspace switch (dmcore polls every 2s)
|
||||
const PERIODIC_REFRESH_SECONDS = 10; // Periodic refresh as backup to catch stale data
|
||||
let DESKMETER_API_URL = null;
|
||||
|
||||
const TaskIndicator = GObject.registerClass(
|
||||
class TaskIndicator extends PanelMenu.Button {
|
||||
_init() {
|
||||
super._init(0.0, "Deskmeter Task Indicator", false);
|
||||
|
||||
// Create label for task display
|
||||
this._label = new St.Label({
|
||||
text: "detecting...",
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
style_class: "deskmeter-task-label",
|
||||
});
|
||||
|
||||
this.add_child(this._label);
|
||||
|
||||
this._debounceTimeout = null;
|
||||
this._periodicTimeout = null;
|
||||
this._workspaceManager = global.workspace_manager;
|
||||
this._apiUrl = null;
|
||||
|
||||
// Connect to workspace switch signal
|
||||
this._workspaceSwitchedId = this._workspaceManager.connect(
|
||||
"workspace-switched",
|
||||
this._onWorkspaceSwitched.bind(this),
|
||||
);
|
||||
|
||||
// Detect API port, then start updates
|
||||
this._detectApiPort();
|
||||
}
|
||||
|
||||
_startPeriodicRefresh() {
|
||||
// Periodic refresh as backup to catch stale data
|
||||
this._periodicTimeout = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
PERIODIC_REFRESH_SECONDS,
|
||||
() => {
|
||||
this._updateTask();
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_detectApiPort() {
|
||||
// Try each port in sequence
|
||||
this._tryNextPort(0);
|
||||
}
|
||||
|
||||
_tryNextPort(index) {
|
||||
if (index >= DEFAULT_PORTS.length) {
|
||||
// No ports responded, use default and let it show "offline"
|
||||
this._apiUrl = `http://localhost:${DEFAULT_PORTS[DEFAULT_PORTS.length - 1]}/api/current_task`;
|
||||
this._scheduleUpdate();
|
||||
this._startPeriodicRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const port = DEFAULT_PORTS[index];
|
||||
const url = `http://localhost:${port}/api/current_task`;
|
||||
|
||||
try {
|
||||
let file = Gio.File.new_for_uri(url);
|
||||
file.load_contents_async(null, (source, result) => {
|
||||
try {
|
||||
let [success, contents] = source.load_contents_finish(result);
|
||||
if (success) {
|
||||
// Port responded, use it
|
||||
this._apiUrl = url;
|
||||
this._scheduleUpdate();
|
||||
this._startPeriodicRefresh();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// This port failed, try next
|
||||
this._tryNextPort(index + 1);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// This port failed, try next
|
||||
this._tryNextPort(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
_onWorkspaceSwitched() {
|
||||
// Debounce updates - dmcore takes ~2 seconds to detect and update
|
||||
// We wait a bit to ensure the task has been updated in MongoDB
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
|
||||
_scheduleUpdate() {
|
||||
// Clear any pending update
|
||||
if (this._debounceTimeout) {
|
||||
GLib.source_remove(this._debounceTimeout);
|
||||
}
|
||||
|
||||
// Schedule new update
|
||||
this._debounceTimeout = GLib.timeout_add(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
DEBOUNCE_DELAY,
|
||||
() => {
|
||||
this._updateTask();
|
||||
this._debounceTimeout = null;
|
||||
return GLib.SOURCE_REMOVE;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_updateTask() {
|
||||
if (!this._apiUrl) {
|
||||
this._label.set_text("detecting...");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create HTTP request
|
||||
let file = Gio.File.new_for_uri(this._apiUrl);
|
||||
file.load_contents_async(null, (source, result) => {
|
||||
try {
|
||||
let [success, contents] = source.load_contents_finish(result);
|
||||
if (success) {
|
||||
let decoder = new TextDecoder("utf-8");
|
||||
let data = JSON.parse(decoder.decode(contents));
|
||||
|
||||
// Update label with task path
|
||||
let displayText = data.task_path || "no task";
|
||||
|
||||
// Optionally truncate long paths
|
||||
if (displayText.length > 40) {
|
||||
let parts = displayText.split("/");
|
||||
if (parts.length > 2) {
|
||||
displayText = ".../" + parts.slice(-2).join("/");
|
||||
} else {
|
||||
displayText = displayText.substring(0, 37) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
this._label.set_text(displayText);
|
||||
}
|
||||
} catch (e) {
|
||||
this._label.set_text("error");
|
||||
logError(e, "Failed to parse deskmeter response");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this._label.set_text("offline");
|
||||
logError(e, "Failed to fetch deskmeter task");
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
try {
|
||||
if (this._debounceTimeout) {
|
||||
GLib.source_remove(this._debounceTimeout);
|
||||
this._debounceTimeout = null;
|
||||
}
|
||||
|
||||
if (this._periodicTimeout) {
|
||||
GLib.source_remove(this._periodicTimeout);
|
||||
this._periodicTimeout = null;
|
||||
}
|
||||
|
||||
if (this._workspaceSwitchedId) {
|
||||
this._workspaceManager.disconnect(this._workspaceSwitchedId);
|
||||
this._workspaceSwitchedId = null;
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
} catch (e) {
|
||||
// Log error but don't crash GNOME Shell
|
||||
logError(e, "Failed to destroy TaskIndicator");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default class Extension {
|
||||
constructor() {
|
||||
this._indicator = null;
|
||||
}
|
||||
|
||||
enable() {
|
||||
try {
|
||||
this._indicator = new TaskIndicator();
|
||||
|
||||
// Add to panel - position after workspace indicator
|
||||
// Panel boxes: left, center, right
|
||||
// We'll add it to the left panel, after other items
|
||||
Main.panel.addToStatusArea(
|
||||
"deskmeter-task-indicator",
|
||||
this._indicator,
|
||||
1,
|
||||
"left",
|
||||
);
|
||||
} catch (e) {
|
||||
// Log error but don't crash GNOME Shell
|
||||
logError(e, "Failed to enable Deskmeter extension");
|
||||
|
||||
// Clean up if partially initialized
|
||||
if (this._indicator) {
|
||||
try {
|
||||
this._indicator.destroy();
|
||||
} catch (destroyError) {
|
||||
logError(destroyError, "Failed to cleanup indicator");
|
||||
}
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disable() {
|
||||
try {
|
||||
if (this._indicator) {
|
||||
this._indicator.destroy();
|
||||
this._indicator = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Log error but don't crash GNOME Shell
|
||||
logError(e, "Failed to disable Deskmeter extension");
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
"44",
|
||||
"45",
|
||||
"46",
|
||||
"47"
|
||||
"47",
|
||||
"48",
|
||||
"49"
|
||||
],
|
||||
"url": "",
|
||||
"version": 1
|
||||
167
dmapp/dmos/task_window.py
Normal file
167
dmapp/dmos/task_window.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Deskmeter Task Window - Regular Mode
|
||||
Shows current task in an always-on-top window visible on all workspaces
|
||||
"""
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '4.0')
|
||||
from gi.repository import Gtk, GLib
|
||||
import urllib.request
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Try ports in order: env var, command line arg, or try common ports
|
||||
DEFAULT_PORTS = [10001, 10000] # worktree first, then default
|
||||
UPDATE_INTERVAL = 500 # milliseconds - fast updates to catch workspace changes
|
||||
WORKSPACE_CHECK_INTERVAL = 200 # milliseconds - check for workspace changes
|
||||
|
||||
# Global API URL (will be set after port detection)
|
||||
DESKMETER_API_URL = None
|
||||
|
||||
|
||||
class TaskWindow(Gtk.ApplicationWindow):
|
||||
def __init__(self, app, api_url):
|
||||
super().__init__(application=app, title="Deskmeter Task")
|
||||
|
||||
self.api_url = api_url
|
||||
|
||||
# Set window properties
|
||||
self.set_default_size(400, 60)
|
||||
|
||||
# Remove window decorations (no title bar, close button, etc.)
|
||||
self.set_decorated(False)
|
||||
|
||||
# Create label for task display
|
||||
self.label = Gtk.Label(label="Loading...")
|
||||
self.label.set_markup('<span font_desc="14">Loading...</span>')
|
||||
self.label.set_margin_top(20)
|
||||
self.label.set_margin_bottom(20)
|
||||
self.label.set_margin_start(20)
|
||||
self.label.set_margin_end(20)
|
||||
|
||||
# Set label to allow selection (useful for copying)
|
||||
self.label.set_selectable(True)
|
||||
|
||||
# Create box container
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
box.append(self.label)
|
||||
|
||||
self.set_child(box)
|
||||
|
||||
# Track current workspace for change detection
|
||||
self.current_workspace = self._get_current_workspace()
|
||||
self.last_task = None
|
||||
|
||||
# Start periodic updates
|
||||
GLib.timeout_add(UPDATE_INTERVAL, self.update_task)
|
||||
|
||||
# Monitor workspace changes more frequently
|
||||
GLib.timeout_add(WORKSPACE_CHECK_INTERVAL, self.check_workspace_change)
|
||||
|
||||
# Initial update
|
||||
self.update_task()
|
||||
|
||||
def _get_current_workspace(self):
|
||||
"""Get current workspace number using wmctrl"""
|
||||
try:
|
||||
result = subprocess.run(['wmctrl', '-d'], capture_output=True, text=True, timeout=1)
|
||||
for line in result.stdout.splitlines():
|
||||
if '*' in line: # Current workspace marked with *
|
||||
return line.split()[0]
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
def check_workspace_change(self):
|
||||
"""Check if workspace changed and trigger immediate update"""
|
||||
new_workspace = self._get_current_workspace()
|
||||
if new_workspace != self.current_workspace and new_workspace is not None:
|
||||
self.current_workspace = new_workspace
|
||||
# Workspace changed, update immediately
|
||||
GLib.timeout_add(2200, self.update_task) # Wait 2.2s for dmcore to update
|
||||
return True
|
||||
|
||||
def update_task(self):
|
||||
"""Fetch current task from API and update label"""
|
||||
try:
|
||||
with urllib.request.urlopen(self.api_url, timeout=2) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
task_path = data.get('task_path', 'no task')
|
||||
|
||||
# Only update label if task changed (reduces flicker)
|
||||
if task_path != self.last_task:
|
||||
self.last_task = task_path
|
||||
# Update label with markup for better visibility
|
||||
self.label.set_markup(f'<span font_desc="14" weight="bold">{task_path}</span>')
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
self.label.set_markup('<span font_desc="14" foreground="red">offline - dmweb not running</span>')
|
||||
self.last_task = None
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.label.set_markup('<span font_desc="14" foreground="orange">error - invalid API response</span>')
|
||||
self.last_task = None
|
||||
|
||||
except Exception as e:
|
||||
self.label.set_markup(f'<span font_desc="14" foreground="red">error: {str(e)}</span>')
|
||||
self.last_task = None
|
||||
|
||||
# Return True to keep the timer running
|
||||
return True
|
||||
|
||||
|
||||
class TaskApp(Gtk.Application):
|
||||
def __init__(self, api_url):
|
||||
super().__init__(application_id='local.deskmeter.task-window')
|
||||
self.api_url = api_url
|
||||
|
||||
def do_activate(self):
|
||||
window = TaskWindow(self, self.api_url)
|
||||
window.present()
|
||||
|
||||
|
||||
def detect_api_port():
|
||||
"""Try to find which port the dmweb API is running on"""
|
||||
# Check environment variable first
|
||||
env_port = os.environ.get('DESKMETER_PORT')
|
||||
if env_port:
|
||||
url = f'http://localhost:{env_port}/api/current_task'
|
||||
print(f"Using port from DESKMETER_PORT env var: {env_port}")
|
||||
return url
|
||||
|
||||
# Try common ports
|
||||
for port in DEFAULT_PORTS:
|
||||
url = f'http://localhost:{port}/api/current_task'
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as response:
|
||||
# If we get here, the port is responding
|
||||
print(f"Found dmweb API on port {port}")
|
||||
return url
|
||||
except:
|
||||
continue
|
||||
|
||||
# Fallback to default
|
||||
print(f"Could not detect dmweb API, using default port {DEFAULT_PORTS[-1]}")
|
||||
return f'http://localhost:{DEFAULT_PORTS[-1]}/api/current_task'
|
||||
|
||||
|
||||
def main():
|
||||
# Check for port argument
|
||||
if len(sys.argv) > 1 and sys.argv[1].isdigit():
|
||||
port = sys.argv[1]
|
||||
api_url = f'http://localhost:{port}/api/current_task'
|
||||
print(f"Using port from command line: {port}")
|
||||
else:
|
||||
api_url = detect_api_port()
|
||||
|
||||
print(f"API URL: {api_url}")
|
||||
|
||||
app = TaskApp(api_url)
|
||||
return app.run([])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,8 +1,20 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, render_template, jsonify
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
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 (
|
||||
SUPPORTED_TIMEZONES,
|
||||
convert_seconds,
|
||||
default_timezone,
|
||||
get_current_task_info,
|
||||
get_period_totals,
|
||||
get_raw_switches,
|
||||
get_task_blocks_calendar,
|
||||
get_task_time_seconds,
|
||||
get_timezone,
|
||||
get_work_period_totals,
|
||||
task_or_none,
|
||||
)
|
||||
|
||||
dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates")
|
||||
|
||||
@@ -20,6 +32,12 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
Google Calendar-style view showing task blocks at their actual times.
|
||||
"""
|
||||
task = None
|
||||
grid = int(request.args.get("grid", 1)) # Grid hours: 1, 3, or 6
|
||||
if grid not in [1, 3, 6]:
|
||||
grid = 1
|
||||
|
||||
tz_name = request.args.get("tz")
|
||||
tz = get_timezone(tz_name)
|
||||
|
||||
if not year:
|
||||
year = datetime.today().year
|
||||
@@ -28,12 +46,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=tz
|
||||
)
|
||||
|
||||
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)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=60, grid_hours=grid, tz=tz
|
||||
)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
days = [base_date]
|
||||
@@ -41,7 +63,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)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=300, grid_hours=grid, tz=tz
|
||||
)
|
||||
prev_date = start - timedelta(days=7)
|
||||
next_date = start + timedelta(days=7)
|
||||
days = [start + timedelta(days=i) for i in range(7)]
|
||||
@@ -49,18 +73,20 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
elif scope == "monthly":
|
||||
start = base_date.replace(day=1)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1)
|
||||
end = datetime(year + 1, 1, 1, tzinfo=tz) - 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)
|
||||
end = datetime(year, month + 1, 1, tzinfo=tz) - timedelta(seconds=1)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=600, grid_hours=grid, tz=tz
|
||||
)
|
||||
if month == 1:
|
||||
prev_date = datetime(year - 1, 12, 1, tzinfo=timezone)
|
||||
prev_date = datetime(year - 1, 12, 1, tzinfo=tz)
|
||||
else:
|
||||
prev_date = datetime(year, month - 1, 1, tzinfo=timezone)
|
||||
prev_date = datetime(year, month - 1, 1, tzinfo=tz)
|
||||
if month == 12:
|
||||
next_date = datetime(year + 1, 1, 1, tzinfo=timezone)
|
||||
next_date = datetime(year + 1, 1, 1, tzinfo=tz)
|
||||
else:
|
||||
next_date = datetime(year, month + 1, 1, tzinfo=timezone)
|
||||
next_date = datetime(year, month + 1, 1, tzinfo=tz)
|
||||
days = []
|
||||
current = start
|
||||
while current <= end:
|
||||
@@ -70,7 +96,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)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=60, grid_hours=grid, tz=tz
|
||||
)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
days = [base_date]
|
||||
@@ -85,7 +113,10 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
prev_date=prev_date,
|
||||
next_date=next_date,
|
||||
days=days,
|
||||
auto_refresh=False
|
||||
grid=grid,
|
||||
tz_name=tz_name,
|
||||
timezones=SUPPORTED_TIMEZONES,
|
||||
auto_refresh=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +129,9 @@ def switches_view(scope="daily", year=None, month=None, day=None):
|
||||
"""
|
||||
task = None
|
||||
|
||||
tz_name = request.args.get("tz")
|
||||
tz = get_timezone(tz_name)
|
||||
|
||||
if not year:
|
||||
year = datetime.today().year
|
||||
if not month:
|
||||
@@ -105,7 +139,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=tz
|
||||
)
|
||||
|
||||
if scope == "daily":
|
||||
start = base_date
|
||||
@@ -122,17 +158,17 @@ def switches_view(scope="daily", year=None, month=None, day=None):
|
||||
elif scope == "monthly":
|
||||
start = base_date.replace(day=1)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1)
|
||||
end = datetime(year + 1, 1, 1, tzinfo=tz) - timedelta(seconds=1)
|
||||
else:
|
||||
end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1)
|
||||
end = datetime(year, month + 1, 1, tzinfo=tz) - timedelta(seconds=1)
|
||||
if month == 1:
|
||||
prev_date = datetime(year - 1, 12, 1, tzinfo=timezone)
|
||||
prev_date = datetime(year - 1, 12, 1, tzinfo=tz)
|
||||
else:
|
||||
prev_date = datetime(year, month - 1, 1, tzinfo=timezone)
|
||||
prev_date = datetime(year, month - 1, 1, tzinfo=tz)
|
||||
if month == 12:
|
||||
next_date = datetime(year + 1, 1, 1, tzinfo=timezone)
|
||||
next_date = datetime(year + 1, 1, 1, tzinfo=tz)
|
||||
else:
|
||||
next_date = datetime(year, month + 1, 1, tzinfo=timezone)
|
||||
next_date = datetime(year, month + 1, 1, tzinfo=tz)
|
||||
else:
|
||||
scope = "daily"
|
||||
start = base_date
|
||||
@@ -140,7 +176,7 @@ def switches_view(scope="daily", year=None, month=None, day=None):
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
|
||||
raw_switches = get_raw_switches(start, end, task)
|
||||
raw_switches = get_raw_switches(start, end, task, tz=tz)
|
||||
|
||||
return render_template(
|
||||
"switches_view.html",
|
||||
@@ -151,7 +187,9 @@ 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
|
||||
tz_name=tz_name,
|
||||
timezones=SUPPORTED_TIMEZONES,
|
||||
auto_refresh=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -163,20 +201,28 @@ def index(task=None):
|
||||
"""
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
start = datetime.today().replace(
|
||||
hour=0, minute=0, second=0, tzinfo=default_timezone
|
||||
)
|
||||
end = datetime.today().replace(
|
||||
hour=23, minute=59, second=59, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
rows = get_period_totals(start, end, task)
|
||||
|
||||
# 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")
|
||||
@@ -185,10 +231,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")
|
||||
@@ -199,20 +244,27 @@ def api_today(task=None):
|
||||
"""
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
start = datetime.today().replace(
|
||||
hour=0, minute=0, second=0, tzinfo=default_timezone
|
||||
)
|
||||
end = datetime.today().replace(
|
||||
hour=23, minute=59, second=59, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
rows = get_period_totals(start, end, task)
|
||||
|
||||
# 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/<int:month>/<int:day>")
|
||||
@@ -225,11 +277,11 @@ def oneday(
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime(2025, month, day).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
hour=0, minute=0, second=0, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
end = datetime(2025, month, day).replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone
|
||||
hour=23, minute=59, second=59, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
rows = get_period_totals(start, end)
|
||||
@@ -240,11 +292,11 @@ def oneday(
|
||||
@dmbp.route("/period/<start>/<end>")
|
||||
def period(start, end):
|
||||
start = datetime(*map(int, start.split("-"))).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
hour=0, minute=0, second=0, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
end = datetime(*map(int, end.split("-"))).replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone
|
||||
hour=23, minute=59, second=59, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
rows = get_period_totals(start, end)
|
||||
@@ -257,8 +309,12 @@ def work():
|
||||
"""
|
||||
Show total time used per work project for today
|
||||
"""
|
||||
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
start = datetime.today().replace(
|
||||
hour=0, minute=0, second=0, tzinfo=default_timezone
|
||||
)
|
||||
end = datetime.today().replace(
|
||||
hour=23, minute=59, second=59, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
rows = get_work_period_totals(start, end)
|
||||
|
||||
@@ -274,9 +330,13 @@ def totals(task=None):
|
||||
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime(2020, 1, 1).replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
start = datetime(2020, 1, 1).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
end = datetime(2030, 1, 1).replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
end = datetime(2030, 1, 1).replace(
|
||||
hour=23, minute=59, second=59, tzinfo=default_timezone
|
||||
)
|
||||
|
||||
rows = get_period_totals(start, end)
|
||||
|
||||
|
||||
@@ -8,15 +8,12 @@ from flask import Blueprint, render_template
|
||||
from .dm import dmbp
|
||||
from .get_period_times import (
|
||||
get_period_totals,
|
||||
read_and_extract,
|
||||
task_file,
|
||||
get_work_period_totals,
|
||||
task_or_none,
|
||||
timezone,
|
||||
get_work_period_totals,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class DMHTMLCalendar(calendar.HTMLCalendar):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -148,7 +145,9 @@ def workmonth(month=None, year=None):
|
||||
cal.setcalmonth(usemonth)
|
||||
cal.setcalyear(useyear)
|
||||
|
||||
return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=False)
|
||||
return render_template(
|
||||
"calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=False
|
||||
)
|
||||
|
||||
|
||||
@dmbp.route("/month")
|
||||
@@ -174,4 +173,6 @@ def month(month=None, year=None, task=None):
|
||||
cal.setcalmonth(usemonth)
|
||||
cal.setcalyear(useyear)
|
||||
|
||||
return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=True)
|
||||
return render_template(
|
||||
"calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=True
|
||||
)
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from pymongo import MongoClient
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
timezone = ZoneInfo("America/Argentina/Buenos_Aires")
|
||||
from pymongo import MongoClient
|
||||
|
||||
default_timezone = ZoneInfo("America/Argentina/Buenos_Aires")
|
||||
timezone = default_timezone # Keep for backwards compatibility
|
||||
utctz = ZoneInfo("UTC")
|
||||
|
||||
client = MongoClient()
|
||||
SUPPORTED_TIMEZONES = [
|
||||
("America/Argentina/Buenos_Aires", "Buenos Aires"),
|
||||
("America/New_York", "New York"),
|
||||
("America/Los_Angeles", "Los Angeles"),
|
||||
("Europe/London", "London"),
|
||||
("Europe/Paris", "Paris"),
|
||||
("UTC", "UTC"),
|
||||
]
|
||||
|
||||
|
||||
def get_timezone(tz_name=None):
|
||||
"""Get ZoneInfo for timezone name, with validation."""
|
||||
if not tz_name:
|
||||
return default_timezone
|
||||
# Validate against supported list
|
||||
valid_names = [tz[0] for tz in SUPPORTED_TIMEZONES]
|
||||
if tz_name in valid_names:
|
||||
return ZoneInfo(tz_name)
|
||||
return default_timezone
|
||||
|
||||
|
||||
client = MongoClient(os.environ.get("MONGODB_HOST", "localhost"))
|
||||
db = client.deskmeter
|
||||
switches = db.switch
|
||||
tasks = db.task
|
||||
@@ -38,8 +61,8 @@ def parse_task_line(line):
|
||||
|
||||
|
||||
def load_task_from_files(task_id):
|
||||
"""Search task directory files for a task ID and load it into task_history."""
|
||||
for task_filepath in task_dir.glob("*"):
|
||||
"""Search task directory files (recursively) for a task ID and load it into task_history."""
|
||||
for task_filepath in task_dir.glob("**/*"):
|
||||
if not task_filepath.is_file():
|
||||
continue
|
||||
|
||||
@@ -65,12 +88,14 @@ def load_task_from_files(task_id):
|
||||
# Found it! Insert into task_history
|
||||
task_history.update_one(
|
||||
{"task_id": task_id},
|
||||
{"$set": {
|
||||
"path": full_path,
|
||||
"task_id": task_id,
|
||||
"source_file": task_filepath.name
|
||||
}},
|
||||
upsert=True
|
||||
{
|
||||
"$set": {
|
||||
"path": full_path,
|
||||
"task_id": task_id,
|
||||
"source_file": task_filepath.name,
|
||||
}
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
return full_path
|
||||
except:
|
||||
@@ -134,15 +159,10 @@ def get_task_time_seconds(start, end, task_id, workspaces=None):
|
||||
"$match": {
|
||||
"date": {"$gte": start, "$lte": end},
|
||||
"task": task_id,
|
||||
"workspace": {"$in": workspaces}
|
||||
"workspace": {"$in": workspaces},
|
||||
}
|
||||
},
|
||||
{
|
||||
"$group": {
|
||||
"_id": None,
|
||||
"total_seconds": {"$sum": "$delta"}
|
||||
}
|
||||
}
|
||||
{"$group": {"_id": None, "total_seconds": {"$sum": "$delta"}}},
|
||||
]
|
||||
|
||||
result = list(switches.aggregate(pipeline))
|
||||
@@ -154,12 +174,8 @@ def get_task_time_seconds(start, end, task_id, workspaces=None):
|
||||
|
||||
|
||||
def task_or_none(task=None):
|
||||
if not task:
|
||||
task = read_and_extract(task_file)
|
||||
|
||||
if task == "all":
|
||||
task = None
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@@ -180,24 +196,6 @@ def convert_seconds(seconds, use_days=False):
|
||||
return "{:02d}:{:02d}:{:02d}".format(hours + days * 24, minutes, remaining_seconds)
|
||||
|
||||
|
||||
def extract(line):
|
||||
if line.rstrip().endswith("*"):
|
||||
pipe_index = line.find("|")
|
||||
if pipe_index != -1 and len(line) > pipe_index + 8:
|
||||
value = line[pipe_index + 1 : pipe_index + 9]
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def read_and_extract(file_path):
|
||||
with open(file_path, "r") as file:
|
||||
for line in file:
|
||||
value = extract(line)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def get_work_period_totals(start, end):
|
||||
"""Get period totals grouped by task with full path."""
|
||||
# Get all tasks with time in the period
|
||||
@@ -206,15 +204,10 @@ def get_work_period_totals(start, end):
|
||||
"$match": {
|
||||
"date": {"$gte": start, "$lte": end},
|
||||
"workspace": {"$in": ["Plan", "Think", "Work"]},
|
||||
"task": {"$exists": True, "$ne": None}
|
||||
"task": {"$exists": True, "$ne": None},
|
||||
}
|
||||
},
|
||||
{
|
||||
"$group": {
|
||||
"_id": "$task",
|
||||
"total_seconds": {"$sum": "$delta"}
|
||||
}
|
||||
}
|
||||
{"$group": {"_id": "$task", "total_seconds": {"$sum": "$delta"}}},
|
||||
]
|
||||
|
||||
results = list(switches.aggregate(pipeline))
|
||||
@@ -228,98 +221,120 @@ def get_work_period_totals(start, end):
|
||||
# Get task path with history fallback
|
||||
task_path = get_task_path(task_id)
|
||||
|
||||
combined_rows.append({
|
||||
"ws": task_path,
|
||||
"total": convert_seconds(total_seconds)
|
||||
})
|
||||
combined_rows.append(
|
||||
{"ws": task_path, "total": convert_seconds(total_seconds)}
|
||||
)
|
||||
|
||||
# Sort by path for consistency
|
||||
combined_rows.sort(key=lambda x: x["ws"])
|
||||
return combined_rows
|
||||
|
||||
|
||||
def get_task_blocks_calendar(start, end, task=None, min_block_seconds=300):
|
||||
def get_task_blocks_calendar(
|
||||
start, end, task=None, min_block_seconds=300, grid_hours=1, tz=None
|
||||
):
|
||||
"""
|
||||
Get task blocks for calendar-style visualization.
|
||||
Groups consecutive switches to the same task into blocks, tracking active/idle time.
|
||||
Get task blocks for calendar-style visualization, aggregated by time grid.
|
||||
Shows all tasks worked on during each grid period, with overlapping blocks.
|
||||
Each task block's height is proportional to time spent in that grid period.
|
||||
|
||||
Args:
|
||||
grid_hours: Grid size in hours (1, 3, or 6)
|
||||
|
||||
Returns list of blocks:
|
||||
[{
|
||||
'task_id': str,
|
||||
'task_path': str,
|
||||
'start': datetime,
|
||||
'end': datetime,
|
||||
'duration': int (total seconds),
|
||||
'active_seconds': int (Plan/Think/Work time),
|
||||
'idle_seconds': int (Other/Away time),
|
||||
'active_ratio': float (0.0 to 1.0)
|
||||
'start': datetime (start of grid period),
|
||||
'end': datetime (end of grid period or actual end time if less),
|
||||
'duration': int (seconds in this grid block),
|
||||
'hour': int (hour of grid start, 0-23),
|
||||
'active_seconds': int,
|
||||
'active_ratio': float (always 1.0)
|
||||
}, ...]
|
||||
"""
|
||||
local_tz = tz if tz else default_timezone
|
||||
|
||||
task_query = {"$in": task.split(",")} if task else {}
|
||||
|
||||
match_query = {"date": {"$gte": start, "$lte": end}}
|
||||
match_query = {
|
||||
"date": {"$gte": start, "$lte": end},
|
||||
"workspace": {"$in": ["Plan", "Think", "Work"]}, # Only active workspaces
|
||||
}
|
||||
if task_query:
|
||||
match_query["task"] = task_query
|
||||
|
||||
# Get all switches in period, sorted by date
|
||||
# Get all active switches in period
|
||||
raw_switches = list(switches.find(match_query).sort("date", 1))
|
||||
|
||||
if not raw_switches:
|
||||
return []
|
||||
|
||||
blocks = []
|
||||
current_block = None
|
||||
# Aggregate by grid period and task
|
||||
# Structure: {(date, grid_start_hour, task_id): total_seconds}
|
||||
grid_task_time = defaultdict(lambda: {"duration": 0, "task_path": None})
|
||||
|
||||
for switch in raw_switches:
|
||||
ws = switch["workspace"]
|
||||
task_id = switch.get("task")
|
||||
switch_start = switch["date"].replace(tzinfo=utctz).astimezone(timezone)
|
||||
switch_start = switch["date"].replace(tzinfo=utctz).astimezone(local_tz)
|
||||
switch_duration = switch["delta"]
|
||||
switch_end = switch_start + timedelta(seconds=switch_duration)
|
||||
|
||||
is_active = ws in ["Plan", "Think", "Work"]
|
||||
# Calculate how much time falls in each grid period this switch spans
|
||||
current_time = switch_start
|
||||
remaining_duration = switch_duration
|
||||
|
||||
# Start new block if task changed
|
||||
if current_block is None or current_block["task_id"] != task_id:
|
||||
if current_block is not None:
|
||||
blocks.append(current_block)
|
||||
while remaining_duration > 0 and current_time < switch_end:
|
||||
# Calculate grid period start (hour rounded down to grid_hours)
|
||||
grid_hour = (current_time.hour // grid_hours) * grid_hours
|
||||
grid_start = current_time.replace(
|
||||
hour=grid_hour, minute=0, second=0, microsecond=0
|
||||
)
|
||||
grid_end = grid_start + timedelta(hours=grid_hours)
|
||||
|
||||
# Get task path with history fallback
|
||||
task_path = get_task_path(task_id) or "No Task"
|
||||
# Time in this grid period
|
||||
time_in_grid = min(
|
||||
(grid_end - current_time).total_seconds(), remaining_duration
|
||||
)
|
||||
|
||||
current_block = {
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"start": switch_start,
|
||||
"end": switch_end,
|
||||
"duration": switch_duration,
|
||||
"active_seconds": switch_duration if is_active else 0,
|
||||
"idle_seconds": 0 if is_active else switch_duration
|
||||
}
|
||||
else:
|
||||
# Extend current block
|
||||
current_block["end"] = switch_end
|
||||
current_block["duration"] += switch_duration
|
||||
if is_active:
|
||||
current_block["active_seconds"] += switch_duration
|
||||
else:
|
||||
current_block["idle_seconds"] += switch_duration
|
||||
key = (current_time.date(), grid_hour, task_id)
|
||||
|
||||
# Add final block
|
||||
if current_block is not None:
|
||||
blocks.append(current_block)
|
||||
# Get task path (cache it)
|
||||
if grid_task_time[key]["task_path"] is None:
|
||||
task_path = get_task_path(task_id) or "No Task"
|
||||
grid_task_time[key]["task_path"] = task_path
|
||||
|
||||
# Filter out very short blocks and calculate active ratio
|
||||
filtered_blocks = []
|
||||
for block in blocks:
|
||||
if block["duration"] >= min_block_seconds:
|
||||
block["active_ratio"] = block["active_seconds"] / block["duration"] if block["duration"] > 0 else 0
|
||||
filtered_blocks.append(block)
|
||||
grid_task_time[key]["duration"] += time_in_grid
|
||||
|
||||
return filtered_blocks
|
||||
remaining_duration -= time_in_grid
|
||||
current_time = grid_end
|
||||
|
||||
# Convert to blocks
|
||||
blocks = []
|
||||
for (date, grid_hour, task_id), data in grid_task_time.items():
|
||||
if data["duration"] >= min_block_seconds:
|
||||
grid_start = datetime(
|
||||
date.year, date.month, date.day, grid_hour, 0, 0, tzinfo=local_tz
|
||||
)
|
||||
|
||||
blocks.append(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"task_path": data["task_path"],
|
||||
"start": grid_start,
|
||||
"end": grid_start + timedelta(seconds=data["duration"]),
|
||||
"hour": grid_hour,
|
||||
"duration": int(data["duration"]),
|
||||
"active_seconds": int(data["duration"]),
|
||||
"idle_seconds": 0,
|
||||
"active_ratio": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(blocks, key=lambda x: (x["start"], x["task_path"]))
|
||||
|
||||
|
||||
def get_raw_switches(start, end, task=None):
|
||||
def get_raw_switches(start, end, task=None, tz=None):
|
||||
"""
|
||||
Get all raw switch documents in the period.
|
||||
|
||||
@@ -332,6 +347,8 @@ def get_raw_switches(start, end, task=None):
|
||||
'delta': int (seconds)
|
||||
}, ...]
|
||||
"""
|
||||
local_tz = tz if tz else default_timezone
|
||||
|
||||
task_query = {"$in": task.split(",")} if task else {}
|
||||
|
||||
match_query = {"date": {"$gte": start, "$lte": end}}
|
||||
@@ -346,13 +363,15 @@ def get_raw_switches(start, end, task=None):
|
||||
# Get task path with history fallback
|
||||
task_path = get_task_path(task_id) or "No Task"
|
||||
|
||||
result.append({
|
||||
"workspace": switch["workspace"],
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"date": switch["date"].replace(tzinfo=utctz).astimezone(timezone),
|
||||
"delta": switch["delta"]
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"workspace": switch["workspace"],
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"date": switch["date"].replace(tzinfo=utctz).astimezone(local_tz),
|
||||
"delta": switch["delta"],
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block head %}
|
||||
|
||||
{% block head %}
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
margin: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #444;
|
||||
}
|
||||
|
||||
.nav-bar a {
|
||||
text-decoration: none;
|
||||
color: #6b9bd1;
|
||||
font-size: 12pt;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.nav-bar a:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
td > * {
|
||||
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align : top;
|
||||
vertical-align: top;
|
||||
height: 25px;
|
||||
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border: 1px solid black;
|
||||
border: 1px solid #444;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -25,8 +52,16 @@ table {
|
||||
{% endblock head %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% block content %}
|
||||
|
||||
{{ content | safe }}
|
||||
<div class="nav-bar">
|
||||
<a href="/">Today</a>
|
||||
<a href="/calendar/daily">Calendar</a>
|
||||
<a href="/switches/daily">Switches</a>
|
||||
<a href="/work">Work</a>
|
||||
<a href="/totals">All Time</a>
|
||||
</div>
|
||||
|
||||
{{ content | safe }}
|
||||
|
||||
{% endblock content %}
|
||||
@@ -1,32 +1,48 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% extends 'layout.html' %} {% block head %}
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
font-family: monospace;
|
||||
background: #fff;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.nav-bar a {
|
||||
text-decoration: none;
|
||||
color: #6b9bd1;
|
||||
font-size: 12pt;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.nav-bar a:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #333;
|
||||
border-bottom: 2px solid #444;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a {
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
color: #888;
|
||||
font-size: 16pt;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a.active {
|
||||
color: #000;
|
||||
color: #e0e0e0;
|
||||
font-weight: bold;
|
||||
border-bottom: 3px solid #000;
|
||||
border-bottom: 3px solid #6b9bd1;
|
||||
}
|
||||
|
||||
.date-nav {
|
||||
@@ -39,33 +55,38 @@
|
||||
|
||||
.date-nav a {
|
||||
text-decoration: none;
|
||||
color: #2563eb;
|
||||
color: #6b9bd1;
|
||||
font-size: 18pt;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid #333;
|
||||
min-height: 600px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
width: 60px;
|
||||
border-right: 1px solid #ddd;
|
||||
background: #f9f9f9;
|
||||
border-right: 1px solid #333;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding: 5px;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
@@ -75,8 +96,9 @@
|
||||
|
||||
.day-column {
|
||||
flex: 1;
|
||||
border-right: 1px solid #ddd;
|
||||
border-right: 1px solid #333;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.day-column:last-child {
|
||||
@@ -85,13 +107,15 @@
|
||||
|
||||
.day-header {
|
||||
height: 40px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
background: #f0f0f0;
|
||||
border-bottom: 2px solid #444;
|
||||
background: #2a2a2a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 11pt;
|
||||
color: #e0e0e0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.day-grid {
|
||||
@@ -104,27 +128,34 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.task-block {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
font-size: 9pt;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
transition: all 0.15s ease-out;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.task-block:hover {
|
||||
z-index: 100;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
z-index: 100 !important;
|
||||
overflow: visible;
|
||||
min-height: fit-content;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.task-block:hover .task-label,
|
||||
.task-block:hover .task-time {
|
||||
background: inherit;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.task-label {
|
||||
@@ -136,60 +167,113 @@
|
||||
font-size: 8pt;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.block-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10pt;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.task-block:hover .block-tooltip {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
{% endblock head %}
|
||||
{% endblock head %} {% block content %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
class="nav-bar"
|
||||
style="
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #444;
|
||||
font-size: 12pt;
|
||||
"
|
||||
>
|
||||
<a href="/">Today</a>
|
||||
<a href="/calendar/daily">Calendar</a>
|
||||
<a
|
||||
href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
|
||||
>Switches</a
|
||||
>
|
||||
<a href="/totals">All Time</a>
|
||||
<select
|
||||
id="tz-selector"
|
||||
style="
|
||||
margin-left: auto;
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 12pt;
|
||||
"
|
||||
onchange="window.location.href = window.location.pathname + '?grid={{ grid }}&tz=' + this.value"
|
||||
>
|
||||
{% for tz_value, tz_label in timezones %}
|
||||
<option value="{{ tz_value }}" {% if tz_name == tz_value or (not tz_name and loop.first) %}selected{% endif %}>
|
||||
{{ tz_label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="nav-tabs">
|
||||
<a href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'daily' %}active{% endif %}">Daily</a>
|
||||
<a href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a>
|
||||
<a href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a>
|
||||
<span style="margin-left: auto;">
|
||||
<a href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Switches</a>
|
||||
<a
|
||||
href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
class="{% if scope == 'daily' %}active{% endif %}"
|
||||
>Daily</a
|
||||
>
|
||||
<a
|
||||
href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
class="{% if scope == 'weekly' %}active{% endif %}"
|
||||
>Weekly</a
|
||||
>
|
||||
<a
|
||||
href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
class="{% if scope == 'monthly' %}active{% endif %}"
|
||||
>Monthly</a
|
||||
>
|
||||
<span
|
||||
style="margin-left: auto; display: flex; gap: 15px; align-items: center"
|
||||
>
|
||||
<span style="color: #999; font-size: 11pt">Grid:</span>
|
||||
<a
|
||||
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=1{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
class="{% if grid == 1 %}active{% endif %}"
|
||||
style="font-size: 11pt"
|
||||
>1h</a
|
||||
>
|
||||
<a
|
||||
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=3{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
class="{% if grid == 3 %}active{% endif %}"
|
||||
style="font-size: 11pt"
|
||||
>3h</a
|
||||
>
|
||||
<a
|
||||
href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}?grid=6{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
class="{% if grid == 6 %}active{% endif %}"
|
||||
style="font-size: 11pt"
|
||||
>6h</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="date-nav">
|
||||
<a href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}">←</a>
|
||||
<a
|
||||
href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
>←</a
|
||||
>
|
||||
{% if scope == 'daily' %}
|
||||
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
|
||||
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
|
||||
{% elif scope == 'weekly' %}
|
||||
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
|
||||
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
|
||||
{% elif scope == 'monthly' %}
|
||||
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
|
||||
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
|
||||
{% endif %}
|
||||
<a href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}">→</a>
|
||||
<a
|
||||
href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}?grid={{ grid }}{% if tz_name %}&tz={{ tz_name }}{% endif %}"
|
||||
>→</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid">
|
||||
<div class="time-column">
|
||||
<div class="day-header" style="height: 40px;"></div>
|
||||
{% for hour in range(24) %}
|
||||
<div class="time-slot">{{ '%02d:00'|format(hour) }}</div>
|
||||
<div class="day-header" style="height: 40px"></div>
|
||||
{% for hour in range(0, 24, grid) %}
|
||||
<div class="time-slot" style="height: {{ grid * 60 }}px;">
|
||||
{{ '%02d:00'|format(hour) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -197,44 +281,44 @@
|
||||
{% for day in days %}
|
||||
<div class="day-column">
|
||||
<div class="day-header">
|
||||
{{ day.strftime('%a %d') if scope != 'daily' else day.strftime('%A') }}
|
||||
{{ day.strftime('%a %d') if scope != 'daily' else
|
||||
day.strftime('%A') }}
|
||||
</div>
|
||||
<div class="day-grid">
|
||||
{% for hour in range(24) %}
|
||||
<div class="hour-line" style="top: {{ hour * 60 }}px;"></div>
|
||||
{% endfor %}
|
||||
{% for hour in range(0, 24, grid) %}
|
||||
<div
|
||||
class="hour-line"
|
||||
style="top: {{ hour * 60 }}px; height: {{ grid * 60 }}px;"
|
||||
></div>
|
||||
{% endfor %} {% set day_blocks = [] %} {% for block in blocks %}
|
||||
{% if block.start.date() == day.date() %} {% set _ =
|
||||
day_blocks.append(block) %} {% endif %} {% endfor %} {% set
|
||||
hour_groups = {} %} {% for block in day_blocks %} {% set
|
||||
hour_key = block.hour %} {% if hour_key not in hour_groups %} {%
|
||||
set _ = hour_groups.update({hour_key: []}) %} {% endif %} {% set
|
||||
_ = hour_groups[hour_key].append(block) %} {% endfor %} {% for
|
||||
hour_key, hour_blocks in hour_groups.items() %} {% for block in
|
||||
hour_blocks %} {% set idx = loop.index0 %} {% set base_top_px =
|
||||
block.hour * 60 %} {% set duration_hours = block.duration /
|
||||
3600.0 %} {% set height_px = duration_hours * 60 %} {% if
|
||||
height_px < 2 %}{% set height_px = 2 %}{% endif %} {% set
|
||||
task_hash = block.task_path|hash if block.task_path else 0 %} {%
|
||||
set base_color_hue = task_hash % 360 %} {% set active_color =
|
||||
'hsl(%d, 60%%, 45%%)'|format(base_color_hue) %} {% set
|
||||
offset_pct = idx * 8 %} {% set width_pct = 100 - (idx * 8) - 2
|
||||
%} {% set vertical_offset_px = idx * 4 %} {% set top_px =
|
||||
base_top_px + vertical_offset_px %}
|
||||
|
||||
{% for block in blocks %}
|
||||
{% if block.start.date() == day.date() %}
|
||||
{% set start_hour = block.start.hour + block.start.minute / 60.0 %}
|
||||
{% set duration_hours = block.duration / 3600.0 %}
|
||||
{% set top_px = start_hour * 60 %}
|
||||
{% set height_px = duration_hours * 60 %}
|
||||
{% if height_px < 2 %}{% set height_px = 2 %}{% endif %}
|
||||
|
||||
{% set task_hash = block.task_path|hash if block.task_path else 0 %}
|
||||
{% set base_color_hue = task_hash % 360 %}
|
||||
|
||||
{# Create gradient: active color on left (0% to active_ratio%), idle color on right #}
|
||||
{% set active_pct = (block.active_ratio * 100)|int %}
|
||||
{% set active_color = 'hsl(%d, 70%%, 50%%)'|format(base_color_hue) %}
|
||||
{% set idle_color = 'hsl(%d, 30%%, 70%%)'|format(base_color_hue) %}
|
||||
|
||||
<div class="task-block"
|
||||
style="top: {{ top_px }}px; height: {{ height_px }}px;
|
||||
background: linear-gradient(to right, {{ active_color }} 0%, {{ active_color }} {{ active_pct }}%, {{ idle_color }} {{ active_pct }}%, {{ idle_color }} 100%);">
|
||||
<div class="task-label">{{ block.task_path }}</div>
|
||||
<div class="task-time">{{ block.start.strftime('%H:%M') }} ({{ (block.duration // 60)|int }}m)</div>
|
||||
<div class="block-tooltip">
|
||||
<strong>{{ block.task_path }}</strong><br>
|
||||
{{ block.start.strftime('%H:%M') }} - {{ block.end.strftime('%H:%M') }}<br>
|
||||
Duration: {{ (block.duration // 60)|int }} minutes<br>
|
||||
Active: {{ (block.active_seconds // 60)|int }}m ({{ active_pct }}%)<br>
|
||||
Idle: {{ (block.idle_seconds // 60)|int }}m
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div
|
||||
class="task-block"
|
||||
style="top: {{ top_px }}px; height: {{ height_px }}px; left: {{ offset_pct }}%; width: {{ width_pct }}%; background: {{ active_color }}; opacity: 0.9; z-index: {{ idx + 1 }};"
|
||||
>
|
||||
<div class="task-label">{{ block.task_path }}</div>
|
||||
<div class="task-time">
|
||||
{{ (block.duration // 60)|int }}m
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %} {% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<meta http-equiv="refresh" content="5">
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,64 +1,116 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- <link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}"> -->
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
<!-- <link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}"> -->
|
||||
{% block head %} {% endblock %}
|
||||
|
||||
<style>
|
||||
body
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh; /* This ensures that the container takes the full height of the viewport */
|
||||
margin: 0; /* Remove default margin */
|
||||
}
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.grey {
|
||||
color:grey;
|
||||
}
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #444;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color:blue;
|
||||
}
|
||||
.nav-bar a {
|
||||
text-decoration: none;
|
||||
color: #6b9bd1;
|
||||
font-size: 12pt;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 84pt
|
||||
}
|
||||
td {
|
||||
padding-right: 100px;
|
||||
}
|
||||
</style>
|
||||
.nav-bar a:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
{% if auto_refresh %}
|
||||
<script>
|
||||
function refreshData() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/today', true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
document.getElementById('content-container').innerHTML = xhr.responseText;
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
.grey {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
// Auto-refresh every 5 seconds using AJAX
|
||||
setInterval(refreshData, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
.blue {
|
||||
color: #6b9bd1;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: clamp(14pt, 3vw, 28pt);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.1em 0.5em;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% if auto_refresh %}
|
||||
<script>
|
||||
function refreshData() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "/api/today", true);
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
document.getElementById("content-container").innerHTML =
|
||||
xhr.responseText;
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
// Auto-refresh every 5 seconds using AJAX
|
||||
setInterval(refreshData, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- agregar función que me diga cuanto tiempo hace que esta activo el escritorio
|
||||
(calcular el delta con el ultimo switch y pasarlo a mm:ss) -->
|
||||
|
||||
<div class="nav-bar">
|
||||
<a href="/">Today</a>
|
||||
<a href="/calendar/daily">Calendar</a>
|
||||
<a href="/switches/daily">Switches</a>
|
||||
<a href="/totals">All Time</a>
|
||||
</div>
|
||||
|
||||
<div id="content-container">
|
||||
{% block content %}
|
||||
{% include 'main_content.html' %}
|
||||
{% endblock %}
|
||||
{% block content %} {% include 'main_content.html' %} {% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +1,39 @@
|
||||
{% if current_task_path and current_task_time %}
|
||||
<div id="current-task-info" style="font-size: 48pt; margin-bottom: 40px; text-align: center;">
|
||||
<div style="color: #333;">{{ current_task_path }}</div>
|
||||
<div style="color: #666; font-size: 36pt;">{{ current_task_time }}</div>
|
||||
{% if current_task_path %}
|
||||
<div
|
||||
id="current-task-info"
|
||||
style="
|
||||
font-size: clamp(10pt, 2vw, 16pt);
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
"
|
||||
>
|
||||
Current: <span style="color: #6b9bd1">{{ current_task_path }}</span>
|
||||
</div>
|
||||
{% endif %} {% if task_rows %}
|
||||
<div id="task-list" style="margin-bottom: 25px">
|
||||
<table>
|
||||
<tbody>
|
||||
{% for row in task_rows %}
|
||||
<tr>
|
||||
<td>{{ row["ws"] }}</td>
|
||||
<td>{{ row["total"] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table>
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
|
||||
<table class="workspace-table">
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td class="{{my_class}}" >{{ row["ws"] }}</td>
|
||||
<td class="{{my_class}}" >{{ row["total"] }}</td>
|
||||
<td class="{{my_class}}">{{ row["ws"] }}</td>
|
||||
<td class="{{my_class}}">{{ row["total"] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,16 +1,81 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% block head %}
|
||||
<style>
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
margin: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #444;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-bar a {
|
||||
text-decoration: none;
|
||||
color: #6b9bd1;
|
||||
font-size: 12pt;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.nav-bar a:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 24pt;
|
||||
border-collapse: collapse;
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 40px 10px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.grey {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: #6b9bd1;
|
||||
}
|
||||
</style>
|
||||
{% endblock head %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="nav-bar">
|
||||
<a href="/">Today</a>
|
||||
<a href="/calendar/daily">Calendar</a>
|
||||
<a href="/switches/daily">Switches</a>
|
||||
<a href="/work">Work</a>
|
||||
<a href="/totals">All Time</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
{% for row in rows %}
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td>{{ row["ws"] }}</td>
|
||||
<td>{{ row["total"] }}</td>
|
||||
<td class="{{ my_class }}">{{ row["ws"] }}</td>
|
||||
<td class="{{ my_class }}">{{ row["total"] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
{% endblock content %}
|
||||
@@ -1,31 +1,48 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% extends 'layout.html' %} {% block head %}
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
font-family: monospace;
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.nav-bar a {
|
||||
text-decoration: none;
|
||||
color: #6b9bd1;
|
||||
font-size: 12pt;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.nav-bar a:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #333;
|
||||
border-bottom: 2px solid #444;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a {
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
color: #888;
|
||||
font-size: 16pt;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a.active {
|
||||
color: #000;
|
||||
color: #e0e0e0;
|
||||
font-weight: bold;
|
||||
border-bottom: 3px solid #000;
|
||||
border-bottom: 3px solid #6b9bd1;
|
||||
}
|
||||
|
||||
.date-nav {
|
||||
@@ -38,18 +55,19 @@
|
||||
|
||||
.date-nav a {
|
||||
text-decoration: none;
|
||||
color: #2563eb;
|
||||
color: #6b9bd1;
|
||||
font-size: 18pt;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.switches-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
@@ -62,35 +80,41 @@
|
||||
|
||||
.switch-time {
|
||||
width: 120px;
|
||||
color: #666;
|
||||
color: #aaa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.switch-workspace {
|
||||
width: 80px;
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.switch-task {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.switch-duration {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* Active vs idle workspace indicator */
|
||||
.ws-active { font-weight: bold; }
|
||||
.ws-idle { opacity: 0.6; }
|
||||
.ws-active {
|
||||
font-weight: bold;
|
||||
}
|
||||
.ws-idle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #f0f0f0;
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
@@ -105,39 +129,69 @@
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
{% endblock head %}
|
||||
{% endblock head %} {% block content %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
class="nav-bar"
|
||||
style="
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #444;
|
||||
"
|
||||
>
|
||||
<a href="/">Today</a>
|
||||
<a href="/calendar/daily{% if tz_name %}?tz={{ tz_name }}{% endif %}"
|
||||
>Calendar</a
|
||||
>
|
||||
<a href="/switches/daily">Switches</a>
|
||||
<a href="/totals">All Time</a>
|
||||
<select id="tz-selector" style="margin-left: auto; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; padding: 5px 10px; border-radius: 3px; font-family: monospace; font-size: 12pt;" onchange="window.location.href = window.location.pathname + '?tz=' + this.value">
|
||||
{% for tz_value, tz_label in timezones %}<option value="{{ tz_value }}" {% if tz_name == tz_value or (not tz_name and loop.first) %}selected{% endif %}>{{ tz_label }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="nav-tabs">
|
||||
<a href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'daily' %}active{% endif %}">Daily</a>
|
||||
<a href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a>
|
||||
<a href="/switches/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a>
|
||||
<span style="margin-left: auto;">
|
||||
<a href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Calendar</a>
|
||||
</span>
|
||||
<a
|
||||
href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
|
||||
class="{% if scope == 'daily' %}active{% endif %}"
|
||||
>Daily</a
|
||||
>
|
||||
<a
|
||||
href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
|
||||
class="{% if scope == 'weekly' %}active{% endif %}"
|
||||
>Weekly</a
|
||||
>
|
||||
<a
|
||||
href="/switches/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
|
||||
class="{% if scope == 'monthly' %}active{% endif %}"
|
||||
>Monthly</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="date-nav">
|
||||
<a href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}">←</a>
|
||||
<a
|
||||
href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
|
||||
>←</a
|
||||
>
|
||||
{% if scope == 'daily' %}
|
||||
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
|
||||
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
|
||||
{% elif scope == 'weekly' %}
|
||||
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
|
||||
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
|
||||
{% elif scope == 'monthly' %}
|
||||
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
|
||||
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
|
||||
{% endif %}
|
||||
<a href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}">→</a>
|
||||
<a
|
||||
href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}{% if tz_name %}?tz={{ tz_name }}{% endif %}"
|
||||
>→</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
@@ -148,7 +202,10 @@
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Time:</span>
|
||||
<span class="stat-value">{{ (switches|sum(attribute='delta') // 3600)|int }}h {{ ((switches|sum(attribute='delta') % 3600) // 60)|int }}m</span>
|
||||
<span class="stat-value"
|
||||
>{{ (switches|sum(attribute='delta') // 3600)|int }}h {{
|
||||
((switches|sum(attribute='delta') % 3600) // 60)|int }}m</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,30 +213,43 @@
|
||||
<h3>All Switches ({{ switches|length }})</h3>
|
||||
|
||||
<div class="switches-container">
|
||||
{% for switch in switches %}
|
||||
{% set is_active = switch.workspace in ['Plan', 'Think', 'Work'] %}
|
||||
{% set task_hash = switch.task_path|hash if switch.task_path else 0 %}
|
||||
{% set border_hue = task_hash % 360 %}
|
||||
{% set border_color = 'hsl(%d, 70%%, 50%%)'|format(border_hue) %}
|
||||
{% set bg_color = 'hsl(%d, 70%%, 95%%)'|format(border_hue) %}
|
||||
<div class="switch-row {{ 'ws-active' if is_active else 'ws-idle' }}"
|
||||
style="border-left-color: {{ border_color }}; background-color: {{ bg_color }};">
|
||||
<div class="switch-time">{{ switch.date.strftime('%m/%d %H:%M:%S') }}</div>
|
||||
{% set max_delta = switches|map(attribute='delta')|max if switches else 1 %}
|
||||
{% set base_height = 30 %} {% set max_height = 200 %} {% for switch in
|
||||
switches %} {% set is_active = switch.workspace in ['Plan', 'Think', 'Work']
|
||||
%} {% set task_hash = switch.task_path|hash if switch.task_path else 0 %} {%
|
||||
set border_hue = task_hash % 360 %} {% set border_color = 'hsl(%d, 70%%,
|
||||
50%%)'|format(border_hue) %} {% set bg_color = 'hsl(%d, 30%%,
|
||||
20%%)'|format(border_hue) %} {% set height_ratio = (switch.delta /
|
||||
max_delta) if max_delta > 0 else 0 %} {% set cell_height = (base_height +
|
||||
(height_ratio * (max_height - base_height)))|int %}
|
||||
<div
|
||||
class="switch-row {{ 'ws-active' if is_active else 'ws-idle' }}"
|
||||
style="border-left-color: {{ border_color }}; background-color: {{ bg_color }}; height: {{ cell_height }}px;"
|
||||
>
|
||||
<div class="switch-time">
|
||||
{{ switch.date.strftime('%m/%d %H:%M:%S') }}
|
||||
</div>
|
||||
<div class="switch-workspace">{{ switch.workspace }}</div>
|
||||
<div class="switch-task">{{ switch.task_path }}</div>
|
||||
<div class="switch-duration">
|
||||
{% if switch.delta >= 3600 %}
|
||||
{{ (switch.delta // 3600)|int }}h {{ ((switch.delta % 3600) // 60)|int }}m
|
||||
{% else %}
|
||||
{{ (switch.delta // 60)|int }}m {{ (switch.delta % 60)|int }}s
|
||||
{% endif %}
|
||||
{% if switch.delta >= 3600 %} {{ (switch.delta // 3600)|int }}h {{
|
||||
((switch.delta % 3600) // 60)|int }}m {% else %} {{ (switch.delta //
|
||||
60)|int }}m {{ (switch.delta % 60)|int }}s {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not switches %}
|
||||
<p style="text-align: center; color: #666; margin-top: 40px;">No switches in this period</p>
|
||||
<p style="text-align: center; color: #666; margin-top: 40px">
|
||||
No switches in this period
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
h3 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock content %}
|
||||
|
||||
20
dmold/dmweb/__init__.py
Normal file
20
dmold/dmweb/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from flask import Flask
|
||||
|
||||
from . import dm, dmcal
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask("deskmeter")
|
||||
|
||||
app.debug = True
|
||||
app.register_blueprint(dm.dmbp)
|
||||
|
||||
# Register custom Jinja2 filters
|
||||
@app.template_filter('hash')
|
||||
def hash_filter(s):
|
||||
"""Return hash of string for consistent color generation"""
|
||||
if s is None:
|
||||
return 0
|
||||
return hash(str(s))
|
||||
|
||||
return app
|
||||
BIN
dmold/dmweb/__init__.pyc
Normal file
BIN
dmold/dmweb/__init__.pyc
Normal file
Binary file not shown.
283
dmold/dmweb/dm.py
Normal file
283
dmold/dmweb/dm.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, render_template, jsonify
|
||||
|
||||
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
|
||||
|
||||
dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates")
|
||||
|
||||
|
||||
@dmbp.route("/favicon.ico")
|
||||
def favicon():
|
||||
return "", 204 # No Content
|
||||
|
||||
|
||||
@dmbp.route("/calendar")
|
||||
@dmbp.route("/calendar/<string:scope>")
|
||||
@dmbp.route("/calendar/<string:scope>/<int:year>/<int:month>/<int:day>")
|
||||
def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
"""
|
||||
Google Calendar-style view showing task blocks at their actual times.
|
||||
"""
|
||||
task = None
|
||||
|
||||
if not year:
|
||||
year = datetime.today().year
|
||||
if not month:
|
||||
month = datetime.today().month
|
||||
if not day:
|
||||
day = datetime.today().day
|
||||
|
||||
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)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
days = [base_date]
|
||||
|
||||
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)
|
||||
prev_date = start - timedelta(days=7)
|
||||
next_date = start + timedelta(days=7)
|
||||
days = [start + timedelta(days=i) for i in range(7)]
|
||||
|
||||
elif scope == "monthly":
|
||||
start = base_date.replace(day=1)
|
||||
if month == 12:
|
||||
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)
|
||||
if month == 1:
|
||||
prev_date = datetime(year - 1, 12, 1, tzinfo=timezone)
|
||||
else:
|
||||
prev_date = datetime(year, month - 1, 1, tzinfo=timezone)
|
||||
if month == 12:
|
||||
next_date = datetime(year + 1, 1, 1, tzinfo=timezone)
|
||||
else:
|
||||
next_date = datetime(year, month + 1, 1, tzinfo=timezone)
|
||||
days = []
|
||||
current = start
|
||||
while current <= end:
|
||||
days.append(current)
|
||||
current += timedelta(days=1)
|
||||
else:
|
||||
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)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
days = [base_date]
|
||||
|
||||
return render_template(
|
||||
"calendar_view.html",
|
||||
scope=scope,
|
||||
blocks=blocks,
|
||||
start=start,
|
||||
end=end,
|
||||
base_date=base_date,
|
||||
prev_date=prev_date,
|
||||
next_date=next_date,
|
||||
days=days,
|
||||
auto_refresh=False
|
||||
)
|
||||
|
||||
|
||||
@dmbp.route("/switches")
|
||||
@dmbp.route("/switches/<string:scope>")
|
||||
@dmbp.route("/switches/<string:scope>/<int:year>/<int:month>/<int:day>")
|
||||
def switches_view(scope="daily", year=None, month=None, day=None):
|
||||
"""
|
||||
Raw switches view showing all switch documents.
|
||||
"""
|
||||
task = None
|
||||
|
||||
if not year:
|
||||
year = datetime.today().year
|
||||
if not month:
|
||||
month = datetime.today().month
|
||||
if not day:
|
||||
day = datetime.today().day
|
||||
|
||||
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)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
|
||||
elif scope == "weekly":
|
||||
start = base_date - timedelta(days=base_date.weekday())
|
||||
end = start + timedelta(days=6, hours=23, minutes=59, seconds=59)
|
||||
prev_date = start - timedelta(days=7)
|
||||
next_date = start + timedelta(days=7)
|
||||
|
||||
elif scope == "monthly":
|
||||
start = base_date.replace(day=1)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1)
|
||||
else:
|
||||
end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1)
|
||||
if month == 1:
|
||||
prev_date = datetime(year - 1, 12, 1, tzinfo=timezone)
|
||||
else:
|
||||
prev_date = datetime(year, month - 1, 1, tzinfo=timezone)
|
||||
if month == 12:
|
||||
next_date = datetime(year + 1, 1, 1, tzinfo=timezone)
|
||||
else:
|
||||
next_date = datetime(year, month + 1, 1, tzinfo=timezone)
|
||||
else:
|
||||
scope = "daily"
|
||||
start = base_date
|
||||
end = base_date.replace(hour=23, minute=59, second=59)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
|
||||
raw_switches = get_raw_switches(start, end, task)
|
||||
|
||||
return render_template(
|
||||
"switches_view.html",
|
||||
scope=scope,
|
||||
switches=raw_switches,
|
||||
start=start,
|
||||
end=end,
|
||||
base_date=base_date,
|
||||
prev_date=prev_date,
|
||||
next_date=next_date,
|
||||
auto_refresh=False
|
||||
)
|
||||
|
||||
|
||||
@dmbp.route("/")
|
||||
@dmbp.route("/<string:task>")
|
||||
def index(task=None):
|
||||
"""
|
||||
Show total time used in each desktop for today
|
||||
"""
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
|
||||
rows = get_period_totals(start, end, task)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@dmbp.route("/api/current_task")
|
||||
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"
|
||||
})
|
||||
|
||||
|
||||
@dmbp.route("/api/today")
|
||||
@dmbp.route("/api/today/<string:task>")
|
||||
def api_today(task=None):
|
||||
"""
|
||||
HTML fragment API endpoint for today's data (for AJAX updates)
|
||||
"""
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
|
||||
rows = get_period_totals(start, end, task)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@dmbp.route("/day/<int:month>/<int:day>")
|
||||
@dmbp.route("/day/<string:task>/<int:month>/<int:day>")
|
||||
def oneday(
|
||||
month,
|
||||
day,
|
||||
task=None,
|
||||
):
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime(2025, month, day).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
)
|
||||
|
||||
end = datetime(2025, month, day).replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone
|
||||
)
|
||||
|
||||
rows = get_period_totals(start, end)
|
||||
|
||||
return render_template("pages.html", rows=rows, auto_refresh=True)
|
||||
|
||||
|
||||
@dmbp.route("/period/<start>/<end>")
|
||||
def period(start, end):
|
||||
start = datetime(*map(int, start.split("-"))).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
)
|
||||
|
||||
end = datetime(*map(int, end.split("-"))).replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone
|
||||
)
|
||||
|
||||
rows = get_period_totals(start, end)
|
||||
|
||||
return render_template("pages.html", rows=rows, auto_refresh=True)
|
||||
|
||||
|
||||
@dmbp.route("/work")
|
||||
def work():
|
||||
"""
|
||||
Show total time used per work project for today
|
||||
"""
|
||||
start = datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
end = datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
|
||||
rows = get_work_period_totals(start, end)
|
||||
|
||||
return render_template("main.html", rows=rows, auto_refresh=False)
|
||||
|
||||
|
||||
@dmbp.route("/totals")
|
||||
@dmbp.route("/totals/<string:task>")
|
||||
def totals(task=None):
|
||||
"""
|
||||
Show total time used in each desktop for all time
|
||||
"""
|
||||
|
||||
task = task_or_none(task)
|
||||
|
||||
start = datetime(2020, 1, 1).replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
|
||||
end = datetime(2030, 1, 1).replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
|
||||
rows = get_period_totals(start, end)
|
||||
|
||||
return render_template("pages.html", rows=rows, auto_refresh=True)
|
||||
177
dmold/dmweb/dmcal.py
Normal file
177
dmold/dmweb/dmcal.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import calendar
|
||||
from datetime import datetime
|
||||
from pprint import pprint
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
# import pytz
|
||||
from .dm import dmbp
|
||||
from .get_period_times import (
|
||||
get_period_totals,
|
||||
read_and_extract,
|
||||
task_file,
|
||||
task_or_none,
|
||||
timezone,
|
||||
get_work_period_totals,
|
||||
)
|
||||
|
||||
|
||||
|
||||
class DMHTMLCalendar(calendar.HTMLCalendar):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.use_work_projects = False
|
||||
self.task = None
|
||||
|
||||
def setcalmonth(self, month):
|
||||
self.dmmonth = month
|
||||
|
||||
def setcalyear(self, year):
|
||||
self.dmyear = year
|
||||
|
||||
def settask(self, task):
|
||||
self.task = task
|
||||
|
||||
def set_work_projects_mode(self, enabled=True):
|
||||
self.use_work_projects = enabled
|
||||
|
||||
def oneday(self, month, day):
|
||||
current_year = datetime.today().year
|
||||
start = datetime(self.dmyear, month, day).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
)
|
||||
end = datetime(self.dmyear, month, day).replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone
|
||||
)
|
||||
|
||||
if self.use_work_projects:
|
||||
rows = get_work_period_totals(start, end)
|
||||
else:
|
||||
rows = get_period_totals(start, end, self.task)
|
||||
|
||||
returnstr = "<table class='totaltable'>"
|
||||
for row in rows:
|
||||
returnstr += "<tr><td>{}</td><td>{}</td></tr>".format(
|
||||
row["ws"], row["total"]
|
||||
)
|
||||
|
||||
returnstr += "</table>"
|
||||
return returnstr
|
||||
|
||||
def oneweek(self, month, week):
|
||||
start_day = None
|
||||
end_day = None
|
||||
|
||||
for d, wd in week:
|
||||
if d == 0:
|
||||
continue
|
||||
else:
|
||||
start_day = d
|
||||
break
|
||||
|
||||
for d, wd in reversed(week):
|
||||
if d == 0:
|
||||
continue
|
||||
else:
|
||||
end_day = d
|
||||
break
|
||||
|
||||
start = datetime(self.dmyear, month, start_day).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
)
|
||||
|
||||
end = datetime(self.dmyear, month, end_day).replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone
|
||||
)
|
||||
|
||||
if self.use_work_projects:
|
||||
rows = get_work_period_totals(start, end)
|
||||
else:
|
||||
rows = get_period_totals(start, end, self.task)
|
||||
|
||||
print(rows)
|
||||
|
||||
returnstr = "<table class='totaltable'>"
|
||||
for row in rows:
|
||||
returnstr += "<tr><td>{}</td><td>{}</td></tr>".format(
|
||||
row["ws"], row["total"]
|
||||
)
|
||||
|
||||
returnstr += "</table>"
|
||||
return returnstr
|
||||
|
||||
def formatweekheader(self):
|
||||
"""
|
||||
Return a header for a week as a table row.
|
||||
"""
|
||||
s = "".join(self.formatweekday(i) for i in self.iterweekdays())
|
||||
s += "<td>Week Totals</td>"
|
||||
return "<tr>%s</tr>" % s
|
||||
|
||||
def formatweek(self, theweek):
|
||||
"""
|
||||
Return a complete week as a table row.
|
||||
"""
|
||||
s = "".join(self.formatday(d, wd) for (d, wd) in theweek)
|
||||
s += "<td>{}</td>".format(self.oneweek(self.dmmonth, theweek))
|
||||
return "<tr>%s</tr>" % s
|
||||
|
||||
def formatday(self, day, weekday):
|
||||
"""
|
||||
Return a day as a table cell.
|
||||
"""
|
||||
if day == 0:
|
||||
return '<td class="noday"> </td>' # day outside month
|
||||
else:
|
||||
return '<td class="%s">%s</td>' % (
|
||||
self.cssclasses[weekday],
|
||||
self.oneday(self.dmmonth, day),
|
||||
)
|
||||
|
||||
|
||||
@dmbp.route("/workmonth")
|
||||
@dmbp.route("/workmonth/<int:month>")
|
||||
@dmbp.route("/workmonth/<int:month>/<int:year>")
|
||||
def workmonth(month=None, year=None):
|
||||
usemonth = datetime.today().month
|
||||
useyear = datetime.today().year
|
||||
|
||||
if month:
|
||||
usemonth = month
|
||||
|
||||
if year:
|
||||
useyear = year
|
||||
|
||||
cal = DMHTMLCalendar(calendar.SATURDAY)
|
||||
|
||||
cal.set_work_projects_mode(True)
|
||||
cal.setcalmonth(usemonth)
|
||||
cal.setcalyear(useyear)
|
||||
|
||||
return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=False)
|
||||
|
||||
|
||||
@dmbp.route("/month")
|
||||
@dmbp.route("/month/<int:month>")
|
||||
@dmbp.route("/month/<int:month>/<int:year>")
|
||||
@dmbp.route("/month/<string:task>")
|
||||
@dmbp.route("/month/<string:task>/<int:month>")
|
||||
@dmbp.route("/month/<string:task>/<int:month>/<int:year>")
|
||||
def month(month=None, year=None, task=None):
|
||||
usemonth = datetime.today().month
|
||||
useyear = datetime.today().year
|
||||
|
||||
if month:
|
||||
usemonth = month
|
||||
|
||||
if year:
|
||||
useyear = year
|
||||
|
||||
cal = DMHTMLCalendar(calendar.SATURDAY)
|
||||
|
||||
cal.settask(task_or_none(task))
|
||||
|
||||
cal.setcalmonth(usemonth)
|
||||
cal.setcalyear(useyear)
|
||||
|
||||
return render_template("calendar.html", content=cal.formatmonth(useyear, usemonth), auto_refresh=True)
|
||||
550
dmold/dmweb/get_period_times.py
Normal file
550
dmold/dmweb/get_period_times.py
Normal file
@@ -0,0 +1,550 @@
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from pymongo import MongoClient
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
timezone = ZoneInfo("America/Argentina/Buenos_Aires")
|
||||
utctz = ZoneInfo("UTC")
|
||||
|
||||
client = MongoClient()
|
||||
db = client.deskmeter
|
||||
switches = db.switch
|
||||
tasks = db.task
|
||||
task_history = db.task_history
|
||||
|
||||
|
||||
task_file = "/home/mariano/LETRAS/adm/task/main"
|
||||
task_dir = Path(task_file).parent
|
||||
|
||||
|
||||
def parse_task_line(line):
|
||||
"""Parse a task line to extract task name and ID."""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None, None
|
||||
|
||||
parts = line.split("|")
|
||||
if len(parts) > 1:
|
||||
task_name = parts[0].strip()
|
||||
id_parts = parts[1].split()
|
||||
if id_parts:
|
||||
task_id = id_parts[0].strip()
|
||||
return task_name, task_id
|
||||
return task_name, None
|
||||
|
||||
return parts[0].strip(), None
|
||||
|
||||
|
||||
def load_task_from_files(task_id):
|
||||
"""Search task directory files for a task ID and load it into task_history."""
|
||||
for task_filepath in task_dir.glob("*"):
|
||||
if not task_filepath.is_file():
|
||||
continue
|
||||
|
||||
current_path = []
|
||||
try:
|
||||
with open(task_filepath, "r") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
level = indent // 4
|
||||
|
||||
task_name, found_id = parse_task_line(line)
|
||||
if task_name is None:
|
||||
continue
|
||||
|
||||
current_path = current_path[:level]
|
||||
current_path.append(task_name)
|
||||
full_path = "/".join(current_path)
|
||||
|
||||
if found_id == task_id:
|
||||
# Found it! Insert into task_history
|
||||
task_history.update_one(
|
||||
{"task_id": task_id},
|
||||
{"$set": {
|
||||
"path": full_path,
|
||||
"task_id": task_id,
|
||||
"source_file": task_filepath.name
|
||||
}},
|
||||
upsert=True
|
||||
)
|
||||
return full_path
|
||||
except:
|
||||
# Skip files that can't be read
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_task_path(task_id):
|
||||
"""
|
||||
Get task path from tasks collection, falling back to task_history.
|
||||
If not found, searches task directory files and populates task_history on-demand.
|
||||
"""
|
||||
if not task_id:
|
||||
return None
|
||||
|
||||
# Try current tasks first
|
||||
task_doc = tasks.find_one({"task_id": task_id})
|
||||
if task_doc and "path" in task_doc:
|
||||
return task_doc["path"]
|
||||
|
||||
# Try task history cache
|
||||
task_doc = task_history.find_one({"task_id": task_id})
|
||||
if task_doc and "path" in task_doc:
|
||||
return task_doc["path"]
|
||||
|
||||
# Not in cache, search files and populate history
|
||||
task_path = load_task_from_files(task_id)
|
||||
if task_path:
|
||||
return task_path
|
||||
|
||||
# Still not found, return ID as fallback
|
||||
return task_id
|
||||
|
||||
|
||||
def get_current_task_info():
|
||||
"""Get current task ID and path from state and tasks collection"""
|
||||
states = db.state
|
||||
current_doc = states.find_one({"_id": "current"})
|
||||
|
||||
if not current_doc or "task" not in current_doc:
|
||||
return None, None
|
||||
|
||||
task_id = current_doc["task"]
|
||||
task_doc = tasks.find_one({"task_id": task_id})
|
||||
|
||||
if task_doc and "path" in task_doc:
|
||||
return task_id, task_doc["path"]
|
||||
|
||||
return task_id, None
|
||||
|
||||
|
||||
def get_task_time_seconds(start, end, task_id, workspaces=None):
|
||||
"""Get total seconds for a task within a time period using MongoDB aggregation."""
|
||||
if workspaces is None:
|
||||
workspaces = ["Plan", "Think", "Work"]
|
||||
|
||||
pipeline = [
|
||||
{
|
||||
"$match": {
|
||||
"date": {"$gte": start, "$lte": end},
|
||||
"task": task_id,
|
||||
"workspace": {"$in": workspaces}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$group": {
|
||||
"_id": None,
|
||||
"total_seconds": {"$sum": "$delta"}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
result = list(switches.aggregate(pipeline))
|
||||
|
||||
if result and len(result) > 0:
|
||||
return result[0]["total_seconds"]
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def task_or_none(task=None):
|
||||
if not task:
|
||||
task = read_and_extract(task_file)
|
||||
|
||||
if task == "all":
|
||||
task = None
|
||||
|
||||
return task
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone)
|
||||
|
||||
|
||||
def convert_seconds(seconds, use_days=False):
|
||||
days = seconds // 86400
|
||||
hours = (seconds % 86400) // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
remaining_seconds = seconds % 60
|
||||
|
||||
if use_days:
|
||||
return "{} days, {:02d}:{:02d}:{:02d}".format(
|
||||
days, hours, minutes, remaining_seconds
|
||||
)
|
||||
return "{:02d}:{:02d}:{:02d}".format(hours + days * 24, minutes, remaining_seconds)
|
||||
|
||||
|
||||
def extract(line):
|
||||
if line.rstrip().endswith("*"):
|
||||
pipe_index = line.find("|")
|
||||
if pipe_index != -1 and len(line) > pipe_index + 8:
|
||||
value = line[pipe_index + 1 : pipe_index + 9]
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def read_and_extract(file_path):
|
||||
with open(file_path, "r") as file:
|
||||
for line in file:
|
||||
value = extract(line)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def get_work_period_totals(start, end):
|
||||
"""Get period totals grouped by task with full path."""
|
||||
# Get all tasks with time in the period
|
||||
pipeline = [
|
||||
{
|
||||
"$match": {
|
||||
"date": {"$gte": start, "$lte": end},
|
||||
"workspace": {"$in": ["Plan", "Think", "Work"]},
|
||||
"task": {"$exists": True, "$ne": None}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$group": {
|
||||
"_id": "$task",
|
||||
"total_seconds": {"$sum": "$delta"}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
results = list(switches.aggregate(pipeline))
|
||||
combined_rows = []
|
||||
|
||||
for result in results:
|
||||
task_id = result["_id"]
|
||||
total_seconds = result["total_seconds"]
|
||||
|
||||
if total_seconds > 0:
|
||||
# Get task path with history fallback
|
||||
task_path = get_task_path(task_id)
|
||||
|
||||
combined_rows.append({
|
||||
"ws": task_path,
|
||||
"total": convert_seconds(total_seconds)
|
||||
})
|
||||
|
||||
# Sort by path for consistency
|
||||
combined_rows.sort(key=lambda x: x["ws"])
|
||||
return combined_rows
|
||||
|
||||
|
||||
def get_task_blocks_calendar(start, end, task=None, min_block_seconds=300):
|
||||
"""
|
||||
Get task blocks for calendar-style visualization.
|
||||
Groups consecutive switches to the same task into blocks, tracking active/idle time.
|
||||
|
||||
Returns list of blocks:
|
||||
[{
|
||||
'task_id': str,
|
||||
'task_path': str,
|
||||
'start': datetime,
|
||||
'end': datetime,
|
||||
'duration': int (total seconds),
|
||||
'active_seconds': int (Plan/Think/Work time),
|
||||
'idle_seconds': int (Other/Away time),
|
||||
'active_ratio': float (0.0 to 1.0)
|
||||
}, ...]
|
||||
"""
|
||||
task_query = {"$in": task.split(",")} if task else {}
|
||||
|
||||
match_query = {"date": {"$gte": start, "$lte": end}}
|
||||
if task_query:
|
||||
match_query["task"] = task_query
|
||||
|
||||
# Get all switches in period, sorted by date
|
||||
raw_switches = list(switches.find(match_query).sort("date", 1))
|
||||
|
||||
if not raw_switches:
|
||||
return []
|
||||
|
||||
blocks = []
|
||||
current_block = None
|
||||
|
||||
for switch in raw_switches:
|
||||
ws = switch["workspace"]
|
||||
task_id = switch.get("task")
|
||||
switch_start = switch["date"].replace(tzinfo=utctz).astimezone(timezone)
|
||||
switch_duration = switch["delta"]
|
||||
switch_end = switch_start + timedelta(seconds=switch_duration)
|
||||
|
||||
is_active = ws in ["Plan", "Think", "Work"]
|
||||
|
||||
# Start new block if task changed
|
||||
if current_block is None or current_block["task_id"] != task_id:
|
||||
if current_block is not None:
|
||||
blocks.append(current_block)
|
||||
|
||||
# Get task path with history fallback
|
||||
task_path = get_task_path(task_id) or "No Task"
|
||||
|
||||
current_block = {
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"start": switch_start,
|
||||
"end": switch_end,
|
||||
"duration": switch_duration,
|
||||
"active_seconds": switch_duration if is_active else 0,
|
||||
"idle_seconds": 0 if is_active else switch_duration
|
||||
}
|
||||
else:
|
||||
# Extend current block
|
||||
current_block["end"] = switch_end
|
||||
current_block["duration"] += switch_duration
|
||||
if is_active:
|
||||
current_block["active_seconds"] += switch_duration
|
||||
else:
|
||||
current_block["idle_seconds"] += switch_duration
|
||||
|
||||
# Add final block
|
||||
if current_block is not None:
|
||||
blocks.append(current_block)
|
||||
|
||||
# Filter out very short blocks and calculate active ratio
|
||||
filtered_blocks = []
|
||||
for block in blocks:
|
||||
if block["duration"] >= min_block_seconds:
|
||||
block["active_ratio"] = block["active_seconds"] / block["duration"] if block["duration"] > 0 else 0
|
||||
filtered_blocks.append(block)
|
||||
|
||||
return filtered_blocks
|
||||
|
||||
|
||||
def get_raw_switches(start, end, task=None):
|
||||
"""
|
||||
Get all raw switch documents in the period.
|
||||
|
||||
Returns list of switches:
|
||||
[{
|
||||
'workspace': str,
|
||||
'task_id': str,
|
||||
'task_path': str,
|
||||
'date': datetime,
|
||||
'delta': int (seconds)
|
||||
}, ...]
|
||||
"""
|
||||
task_query = {"$in": task.split(",")} if task else {}
|
||||
|
||||
match_query = {"date": {"$gte": start, "$lte": end}}
|
||||
if task_query:
|
||||
match_query["task"] = task_query
|
||||
|
||||
raw_switches = list(switches.find(match_query).sort("date", 1))
|
||||
|
||||
result = []
|
||||
for switch in raw_switches:
|
||||
task_id = switch.get("task")
|
||||
# Get task path with history fallback
|
||||
task_path = get_task_path(task_id) or "No Task"
|
||||
|
||||
result.append({
|
||||
"workspace": switch["workspace"],
|
||||
"task_id": task_id,
|
||||
"task_path": task_path,
|
||||
"date": switch["date"].replace(tzinfo=utctz).astimezone(timezone),
|
||||
"delta": switch["delta"]
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_period_totals(start, end, task=None):
|
||||
task_query = {"$in": task.split(",")} if task else {}
|
||||
|
||||
match_query = {"date": {"$gte": start, "$lte": end}}
|
||||
if task_query:
|
||||
match_query["task"] = task_query
|
||||
|
||||
pipeline = [
|
||||
{"$match": match_query},
|
||||
{"$sort": {"date": 1}},
|
||||
{
|
||||
"$group": {
|
||||
"_id": None,
|
||||
"documents_in_range": {"$push": "$$ROOT"},
|
||||
"first_doc": {"$first": "$$ROOT"},
|
||||
"last_doc": {"$last": "$$ROOT"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "switch",
|
||||
"let": {"first_date": "$first_doc.date", "task": "$first_doc.task"},
|
||||
"pipeline": [
|
||||
{
|
||||
"$match": {
|
||||
"$expr": {
|
||||
"$and": [
|
||||
{
|
||||
"$lt": ["$date", "$$first_date"]
|
||||
}, # Only before the first date
|
||||
{
|
||||
"$eq": ["$task", "$$task"]
|
||||
}, # Must have the same task
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{"$sort": {"date": -1}}, # Get the most recent (closest) document
|
||||
{"$limit": 1}, # Only the immediate previous document
|
||||
],
|
||||
"as": "before_first",
|
||||
}
|
||||
},
|
||||
{
|
||||
"$project": {
|
||||
"documents": {
|
||||
"$concatArrays": [
|
||||
{"$ifNull": ["$before_first", []]}, # Add only if found
|
||||
"$documents_in_range",
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{"$unwind": "$documents"},
|
||||
{"$replaceRoot": {"newRoot": "$documents"}},
|
||||
{
|
||||
"$group": {
|
||||
"_id": "$workspace",
|
||||
"total": {"$sum": "$delta"},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
results = list(switches.aggregate(pipeline))
|
||||
|
||||
if not results:
|
||||
return [{"ws": "No Data", "total": ""}]
|
||||
|
||||
pipeline_before_after = [
|
||||
# Match documents within the date range
|
||||
{"$match": match_query},
|
||||
{"$sort": {"date": 1}},
|
||||
{
|
||||
"$group": {
|
||||
"_id": None,
|
||||
"first_doc": {"$first": "$$ROOT"},
|
||||
"last_doc": {"$last": "$$ROOT"},
|
||||
}
|
||||
},
|
||||
# Lookup to get one document before the first document in the range
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "switch",
|
||||
"let": {"first_date": "$first_doc.date", "task": "$first_doc.task"},
|
||||
"pipeline": [
|
||||
{
|
||||
"$match": {
|
||||
"$expr": {
|
||||
"$and": [
|
||||
{
|
||||
"$lt": ["$date", "$$first_date"]
|
||||
}, # Only before the first date
|
||||
{
|
||||
"$eq": ["$task", "$$task"]
|
||||
}, # Must have the same task
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{"$sort": {"date": -1}}, # Get the most recent (closest) document
|
||||
{"$limit": 1}, # Only the immediate previous document
|
||||
],
|
||||
"as": "before_first",
|
||||
}
|
||||
},
|
||||
{
|
||||
"$project": {
|
||||
"before_first": {
|
||||
"$ifNull": [{"$arrayElemAt": ["$before_first", 0]}, ""]
|
||||
},
|
||||
"last_doc": "$last_doc", # Include the last_doc from the matched period
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
aux_results = list(switches.aggregate(pipeline_before_after))
|
||||
|
||||
# Safety check: if aux_results is empty, return early with no data
|
||||
if not aux_results:
|
||||
return [{"ws": "No Data", "total": ""}]
|
||||
|
||||
bfirst = aux_results[0]["before_first"]
|
||||
start_delta = 0
|
||||
|
||||
if bfirst:
|
||||
bfdate = bfirst["date"].replace(tzinfo=utctz)
|
||||
time_since_bfirst = round((start - bfdate.astimezone(timezone)).total_seconds())
|
||||
|
||||
# Only apply start_delta if the before_first switch actually crosses into the period
|
||||
# If time_since_bfirst > bfirst["delta"], the switch ended before the period started
|
||||
if time_since_bfirst <= bfirst["delta"]:
|
||||
start_delta = time_since_bfirst
|
||||
|
||||
ldoc = aux_results[0]["last_doc"]
|
||||
lastdate = ldoc["date"].replace(tzinfo=utctz)
|
||||
end_delta = round((end - lastdate.astimezone(timezone)).total_seconds())
|
||||
|
||||
rows = []
|
||||
active_vs_idle = {"Active": 0, "Idle": 0}
|
||||
|
||||
for result in results:
|
||||
if bfirst:
|
||||
if result["_id"] == bfirst["workspace"]:
|
||||
# Safety: ensure start_delta doesn't exceed total
|
||||
adjustment = min(start_delta, result["total"])
|
||||
result["total"] -= adjustment
|
||||
|
||||
if end < now():
|
||||
if result["_id"] == ldoc["workspace"]:
|
||||
# Safety: ensure we don't subtract more than the total
|
||||
adjustment = ldoc["delta"] - end_delta
|
||||
safe_adjustment = min(adjustment, result["total"])
|
||||
result["total"] -= safe_adjustment
|
||||
|
||||
for result in results:
|
||||
if result["total"] > 0:
|
||||
rows.append(
|
||||
{"ws": result["_id"], "total": convert_seconds(result["total"])}
|
||||
)
|
||||
if result["_id"] in ["Think", "Plan", "Work"]:
|
||||
active_vs_idle["Active"] += result["total"]
|
||||
if result["_id"] in ["Away", "Other"]:
|
||||
active_vs_idle["Idle"] += result["total"]
|
||||
|
||||
order = ["Plan", "Think", "Work", "Other", "Away", "Active", "Idle"]
|
||||
|
||||
rows = sorted(rows, key=lambda x: order.index(x["ws"]))
|
||||
|
||||
for k, v in active_vs_idle.items():
|
||||
rows.append({"ws": k, "total": convert_seconds(v)})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# print(
|
||||
# get_period_totals(
|
||||
# datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
# - timedelta(days=1),
|
||||
# datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone)
|
||||
# - timedelta(days=1),
|
||||
# # "ffbe198e",
|
||||
# )
|
||||
# )
|
||||
|
||||
# print(
|
||||
# get_period_totals(
|
||||
# datetime.today().replace(hour=0, minute=0, second=0, tzinfo=timezone),
|
||||
# datetime.today().replace(hour=23, minute=59, second=59, tzinfo=timezone),
|
||||
# "5fc751ec",
|
||||
# )
|
||||
# )
|
||||
4
dmold/dmweb/run.py
Normal file
4
dmold/dmweb/run.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from dmweb import create_app
|
||||
|
||||
app = create_app()
|
||||
app.run(host="0.0.0.0", debug=True, threaded=True, port=10000)
|
||||
7
dmold/dmweb/static/styles/dm.css
Normal file
7
dmold/dmweb/static/styles/dm.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.sat {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
table {
|
||||
border: 1px solid black;
|
||||
}
|
||||
32
dmold/dmweb/templates/calendar.html
Normal file
32
dmold/dmweb/templates/calendar.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block head %}
|
||||
|
||||
|
||||
<style type="text/css">
|
||||
|
||||
td > * {
|
||||
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align : top;
|
||||
height: 25px;
|
||||
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
{% endblock head %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
{{ content | safe }}
|
||||
|
||||
{% endblock content %}
|
||||
244
dmold/dmweb/templates/calendar_view.html
Normal file
244
dmold/dmweb/templates/calendar_view.html
Normal file
@@ -0,0 +1,244 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
font-family: monospace;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a {
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
font-size: 16pt;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a.active {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border-bottom: 3px solid #000;
|
||||
}
|
||||
|
||||
.date-nav {
|
||||
margin: 20px 0;
|
||||
font-size: 14pt;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.date-nav a {
|
||||
text-decoration: none;
|
||||
color: #2563eb;
|
||||
font-size: 18pt;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
border: 1px solid #ddd;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.time-column {
|
||||
width: 60px;
|
||||
border-right: 1px solid #ddd;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 5px;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.day-column {
|
||||
flex: 1;
|
||||
border-right: 1px solid #ddd;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.day-column:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
height: 40px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.day-grid {
|
||||
position: relative;
|
||||
height: 1440px; /* 24 hours * 60px */
|
||||
}
|
||||
|
||||
.hour-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.task-block {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
font-size: 9pt;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.task-block:hover {
|
||||
z-index: 100;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.task-label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.task-time {
|
||||
font-size: 8pt;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.block-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 10pt;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.task-block:hover .block-tooltip {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
{% endblock head %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="nav-tabs">
|
||||
<a href="/calendar/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'daily' %}active{% endif %}">Daily</a>
|
||||
<a href="/calendar/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a>
|
||||
<a href="/calendar/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a>
|
||||
<span style="margin-left: auto;">
|
||||
<a href="/switches/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Switches</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="date-nav">
|
||||
<a href="/calendar/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}">←</a>
|
||||
{% if scope == 'daily' %}
|
||||
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
|
||||
{% elif scope == 'weekly' %}
|
||||
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
|
||||
{% elif scope == 'monthly' %}
|
||||
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
|
||||
{% endif %}
|
||||
<a href="/calendar/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}">→</a>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid">
|
||||
<div class="time-column">
|
||||
<div class="day-header" style="height: 40px;"></div>
|
||||
{% for hour in range(24) %}
|
||||
<div class="time-slot">{{ '%02d:00'|format(hour) }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="days-grid">
|
||||
{% for day in days %}
|
||||
<div class="day-column">
|
||||
<div class="day-header">
|
||||
{{ day.strftime('%a %d') if scope != 'daily' else day.strftime('%A') }}
|
||||
</div>
|
||||
<div class="day-grid">
|
||||
{% for hour in range(24) %}
|
||||
<div class="hour-line" style="top: {{ hour * 60 }}px;"></div>
|
||||
{% endfor %}
|
||||
|
||||
{% for block in blocks %}
|
||||
{% if block.start.date() == day.date() %}
|
||||
{% set start_hour = block.start.hour + block.start.minute / 60.0 %}
|
||||
{% set duration_hours = block.duration / 3600.0 %}
|
||||
{% set top_px = start_hour * 60 %}
|
||||
{% set height_px = duration_hours * 60 %}
|
||||
{% if height_px < 2 %}{% set height_px = 2 %}{% endif %}
|
||||
|
||||
{% set task_hash = block.task_path|hash if block.task_path else 0 %}
|
||||
{% set base_color_hue = task_hash % 360 %}
|
||||
|
||||
{# Create gradient: active color on left (0% to active_ratio%), idle color on right #}
|
||||
{% set active_pct = (block.active_ratio * 100)|int %}
|
||||
{% set active_color = 'hsl(%d, 70%%, 50%%)'|format(base_color_hue) %}
|
||||
{% set idle_color = 'hsl(%d, 30%%, 70%%)'|format(base_color_hue) %}
|
||||
|
||||
<div class="task-block"
|
||||
style="top: {{ top_px }}px; height: {{ height_px }}px;
|
||||
background: linear-gradient(to right, {{ active_color }} 0%, {{ active_color }} {{ active_pct }}%, {{ idle_color }} {{ active_pct }}%, {{ idle_color }} 100%);">
|
||||
<div class="task-label">{{ block.task_path }}</div>
|
||||
<div class="task-time">{{ block.start.strftime('%H:%M') }} ({{ (block.duration // 60)|int }}m)</div>
|
||||
<div class="block-tooltip">
|
||||
<strong>{{ block.task_path }}</strong><br>
|
||||
{{ block.start.strftime('%H:%M') }} - {{ block.end.strftime('%H:%M') }}<br>
|
||||
Duration: {{ (block.duration // 60)|int }} minutes<br>
|
||||
Active: {{ (block.active_seconds // 60)|int }}m ({{ active_pct }}%)<br>
|
||||
Idle: {{ (block.idle_seconds // 60)|int }}m
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock content %}
|
||||
26
dmold/dmweb/templates/layout.html
Normal file
26
dmold/dmweb/templates/layout.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<html>
|
||||
<head>
|
||||
|
||||
<!--
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}">
|
||||
-->
|
||||
|
||||
{% if auto_refresh %}
|
||||
<meta http-equiv="refresh" content="5">
|
||||
{% endif %}
|
||||
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
64
dmold/dmweb/templates/main.html
Normal file
64
dmold/dmweb/templates/main.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- <link rel="stylesheet" href="{{ url_for('static', filename='styles/dm.css') }}"> -->
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
<style>
|
||||
body
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh; /* This ensures that the container takes the full height of the viewport */
|
||||
margin: 0; /* Remove default margin */
|
||||
}
|
||||
|
||||
.grey {
|
||||
color:grey;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color:blue;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 84pt
|
||||
}
|
||||
td {
|
||||
padding-right: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% if auto_refresh %}
|
||||
<script>
|
||||
function refreshData() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/api/today', true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
document.getElementById('content-container').innerHTML = xhr.responseText;
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
// Auto-refresh every 5 seconds using AJAX
|
||||
setInterval(refreshData, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- agregar función que me diga cuanto tiempo hace que esta activo el escritorio
|
||||
(calcular el delta con el ultimo switch y pasarlo a mm:ss) -->
|
||||
|
||||
<div id="content-container">
|
||||
{% block content %}
|
||||
{% include 'main_content.html' %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
dmold/dmweb/templates/main_content.html
Normal file
23
dmold/dmweb/templates/main_content.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% if current_task_path and current_task_time %}
|
||||
<div id="current-task-info" style="font-size: 48pt; margin-bottom: 40px; text-align: center;">
|
||||
<div style="color: #333;">{{ current_task_path }}</div>
|
||||
<div style="color: #666; font-size: 36pt;">{{ current_task_time }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<table>
|
||||
<tbody>
|
||||
{% 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 %}
|
||||
<tr>
|
||||
<td class="{{my_class}}" >{{ row["ws"] }}</td>
|
||||
<td class="{{my_class}}" >{{ row["total"] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
16
dmold/dmweb/templates/pages.html
Normal file
16
dmold/dmweb/templates/pages.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<table>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td>{{ row["ws"] }}</td>
|
||||
<td>{{ row["total"] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
{% endblock content %}
|
||||
185
dmold/dmweb/templates/switches_view.html
Normal file
185
dmold/dmweb/templates/switches_view.html
Normal file
@@ -0,0 +1,185 @@
|
||||
{% extends 'layout.html' %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a {
|
||||
text-decoration: none;
|
||||
color: #666;
|
||||
font-size: 16pt;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.nav-tabs a.active {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border-bottom: 3px solid #000;
|
||||
}
|
||||
|
||||
.date-nav {
|
||||
margin: 20px 0;
|
||||
font-size: 14pt;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.date-nav a {
|
||||
text-decoration: none;
|
||||
color: #2563eb;
|
||||
font-size: 18pt;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.switches-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-left: 4px solid;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.switch-time {
|
||||
width: 120px;
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.switch-workspace {
|
||||
width: 80px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.switch-task {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.switch-duration {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Active vs idle workspace indicator */
|
||||
.ws-active { font-weight: bold; }
|
||||
.ws-idle { opacity: 0.6; }
|
||||
|
||||
.stats {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
{% endblock head %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="nav-tabs">
|
||||
<a href="/switches/daily/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'daily' %}active{% endif %}">Daily</a>
|
||||
<a href="/switches/weekly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'weekly' %}active{% endif %}">Weekly</a>
|
||||
<a href="/switches/monthly/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}"
|
||||
class="{% if scope == 'monthly' %}active{% endif %}">Monthly</a>
|
||||
<span style="margin-left: auto;">
|
||||
<a href="/calendar/{{ scope }}/{{ base_date.year }}/{{ base_date.month }}/{{ base_date.day }}">View Calendar</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="date-nav">
|
||||
<a href="/switches/{{ scope }}/{{ prev_date.year }}/{{ prev_date.month }}/{{ prev_date.day }}">←</a>
|
||||
{% if scope == 'daily' %}
|
||||
<span class="date-info">{{ base_date.strftime('%Y-%m-%d %A') }}</span>
|
||||
{% elif scope == 'weekly' %}
|
||||
<span class="date-info">Week of {{ start.strftime('%Y-%m-%d') }}</span>
|
||||
{% elif scope == 'monthly' %}
|
||||
<span class="date-info">{{ base_date.strftime('%B %Y') }}</span>
|
||||
{% endif %}
|
||||
<a href="/switches/{{ scope }}/{{ next_date.year }}/{{ next_date.month }}/{{ next_date.day }}">→</a>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Switches:</span>
|
||||
<span class="stat-value">{{ switches|length }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Time:</span>
|
||||
<span class="stat-value">{{ (switches|sum(attribute='delta') // 3600)|int }}h {{ ((switches|sum(attribute='delta') % 3600) // 60)|int }}m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>All Switches ({{ switches|length }})</h3>
|
||||
|
||||
<div class="switches-container">
|
||||
{% for switch in switches %}
|
||||
{% set is_active = switch.workspace in ['Plan', 'Think', 'Work'] %}
|
||||
{% set task_hash = switch.task_path|hash if switch.task_path else 0 %}
|
||||
{% set border_hue = task_hash % 360 %}
|
||||
{% set border_color = 'hsl(%d, 70%%, 50%%)'|format(border_hue) %}
|
||||
{% set bg_color = 'hsl(%d, 70%%, 95%%)'|format(border_hue) %}
|
||||
<div class="switch-row {{ 'ws-active' if is_active else 'ws-idle' }}"
|
||||
style="border-left-color: {{ border_color }}; background-color: {{ bg_color }};">
|
||||
<div class="switch-time">{{ switch.date.strftime('%m/%d %H:%M:%S') }}</div>
|
||||
<div class="switch-workspace">{{ switch.workspace }}</div>
|
||||
<div class="switch-task">{{ switch.task_path }}</div>
|
||||
<div class="switch-duration">
|
||||
{% if switch.delta >= 3600 %}
|
||||
{{ (switch.delta // 3600)|int }}h {{ ((switch.delta % 3600) // 60)|int }}m
|
||||
{% else %}
|
||||
{{ (switch.delta // 60)|int }}m {{ (switch.delta % 60)|int }}s
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if not switches %}
|
||||
<p style="text-align: center; color: #666; margin-top: 40px;">No switches in this period</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock content %}
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
dmweb:
|
||||
build: .
|
||||
image: registry.mcrn.ar/dmweb:latest
|
||||
container_name: dmweb
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mongo
|
||||
environment:
|
||||
- MONGODB_HOST=mongo
|
||||
- TZ=America/Argentina/Buenos_Aires
|
||||
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:
|
||||
436
docs/README.md
Normal file
436
docs/README.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Deskmeter OS Integration
|
||||
|
||||
Display your current deskmeter task using GNOME Shell extension or standalone GTK window.
|
||||
|
||||
**Location**: `dmos/` (OS-specific components)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### GTK Task Window (Immediate - No logout)
|
||||
|
||||
```bash
|
||||
# Run with auto port detection
|
||||
python3 dmos/task_window.py
|
||||
|
||||
# Make it always-on-top
|
||||
wmctrl -r "Deskmeter Task" -b add,above,sticky
|
||||
```
|
||||
|
||||
### GNOME Extension (Requires logout)
|
||||
|
||||
```bash
|
||||
cd dmos/gnome-extension
|
||||
./install.sh
|
||||
|
||||
# Enable extension
|
||||
gnome-extensions enable deskmeter-indicator@local
|
||||
|
||||
# Log out and back in (required on Wayland)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### 1. GTK Task Window (`dmos/task_window.py`)
|
||||
|
||||
Standalone GTK4 window showing current task, always-on-top and visible on all workspaces.
|
||||
|
||||
**Features**:
|
||||
- No window decorations (minimal UI)
|
||||
- Updates every 500ms
|
||||
- Workspace change detection (updates 2.2s after switch)
|
||||
- Auto port detection (tries 10001, then 10000)
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
# Auto-detect port
|
||||
python3 dmos/task_window.py
|
||||
|
||||
# Specify port
|
||||
python3 dmos/task_window.py 10001
|
||||
|
||||
# Or via environment
|
||||
DESKMETER_PORT=10001 python3 dmos/task_window.py
|
||||
```
|
||||
|
||||
**Window States**:
|
||||
- `Loading...` - Initial state
|
||||
- `work/default` - Current task path
|
||||
- `offline - dmweb not running` (red) - Cannot connect
|
||||
- `error - invalid API response` (orange) - Invalid JSON
|
||||
- `no task` - Valid response, no task set
|
||||
|
||||
**Close**:
|
||||
- `Alt+F4`
|
||||
- `pkill -f task_window.py`
|
||||
|
||||
### 2. GNOME Extension (`dmos/gnome-extension/`)
|
||||
|
||||
Panel indicator integrated into GNOME Shell top bar.
|
||||
|
||||
**Features**:
|
||||
- Native panel integration (left side)
|
||||
- Workspace switch detection with 2.2s debounce
|
||||
- Auto port detection (10001, 10000)
|
||||
- Error handling (won't crash GNOME Shell)
|
||||
- Auto-truncates long paths (shows `.../last/two`)
|
||||
|
||||
**Files**:
|
||||
- `extension.js` - Main extension code
|
||||
- `metadata.json` - Extension metadata (GNOME 40-49)
|
||||
- `stylesheet.css` - Panel styling
|
||||
|
||||
**Installation**:
|
||||
```bash
|
||||
cd dmos/gnome-extension
|
||||
./install.sh # First install
|
||||
# OR
|
||||
./update.sh # After code changes
|
||||
|
||||
# Restart GNOME Shell:
|
||||
# X11: Alt+F2 → 'r' → Enter
|
||||
# Wayland: Log out and back in
|
||||
```
|
||||
|
||||
**Check Status**:
|
||||
```bash
|
||||
# List enabled extensions
|
||||
gnome-extensions list --enabled | grep deskmeter
|
||||
|
||||
# View logs
|
||||
journalctl --user -u org.gnome.Shell@wayland.service -f | grep deskmeter
|
||||
|
||||
# Debug with Looking Glass
|
||||
# Alt+F2 → 'lg' → Extensions tab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Auto-Detection
|
||||
|
||||
Both components automatically detect dmweb port:
|
||||
|
||||
**Priority Order**:
|
||||
1. Command line argument (window only)
|
||||
2. Environment variable `DESKMETER_PORT` (window only)
|
||||
3. Auto-detection: tries 10001, then 10000
|
||||
|
||||
**Test Ports**:
|
||||
```bash
|
||||
curl http://localhost:10001/api/current_task
|
||||
curl http://localhost:10000/api/current_task
|
||||
```
|
||||
|
||||
**Configure Ports**:
|
||||
```python
|
||||
# In dmos/task_window.py (line 36)
|
||||
DEFAULT_PORTS = [10001, 10000]
|
||||
```
|
||||
|
||||
```javascript
|
||||
// In dmos/gnome-extension/deskmeter-indicator@local/extension.js (line 11)
|
||||
const DEFAULT_PORTS = [10001, 10000];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Both Components
|
||||
- dmweb Flask server running (`cd dmapp/dmweb && python3 run.py`)
|
||||
- `/api/current_task` endpoint accessible
|
||||
|
||||
### GTK Window
|
||||
- Python 3 with GTK4: `sudo apt install python3-gi gir1.2-gtk-4.0`
|
||||
- wmctrl: `sudo apt install wmctrl`
|
||||
|
||||
### GNOME Extension
|
||||
- GNOME Shell 40-49
|
||||
- Works on X11 and Wayland
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### dmweb Not Running
|
||||
|
||||
```bash
|
||||
# Check if dmweb is running
|
||||
ps aux | grep dmweb
|
||||
|
||||
# Check listening ports
|
||||
ss -tlnp | grep -E "(10000|10001)"
|
||||
|
||||
# Start dmweb
|
||||
cd dmapp/dmweb
|
||||
python3 run.py
|
||||
|
||||
# Test API
|
||||
curl http://localhost:10000/api/current_task
|
||||
# Expected: {"task_id":"abc12345","task_path":"work/default"}
|
||||
```
|
||||
|
||||
### GTK Window Issues
|
||||
|
||||
**"offline - dmweb not running"**:
|
||||
- Start dmweb (see above)
|
||||
|
||||
**Window not staying on top**:
|
||||
```bash
|
||||
wmctrl -r "Deskmeter Task" -b add,above,sticky
|
||||
```
|
||||
|
||||
**GTK4 not found**:
|
||||
```bash
|
||||
sudo apt install python3-gi gir1.2-gtk-4.0
|
||||
```
|
||||
|
||||
**Window doesn't update on workspace change**:
|
||||
```bash
|
||||
# Test wmctrl
|
||||
wmctrl -d
|
||||
# Should show workspaces with * marking current
|
||||
```
|
||||
|
||||
### GNOME Extension Issues
|
||||
|
||||
**Extension not showing in panel**:
|
||||
```bash
|
||||
# Check if enabled
|
||||
gnome-extensions list --enabled | grep deskmeter
|
||||
|
||||
# Re-enable
|
||||
gnome-extensions enable deskmeter-indicator@local
|
||||
|
||||
# View errors
|
||||
journalctl --user -u org.gnome.Shell@wayland.service --since "5 minutes ago" | grep -i deskmeter
|
||||
```
|
||||
|
||||
**Shows "detecting..." forever**:
|
||||
- dmweb not running on 10001 or 10000
|
||||
- Start dmweb and log out/in
|
||||
|
||||
**Shows "offline"**:
|
||||
```bash
|
||||
# Test API endpoint
|
||||
curl http://localhost:10000/api/current_task
|
||||
|
||||
# Check dmweb logs for errors
|
||||
```
|
||||
|
||||
**Changes not appearing after update**:
|
||||
- Ensure `./update.sh` ran successfully
|
||||
- Must restart GNOME Shell (X11) or logout/login (Wayland)
|
||||
- Check logs: `journalctl -f -o cat /usr/bin/gnome-shell`
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Window vs Extension
|
||||
|
||||
| Feature | GTK Window | GNOME Extension |
|
||||
|---------|------------|-----------------|
|
||||
| Visibility | Separate window | Panel indicator |
|
||||
| Screen space | Takes window space | Minimal (panel) |
|
||||
| Setup | Run anytime | Requires logout |
|
||||
| Restart needed | No | Yes (Wayland) |
|
||||
| All workspaces | ✅ (sticky) | ✅ (panel) |
|
||||
| Integration | Standalone | Native GNOME |
|
||||
|
||||
**Use GTK window for**:
|
||||
- Quick testing
|
||||
- No logout required
|
||||
- Temporary usage
|
||||
|
||||
**Use GNOME extension for**:
|
||||
- Permanent setup
|
||||
- Cleaner integration
|
||||
- Less screen clutter
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### GTK Window Settings
|
||||
|
||||
Edit `dmos/task_window.py`:
|
||||
|
||||
```python
|
||||
# Update frequency (line 37)
|
||||
UPDATE_INTERVAL = 2000 # milliseconds
|
||||
|
||||
# Workspace check frequency (line 38)
|
||||
WORKSPACE_CHECK_INTERVAL = 200 # milliseconds
|
||||
|
||||
# Window size (line ~50)
|
||||
self.set_default_size(400, 60) # width x height
|
||||
|
||||
# Font size (line ~27)
|
||||
self.label.set_markup('<span font_desc="14">Loading...</span>')
|
||||
```
|
||||
|
||||
### GNOME Extension Settings
|
||||
|
||||
Edit `dmos/gnome-extension/deskmeter-indicator@local/extension.js`:
|
||||
|
||||
```javascript
|
||||
// Debounce delay (line 10)
|
||||
const DEBOUNCE_DELAY = 2200; // milliseconds
|
||||
|
||||
// Port list (line 11)
|
||||
const DEFAULT_PORTS = [10001, 10000];
|
||||
|
||||
// Update interval (line 12)
|
||||
const UPDATE_INTERVAL = 30000; // 30 seconds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auto-start (Optional)
|
||||
|
||||
### GTK Window on Login
|
||||
|
||||
```bash
|
||||
# Create desktop entry
|
||||
cat > ~/.config/autostart/deskmeter-task-window.desktop <<EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Deskmeter Task Window
|
||||
Exec=/home/mariano/wdir/dm/dmos/task_window.py
|
||||
Hidden=false
|
||||
NoDisplay=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
```
|
||||
|
||||
### GNOME Extension
|
||||
|
||||
Enabled extensions auto-start with GNOME Shell. Just ensure it's enabled:
|
||||
```bash
|
||||
gnome-extensions enable deskmeter-indicator@local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Structure
|
||||
|
||||
```
|
||||
dmos/
|
||||
├── gnome-extension/
|
||||
│ ├── deskmeter-indicator@local/
|
||||
│ │ ├── extension.js # Main extension code
|
||||
│ │ ├── metadata.json # GNOME metadata
|
||||
│ │ ├── stylesheet.css # Panel styling
|
||||
│ │ └── deskmeter-indicator@local.zip # Packaged extension
|
||||
│ ├── install.sh # Initial installation
|
||||
│ └── update.sh # Update after changes
|
||||
└── task_window.py # GTK4 window app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### GTK Window
|
||||
- [ ] Window appears without decorations
|
||||
- [ ] Shows current task path
|
||||
- [ ] Can be moved/positioned
|
||||
- [ ] Closes with Alt+F4
|
||||
- [ ] Updates on workspace change (~2s delay)
|
||||
- [ ] Can set always-on-top with wmctrl
|
||||
- [ ] Auto-detects port (check console)
|
||||
|
||||
### GNOME Extension
|
||||
- [ ] Shows in panel (left side)
|
||||
- [ ] Displays current task
|
||||
- [ ] Updates on workspace switch (~2s delay)
|
||||
- [ ] Doesn't crash GNOME Shell if dmweb offline
|
||||
- [ ] Can disable: `gnome-extensions disable deskmeter-indicator@local`
|
||||
- [ ] Truncates long paths correctly
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### GTK Window
|
||||
- Workspace checks: 200ms interval (lightweight wmctrl)
|
||||
- Task updates: 2000ms interval
|
||||
- Post-switch delay: 2200ms (allows dmcore to update)
|
||||
- CPU usage: ~0.1-0.2%
|
||||
|
||||
### GNOME Extension
|
||||
- Event-driven (workspace switches)
|
||||
- Periodic refresh: 30s
|
||||
- Debounce delay: 2200ms
|
||||
- Minimal overhead (GNOME Shell event loop)
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Updating Extension
|
||||
|
||||
```bash
|
||||
cd dmos/gnome-extension
|
||||
|
||||
# 1. Edit extension.js, metadata.json, or stylesheet.css
|
||||
|
||||
# 2. Update installed version
|
||||
./update.sh
|
||||
|
||||
# 3. Restart GNOME Shell
|
||||
# X11: Alt+F2 → 'r' → Enter
|
||||
# Wayland: Log out and back in
|
||||
|
||||
# 4. Check logs for errors
|
||||
journalctl -f -o cat /usr/bin/gnome-shell | grep deskmeter
|
||||
```
|
||||
|
||||
### Updating Window
|
||||
|
||||
```bash
|
||||
# 1. Edit dmos/task_window.py
|
||||
|
||||
# 2. Kill running instance
|
||||
pkill -f task_window.py
|
||||
|
||||
# 3. Run updated version
|
||||
python3 dmos/task_window.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoint
|
||||
|
||||
Both components use the same endpoint:
|
||||
|
||||
**URL**: `http://localhost:{PORT}/api/current_task`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"task_id": "abc12345",
|
||||
"task_path": "work/default"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Handling**:
|
||||
- Connection refused → "offline"
|
||||
- Invalid JSON → "error"
|
||||
- Missing task_path → "no task"
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
|
||||
1. **Start dmweb**: `cd dmapp/dmweb && python3 run.py`
|
||||
2. **Test API**: `curl http://localhost:10000/api/current_task`
|
||||
3. **Try window first**: `python3 dmos/task_window.py` (no logout)
|
||||
4. **Check logs**: `journalctl` for extension, console for window
|
||||
5. **Verify ports**: `ss -tlnp | grep -E "(10000|10001)"`
|
||||
|
||||
---
|
||||
|
||||
**Ready to test!** Start with the GTK window for immediate feedback, then try the extension for permanent integration.
|
||||
76
docs/architecture/01-system-overview.dot
Normal file
76
docs/architecture/01-system-overview.dot
Normal file
@@ -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"];
|
||||
}
|
||||
173
docs/architecture/01-system-overview.svg
Normal file
173
docs/architecture/01-system-overview.svg
Normal file
@@ -0,0 +1,173 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 14.1.1 (0)
|
||||
-->
|
||||
<!-- Title: SystemOverview Pages: 1 -->
|
||||
<svg width="467pt" height="624pt"
|
||||
viewBox="0.00 0.00 467.00 624.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 619.69)">
|
||||
<title>SystemOverview</title>
|
||||
<polygon fill="white" stroke="none" points="-4,4 -4,-619.69 463,-619.69 463,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="229.5" y="-596.49" font-family="Helvetica,sans-Serif" font-size="16.00">Deskmeter - System Architecture</text>
|
||||
<g id="clust1" class="cluster">
|
||||
<title>cluster_local</title>
|
||||
<polygon fill="#e8f5e9" stroke="#e8f5e9" points="185,-125.62 185,-457.19 434,-457.19 434,-125.62 185,-125.62"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-437.99" font-family="Helvetica,sans-Serif" font-size="16.00">Local Machine (Bare Metal)</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<title>cluster_os</title>
|
||||
<polygon fill="none" stroke="gray" stroke-dasharray="5,2" points="185,-500.69 185,-580.19 451,-580.19 451,-500.69 185,-500.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="318" y="-560.99" font-family="Helvetica,sans-Serif" font-size="16.00">OS Integration</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<title>cluster_remote</title>
|
||||
<polygon fill="#fff3e0" stroke="#fff3e0" points="8,-8 8,-334.19 177,-334.19 177,-8 8,-8"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="92.5" y="-314.99" font-family="Helvetica,sans-Serif" font-size="16.00">AWS EC2 (mcrn.ar)</text>
|
||||
</g>
|
||||
<g id="clust4" class="cluster">
|
||||
<title>cluster_docker</title>
|
||||
<polygon fill="none" stroke="#f57c00" stroke-dasharray="5,2" points="16,-16 16,-205.12 169,-205.12 169,-16 16,-16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="92.5" y="-185.93" font-family="Helvetica,sans-Serif" font-size="16.00">Docker Compose</text>
|
||||
</g>
|
||||
<!-- dmcore -->
|
||||
<g id="node1" class="node">
|
||||
<title>dmcore</title>
|
||||
<path fill="#c8e6c9" stroke="black" d="M309.25,-421.69C309.25,-421.69 204.75,-421.69 204.75,-421.69 198.75,-421.69 192.75,-415.69 192.75,-409.69 192.75,-409.69 192.75,-397.69 192.75,-397.69 192.75,-391.69 198.75,-385.69 204.75,-385.69 204.75,-385.69 309.25,-385.69 309.25,-385.69 315.25,-385.69 321.25,-391.69 321.25,-397.69 321.25,-397.69 321.25,-409.69 321.25,-409.69 321.25,-415.69 315.25,-421.69 309.25,-421.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="257" y="-406.74" font-family="Helvetica,sans-Serif" font-size="11.00">dmcore</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="257" y="-393.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Workspace Tracker)</text>
|
||||
</g>
|
||||
<!-- mongo_local -->
|
||||
<g id="node4" class="node">
|
||||
<title>mongo_local</title>
|
||||
<path fill="#ffecb3" stroke="black" d="M300.25,-300.38C300.25,-302.79 280.86,-304.75 257,-304.75 233.14,-304.75 213.75,-302.79 213.75,-300.38 213.75,-300.38 213.75,-261 213.75,-261 213.75,-258.59 233.14,-256.62 257,-256.62 280.86,-256.62 300.25,-258.59 300.25,-261 300.25,-261 300.25,-300.38 300.25,-300.38"/>
|
||||
<path fill="none" stroke="black" d="M300.25,-300.38C300.25,-297.96 280.86,-296 257,-296 233.14,-296 213.75,-297.96 213.75,-300.38"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="257" y="-283.74" font-family="Helvetica,sans-Serif" font-size="11.00">MongoDB</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="257" y="-270.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Replica Set)</text>
|
||||
</g>
|
||||
<!-- dmcore->mongo_local -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>dmcore->mongo_local</title>
|
||||
<path fill="none" stroke="#ffa000" d="M257,-385.35C257,-367.52 257,-339.24 257,-316.72"/>
|
||||
<polygon fill="#ffa000" stroke="#ffa000" points="260.5,-316.74 257,-306.74 253.5,-316.74 260.5,-316.74"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="278.38" y="-358.19" font-family="Helvetica,sans-Serif" font-size="10.00">write</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="278.38" y="-345.44" font-family="Helvetica,sans-Serif" font-size="10.00">switches</text>
|
||||
</g>
|
||||
<!-- dmweb_local -->
|
||||
<g id="node2" class="node">
|
||||
<title>dmweb_local</title>
|
||||
<path fill="#c8e6c9" stroke="black" d="M414.25,-421.69C414.25,-421.69 351.75,-421.69 351.75,-421.69 345.75,-421.69 339.75,-415.69 339.75,-409.69 339.75,-409.69 339.75,-397.69 339.75,-397.69 339.75,-391.69 345.75,-385.69 351.75,-385.69 351.75,-385.69 414.25,-385.69 414.25,-385.69 420.25,-385.69 426.25,-391.69 426.25,-397.69 426.25,-397.69 426.25,-409.69 426.25,-409.69 426.25,-415.69 420.25,-421.69 414.25,-421.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="383" y="-406.74" font-family="Helvetica,sans-Serif" font-size="11.00">dmweb</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="383" y="-393.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Dev Server)</text>
|
||||
</g>
|
||||
<!-- dmweb_local->mongo_local -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>dmweb_local->mongo_local</title>
|
||||
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M364.95,-385.35C345.23,-366.41 313.24,-335.7 289.2,-312.61"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="291.79,-310.24 282.15,-305.84 286.94,-315.29 291.79,-310.24"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="356.64" y="-351.81" font-family="Helvetica,sans-Serif" font-size="10.00">read</text>
|
||||
</g>
|
||||
<!-- dmsync -->
|
||||
<g id="node3" class="node">
|
||||
<title>dmsync</title>
|
||||
<path fill="#dcedc8" stroke="black" d="M299.88,-169.62C299.88,-169.62 208.12,-169.62 208.12,-169.62 202.12,-169.62 196.12,-163.62 196.12,-157.62 196.12,-157.62 196.12,-145.62 196.12,-145.62 196.12,-139.62 202.12,-133.62 208.12,-133.62 208.12,-133.62 299.88,-133.62 299.88,-133.62 305.88,-133.62 311.88,-139.62 311.88,-145.62 311.88,-145.62 311.88,-157.62 311.88,-157.62 311.88,-163.62 305.88,-169.62 299.88,-169.62"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="254" y="-154.68" font-family="Helvetica,sans-Serif" font-size="11.00">dmsync</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="254" y="-141.18" font-family="Helvetica,sans-Serif" font-size="11.00">(Change Streams)</text>
|
||||
</g>
|
||||
<!-- mongo_remote -->
|
||||
<g id="node8" class="node">
|
||||
<title>mongo_remote</title>
|
||||
<path fill="#ffecb3" stroke="black" d="M154.25,-67.75C154.25,-70.16 138.9,-72.12 120,-72.12 101.1,-72.12 85.75,-70.16 85.75,-67.75 85.75,-67.75 85.75,-28.38 85.75,-28.38 85.75,-25.96 101.1,-24 120,-24 138.9,-24 154.25,-25.96 154.25,-28.38 154.25,-28.38 154.25,-67.75 154.25,-67.75"/>
|
||||
<path fill="none" stroke="black" d="M154.25,-67.75C154.25,-65.34 138.9,-63.38 120,-63.38 101.1,-63.38 85.75,-65.34 85.75,-67.75"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="120" y="-51.11" font-family="Helvetica,sans-Serif" font-size="11.00">MongoDB</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="120" y="-37.61" font-family="Helvetica,sans-Serif" font-size="11.00">(Docker)</text>
|
||||
</g>
|
||||
<!-- dmsync->mongo_remote -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>dmsync->mongo_remote</title>
|
||||
<path fill="none" stroke="#7b1fa2" stroke-width="2" d="M230.96,-133.17C211.01,-118.04 181.75,-95.87 158.29,-78.08"/>
|
||||
<polygon fill="#7b1fa2" stroke="#7b1fa2" stroke-width="2" points="161.78,-76.34 151.7,-73.09 157.55,-81.92 161.78,-76.34"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="227.08" y="-106.12" font-family="Helvetica,sans-Serif" font-size="10.00">push</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="227.08" y="-93.38" font-family="Helvetica,sans-Serif" font-size="10.00">changes</text>
|
||||
</g>
|
||||
<!-- mongo_local->dmsync -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>mongo_local->dmsync</title>
|
||||
<path fill="none" stroke="#7b1fa2" d="M256.45,-256.38C255.95,-235.25 255.21,-204.03 254.68,-181.35"/>
|
||||
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="258.18,-181.5 254.45,-171.58 251.18,-181.66 258.18,-181.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="276.28" y="-229.12" font-family="Helvetica,sans-Serif" font-size="10.00">Change</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="276.28" y="-216.38" font-family="Helvetica,sans-Serif" font-size="10.00">Streams</text>
|
||||
</g>
|
||||
<!-- wmctrl -->
|
||||
<g id="node5" class="node">
|
||||
<title>wmctrl</title>
|
||||
<path fill="#e3f2fd" stroke="black" d="M296.88,-544.69C296.88,-544.69 205.12,-544.69 205.12,-544.69 199.12,-544.69 193.12,-538.69 193.12,-532.69 193.12,-532.69 193.12,-520.69 193.12,-520.69 193.12,-514.69 199.12,-508.69 205.12,-508.69 205.12,-508.69 296.88,-508.69 296.88,-508.69 302.88,-508.69 308.88,-514.69 308.88,-520.69 308.88,-520.69 308.88,-532.69 308.88,-532.69 308.88,-538.69 302.88,-544.69 296.88,-544.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="251" y="-529.74" font-family="Helvetica,sans-Serif" font-size="11.00">wmctrl</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="251" y="-516.24" font-family="Helvetica,sans-Serif" font-size="11.00">(X11 Workspaces)</text>
|
||||
</g>
|
||||
<!-- wmctrl->dmcore -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>wmctrl->dmcore</title>
|
||||
<path fill="none" stroke="#388e3c" d="M251.86,-508.35C252.83,-488.76 254.43,-456.58 255.58,-433.26"/>
|
||||
<polygon fill="#388e3c" stroke="#388e3c" points="259.07,-433.66 256.07,-423.5 252.08,-433.32 259.07,-433.66"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="280.25" y="-481.19" font-family="Helvetica,sans-Serif" font-size="10.00">workspace</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="280.25" y="-468.44" font-family="Helvetica,sans-Serif" font-size="10.00">detection</text>
|
||||
</g>
|
||||
<!-- gnome_ext -->
|
||||
<g id="node6" class="node">
|
||||
<title>gnome_ext</title>
|
||||
<path fill="#e3f2fd" stroke="black" d="M430.88,-544.69C430.88,-544.69 339.12,-544.69 339.12,-544.69 333.12,-544.69 327.12,-538.69 327.12,-532.69 327.12,-532.69 327.12,-520.69 327.12,-520.69 327.12,-514.69 333.12,-508.69 339.12,-508.69 339.12,-508.69 430.88,-508.69 430.88,-508.69 436.88,-508.69 442.88,-514.69 442.88,-520.69 442.88,-520.69 442.88,-532.69 442.88,-532.69 442.88,-538.69 436.88,-544.69 430.88,-544.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="385" y="-529.74" font-family="Helvetica,sans-Serif" font-size="11.00">GNOME Extension</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="385" y="-516.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Panel Indicator)</text>
|
||||
</g>
|
||||
<!-- gnome_ext->dmweb_local -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>gnome_ext->dmweb_local</title>
|
||||
<path fill="none" stroke="#1976d2" stroke-dasharray="5,2" d="M384.71,-508.35C384.39,-488.76 383.86,-456.58 383.47,-433.26"/>
|
||||
<polygon fill="#1976d2" stroke="#1976d2" points="386.98,-433.45 383.31,-423.51 379.98,-433.56 386.98,-433.45"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="402.78" y="-474.81" font-family="Helvetica,sans-Serif" font-size="10.00">API poll</text>
|
||||
</g>
|
||||
<!-- dmweb_remote -->
|
||||
<g id="node7" class="node">
|
||||
<title>dmweb_remote</title>
|
||||
<path fill="#ffe0b2" stroke="black" d="M149,-169.62C149,-169.62 55,-169.62 55,-169.62 49,-169.62 43,-163.62 43,-157.62 43,-157.62 43,-145.62 43,-145.62 43,-139.62 49,-133.62 55,-133.62 55,-133.62 149,-133.62 149,-133.62 155,-133.62 161,-139.62 161,-145.62 161,-145.62 161,-157.62 161,-157.62 161,-163.62 155,-169.62 149,-169.62"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="102" y="-154.68" font-family="Helvetica,sans-Serif" font-size="11.00">dmweb</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="102" y="-141.18" font-family="Helvetica,sans-Serif" font-size="11.00">(Flask + Gunicorn)</text>
|
||||
</g>
|
||||
<!-- dmweb_remote->mongo_remote -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>dmweb_remote->mongo_remote</title>
|
||||
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M105.05,-133.4C107.47,-119.79 110.91,-100.35 113.88,-83.57"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="117.29,-84.43 115.59,-73.97 110.39,-83.21 117.29,-84.43"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="123.49" y="-99.75" font-family="Helvetica,sans-Serif" font-size="10.00">read</text>
|
||||
</g>
|
||||
<!-- nginx -->
|
||||
<g id="node9" class="node">
|
||||
<title>nginx</title>
|
||||
<path fill="#bbdefb" stroke="black" d="M126.88,-298.69C126.88,-298.69 77.12,-298.69 77.12,-298.69 71.12,-298.69 65.12,-292.69 65.12,-286.69 65.12,-286.69 65.12,-274.69 65.12,-274.69 65.12,-268.69 71.12,-262.69 77.12,-262.69 77.12,-262.69 126.88,-262.69 126.88,-262.69 132.88,-262.69 138.88,-268.69 138.88,-274.69 138.88,-274.69 138.88,-286.69 138.88,-286.69 138.88,-292.69 132.88,-298.69 126.88,-298.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="102" y="-283.74" font-family="Helvetica,sans-Serif" font-size="11.00">Nginx</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="102" y="-270.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Gateway)</text>
|
||||
</g>
|
||||
<!-- nginx->dmweb_remote -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>nginx->dmweb_remote</title>
|
||||
<path fill="none" stroke="#1976d2" d="M102,-262.29C102,-241.38 102,-206 102,-181.09"/>
|
||||
<polygon fill="#1976d2" stroke="#1976d2" points="105.5,-181.33 102,-171.33 98.5,-181.33 105.5,-181.33"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="115.88" y="-222.75" font-family="Helvetica,sans-Serif" font-size="10.00">proxy</text>
|
||||
</g>
|
||||
<!-- browser -->
|
||||
<g id="node10" class="node">
|
||||
<title>browser</title>
|
||||
<path fill="#f3e5f5" stroke="black" d="M145.62,-421.69C145.62,-421.69 58.38,-421.69 58.38,-421.69 52.38,-421.69 46.38,-415.69 46.38,-409.69 46.38,-409.69 46.38,-397.69 46.38,-397.69 46.38,-391.69 52.38,-385.69 58.38,-385.69 58.38,-385.69 145.62,-385.69 145.62,-385.69 151.62,-385.69 157.62,-391.69 157.62,-397.69 157.62,-397.69 157.62,-409.69 157.62,-409.69 157.62,-415.69 151.62,-421.69 145.62,-421.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="102" y="-406.74" font-family="Helvetica,sans-Serif" font-size="11.00">Browser</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="102" y="-393.24" font-family="Helvetica,sans-Serif" font-size="11.00">(Portfolio Viewer)</text>
|
||||
</g>
|
||||
<!-- browser->nginx -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>browser->nginx</title>
|
||||
<path fill="none" stroke="#1976d2" d="M102,-385.35C102,-365.76 102,-333.58 102,-310.26"/>
|
||||
<polygon fill="#1976d2" stroke="#1976d2" points="105.5,-310.51 102,-300.51 98.5,-310.51 105.5,-310.51"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="117.75" y="-351.81" font-family="Helvetica,sans-Serif" font-size="10.00">HTTPS</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
76
docs/architecture/02-data-sync.dot
Normal file
76
docs/architecture/02-data-sync.dot
Normal file
@@ -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"];
|
||||
}
|
||||
162
docs/architecture/02-data-sync.svg
Normal file
162
docs/architecture/02-data-sync.svg
Normal file
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 14.1.1 (0)
|
||||
-->
|
||||
<!-- Title: DataSync Pages: 1 -->
|
||||
<svg width="760pt" height="499pt"
|
||||
viewBox="0.00 0.00 760.00 499.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 494.5)">
|
||||
<title>DataSync</title>
|
||||
<polygon fill="white" stroke="none" points="-4,4 -4,-494.5 755.75,-494.5 755.75,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="375.88" y="-471.3" font-family="Helvetica,sans-Serif" font-size="16.00">Deskmeter - Change Streams Data Sync</text>
|
||||
<g id="clust1" class="cluster">
|
||||
<title>cluster_local_mongo</title>
|
||||
<polygon fill="#e8f5e9" stroke="#e8f5e9" points="4.5,-169 4.5,-455 252.25,-455 252.25,-169 4.5,-169"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="128.38" y="-435.8" font-family="Helvetica,sans-Serif" font-size="16.00">Local MongoDB (Replica Set)</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<title>cluster_collections</title>
|
||||
<polygon fill="none" stroke="#388e3c" stroke-dasharray="5,2" points="16,-177 16,-419 156.75,-419 156.75,-177 16,-177"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-399.8" font-family="Helvetica,sans-Serif" font-size="16.00">Collections</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<title>cluster_dmsync</title>
|
||||
<polygon fill="#f3e5f5" stroke="#f3e5f5" points="309,-258 309,-368 724.38,-368 724.38,-258 309,-258"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="516.69" y="-348.8" font-family="Helvetica,sans-Serif" font-size="16.00">dmsync Daemon</text>
|
||||
</g>
|
||||
<g id="clust4" class="cluster">
|
||||
<title>cluster_remote_mongo</title>
|
||||
<polygon fill="#fff3e0" stroke="#fff3e0" points="518.25,-8 518.25,-250 751.75,-250 751.75,-8 518.25,-8"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="635" y="-230.8" font-family="Helvetica,sans-Serif" font-size="16.00">Remote MongoDB (Docker)</text>
|
||||
</g>
|
||||
<!-- oplog -->
|
||||
<g id="node1" class="node">
|
||||
<title>oplog</title>
|
||||
<path fill="#c8e6c9" stroke="black" d="M239.75,-298.73C239.75,-300.53 227.65,-302 212.75,-302 197.85,-302 185.75,-300.53 185.75,-298.73 185.75,-298.73 185.75,-269.27 185.75,-269.27 185.75,-267.47 197.85,-266 212.75,-266 227.65,-266 239.75,-267.47 239.75,-269.27 239.75,-269.27 239.75,-298.73 239.75,-298.73"/>
|
||||
<path fill="none" stroke="black" d="M239.75,-298.73C239.75,-296.92 227.65,-295.45 212.75,-295.45 197.85,-295.45 185.75,-296.92 185.75,-298.73"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="212.75" y="-280.3" font-family="Helvetica,sans-Serif" font-size="11.00">Oplog</text>
|
||||
</g>
|
||||
<!-- watcher -->
|
||||
<g id="node6" class="node">
|
||||
<title>watcher</title>
|
||||
<path fill="#e1bee7" stroke="black" d="M405.75,-302C405.75,-302 329,-302 329,-302 323,-302 317,-296 317,-290 317,-290 317,-278 317,-278 317,-272 323,-266 329,-266 329,-266 405.75,-266 405.75,-266 411.75,-266 417.75,-272 417.75,-278 417.75,-278 417.75,-290 417.75,-290 417.75,-296 411.75,-302 405.75,-302"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="367.38" y="-287.05" font-family="Helvetica,sans-Serif" font-size="11.00">Change Stream</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="367.38" y="-273.55" font-family="Helvetica,sans-Serif" font-size="11.00">Watcher</text>
|
||||
</g>
|
||||
<!-- oplog->watcher -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>oplog->watcher</title>
|
||||
<path fill="none" stroke="#7b1fa2" stroke-width="2" d="M239.99,-284C258,-284 282.67,-284 305.43,-284"/>
|
||||
<polygon fill="#7b1fa2" stroke="#7b1fa2" stroke-width="2" points="303.74,-287.5 313.74,-284 303.74,-280.5 303.74,-287.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="280.62" y="-287.25" font-family="Helvetica,sans-Serif" font-size="10.00">watch()</text>
|
||||
</g>
|
||||
<!-- switch_coll -->
|
||||
<g id="node2" class="node">
|
||||
<title>switch_coll</title>
|
||||
<path fill="#dcedc8" stroke="black" d="M136.75,-221C136.75,-221 36,-221 36,-221 30,-221 24,-215 24,-209 24,-209 24,-197 24,-197 24,-191 30,-185 36,-185 36,-185 136.75,-185 136.75,-185 142.75,-185 148.75,-191 148.75,-197 148.75,-197 148.75,-209 148.75,-209 148.75,-215 142.75,-221 136.75,-221"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-206.05" font-family="Helvetica,sans-Serif" font-size="11.00">switch</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-192.55" font-family="Helvetica,sans-Serif" font-size="11.00">(workspace events)</text>
|
||||
</g>
|
||||
<!-- switch_coll->oplog -->
|
||||
<!-- task_coll -->
|
||||
<g id="node3" class="node">
|
||||
<title>task_coll</title>
|
||||
<path fill="#dcedc8" stroke="black" d="M123.25,-275C123.25,-275 49.5,-275 49.5,-275 43.5,-275 37.5,-269 37.5,-263 37.5,-263 37.5,-251 37.5,-251 37.5,-245 43.5,-239 49.5,-239 49.5,-239 123.25,-239 123.25,-239 129.25,-239 135.25,-245 135.25,-251 135.25,-251 135.25,-263 135.25,-263 135.25,-269 129.25,-275 123.25,-275"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-260.05" font-family="Helvetica,sans-Serif" font-size="11.00">task</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-246.55" font-family="Helvetica,sans-Serif" font-size="11.00">(current tasks)</text>
|
||||
</g>
|
||||
<!-- task_coll->oplog -->
|
||||
<!-- state_coll -->
|
||||
<g id="node4" class="node">
|
||||
<title>state_coll</title>
|
||||
<path fill="#dcedc8" stroke="black" d="M122.88,-329C122.88,-329 49.88,-329 49.88,-329 43.88,-329 37.88,-323 37.88,-317 37.88,-317 37.88,-305 37.88,-305 37.88,-299 43.88,-293 49.88,-293 49.88,-293 122.88,-293 122.88,-293 128.88,-293 134.88,-299 134.88,-305 134.88,-305 134.88,-317 134.88,-317 134.88,-323 128.88,-329 122.88,-329"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-314.05" font-family="Helvetica,sans-Serif" font-size="11.00">state</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-300.55" font-family="Helvetica,sans-Serif" font-size="11.00">(current state)</text>
|
||||
</g>
|
||||
<!-- state_coll->oplog -->
|
||||
<!-- history_coll -->
|
||||
<g id="node5" class="node">
|
||||
<title>history_coll</title>
|
||||
<path fill="#dcedc8" stroke="black" d="M117.25,-383C117.25,-383 55.5,-383 55.5,-383 49.5,-383 43.5,-377 43.5,-371 43.5,-371 43.5,-359 43.5,-359 43.5,-353 49.5,-347 55.5,-347 55.5,-347 117.25,-347 117.25,-347 123.25,-347 129.25,-353 129.25,-359 129.25,-359 129.25,-371 129.25,-371 129.25,-377 123.25,-383 117.25,-383"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-368.05" font-family="Helvetica,sans-Serif" font-size="11.00">task_history</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="86.38" y="-354.55" font-family="Helvetica,sans-Serif" font-size="11.00">(path cache)</text>
|
||||
</g>
|
||||
<!-- history_coll->oplog -->
|
||||
<!-- resume_token -->
|
||||
<g id="node7" class="node">
|
||||
<title>resume_token</title>
|
||||
<polygon fill="#e1bee7" stroke="black" points="710.38,-317 552.62,-317 552.62,-281 716.38,-281 716.38,-311 710.38,-317"/>
|
||||
<polyline fill="none" stroke="black" points="710.38,-317 710.38,-311"/>
|
||||
<polyline fill="none" stroke="black" points="716.38,-311 710.38,-311"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-302.05" font-family="Helvetica,sans-Serif" font-size="11.00">Resume Token</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-288.55" font-family="Helvetica,sans-Serif" font-size="11.00">(~/.dmsync-resume-token)</text>
|
||||
</g>
|
||||
<!-- watcher->resume_token -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>watcher->resume_token</title>
|
||||
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M418.2,-283.38C442.89,-283.35 473.16,-283.73 500.25,-285.25 513.4,-285.99 527.26,-287.07 540.86,-288.32"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="540.41,-291.79 550.7,-289.25 541.07,-284.82 540.41,-291.79"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="468" y="-288.5" font-family="Helvetica,sans-Serif" font-size="10.00">persist</text>
|
||||
</g>
|
||||
<!-- remote_switch -->
|
||||
<g id="node8" class="node">
|
||||
<title>remote_switch</title>
|
||||
<path fill="#ffe0b2" stroke="black" d="M649.5,-214C649.5,-214 619.5,-214 619.5,-214 613.5,-214 607.5,-208 607.5,-202 607.5,-202 607.5,-190 607.5,-190 607.5,-184 613.5,-178 619.5,-178 619.5,-178 649.5,-178 649.5,-178 655.5,-178 661.5,-184 661.5,-190 661.5,-190 661.5,-202 661.5,-202 661.5,-208 655.5,-214 649.5,-214"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-192.3" font-family="Helvetica,sans-Serif" font-size="11.00">switch</text>
|
||||
</g>
|
||||
<!-- watcher->remote_switch -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>watcher->remote_switch</title>
|
||||
<path fill="none" stroke="#f57c00" d="M418.24,-267.45C470.07,-250.25 549.97,-223.72 596.46,-208.29"/>
|
||||
<polygon fill="#f57c00" stroke="#f57c00" points="597.42,-211.66 605.81,-205.19 595.21,-205.02 597.42,-211.66"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="468" y="-263.65" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
|
||||
</g>
|
||||
<!-- remote_task -->
|
||||
<g id="node9" class="node">
|
||||
<title>remote_task</title>
|
||||
<path fill="#ffe0b2" stroke="black" d="M649.5,-160C649.5,-160 619.5,-160 619.5,-160 613.5,-160 607.5,-154 607.5,-148 607.5,-148 607.5,-136 607.5,-136 607.5,-130 613.5,-124 619.5,-124 619.5,-124 649.5,-124 649.5,-124 655.5,-124 661.5,-130 661.5,-136 661.5,-136 661.5,-148 661.5,-148 661.5,-154 655.5,-160 649.5,-160"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-138.3" font-family="Helvetica,sans-Serif" font-size="11.00">task</text>
|
||||
</g>
|
||||
<!-- watcher->remote_task -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>watcher->remote_task</title>
|
||||
<path fill="none" stroke="#f57c00" d="M386.34,-265.52C412.71,-239.57 464.61,-192.85 518.25,-169 543.15,-157.93 573.17,-151.08 596.34,-147.06"/>
|
||||
<polygon fill="#f57c00" stroke="#f57c00" points="596.63,-150.55 605.93,-145.49 595.5,-143.65 596.63,-150.55"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="468" y="-224.4" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
|
||||
</g>
|
||||
<!-- remote_state -->
|
||||
<g id="node10" class="node">
|
||||
<title>remote_state</title>
|
||||
<path fill="#ffe0b2" stroke="black" d="M649.5,-106C649.5,-106 619.5,-106 619.5,-106 613.5,-106 607.5,-100 607.5,-94 607.5,-94 607.5,-82 607.5,-82 607.5,-76 613.5,-70 619.5,-70 619.5,-70 649.5,-70 649.5,-70 655.5,-70 661.5,-76 661.5,-82 661.5,-82 661.5,-94 661.5,-94 661.5,-100 655.5,-106 649.5,-106"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-84.3" font-family="Helvetica,sans-Serif" font-size="11.00">state</text>
|
||||
</g>
|
||||
<!-- watcher->remote_state -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>watcher->remote_state</title>
|
||||
<path fill="none" stroke="#f57c00" d="M374.68,-265.75C384.38,-239.86 405.12,-192.16 435.75,-161.25 479.42,-117.18 551.93,-99.36 595.96,-92.35"/>
|
||||
<polygon fill="#f57c00" stroke="#f57c00" points="596.42,-95.81 605.81,-90.9 595.41,-88.89 596.42,-95.81"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="468" y="-164.5" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
|
||||
</g>
|
||||
<!-- remote_history -->
|
||||
<g id="node11" class="node">
|
||||
<title>remote_history</title>
|
||||
<path fill="#ffe0b2" stroke="black" d="M663.88,-52C663.88,-52 605.12,-52 605.12,-52 599.12,-52 593.12,-46 593.12,-40 593.12,-40 593.12,-28 593.12,-28 593.12,-22 599.12,-16 605.12,-16 605.12,-16 663.88,-16 663.88,-16 669.88,-16 675.88,-22 675.88,-28 675.88,-28 675.88,-40 675.88,-40 675.88,-46 669.88,-52 663.88,-52"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="634.5" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00">task_history</text>
|
||||
</g>
|
||||
<!-- watcher->remote_history -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>watcher->remote_history</title>
|
||||
<path fill="none" stroke="#f57c00" d="M370.83,-265.73C376.42,-230.55 393.07,-152.59 435.75,-104.25 472.79,-62.29 536.59,-45.39 581.69,-38.59"/>
|
||||
<polygon fill="#f57c00" stroke="#f57c00" points="582.09,-42.07 591.52,-37.24 581.13,-35.13 582.09,-42.07"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="468" y="-107.5" font-family="Helvetica,sans-Serif" font-size="10.00">upsert</text>
|
||||
</g>
|
||||
<!-- resume_token->watcher -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>resume_token->watcher</title>
|
||||
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M552.48,-302.23C516.62,-302.72 473.99,-302.03 435.75,-298 433.7,-297.78 431.62,-297.53 429.53,-297.25"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="430.15,-293.81 419.73,-295.75 429.09,-300.73 430.15,-293.81"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="468" y="-305.07" font-family="Helvetica,sans-Serif" font-size="10.00">resume_after</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
83
docs/architecture/03-deployment.dot
Normal file
83
docs/architecture/03-deployment.dot
Normal file
@@ -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"];
|
||||
}
|
||||
158
docs/architecture/03-deployment.svg
Normal file
158
docs/architecture/03-deployment.svg
Normal file
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 14.1.1 (0)
|
||||
-->
|
||||
<!-- Title: Deployment Pages: 1 -->
|
||||
<svg width="447pt" height="701pt"
|
||||
viewBox="0.00 0.00 447.00 701.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 697.44)">
|
||||
<title>Deployment</title>
|
||||
<polygon fill="white" stroke="none" points="-4,4 -4,-697.44 443.25,-697.44 443.25,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="219.62" y="-674.24" font-family="Helvetica,sans-Serif" font-size="16.00">Deskmeter - Deployment Architecture</text>
|
||||
<g id="clust1" class="cluster">
|
||||
<title>cluster_dev</title>
|
||||
<polygon fill="#e3f2fd" stroke="#e3f2fd" points="8,-476.69 8,-657.94 191,-657.94 191,-476.69 8,-476.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="99.5" y="-638.74" font-family="Helvetica,sans-Serif" font-size="16.00">Development (Local)</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<title>cluster_registry</title>
|
||||
<polygon fill="#f3e5f5" stroke="#f3e5f5" points="40,-307.88 40,-399.5 190,-399.5 190,-307.88 40,-307.88"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="115" y="-380.3" font-family="Helvetica,sans-Serif" font-size="16.00">Private Registry</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<title>cluster_aws</title>
|
||||
<polygon fill="#fff3e0" stroke="#fff3e0" points="227,-8 227,-428.94 397,-428.94 397,-8 227,-8"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="312" y="-409.74" font-family="Helvetica,sans-Serif" font-size="16.00">AWS EC2 (mcrn.ar)</text>
|
||||
</g>
|
||||
<g id="clust4" class="cluster">
|
||||
<title>cluster_gateway</title>
|
||||
<polygon fill="none" stroke="#f57c00" stroke-dasharray="5,2" points="235,-223.12 235,-393.44 389,-393.44 389,-223.12 235,-223.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="312" y="-374.24" font-family="Helvetica,sans-Serif" font-size="16.00">gateway network</text>
|
||||
</g>
|
||||
<g id="clust5" class="cluster">
|
||||
<title>cluster_internal</title>
|
||||
<polygon fill="none" stroke="#388e3c" stroke-dasharray="5,2" points="235,-16 235,-192.38 382,-192.38 382,-16 235,-16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="308.5" y="-173.18" font-family="Helvetica,sans-Serif" font-size="16.00">internal network</text>
|
||||
</g>
|
||||
<!-- source -->
|
||||
<g id="node1" class="node">
|
||||
<title>source</title>
|
||||
<path fill="#bbdefb" stroke="black" d="M171.12,-622.44C171.12,-622.44 38.88,-622.44 38.88,-622.44 32.88,-622.44 26.88,-616.44 26.88,-610.44 26.88,-610.44 26.88,-598.44 26.88,-598.44 26.88,-592.44 32.88,-586.44 38.88,-586.44 38.88,-586.44 171.12,-586.44 171.12,-586.44 177.12,-586.44 183.12,-592.44 183.12,-598.44 183.12,-598.44 183.12,-610.44 183.12,-610.44 183.12,-616.44 177.12,-622.44 171.12,-622.44"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="105" y="-607.49" font-family="Helvetica,sans-Serif" font-size="11.00">Source Code</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="105" y="-593.99" font-family="Helvetica,sans-Serif" font-size="11.00">(/home/mariano/wdir/dm)</text>
|
||||
</g>
|
||||
<!-- docker_build -->
|
||||
<g id="node2" class="node">
|
||||
<title>docker_build</title>
|
||||
<path fill="#bbdefb" stroke="black" d="M168.63,-520.69C168.63,-520.69 72.32,-520.69 72.32,-520.69 66.32,-520.69 56.41,-516.14 52.49,-511.59 52.49,-511.59 37.19,-493.79 37.19,-493.79 33.28,-489.24 35.37,-484.69 41.37,-484.69 41.37,-484.69 137.68,-484.69 137.68,-484.69 143.68,-484.69 153.59,-489.24 157.51,-493.79 157.51,-493.79 172.81,-511.59 172.81,-511.59 176.72,-516.14 174.63,-520.69 168.63,-520.69"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="105" y="-498.99" font-family="Helvetica,sans-Serif" font-size="11.00">docker build</text>
|
||||
</g>
|
||||
<!-- source->docker_build -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>source->docker_build</title>
|
||||
<path fill="none" stroke="black" d="M105,-586.06C105,-571.26 105,-549.69 105,-532.33"/>
|
||||
<polygon fill="black" stroke="black" points="108.5,-532.38 105,-522.38 101.5,-532.38 108.5,-532.38"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="129.38" y="-558.94" font-family="Helvetica,sans-Serif" font-size="10.00">Dockerfile</text>
|
||||
</g>
|
||||
<!-- registry -->
|
||||
<g id="node3" class="node">
|
||||
<title>registry</title>
|
||||
<path fill="#e1bee7" stroke="black" d="M182.25,-359.62C182.25,-362.04 152.11,-364 115,-364 77.89,-364 47.75,-362.04 47.75,-359.62 47.75,-359.62 47.75,-320.25 47.75,-320.25 47.75,-317.84 77.89,-315.88 115,-315.88 152.11,-315.88 182.25,-317.84 182.25,-320.25 182.25,-320.25 182.25,-359.62 182.25,-359.62"/>
|
||||
<path fill="none" stroke="black" d="M182.25,-359.62C182.25,-357.21 152.11,-355.25 115,-355.25 77.89,-355.25 47.75,-357.21 47.75,-359.62"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="115" y="-342.99" font-family="Helvetica,sans-Serif" font-size="11.00">registry.mcrn.ar:5000</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="115" y="-329.49" font-family="Helvetica,sans-Serif" font-size="11.00">(Docker Registry)</text>
|
||||
</g>
|
||||
<!-- docker_build->registry -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>docker_build->registry</title>
|
||||
<path fill="none" stroke="#7b1fa2" d="M106.07,-484.48C107.68,-458.62 110.76,-409.17 112.84,-375.63"/>
|
||||
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="116.31,-376.2 113.44,-366 109.33,-375.76 116.31,-376.2"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="138.06" y="-440.19" font-family="Helvetica,sans-Serif" font-size="10.00">docker push</text>
|
||||
</g>
|
||||
<!-- dmweb_container -->
|
||||
<g id="node5" class="node">
|
||||
<title>dmweb_container</title>
|
||||
<path fill="#ffe0b2" stroke="black" d="M322.5,-267.12C322.5,-267.12 255.5,-267.12 255.5,-267.12 249.5,-267.12 243.5,-261.12 243.5,-255.12 243.5,-255.12 243.5,-243.12 243.5,-243.12 243.5,-237.12 249.5,-231.12 255.5,-231.12 255.5,-231.12 322.5,-231.12 322.5,-231.12 328.5,-231.12 334.5,-237.12 334.5,-243.12 334.5,-243.12 334.5,-255.12 334.5,-255.12 334.5,-261.12 328.5,-267.12 322.5,-267.12"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="289" y="-252.18" font-family="Helvetica,sans-Serif" font-size="11.00">dmweb:latest</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="289" y="-238.68" font-family="Helvetica,sans-Serif" font-size="11.00">(port 10000)</text>
|
||||
</g>
|
||||
<!-- registry->dmweb_container -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>registry->dmweb_container</title>
|
||||
<path fill="none" stroke="#7b1fa2" d="M159.81,-316.07C185.94,-302.73 218.78,-285.97 244.81,-272.68"/>
|
||||
<polygon fill="#7b1fa2" stroke="#7b1fa2" points="246.12,-275.94 253.43,-268.28 242.94,-269.71 246.12,-275.94"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="244.21" y="-288.38" font-family="Helvetica,sans-Serif" font-size="10.00">docker pull</text>
|
||||
</g>
|
||||
<!-- nginx -->
|
||||
<g id="node4" class="node">
|
||||
<title>nginx</title>
|
||||
<path fill="#ffe0b2" stroke="black" d="M356,-357.94C356,-357.94 268,-357.94 268,-357.94 262,-357.94 256,-351.94 256,-345.94 256,-345.94 256,-333.94 256,-333.94 256,-327.94 262,-321.94 268,-321.94 268,-321.94 356,-321.94 356,-321.94 362,-321.94 368,-327.94 368,-333.94 368,-333.94 368,-345.94 368,-345.94 368,-351.94 362,-357.94 356,-357.94"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="312" y="-342.99" font-family="Helvetica,sans-Serif" font-size="11.00">Nginx</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="312" y="-329.49" font-family="Helvetica,sans-Serif" font-size="11.00">(SSL termination)</text>
|
||||
</g>
|
||||
<!-- nginx->dmweb_container -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>nginx->dmweb_container</title>
|
||||
<path fill="none" stroke="#f57c00" d="M307.57,-321.81C304.37,-309.48 299.99,-292.57 296.29,-278.27"/>
|
||||
<polygon fill="#f57c00" stroke="#f57c00" points="299.77,-277.77 293.88,-268.96 293,-279.52 299.77,-277.77"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="328.94" y="-288.38" font-family="Helvetica,sans-Serif" font-size="10.00">proxy_pass</text>
|
||||
</g>
|
||||
<!-- mongo_container -->
|
||||
<g id="node6" class="node">
|
||||
<title>mongo_container</title>
|
||||
<path fill="#c8e6c9" stroke="black" d="M328.5,-152.5C328.5,-154.91 309.45,-156.88 286,-156.88 262.55,-156.88 243.5,-154.91 243.5,-152.5 243.5,-152.5 243.5,-113.12 243.5,-113.12 243.5,-110.71 262.55,-108.75 286,-108.75 309.45,-108.75 328.5,-110.71 328.5,-113.12 328.5,-113.12 328.5,-152.5 328.5,-152.5"/>
|
||||
<path fill="none" stroke="black" d="M328.5,-152.5C328.5,-150.09 309.45,-148.12 286,-148.12 262.55,-148.12 243.5,-150.09 243.5,-152.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="286" y="-135.86" font-family="Helvetica,sans-Serif" font-size="11.00">mongo:7</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="286" y="-122.36" font-family="Helvetica,sans-Serif" font-size="11.00">(port 27017)</text>
|
||||
</g>
|
||||
<!-- dmweb_container->mongo_container -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>dmweb_container->mongo_container</title>
|
||||
<path fill="none" stroke="#388e3c" d="M288.54,-230.76C288.11,-214.37 287.46,-189.3 286.92,-168.77"/>
|
||||
<polygon fill="#388e3c" stroke="#388e3c" points="290.42,-168.71 286.66,-158.81 283.42,-168.9 290.42,-168.71"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="351.78" y="-203.62" font-family="Helvetica,sans-Serif" font-size="10.00">MONGODB_HOST=mongo</text>
|
||||
</g>
|
||||
<!-- mongo_volume -->
|
||||
<g id="node7" class="node">
|
||||
<title>mongo_volume</title>
|
||||
<polygon fill="#dcedc8" stroke="black" points="327,-60 324,-64 303,-64 300,-60 245,-60 245,-24 327,-24 327,-60"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="286" y="-45.05" font-family="Helvetica,sans-Serif" font-size="11.00">mongo-data</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="286" y="-31.55" font-family="Helvetica,sans-Serif" font-size="11.00">(volume)</text>
|
||||
</g>
|
||||
<!-- mongo_container->mongo_volume -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>mongo_container->mongo_volume</title>
|
||||
<path fill="none" stroke="black" stroke-dasharray="5,2" d="M286,-108.48C286,-97.15 286,-83.43 286,-71.49"/>
|
||||
<polygon fill="black" stroke="black" points="289.5,-71.8 286,-61.8 282.5,-71.8 289.5,-71.8"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="302.5" y="-81.25" font-family="Helvetica,sans-Serif" font-size="10.00">persist</text>
|
||||
</g>
|
||||
<!-- dns -->
|
||||
<g id="node8" class="node">
|
||||
<title>dns</title>
|
||||
<path fill="#ffcdd2" stroke="black" d="M307.48,-534.33C307.48,-534.33 210.27,-506.04 210.27,-506.04 204.51,-504.36 204.51,-501.01 210.27,-499.33 210.27,-499.33 307.48,-471.04 307.48,-471.04 313.24,-469.36 324.76,-469.36 330.52,-471.04 330.52,-471.04 427.73,-499.33 427.73,-499.33 433.49,-501.01 433.49,-504.36 427.73,-506.04 427.73,-506.04 330.52,-534.33 330.52,-534.33 324.76,-536.01 313.24,-536.01 307.48,-534.33"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="319" y="-505.74" font-family="Helvetica,sans-Serif" font-size="11.00">deskmeter.mcrn.ar</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="319" y="-492.24" font-family="Helvetica,sans-Serif" font-size="11.00">(DNS)</text>
|
||||
</g>
|
||||
<!-- dns->nginx -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>dns->nginx</title>
|
||||
<path fill="none" stroke="#1976d2" d="M317.53,-467.99C316.26,-438.8 314.44,-397.03 313.24,-369.47"/>
|
||||
<polygon fill="#1976d2" stroke="#1976d2" points="316.75,-369.6 312.82,-359.76 309.76,-369.9 316.75,-369.6"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="332.29" y="-440.19" font-family="Helvetica,sans-Serif" font-size="10.00">HTTPS</text>
|
||||
</g>
|
||||
<!-- internet -->
|
||||
<g id="node9" class="node">
|
||||
<title>internet</title>
|
||||
<path fill="#f5f5f5" stroke="black" d="M364.5,-622.44C364.5,-622.44 273.5,-622.44 273.5,-622.44 267.5,-622.44 261.5,-616.44 261.5,-610.44 261.5,-610.44 261.5,-598.44 261.5,-598.44 261.5,-592.44 267.5,-586.44 273.5,-586.44 273.5,-586.44 364.5,-586.44 364.5,-586.44 370.5,-586.44 376.5,-592.44 376.5,-598.44 376.5,-598.44 376.5,-610.44 376.5,-610.44 376.5,-616.44 370.5,-622.44 364.5,-622.44"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="319" y="-607.49" font-family="Helvetica,sans-Serif" font-size="11.00">Internet</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="319" y="-593.99" font-family="Helvetica,sans-Serif" font-size="11.00">(Portfolio Visitors)</text>
|
||||
</g>
|
||||
<!-- internet->dns -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>internet->dns</title>
|
||||
<path fill="none" stroke="#1976d2" d="M319,-586.06C319,-575.87 319,-562.48 319,-549.46"/>
|
||||
<polygon fill="#1976d2" stroke="#1976d2" points="322.5,-549.72 319,-539.72 315.5,-549.72 322.5,-549.72"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
115
docs/architecture/graph.html
Normal file
115
docs/architecture/graph.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Graph Viewer - Deskmeter</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body class="graph-viewer">
|
||||
<header class="graph-header">
|
||||
<a href="index.html" class="back-link">← Index</a>
|
||||
<div class="nav-controls">
|
||||
<button onclick="navigate(-1)" id="btn-prev" title="Previous (←)">◀</button>
|
||||
<span id="nav-position">1 / 3</span>
|
||||
<button onclick="navigate(1)" id="btn-next" title="Next (→)">▶</button>
|
||||
</div>
|
||||
<h1 id="graph-title">Loading...</h1>
|
||||
<div class="graph-controls">
|
||||
<button onclick="setMode('fit')">Fit</button>
|
||||
<button onclick="setMode('fit-width')">Width</button>
|
||||
<button onclick="setMode('fit-height')">Height</button>
|
||||
<button onclick="setMode('actual-size')">100%</button>
|
||||
<button onclick="downloadSvg()">↓ SVG</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="graph-container" id="graph-container">
|
||||
<img id="graph-img" src="" alt="Graph">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const graphOrder = [
|
||||
'01-system-overview',
|
||||
'02-data-sync',
|
||||
'03-deployment'
|
||||
];
|
||||
|
||||
const graphs = {
|
||||
'01-system-overview': {
|
||||
title: 'System Overview',
|
||||
file: '01-system-overview.svg'
|
||||
},
|
||||
'02-data-sync': {
|
||||
title: 'Data Sync Architecture',
|
||||
file: '02-data-sync.svg'
|
||||
},
|
||||
'03-deployment': {
|
||||
title: 'Deployment Architecture',
|
||||
file: '03-deployment.svg'
|
||||
}
|
||||
};
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let graphKey = params.get('g') || '01-system-overview';
|
||||
let currentIndex = graphOrder.indexOf(graphKey);
|
||||
if (currentIndex === -1) currentIndex = 0;
|
||||
|
||||
function loadGraph(key) {
|
||||
const graph = graphs[key];
|
||||
document.getElementById('graph-title').textContent = graph.title;
|
||||
document.getElementById('graph-img').src = graph.file;
|
||||
document.title = graph.title + ' - Deskmeter';
|
||||
history.replaceState(null, '', '?g=' + key);
|
||||
graphKey = key;
|
||||
updateNavHints();
|
||||
}
|
||||
|
||||
function updateNavHints() {
|
||||
const idx = graphOrder.indexOf(graphKey);
|
||||
const prevBtn = document.getElementById('btn-prev');
|
||||
const nextBtn = document.getElementById('btn-next');
|
||||
prevBtn.disabled = idx === 0;
|
||||
nextBtn.disabled = idx === graphOrder.length - 1;
|
||||
document.getElementById('nav-position').textContent = (idx + 1) + ' / ' + graphOrder.length;
|
||||
}
|
||||
|
||||
function navigate(direction) {
|
||||
const idx = graphOrder.indexOf(graphKey);
|
||||
const newIdx = idx + direction;
|
||||
if (newIdx >= 0 && newIdx < graphOrder.length) {
|
||||
currentIndex = newIdx;
|
||||
loadGraph(graphOrder[newIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
const container = document.getElementById('graph-container');
|
||||
container.className = 'graph-container ' + mode;
|
||||
}
|
||||
|
||||
function downloadSvg() {
|
||||
const graph = graphs[graphKey];
|
||||
const link = document.createElement('a');
|
||||
link.href = graph.file;
|
||||
link.download = graph.file;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
navigate(-1);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
navigate(1);
|
||||
} else if (e.key === 'Escape') {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
loadGraph(graphOrder[currentIndex]);
|
||||
setMode('fit');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
180
docs/architecture/index.html
Normal file
180
docs/architecture/index.html
Normal file
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deskmeter - Architecture & Design</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Deskmeter</h1>
|
||||
<p class="subtitle">Productivity Tracking - Architecture Documentation</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="graph-section" id="overview">
|
||||
<div class="graph-header-row">
|
||||
<h2>System Overview</h2>
|
||||
<a href="graph.html?g=01-system-overview" class="view-btn">View Full</a>
|
||||
</div>
|
||||
<a href="graph.html?g=01-system-overview" class="graph-preview">
|
||||
<img src="01-system-overview.svg" alt="System Overview">
|
||||
</a>
|
||||
<div class="graph-details">
|
||||
<p>High-level architecture showing local tracking, data sync, and remote portfolio deployment.</p>
|
||||
<h4>Key Components</h4>
|
||||
<ul>
|
||||
<li><strong>dmcore</strong>: Workspace tracking daemon using wmctrl (X11)</li>
|
||||
<li><strong>dmweb</strong>: Flask web dashboard with calendar and switches views</li>
|
||||
<li><strong>dmsync</strong>: Change Streams daemon pushing data to remote MongoDB</li>
|
||||
<li><strong>GNOME Extension</strong>: Panel indicator showing current task</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="graph-section" id="data-sync">
|
||||
<div class="graph-header-row">
|
||||
<h2>Data Sync Architecture</h2>
|
||||
<a href="graph.html?g=02-data-sync" class="view-btn">View Full</a>
|
||||
</div>
|
||||
<a href="graph.html?g=02-data-sync" class="graph-preview">
|
||||
<img src="02-data-sync.svg" alt="Data Sync">
|
||||
</a>
|
||||
<div class="graph-details">
|
||||
<p>Real-time sync using MongoDB Change Streams with resume token persistence.</p>
|
||||
<h4>Collections Synced</h4>
|
||||
<table class="details-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Collection</th>
|
||||
<th>Purpose</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>switch</code></td>
|
||||
<td>Workspace switch events with timestamps and durations</td>
|
||||
<td>~33k docs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>task</code></td>
|
||||
<td>Current task definitions from task file</td>
|
||||
<td>~50 docs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>task_history</code></td>
|
||||
<td>Cached historic task path lookups</td>
|
||||
<td>~200 docs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>state</code></td>
|
||||
<td>Current workspace and task state</td>
|
||||
<td>1 doc</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>Why Change Streams?</h4>
|
||||
<ul>
|
||||
<li>Reacts to actual changes (not polling)</li>
|
||||
<li>Resume token ensures no data loss on restart</li>
|
||||
<li>Native MongoDB feature (requires replica set)</li>
|
||||
<li>Clean, intentional architecture for portfolio</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="graph-section" id="deployment">
|
||||
<div class="graph-header-row">
|
||||
<h2>Deployment Architecture</h2>
|
||||
<a href="graph.html?g=03-deployment" class="view-btn">View Full</a>
|
||||
</div>
|
||||
<a href="graph.html?g=03-deployment" class="graph-preview">
|
||||
<img src="03-deployment.svg" alt="Deployment">
|
||||
</a>
|
||||
<div class="graph-details">
|
||||
<p>Docker Compose deployment on AWS EC2 with Nginx reverse proxy.</p>
|
||||
<h4>Networks</h4>
|
||||
<ul>
|
||||
<li><strong>gateway</strong>: External network connecting Nginx to dmweb</li>
|
||||
<li><strong>internal</strong>: Private network for dmweb ↔ MongoDB communication</li>
|
||||
</ul>
|
||||
<h4>Deployment Flow</h4>
|
||||
<ul>
|
||||
<li>Build Docker image locally</li>
|
||||
<li>Push to private registry (registry.mcrn.ar)</li>
|
||||
<li>Pull and deploy on AWS EC2</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="findings-section">
|
||||
<h2>Design Decisions</h2>
|
||||
<div class="findings-grid">
|
||||
<article class="finding-card">
|
||||
<h3>Bare Metal Core</h3>
|
||||
<p>dmcore runs on bare metal (not Docker) because it requires direct OS access for workspace detection via wmctrl and X11.</p>
|
||||
</article>
|
||||
<article class="finding-card">
|
||||
<h3>Single-Node Replica Set</h3>
|
||||
<p>Local MongoDB configured as replica set (rs0) to enable Change Streams. No actual replication, just API requirement.</p>
|
||||
</article>
|
||||
<article class="finding-card">
|
||||
<h3>Push Sync Model</h3>
|
||||
<p>Local machine pushes to remote (not pull). MongoDB on remote is not exposed to internet - only accessible from the sync daemon.</p>
|
||||
</article>
|
||||
<article class="finding-card">
|
||||
<h3>Portfolio-Ready</h3>
|
||||
<p>Remote instance shows historical data. "Current task" reflects last-synced state, not real-time.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tech-section">
|
||||
<h2>Technology Stack</h2>
|
||||
<div class="tech-grid">
|
||||
<div class="tech-column">
|
||||
<h3>Core</h3>
|
||||
<ul>
|
||||
<li>Python 3.11</li>
|
||||
<li>Flask</li>
|
||||
<li>PyMongo</li>
|
||||
<li>Jinja2 Templates</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tech-column">
|
||||
<h3>Data</h3>
|
||||
<ul>
|
||||
<li>MongoDB 7</li>
|
||||
<li>Change Streams</li>
|
||||
<li>Replica Set</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tech-column">
|
||||
<h3>Infrastructure</h3>
|
||||
<ul>
|
||||
<li>Docker</li>
|
||||
<li>Docker Compose</li>
|
||||
<li>Nginx</li>
|
||||
<li>AWS EC2</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tech-column">
|
||||
<h3>OS Integration</h3>
|
||||
<ul>
|
||||
<li>wmctrl (X11)</li>
|
||||
<li>GNOME Shell Extension</li>
|
||||
<li>systemd</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Deskmeter - Architecture Documentation</p>
|
||||
<p class="date">Generated: <time datetime="2025-01">January 2025</time></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
348
docs/architecture/styles.css
Normal file
348
docs/architecture/styles.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
# Deskmeter GNOME Task Indicator
|
||||
|
||||
A GNOME Shell extension that displays your current deskmeter task in the top panel, positioned to the left of the panel indicators.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- GNOME Shell (versions 40-47 supported)
|
||||
- Deskmeter web server running on `http://localhost:10000`
|
||||
- The `/api/current_task` endpoint must be accessible
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the extension to your GNOME extensions directory:
|
||||
|
||||
```bash
|
||||
cp -r /home/mariano/wdir/dm/gnome-extension/deskmeter-indicator@local ~/.local/share/gnome-shell/extensions/
|
||||
```
|
||||
|
||||
2. Restart GNOME Shell:
|
||||
- On X11: Press `Alt+F2`, type `r`, and press Enter
|
||||
- On Wayland: Log out and log back in
|
||||
|
||||
3. Enable the extension:
|
||||
|
||||
```bash
|
||||
gnome-extensions enable deskmeter-indicator@local
|
||||
```
|
||||
|
||||
Or use GNOME Extensions app (install with `sudo apt install gnome-shell-extension-prefs` if needed).
|
||||
|
||||
## Configuration
|
||||
|
||||
The extension updates automatically when you switch workspaces. It waits 2.2 seconds after a workspace switch to allow dmcore (which polls every 2 seconds) to detect the change and update MongoDB.
|
||||
|
||||
You can adjust the delay in `extension.js`:
|
||||
|
||||
```javascript
|
||||
const DEBOUNCE_DELAY = 2200; // milliseconds
|
||||
```
|
||||
|
||||
The API URL is set to:
|
||||
|
||||
```javascript
|
||||
const DESKMETER_API_URL = 'http://localhost:10000/api/current_task';
|
||||
```
|
||||
|
||||
## Uninstallation
|
||||
|
||||
```bash
|
||||
gnome-extensions disable deskmeter-indicator@local
|
||||
rm -rf ~/.local/share/gnome-shell/extensions/deskmeter-indicator@local
|
||||
```
|
||||
|
||||
Then restart GNOME Shell.
|
||||
|
||||
## Updating the Extension
|
||||
|
||||
After making changes to the extension code:
|
||||
|
||||
```bash
|
||||
# Use the update script
|
||||
cd /home/mariano/wdir/dm/gnome-extension
|
||||
./update.sh
|
||||
|
||||
# Then restart GNOME Shell (X11 only)
|
||||
Alt+F2, type: r, press Enter
|
||||
|
||||
# On Wayland: log out and back in
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
cp -r /home/mariano/wdir/dm/gnome-extension/deskmeter-indicator@local \
|
||||
~/.local/share/gnome-shell/extensions/
|
||||
# Then restart GNOME Shell
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Extension not showing
|
||||
|
||||
1. Check if the extension is enabled:
|
||||
```bash
|
||||
gnome-extensions list --enabled
|
||||
```
|
||||
|
||||
2. Check for errors:
|
||||
```bash
|
||||
journalctl -f -o cat /usr/bin/gnome-shell
|
||||
```
|
||||
|
||||
3. Try disabling and re-enabling:
|
||||
```bash
|
||||
gnome-extensions disable deskmeter-indicator@local
|
||||
gnome-extensions enable deskmeter-indicator@local
|
||||
```
|
||||
|
||||
### Shows "offline" or "error"
|
||||
|
||||
- Ensure dmweb Flask server is running on port 10000
|
||||
- Test the API endpoint:
|
||||
```bash
|
||||
curl http://localhost:10000/api/current_task
|
||||
```
|
||||
Should return JSON like: `{"task_id":"12345678","task_path":"work/default"}`
|
||||
|
||||
### Changes not appearing
|
||||
|
||||
- Make sure you copied files after editing
|
||||
- GNOME Shell must be restarted (no way around this)
|
||||
- Check logs for JavaScript errors: `journalctl -b -o cat /usr/bin/gnome-shell | grep deskmeter`
|
||||
|
||||
### Debug with Looking Glass
|
||||
|
||||
Press `Alt+F2`, type `lg`, press Enter. Go to Extensions tab to see if the extension loaded and check for errors.
|
||||
|
||||
### Task path too long
|
||||
|
||||
The extension automatically truncates paths longer than 40 characters, showing only the last two segments with a `.../ ` prefix.
|
||||
@@ -1,132 +0,0 @@
|
||||
import GObject from 'gi://GObject';
|
||||
import St from 'gi://St';
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Clutter from 'gi://Clutter';
|
||||
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||
|
||||
const DESKMETER_API_URL = 'http://localhost:10000/api/current_task';
|
||||
const DEBOUNCE_DELAY = 2200; // Wait 2.2s after workspace switch (dmcore polls every 2s)
|
||||
|
||||
const TaskIndicator = GObject.registerClass(
|
||||
class TaskIndicator extends PanelMenu.Button {
|
||||
_init() {
|
||||
super._init(0.0, 'Deskmeter Task Indicator', false);
|
||||
|
||||
// Create label for task display
|
||||
this._label = new St.Label({
|
||||
text: 'loading...',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
style_class: 'deskmeter-task-label'
|
||||
});
|
||||
|
||||
this.add_child(this._label);
|
||||
|
||||
this._debounceTimeout = null;
|
||||
this._workspaceManager = global.workspace_manager;
|
||||
|
||||
// Connect to workspace switch signal
|
||||
this._workspaceSwitchedId = this._workspaceManager.connect(
|
||||
'workspace-switched',
|
||||
this._onWorkspaceSwitched.bind(this)
|
||||
);
|
||||
|
||||
// Initial update
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
|
||||
_onWorkspaceSwitched() {
|
||||
// Debounce updates - dmcore takes ~2 seconds to detect and update
|
||||
// We wait a bit to ensure the task has been updated in MongoDB
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
|
||||
_scheduleUpdate() {
|
||||
// Clear any pending update
|
||||
if (this._debounceTimeout) {
|
||||
GLib.source_remove(this._debounceTimeout);
|
||||
}
|
||||
|
||||
// Schedule new update
|
||||
this._debounceTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, DEBOUNCE_DELAY, () => {
|
||||
this._updateTask();
|
||||
this._debounceTimeout = null;
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
}
|
||||
|
||||
_updateTask() {
|
||||
try {
|
||||
// Create HTTP request
|
||||
let file = Gio.File.new_for_uri(DESKMETER_API_URL);
|
||||
file.load_contents_async(null, (source, result) => {
|
||||
try {
|
||||
let [success, contents] = source.load_contents_finish(result);
|
||||
if (success) {
|
||||
let decoder = new TextDecoder('utf-8');
|
||||
let data = JSON.parse(decoder.decode(contents));
|
||||
|
||||
// Update label with task path
|
||||
let displayText = data.task_path || 'no task';
|
||||
|
||||
// Optionally truncate long paths
|
||||
if (displayText.length > 40) {
|
||||
let parts = displayText.split('/');
|
||||
if (parts.length > 2) {
|
||||
displayText = '.../' + parts.slice(-2).join('/');
|
||||
} else {
|
||||
displayText = displayText.substring(0, 37) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
this._label.set_text(displayText);
|
||||
}
|
||||
} catch (e) {
|
||||
this._label.set_text('error');
|
||||
logError(e, 'Failed to parse deskmeter response');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this._label.set_text('offline');
|
||||
logError(e, 'Failed to fetch deskmeter task');
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._debounceTimeout) {
|
||||
GLib.source_remove(this._debounceTimeout);
|
||||
this._debounceTimeout = null;
|
||||
}
|
||||
|
||||
if (this._workspaceSwitchedId) {
|
||||
this._workspaceManager.disconnect(this._workspaceSwitchedId);
|
||||
this._workspaceSwitchedId = null;
|
||||
}
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
export default class Extension {
|
||||
constructor() {
|
||||
this._indicator = null;
|
||||
}
|
||||
|
||||
enable() {
|
||||
this._indicator = new TaskIndicator();
|
||||
|
||||
// Add to panel - position after workspace indicator
|
||||
// Panel boxes: left, center, right
|
||||
// We'll add it to the left panel, after other items
|
||||
Main.panel.addToStatusArea('deskmeter-task-indicator', this._indicator, 1, 'left');
|
||||
}
|
||||
|
||||
disable() {
|
||||
if (this._indicator) {
|
||||
this._indicator.destroy();
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
run_task_window.sh
Normal file
22
run_task_window.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Launcher for Deskmeter Task Window with always-on-top and sticky properties
|
||||
|
||||
# Start the task window in background
|
||||
python3 task_window.py &
|
||||
TASK_PID=$!
|
||||
|
||||
# Wait for window to appear
|
||||
sleep 2
|
||||
|
||||
# Find the window and set properties
|
||||
# Make it always on top and visible on all workspaces (sticky)
|
||||
wmctrl -r "Deskmeter Task" -b add,above,sticky
|
||||
|
||||
echo "Task window started (PID: $TASK_PID)"
|
||||
echo "Window set to: always-on-top + visible on all workspaces"
|
||||
echo ""
|
||||
echo "To close: kill $TASK_PID"
|
||||
echo "Or just close the window normally"
|
||||
|
||||
# Keep script running to show PID
|
||||
wait $TASK_PID
|
||||
Reference in New Issue
Block a user