diff --git a/core/clients/xmpp.py b/core/clients/xmpp.py index 34394aa..f354bf9 100644 --- a/core/clients/xmpp.py +++ b/core/clients/xmpp.py @@ -1033,7 +1033,7 @@ class XMPPComponent(ComponentXMPP): sender="XMPP", text=body, ts=int(now().timestamp() * 1000), - # outgoing=detail.is_outgoing_message, ????????? TODO: + outgoing=True, ) self.log.info("Stored a message sent from XMPP in the history.") diff --git a/core/templates/partials/compose-panel.html b/core/templates/partials/compose-panel.html index 575e794..6e790b3 100644 --- a/core/templates/partials/compose-panel.html +++ b/core/templates/partials/compose-panel.html @@ -284,6 +284,17 @@ {% endif %}

{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %} + {% if msg.read_ts %} + + + {{ msg.read_display }} + + {% elif msg.delivered_ts %} + + + {{ msg.delivered_display }} + + {% endif %}

@@ -538,6 +549,18 @@ #{{ panel_id }} .compose-msg-meta { margin: 0; } + #{{ panel_id }} .compose-ticks { + display: inline-flex; + align-items: center; + gap: 0.22rem; + margin-left: 0.4rem; + color: #6b7787; + font-size: 0.72rem; + } + #{{ panel_id }} .compose-tick-time { + font-size: 0.66rem; + color: #616161; + } #{{ panel_id }} .compose-platform-switch { margin-top: 0.32rem; } @@ -1736,7 +1759,41 @@ if (msg.author) { metaText += " · " + String(msg.author); } - meta.textContent = metaText; + meta.textContent = metaText; + // Render delivery/read ticks and a small time label when available. + if (msg.read_ts) { + const tickWrap = document.createElement("span"); + tickWrap.className = "compose-ticks"; + tickWrap.title = "Read at " + String(msg.read_display || msg.read_ts || ""); + const icon = document.createElement("span"); + icon.className = "icon is-small"; + const i = document.createElement("i"); + i.className = "fa-solid fa-check-double has-text-info"; + icon.appendChild(i); + const timeSpan = document.createElement("span"); + timeSpan.className = "compose-tick-time"; + timeSpan.textContent = String(msg.read_display || ""); + tickWrap.appendChild(icon); + tickWrap.appendChild(timeSpan); + meta.appendChild(document.createTextNode(" ")); + meta.appendChild(tickWrap); + } else if (msg.delivered_ts) { + const tickWrap = document.createElement("span"); + tickWrap.className = "compose-ticks"; + tickWrap.title = "Delivered at " + String(msg.delivered_display || msg.delivered_ts || ""); + const icon = document.createElement("span"); + icon.className = "icon is-small"; + const i = document.createElement("i"); + i.className = "fa-solid fa-check-double has-text-grey"; + icon.appendChild(i); + const timeSpan = document.createElement("span"); + timeSpan.className = "compose-tick-time"; + timeSpan.textContent = String(msg.delivered_display || ""); + tickWrap.appendChild(icon); + tickWrap.appendChild(timeSpan); + meta.appendChild(document.createTextNode(" ")); + meta.appendChild(tickWrap); + } bubble.appendChild(meta); row.appendChild(bubble); diff --git a/core/views/compose.py b/core/views/compose.py index 08566ee..cce6c51 100644 --- a/core/views/compose.py +++ b/core/views/compose.py @@ -324,6 +324,14 @@ def _serialize_message(msg: Message) -> dict: ) display_text = text_value if text_value.strip() else ("(no text)" if not image_url else "") author = str(msg.custom_author or "").strip() + delivered_ts = int(msg.delivered_ts or 0) + read_ts = int(msg.read_ts or 0) + delivered_display = _format_ts_label(int(delivered_ts)) if delivered_ts else "" + read_display = _format_ts_label(int(read_ts)) if read_ts else "" + ts_val = int(msg.ts or 0) + delivered_delta = int(delivered_ts - ts_val) if delivered_ts and ts_val else None + read_delta = int(read_ts - ts_val) if read_ts and ts_val else None + return { "id": str(msg.id), "ts": int(msg.ts or 0), @@ -335,6 +343,12 @@ def _serialize_message(msg: Message) -> dict: "hide_text": hide_text, "author": author, "outgoing": _is_outgoing(msg), + "delivered_ts": delivered_ts, + "read_ts": read_ts, + "delivered_display": delivered_display, + "read_display": read_display, + "delivered_delta": delivered_delta, + "read_delta": read_delta, } diff --git a/docker-compose.yml b/docker-compose.yml index beaa77f..630b5e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -271,6 +271,48 @@ services: # memory: 0.25G #network_mode: host + # Optional watcher service to restart the runtime router (UR) when core code changes. + # This runs the `docker/watch_and_restart.py` script inside the same image and + # will restart the `ur_gia` container when files under `/code/core` change. + watch_ur: + image: xf/gia:prod + container_name: watch_ur_gia + build: + context: . + args: + OPERATION: ${OPERATION} + command: sh -c '. /venv/bin/activate && python docker/watch_and_restart.py' + volumes: + - ${REPO_DIR}:/code + - ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini + - ${APP_DATABASE_FILE}:/conf/db.sqlite3 + - type: bind + source: /code/vrun + target: /var/run + environment: + WATCH_PATHS: "/code/core" + TARGET_CONTAINER: "ur_gia" + + # Optional watcher service to restart the scheduling process when app code changes. + watch_scheduling: + image: xf/gia:prod + container_name: watch_scheduling_gia + build: + context: . + args: + OPERATION: ${OPERATION} + command: sh -c '. /venv/bin/activate && python docker/watch_and_restart.py' + volumes: + - ${REPO_DIR}:/code + - ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini + - ${APP_DATABASE_FILE}:/conf/db.sqlite3 + - type: bind + source: /code/vrun + target: /var/run + environment: + WATCH_PATHS: "/code/app" + TARGET_CONTAINER: "scheduling_gia" + redis: image: redis container_name: redis_gia diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index d846186..93980ab 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -23,5 +23,7 @@ log-level=debug # Autoreload on code changes (graceful reload) py-autoreload=1 -py-autoreload-on-edit=/code/GIA/core -py-autoreload-on-edit=/code/GIA/app \ No newline at end of file +# In the container the repository is mounted at /code, not /code/GIA +# point autoreload at the actual in-container paths +py-autoreload-on-edit=/code/core +py-autoreload-on-edit=/code/app \ No newline at end of file diff --git a/docker/watch_and_restart.py b/docker/watch_and_restart.py index efa2208..1e683a6 100644 --- a/docker/watch_and_restart.py +++ b/docker/watch_and_restart.py @@ -1,6 +1,27 @@ #!/usr/bin/env python3 """ -Watch for code changes in core/ and app/ and restart ur_gia container. +Watch for code changes and restart a target container. + +This script watches one or more directories (set via the `WATCH_PATHS` +environment variable, comma-separated) and restarts the container named by +`TARGET_CONTAINER` (defaults to `ur_gia`) when a filesystem change is detected. + +Typical usage in this repo (examples are provided in `docker-compose.yml`): + + - To restart the runtime router (UR) when core code changes set: + WATCH_PATHS=/code/core + TARGET_CONTAINER=ur_gia + + - To restart the scheduling command when app code changes set: + WATCH_PATHS=/code/app + TARGET_CONTAINER=scheduling_gia + +If you need to force a restart manually (for example to refresh a running +management command), you can "touch" any file under the watched path: + + docker compose exec sh -c 'touch /code/core/__restart__' + +The watcher ignores `__pycache__`, `.pyc` files and `.git` paths. """ import os import sys @@ -40,17 +61,16 @@ class ChangeHandler(FileSystemEventHandler): self._restart_ur() def _restart_ur(self): - print(f'[{time.strftime("%H:%M:%S")}] Restarting ur_gia...', flush=True) + # Determine target container from environment (default `ur_gia`) + target = os.environ.get('TARGET_CONTAINER', 'ur_gia') + print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True) # Try podman first (preferred in this setup), then docker - result = subprocess.run( - 'podman restart ur_gia 2>/dev/null || docker restart ur_gia 2>/dev/null', - shell=True, - capture_output=True, - ) + cmd = f"podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null" + result = subprocess.run(cmd, shell=True, capture_output=True) if result.returncode == 0: - print(f'[{time.strftime("%H:%M:%S")}] ur_gia restarted successfully', flush=True) + print(f'[{time.strftime("%H:%M:%S")}] {target} restarted successfully', flush=True) else: - print(f'[{time.strftime("%H:%M:%S")}] ur_gia restart failed', flush=True) + print(f'[{time.strftime("%H:%M:%S")}] {target} restart failed', flush=True) time.sleep(1) @@ -58,12 +78,16 @@ def main(): handler = ChangeHandler() observer = Observer() - # Watch both core and app directories - watch_paths = ['/code/GIA/core', '/code/GIA/app'] + # Allow overriding watched paths via environment variable `WATCH_PATHS`. + # Default is `/code/core,/code/app` but you can set e.g. `WATCH_PATHS=/code/core` + watch_paths_env = os.environ.get('WATCH_PATHS', '/code/core,/code/app') + watch_paths = [p.strip() for p in watch_paths_env.split(',') if p.strip()] for path in watch_paths: if os.path.exists(path): observer.schedule(handler, path, recursive=True) print(f'Watching: {path}', flush=True) + else: + print(f'Not found (will not watch): {path}', flush=True) observer.start() print(f'[{time.strftime("%H:%M:%S")}] File watcher started. Monitoring for changes...', flush=True) diff --git a/docker/watch_simple.py b/docker/watch_simple.py index eff42be..b3d3660 100644 --- a/docker/watch_simple.py +++ b/docker/watch_simple.py @@ -1,7 +1,18 @@ #!/usr/bin/env python3 """ Simple file watcher using stat() instead of watchdog (no external deps). -Watches core/ and app/ for changes and restarts ur_gia. + +This lightweight watcher can be used when you don't want the `watchdog` +dependency. Configure the directories to watch using the `WATCH_PATHS` +environment variable (comma-separated). Configure which container to restart +using `TARGET_CONTAINER` (defaults to `ur_gia`). + +Example: + + WATCH_PATHS=/code/core TARGET_CONTAINER=ur_gia + +Touching a file under the watched path will trigger a restart of the target +container; e.g. `touch /code/core/__restart__` will cause the watcher to act. """ import os import sys @@ -27,21 +38,23 @@ def get_mtime(path): def restart_ur(): - """Restart ur_gia container.""" - print(f'[{time.strftime("%H:%M:%S")}] Restarting ur_gia...', flush=True) - result = subprocess.run( - 'podman restart ur_gia 2>/dev/null || docker restart ur_gia 2>/dev/null', - shell=True, - capture_output=True, - ) + """Restart target container (defaults to `ur_gia`).""" + target = os.environ.get('TARGET_CONTAINER', 'ur_gia') + print(f'[{time.strftime("%H:%M:%S")}] Restarting {target}...', flush=True) + cmd = f'podman restart {target} 2>/dev/null || docker restart {target} 2>/dev/null' + result = subprocess.run(cmd, shell=True, capture_output=True) if result.returncode == 0: - print(f'[{time.strftime("%H:%M:%S")}] ur_gia restarted', flush=True) + print(f'[{time.strftime("%H:%M:%S")}] {target} restarted', flush=True) else: print(f'[{time.strftime("%H:%M:%S")}] restart failed', flush=True) def main(): - paths = ['/code/GIA/core', '/code/GIA/app'] + # In the container the repository is mounted at /code + # Allow overriding watched paths via environment variable `WATCH_PATHS`. + # Default is `/code/core,/code/app`. + paths_env = os.environ.get('WATCH_PATHS', '/code/core,/code/app') + paths = [p.strip() for p in paths_env.split(',') if p.strip()] last_mtimes = {} for path in paths: