deployment, frontend updates
This commit is contained in:
97
dmapp/dmdb/sync.py
Normal file
97
dmapp/dmdb/sync.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
dmsync - MongoDB Change Streams sync daemon
|
||||
Watches local deskmeter database and pushes changes to remote MongoDB
|
||||
|
||||
Requires local MongoDB to be configured as a replica set.
|
||||
Uses resume tokens to continue from last position after restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import PyMongoError
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
|
||||
)
|
||||
log = logging.getLogger("dmsync")
|
||||
|
||||
RESUME_TOKEN_FILE = Path.home() / ".dmsync-resume-token"
|
||||
REMOTE_HOST = os.environ.get("DMSYNC_REMOTE_HOST", "mcrn.ar")
|
||||
REMOTE_PORT = int(os.environ.get("DMSYNC_REMOTE_PORT", 27017))
|
||||
COLLECTIONS = ("switch", "task", "task_history", "state")
|
||||
|
||||
|
||||
def load_resume_token():
|
||||
"""Load resume token from file if exists."""
|
||||
if RESUME_TOKEN_FILE.exists():
|
||||
try:
|
||||
return json.loads(RESUME_TOKEN_FILE.read_text())
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
log.warning(f"Failed to load resume token: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_resume_token(token):
|
||||
"""Persist resume token to file."""
|
||||
try:
|
||||
RESUME_TOKEN_FILE.write_text(json.dumps(token))
|
||||
except IOError as e:
|
||||
log.error(f"Failed to save resume token: {e}")
|
||||
|
||||
|
||||
def sync():
|
||||
"""Main sync loop using Change Streams."""
|
||||
log.info(f"Connecting to local MongoDB...")
|
||||
local = MongoClient()
|
||||
|
||||
log.info(f"Connecting to remote MongoDB at {REMOTE_HOST}:{REMOTE_PORT}...")
|
||||
remote = MongoClient(REMOTE_HOST, REMOTE_PORT)
|
||||
|
||||
local_db = local.deskmeter
|
||||
remote_db = remote.deskmeter
|
||||
|
||||
resume_token = load_resume_token()
|
||||
if resume_token:
|
||||
log.info("Resuming from saved token")
|
||||
|
||||
watch_kwargs = {"resume_after": resume_token} if resume_token else {}
|
||||
|
||||
# Watch for inserts, updates, and replaces on the database
|
||||
pipeline = [{"$match": {"operationType": {"$in": ["insert", "update", "replace"]}}}]
|
||||
|
||||
log.info(f"Watching collections: {', '.join(COLLECTIONS)}")
|
||||
|
||||
try:
|
||||
with local_db.watch(pipeline, **watch_kwargs) as stream:
|
||||
for change in stream:
|
||||
collection = change["ns"]["coll"]
|
||||
|
||||
if collection not in COLLECTIONS:
|
||||
continue
|
||||
|
||||
doc = change.get("fullDocument")
|
||||
if not doc:
|
||||
continue
|
||||
|
||||
# Upsert to remote
|
||||
result = remote_db[collection].replace_one(
|
||||
{"_id": doc["_id"]}, doc, upsert=True
|
||||
)
|
||||
|
||||
action = "inserted" if result.upserted_id else "updated"
|
||||
log.info(f"{collection}: {action} {doc['_id']}")
|
||||
|
||||
save_resume_token(stream.resume_token)
|
||||
|
||||
except PyMongoError as e:
|
||||
log.error(f"MongoDB error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
log.info("Starting dmsync daemon")
|
||||
sync()
|
||||
@@ -1,8 +1,18 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from flask import Blueprint, render_template, jsonify
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
|
||||
from .get_period_times import get_period_totals, task_or_none, timezone, get_work_period_totals, get_current_task_info, convert_seconds, get_task_time_seconds, get_task_blocks_calendar, get_raw_switches
|
||||
from .get_period_times import (
|
||||
convert_seconds,
|
||||
get_current_task_info,
|
||||
get_period_totals,
|
||||
get_raw_switches,
|
||||
get_task_blocks_calendar,
|
||||
get_task_time_seconds,
|
||||
get_work_period_totals,
|
||||
task_or_none,
|
||||
timezone,
|
||||
)
|
||||
|
||||
dmbp = Blueprint("deskmeter", __name__, url_prefix="/", template_folder="templates")
|
||||
|
||||
@@ -22,7 +32,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
from flask import request
|
||||
|
||||
task = None
|
||||
grid = int(request.args.get('grid', 1)) # Grid hours: 1, 3, or 6
|
||||
grid = int(request.args.get("grid", 1)) # Grid hours: 1, 3, or 6
|
||||
if grid not in [1, 3, 6]:
|
||||
grid = 1
|
||||
|
||||
@@ -33,12 +43,16 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
if not day:
|
||||
day = datetime.today().day
|
||||
|
||||
base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
base_date = datetime(year, month, day).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
)
|
||||
|
||||
if scope == "daily":
|
||||
start = base_date
|
||||
end = base_date.replace(hour=23, minute=59, second=59)
|
||||
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60, grid_hours=grid)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=60, grid_hours=grid
|
||||
)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
days = [base_date]
|
||||
@@ -46,7 +60,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
elif scope == "weekly":
|
||||
start = base_date - timedelta(days=base_date.weekday())
|
||||
end = start + timedelta(days=6, hours=23, minutes=59, seconds=59)
|
||||
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=300, grid_hours=grid)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=300, grid_hours=grid
|
||||
)
|
||||
prev_date = start - timedelta(days=7)
|
||||
next_date = start + timedelta(days=7)
|
||||
days = [start + timedelta(days=i) for i in range(7)]
|
||||
@@ -57,7 +73,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
end = datetime(year + 1, 1, 1, tzinfo=timezone) - timedelta(seconds=1)
|
||||
else:
|
||||
end = datetime(year, month + 1, 1, tzinfo=timezone) - timedelta(seconds=1)
|
||||
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=600, grid_hours=grid)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=600, grid_hours=grid
|
||||
)
|
||||
if month == 1:
|
||||
prev_date = datetime(year - 1, 12, 1, tzinfo=timezone)
|
||||
else:
|
||||
@@ -75,7 +93,9 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
scope = "daily"
|
||||
start = base_date
|
||||
end = base_date.replace(hour=23, minute=59, second=59)
|
||||
blocks = get_task_blocks_calendar(start, end, task, min_block_seconds=60, grid_hours=grid)
|
||||
blocks = get_task_blocks_calendar(
|
||||
start, end, task, min_block_seconds=60, grid_hours=grid
|
||||
)
|
||||
prev_date = base_date - timedelta(days=1)
|
||||
next_date = base_date + timedelta(days=1)
|
||||
days = [base_date]
|
||||
@@ -91,7 +111,7 @@ def calendar_view(scope="daily", year=None, month=None, day=None):
|
||||
next_date=next_date,
|
||||
days=days,
|
||||
grid=grid,
|
||||
auto_refresh=False
|
||||
auto_refresh=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -111,7 +131,9 @@ def switches_view(scope="daily", year=None, month=None, day=None):
|
||||
if not day:
|
||||
day = datetime.today().day
|
||||
|
||||
base_date = datetime(year, month, day).replace(hour=0, minute=0, second=0, tzinfo=timezone)
|
||||
base_date = datetime(year, month, day).replace(
|
||||
hour=0, minute=0, second=0, tzinfo=timezone
|
||||
)
|
||||
|
||||
if scope == "daily":
|
||||
start = base_date
|
||||
@@ -157,7 +179,7 @@ def switches_view(scope="daily", year=None, month=None, day=None):
|
||||
base_date=base_date,
|
||||
prev_date=prev_date,
|
||||
next_date=next_date,
|
||||
auto_refresh=False
|
||||
auto_refresh=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -176,13 +198,17 @@ def index(task=None):
|
||||
|
||||
# Get current task info
|
||||
current_task_id, current_task_path = get_current_task_info()
|
||||
current_task_time = None
|
||||
if current_task_id:
|
||||
total_seconds = get_task_time_seconds(start, end, current_task_id)
|
||||
if total_seconds > 0:
|
||||
current_task_time = convert_seconds(total_seconds)
|
||||
|
||||
return render_template("main.html", rows=rows, current_task_path=current_task_path, current_task_time=current_task_time, auto_refresh=True)
|
||||
# Get all tasks worked on today
|
||||
task_rows = get_work_period_totals(start, end)
|
||||
|
||||
return render_template(
|
||||
"main.html",
|
||||
rows=rows,
|
||||
current_task_path=current_task_path,
|
||||
task_rows=task_rows,
|
||||
auto_refresh=True,
|
||||
)
|
||||
|
||||
|
||||
@dmbp.route("/api/current_task")
|
||||
@@ -191,10 +217,9 @@ def api_current_task():
|
||||
JSON API endpoint returning current task information
|
||||
"""
|
||||
current_task_id, current_task_path = get_current_task_info()
|
||||
return jsonify({
|
||||
"task_id": current_task_id,
|
||||
"task_path": current_task_path or "no task"
|
||||
})
|
||||
return jsonify(
|
||||
{"task_id": current_task_id, "task_path": current_task_path or "no task"}
|
||||
)
|
||||
|
||||
|
||||
@dmbp.route("/api/today")
|
||||
@@ -212,13 +237,16 @@ def api_today(task=None):
|
||||
|
||||
# Get current task info
|
||||
current_task_id, current_task_path = get_current_task_info()
|
||||
current_task_time = None
|
||||
if current_task_id:
|
||||
total_seconds = get_task_time_seconds(start, end, current_task_id)
|
||||
if total_seconds > 0:
|
||||
current_task_time = convert_seconds(total_seconds)
|
||||
|
||||
return render_template("main_content.html", rows=rows, current_task_path=current_task_path, current_task_time=current_task_time)
|
||||
# Get all tasks worked on today
|
||||
task_rows = get_work_period_totals(start, end)
|
||||
|
||||
return render_template(
|
||||
"main_content.html",
|
||||
rows=rows,
|
||||
current_task_path=current_task_path,
|
||||
task_rows=task_rows,
|
||||
)
|
||||
|
||||
|
||||
@dmbp.route("/day/<int:month>/<int:day>")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
@@ -8,7 +9,7 @@ from pymongo import MongoClient
|
||||
timezone = ZoneInfo("America/Argentina/Buenos_Aires")
|
||||
utctz = ZoneInfo("UTC")
|
||||
|
||||
client = MongoClient()
|
||||
client = MongoClient(os.environ.get("MONGODB_HOST", "localhost"))
|
||||
db = client.deskmeter
|
||||
switches = db.switch
|
||||
tasks = db.task
|
||||
|
||||
@@ -1,80 +1,104 @@
|
||||
<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 */
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
<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;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #444;
|
||||
}
|
||||
|
||||
.nav-bar a {
|
||||
text-decoration: none;
|
||||
color: #6b9bd1;
|
||||
font-size: 11pt;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
.nav-bar a {
|
||||
text-decoration: none;
|
||||
color: #6b9bd1;
|
||||
font-size: 12pt;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.nav-bar a:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
.nav-bar a:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.grey {
|
||||
color: #888;
|
||||
}
|
||||
.grey {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: #6b9bd1;
|
||||
}
|
||||
.blue {
|
||||
color: #6b9bd1;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 84pt
|
||||
}
|
||||
td {
|
||||
padding-right: 100px;
|
||||
}
|
||||
</style>
|
||||
table {
|
||||
font-size: clamp(14pt, 3vw, 28pt);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
{% 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();
|
||||
}
|
||||
td {
|
||||
padding: 0.1em 0.5em;
|
||||
}
|
||||
|
||||
// Auto-refresh every 5 seconds using AJAX
|
||||
setInterval(refreshData, 5000);
|
||||
</script>
|
||||
{% endif %}
|
||||
td:first-child {
|
||||
text-align: right;
|
||||
padding-right: 0.3em;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
text-align: left;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.workspace-table {
|
||||
font-size: clamp(10pt, 2vw, 18pt);
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
.workspace-table td {
|
||||
padding: 0.05em 0.4em;
|
||||
}
|
||||
</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) -->
|
||||
|
||||
@@ -87,9 +111,7 @@
|
||||
</div>
|
||||
|
||||
<div id="content-container">
|
||||
{% block content %}
|
||||
{% include 'main_content.html' %}
|
||||
{% endblock %}
|
||||
{% block content %} {% include 'main_content.html' %} {% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</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: #e0e0e0;">{{ current_task_path }}</div>
|
||||
<div style="color: #999; 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>
|
||||
|
||||
Reference in New Issue
Block a user