1
0
forked from iarv/mc-webui

234 Commits
main ... main

Author SHA1 Message Date
MarekWo
0b3bd1da60 fix(dm): delayed path backfill for FLOOD-delivered messages
When FLOOD delivery is confirmed, the PATH_UPDATE event payload often
has empty path data because firmware updates the contact's out_path
asynchronously. After 3s delay, read the contact's updated path from
the meshcore library's in-memory contacts dict and backfill the DB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:23:35 +01:00
MarekWo
4de6d72cfe fix(dm): update delivery path from PATH event after ACK race
When both ACK and PATH_UPDATE fire for FLOOD delivery, _on_ack may
store empty path before PATH_UPDATE can provide the discovered route.
Now _on_path_update also checks for recently-delivered DMs with empty
delivery_path and backfills with the discovered path from the event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:05:35 +01:00
MarekWo
58af37238b fix(ui): move retry counter above Resend button, same line as delivery info
Retry counter now renders as a dm-delivery-meta div above the Resend
button instead of inline next to it, matching the position of the
post-delivery info. Prevents text from crowding the button on short
messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:58:31 +01:00
MarekWo
f135c90e61 fix(ui): align DM route popup to the right to prevent overflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:48:27 +01:00
MarekWo
90c1c90ba3 feat(dm): clickable route popup for long delivery paths
Long routes (>4 hops) show truncated with dotted underline; clicking
opens a popup with the full route and hop count, same style as channel
message path popups. Short routes (<=4 hops) display inline as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:44:40 +01:00
MarekWo
7d8a3c895d fix(dm): use discovered path from PATH event for delivery route
When PATH_UPDATE confirms delivery, use the actual path from the
event data instead of the empty path_desc from _retry_context (which
is empty during FLOOD phase). This captures the route firmware
discovered via the flood delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:39:53 +01:00
MarekWo
3c7f70175f fix(dm): handle FLOOD delivery and old DIRECT path gracefully
Add hex validation to formatDmRoute to avoid garbling old "DIRECT"
values. When no hex route available (FLOOD delivery), fall back to
delivery_route from ACK (e.g. show "FLOOD" stripped of PATH_ prefix).
Ensures delivery meta always shows something useful.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:28:40 +01:00
MarekWo
7a44d3b95d fix(dm): resolve race condition — delivery info stored before task cancel
The _on_ack handler cancels the retry task before _retry() can store
delivery info (attempt count, path). Fix by maintaining a _retry_context
dict updated before each send. _on_ack reads context and stores delivery
info + emits dm_delivered_info BEFORE cancelling the task. Same fix
applied to PATH_UPDATE backup delivery handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:11:05 +01:00
MarekWo
885a967348 fix(dm): show delivery route as hex path, add real-time delivery info
Store actual hex path instead of DIRECT/FLOOD labels in delivery_path.
Format route as AB→CD→EF (same as channel messages, truncated if >4
hops). Add dm_delivered_info WebSocket event so delivery meta appears
in real-time without needing page reload. Remove path info from failed
messages since it's not meaningful for undelivered messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:21:53 +01:00
MarekWo
677036a831 fix(dm): move retry counter below message, show delivery info visually
Move the attempt counter (e.g. "Attempt 15/24") from next to the status
icon to below the message text, left of the Resend button. Add visible
delivery meta line for delivered/failed messages showing attempt count
and path used. Store attempt info for failed messages too. Replace
Polish abbreviations (ŚK, ŚD, ŚG) with English in all log messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:52:00 +01:00
MarekWo
7dbbba57b9 feat(dm): add real-time retry status and persistent delivery info
Show retry progress in DM message bubble via WebSocket:
- "attempt X/Y" counter updates in real-time during retries
- Failed icon (✗) when all retries exhausted
- Delivery info persisted in DB (attempt number, path used)

Backend: emit dm_retry_status/dm_retry_failed socket events,
store delivery_attempt/delivery_path in direct_messages table.
Frontend: socket listeners update status icon and counter,
delivered tooltip shows attempt info and path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:25:35 +01:00
MarekWo
d2e019fa0e refactor(dm): restructure retry logic into 4-scenario matrix
Replace 3-way branching (configured_paths/has_path/else) with
4-scenario matrix based on (has_path × has_configured_paths):

- S1: No path, no configured paths → FLOOD only
- S2: Has path, no configured paths → DIRECT + optional FLOOD
- S3: No path, has configured paths → FLOOD first, then ŚD rotation
- S4: Has path, has configured paths → DIRECT on ŚK, ŚD rotation, optional FLOOD

Key changes:
- S3: FLOOD before configured paths (discover new routes)
- S4: exhaust retries on current ŚK before rotating ŚD
- S4: dedup ŚG/ŚK to skip redundant retries on same path
- Add _paths_match() helper for path deduplication
- Update tooltip text for settings clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 11:55:17 +01:00
MarekWo
9be7ae6cc4 fix(ui): always refresh contact data on path_changed event
The path_changed socket handler was skipping the refresh when Contact
Info modal was closed. This meant contactsList stayed stale, so opening
the modal later still showed outdated path info. Now always refreshes
contactsList on any path_changed event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 07:32:33 +01:00
MarekWo
5df9b4b4a2 fix(ui): refresh Contact Info path display in real-time
Path info in Contact Info modal was stale due to 60s server cache
and no refresh after path operations. Now:
- Invalidate contacts cache after reset_path, change_path, path_update
- Emit 'path_changed' socket event on PATH_UPDATE from device
- UI listens and re-renders Contact Info when path changes
- Reset to FLOOD button immediately refreshes the path display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:29:26 +01:00
MarekWo
292d1d91af fix(contacts): restore flexbox list height, remove calc() overrides
The contact list in Existing/Pending Contacts was not using all available
space due to calc(100vh - ...) and max-height rules overriding the
flexbox layout. Remove fixed height constraints from #pendingList and
#existingList in both contacts_base.html and style.css, letting the
flexbox chain (body > main > container > pageContent > list) fill the
remaining viewport space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 07:54:47 +01:00
MarekWo
054b80926d docs: update documentation for path management, add contact, theme, sidebar
- README: add multi-path routing, add contact via URI/QR, dark/light theme,
  desktop sidebar, device share tab, pubkey-based DB naming
- User Guide: add sections for Adding Contacts (URI/QR/manual), DM Path
  Management (multi-path, repeater picker, map picker, keep path toggle),
  Device Share tab, theme setting, desktop sidebar notes
- Architecture: add path management API endpoints (CRUD, reorder, reset,
  no_auto_flood), manual-add, push-to-device, move-to-cache endpoints,
  update DB naming to pubkey prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:08:58 +01:00
MarekWo
54be1796f8 fix(ui): reduce DM sidebar contact name font size to match channel sidebar
Global .contact-name (1.1rem/600) was bleeding into DM sidebar items.
Added explicit 0.88rem/400 override for .dm-sidebar-item .contact-name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 17:51:26 +01:00
MarekWo
71e00caa55 feat(ui): add dark/light theme switching with Settings toggle
- Create theme.css with CSS custom properties for light/dark themes
- Dark theme inspired by demo landing page (deep navy palette)
- Update style.css: replace ~145 hardcoded colors with CSS variables
- Extract inline styles from index.html, contacts.html, dm.html to style.css
- Add Appearance tab in Settings modal with theme selector
- Bootstrap 5.3 data-bs-theme integration for native dark mode
- Theme persisted in localStorage, applied before CSS loads (no FOUC)
- Console and System Log panels unchanged (already dark themed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 08:23:26 +01:00
MarekWo
2e6f0d01d6 feat(device): add Share tab with QR code and URI for sharing own contact
Adds a Share tab to the Device Info modal that generates a QR code
and copyable URI (meshcore://contact/add?...) for sharing the device
contact with other users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:12:30 +01:00
MarekWo
ce88ec291f fix(dm): preserve sidebar search filter when conversations refresh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:59:28 +01:00
MarekWo
c6eb2b1755 fix(dm): remove d-none class that conflicts with media query on desktop header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:57:54 +01:00
MarekWo
1e768e799b feat(ui): add channel/contact sidebar for wide screens (desktop/tablet)
On screens >= 992px (lg breakpoint), show a persistent sidebar panel:
- Group chat: channel list with unread badges, active highlight, muted state
- DM: conversation/contact list with search, unread dots, type badges
- Desktop contact header with info button replaces mobile selector
- Mobile/narrow screens unchanged (dropdown/top selector still used)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:56:32 +01:00
MarekWo
7b2f721d1d fix(contacts): wrap long public keys in add contact previews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:43:28 +01:00
MarekWo
17b3c1c89c fix(contacts): correct COM type label from Communicator to Companion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:01:12 +01:00
MarekWo
878d489661 feat(contacts): add contact UI with URI paste, QR scan, and manual entry
Stage 2 of manual contact add feature:
- POST /api/contacts/manual-add endpoint (URI or raw params)
- New /contacts/add page with 3 input tabs (URI, QR code, Manual)
- QR scanning via html5-qrcode (camera + image upload fallback)
- Client-side URI parsing with preview before submission
- Nav card in Contact Management above Pending Contacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 20:54:41 +01:00
MarekWo
0973d2d714 fix(contacts): invalidate contacts cache after push/move operations
The /api/contacts/detailed endpoint has a 60s cache. Without invalidation
after push-to-device or move-to-cache, the UI showed stale data until
cache expired, making it look like the operation didn't work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:25:03 +01:00
MarekWo
9ee63188d2 feat(contacts): add push-to-device and move-to-cache operations
Enable moving contacts between device and cache directly from the
Existing Contacts UI:
- "To device" button on cache-only contacts (pushes to device)
- "To cache" button on device contacts (removes from device, keeps in DB)

This helps manage the 350-contact device limit by offloading inactive
contacts to cache and restoring them when needed.

- Add DeviceManager.push_to_device() and move_to_cache() methods
- Add API endpoints: POST /contacts/<pk>/push-to-device, move-to-cache
- Add UI buttons with confirm dialogs in contacts.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:06:26 +01:00
MarekWo
215515fe02 fix(contacts): add missing out_path_hash_mode field for manual_add
The meshcore library's update_contact() reads out_path_hash_mode directly
from the contact dict. Without it, add_contact_manual() fails with
KeyError: 'out_path_hash_mode'. Default value 0 is correct for new
contacts with no known path (flood mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:58:06 +01:00
MarekWo
3e8eb00e3e feat(contacts): add manual_add command for adding contacts from URI or params
Add support for adding contacts manually using the MeshCore mobile app URI
format (meshcore://contact/add?name=...&public_key=...&type=...) or raw
parameters (public_key, type, name). This enables contact sharing between
mc-webui and the MeshCore Android/iOS app via URI/QR codes.

- Add parse_meshcore_uri() helper to parse mobile app URIs
- Add DeviceManager.add_contact_manual() using CMD_ADD_UPDATE_CONTACT
- Update import_contact_uri() to handle both mobile app and hex blob URIs
- Add manual_add console command with two usage variants
- Update console help text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:52:22 +01:00
MarekWo
d54d8f58dd fix(console): fix node_discover display using correct payload fields
The DISCOVER_RESPONSE payload uses 'pubkey' and 'node_type', not
'public_key'/'name'/'adv_name'. Now shows pubkey prefix, resolved
contact name, node type, SNR, and RSSI. Also rename CLI->COM type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 14:17:42 +01:00
MarekWo
2c73e20775 fix(backup): use DB filename as backup prefix instead of hardcoded 'mc-webui'
Backup filenames now derive from the active DB stem (e.g. mc_9cebbd27.2026-03-24.db).
Listing and cleanup glob *.db so existing mc-webui.* backups remain visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:42:31 +01:00
MarekWo
f9bcbabb86 fix: use Flask current_app for DB access in read_status and contacts_cache
'from app.main import db' gets None because python -m app.main loads the
module as __main__, creating a separate module instance from app.main.
Use current_app.db (Flask app context) instead — same pattern as api.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:26:07 +01:00
MarekWo
5ccd882c5a refactor: eliminate JSONL companion files, delegate to DB
Remove contacts_cache.jsonl and adverts.jsonl file I/O — all contact
data is already in the SQLite contacts/advertisements tables. Clean up
stale JSONL files (acks, echoes, path, dm_sent) at startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:16:41 +01:00
MarekWo
2a9f90c01d refactor: migrate read_status from JSON file to SQLite database
Replace file-based .read_status.json with DB-backed read_status table.
One-time migration imports existing data at startup. The read_status.py
module keeps the same public API so route handlers need no changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:13:26 +01:00
MarekWo
acfa5d3550 refactor: use public key prefix for DB filename instead of device name
DB filename changes from {device_name}.db to mc_{pubkey[:8]}.db,
making it stable across device renames and preparing for multi-device support.
Existing databases are auto-migrated at startup by probing the device table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:11:20 +01:00
MarekWo
92a88cae22 fix: persist channel secrets to DB at startup for pkt_payload computation
_load_channel_secrets() cached secrets in memory only. After dfc3b14
switched /api/messages to use DB channels instead of device calls,
the empty channels table caused Route info and Analyzer links to
disappear from message bubbles.

Now upserts each channel (name + secret) to DB during startup so
the API can compute pkt_payload without hitting the device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:51:34 +01:00
MarekWo
1684f9f3ff fix: disable buggy library auto-reconnect, handle reconnection ourselves
meshcore 2.3.0's ConnectionManager has a bug: when auto-reconnect creates
a new TCP connection, the old connection's connection_lost callback fires,
triggering another reconnect cycle. Since each success resets the attempt
counter, this loops forever (~1 TCP connection/second).

Disabled library auto_reconnect and added reconnection logic to
_on_disconnected() with 3 attempts and increasing backoff (5/10/15s).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:31:45 +01:00
MarekWo
dfc3b1403a fix: prevent page hang when device channel queries block
/api/messages and /api/messages/updates called get_channels_cached()
which blocks on device communication when cache is cold (up to 240s).
Now uses DB-cached channels for pkt_payload computation instead.

Frontend loadMessages() now has a 15s timeout with auto-retry
and clears the loading spinner on error instead of leaving it
spinning indefinitely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:26:26 +01:00
MarekWo
343b6f40a8 fix(ui): rename 'No Flood' toggle to 'Keep path'
'No Flood' was confusing next to the 'Reset to FLOOD' button.
'Keep path' better describes the behavior: don't auto-reset
the path to FLOOD after failed direct retries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:12:31 +01:00
MarekWo
aa2ba0a5c8 fix(dm): prioritize primary (starred) path in retry rotation
PATH_ROTATION now has 3 phases:
1. Exhaust retries on primary path first (initial send + retries_per_path-1)
2. Rotate through remaining non-primary paths
3. Optional FLOOD fallback (if no_auto_flood=False)

Previously, retry iterated all paths in sort_order giving the primary
path only the initial send attempt before switching to the first path
on the list, which was often an older/worse path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 20:33:36 +01:00
MarekWo
bdcc68513d fix(paths): use library's change_contact_path to avoid negative int error
_change_path_async manually set out_path and out_path_len on the contact
dict then called update_contact(contact) with path=None. This path reads
out_path_hash_mode from the contact dict, which is -1 when the contact
is in flood mode (after reset_path or device read with plen=255).
The encoding then produced: hop_count | (-1 << 6) = negative number,
causing "can't convert negative int to unsigned" in to_bytes().

Fix: use mc.commands.change_contact_path() which properly computes all
fields including out_path_hash_mode, avoiding the negative value issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 20:07:35 +01:00
MarekWo
8fd918d39b feat(dm): add DEBUG logging for individual retry attempts
Enables detailed tracking of each DM retry step: send attempt,
ACK wait timeout, and ACK timeout results. device_manager logger
set to DEBUG level so these messages appear in System Log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 19:42:17 +01:00
MarekWo
23687b2973 fix(paths): use _get_dm() instead of nonexistent cli.get_device_manager()
The reset_flood endpoint was calling cli.get_device_manager() which
doesn't exist. All other endpoints use the local _get_dm() helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:39:07 +01:00
MarekWo
c82fb9f334 fix(paths): fix reset_flood endpoint error handling and add logging
Endpoint now returns error if device reset fails instead of always
returning success:true. Added logging to both endpoint and
device_manager.reset_path to diagnose reset failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:32:56 +01:00
MarekWo
08b972b891 fix(paths): separate Reset to FLOOD from Clear Paths
Reset to FLOOD now only resets the device path without deleting
configured paths from the database. New Clear Paths button deletes
all configured paths from DB without touching the device. This lets
users reset to FLOOD to discover new paths while keeping their
configured alternatives intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 10:10:40 +01:00
MarekWo
08ba91b9ba fix(paths): allow non-adjacent duplicate hops for 1-byte paths
For 1B hash size, duplicate repeater IDs are valid as long as they
don't appear consecutively (e.g. AA->BB->CC->AA->EE works fine).
For 2B/3B, duplicates remain fully blocked. Applied to all three
input methods: list picker, map picker, and manual entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:38:29 +01:00
MarekWo
ba26b3dc3a fix(paths): prevent duplicate repeater IDs in path
Blocks adding the same hop prefix twice via all three methods:
- List picker: shows warning notification, ignores click
- Map picker: shows warning notification, keeps selection
- Manual entry: validates on Add Path, rejects with error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:19:55 +01:00
MarekWo
796fb917e4 refactor(paths): move Add Path form into its own modal
The path creation form is now a separate modal (z-index 1070) that
opens above Contact Info with its own backdrop, making the UI layers
clearly distinguishable. Map picker modal bumped to z-index 1080 so
it stacks correctly above both Contact Info and Add Path modals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:48:38 +01:00
MarekWo
0bca19e936 fix(paths): add backdrop between map picker and Contact Info modal
Raises the z-index of the map modal (1070) and its backdrop (1060)
so the Contact Info modal behind is visually grayed out, making it
clear which modal is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:38:25 +01:00
MarekWo
a0a957289e feat(paths): keep map picker open after adding repeater
Map modal no longer closes on Add - resets selection instead so user
can pick multiple repeaters in sequence. Cancel button renamed to Close.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:23:21 +01:00
MarekWo
bf00e7c7d3 feat(paths): add repeater map picker for path configuration
Adds a map button (geo icon) next to the list picker in the path form.
Clicking it opens a modal with a Leaflet map showing repeater locations.
User clicks a repeater marker, then clicks Add to append its ID prefix
to the path hex. Includes Cached toggle to show all DB repeaters vs
only device-known ones. Respects current hash size setting (1B/2B/3B).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:13:20 +01:00
MarekWo
8aff9be570 fix(map): show correct "Last seen" for cached contacts
Used c.last_advert (numeric Unix timestamp) instead of c.last_seen
(ISO 8601 string) which caused formatTimeAgo() to return "Invalid Date".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 07:50:46 +01:00
MarekWo
aa1a1b203c feat(paths): add import button for current device path
Shows a download icon next to the device path display in Contact Info.
Clicking it imports the current device path into configured paths as
primary, with hash_size properly decoded from out_path_len.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:51:53 +01:00
MarekWo
b6dc03dce5 feat(paths): add Name/ID search mode toggle in repeater picker
ID mode searches by first N hex chars of public key (2/4/6 chars
depending on selected hash size). Placeholder updates dynamically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:38:41 +01:00
MarekWo
d6b2d01e2c fix(paths): use correct type=2 for repeater contacts
Type 1 is COM (companion), type 2 is REP (repeater).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:25:24 +01:00
MarekWo
8cc67f77d5 feat(dm): add multi-path management and per-contact no-flood toggle
- New `contact_paths` table for storing multiple user-configured paths per contact
- New `no_auto_flood` column on contacts to prevent automatic DIRECT→FLOOD reset
- Path rotation during DM retry: cycles through configured paths before optional flood fallback
- REST API for path CRUD, reorder, reset-to-flood, repeater listing
- Path management UI in Contact Info modal: add/delete/reorder paths, repeater picker with uniqueness warnings, hash size selector (1B/2B/3B)
- "No Flood" per-contact toggle in modal footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 21:20:51 +01:00
MarekWo
dd81fbf0b7 fix(console): add min_timeout=15s to binary repeater requests
req_status, req_acl, req_neighbours, req_mma use send_binary_req
which calculates timeout from suggested_timeout/800. After firmware
updates this can be too short, causing instant timeouts. Adding
min_timeout=15 ensures we wait at least 15 seconds for a response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:01:44 +01:00
MarekWo
88d805dd3a docs: upgrade diagram 2026-03-21 16:37:35 +01:00
MarekWo
66ada3d03c docs: comprehensive documentation update for v2 features
Update all documentation to reflect features added since last doc update:
- README: new features list, gallery screenshots, development status
- User Guide: global search, console commands, device dashboard, settings,
  system log, backup, updated DM and contact management sections
- Architecture: complete API reference, WebSocket namespaces, updated
  project structure and database tables
- Troubleshooting: remove v1 bridge references, add UI-based backup,
  system log references
- Gallery: add 4 new screenshots (search, filtering, settings, system log),
  update 12 existing screenshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:26:52 +01:00
MarekWo
ce8227247f feat(chat): add quote dialog with configurable quote length
Add Group Chat tab in Settings with configurable quote byte limit.
When quoting a message longer than the limit, a dialog asks whether
to use full or truncated quote (with editable byte count).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 15:25:38 +01:00
MarekWo
33a71bed17 refactor(ui): rename contact type label CLI to COM (companion)
The MeshCore community uses "companion" not "client" for type 1 nodes.
Rename the CLI label to COM across all UI, API, JS, and docs to align
with official terminology. Includes cache migration for old CLI entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:37:30 +01:00
MarekWo
8ba5381921 fix(settings): add note clarifying retry count includes initial send
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:57:23 +01:00
MarekWo
9e90e30d9f refactor(settings): compact table layout with tooltip info icons
Replace vertical form fields with table rows for less screen space.
Descriptions moved to (i) tooltip icons on hover/touch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:33:19 +01:00
MarekWo
6f1a5462e9 feat(settings): add Settings modal with configurable DM retry parameters
Replace hardcoded DM retry logic with user-configurable settings stored
in app_settings DB. Settings modal opens from menu with tab-based UI
(ready for future settings tabs). Defaults: 3 direct + 1 flood retries
(was 8+2), 30s/60s intervals, 60s grace period.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:18:03 +01:00
MarekWo
0108ea9149 refactor(menu): reorganize menu into logical sections + larger touch targets
Reorganize menu from 2 sections (Network Commands, Configuration) into 4:
- Messages (top, no header) - daily actions
- Network - advert commands
- Tools - Map, Console
- System - Device Info, System Log, Backup, Settings placeholder

Increase navbar button/select touch targets (min 40px) for mobile usability.
Widen offcanvas menu from 280px to 300px.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:55:59 +01:00
MarekWo
c48666843a fix(contacts): show ignored/blocked contacts in All Sources filter
Previously ignored and blocked contacts were hidden from the "All Sources"
view, making them only discoverable via dedicated Ignored/Blocked filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:22:52 +01:00
MarekWo
a1f2a1c5ef feat: name database file after device name for multi-device support
Database file is now named {device_name}.db (e.g. MarWoj.db) instead of
the generic mc-webui.db. On first boot, mc-webui.db is automatically
renamed once the device name is detected. On subsequent boots, the
existing device-named DB is found by scanning the config directory.

This enables future multi-device support where each MeshCore device
has its own separate database file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:07:58 +01:00
MarekWo
ca0ba37be5 fix(logs): open System Log as fullscreen modal like Console
Changed from target="_blank" link to fullscreen modal with iframe,
matching the pattern used by Console, DM, and Contacts modals.
Iframe loads on open and clears on close to manage WebSocket lifecycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:41:20 +01:00
MarekWo
0110e65b97 feat: add System Log viewer with real-time streaming
In-memory ring buffer (2000 entries) captures all Python log records.
New /logs page streams entries via WebSocket in real-time with:
- Level filter (DEBUG/INFO/WARNING/ERROR)
- Module filter (auto-populated from seen loggers)
- Text search with highlighting
- Auto-scroll with pause/resume
- Dark theme matching Console style

Menu entry added under Configuration section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:34:29 +01:00
MarekWo
4f25d244b1 refactor: migrate .webui_settings.json to database + fix NEW_CONTACT edge case
All settings (protected_contacts, cleanup_settings, retention_settings,
manual_add_contacts) moved from .webui_settings.json file to SQLite database.
Startup migration auto-imports existing file and renames it to .json.bak.

Added safeguard in _on_new_contact: if firmware fires NEW_CONTACT for a
contact already on the device, skip pending and log a warning. Also added
diagnostic logging showing previous DB state (source, protected) when
contacts reappear as pending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 20:14:15 +01:00
MarekWo
e106b5493b fix(contacts): dynamic list height and tighter card spacing
- Replace fixed calc() heights with flexbox layout so the contact list
  fills all remaining viewport space on any screen size
- Make body/main/container chain flex columns so the list can grow
- Reduce vertical spacing between contact name, public key, and
  last advert rows for more compact cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:55:33 +01:00
MarekWo
670715f57f fix(contacts): disable ignore/block buttons for protected contacts
- Existing Contacts: Ignore and Block buttons are now disabled when
  contact is protected, matching the existing Delete button behavior
- updateProtectionUI: toggling protection now also enables/disables
  Ignore, Block, and Delete buttons dynamically
- Chat: Ignore and Block buttons are hidden in message bubbles for
  protected contacts (loads protected pubkeys on init)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 09:15:44 +01:00
MarekWo
3337e3fdff feat(contacts): compact pending filters with batch ignore
- Remove redundant 'Contact Types:' label
- Move type badges above search input
- Place search, Approve, and Ignore buttons in single responsive row
- Add tooltip (i) on Filters header with usage hint
- Add batch Ignore button to ignore all filtered pending contacts
- Remove duplicate filtered count badge from Approve button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 08:30:32 +01:00
MarekWo
39a0e944a7 fix(console): correct trace display order
SNR precedes the hop hash: 12.50 > [5e]12.25 > [d1]-8.25 > [e7]-3.00
(each SNR shows link quality, hash shows the next relay node)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 17:29:53 +01:00
MarekWo
4b4e71f5bd fix(console): correct trace output format
Format: 12.50 > [5e]12.25 > [d1]-8.25 > [e7]-3.00
(SNR first, then each hop shows [hash]SNR)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 17:17:40 +01:00
MarekWo
20924d134d fix(console): trace path support, stats field names, self_telemetry format
- trace: accepts comma-separated hex path (e.g. "trace 5e,d1,e7"),
  waits for TRACE_DATA response with proper timeout from device
- stats: fix field names (uptime_secs, queue_len, battery_mv, etc.),
  show all radio/packet stats with detail breakdown
- self_telemetry: format LPP sensor data nicely instead of raw dict

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 17:03:35 +01:00
MarekWo
019d351ab7 fix(console): req_regions and req_owner output formatting
- req_regions: library returns string, not dict — was crashing
  with "'str' object has no attribute 'items'"
- req_owner: format like meshcore-cli ("X is owned by Y")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 16:17:42 +01:00
MarekWo
3057882f20 fix(console): get/set help formatting, fix get path_hash_mode
- get help / set help: detailed parameter descriptions with
  explanations, matching meshcore-cli style
- get path_hash_mode: library returns int not Event, fixed check
- set help: now reachable (was behind len(args)>=3 guard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:42:31 +01:00
MarekWo
5a4c259c0b fix(console): ver command now queries firmware info properly
Was using self_info (which has no firmware data). Now uses
send_device_query() like meshcore-cli, showing model, version,
build date and repeat mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:23:56 +01:00
MarekWo
3acdc7a402 feat(console): fix req_clock format, add req_neighbours command
- req_clock: parse timestamp from binary hex data (little-endian)
  and display as human-readable datetime, matching meshcore-cli
- req_neighbours: new command that fetches neighbour list from
  repeater with formatted output (name resolution from device
  contacts and DB cache, time ago, SNR)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:25:28 +01:00
MarekWo
3f9b6e54c8 fix(console): repeater req_* return value check
meshcore _sync methods return dict (data) or None (error/timeout),
not Event objects. hasattr(dict, 'payload') is always False, causing
instant "timeout" errors. Changed to check `result is not None`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 12:08:11 +01:00
MarekWo
fe7c67ee9a fix(console): human-readable clock, fix repeater timeouts
- Clock command now shows datetime like meshcore-cli: "Current time: 2026-03-19 11:39:07 (1773916747)"
- Repeater req_* commands: pass timeout=0 to meshcore library so it uses
  device's suggested_timeout instead of hardcoded 30s (matching meshcore-cli behavior)
- Execute timeout raised to 120s to accommodate slow repeater responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:58:46 +01:00
MarekWo
4f64cc92e5 feat(console): add device/channel management commands (Etap 3)
Add device management: get/set params, clock/clock sync, time,
reboot, ver, scope, self_telemetry, node_discover.
Add channel management: get_channel, set_channel, add_channel,
remove_channel. Update help text with all command categories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:10:46 +01:00
MarekWo
d80f9a7b3a feat(console): add contact management commands (Etap 2)
Add 14 console commands for contact management: contact_info,
path, disc_path, reset_path, change_path, advert_path,
share_contact, export_contact, import_contact, remove_contact,
change_flags, pending_contacts, add_pending, flush_pending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:09:03 +01:00
MarekWo
d6b92e2754 feat(console): add repeater management commands (Etap 1)
Add 9 new console commands for repeater management:
login, logout, cmd, req_status, req_regions, req_owner,
req_acl, req_clock, req_mma. Add resolve_contact helper
and _parse_time_arg utility. Update help text with categories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:07:22 +01:00
MarekWo
f66e95ffa0 fix: contact delete by pubkey, cache contacts as pending, cache delete button
1. Delete button now sends public_key instead of name to avoid matching
   wrong contacts when multiple share similar names.
2. _on_advertisement adds cache-only contacts to mc.pending_contacts when
   manual approval is enabled, so they appear in the pending list after
   advertising (even if meshcore fires ADVERTISEMENT instead of NEW_CONTACT).
3. Added Delete button for cache-only contacts with dedicated
   /api/contacts/cached/delete endpoint and hard_delete_contact DB method.
4. approve_contact/reject_contact now handle DB-only pending contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 08:01:56 +01:00
MarekWo
eb19f3cf76 feat(dm): add clear search button (x) next to contact info
Shows an X button when a conversation is selected, allowing quick
clearing of the search field to find another contact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:35:31 +01:00
MarekWo
e1d3534624 fix(dm): resolve contact names from device, not backend pubkeys
- Add resolveConversationName() that prioritizes device contacts over
  backend display_name (which falls back to pubkey when DB JOIN fails)
- Add isPubkey() guard to prevent overwriting good names with hex strings
- Add arrow key navigation (Up/Down) in searchable contact dropdown
- Auto-focus message input after selecting contact from dropdown
- Skip filtering when search input contains a pubkey (show all contacts)
- Keep search input and placeholder in sync with best known name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:13:03 +01:00
MarekWo
50fdee05ed fix(dm): prevent dropdown close on mouse hover, ensure name after select
- Prevent mousedown on dropdown from stealing focus (which closed the
  dropdown before click could register on desktop)
- After selecting from dropdown, override search input with the known
  name to guarantee correct display even if prefix match fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:43:48 +01:00
MarekWo
1ecf2f60f0 fix(dm): resolve conversation name via prefix match for saved IDs
When restoring a conversation from localStorage, the saved ID may have
a different pubkey prefix length than the API returns (e.g. pk_e4ce0a07
vs pk_e4ce0a075359459f...). Now selectConversation() does prefix
matching against dmConversations and upgrades the stored ID, so the
display name is resolved correctly instead of showing raw pubkey prefix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:35:40 +01:00
MarekWo
8ce5fa85ba feat(dm): searchable contact selector, contact info modal, device-only contacts
Redesign DM chat contact selector:
- Replace <select> dropdown with searchable text input + filtered dropdown
- Show only device contacts (from /api/contacts/detailed), not all cached
- Sort contacts alphabetically, conversations by recency
- Type badge (CLI/REP/ROOM/SENS) shown in dropdown items
- Keyboard support: Enter selects first match, Escape closes

Add Contact Info modal (replaces Retry toggle in header):
- Shows contact name, type, public key, last advert, path/route, GPS
- Auto Retry toggle moved into modal footer
- Designed for future extensibility (manual path setting etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:27:01 +01:00
MarekWo
21b1c0510f fix(dm): emit original expected_ack on retry ACK for frontend matching
When a DM is retried, the retry send generates a NEW ack code. The
backend correctly maps retry ack → dm_id via _pending_acks, but
the WebSocket emit was sending the retry ack code. The frontend DOM
still has data-ack="<original_ack>" from the first send, so it could
never match retry ACKs → delivery checkmark never appeared.

Now both _on_ack() and _confirm_delivery() look up the original
expected_ack from the database before emitting to the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:54:50 +01:00
MarekWo
5ecb48c772 fix(api): fix NameError on out_path variable rename
Variable was renamed to out_path_raw but one reference was missed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:30:32 +01:00
MarekWo
3622619ba4 fix(contacts): decode path correctly using MeshCore V1 encoding
Path buffer from firmware contains trailing garbage bytes beyond the
actual hop data. out_path_len encodes both hop count (lower 6 bits)
and hash size (upper 2 bits). Now we:
- Truncate out_path to meaningful bytes (hop_count * hash_size)
- Format as readable E7→DE→54→54→D8 instead of raw hex string
- Show hop count derived from actual path arrows

Example: out_path_len=5 with out_path="e7de5454d81c49dfb86f8a"
now correctly displays as "E7→DE→54→54→D8 (5 hops)" instead of
showing the full 11-byte buffer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:26:06 +01:00
MarekWo
5f72f40742 feat(contacts): show path/route info in UI and split console commands
- Console `contacts` now shows device-only contacts with path info
  (matching meshcore-cli format: name, type, pubkey, path)
- New `contacts_all` command shows all contacts (device + cached from DB)
- Contact cards in UI now always show routing mode for device contacts
  (Flood, Direct 0 hop, or hex path with hop count)
- Fix path_or_mode computation: prioritize out_path over out_path_len
  to handle firmware edge case where out_path exists but out_path_len=-1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:01:15 +01:00
MarekWo
fa8190923f fix(dm): adjust retry strategy based on DIRECT vs FLOOD routing
- DIRECT (known path): 10 attempts (8 DIRECT + 2 FLOOD), 30s wait between
- FLOOD (no path): 3 attempts only, 60s wait between
- Prevents flooding the mesh with rapid retries when no path is known
- Longer DIRECT wait gives ACKs more time to return through multi-hop paths
- Log retry mode at task start for easier debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:21:02 +01:00
MarekWo
e473cbf495 fix(dm): cancel retry on early ACK/PATH, add 60s grace period for late ACKs
- Cancel retry task immediately when _on_ack or PATH handler confirms delivery
- Keep pending_acks for 60s after retry exhaustion so late ACKs are matched
- Prevents orphaned ACKs (no dm_id) when ACK arrives shortly after exhaustion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:10:10 +01:00
MarekWo
3a26da18fd fix(websocket): update message metadata in-place without full chat reload
Instead of reloading the entire message list when echo data arrives,
now updates only the affected message elements in the DOM:

- Add data-msg-id attribute to message wrappers for targeted lookup
- Add GET /api/messages/<id>/meta endpoint returning metadata for a
  single message (computes pkt_payload, looks up echoes, analyzer URL)
- Replace loadMessages() echo handler with refreshMessagesMeta() that
  finds messages missing metadata and updates them individually
- Fix path_len=0 treated as falsy (use ?? instead of ||)

Flow: message appears instantly via WebSocket (with SNR + hops), then
~2s later echo data triggers targeted meta fetch → route info and
analyzer button appear smoothly without any chat window reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:49:56 +01:00
MarekWo
0e15df430f fix(websocket): listen for echo events to update message metadata in real-time
The backend already emits 'echo' SocketIO events when RX_LOG_DATA arrives
with route/path data, but the frontend wasn't listening. Now the frontend
handles echo events with a debounced loadMessages() refresh (2s delay) to
pick up computed pkt_payload, analyzer_url, hops, and route info.

This fixes messages appearing without metadata until manual page refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:38:46 +01:00
MarekWo
e817181261 fix(websocket): include SNR, hops, route and analyzer URL in channel message events
The SocketIO new_message emit for channel messages was missing snr,
path_len, pkt_payload and analyzer_url fields, causing messages received
via WebSocket to render without metadata until a full page refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:12:41 +01:00
MarekWo
9a0d05ae93 fix(search): remove double hash in channel names, add FTS5 syntax help
Channel names from device already include # prefix — removed hardcoded #
from search results badge. Added (?) help button with search syntax
examples and link to FTS5 docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:38:37 +01:00
MarekWo
d74a1572bb feat(dm): increase retry attempts to 10, delay flood fallback to attempt 8
Keep direct path retries longer before falling back to flood mode,
giving more time for ACK delivery on known routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:26:58 +01:00
MarekWo
6fcbcb7d4f fix(ui): fix [object Object] in device info/stats, soften search button color
- Stats: battery fallback used ${bat} on an object — now uses battery_mv
  from core stats when dedicated get_bat returns null
- Info: remove old v1 regex JSON parsing, use v2 dict response directly
- Search FAB: change from bright orange to muted teal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:14:25 +01:00
MarekWo
65b33b4af6 fix(phase3): fix database/device access in search, backup, stats, map endpoints
- Search, backup, stats endpoints used current_app.config.get('DEVICE_MANAGER')
  which doesn't exist — replaced with _get_dm()/_get_db() helpers
- /api/device/info used old v1 CLI — replaced with DeviceManager.get_device_info()
  returning structured dict instead of string (fixes map own device marker)
- Moved search button from navbar to FAB menu (between filter and DM buttons)
- Bump SW cache to v7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:02:23 +01:00
MarekWo
e4a1e75cc0 fix(socketio): register /chat namespace handler to fix real-time message delivery
The /chat namespace had no server-side connect handler registered. With
python-socketio 5.x (always_connect=False), client connections to
unregistered namespaces are silently rejected. This caused all SocketIO
events (new_message, ack, echo) to never reach the frontend — messages
only appeared via the 60s polling fallback.

Fixes:
- Add @socketio.on('connect', namespace='/chat') handler in main.py
- Add optimistic message append: sent messages appear instantly before
  API round-trip (eliminates 3-4s serial command delay)
- Skip own-message SocketIO events to prevent duplicates
- Add connect_error handler for frontend debugging
- Bump SW cache to v6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:15:43 +01:00
MarekWo
c6a2444249 fix(backup): allow backup job scheduling before db reference is set
The _db reference is set by init_retention_schedule() which runs after
schedule_daily_archiving(). The backup job checks _db at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:29:30 +01:00
MarekWo
3f9d096ed0 chore(sw): bump cache to v5, add filter-utils.js to cached assets
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:28:02 +01:00
MarekWo
ec383bf8e9 feat(map): add own device marker, last seen info, and formatTimeAgo
- Own device shown as red star marker on map (from self_info GPS)
- Contact popups now show "Last seen: X min ago" from last_advert
- New formatTimeAgo() utility for relative timestamps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:27:37 +01:00
MarekWo
ab01e6f17a feat(stats): add device statistics dashboard with Info/Stats tabs
- New GET /api/device/stats endpoint (core, radio, packets, DB stats)
- Device Info modal now has Info and Stats tabs
- Stats tab shows: battery, uptime, TX/RX air time, packet counts,
  DB row counts, and database size

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:25:45 +01:00
MarekWo
6fba37c609 feat(console): add stats, telemetry, neighbors, trace commands
- stats: device uptime, TX/RX air time, packet counts, errors
- telemetry <name>: request sensor data from remote node
- neighbors <name>: list neighbors of a remote node
- trace [tag]: send trace packet for mesh topology discovery

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:24:08 +01:00
MarekWo
4ecab9b307 feat(backup): add backup API endpoints and UI
- POST /api/backup/create — trigger immediate backup
- GET /api/backup/list — list backups with sizes
- GET /api/backup/download — download backup file
- Backup modal accessible from menu with create/download buttons
- Daily automatic backup via APScheduler (configurable hour/retention)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:22:50 +01:00
MarekWo
d6e2a3472a feat(search): add global message search with FTS5 backend
- New GET /api/messages/search endpoint using existing FTS5 indexes
- Search modal accessible from navbar search icon
- Debounced search (300ms) across all channel and DM messages
- Results show source (channel/DM), sender, timestamp with highlights
- Click result navigates to the relevant channel or DM conversation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:20:04 +01:00
MarekWo
a501da914a fix(chat): replace triple reload with SocketIO append after sending message
Instead of reloading all messages 3 times (1s, 6s, 15s) after sending,
the sent message now appears instantly via SocketIO new_message event.
Only one deferred reload remains at 15s to pick up echo data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 07:17:39 +01:00
MarekWo
653d8d8646 docs: update public documentation to reflect v2 architecture and features 2026-03-11 08:07:22 +01:00
MarekWo
92b55d9bdb feat(map): add Cached switch to show cache-only contacts on map
The Map modal (from main menu) now has a "Cached" toggle switch that,
when enabled, also displays contacts stored in the DB cache alongside
device contacts. Cached markers are slightly smaller and more
transparent to visually distinguish them from device contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 07:01:53 +01:00
MarekWo
3fb1c09dc1 fix(chat): exclude blocked contacts from unread message counts
Both /api/messages/updates (channel) and /api/dm/updates endpoints
now filter out blocked contacts when computing unread counts, so
badge numbers no longer include messages from blocked users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 06:45:38 +01:00
MarekWo
82b55d450e fix(chat): use showNotification instead of showToast in chat actions
showToast is only defined in contacts.js, not app.js. The chat page
uses showNotification. The ReferenceError was silently caught, preventing
loadBlockedNames() and loadMessages() from executing after blocking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:31:58 +01:00
MarekWo
e1ceff3a65 fix(chat): refresh messages after blocking contact from chat
Added client-side blocked name filtering in displayMessages() as
defense-in-depth alongside server-side filtering. This ensures blocked
sender messages are hidden immediately after blocking from chat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:27:48 +01:00
MarekWo
833d01df9f fix(contacts): remove duplicate const declaration breaking contacts.js
applySortAndFilters() had duplicate const declarations for sourceFilter
and selectedSource, causing a SyntaxError that prevented the entire
contacts.js from loading. Both Pending and Existing pages were broken.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:14:10 +01:00
MarekWo
b0076c3739 feat(contacts): name-based blocking, fix CSS breakpoint
- New blocked_names table for blocking bots without known public_key
- get_blocked_contact_names() returns union of pubkey-blocked + name-blocked
- POST /api/contacts/block-name endpoint for name-based blocking
- GET /api/contacts/blocked-names-list for management UI
- Block button always visible in chat (falls back to name-based block)
- Blocked Names section shown in Existing Contacts Blocked filter
- CSS breakpoint for icon-only buttons: 768px → 428px (iPhone-sized)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:03:19 +01:00
MarekWo
0d5c021e40 fix(contacts): type label bug, responsive buttons, remove Copy Key
- Fix pending contacts always showing "CLI" — compute type_label in API
- Remove Copy Key button from pending cards, make key clickable instead
- Responsive contact buttons: icon+text on desktop, icon-only on <=768px
- Add flex-wrap for button rows on small screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:16:04 +01:00
MarekWo
2a3a48ed5f feat(contacts): add ignored and blocked contact lists
- New DB tables: ignored_contacts, blocked_contacts (keyed by pubkey)
- Ignored contacts: cached but excluded from pending/auto-add
- Blocked contacts: ignored + messages hidden from chat (stored in DB)
- Backend: filter in _on_new_contact, _on_channel_message, _on_dm_received
- API: /contacts/<pk>/ignore, /contacts/<pk>/block toggle endpoints
- API: filter blocked from /api/messages and /dm/conversations
- Frontend: Ignore/Block buttons on pending cards, existing cards, chat messages
- Frontend: source filter dropdown with Ignored/Blocked options
- Frontend: status icons (eye-slash, slash-circle) on contact cards
- Frontend: real-time blocked message filtering via socketio
- Name→pubkey mapping for chat window block/ignore buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:10:21 +01:00
MarekWo
b709cc7b14 feat(contacts): add device/cache source icon to contact cards
Green chip icon for device contacts, grey cloud icon for cache-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:03:46 +01:00
MarekWo
09fbc56956 feat(contacts): complete cache functionality, fix display bugs
- _on_new_contact() in manual mode: upsert to DB as cache (source='advert')
  so contacts appear in @mentions and Cache filter before approval
- _on_advertisement(): check mc.pending_contacts for name/metadata fallback
- get_pending_contacts(): include last_advert in response
- /api/contacts/cached: return numeric last_advert timestamp
- contacts.js: fix adv_lat/adv_lon field names (was c.lat/c.lon),
  use last_advert timestamp instead of last_seen datetime string
- upsert_contact: source priority — never downgrade 'device' to 'advert'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:51:25 +01:00
MarekWo
34b6e9b1ec fix(contacts): fallback add to mc.contacts after approve
ensure_contacts(follow=True) may not return the just-added contact.
Add manual fallback to mc.contacts so /api/contacts/detailed and
send_dm can find it immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:37:51 +01:00
MarekWo
6c34ce85d8 fix(contacts): sync device↔DB contacts, restore contact cache
- get_contacts_with_last_seen() reads from mc.contacts (device firmware)
  instead of DB, so /api/contacts/detailed returns only device contacts
- _sync_contacts_to_db() now bidirectional: downgrades stale 'device'
  contacts to 'advert' (cache-only) when not on device anymore
- delete_contact() sets source='advert' (cache) instead of 'deleted',
  keeping contacts visible in @mentions and cache filter
- get_contacts() returns all contacts (no 'deleted' filter needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:10:13 +01:00
MarekWo
b516d4e370 docs: clarify DM contact issue — device table vs DB mismatch
Root cause is device firmware contact table being empty (after reflash
or reset), not the v1→v2 migration itself. DB retains hundreds of
contacts from advert history but device only has those explicitly added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:50:01 +01:00
MarekWo
808a9a6bb3 docs: add v1 to v2 migration guide
Documents breaking changes including the need to re-add DM contacts
to the device firmware table after migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:46:10 +01:00
MarekWo
53928390c8 fix(contacts): soft-delete contacts to preserve DM history
- delete_contact() now sets source='deleted' instead of SQL DELETE
- get_contacts() filters out deleted contacts (hidden from UI)
- upsert_contact() on re-add overwrites source, auto-undeleting
- DM FK references stay intact, no more orphaned messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:29:44 +01:00
MarekWo
66fa261151 fix(dm): prevent orphaned DMs on contact deletion, improve relinking
- Stop deleting contacts from DB on device removal (preserves DM history)
- Filter NULL contact_pubkey from DM conversations list
- Match outgoing DMs by contact name in raw_json during relinking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 08:09:24 +01:00
MarekWo
d1ce3ceb92 fix(dm): refresh mc.contacts on approve, DB name fallback, relink orphans
Three fixes for DM sending after contact delete/re-add:

1. approve_contact() now calls ensure_contacts() to refresh mc.contacts
   so send_dm can find newly added contacts immediately

2. cli.send_dm() falls back to DB name lookup when mc.contacts misses,
   preventing the contact name from being passed as a pubkey string

3. approve_contact() re-links orphaned DMs (NULL contact_pubkey from
   ON DELETE SET NULL) back to the re-added contact

New DB methods: get_contact_by_name(), relink_orphaned_dms()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:41:15 +01:00
MarekWo
1a3a1e937c fix(dm): clear error when contact not on device, log error_code detail
Device firmware requires contacts in its table to send DMs. Passing
a raw pubkey string results in error_code=2. Show actionable error
message instead of generic "Device error".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:32:56 +01:00
MarekWo
6a5fe98e32 debug(dm): add detailed logging for device send errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:31:28 +01:00
MarekWo
94f1bd98de fix(dm): don't require contact in mc.contacts to send DM
When the contact is not found in the in-memory mc.contacts dict,
fall back to passing the pubkey hex string directly to send_msg()
which handles it natively. Fixes "Contact not found" error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:26:24 +01:00
MarekWo
8f31c27360 fix(dm): resolve short pubkey prefix to full key on incoming DM
When a DM arrives with only pubkey_prefix (short hex) and the sender
is not in mc.contacts, fall back to DB prefix lookup to get the full
64-char public key. Prevents ghost contact entries and "Unknown" DM
conversations.

Also adds get_contact_by_prefix() database helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:19:46 +01:00
MarekWo
5b757e9548 feat(dm): add delivery confirmation, retry, and receiver-side dedup
- Fix ACK handler bug: read 'code' field instead of 'expected_ack'
- Add DM retry (up to 3 attempts) with same timestamp for receiver dedup
- Add receiver-side dedup in _on_dm_received() (sender_timestamp or time-window)
- Add PATH_UPDATE as backup delivery signal for flood DMs
- Track pending acks with dm_id for proper ACK→DM linkage
- Return dm_id and expected_ack from POST /dm/messages API
- Add find_dm_duplicate() and get_dm_by_id() database helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:02:58 +01:00
MarekWo
c1b0085710 feat(watchdog): skip USB reset if TCP connection is used
Since mc-webui can now connect via TCP to a remote proxy instead of local USB/serial device, the hardware USB bus reset logic in Watchdog will no longer blindly attempt a reset on repeated container crashes.

Added \is_tcp_connection()\ helper to read the config and conditionally skip the USB reset if TCP is active.
2026-03-06 09:53:06 +00:00
MarekWo
dc8c7ad1d6 fix(channels): pre-allocate reader.channels to prevent lib corruption
meshcore lib 2.2.21 bug: reader.py line 434 does
  self.channels = self.channels.extend([...])
which sets self.channels = None (extend returns None).
This corrupts ALL subsequent channel message processing.

Fix: after getting max_channels from device_info, pre-allocate
mc._reader.channels to max_channels slots so the extend path
is never triggered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:05:17 +01:00
MarekWo
8821892b4c fix(channels): stop channel iteration on consecutive empty slots
meshcore lib 2.2.21 has a bug where list.extend() return value (None)
is assigned to self.channels, corrupting state for indices >= 20.
Stop iterating after 3 consecutive empty/failed channels to avoid
hitting this bug and reduce error log spam.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:02:43 +01:00
MarekWo
97323649c7 feat(tcp): document TCP connection in .env.example + fix defaults
- Add MC_TCP_HOST and MC_TCP_PORT options to .env.example with clear
  documentation for serial vs TCP transport selection
- Change default MC_TCP_PORT from 5000 to 5555 (avoids Flask conflict)
- Wire MC_TCP_HOST/MC_TCP_PORT through docker-compose.yml from .env
  (previously was commented-out hardcoded values)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:27:08 +01:00
MarekWo
5c47a5b617 fix(watchdog): add logical USB unbind/bind and authorized toggle
When a native USB ESP32 device freezes, ioctl reset or DTR/RTS is often ignored. This uses sysfs unbind/bind and authorized toggles to forcefully drop the device from the kernel logic, causing it to re-enumerate cleanly without physical power cycles.
2026-03-03 20:28:40 +00:00
MarekWo
02b75c167b fix(watchdog): add ESP32 hardware reset via DTR/RTS
Since a standard USB bus reset often isn't enough to revive a hung ESP32, this adds a serial DTR/RTS toggle sequence (used by esptool) to physically reset the chip before trying a USB bus reset.
2026-03-03 20:20:52 +00:00
MarekWo
d079f97a38 fix(watchdog): stop container before resetting USB bus
This prevents the container from holding the serial port open during the hardware reset, which was causing the reset to fail or the device to re-enumerate on a different port.
2026-03-03 20:13:19 +00:00
MarekWo
ad8c5702f9 feat(watchdog): monitor mc-webui logs for unresponsive LoRa device
The v2 branch consolidated meshcore-bridge into mc-webui. Watchdog now:
- Monitors mc-webui logs for specific device connection errors
- Automatically restarts the container when errors are detected
- Performs a hardware USB bus reset if errors persist across 3 restarts
- Updated README.md to reflect the removal of meshcore-bridge
2026-03-03 20:01:46 +00:00
MarekWo
d6a7354f06 fix(channels): use device-reported max_channels instead of hardcoded 8
Firmware reports MAX_GROUP_CHANNELS (typically 40 for companion builds)
in the DEVICE_INFO response. Fetch it at startup and use it in all
channel iteration loops. Previously hardcoded range(8) prevented
channels 8+ from appearing and blocked adding new channels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:38:15 +01:00
MarekWo
9b206beeac fix(echo): validate channel hash before correlating echo with sent message
Pending echo correlation was assigning ANY first echo to the sent message,
even if it came from a different channel. This caused cross-channel mismatches
(e.g., Public channel echo assigned to #krakow message).

Fix: check that pkt_payload's first byte (channel_hash = sha256(secret)[0])
matches the channel we sent on before accepting correlation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:18:32 +01:00
MarekWo
ac1667bd01 fix(ui): fall back to echo SNR when message SNR is missing
meshcore library doesn't always provide SNR in CHANNEL_MSG_RECV events.
Use the first echo path's SNR as fallback for inline display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:25:25 +01:00
MarekWo
ba990b155f fix(ui): fix leading pipe separator when SNR is missing from message
Use array-based metaParts.join(' | ') instead of string concatenation
to avoid ugly leading "| Hops: 0" when meshcore lib doesn't provide SNR.
Also revert temporary INFO-level debug logging back to DEBUG.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:19:09 +01:00
MarekWo
7bcd6bd216 debug: add INFO logging for RX_LOG_DATA events and subscriptions 2026-03-02 16:56:43 +01:00
MarekWo
9f249a4521 feat(echoes): add RX_LOG_DATA echo tracking + sent message pkt_payload correlation
- Subscribe to RX_LOG_DATA events to capture repeated radio packets
- Parse GRP_TXT (0x05) payload to extract pkt_payload and path
- Classify echoes as sent (pending echo correlation) or incoming
- Register pending echo when sending channel messages for pkt_payload capture
- Add update_message_pkt_payload() DB method for sent message correlation
- Return echo_paths/echo_snrs for ALL messages (not just own) in GET /messages
- Frontend: build paths from echo_paths for incoming message route display
- Emit SocketIO 'echo' event for real-time badge updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:53:12 +01:00
MarekWo
44832ada5e fix(analyzer): fix pkt_payload computation for block-aligned text + trailing whitespace
Two bugs in Analyzer URL generation:
1. Firmware omits null+padding when header+text exactly fills AES block boundary
   (len % 16 == 0), but our code always added \0+padding → wrong MAC → wrong hash
2. content.strip() in event handler removed trailing whitespace that was part of
   the original packet. Now uses raw_text from raw_json (preserves original text)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:53:59 +01:00
MarekWo
2a3a00e654 fix(contacts): refresh contacts on unknown advert + cleanup fixes
When an ADVERTISEMENT arrives for a pubkey not in mc.contacts, the
firmware has auto-added a new contact. Trigger ensure_contacts() to
refresh the contact list and get the name. Also: remove contacts from
mc.contacts cache on delete, add reject/clear pending API endpoints,
promote advert logging to INFO level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:30:07 +01:00
MarekWo
37694dde09 fix(contacts): remove from pending list after approve + add reject/clear
approve_contact now removes the contact from mc.pending_contacts after
successful approval. Added reject_contact (remove without adding) and
clear_pending_contacts methods with API endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 08:14:10 +01:00
MarekWo
2c547ee1fc fix(perf): disable auto_update_contacts to prevent serial blocking
auto_update_contacts=True triggers ensure_contacts() on every
ADVERTISEMENT event, fetching 324+ contacts over serial (several seconds).
This blocks the serial port and delays MESSAGES_WAITING processing,
causing 10-30s message reception delays. Contacts are synced at startup
and updated individually via NEW_CONTACT events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:48:08 +01:00
MarekWo
63f2473933 fix(contacts): respect manual approval setting in NEW_CONTACT handler
When manual approval is enabled in settings, _on_new_contact now skips
DB upsert and emits SocketIO event for pending contacts. When auto mode
is on, contacts are added to DB immediately as before. Also enriched
approve_contact and get_pending_contacts with full contact data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:45:44 +01:00
MarekWo
499759931c feat(analyzer): compute pkt_payload from channel secrets for Analyzer URLs
meshcore v2 doesn't provide pkt_payload in events, so compute it
lazily in the API from channel secrets + message data. Analyzer URLs
now appear for ALL messages (own and incoming), not just own.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:38:44 +01:00
MarekWo
e37ab4243c Revert "fix(contacts): respect manual approval + fix message delay"
This reverts commit 6bb985f9c4bdb4b6a1ba9ef7337c13fb065f6ea7.
2026-03-01 21:57:37 +01:00
MarekWo
6bb985f9c4 fix(contacts): respect manual approval + fix message delay
1. Manual approval: _on_new_contact now checks self_info
   manual_add_contacts flag. When enabled, new contacts stay in
   mc.pending_contacts for UI approval instead of auto-adding to DB.

2. Message delay: disable auto_update_contacts which was triggering
   full contact list refresh (270+ records over serial) on every
   ADVERTISEMENT event, blocking message reception for seconds.
   Contact names for adverts are looked up from cached mc.contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:46:08 +01:00
MarekWo
db5aac084c fix(adverts): fix empty names + enable auto_update_contacts
- ADVERTISEMENT events only contain public_key — look up name/type/lat/lon
  from mc.contacts instead of the empty payload
- Enable mc.auto_update_contacts so meshcore refreshes contacts after adverts
- Fix NEW_CONTACT handler to store lat/lon/last_advert (was only storing
  name and type)
- Fix type field: NEW_CONTACT uses 'type' not 'adv_type'
- Graceful handling of set_manual_add_contacts firmware incompatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:02:49 +01:00
MarekWo
1df8fa03f9 debug: add ADVERT/NEW_CONTACT payload logging 2026-03-01 18:42:29 +01:00
MarekWo
b034a181ce feat(retention): add message retention scheduling (Task 2.6)
- Add daily retention job that deletes old channel messages, DMs, and
  advertisements based on configurable age threshold
- Add GET/POST /api/retention-settings endpoints
- Extend cleanup_old_messages() to optionally include DMs and adverts
- Wire up APScheduler in create_app() (also enables existing archiving
  and contact cleanup schedulers that were never started in v2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:28:54 +01:00
MarekWo
d89e276054 feat(api): add advertisement history API endpoint (Task 2.8)
Add GET /api/advertisements with optional pubkey filter and limit.
Enriches results with contact name lookup from cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:26:37 +01:00
MarekWo
5df10f0ab9 feat(v2): Expand console router with status, channels, help commands
- Add 'status' command: connection, name, battery, contacts count
- Add 'channels' command: list configured channels (0-7)
- Add 'help' command: list all available commands with descriptions
- Update unknown command message to suggest 'help'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:23:40 +01:00
MarekWo
f8c7bfb115 feat(v2): Add SocketIO real-time push for channel messages page
- Add SocketIO /chat client to app.js for real-time message updates
- Listen for new_message (channel) events, refresh current channel
- Update unread badges for other channels in real-time
- Listen for device_status to update connection indicator
- Reduce polling interval from 10s to 60s (fallback only)
- Include socket.io.min.js in base.html template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:21:56 +01:00
MarekWo
ebfe383190 feat(v2): Add SocketIO real-time push for DM page
- Add SocketIO /chat client to dm.js for real-time DM and ACK updates
- Listen for new_message (dm), ack, device_status events
- Remove 5x cascading refresh after send (replaced by SocketIO ACK)
- Reduce polling interval from 10s to 60s (fallback only)
- Add data-ack attribute to status icons for real-time ACK updates
- Enrich ACK emission with snr/rssi/route_type (device_manager.py)
- Include socket.io.min.js in dm.html template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:19:54 +01:00
MarekWo
e18ad0f7a3 fix(v2): Fix echo enrichment bug + add analyzer URL to channel messages
Bug: echo enrichment at api.py:384 used leaked `row` variable from
previous loop — all messages got echo data from the LAST DB row.

Fix: include pkt_payload in message dict during conversion loop,
then enrich each message with its own echo data and analyzer URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:16:50 +01:00
MarekWo
7f9aa4ac58 fix(v2): Truncate pubkey in DM placeholder using displayName() helper
currentRecipient keeps full value for sending, displayName() used for
all placeholder/dropdown display to truncate 64-char pubkeys to 8+...

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:31:21 +01:00
MarekWo
3e81eeeae7 fix(v2): Fix last_advert timestamps and DM placeholder display
- Sync last_advert from device contacts as Unix timestamp (was missing)
- Convert _on_advertisement to store Unix timestamp (was ISO string)
- Add _parse_last_advert() to handle both ISO and Unix formats in API
- Truncate full pubkey to short prefix in DM placeholder and dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:23:45 +01:00
MarekWo
c20e7c20ad fix(v2): Fix DM contact names showing as pubkey prefix
- DM handler: don't overwrite contact name with prefix when name unknown
- Migration: upsert contact with sender name from v1 PRIV entries
- Fixes conversations showing "4e45565e" instead of "demo mc-webui"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:58:15 +01:00
MarekWo
4b463fecfa fix(v2): Resolve DM sender pubkey prefix to full key from contacts
Incoming DM events only contain a short pubkey_prefix. Now resolves it
to the full public_key via mc.get_contact_by_key_prefix() so incoming
and outgoing messages end up in the same conversation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:46:10 +01:00
MarekWo
95dcf38d06 fix(v2): Handle bytes expected_ack from meshcore + DM prefix matching
- Convert bytes to hex string for expected_ack and pkt_payload via _to_str()
- Support pubkey prefix matching in get_dm_messages() (LIKE for short keys)
- Fixes "Object of type bytes is not JSON serializable" error on DM view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:36:37 +01:00
MarekWo
752c60f02d feat(v2): Import archive .msgs files + DB-based message history
Migration now imports all archive files (oldest first) in addition to the
live .msgs file, with deduplication. Archives endpoint and message history
now query SQLite by date instead of reading .msgs files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:05:33 +01:00
MarekWo
97a2014af2 feat(v2): Auto-migrate v1 .msgs data to SQLite on first startup
Reads the existing .msgs JSONL file and imports channel messages and DMs
into the v2 SQLite database. Runs automatically when device connects and
DB is empty. Handles sender parsing, pubkey resolution, and FK constraints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:32:28 +01:00
MarekWo
64860ba178 fix(v2): Parse sender name from channel message text format
Channel messages from meshcore arrive as "SenderName: message text".
The library doesn't provide sender name separately. Now parsing it
from the text (split on first colon), matching v1 parser behavior.

Also:
- Look up DM sender names from mc.contacts instead of event payload
- Fix SNR field name (uppercase 'SNR' from meshcore library)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:10:13 +01:00
MarekWo
65eb44d0ff fix(v2): Use event.payload instead of event.data throughout
The meshcore Event class has 'payload' not 'data'. All event handlers
were silently getting empty dicts, causing:
- Channel messages showing 'Unknown' sender
- Channel info not returning name/secret
- Sent message event data being lost

Also normalizes channel_name/channel_secret keys from CHANNEL_INFO
events and converts secret bytes to hex string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:01:42 +01:00
MarekWo
a7b9b74fa2 fix(v2): Use mc.commands for meshcore command methods
MeshCore library exposes command methods (get_channel, send_msg,
send_advert, etc.) on mc.commands, not directly on the MeshCore
instance. Updated all DeviceManager calls accordingly.

Fixes: channels not loading, message sending, advert, battery, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:49:04 +01:00
MarekWo
1b142b26f2 fix(v2): Use Flask current_app for DeviceManager lookup in cli.py
The module-level 'from app.main import device_manager' was returning
None in Flask request context even though device_manager was set.
Now tries current_app.device_manager first (Flask app context),
falling back to module import for non-request contexts.

Fixes 500 errors on /api/contacts, /api/contacts/pending,
/api/contacts/detailed, and /api/channels endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:33:07 +01:00
MarekWo
adf17d2d54 fix(v2): Add connection retry logic and self_info null guard
- Add _connect_with_retry() with exponential backoff (10 attempts)
- Guard against self_info being None after meshcore library disconnects
  due to unresponsive device
- Prevents crash when device is busy (e.g. held by orphan container)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:23:05 +01:00
MarekWo
2e95bbf9b5 fix(v2): Serial port auto-detection and channel_messages query
- Add _detect_serial_port() to DeviceManager — resolves 'auto' to
  actual device via /dev/serial/by-id with common path fallbacks
- Make channel_idx optional in get_channel_messages() so status and
  channel-updates endpoints can query across all channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:15:44 +01:00
MarekWo
e98acf6afa feat(v2): Add pkt_payload to DMs, update watchdog for single container
- Add pkt_payload column to direct_messages table for stable packet
  hash generation and Analyzer URL linking
- Update insert_direct_message() and DeviceManager to store pkt_payload
- Add test for DM pkt_payload storage (43 tests pass)
- Update watchdog to monitor only mc-webui (meshcore-bridge removed)
- USB reset trigger now fires for mc-webui container failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:01:43 +01:00
MarekWo
df8e2d2218 feat(v2): Route API endpoints through Database, remove bridge
- Update api.py: messages, contacts, DM endpoints read from SQLite DB
- Add DB fallback paths for parser.py backward compatibility
- Replace bridge echo registration with DeviceManager event handling
- Update status endpoint to use db.get_stats()
- Update channel updates/DM updates endpoints for DB queries
- Delete channel messages via DB instead of parser
- Remove meshcore-bridge/ directory (no longer needed in v2)
- Remove MC_BRIDGE_URL from config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:28:14 +01:00
MarekWo
badf67cf74 feat(v2): Rewrite main.py and cli.py for direct device communication
main.py: Initialize Database + DeviceManager in create_app(), replace
bridge-dependent startup code, simplified console command router.
cli.py: All functions now delegate to DeviceManager instead of HTTP
bridge calls. Same signatures preserved for api.py compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:23:59 +01:00
MarekWo
a8a0becb13 feat(v2): Complete DeviceManager with event handlers and commands
Event handlers: channel messages, DMs, ACKs, adverts, path updates,
new contacts, disconnection — all write to Database + emit SocketIO.
Command methods: send_channel_message, send_dm, get/delete contacts,
get/set/remove channels, send_advert, check_connection, battery,
manual_add_contacts, pending contacts approval.
Auto message fetching and initial contact sync on connect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:21:25 +01:00
MarekWo
8959261aca test(v2): Add 42 integration tests for Database class
Tests cover: schema init, WAL mode, device info, contacts CRUD
(with protection, GPS, upsert semantics), channels, channel messages
(limit/offset/filter), DMs with conversations, ACKs, echoes,
FTS5 search (channel+DM+combined), read status, muting,
backup/restore, cleanup, advertisements, and paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:04:28 +01:00
MarekWo
bd825f48c3 feat(v2): Single container Docker setup with direct USB access
Replace two-container bridge architecture with single container.
Dockerfile adds udev for serial device support.
docker-compose.yml: one service with cgroup rules for ttyUSB/ttyACM,
SQLite DB path, backup settings, optional TCP mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:02:11 +01:00
MarekWo
c9cf37e8d5 feat(v2): Add DeviceManager skeleton with connect/disconnect
Background thread runs meshcore async event loop. Supports both
serial and TCP transports. Flask routes bridge sync→async via
execute() method. Event subscriptions marked as TODO for Phase 1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:01:16 +01:00
MarekWo
68b14434ca feat(v2): Add Database class with full CRUD and backup
Sync SQLite wrapper with WAL mode, connection-per-call thread safety.
Methods for: device info, contacts (upsert/get/delete/protect),
channels, channel messages, DMs, ACKs, echoes, paths, advertisements,
read status, FTS5 search, stats, cleanup, and sqlite3.backup().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:59:39 +01:00
MarekWo
9f9b6e7ed7 feat(v2): Add SQLite schema with 10 tables, indexes and FTS5
Tables: device, contacts, channels, channel_messages, direct_messages,
acks, echoes, paths, advertisements, read_status.
Includes schema_version for migrations, FTS5 virtual tables with
auto-sync triggers for full-text search on messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:58:19 +01:00
MarekWo
ebfc3c9845 feat(v2): Add v2 config settings (DB, TCP, backup)
New environment variables for Phase 0:
- MC_DB_PATH: SQLite database location
- MC_TCP_HOST/MC_TCP_PORT: TCP transport (meshcore-proxy)
- MC_BACKUP_ENABLED/HOUR/RETENTION_DAYS: automated backup
- MC_AUTO_RECONNECT, MC_LOG_LEVEL: connection management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:57:08 +01:00
MarekWo
a0eb590baa chore(v2): Add meshcore dependency and gitignore docs/v2
- Add meshcore>=2.2.0 for direct device communication (Phase 0.1)
- Exclude docs/v2/ from git (local working notes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:56:32 +01:00
MarekWo
2254580f01 chore(v2): Initialize v2 branch with status tracking
- Create v2 branch for mc-webui direct device communication migration
- Add docs/v2/STATUS.md for development progress tracking
- Exclude PRD documents from git (local-only planning docs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:42:24 +01:00
MarekWo
39f4a71538 fix(dm): Fix PATH_UPDATE race condition and confirm all retry acks
The retry thread was removing pending_flood_acks immediately after
exhausting retries. PATH_UPDATE arriving even 1 second later would
find no pending entry to match, leaving the message undelivered.

Changes:
- Don't clean up pending_flood_acks in retry threads, keep entries
  alive for late PATH_UPDATE arrivals
- Add TTL-based cleanup (2 min) in _process_path_update
- When PATH_UPDATE confirms delivery, save synthetic ACK for the
  original ack code AND all retry group ack codes so the frontend
  ack_status polling finds a match regardless of which code it checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:02:42 +01:00
MarekWo
66b553a8b5 fix(dm): Retry flood DM when initial send fails with device error
When sending a DM to a no-path contact, meshcli can return
"Error sending message" if the device-level flood send fails.
Previously this was treated as a terminal failure with no retry.

Now detects the error and starts a background flood retry thread
that re-attempts up to auto_retry_flood_only times (default 3),
waiting between attempts for a PATH_UPDATE to establish the route.
If a retry succeeds, registers for normal delivery tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:58:48 +01:00
MarekWo
2764e1c551 feat(dm): Add PATH-based delivery tracking for flood DMs
When sending DM to contacts without a known path (flood mode), ACK is
piggybacked inside the PATH response packet and not visible on stdout.
This adds PATH_UPDATE event parsing to confirm flood DM delivery.

Changes:
- Enable print_path_updates in meshcli init for PATH_UPDATE events
- Parse PATH echoes from json_log_rx -> .path.jsonl log (diagnostics)
- Parse PATH_UPDATE events to confirm flood DM delivery by public_key
- Replace "skip retry for no-path contacts" with proper flood retry
  (max 3 attempts, matching standard Meshcore app behavior)
- Split _retry_send into _retry_send_flood and _retry_send_direct
- Refactor _get_contact_path_len -> _get_contact_info (returns both
  out_path_len and public_key)
- Add GET /paths endpoint for diagnostics
- Add flood_only config param for auto-retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:33:39 +01:00
MarekWo
6956cd5415 fix(dm): Skip retry for contacts without path (flood sends)
When a contact has no path set (out_path_len == -1), the initial msg
is already sent as flood. Retrying would spam the network with up to
8 flood messages in quick succession.

Now checks contact's out_path_len via .ci command before starting the
retry loop. If no path exists, logs and skips retry entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:34:53 +01:00
MarekWo
8ab19582cd fix(dm): Fix unread markers by deduping retries in all endpoints
Extracted dedup_retry_messages() helper (300s window, was 120s which
was too tight) and applied it in three places:
- GET /api/dm/messages - already had inline dedup, now uses helper
- get_dm_conversations() - fixes last_message_timestamp inflation
- GET /api/dm/updates - was missing dedup entirely, counted retries
  as unread messages (root cause of persistent unread markers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:34:39 +01:00
MarekWo
c2acbb4ba1 fix(dm): Wait for msg JSON response and fix unread markers
Monitor: For .msg commands, keep waiting for expected_ack in response
instead of completing on silence timeout. Waits up to half of cmd_timeout
(5s) for the JSON to arrive before giving up.

Unread: Apply 120s time-window dedup to outgoing messages in conversation
listing, so retry messages don't inflate last_message_timestamp and cause
permanent unread markers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:19:28 +01:00
MarekWo
49159a888c fix(dm): Continue retry on command timeout and dedup retry messages
- Don't abort retry loop when msg command fails or times out - the device
  may be temporarily busy (especially during flood mode)
- Add 120s time-window dedup for outgoing messages with same text+recipient
  to prevent duplicate messages in chat when retry acks aren't tracked

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:58:05 +01:00
MarekWo
0367b38770 fix(dm): Ensure msg JSON response is captured before completing command
The 300ms response silence timeout was too short for .msg commands -
the prompt echo arrived quickly but the JSON with expected_ack could
arrive after a gap > 300ms, causing retry to abort mid-sequence.

- Add 1.5s minimum wait for .msg/.m commands in response monitor
- Don't abort retry loop when ack parsing fails (message was still sent)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:21:28 +01:00
MarekWo
37c2d3d51f fix(dm): Use .msg prefix for JSON output to enable auto-retry
The msg command in meshcli only outputs JSON (with expected_ack and
suggested_timeout) when json_output=True, triggered by the dot prefix
(e.g., .msg instead of msg). Without JSON output, the bridge couldn't
extract ack codes and retry was never triggered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:56:37 +01:00
MarekWo
c0f93029cd fix(dm): Fix auto-retry not triggering and increase retry limits
- Fix JSON parsing: msg command outputs multi-line pretty-printed JSON
  (indent=4), but parser tried line-by-line. Now tries full-text parse
  first, then line-by-line, then regex fallback.
- Change retry limits: 5 direct + 3 flood attempts (was 3 total)
- Separate max_attempts (direct) and max_flood parameters
- Add debug logging when ack extraction fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:39:58 +01:00
MarekWo
c37a7d3b23 feat(dm): Auto-retry for undelivered DM messages
Implement automatic retry for DM messages when ACK is not received,
similar to the MeshCore mobile app's Auto Retry feature. The bridge
monitors for ACK after each send and retries up to 3 times, switching
to flood routing after 2 failed direct attempts via reset_path.

- Bridge: background retry engine with configurable max_attempts,
  flood_after; retry group tracking to prevent duplicate messages
- Bridge: enhanced ACK status checks retry groups so delivery is
  detected even if only a retry attempt's ACK arrives
- Backend: filter retry SENT_MSG duplicates from message list
- Frontend: extended ACK polling window, auto-retry toggle in DM bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:23:32 +01:00
MarekWo
fd4818cfad fix(ui): Remove CSS rule that stacked channel buttons vertically
Remove nested @media (max-width: 400px) rule that forced btn-group
to flex-direction: column, causing buttons to stack on mobile.
Also remove now-unused .list-group-item small styles (channel keys
no longer shown).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 07:35:56 +01:00
MarekWo
71f292d843 feat(ui): Mark-all-read confirmation dialog and compact channel list
Add confirm() dialog before marking all messages as read, showing
list of unread channels with counts. Remove channel key/ID from
Manage Channels modal to save vertical space on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 07:27:35 +01:00
MarekWo
7a4f4d3161 feat(notifications): Channel mute toggle and mark-all-as-read bell button
Add ability to mute notifications for individual channels via Manage
Channels modal (bell/bell-slash toggle button). Muted channels are
excluded from unread badge counts, browser notifications, and app icon
badge. Bell icon click now marks all channels as read in bulk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:00:40 +01:00
MarekWo
ad478a8d47 feat(ui): Add @me filter button, DM filter push-down, and DM FAB toggle
- Add person icon button in filter bar that inserts the current device
  name into the search field, for filtering own messages
- DM filter bar already benefits from the CSS sibling push-down rule
  added in previous commit (same class names used)
- Add collapsible FAB toggle to DM view, same pattern as channel chat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:05:47 +01:00
MarekWo
6310c41934 feat(ui): FAB toggle, filter bar layout fix, and filter @mentions
- Add collapsible FAB container with chevron toggle button to
  temporarily hide floating action buttons that overlap messages
- Make filter bar push messages down instead of overlaying the first
  matched message (CSS sibling selector adds padding-top)
- Add @mentions autocomplete to filter search bar - typing @ shows
  contact list dropdown, selecting inserts plain name (not @[] format)
  so all messages from/mentioning that user are found

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:47:00 +01:00
MarekWo
000c4f6884 fix: Make container port match FLASK_PORT for custom port configurations
Previously, the internal container port was hardcoded to 5000, so setting
FLASK_PORT to a different value would break the port mapping and healthcheck.

Credit: Tymo3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:01:12 +01:00
MarekWo
2f82c589c7 feat(watchdog): Hardware USB bus reset for stuck LoRa devices
Implement a smart auto-detection and low-level fcntl ioctl reset mechanism for LoRa USB devices. This 'last resort' recovery is triggered if the meshcore-bridge container fails to recover after 3 restarts within an 8-minute window. Includes updates to the installer, systemd service, and newly added README.

Co-Authored-By: Gemini CLI <noreply@google.com>
2026-02-22 20:15:27 +00:00
MarekWo
f1e5f39a4e fix: Reload echoes/acks after device name detection
When MC_DEVICE_NAME=auto, _load_echoes() runs with "auto.echoes.jsonl"
which doesn't exist. After actual device name is detected and paths
updated, the data was never reloaded from the correct file, leaving
incoming_paths and echo_counts empty. This caused missing path info
for all messages older than the current bridge session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:57:47 +01:00
MarekWo
bcdc014965 fix: Extend sent echo_counts retention from 1h to 7 days
Same 1-hour cleanup issue as incoming_paths: sent messages lost
their analyzer links after ~1 hour because echo_counts was pruned
on every new send. Now matches .echoes.jsonl 7-day retention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:24:57 +01:00
MarekWo
9ad3435609 fix: Always use attempt=0 payload for analyzer URL computation
The attempt loop (0-3) for matching incoming echo paths left
computed_payload at attempt=3 when no match was found, producing
wrong analyzer hashes. Combined with 1-hour incoming_paths cleanup
in bridge (vs 7-day .echoes.jsonl retention), this caused older
messages to lose both path info and correct analyzer links.

Two fixes:
- Compute base_payload at attempt=0 upfront for analyzer URL
- Extend incoming_paths memory cleanup from 1h to 7 days

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:15:12 +01:00
MarekWo
6d50391ea8 fix: Decode GPS coordinates as int32/1e6, not float
MeshCore encodes lat/lon as little-endian signed int32 divided by 1e6,
not as IEEE 754 floats. This caused all map pins to show at (0,0).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:45:43 +01:00
MarekWo
587bc8cb9f fix: Validate GPS coordinates from advert payloads
Discard NaN, Infinity, and out-of-range lat/lon values from
struct.unpack to prevent JSON parse errors in browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:10:34 +01:00
MarekWo
247b11e1e9 feat: Enrich contacts cache with GPS coordinates and node type
- Extract lat/lon from advert payloads (struct unpack from binary)
- Store type_label and lat/lon in cache from device seed and adverts
- Show Map button for cache contacts with GPS coordinates
- Show colored type badge (CLI/REP/ROOM/SENS) for typed cache contacts
- Type filter now works for cache contacts with known type
- Change counter label from "known" to "cached"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:40:42 +01:00
MarekWo
a5e767e5bf fix: Replace sort buttons with dropdown for mobile-friendly contact filters
Replaces two sort toggle buttons with a single <select> dropdown (e-commerce style)
so all 3 filter/sort controls fit on mobile screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:19:56 +01:00
MarekWo
de0108d6aa feat: Add persistent contacts cache for @mention autocomplete
Contacts cache accumulates all known node names from device contacts
and adverts into a JSONL file, so @mentions work even after contacts
are removed from the device. Background thread scans adverts every
45s and parses advert payloads to extract public keys and node names.

Existing Contacts page now shows merged view with "Cache" badge for
contacts not on device, plus source filter (All/On device/Cache only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:13:36 +01:00
MarekWo
0a73556c78 fix: Use bi-clipboard-data icon for analyzer (bi-flask unavailable)
bi-flask was added in Bootstrap Icons 1.12+, but project uses 1.11.2.
Replace with bi-clipboard-data which is available and conveys
"data analysis".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:24:43 +01:00
MarekWo
5a7a9476f8 feat: Always show analyzer link for incoming msgs + flask icon
Generate analyzer_url from computed pkt_payload for all incoming
channel messages, not just those with echo path matches. This means
the analyzer button appears even when no route paths were captured.

Also change analyzer button icon from bi-search (magnifying glass)
to bi-flask (lab flask) to better convey "analysis/inspection".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:20:10 +01:00
MarekWo
68b2166445 fix: Use unstripped raw_text for pkt_payload computation
The parser's .strip() was removing trailing whitespace from message
text, but the encrypted radio payload includes those trailing spaces.
This caused pkt_payload mismatches for messages ending with spaces
(e.g., "Dzień dobry "). Use original unstripped text for raw_text.

Also add debug logging for unmatched messages to help diagnose
remaining edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:09:28 +01:00
MarekWo
28148d32d8 feat: Deterministic echo-to-message matching via pkt_payload computation
Replace unreliable timestamp-based heuristic (±10s window) with exact
cryptographic matching for incoming channel message routes. Compute
pkt_payload by reconstructing the AES-128-ECB encrypted packet from
message data (sender_timestamp, txt_type, text) + channel secret, then
match against echo data by exact key lookup.

Also accumulate ALL route paths per message (previously only last path
was kept due to dict overwrite), and display them in a multi-path popup
showing SNR and hops for each route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 07:29:49 +01:00
MarekWo
2ed3dc3758 feat: Add unknown delivery status indicator + update docs
Add clickable "?" icon on DMs without ACK, showing a popup
explaining that delivery is unknown (mobile-friendly).
Update README, user guide with new features (Analyzer links,
DM delivery tracking).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:14:58 +01:00
MarekWo
235c74338d fix: Skip redundant DM refreshes once delivery ACK is confirmed
Stop scheduling further post-send reloads as soon as the last
own message shows a delivery checkmark.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 07:51:36 +01:00
MarekWo
cdd28e66fc fix: Auto-refresh DM view after send to show delivery status
Add two extra delayed reloads (6s, 15s) after sending a DM,
matching the channel chat pattern, so ACK checkmarks appear
without needing to send another message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:39:14 +01:00
MarekWo
7a960f2556 feat: Add DM delivery tracking via ACK packet detection
Bridge captures ACK packets from meshcli stdout (json_log_rx),
persists to .acks.jsonl, and exposes /ack_status endpoint.
Delivery status is merged server-side into DM messages and
displayed as a green checkmark with SNR/route tooltip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:30:33 +01:00
MarekWo
cf537628cf feat: Add MeshCore Analyzer link button to channel messages
Compute packet_hash from pkt_payload (SHA-256 of type byte + payload)
and generate analyzer.letsmesh.net links. Button appears on both sent
and received messages when echo data is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:26:43 +01:00
MarekWo
4bb33a7346 upg: Meshcore-cli upgrade to 1.4.2 2026-02-15 16:06:49 +01:00
MarekWo
eb303c35ad fix: Filter meshcli prompt lines to eliminate false WARN results
Prompt lines (DeviceName|* ...) and summary lines (> N contacts)
are normal meshcli output, not format changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:22:37 +01:00
MarekWo
bb0937e52a fix: Show unparsed line content in WARN messages for easier diagnosis
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:20:40 +01:00
MarekWo
527204ea87 fix: Support piped execution for compat checker (env vars instead of argparse)
The script runs from host piped into the container, so argparse
doesn't work with stdin. Use env vars (BRIDGE_URL, FULL) as primary
config with fallback CLI arg parsing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:16:26 +01:00
MarekWo
47877fb9e1 feat: Add meshcore-cli compatibility checker script
Diagnostic tool that tests all meshcli commands and response formats
used by mc-webui against a running bridge instance, detecting breaking
changes early when updating meshcore-cli versions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:05:40 +01:00
MarekWo
35c47de624 fix: Update advert/echo log paths when device name is detected
When MC_DEVICE_NAME=auto, the bridge initially creates log files as
auto.adverts.jsonl and auto.echoes.jsonl. After detecting the real
device name, it now renames them and updates paths. Also adds
echoes_log to the /health endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:35:31 +01:00
MarekWo
f35b4ebe95 fix: Retry device name detection when bridge is not ready at startup
The background thread now retries with exponential backoff (5s→60s)
instead of giving up after 3 attempts. Also accepts detected device
name from bridge even when bridge health status is unhealthy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:14:41 +01:00
MarekWo
1d8449138d docs: Add troubleshooting section for unresponsive device (firmware corruption)
Documented the bridge crash-loop scenario where the MeshCore device
serial port connects but firmware doesn't respond to commands,
including symptoms, what doesn't help, and the fix (re-flash firmware).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 10:33:32 +01:00
70 changed files with 17584 additions and 4636 deletions

View File

@@ -2,17 +2,25 @@
# Copy this file to .env and adjust values for your setup
# ============================================
# MeshCore Device Configuration
# MeshCore Device Connection
# ============================================
# Two transport options: Serial (USB) or TCP (network).
# Set MC_TCP_HOST to use TCP; leave empty to use serial.
# Serial port path
# --- Option A: Serial (default) ---
# Use "auto" for automatic detection (recommended if only one USB device)
# Or specify manually: /dev/serial/by-id/usb-xxx or /dev/ttyUSB0
# Find available devices: ls /dev/serial/by-id/
MC_SERIAL_PORT=auto
# --- Option B: TCP (e.g. remote device via ser2net, meshcore-proxy) ---
# Set the IP/hostname of the device to connect via TCP instead of serial.
# When MC_TCP_HOST is set, MC_SERIAL_PORT is ignored.
# MC_TCP_HOST=192.168.1.100
# MC_TCP_PORT=5555
# Your MeshCore device name (used for .msgs file)
# Use "auto" for automatic detection from meshcli (recommended)
# Use "auto" for automatic detection from device (recommended)
# Or specify manually: MarWoj, SP5XYZ, MyNode
MC_DEVICE_NAME=auto

6
.gitignore vendored
View File

@@ -76,6 +76,7 @@ data/
# ============================================
*.log
*.sql
!app/schema.sql
*.sqlite
*.db
@@ -102,3 +103,8 @@ docs/UI-Contact-Management-MVP-v2.md
docs/TEST-PLAN-Contact-Management-v2.md
docs/github-discussion-*.md
docs/github-response-spaces-in-device-name.md
docs/check-compat-howto.md
docs/v2/
docs/PRD-mc-webui-2.md
docs/PRD-mc-webui-2-en.html
docs/PRD-mc-webui-2-pl.html

View File

@@ -1,10 +1,13 @@
# mc-webui Dockerfile
# Python 3.11+ with Flask (meshcore-cli runs in separate bridge container)
# mc-webui v2 Dockerfile
# Single container with direct MeshCore device access (serial/TCP)
FROM python:3.11-slim
# Install curl for testing
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Install system deps: curl (healthcheck), udev (serial device support)
RUN apt-get update && apt-get install -y \
curl \
udev \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
@@ -17,7 +20,6 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
# Note: Run 'python -m app.version freeze' before build to include version info
# The version_frozen.py file will be copied automatically if it exists
COPY app/ ./app/
# Expose Flask port

View File

@@ -1,28 +1,38 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
# mc-webui
A lightweight web interface for meshcore-cli, providing browser-based access to MeshCore mesh network.
A lightweight web interface providing browser-based access to MeshCore mesh network.
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/MarekWo/mc-webui)
## Overview
**mc-webui** is a Flask-based web application that wraps `meshcore-cli`, eliminating the need for SSH/terminal access when using MeshCore chat on a LoRa device connected to a Debian VM via BLE or USB. Tested on Heltec V3 and Heltec V4.
**mc-webui** is a Flask-based web application providing browser-based access to MeshCore mesh network. It communicates directly with your LoRa device (via USB, BLE, or TCP) using the `meshcore` Python library, eliminating the need for SSH/terminal access. Tested on Heltec V3 and Heltec V4.
![Diagram](images/diagram.jpeg)
## Key Features
- **Mobile-first design** - Responsive UI optimized for small screens
- **Mobile-first design** - Responsive UI optimized for small screens, with desktop sidebar for wide screens
- **Channel management** - Create, join, share (QR code), and switch between encrypted channels
- **Direct Messages (DM)** - Private messaging with delivery status tracking
- **Direct Messages (DM)** - Private messaging with searchable contact selector, delivery tracking, configurable retry strategy, and multi-path routing
- **Smart notifications** - Unread message counters per channel with cross-device sync
- **Contact management** - Manual approval mode, filtering, protection, cleanup tools
- **Contact map** - View contacts with GPS coordinates on OpenStreetMap (Leaflet)
- **Contact management** - Manual approval, add via URI/QR, filtering, protection, ignoring, blocking, batch operations, and cleanup tools
- **Global search** - Full-text search across all messages (channels and DMs) with FTS5 backend
- **Database** - Fast and reliable SQLite storage for messages, contacts, and configurations
- **Contact map** - View contacts and own device on OpenStreetMap (Leaflet) with last seen info
- **Message archives** - Automatic daily archiving with browse-by-date selector
- **Interactive Console** - Direct meshcli command execution via WebSocket
- **Interactive Console** - Full MeshCore command suite via WebSocket — repeater, contact, device, and channel management
- **Device dashboard** - Device info, statistics, and contact sharing (QR code / URI)
- **Dark/Light theme** - Toggle between dark and light UI themes
- **Settings** - Configurable DM retry parameters, message retention, quote length, and theme
- **System Log** - Real-time log viewer with streaming
- **Database backup** - Create, list, and download database backups from the UI
- **@Mentions autocomplete** - Type @ to see contact suggestions with fuzzy search
- **Echo tracking** - "Heard X repeats" with repeater IDs for sent messages, route path for incoming messages (persisted across restarts)
- **Echo tracking** - "Heard X repeats" with repeater IDs for sent messages, all route paths for incoming messages with deterministic payload matching (persisted across restarts)
- **MeshCore Analyzer** - View packet details on analyzer.letsmesh.net directly from channel messages
- **DM delivery tracking** - ACK-based delivery confirmation with SNR and route info
- **Multi-device support** - Database file named after device public key for easy multi-device setups
- **PWA support** - Browser notifications and installable app (experimental)
- **Full offline support** - Works without internet (local Bootstrap, icons, emoji picker)
@@ -41,9 +51,9 @@ For detailed feature documentation, see the [User Guide](docs/user-guide.md).
- Docker and Docker Compose installed ([installation guide](docs/docker-install.md))
**Important Notes:**
- No meshcore-cli installation required on host - automatically installed inside Docker container
- Powered by direct meshcore library integration (v2 architecture)
- No manual directory setup needed - all data stored in `./data/` inside the project directory
- meshcore-cli version 1.3.12+ is automatically installed for proper DM functionality
- Uses a single-container architecture with a fast SQLite database
---
@@ -96,16 +106,16 @@ For detailed feature documentation, see the [User Guide](docs/user-guide.md).
This will:
- Download base images (Python, Alpine Linux)
- Install meshcore-cli inside containers
- Install the `meshcore` Python library
- Create `./data/` directory structure automatically
- Start both containers (meshcore-bridge and mc-webui)
- Start the mc-webui container
5. **Verify installation**
```bash
docker compose ps
```
Both containers should show `Up` status. Check logs if needed:
The container should show `Up` status. Check logs if needed:
```bash
docker compose logs -f
```
@@ -133,7 +143,9 @@ For detailed feature documentation, see the [User Guide](docs/user-guide.md).
3. **Switch channels** - Use the dropdown in navbar
4. **Direct Messages** - Access via menu (☰) → "Direct Messages"
5. **Manage contacts** - Access via menu (☰) → "Contact Management"
6. **Console** - Access via menu (☰) → "Console" for direct meshcli commands
6. **Console** - Access via menu (☰) → "Console" for MeshCore commands
7. **Search** - Access via menu (☰) → "Search" for full-text message search
8. **Settings** - Access via menu (☰) → "Settings" for DM retry and other configuration
For complete usage instructions, see the [User Guide](docs/user-guide.md).
@@ -263,6 +275,12 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
<td align="center"><a href="gallery/approve_contact.png"><img src="gallery/approve_contact.png" width="150"><br>Approve Contact</a></td>
<td align="center"><a href="gallery/channel_management.png"><img src="gallery/channel_management.png" width="150"><br>Channel Management</a></td>
</tr>
<tr>
<td align="center"><a href="gallery/global_search.png"><img src="gallery/global_search.png" width="150"><br>Global Search</a></td>
<td align="center"><a href="gallery/message_filtering.png"><img src="gallery/message_filtering.png" width="150"><br>Message Filtering</a></td>
<td align="center"><a href="gallery/DM_Settings.png"><img src="gallery/DM_Settings.png" width="150"><br>Settings</a></td>
<td align="center"><a href="gallery/sytem_log.png"><img src="gallery/sytem_log.png" width="150"><br>System Log</a></td>
</tr>
<tr>
<td align="center"><a href="gallery/map.png"><img src="gallery/map.png" width="150"><br>Map</a></td>
<td align="center"><a href="gallery/map_individual.png"><img src="gallery/map_individual.png" width="150"><br>Map (Individual)</a></td>
@@ -289,31 +307,39 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
### Completed Features
- [x] Environment Setup & Docker Architecture
- [x] Backend Basics (REST API, message parsing, CLI wrapper)
- [x] Frontend Chat View (Bootstrap UI, message display)
- [x] Message Sending (Send form, reply functionality)
- [x] Environment Setup & Docker Architecture (single-container, direct device access)
- [x] Backend Basics (REST API, SQLite database, meshcore library integration)
- [x] Frontend Chat View (Bootstrap UI, message display, quote/reply)
- [x] Message Sending (Send form, reply, quote with configurable length)
- [x] Intelligent Auto-refresh (10s checks, UI updates only when needed)
- [x] Contact Management (Cleanup modal with configurable threshold)
- [x] Contact Management (Approval, add via URI/QR, filtering, protection, ignore/block, batch operations, cleanup)
- [x] Channel Management (Create, join, share via QR, delete with auto-cleanup)
- [x] Public Channels (# prefix support, auto-key generation)
- [x] Message Archiving (Daily archiving with browse-by-date selector)
- [x] Smart Notifications (Unread counters per channel and total)
- [x] Direct Messages (DM) - Private messaging with delivery status tracking
- [x] Advanced Contact Management - Multi-page interface with sorting, filtering
- [x] Direct Messages (DM) - Searchable contact selector, delivery tracking, configurable retry, multi-path routing
- [x] Global Message Search - Full-text search across channels and DMs (FTS5)
- [x] Message Content Enhancements - Mention badges, clickable URLs, image previews
- [x] @Mentions Autocomplete - Type @ to get contact suggestions with fuzzy search
- [x] PWA Notifications (Experimental) - Browser notifications and app badge counters
- [x] Full Offline Support - Local Bootstrap libraries and Service Worker caching
- [x] Interactive Console - Direct meshcli access via WebSocket with command history
- [x] Contact Map - View contacts with GPS coordinates on OpenStreetMap (Leaflet)
- [x] Interactive Console - Full MeshCore command suite (repeater, contact, device, channel management)
- [x] Contact Map - View contacts and own device on OpenStreetMap (Leaflet)
- [x] Echo Tracking - "Heard X repeats" badge for sent channel messages
- [x] MeshCore Analyzer - Packet analysis links on channel messages (analyzer.letsmesh.net)
- [x] DM Delivery Tracking - ACK-based delivery checkmarks with SNR/route details
- [x] Device Dashboard - Device info, statistics, and contact sharing (QR/URI)
- [x] Settings Modal - DM retry parameters, message retention, and dark/light theme
- [x] System Log - Real-time log viewer with streaming
- [x] Database Backup - Create, list, and download backups from the UI
- [x] Desktop Sidebar - Channel/contact sidebar for wide screens (tablet/desktop)
- [x] Dark/Light Theme - Toggle between dark and light UI themes
- [x] Multi-device Support - Database file named after device public key
### Next Steps
- [ ] Performance Optimization - Frontend and backend improvements
- [ ] Enhanced Testing - Unit and integration tests
- [ ] Documentation Polish - API docs and usage guides
---
@@ -342,7 +368,7 @@ This is an open-source project. Contributions are welcome!
## References
- [MeshCore Documentation](https://meshcore.org)
- [meshcore-cli GitHub](https://github.com/meshcore-dev/meshcore-cli)
- [meshcore Python library](https://pypi.org/project/meshcore/)
---

View File

@@ -6,12 +6,16 @@ from app.archiver.manager import (
archive_messages,
list_archives,
get_archive_path,
schedule_daily_archiving
schedule_daily_archiving,
schedule_retention,
init_retention_schedule
)
__all__ = [
'archive_messages',
'list_archives',
'get_archive_path',
'schedule_daily_archiving'
'schedule_daily_archiving',
'schedule_retention',
'init_retention_schedule'
]

View File

@@ -20,6 +20,11 @@ _scheduler: Optional[BackgroundScheduler] = None
# Job IDs
CLEANUP_JOB_ID = 'daily_cleanup'
RETENTION_JOB_ID = 'daily_retention'
BACKUP_JOB_ID = 'daily_backup'
# Module-level db reference (set by init_retention_schedule)
_db = None
def get_local_timezone_name() -> str:
@@ -291,7 +296,7 @@ def _cleanup_job():
return
# Convert to list format (same as preview-cleanup endpoint)
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
type_labels = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
contacts = []
for public_key, details in contacts_detailed.items():
contacts.append({
@@ -460,6 +465,103 @@ def init_cleanup_schedule():
logger.error(f"Error initializing cleanup schedule: {e}", exc_info=True)
def _retention_job():
"""Background job that runs daily to delete old messages from DB."""
logger.info("Running daily retention job...")
try:
from app.routes.api import get_retention_settings
settings = get_retention_settings()
if not settings.get('enabled'):
logger.info("Message retention is disabled, skipping")
return
if _db is None:
logger.error("Database not available for retention job")
return
days = settings.get('days', 90)
include_dms = settings.get('include_dms', False)
include_adverts = settings.get('include_adverts', False)
result = _db.cleanup_old_messages(
days=days,
include_dms=include_dms,
include_adverts=include_adverts
)
total = sum(result.values())
logger.info(f"Retention job completed: {total} rows deleted ({result})")
except Exception as e:
logger.error(f"Retention job failed: {e}", exc_info=True)
def schedule_retention(enabled: bool, hour: int = 2) -> bool:
"""Add or remove the retention job from the scheduler."""
global _scheduler
if _scheduler is None:
logger.warning("Scheduler not initialized, cannot schedule retention")
return False
try:
if enabled:
if not isinstance(hour, int) or hour < 0 or hour > 23:
hour = 2
trigger = CronTrigger(hour=hour, minute=30)
_scheduler.add_job(
func=_retention_job,
trigger=trigger,
id=RETENTION_JOB_ID,
name='Daily Message Retention',
replace_existing=True
)
tz_name = get_local_timezone_name()
logger.info(f"Retention job scheduled - will run daily at {hour:02d}:30 ({tz_name})")
else:
try:
_scheduler.remove_job(RETENTION_JOB_ID)
logger.info("Retention job removed from scheduler")
except Exception:
pass
return True
except Exception as e:
logger.error(f"Error scheduling retention: {e}", exc_info=True)
return False
def init_retention_schedule(db=None):
"""Initialize retention schedule from saved settings. Call at startup."""
global _db
if db is not None:
_db = db
try:
from app.routes.api import get_retention_settings
settings = get_retention_settings()
if settings.get('enabled'):
hour = settings.get('hour', 2)
schedule_retention(enabled=True, hour=hour)
tz_name = get_local_timezone_name()
logger.info(f"Message retention enabled from saved settings (hour={hour:02d}:30 {tz_name})")
else:
logger.info("Message retention is disabled in saved settings")
except Exception as e:
logger.error(f"Error initializing retention schedule: {e}", exc_info=True)
def schedule_daily_archiving():
"""
Initialize and start the background scheduler for daily archiving.
@@ -502,10 +604,60 @@ def schedule_daily_archiving():
# Initialize cleanup schedule from saved settings
init_cleanup_schedule()
# Initialize backup schedule
init_backup_schedule()
except Exception as e:
logger.error(f"Failed to start archive scheduler: {e}", exc_info=True)
def init_backup_schedule():
"""Initialize daily backup job from config."""
global _scheduler
if _scheduler is None:
return
if not config.MC_BACKUP_ENABLED:
logger.info("Backup is disabled in configuration")
return
try:
backup_hour = config.MC_BACKUP_HOUR
trigger = CronTrigger(hour=backup_hour, minute=0)
backup_dir = Path(config.MC_CONFIG_DIR) / 'backups'
_scheduler.add_job(
func=_backup_job,
trigger=trigger,
id=BACKUP_JOB_ID,
name='Daily Database Backup',
replace_existing=True,
args=[backup_dir]
)
logger.info(f"Backup schedule initialized: daily at {backup_hour:02d}:00")
except Exception as e:
logger.error(f"Error scheduling backup: {e}", exc_info=True)
def _backup_job(backup_dir):
"""Execute daily backup and cleanup old backups."""
global _db
if _db is None:
logger.warning("No database reference for backup")
return
try:
backup_path = _db.create_backup(backup_dir)
logger.info(f"Daily backup completed: {backup_path}")
removed = _db.cleanup_old_backups(backup_dir, config.MC_BACKUP_RETENTION_DAYS)
if removed > 0:
logger.info(f"Cleaned up {removed} old backup(s)")
except Exception as e:
logger.error(f"Backup job failed: {e}", exc_info=True)
def stop_scheduler():
"""
Stop the background scheduler.

View File

@@ -18,14 +18,29 @@ class Config:
MC_DEVICE_NAME = os.getenv('MC_DEVICE_NAME', 'MeshCore')
MC_CONFIG_DIR = os.getenv('MC_CONFIG_DIR', '/root/.config/meshcore')
# MeshCore Bridge configuration
MC_BRIDGE_URL = os.getenv('MC_BRIDGE_URL', 'http://meshcore-bridge:5001/cli')
# MC_BRIDGE_URL removed in v2 (direct device communication)
# Archive configuration
# Archive configuration (v1 — archives move to SQLite in v2)
MC_ARCHIVE_DIR = os.getenv('MC_ARCHIVE_DIR', '/root/.archive/meshcore')
MC_ARCHIVE_ENABLED = os.getenv('MC_ARCHIVE_ENABLED', 'true').lower() == 'true'
MC_ARCHIVE_RETENTION_DAYS = int(os.getenv('MC_ARCHIVE_RETENTION_DAYS', '7'))
# v2: Database
MC_DB_PATH = os.getenv('MC_DB_PATH', '') # empty = auto: {MC_CONFIG_DIR}/mc_{pubkey_prefix}.db
# v2: TCP connection (alternative to serial, e.g. meshcore-proxy)
MC_TCP_HOST = os.getenv('MC_TCP_HOST', '') # empty = use serial
MC_TCP_PORT = int(os.getenv('MC_TCP_PORT', '5555'))
# v2: Backup
MC_BACKUP_ENABLED = os.getenv('MC_BACKUP_ENABLED', 'true').lower() == 'true'
MC_BACKUP_HOUR = int(os.getenv('MC_BACKUP_HOUR', '2'))
MC_BACKUP_RETENTION_DAYS = int(os.getenv('MC_BACKUP_RETENTION_DAYS', '7'))
# v2: Connection
MC_AUTO_RECONNECT = os.getenv('MC_AUTO_RECONNECT', 'true').lower() == 'true'
MC_LOG_LEVEL = os.getenv('MC_LOG_LEVEL', 'INFO')
# Flask server configuration
FLASK_HOST = os.getenv('FLASK_HOST', '0.0.0.0')
FLASK_PORT = int(os.getenv('FLASK_PORT', '5000'))
@@ -42,10 +57,23 @@ class Config:
"""Get the full path to archive directory"""
return Path(self.MC_ARCHIVE_DIR)
@property
def db_path(self) -> Path:
"""Get SQLite database path"""
if self.MC_DB_PATH:
return Path(self.MC_DB_PATH)
return Path(self.MC_CONFIG_DIR) / 'mc-webui.db'
@property
def use_tcp(self) -> bool:
"""True if TCP transport should be used instead of serial"""
return bool(self.MC_TCP_HOST)
def __repr__(self):
transport = f"tcp={self.MC_TCP_HOST}:{self.MC_TCP_PORT}" if self.use_tcp else f"serial={self.MC_SERIAL_PORT}"
return (
f"Config(device={self.MC_DEVICE_NAME}, "
f"port={self.MC_SERIAL_PORT}, "
f"{transport}, "
f"config_dir={self.MC_CONFIG_DIR})"
)

110
app/contacts_cache.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Contacts Cache - DB-backed contact name/key lookup.
All contact data is stored in the SQLite contacts table.
JSONL files are no longer used.
Kept for backward compatibility: get_all_names(), get_all_contacts(),
parse_advert_payload().
"""
import logging
import math
import struct
from flask import current_app
logger = logging.getLogger(__name__)
_TYPE_LABELS = {0: 'COM', 1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
def _get_db():
"""Get database instance from Flask app context."""
return getattr(current_app, 'db', None)
def get_all_contacts() -> list:
"""Get all known contacts from DB."""
try:
db = _get_db()
if db:
contacts = db.get_contacts()
return [{
'public_key': c.get('public_key', ''),
'name': c.get('name', ''),
'first_seen': c.get('first_seen', ''),
'last_seen': c.get('last_seen', ''),
'source': c.get('source', ''),
'lat': c.get('adv_lat', 0.0) or 0.0,
'lon': c.get('adv_lon', 0.0) or 0.0,
'type_label': _TYPE_LABELS.get(c.get('type', 1), 'UNKNOWN'),
} for c in contacts]
except Exception as e:
logger.error(f"Failed to get contacts: {e}")
return []
def get_all_names() -> list:
"""Get all unique non-empty contact names sorted alphabetically."""
try:
db = _get_db()
if db:
contacts = db.get_contacts()
return sorted(set(c.get('name', '') for c in contacts if c.get('name')))
except Exception as e:
logger.error(f"Failed to get contact names: {e}")
return []
def parse_advert_payload(pkt_payload_hex: str):
"""
Parse advert pkt_payload to extract public_key, node_name, and GPS coordinates.
Layout of pkt_payload (byte offsets):
[0:32] Public Key (32 bytes = 64 hex chars)
[32:36] Timestamp (4 bytes)
[36:100] Signature (64 bytes)
[100] App Flags (1 byte) - bit 4: Location, bit 7: Name
[101+] If Location (bit 4): Lat (4 bytes, LE int32/1e6) + Lon (4 bytes, LE int32/1e6)
If Name (bit 7): Node name (UTF-8, variable length)
Returns:
(public_key_hex, node_name, lat, lon) or (None, None, 0, 0) on failure
"""
try:
raw = bytes.fromhex(pkt_payload_hex)
if len(raw) < 101:
return None, None, 0.0, 0.0
public_key = pkt_payload_hex[:64].lower()
app_flags = raw[100]
has_location = bool(app_flags & 0x10) # bit 4
has_name = bool(app_flags & 0x80) # bit 7
lat, lon = 0.0, 0.0
name_offset = 101
if has_location:
if len(raw) >= 109:
lat_i, lon_i = struct.unpack('<ii', raw[101:109])
lat, lon = lat_i / 1e6, lon_i / 1e6
# Validate: discard NaN, Infinity, and out-of-range values
if (math.isnan(lat) or math.isnan(lon) or
math.isinf(lat) or math.isinf(lon) or
not (-90 <= lat <= 90) or not (-180 <= lon <= 180)):
lat, lon = 0.0, 0.0
name_offset += 8
if not has_name:
return public_key, None, lat, lon
if name_offset >= len(raw):
return public_key, None, lat, lon
name_bytes = raw[name_offset:]
node_name = name_bytes.decode('utf-8', errors='replace').rstrip('\x00')
return public_key, node_name if node_name else None, lat, lon
except Exception:
return None, None, 0.0, 0.0

1153
app/database.py Normal file

File diff suppressed because it is too large Load Diff

2781
app/device_manager.py Normal file

File diff suppressed because it is too large Load Diff

84
app/log_handler.py Normal file
View File

@@ -0,0 +1,84 @@
"""
In-memory ring buffer log handler with WebSocket broadcast.
Captures Python log records into a fixed-size deque and optionally
broadcasts them to connected SocketIO clients in real-time.
"""
import logging
from collections import deque
from datetime import datetime
from threading import Lock
class MemoryLogHandler(logging.Handler):
"""Logging handler that stores records in a ring buffer and broadcasts via SocketIO."""
def __init__(self, capacity=2000, socketio=None):
super().__init__()
self.capacity = capacity
self.buffer = deque(maxlen=capacity)
self.socketio = socketio
self._lock = Lock()
self._seq = 0 # monotonic sequence for client catch-up
def emit(self, record):
try:
entry = self._format_record(record)
with self._lock:
self._seq += 1
entry['seq'] = self._seq
self.buffer.append(entry)
# Broadcast to connected clients
if self.socketio:
self.socketio.emit('log_entry', entry, namespace='/logs')
except Exception:
self.handleError(record)
def _format_record(self, record):
"""Convert LogRecord to a serializable dict."""
return {
'timestamp': datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3],
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
}
def get_entries(self, level=None, logger_filter=None, search=None, limit=None):
"""Return filtered log entries from the buffer.
Args:
level: Minimum log level name (DEBUG, INFO, WARNING, ERROR, CRITICAL)
logger_filter: Logger name prefix filter (e.g. 'app.device_manager')
search: Text search in message (case-insensitive)
limit: Max entries to return (newest first before limit, returned in chronological order)
Returns:
List of log entry dicts
"""
level_num = getattr(logging, level.upper(), 0) if level else 0
search_lower = search.lower() if search else None
with self._lock:
entries = list(self.buffer)
# Apply filters
if level_num > 0:
entries = [e for e in entries if getattr(logging, e['level'], 0) >= level_num]
if logger_filter:
entries = [e for e in entries if e['logger'].startswith(logger_filter)]
if search_lower:
entries = [e for e in entries if search_lower in e['message'].lower()]
# Limit (return newest N, in chronological order)
if limit and limit > 0 and len(entries) > limit:
entries = entries[-limit:]
return entries
def get_loggers(self):
"""Return sorted list of unique logger names seen in the buffer."""
with self._lock:
loggers = sorted({e['logger'] for e in self.buffer})
return loggers

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,8 @@ def parse_message(line: Dict, allowed_channels: Optional[List[int]] = None) -> O
return None
timestamp = line.get('timestamp', 0)
text = line.get('text', '').strip()
raw_text = line.get('text', '')
text = raw_text.strip()
if not text:
return None
@@ -69,7 +70,10 @@ def parse_message(line: Dict, allowed_channels: Optional[List[int]] = None) -> O
'is_own': is_own,
'snr': line.get('SNR'),
'path_len': line.get('path_len'),
'channel_idx': channel_idx
'channel_idx': channel_idx,
'sender_timestamp': line.get('sender_timestamp'),
'txt_type': line.get('txt_type', 0),
'raw_text': raw_text
}
@@ -440,7 +444,8 @@ def _parse_sent_msg(line: Dict) -> Optional[Dict]:
'is_own': True,
'txt_type': txt_type,
'conversation_id': conversation_id,
'dedup_key': dedup_key
'dedup_key': dedup_key,
'expected_ack': line.get('expected_ack'),
}
@@ -554,6 +559,26 @@ def read_dm_messages(
return messages, pubkey_to_name
def dedup_retry_messages(messages: List[Dict], window_seconds: int = 300) -> List[Dict]:
"""Collapse outgoing messages with same text+recipient within a time window.
Auto-retry sends multiple SENT_MSG entries for the same message.
This keeps only the first occurrence and drops duplicates within the window.
"""
deduped = []
seen_outgoing = {} # (recipient, text) -> earliest timestamp
for msg in messages:
if msg.get('direction') == 'outgoing':
key = (msg.get('recipient', ''), msg.get('content', ''))
ts = msg.get('timestamp', 0)
prev_ts = seen_outgoing.get(key)
if prev_ts is not None and abs(ts - prev_ts) <= window_seconds:
continue
seen_outgoing[key] = ts
deduped.append(msg)
return deduped
def get_dm_conversations(days: Optional[int] = 7) -> List[Dict]:
"""
Get list of DM conversations with metadata.
@@ -577,6 +602,8 @@ def get_dm_conversations(days: Optional[int] = 7) -> List[Dict]:
"""
messages, pubkey_to_name = read_dm_messages(days=days)
messages = dedup_retry_messages(messages)
# Build reverse mapping: name -> pubkey_prefix
name_to_pubkey = {name: pk for pk, name in pubkey_to_name.items()}

359
app/migrate_v1.py Normal file
View File

@@ -0,0 +1,359 @@
"""
Migrate v1 data (.msgs JSONL) into v2 SQLite database.
Runs automatically on startup if .msgs file exists and database is empty.
Can also be run manually: python -m app.migrate_v1
Migrates:
- Live .msgs file (today's messages)
- Archive .msgs files (historical messages)
- Channel messages (CHAN, SENT_CHAN)
- Direct messages (PRIV, SENT_MSG)
"""
import json
import logging
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
def _find_msgs_file(data_dir: Path, device_name: str) -> Optional[Path]:
"""Find the live .msgs file for the given device name."""
msgs_file = data_dir / f"{device_name}.msgs"
if msgs_file.exists():
return msgs_file
# Try to find any .msgs file in the data dir
candidates = list(data_dir.glob("*.msgs"))
# Exclude archive files (pattern: name.YYYY-MM-DD.msgs)
live_files = [f for f in candidates if f.stem.count('.') == 0]
if len(live_files) == 1:
return live_files[0]
return None
def _find_archive_files(data_dir: Path, device_name: str) -> List[Path]:
"""Find all archive .msgs files, sorted oldest first."""
archive_files = []
# Check common archive locations
archive_dirs = [
data_dir / 'archive', # /data/archive/
data_dir.parent / 'archive', # sibling archive dir
]
for archive_dir in archive_dirs:
if archive_dir.exists():
# Pattern: DeviceName.YYYY-MM-DD.msgs
for f in archive_dir.glob(f"{device_name}.*.msgs"):
# Validate it's an archive file (has date in name)
parts = f.stem.split('.')
if len(parts) >= 2:
archive_files.append(f)
# Also check data_dir itself for archives
for f in data_dir.glob(f"{device_name}.*.msgs"):
parts = f.stem.split('.')
if len(parts) >= 2 and f not in archive_files:
archive_files.append(f)
# Sort by filename (which sorts by date since format is Name.YYYY-MM-DD)
archive_files.sort(key=lambda f: f.name)
return archive_files
def migrate_v1_data(db, data_dir: Path, device_name: str) -> dict:
"""
Import v1 .msgs data into v2 SQLite database.
Imports both live .msgs file and all archive files.
Args:
db: Database instance
data_dir: Path to meshcore config dir containing .msgs file
device_name: Device name (used for .msgs filename and own message detection)
Returns:
dict with migration stats
"""
stats = {
'channel_messages': 0,
'direct_messages': 0,
'skipped': 0,
'errors': 0,
'files_processed': 0,
}
# Collect all files to import: archives first (oldest), then live
files_to_import = []
archive_files = _find_archive_files(data_dir, device_name)
if archive_files:
files_to_import.extend(archive_files)
logger.info(f"Found {len(archive_files)} archive files to migrate")
live_file = _find_msgs_file(data_dir, device_name)
if live_file:
files_to_import.append(live_file)
if not files_to_import:
logger.info("No .msgs files found, skipping v1 migration")
return {'status': 'skipped', 'reason': 'no_msgs_files'}
logger.info(f"Starting v1 data migration: {len(files_to_import)} files to process")
# Track seen timestamps+text to avoid duplicates across archive and live file
seen_channel = set()
seen_dm = set()
for msgs_file in files_to_import:
file_stats = _import_msgs_file(
db, msgs_file, device_name, seen_channel, seen_dm
)
stats['channel_messages'] += file_stats['channel_messages']
stats['direct_messages'] += file_stats['direct_messages']
stats['skipped'] += file_stats['skipped']
stats['errors'] += file_stats['errors']
stats['files_processed'] += 1
stats['status'] = 'completed'
logger.info(
f"v1 migration complete: {stats['files_processed']} files, "
f"{stats['channel_messages']} channel msgs, "
f"{stats['direct_messages']} DMs, {stats['skipped']} skipped, "
f"{stats['errors']} errors"
)
return stats
def _import_msgs_file(db, msgs_file: Path, device_name: str,
seen_channel: set, seen_dm: set) -> dict:
"""Import a single .msgs file. Returns per-file stats."""
stats = {'channel_messages': 0, 'direct_messages': 0, 'skipped': 0, 'errors': 0}
logger.info(f"Importing {msgs_file.name}...")
try:
lines = msgs_file.read_text(encoding='utf-8', errors='replace').splitlines()
except Exception as e:
logger.error(f"Failed to read {msgs_file}: {e}")
stats['errors'] += 1
return stats
for line_num, raw_line in enumerate(lines, 1):
raw_line = raw_line.strip()
if not raw_line:
continue
try:
entry = json.loads(raw_line)
except json.JSONDecodeError:
stats['errors'] += 1
continue
msg_type = entry.get('type')
try:
if msg_type in ('CHAN', 'SENT_CHAN'):
# Dedup key: timestamp + first 50 chars of text
ts = entry.get('timestamp', 0)
text = entry.get('text', '')[:50]
dedup = (ts, text)
if dedup in seen_channel:
stats['skipped'] += 1
continue
seen_channel.add(dedup)
_migrate_channel_msg(db, entry, device_name)
stats['channel_messages'] += 1
elif msg_type == 'PRIV':
ts = entry.get('timestamp', 0)
text = entry.get('text', '')[:50]
dedup = (ts, text)
if dedup in seen_dm:
stats['skipped'] += 1
continue
seen_dm.add(dedup)
_migrate_dm_incoming(db, entry)
stats['direct_messages'] += 1
elif msg_type == 'SENT_MSG':
if entry.get('txt_type', 0) == 0: # Only private messages
ts = entry.get('timestamp', 0)
text = entry.get('text', '')[:50]
dedup = (ts, text)
if dedup in seen_dm:
stats['skipped'] += 1
continue
seen_dm.add(dedup)
_migrate_dm_outgoing(db, entry, device_name)
stats['direct_messages'] += 1
else:
stats['skipped'] += 1
else:
stats['skipped'] += 1
except Exception as e:
stats['errors'] += 1
if stats['errors'] <= 5:
logger.warning(f"Migration error in {msgs_file.name} line {line_num}: {e}")
logger.info(
f" {msgs_file.name}: {stats['channel_messages']} chan, "
f"{stats['direct_messages']} DMs, {stats['skipped']} skip, "
f"{stats['errors']} err"
)
return stats
def _migrate_channel_msg(db, entry: dict, device_name: str):
"""Migrate a CHAN or SENT_CHAN entry."""
raw_text = entry.get('text', '').strip()
if not raw_text:
return
is_own = entry.get('type') == 'SENT_CHAN'
channel_idx = entry.get('channel_idx', 0)
timestamp = entry.get('timestamp', 0)
if is_own:
sender = entry.get('sender', device_name)
content = raw_text
else:
# Parse sender from "SenderName: message" format
if ':' in raw_text:
sender, content = raw_text.split(':', 1)
sender = sender.strip()
content = content.strip()
else:
sender = 'Unknown'
content = raw_text
db.insert_channel_message(
channel_idx=channel_idx,
sender=sender,
content=content,
timestamp=timestamp,
sender_timestamp=entry.get('sender_timestamp'),
is_own=is_own,
txt_type=entry.get('txt_type', 0),
snr=entry.get('SNR'),
path_len=entry.get('path_len'),
pkt_payload=entry.get('pkt_payload'),
raw_json=json.dumps(entry, default=str),
)
def _migrate_dm_incoming(db, entry: dict):
"""Migrate a PRIV (incoming DM) entry."""
text = entry.get('text', '').strip()
if not text:
return
pubkey_prefix = entry.get('pubkey_prefix', '')
sender_name = entry.get('name', '')
# Resolve prefix to full key if contact exists
contact_key = pubkey_prefix if pubkey_prefix else None
if contact_key:
contact_key = _resolve_pubkey(db, contact_key)
# Create/update contact with sender name from v1 data
if contact_key and sender_name:
db.upsert_contact(
public_key=contact_key,
name=sender_name,
source='message',
)
db.insert_direct_message(
contact_pubkey=contact_key,
direction='in',
content=text,
timestamp=entry.get('timestamp', 0),
sender_timestamp=entry.get('sender_timestamp'),
txt_type=entry.get('txt_type', 0),
snr=entry.get('SNR'),
path_len=entry.get('path_len'),
pkt_payload=entry.get('pkt_payload'),
raw_json=json.dumps(entry, default=str),
)
def _migrate_dm_outgoing(db, entry: dict, device_name: str):
"""Migrate a SENT_MSG (outgoing DM) entry."""
text = entry.get('text', '').strip()
if not text:
return
# For outgoing DMs, we don't have recipient pubkey in v1 data.
# In v1, conversation_id was "name_{recipient}" — we store the name
# in raw_json for reference.
recipient = entry.get('recipient', entry.get('name', ''))
# Try to find pubkey from contacts table by recipient name
contact_pubkey = _lookup_pubkey_by_name(db, recipient)
db.insert_direct_message(
contact_pubkey=contact_pubkey,
direction='out',
content=text,
timestamp=entry.get('timestamp', 0),
sender_timestamp=entry.get('sender_timestamp'),
txt_type=entry.get('txt_type', 0),
expected_ack=entry.get('expected_ack'),
pkt_payload=entry.get('pkt_payload'),
raw_json=json.dumps(entry, default=str),
)
def _resolve_pubkey(db, pubkey_prefix: str) -> Optional[str]:
"""Check if a pubkey prefix matches a contact. Returns full key or None."""
if not pubkey_prefix:
return None
try:
contacts = db.get_contacts()
prefix = pubkey_prefix.lower()
for c in contacts:
pk = (c.get('public_key') or '').lower()
if pk and pk.startswith(prefix):
return pk
except Exception:
pass
return None
def _lookup_pubkey_by_name(db, name: str) -> Optional[str]:
"""Look up a contact's public_key by name. Returns None if not found."""
if not name:
return None
try:
contacts = db.get_contacts()
for c in contacts:
if c.get('name') == name:
return c.get('public_key')
except Exception:
pass
return None
def should_migrate(db, data_dir: Path, device_name: str) -> bool:
"""Check if migration is needed: .msgs files exist and DB has no messages."""
# Check for live file
has_live = _find_msgs_file(data_dir, device_name) is not None
# Check for archive files
has_archives = len(_find_archive_files(data_dir, device_name)) > 0
if not has_live and not has_archives:
return False
# Only migrate if DB is empty (no channel messages and no DMs)
try:
stats = db.get_stats()
total = stats.get('channel_messages', 0) + stats.get('direct_messages', 0)
return total == 0
except Exception:
return False

View File

@@ -1,198 +1,143 @@
"""
Read Status Manager - Server-side storage for message read status
Read Status Manager - DB-backed storage for message read status
Manages the last seen timestamps for channels and DM conversations,
providing cross-device synchronization for unread message tracking.
All data is stored in the read_status table of the SQLite database.
"""
import json
import logging
import os
from pathlib import Path
from threading import Lock
from app.config import config
from flask import current_app
logger = logging.getLogger(__name__)
# Thread-safe lock for file operations
_status_lock = Lock()
# Path to read status file
READ_STATUS_FILE = Path(config.MC_CONFIG_DIR) / '.read_status.json'
def _get_default_status():
"""Get default read status structure"""
return {
'channels': {}, # {"0": timestamp, "1": timestamp, ...}
'dm': {} # {"name_User1": timestamp, "pk_abc123": timestamp, ...}
}
def _get_db():
"""Get database instance from Flask app context."""
return getattr(current_app, 'db', None)
def load_read_status():
"""
Load read status from disk.
"""Load read status from database.
Returns:
dict: Read status with 'channels' and 'dm' keys
dict: Read status with 'channels', 'dm', and 'muted_channels' keys
"""
with _status_lock:
try:
if not READ_STATUS_FILE.exists():
logger.info("Read status file does not exist, creating default")
return _get_default_status()
try:
db = _get_db()
rows = db.get_read_status()
with open(READ_STATUS_FILE, 'r', encoding='utf-8') as f:
status = json.load(f)
channels = {}
dm = {}
muted_channels = []
# Validate structure
if not isinstance(status, dict):
logger.warning("Invalid read status structure, resetting")
return _get_default_status()
for key, row in rows.items():
if key.startswith('chan_'):
chan_idx = key[5:] # "chan_0" -> "0"
channels[chan_idx] = row['last_seen_ts']
if row.get('is_muted'):
try:
muted_channels.append(int(chan_idx))
except ValueError:
pass
elif key.startswith('dm_'):
conv_id = key[3:] # "dm_name_User1" -> "name_User1"
dm[conv_id] = row['last_seen_ts']
# Ensure both keys exist
if 'channels' not in status:
status['channels'] = {}
if 'dm' not in status:
status['dm'] = {}
return {
'channels': channels,
'dm': dm,
'muted_channels': muted_channels,
}
logger.debug(f"Loaded read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations")
return status
except json.JSONDecodeError as e:
logger.error(f"Failed to parse read status file: {e}")
return _get_default_status()
except Exception as e:
logger.error(f"Error loading read status: {e}")
return _get_default_status()
except Exception as e:
logger.error(f"Error loading read status: {e}")
return {'channels': {}, 'dm': {}, 'muted_channels': []}
def save_read_status(status):
"""
Save read status to disk.
Args:
status (dict): Read status with 'channels' and 'dm' keys
Returns:
bool: True if successful, False otherwise
"""
with _status_lock:
try:
# Ensure directory exists
READ_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Write atomically (write to temp file, then rename)
temp_file = READ_STATUS_FILE.with_suffix('.tmp')
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(status, f, indent=2)
# Atomic rename
temp_file.replace(READ_STATUS_FILE)
logger.debug(f"Saved read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations")
return True
except Exception as e:
logger.error(f"Error saving read status: {e}")
return False
"""No-op — data is written per-operation via mark_* functions."""
return True
def mark_channel_read(channel_idx, timestamp):
"""
Mark a channel as read up to a specific timestamp.
Args:
channel_idx (int or str): Channel index (will be converted to string)
timestamp (int or float): Unix timestamp of last read message
Returns:
bool: True if successful, False otherwise
"""
"""Mark a channel as read up to a specific timestamp."""
try:
# Load current status
status = load_read_status()
# Update channel timestamp (ensure key is string for JSON compatibility)
channel_key = str(channel_idx)
status['channels'][channel_key] = int(timestamp)
# Save updated status
success = save_read_status(status)
if success:
logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}")
return success
db = _get_db()
db.mark_read(f"chan_{channel_idx}", int(timestamp))
logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}")
return True
except Exception as e:
logger.error(f"Error marking channel {channel_idx} as read: {e}")
return False
def mark_dm_read(conversation_id, timestamp):
"""
Mark a DM conversation as read up to a specific timestamp.
Args:
conversation_id (str): Conversation identifier (e.g., "name_User1" or "pk_abc123")
timestamp (int or float): Unix timestamp of last read message
Returns:
bool: True if successful, False otherwise
"""
"""Mark a DM conversation as read up to a specific timestamp."""
try:
# Load current status
status = load_read_status()
# Update DM timestamp
status['dm'][conversation_id] = int(timestamp)
# Save updated status
success = save_read_status(status)
if success:
logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}")
return success
db = _get_db()
db.mark_read(f"dm_{conversation_id}", int(timestamp))
logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}")
return True
except Exception as e:
logger.error(f"Error marking DM conversation {conversation_id} as read: {e}")
return False
def get_channel_last_seen(channel_idx):
"""
Get last seen timestamp for a specific channel.
Args:
channel_idx (int or str): Channel index
Returns:
int: Unix timestamp, or 0 if never seen
"""
"""Get last seen timestamp for a specific channel."""
try:
status = load_read_status()
channel_key = str(channel_idx)
return status['channels'].get(channel_key, 0)
return status['channels'].get(str(channel_idx), 0)
except Exception as e:
logger.error(f"Error getting last seen for channel {channel_idx}: {e}")
return 0
def get_dm_last_seen(conversation_id):
"""
Get last seen timestamp for a specific DM conversation.
Args:
conversation_id (str): Conversation identifier
Returns:
int: Unix timestamp, or 0 if never seen
"""
"""Get last seen timestamp for a specific DM conversation."""
try:
status = load_read_status()
return status['dm'].get(conversation_id, 0)
except Exception as e:
logger.error(f"Error getting last seen for DM {conversation_id}: {e}")
return 0
def get_muted_channels():
"""Get list of muted channel indices."""
try:
db = _get_db()
return db.get_muted_channels()
except Exception as e:
logger.error(f"Error getting muted channels: {e}")
return []
def set_channel_muted(channel_idx, muted):
"""Set mute state for a channel."""
try:
db = _get_db()
db.set_channel_muted(int(channel_idx), muted)
logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}")
return True
except Exception as e:
logger.error(f"Error setting mute for channel {channel_idx}: {e}")
return False
def mark_all_channels_read(channel_timestamps):
"""Mark all channels as read in bulk.
Args:
channel_timestamps (dict): {"0": timestamp, "1": timestamp, ...}
"""
try:
db = _get_db()
for channel_key, timestamp in channel_timestamps.items():
db.mark_read(f"chan_{channel_key}", int(timestamp))
logger.info(f"Marked {len(channel_timestamps)} channels as read")
return True
except Exception as e:
logger.error(f"Error marking all channels as read: {e}")
return False

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,17 @@ def contact_management():
)
@views_bp.route('/contacts/add')
def contact_add():
"""
Add Contact page - URI paste, QR scan, manual fields.
"""
return render_template(
'contacts-add.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/contacts/pending')
def contact_pending_list():
"""
@@ -85,6 +96,12 @@ def console():
)
@views_bp.route('/logs')
def logs():
"""System log viewer - real-time log streaming with filters."""
return render_template('logs.html')
@views_bp.route('/health')
def health():
"""

237
app/schema.sql Normal file
View File

@@ -0,0 +1,237 @@
-- mc-webui v2 SQLite Schema
-- WAL mode and foreign keys are enabled programmatically in Database.__init__
-- Schema versioning for future migrations
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO schema_version (version) VALUES (1);
-- Device identity and settings
CREATE TABLE IF NOT EXISTS device (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
public_key TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
self_info TEXT -- JSON blob with full device info
);
-- All known contacts (replaces contacts_cache.jsonl)
CREATE TABLE IF NOT EXISTS contacts (
public_key TEXT PRIMARY KEY, -- hex, lowercase
name TEXT NOT NULL DEFAULT '',
type INTEGER DEFAULT 0, -- node type from device
flags INTEGER DEFAULT 0,
out_path TEXT DEFAULT '', -- outgoing path string
out_path_len INTEGER DEFAULT 0,
last_advert TEXT, -- ISO 8601 timestamp
adv_lat REAL, -- GPS latitude from advert
adv_lon REAL, -- GPS longitude from advert
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT NOT NULL DEFAULT (datetime('now')),
source TEXT DEFAULT 'advert', -- 'advert', 'device', 'manual'
is_protected INTEGER DEFAULT 0, -- 1 = protected from cleanup
lastmod TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Channel configuration
CREATE TABLE IF NOT EXISTS channels (
idx INTEGER PRIMARY KEY, -- channel index (0-7)
name TEXT NOT NULL DEFAULT '',
secret TEXT, -- channel secret/key (hex)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Channel messages (replaces CHAN/SENT_CHAN from .msgs)
CREATE TABLE IF NOT EXISTS channel_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_idx INTEGER NOT NULL DEFAULT 0,
sender TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
timestamp INTEGER NOT NULL DEFAULT 0, -- unix epoch
sender_timestamp INTEGER, -- sender's clock
is_own INTEGER NOT NULL DEFAULT 0, -- 1 = sent by us
txt_type INTEGER DEFAULT 0,
snr REAL,
path_len INTEGER,
pkt_payload TEXT, -- for echo matching
raw_json TEXT, -- original JSON line
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Direct messages (replaces PRIV/SENT_MSG from .msgs)
CREATE TABLE IF NOT EXISTS direct_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_pubkey TEXT, -- FK to contacts (nullable for unknown)
direction TEXT NOT NULL CHECK (direction IN ('in', 'out')),
content TEXT NOT NULL DEFAULT '',
timestamp INTEGER NOT NULL DEFAULT 0, -- unix epoch
sender_timestamp INTEGER,
txt_type INTEGER DEFAULT 0,
snr REAL,
path_len INTEGER,
expected_ack TEXT, -- ACK code for delivery tracking
pkt_payload TEXT, -- raw packet payload for hash/analyzer
signature TEXT, -- dedup signature
raw_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (contact_pubkey) REFERENCES contacts(public_key) ON DELETE SET NULL
);
-- ACK tracking (replaces .acks.jsonl)
CREATE TABLE IF NOT EXISTS acks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
expected_ack TEXT NOT NULL, -- ACK code to match
received_at TEXT NOT NULL DEFAULT (datetime('now')),
snr REAL,
rssi REAL,
route_type TEXT, -- 'direct', 'flood', etc.
is_retry INTEGER DEFAULT 0,
dm_id INTEGER, -- FK to direct_messages (nullable)
FOREIGN KEY (dm_id) REFERENCES direct_messages(id) ON DELETE SET NULL
);
-- Echo tracking (replaces .echoes.jsonl)
CREATE TABLE IF NOT EXISTS echoes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pkt_payload TEXT NOT NULL, -- matches channel_messages.pkt_payload
path TEXT, -- relay path string
snr REAL,
received_at TEXT NOT NULL DEFAULT (datetime('now')),
direction TEXT DEFAULT 'incoming', -- 'sent' or 'incoming'
cm_id INTEGER, -- FK to channel_messages (nullable)
FOREIGN KEY (cm_id) REFERENCES channel_messages(id) ON DELETE SET NULL
);
-- Path tracking (replaces .path.jsonl)
CREATE TABLE IF NOT EXISTS paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_pubkey TEXT,
pkt_payload TEXT,
path TEXT,
snr REAL,
path_len INTEGER,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- User-configured paths for DM retry rotation
CREATE TABLE IF NOT EXISTS contact_paths (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_pubkey TEXT NOT NULL REFERENCES contacts(public_key) ON DELETE CASCADE,
path_hex TEXT NOT NULL DEFAULT '', -- raw hex path bytes (e.g. "5e34e761")
hash_size INTEGER NOT NULL DEFAULT 1, -- bytes per hop: 1, 2, or 3
label TEXT NOT NULL DEFAULT '', -- friendly label (e.g. "via Zalesie")
is_primary INTEGER NOT NULL DEFAULT 0, -- 1 = priority/default path
sort_order INTEGER NOT NULL DEFAULT 0, -- lower = tried first during rotation
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Advertisements (replaces .adverts.jsonl)
CREATE TABLE IF NOT EXISTS advertisements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
type INTEGER DEFAULT 0,
lat REAL,
lon REAL,
timestamp INTEGER NOT NULL DEFAULT 0,
snr REAL,
raw_payload TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Read status tracking (replaces .read_status.json)
CREATE TABLE IF NOT EXISTS read_status (
key TEXT PRIMARY KEY, -- 'chan_0', 'dm_<pubkey>', etc.
last_seen_ts INTEGER DEFAULT 0, -- unix timestamp
is_muted INTEGER DEFAULT 0, -- 1 = muted (channels only)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Ignored contacts (adverts cached but not pending/auto-added)
CREATE TABLE IF NOT EXISTS ignored_contacts (
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Blocked contacts (ignored + messages hidden from display)
CREATE TABLE IF NOT EXISTS blocked_contacts (
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Blocked names (for bots/contacts without known public_key)
CREATE TABLE IF NOT EXISTS blocked_names (
name TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Application settings (key-value store, replaces .webui_settings.json)
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '', -- JSON-encoded value
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ============================================================
-- Indexes
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_cm_channel_ts ON channel_messages(channel_idx, timestamp);
CREATE INDEX IF NOT EXISTS idx_cm_pkt ON channel_messages(pkt_payload);
CREATE INDEX IF NOT EXISTS idx_dm_contact ON direct_messages(contact_pubkey, timestamp);
CREATE INDEX IF NOT EXISTS idx_dm_ack ON direct_messages(expected_ack);
CREATE INDEX IF NOT EXISTS idx_acks_code ON acks(expected_ack);
CREATE INDEX IF NOT EXISTS idx_echoes_pkt ON echoes(pkt_payload);
CREATE INDEX IF NOT EXISTS idx_adv_pubkey ON advertisements(public_key, timestamp);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE INDEX IF NOT EXISTS idx_cp_contact ON contact_paths(contact_pubkey, sort_order);
-- ============================================================
-- Full-Text Search (FTS5)
-- ============================================================
CREATE VIRTUAL TABLE IF NOT EXISTS channel_messages_fts USING fts5(
content,
content=channel_messages,
content_rowid=id
);
CREATE VIRTUAL TABLE IF NOT EXISTS direct_messages_fts USING fts5(
content,
content=direct_messages,
content_rowid=id
);
-- FTS triggers: keep FTS index in sync with source tables
CREATE TRIGGER IF NOT EXISTS cm_fts_insert AFTER INSERT ON channel_messages BEGIN
INSERT INTO channel_messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS cm_fts_delete AFTER DELETE ON channel_messages BEGIN
INSERT INTO channel_messages_fts(channel_messages_fts, rowid, content)
VALUES ('delete', old.id, old.content);
END;
CREATE TRIGGER IF NOT EXISTS cm_fts_update AFTER UPDATE OF content ON channel_messages BEGIN
INSERT INTO channel_messages_fts(channel_messages_fts, rowid, content)
VALUES ('delete', old.id, old.content);
INSERT INTO channel_messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS dm_fts_insert AFTER INSERT ON direct_messages BEGIN
INSERT INTO direct_messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS dm_fts_delete AFTER DELETE ON direct_messages BEGIN
INSERT INTO direct_messages_fts(direct_messages_fts, rowid, content)
VALUES ('delete', old.id, old.content);
END;
CREATE TRIGGER IF NOT EXISTS dm_fts_update AFTER UPDATE OF content ON direct_messages BEGIN
INSERT INTO direct_messages_fts(direct_messages_fts, rowid, content)
VALUES ('delete', old.id, old.content);
INSERT INTO direct_messages_fts(rowid, content) VALUES (new.id, new.content);
END;

File diff suppressed because it is too large Load Diff

613
app/static/css/theme.css Normal file
View File

@@ -0,0 +1,613 @@
/* =============================================================================
mc-webui Theme System
Defines CSS custom properties for light/dark themes.
Bootstrap 5.3 data-bs-theme handles most component styling;
these variables cover custom app-specific elements.
============================================================================= */
/* =============================================================================
Light Theme (default)
============================================================================= */
:root {
/* Backgrounds */
--bg-body: #ffffff;
--bg-surface: #f8f9fa;
--bg-surface-alt: #f0f0f0;
--bg-hover: #e9ecef;
--bg-active: #e7f1ff;
--bg-messages: #ffffff;
--bg-dm-messages: #fafafa;
/* Text */
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--text-meta: #adb5bd;
/* Borders */
--border-color: #dee2e6;
--border-light: #f0f0f0;
/* Messages */
--msg-own-bg: #e7f1ff;
--msg-other-bg: #f8f9fa;
--msg-border: #dee2e6;
--msg-own-border: #b8daff;
/* Sender */
--sender-color: #0d6efd;
--sender-own-color: #084298;
/* Navbar */
--navbar-bg: #0d6efd;
--navbar-border: transparent;
/* Scrollbar */
--scrollbar-track: #f1f1f1;
--scrollbar-thumb: #888;
--scrollbar-thumb-hover: #555;
--scrollbar-thumb-light: #ccc;
--scrollbar-thumb-light-hover: #aaa;
/* Filter */
--filter-bg: #ffffff;
--filter-highlight: #fff3cd;
--filter-input-border: #ced4da;
--filter-btn-me-bg: #e7f1ff;
--filter-btn-me-color: #0d6efd;
--filter-btn-me-hover: #cfe2ff;
--filter-btn-clear-bg: #f8f9fa;
--filter-btn-clear-color: #6c757d;
--filter-btn-clear-hover: #e9ecef;
/* Popup / Dropdown */
--popup-bg: #ffffff;
--popup-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
/* Quote */
--quote-color: #6c757d;
--quote-bg: rgba(108, 117, 125, 0.1);
--quote-border: #6c757d;
--quote-own-color: #495057;
--quote-own-bg: rgba(8, 66, 152, 0.1);
--quote-own-border: #084298;
/* Mention badge */
--mention-bg: #0d6efd;
--mention-own-bg: #084298;
/* Links */
--link-color: #0d6efd;
--link-hover: #0a58ca;
--link-own-color: #084298;
--link-own-hover: #052c65;
/* Channel link */
--channel-link-bg: #198754;
--channel-link-hover: #157347;
--channel-link-own-bg: #0f5132;
--channel-link-own-hover: #0d4429;
/* Echo badge */
--echo-color: #198754;
--echo-bg: rgba(25, 135, 84, 0.1);
/* Search */
--search-mark-bg: #fff3cd;
/* Offcanvas menu */
--offcanvas-item-border: #dee2e6;
--offcanvas-item-hover: #f8f9fa;
--offcanvas-icon-color: #0d6efd;
/* FAB */
--fab-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
--fab-shadow-hover: 0 6px 12px rgba(0, 0, 0, 0.4);
/* Conversation list */
--conversation-border: #dee2e6;
--conversation-hover: #f8f9fa;
--conversation-unread: #e7f1ff;
/* Map filter badges */
--map-badge-inactive-bg: white;
/* Mention autocomplete */
--mention-item-highlight: #e7f1ff;
--mention-item-border: #f0f0f0;
/* Image border */
--image-border: #dee2e6;
/* Actions border */
--actions-border: rgba(0, 0, 0, 0.1);
/* Cards */
--card-bg: #ffffff;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.15);
/* Info badge */
--info-badge-bg: #e7f3ff;
--info-badge-color: #0c5460;
/* Contact key clickable */
--key-hover-color: #0d6efd;
--key-hover-bg: #e7f1ff;
--key-copied-color: #198754;
--key-copied-bg: #d1e7dd;
/* Path items (DM) */
--path-item-bg: #ffffff;
--path-item-border: #dee2e6;
--path-item-primary-bg: #f0f7ff;
--path-item-primary-border: #0d6efd;
/* DM contact dropdown */
--dropdown-bg: #ffffff;
--dropdown-separator-bg: #f8f9fa;
--dropdown-item-hover: #e9ecef;
}
/* =============================================================================
Dark Theme
Inspired by mc-webui demo landing page (https://mc-webui.marwoj.net/)
Color palette: deep navy backgrounds, slate surfaces, soft blue accents
============================================================================= */
[data-theme="dark"] {
/* Override Bootstrap 5.3 dark mode variables for our custom palette */
--bs-body-bg: #0f172a;
--bs-body-color: #f8fafc;
--bs-border-color: #334155;
--bs-tertiary-bg: #1e293b;
--bs-secondary-bg: #162032;
/* Backgrounds */
--bg-body: #0f172a;
--bg-surface: #1e293b;
--bg-surface-alt: #162032;
--bg-hover: #2d3a4e;
--bg-active: #1e3a5f;
--bg-messages: #0f172a;
--bg-dm-messages: #131c2e;
/* Text */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-meta: #475569;
/* Borders */
--border-color: #334155;
--border-light: #1e293b;
/* Messages */
--msg-own-bg: #1e3a5f;
--msg-other-bg: #1e293b;
--msg-border: #334155;
--msg-own-border: #2563eb;
/* Sender */
--sender-color: #60a5fa;
--sender-own-color: #93c5fd;
/* Navbar */
--navbar-bg: #1e293b;
--navbar-border: #334155;
/* Scrollbar */
--scrollbar-track: #1e293b;
--scrollbar-thumb: #475569;
--scrollbar-thumb-hover: #64748b;
--scrollbar-thumb-light: #334155;
--scrollbar-thumb-light-hover: #475569;
/* Filter */
--filter-bg: #1e293b;
--filter-highlight: rgba(251, 191, 36, 0.2);
--filter-input-border: #334155;
--filter-btn-me-bg: #1e3a5f;
--filter-btn-me-color: #60a5fa;
--filter-btn-me-hover: #264a6f;
--filter-btn-clear-bg: #1e293b;
--filter-btn-clear-color: #94a3b8;
--filter-btn-clear-hover: #2d3a4e;
/* Popup / Dropdown */
--popup-bg: #1e293b;
--popup-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4);
/* Quote */
--quote-color: #94a3b8;
--quote-bg: rgba(148, 163, 184, 0.1);
--quote-border: #64748b;
--quote-own-color: #94a3b8;
--quote-own-bg: rgba(37, 99, 235, 0.15);
--quote-own-border: #2563eb;
/* Mention badge */
--mention-bg: #2563eb;
--mention-own-bg: #1d4ed8;
/* Links */
--link-color: #60a5fa;
--link-hover: #93c5fd;
--link-own-color: #93c5fd;
--link-own-hover: #bfdbfe;
/* Channel link */
--channel-link-bg: #059669;
--channel-link-hover: #10b981;
--channel-link-own-bg: #047857;
--channel-link-own-hover: #059669;
/* Echo badge */
--echo-color: #10b981;
--echo-bg: rgba(16, 185, 129, 0.15);
/* Search */
--search-mark-bg: rgba(251, 191, 36, 0.3);
/* Offcanvas menu */
--offcanvas-item-border: #334155;
--offcanvas-item-hover: #253347;
--offcanvas-icon-color: #60a5fa;
/* FAB */
--fab-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
--fab-shadow-hover: 0 6px 12px rgba(0, 0, 0, 0.6);
/* Conversation list */
--conversation-border: #334155;
--conversation-hover: #253347;
--conversation-unread: #1e3a5f;
/* Map filter badges */
--map-badge-inactive-bg: #1e293b;
/* Mention autocomplete */
--mention-item-highlight: #1e3a5f;
--mention-item-border: #334155;
/* Image border */
--image-border: #334155;
/* Actions border */
--actions-border: rgba(255, 255, 255, 0.1);
/* Cards */
--card-bg: #1e293b;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.4);
/* Info badge */
--info-badge-bg: rgba(37, 99, 235, 0.15);
--info-badge-color: #60a5fa;
/* Contact key clickable */
--key-hover-color: #60a5fa;
--key-hover-bg: #1e3a5f;
--key-copied-color: #10b981;
--key-copied-bg: rgba(16, 185, 129, 0.15);
/* Path items (DM) */
--path-item-bg: #1e293b;
--path-item-border: #334155;
--path-item-primary-bg: #1e3a5f;
--path-item-primary-border: #2563eb;
/* DM contact dropdown */
--dropdown-bg: #1e293b;
--dropdown-separator-bg: #162032;
--dropdown-item-hover: #2d3a4e;
}
/* =============================================================================
Dark Theme - Bootstrap Component Overrides
Bootstrap 5.3 data-bs-theme="dark" handles most defaults; these overrides
customize colors to match our deep navy palette.
============================================================================= */
/* Navbar */
[data-theme="dark"] .navbar.bg-primary {
background-color: var(--navbar-bg) !important;
border-bottom: 1px solid var(--navbar-border);
}
[data-theme="dark"] .navbar .btn-outline-light {
border-color: #475569;
color: #94a3b8;
}
[data-theme="dark"] .navbar .btn-outline-light:hover {
background-color: #334155;
border-color: #64748b;
color: #f8fafc;
}
/* Form controls */
[data-theme="dark"] .form-control,
[data-theme="dark"] .form-select {
background-color: var(--bg-body);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .form-control:focus,
[data-theme="dark"] .form-select:focus {
background-color: var(--bg-body);
color: var(--text-primary);
border-color: #3b82f6;
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
[data-theme="dark"] .form-control::placeholder {
color: var(--text-muted);
}
/* Modal */
[data-theme="dark"] .modal-content {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .modal-header {
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .modal-footer {
border-top-color: var(--border-color);
}
[data-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Offcanvas */
[data-theme="dark"] .offcanvas {
background-color: var(--bg-surface);
color: var(--text-primary);
}
[data-theme="dark"] .offcanvas-header {
border-bottom-color: var(--border-color);
}
/* List group */
[data-theme="dark"] .list-group-item {
background-color: transparent;
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .list-group-item-action:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
/* Nav tabs */
[data-theme="dark"] .nav-tabs {
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .nav-tabs .nav-link {
color: var(--text-muted);
}
[data-theme="dark"] .nav-tabs .nav-link:hover {
border-color: var(--border-color);
color: var(--text-secondary);
}
[data-theme="dark"] .nav-tabs .nav-link.active {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-color) var(--border-color) var(--bg-surface);
}
/* Tables */
[data-theme="dark"] .table {
color: var(--text-primary);
border-color: var(--border-color);
}
/* Alerts */
[data-theme="dark"] .alert-info {
background-color: rgba(59, 130, 246, 0.1);
color: #60a5fa;
border-color: rgba(59, 130, 246, 0.2);
}
[data-theme="dark"] .alert-light {
background-color: var(--bg-surface-alt);
color: var(--text-secondary);
border-color: var(--border-color);
}
/* Card (Bootstrap) */
[data-theme="dark"] .card {
background-color: var(--bg-surface);
border-color: var(--border-color);
color: var(--text-primary);
}
/* Badge overrides for better dark mode contrast */
[data-theme="dark"] .badge.bg-secondary {
background-color: #475569 !important;
}
/* Text utilities */
[data-theme="dark"] .text-muted {
color: var(--text-muted) !important;
}
[data-theme="dark"] .text-dark {
color: var(--text-primary) !important;
}
[data-theme="dark"] .border-bottom {
border-bottom-color: var(--border-color) !important;
}
[data-theme="dark"] .border-top {
border-top-color: var(--border-color) !important;
}
/* bg-light override */
[data-theme="dark"] .bg-light {
background-color: var(--bg-surface-alt) !important;
}
/* Toast */
[data-theme="dark"] .toast {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .toast-header {
background-color: var(--bg-surface-alt);
color: var(--text-primary);
border-bottom-color: var(--border-color);
}
/* Progress bar */
[data-theme="dark"] .progress {
background-color: var(--bg-surface-alt);
}
/* Tooltip-like popups */
[data-theme="dark"] .dm-delivery-popup,
[data-theme="dark"] .path-popup {
background-color: #475569;
color: #f8fafc;
}
/* Form check / switch */
[data-theme="dark"] .form-check-input {
background-color: var(--bg-surface-alt);
border-color: var(--border-color);
}
[data-theme="dark"] .form-check-input:checked {
background-color: #3b82f6;
border-color: #3b82f6;
}
/* Input group */
[data-theme="dark"] .input-group-text {
background-color: var(--bg-surface-alt);
color: var(--text-secondary);
border-color: var(--border-color);
}
/* Accordion (if used) */
[data-theme="dark"] .accordion-item {
background-color: var(--bg-surface);
border-color: var(--border-color);
}
/* Dropdown menu (Bootstrap) */
[data-theme="dark"] .dropdown-menu {
background-color: var(--bg-surface);
border-color: var(--border-color);
}
[data-theme="dark"] .dropdown-item {
color: var(--text-primary);
}
[data-theme="dark"] .dropdown-item:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
/* Spinner */
[data-theme="dark"] .spinner-border {
color: #3b82f6;
}
/* Status bar (bottom) */
[data-theme="dark"] .border-top {
border-color: var(--border-color) !important;
}
/* QR code container - keep white bg for readability */
[data-theme="dark"] .qr-code-container,
[data-theme="dark"] #shareChannelQR,
[data-theme="dark"] #deviceShareContent .text-center img,
[data-theme="dark"] #deviceShareContent canvas {
background-color: #ffffff;
padding: 8px;
border-radius: 0.5rem;
}
/* Emoji picker dark mode */
[data-theme="dark"] emoji-picker {
--background: #1e293b;
--border-color: #334155;
--indicator-color: #3b82f6;
--input-border-color: #334155;
--input-font-color: #f8fafc;
--input-placeholder-color: #64748b;
--outline-color: #3b82f6;
--category-font-color: #94a3b8;
--button-active-background: #334155;
--button-hover-background: #2d3a4e;
}
/* =============================================================================
Theme Switcher UI
============================================================================= */
.theme-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid var(--border-color);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
background-color: var(--card-bg);
}
.theme-option:hover {
border-color: #3b82f6;
}
.theme-option.active {
border-color: #3b82f6;
background-color: var(--bg-active);
}
.theme-option-preview {
width: 40px;
height: 40px;
border-radius: 0.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.theme-option-preview.light {
background: linear-gradient(135deg, #ffffff 50%, #e9ecef 50%);
border: 1px solid #dee2e6;
}
.theme-option-preview.dark {
background: linear-gradient(135deg, #1e293b 50%, #0f172a 50%);
border: 1px solid #334155;
}
.theme-option-label {
font-weight: 500;
}
.theme-option-desc {
font-size: 0.8rem;
color: var(--text-muted);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

276
app/static/js/logs.js Normal file
View File

@@ -0,0 +1,276 @@
/**
* System Log Viewer
*
* Real-time log streaming via WebSocket with filtering and search.
*/
(function () {
'use strict';
// --- DOM refs ---
const logEntries = document.getElementById('logEntries');
const loadingMsg = document.getElementById('loadingMsg');
const logCount = document.getElementById('logCount');
const statusDot = document.getElementById('statusDot');
const pauseBtn = document.getElementById('pauseBtn');
const pauseIcon = document.getElementById('pauseIcon');
const clearBtn = document.getElementById('clearBtn');
const levelFilter = document.getElementById('levelFilter');
const loggerFilter = document.getElementById('loggerFilter');
const searchFilter = document.getElementById('searchFilter');
const resetFilters = document.getElementById('resetFilters');
// --- State ---
let paused = false;
let autoScroll = true;
let entries = []; // all received entries
let displayCount = 0;
const MAX_DISPLAY = 3000; // max DOM entries before trimming
let searchDebounce = null;
let knownLoggers = new Set();
// --- Level ordering ---
const LEVEL_ORDER = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3, CRITICAL: 4 };
// --- WebSocket ---
const socket = io('/logs', {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 2000,
});
socket.on('connect', () => {
setStatus('live');
// Load initial entries
loadInitialLogs();
});
socket.on('disconnect', () => {
setStatus('disconnected');
});
socket.on('log_entry', (entry) => {
addEntry(entry);
});
// --- Functions ---
function setStatus(state) {
statusDot.className = 'status-indicator ' + state;
}
function loadInitialLogs() {
const level = levelFilter.value;
const params = new URLSearchParams();
if (level) params.set('level', level);
params.set('limit', '1000');
fetch('/api/logs?' + params.toString())
.then(r => r.json())
.then(data => {
if (!data.success) return;
loadingMsg?.remove();
// Update logger filter options
if (data.loggers) {
data.loggers.forEach(l => knownLoggers.add(l));
updateLoggerOptions();
}
// Render entries
entries = data.entries || [];
renderAll();
})
.catch(err => {
if (loadingMsg) loadingMsg.textContent = 'Failed to load logs';
console.error('Failed to load logs:', err);
});
}
function addEntry(entry) {
entries.push(entry);
// Track new loggers
if (!knownLoggers.has(entry.logger)) {
knownLoggers.add(entry.logger);
updateLoggerOptions();
}
// If paused or filtered out, don't add to DOM
if (paused) {
updateCount();
return;
}
if (matchesFilter(entry)) {
appendEntryDOM(entry);
trimDOM();
if (autoScroll) scrollToBottom();
}
updateCount();
}
function matchesFilter(entry) {
// Level filter
const minLevel = levelFilter.value;
if (minLevel && (LEVEL_ORDER[entry.level] || 0) < (LEVEL_ORDER[minLevel] || 0)) {
return false;
}
// Logger filter
const loggerVal = loggerFilter.value;
if (loggerVal && !entry.logger.startsWith(loggerVal)) {
return false;
}
// Search filter
const search = searchFilter.value.trim().toLowerCase();
if (search && !entry.message.toLowerCase().includes(search) &&
!entry.logger.toLowerCase().includes(search)) {
return false;
}
return true;
}
function renderAll() {
logEntries.innerHTML = '';
displayCount = 0;
const filtered = entries.filter(e => matchesFilter(e));
// Only render last MAX_DISPLAY entries
const toRender = filtered.slice(-MAX_DISPLAY);
const fragment = document.createDocumentFragment();
for (const entry of toRender) {
fragment.appendChild(createEntryElement(entry));
displayCount++;
}
logEntries.appendChild(fragment);
updateCount();
scrollToBottom();
}
function appendEntryDOM(entry) {
logEntries.appendChild(createEntryElement(entry));
displayCount++;
}
function createEntryElement(entry) {
const div = document.createElement('div');
div.className = 'log-line';
// Shorten timestamp to HH:MM:SS.mmm
const ts = entry.timestamp.length > 11 ? entry.timestamp.substring(11) : entry.timestamp;
// Shorten logger name (remove 'app.' prefix)
let loggerName = entry.logger;
if (loggerName.startsWith('app.')) {
loggerName = loggerName.substring(4);
}
// Pad level to 5 chars
const levelPad = entry.level.padEnd(5);
// Build the line with color spans
const search = searchFilter.value.trim().toLowerCase();
let message = escapeHtml(entry.message);
if (search) {
message = highlightSearch(message, search);
}
div.innerHTML =
`<span class="log-ts">${escapeHtml(ts)}</span> ` +
`<span class="log-level-${entry.level}">${escapeHtml(levelPad)}</span> ` +
`<span class="log-logger">${escapeHtml(loggerName.padEnd(18).substring(0, 18))}</span> ` +
`<span class="log-msg-${entry.level}">${message}</span>`;
return div;
}
function trimDOM() {
while (logEntries.children.length > MAX_DISPLAY) {
logEntries.removeChild(logEntries.firstChild);
displayCount--;
}
}
function scrollToBottom() {
logEntries.scrollTop = logEntries.scrollHeight;
}
function updateCount() {
const total = entries.length;
const shown = logEntries.children.length;
logCount.textContent = shown === total
? `${total} entries`
: `${shown} / ${total} entries`;
}
function updateLoggerOptions() {
const current = loggerFilter.value;
// Group loggers by top-level module
const sorted = Array.from(knownLoggers).sort();
loggerFilter.innerHTML = '<option value="">All modules</option>';
for (const name of sorted) {
const opt = document.createElement('option');
opt.value = name;
// Shorten display
opt.textContent = name.startsWith('app.') ? name.substring(4) : name;
if (name === current) opt.selected = true;
loggerFilter.appendChild(opt);
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function highlightSearch(html, search) {
if (!search) return html;
// Case-insensitive highlight (on already-escaped HTML)
const regex = new RegExp('(' + search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return html.replace(regex, '<mark>$1</mark>');
}
// --- Auto-scroll detection ---
logEntries.addEventListener('scroll', () => {
const atBottom = logEntries.scrollHeight - logEntries.scrollTop - logEntries.clientHeight < 50;
autoScroll = atBottom;
});
// --- Controls ---
pauseBtn.addEventListener('click', () => {
paused = !paused;
pauseIcon.className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
setStatus(paused ? 'paused' : 'live');
if (!paused) {
// Resume: re-render to catch up
renderAll();
}
});
clearBtn.addEventListener('click', () => {
entries = [];
logEntries.innerHTML = '';
displayCount = 0;
updateCount();
});
// Filter handlers
levelFilter.addEventListener('change', () => renderAll());
loggerFilter.addEventListener('change', () => renderAll());
searchFilter.addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => renderAll(), 250);
});
resetFilters.addEventListener('click', () => {
levelFilter.value = 'INFO';
loggerFilter.value = '';
searchFilter.value = '';
renderAll();
});
})();

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'mc-webui-v4';
const CACHE_NAME = 'mc-webui-v9';
const ASSETS_TO_CACHE = [
'/',
'/static/css/style.css',
@@ -6,6 +6,7 @@ const ASSETS_TO_CACHE = [
'/static/js/dm.js',
'/static/js/contacts.js',
'/static/js/message-utils.js',
'/static/js/filter-utils.js',
'/static/js/console.js',
'/static/images/android-chrome-192x192.png',
'/static/images/android-chrome-512x512.png',

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
@@ -12,6 +12,15 @@
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
@@ -24,6 +33,8 @@
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
{% block extra_head %}{% endblock %}
</head>
@@ -38,14 +49,14 @@
{% endif %}
</span>
<div class="d-flex align-items-center gap-2">
<div id="notificationBell" class="btn btn-outline-light btn-sm position-relative" style="cursor: default;" title="Unread messages">
<div id="notificationBell" class="btn btn-outline-light position-relative navbar-touch-btn" style="cursor: pointer;" onclick="markAllChannelsRead()" title="Mark all as read">
<i class="bi bi-bell"></i>
</div>
<select id="channelSelector" class="form-select form-select-sm" style="width: auto; min-width: 100px;" title="Select channel">
<select id="channelSelector" class="form-select navbar-touch-select" style="width: auto; min-width: 100px;" title="Select channel">
<option value="0">Public</option>
<!-- Channels loaded dynamically via JavaScript -->
</select>
<button class="btn btn-outline-light btn-sm" data-bs-toggle="offcanvas" data-bs-target="#mainMenu" title="Menu">
<button class="btn btn-outline-light navbar-touch-btn" data-bs-toggle="offcanvas" data-bs-target="#mainMenu" title="Menu">
<i class="bi bi-list"></i>
</button>
</div>
@@ -72,6 +83,7 @@
</div>
<div class="offcanvas-body">
<div class="list-group list-group-flush">
<!-- Messages -->
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" id="refreshBtn">
<i class="bi bi-arrow-clockwise" style="font-size: 1.5rem;"></i>
<span>Refresh Messages</span>
@@ -80,7 +92,6 @@
<i class="bi bi-broadcast-pin" style="font-size: 1.5rem;"></i>
<span>Manage Channels</span>
</button>
<!-- Notifications Toggle -->
<button id="notificationsToggle" class="list-group-item list-group-item-action d-flex align-items-center gap-3" type="button">
<i class="bi bi-bell" style="font-size: 1.5rem;"></i>
<div class="flex-grow-1">
@@ -104,9 +115,9 @@
</select>
</div>
<!-- Network Commands Section -->
<!-- Network -->
<div class="list-group-item py-2 mt-2">
<small class="text-muted fw-bold text-uppercase">Network Commands</small>
<small class="text-muted fw-bold text-uppercase">Network</small>
</div>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" id="advertBtn" title="Send single advertisement (recommended for normal operation)">
<i class="bi bi-megaphone" style="font-size: 1.5rem;"></i>
@@ -123,16 +134,10 @@
</div>
</button>
<!-- Tools -->
<div class="list-group-item py-2 mt-2">
<small class="text-muted fw-bold text-uppercase">Configuration</small>
<small class="text-muted fw-bold text-uppercase">Tools</small>
</div>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#consoleModal" data-bs-dismiss="offcanvas">
<i class="bi bi-terminal" style="font-size: 1.5rem;"></i>
<div>
<span>Console</span>
<small class="d-block text-muted">Direct meshcli commands</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3"
id="mapBtn" title="Show all contacts with GPS on map">
<i class="bi bi-map" style="font-size: 1.5rem;"></i>
@@ -141,10 +146,43 @@
<small class="d-block text-muted">All contacts with GPS</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#consoleModal" data-bs-dismiss="offcanvas">
<i class="bi bi-terminal" style="font-size: 1.5rem;"></i>
<div>
<span>Console</span>
<small class="d-block text-muted">Direct meshcli commands</small>
</div>
</button>
<!-- System -->
<div class="list-group-item py-2 mt-2">
<small class="text-muted fw-bold text-uppercase">System</small>
</div>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" data-bs-dismiss="offcanvas">
<i class="bi bi-cpu" style="font-size: 1.5rem;"></i>
<span>Device Info</span>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#logsModal" data-bs-dismiss="offcanvas">
<i class="bi bi-journal-text" style="font-size: 1.5rem;"></i>
<div>
<span>System Log</span>
<small class="d-block text-muted">Real-time application logs</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#backupModal" data-bs-dismiss="offcanvas">
<i class="bi bi-database-down" style="font-size: 1.5rem;"></i>
<div>
<span>Backup</span>
<small class="d-block text-muted">Database backup & restore</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#settingsModal" data-bs-dismiss="offcanvas">
<i class="bi bi-gear" style="font-size: 1.5rem;"></i>
<div>
<span>Settings</span>
<small class="d-block text-muted">Application settings</small>
</div>
</button>
</div>
</div>
</div>
@@ -274,13 +312,42 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-cpu"></i> Device Info</h5>
<h5 class="modal-title"><i class="bi bi-cpu"></i> Device</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="deviceInfoContent">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm"></div> Loading...
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabDeviceInfo" type="button">Info</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceStats" type="button" id="statsTabBtn">Stats</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceShare" type="button" id="shareTabBtn">Share</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tabDeviceInfo">
<div id="deviceInfoContent">
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
</div>
<div class="tab-pane fade" id="tabDeviceStats">
<div id="deviceStatsContent">
<div class="text-center py-3 text-muted">
Click to load stats
</div>
</div>
</div>
<div class="tab-pane fade" id="tabDeviceShare">
<div id="deviceShareContent">
<div class="text-center py-3 text-muted">
Click to generate share code
</div>
</div>
</div>
</div>
</div>
@@ -288,6 +355,149 @@
</div>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-gear"></i> Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabSettingsMessages" type="button">Messages</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChat" type="button">Group Chat</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAppearance" type="button">Appearance</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tabSettingsMessages">
<div id="settingsMessagesContent">
<form id="dmRetrySettingsForm">
<p class="text-muted small mb-3">Retries are counted after the initial send, e.g. 3 retries = 4 total attempts.</p>
<h6 class="text-muted mb-2">When path is known (DIRECT)</h6>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Direct retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Attempts via known path before switching to flood"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settDirectMaxRetries" min="0" max="20" value="3"></td>
</tr>
<tr>
<td class="ps-0">Flood retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood attempts after direct retries exhausted (when no configured paths)"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settDirectFloodRetries" min="0" max="5" value="1"></td>
</tr>
<tr>
<td class="ps-0">Interval (s) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Seconds to wait between direct retries"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settDirectInterval" min="5" max="300" value="30"></td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2">When no path (FLOOD)</h6>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Max retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood retry attempts (also used after path rotation)"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settFloodMaxRetries" min="0" max="10" value="3"></td>
</tr>
<tr>
<td class="ps-0">Interval (s) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Seconds to wait between flood retries"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settFloodInterval" min="5" max="300" value="60"></td>
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2">Other</h6>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Grace period (s) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Wait for late ACKs after all retries exhausted"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settGracePeriod" min="10" max="300" value="60"></td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="settingsResetBtn">Reset to defaults</button>
</div>
</form>
</div>
</div>
<div class="tab-pane fade" id="tabSettingsChat">
<form id="chatSettingsForm">
<h6 class="text-muted mb-2">Quote</h6>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Quote length (bytes) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Default max UTF-8 bytes for truncated quotes"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settQuoteMaxBytes" min="5" max="120" value="20"></td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="chatSettingsResetBtn">Reset to defaults</button>
</div>
</form>
</div>
<div class="tab-pane fade" id="tabSettingsAppearance">
<h6 class="text-muted mb-3">Theme</h6>
<div class="d-flex flex-column gap-2">
<div class="theme-option active" data-theme-value="light" onclick="setTheme('light')">
<div class="theme-option-preview light">
<i class="bi bi-sun"></i>
</div>
<div>
<div class="theme-option-label">Light</div>
<div class="theme-option-desc">Classic bright interface</div>
</div>
</div>
<div class="theme-option" data-theme-value="dark" onclick="setTheme('dark')">
<div class="theme-option-preview dark">
<i class="bi bi-moon-stars" style="color: #60a5fa;"></i>
</div>
<div>
<div class="theme-option-label">Dark</div>
<div class="theme-option-desc">Easy on the eyes, deep navy palette</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quote Dialog Modal -->
<div class="modal fade" id="quoteModal" tabindex="-1">
<div class="modal-dialog modal-sm modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-quote"></i> Quote message</h6>
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-2">
<p class="text-muted small mb-2" id="quotePreview"></p>
<div class="d-flex gap-2 align-items-center mb-2">
<label class="form-label mb-0 small text-nowrap" for="quoteBytesInput">Max bytes:</label>
<input type="number" class="form-control form-control-sm" id="quoteBytesInput" min="5" max="120" style="width:5rem">
</div>
</div>
<div class="modal-footer py-1">
<button type="button" class="btn btn-outline-secondary btn-sm" id="quoteTruncatedBtn">Truncated</button>
<button type="button" class="btn btn-primary btn-sm" id="quoteFullBtn">Full quote</button>
</div>
</div>
</div>
</div>
<!-- Map Modal (Leaflet) -->
<div class="modal fade" id="mapModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
@@ -300,11 +510,14 @@
<!-- Type filter (hidden for single contact view) -->
<div id="mapTypeFilter" class="d-none px-3 py-2 border-bottom bg-light">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="small text-muted me-1">Show:</span>
<span class="map-filter-badge active" data-type="1" id="mapFilterCLI">CLI</span>
<span class="map-filter-badge active" data-type="1" id="mapFilterCOM">COM</span>
<span class="map-filter-badge active" data-type="2" id="mapFilterREP">REP</span>
<span class="map-filter-badge active" data-type="3" id="mapFilterROOM">ROOM</span>
<span class="map-filter-badge active" data-type="4" id="mapFilterSENS">SENS</span>
<div class="form-check form-switch ms-auto mb-0">
<input class="form-check-input" type="checkbox" id="mapCachedSwitch">
<label class="form-check-label small" for="mapCachedSwitch">Cached</label>
</div>
</div>
</div>
<div id="leafletMap" style="height: 400px; width: 100%;"></div>
@@ -349,6 +562,72 @@
</div>
</div>
<!-- Backup Modal -->
<div class="modal fade" id="backupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-database-down"></i> Database Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<button class="btn btn-primary" id="createBackupBtn" onclick="createBackup()">
<i class="bi bi-plus-circle"></i> Create Backup
</button>
<span id="backupAutoStatus" class="text-muted small"></span>
</div>
<div id="backupList">
<div class="text-center text-muted py-3">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Search Modal -->
<div class="modal fade" id="searchModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-search"></i> Search Messages</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<input type="text" class="form-control" id="searchInput" placeholder="Search all messages..." autofocus>
<button class="btn btn-primary" type="button" id="searchBtn">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-outline-secondary" type="button" id="searchHelpBtn" title="Search syntax help">
<i class="bi bi-question-circle"></i>
</button>
</div>
<div id="searchHelp" class="alert alert-light small mb-3" style="display:none;">
<strong>Search tips:</strong>
<ul class="mb-1 ps-3">
<li><code>hello world</code> — messages containing both words</li>
<li><code>"hello world"</code> — exact phrase</li>
<li><code>hello OR world</code> — either word</li>
<li><code>hello NOT world</code> — hello but not world</li>
<li><code>hell*</code> — prefix match (hello,hellas...)</li>
</ul>
<div class="text-muted">Special characters (<code>. , - :</code>) should be wrapped in quotes.<br>
<a href="https://www.sqlite.org/fts5.html#full_text_query_syntax" target="_blank" rel="noopener">Full FTS5 syntax reference <i class="bi bi-box-arrow-up-right"></i></a></div>
</div>
<div id="searchResults">
<div class="text-center text-muted py-4">
<i class="bi bi-search" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">Search across all channel and direct messages</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<div id="notificationToast" class="toast" role="alert">
@@ -374,7 +653,13 @@
<!-- Filter Utilities (must load before app.js) -->
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- Custom JS -->
<!-- QR Code generator (for Device Share) -->
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<!-- PWA Viewport Fix for Android -->
@@ -408,6 +693,27 @@
}
</script>
<!-- Theme Switching -->
<script>
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('mc-webui-theme', theme);
// Update theme selector UI
document.querySelectorAll('.theme-option').forEach(function(el) {
el.classList.toggle('active', el.getAttribute('data-theme-value') === theme);
});
}
// Initialize theme selector UI on settings modal open
document.addEventListener('DOMContentLoaded', function() {
var current = localStorage.getItem('mc-webui-theme') || 'light';
document.querySelectorAll('.theme-option').forEach(function(el) {
el.classList.toggle('active', el.getAttribute('data-theme-value') === current);
});
});
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,128 @@
{% extends "contacts_base.html" %}
{% block title %}Add Contact - mc-webui{% endblock %}
{% block extra_head %}
<!-- html5-qrcode for QR scanning -->
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
{% endblock %}
{% block page_content %}
<div id="addPageContent" class="p-3">
<!-- Page Header -->
<div class="mb-3">
<h4 class="mb-2">
<i class="bi bi-person-plus"></i> Add Contact
</h4>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" onclick="navigateTo('/contacts/manage');">
<i class="bi bi-arrow-left"></i> Back
</button>
</div>
<!-- Input Mode Tabs -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-uri" data-bs-toggle="tab" data-bs-target="#pane-uri" type="button" role="tab">
<i class="bi bi-link-45deg"></i> URI
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-qr" data-bs-toggle="tab" data-bs-target="#pane-qr" type="button" role="tab">
<i class="bi bi-qr-code-scan"></i> QR Code
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-manual" data-bs-toggle="tab" data-bs-target="#pane-manual" type="button" role="tab">
<i class="bi bi-pencil"></i> Manual
</button>
</li>
</ul>
<div class="tab-content">
<!-- URI Paste Tab -->
<div class="tab-pane fade show active" id="pane-uri" role="tabpanel">
<div class="mb-3">
<label for="uriInput" class="form-label">MeshCore URI:</label>
<textarea class="form-control font-monospace" id="uriInput" rows="3"
placeholder="meshcore://contact/add?name=...&public_key=...&type=..."></textarea>
<small class="form-text text-muted">Paste a meshcore:// URI from the MeshCore mobile app</small>
</div>
<!-- URI Preview -->
<div id="uriPreview" class="alert alert-info d-none mb-3">
<strong>Preview:</strong>
<div><span class="text-muted">Name:</span> <span id="uriPreviewName"></span></div>
<div><span class="text-muted">Key:</span> <span id="uriPreviewKey" class="font-monospace small" style="word-break: break-all;"></span></div>
<div><span class="text-muted">Type:</span> <span id="uriPreviewType"></span></div>
</div>
<button class="btn btn-success" id="addFromUriBtn" disabled>
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
<!-- QR Code Tab -->
<div class="tab-pane fade" id="pane-qr" role="tabpanel">
<!-- Camera Scanner -->
<div id="qrScannerContainer" class="mb-3">
<div id="qrReader" style="width: 100%; max-width: 500px;"></div>
<div id="qrCameraButtons" class="d-flex gap-2 mt-2">
<button class="btn btn-primary btn-sm" id="startCameraBtn">
<i class="bi bi-camera-video"></i> Start Camera
</button>
<button class="btn btn-outline-secondary btn-sm d-none" id="stopCameraBtn">
<i class="bi bi-stop-circle"></i> Stop Camera
</button>
</div>
</div>
<!-- File Upload Fallback -->
<div class="mb-3">
<label for="qrFileInput" class="form-label">Or upload a QR code image:</label>
<input type="file" class="form-control" id="qrFileInput" accept="image/*">
</div>
<!-- QR Result -->
<div id="qrResult" class="alert alert-success d-none mb-3">
<strong>Scanned:</strong>
<div><span class="text-muted">Name:</span> <span id="qrResultName"></span></div>
<div><span class="text-muted">Key:</span> <span id="qrResultKey" class="font-monospace small" style="word-break: break-all;"></span></div>
<div><span class="text-muted">Type:</span> <span id="qrResultType"></span></div>
</div>
<div id="qrError" class="alert alert-danger d-none mb-3"></div>
<button class="btn btn-success d-none" id="addFromQrBtn">
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
<!-- Manual Entry Tab -->
<div class="tab-pane fade" id="pane-manual" role="tabpanel">
<div class="mb-3">
<label for="manualName" class="form-label">Name:</label>
<input type="text" class="form-control" id="manualName" placeholder="Contact name" maxlength="32">
</div>
<div class="mb-3">
<label for="manualKey" class="form-label">Public Key (64 hex chars):</label>
<input type="text" class="form-control font-monospace" id="manualKey"
placeholder="e.g. a1b2c3d4..." maxlength="64" pattern="[0-9a-fA-F]{64}">
<small class="form-text text-muted" id="manualKeyCount">0 / 64 characters</small>
</div>
<div class="mb-3">
<label for="manualType" class="form-label">Contact Type:</label>
<select class="form-select" id="manualType">
<option value="1" selected>COM (Companion)</option>
<option value="2">REP (Repeater)</option>
<option value="3">ROOM (Room Server)</option>
<option value="4">SENS (Sensor)</option>
</select>
</div>
<button class="btn btn-success" id="addManualBtn" disabled>
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
</div>
<!-- Status Messages -->
<div id="addStatus" class="mt-3 d-none"></div>
</div>
{% endblock %}

View File

@@ -29,26 +29,31 @@
<!-- Filter and Sort Toolbar -->
<div class="filter-sort-toolbar">
<!-- Source Filter -->
<select class="form-select" id="sourceFilter">
<option value="ALL">All sources</option>
<option value="DEVICE">On device</option>
<option value="CACHE">Cache only</option>
<option value="IGNORED">Ignored</option>
<option value="BLOCKED">Blocked</option>
</select>
<!-- Type Filter -->
<select class="form-select" id="typeFilter">
<option value="ALL">All Types</option>
<option value="CLI">CLI</option>
<option value="COM">COM</option>
<option value="REP">REP</option>
<option value="ROOM">ROOM</option>
<option value="SENS">SENS</option>
</select>
<!-- Sort Buttons -->
<div class="sort-buttons">
<button class="sort-btn" data-sort="name" id="sortByName" title="Sort by contact name">
<span>Name</span>
<i class="bi bi-sort-down"></i>
</button>
<button class="sort-btn active" data-sort="last_advert" id="sortByLastAdvert" title="Sort by last advertisement time">
<span>Last advert</span>
<i class="bi bi-sort-down"></i>
</button>
</div>
<!-- Sort Dropdown -->
<select class="form-select" id="sortSelect">
<option value="last_advert_desc">Last advert ↓</option>
<option value="last_advert_asc">Last advert ↑</option>
<option value="name_asc">Name A→Z</option>
<option value="name_desc">Name Z→A</option>
</select>
</div>
<!-- Loading State -->

View File

@@ -32,6 +32,15 @@
<i class="bi bi-list-ul"></i> Manage Contacts
</h5>
<!-- Add Contact Card -->
<div class="nav-card" onclick="navigateTo('/contacts/add');" style="border-left: 4px solid #198754;">
<div>
<h6><i class="bi bi-person-plus"></i> Add Contact</h6>
<small class="text-muted">Add from URI, QR code, or manual entry</small>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
<!-- Pending Contacts Card -->
<div class="nav-card" onclick="navigateTo('/contacts/pending');">
<div>
@@ -85,8 +94,8 @@
<label class="form-label">Contact Types:</label>
<div class="d-flex flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input cleanup-type-filter" type="checkbox" value="1" id="cleanupTypeCLI" checked>
<label class="form-check-label" for="cleanupTypeCLI">CLI</label>
<input class="form-check-input cleanup-type-filter" type="checkbox" value="1" id="cleanupTypeCOM" checked>
<label class="form-check-label" for="cleanupTypeCOM">COM</label>
</div>
<div class="form-check">
<input class="form-check-input cleanup-type-filter" type="checkbox" value="2" id="cleanupTypeREP" checked>

View File

@@ -26,44 +26,36 @@
<div class="mb-3">
<div class="card">
<div class="card-body p-3">
<h6 class="mb-3"><i class="bi bi-funnel"></i> Filters</h6>
<!-- Name Search -->
<div class="mb-3">
<input type="text" class="form-control" id="pendingSearchInput"
placeholder="Search by name or public key...">
</div>
<h6 class="mb-2">
<i class="bi bi-funnel"></i> Filters
<i class="bi bi-info-circle text-muted ms-1" role="button" tabindex="0"
data-bs-toggle="tooltip" data-bs-placement="right"
title="Filter contacts by type or name, then approve or ignore them in bulk."></i>
</h6>
<!-- Type Filter Badges -->
<div class="mb-3">
<label class="form-label small text-muted">Contact Types:</label>
<div class="d-flex flex-wrap gap-2">
<span class="type-filter-badge active" data-type="CLI" id="typeFilterCLI">CLI</span>
<span class="type-filter-badge" data-type="REP" id="typeFilterREP">REP</span>
<span class="type-filter-badge" data-type="ROOM" id="typeFilterROOM">ROOM</span>
<span class="type-filter-badge" data-type="SENS" id="typeFilterSENS">SENS</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="type-filter-badge active" data-type="COM" id="typeFilterCOM">COM</span>
<span class="type-filter-badge" data-type="REP" id="typeFilterREP">REP</span>
<span class="type-filter-badge" data-type="ROOM" id="typeFilterROOM">ROOM</span>
<span class="type-filter-badge" data-type="SENS" id="typeFilterSENS">SENS</span>
</div>
<!-- Batch Approval Button -->
<!-- Search + Batch Action Buttons -->
<div class="d-flex gap-2">
<button class="btn btn-success flex-grow-1" id="addFilteredBtn">
<i class="bi bi-check-circle-fill"></i> Add Filtered
<span class="badge bg-light text-dark ms-2" id="filteredCountBadge">0</span>
<input type="text" class="form-control form-control-sm" id="pendingSearchInput"
placeholder="Search..." style="min-width: 0;">
<button class="btn btn-success btn-sm flex-shrink-0" id="addFilteredBtn">
<i class="bi bi-check-circle-fill"></i> Approve
</button>
<button class="btn btn-outline-secondary btn-sm flex-shrink-0" id="ignoreFilteredBtn">
<i class="bi bi-eye-slash"></i> Ignore
</button>
</div>
</div>
</div>
</div>
<!-- Page Description -->
<div class="mb-3">
<p class="text-muted small mb-0">
<i class="bi bi-info-circle"></i>
Approve or reject contacts waiting for manual approval.
</p>
</div>
<!-- Loading State -->
<div id="pendingLoading" class="text-center py-5" style="display: none;">
<div class="spinner-border text-primary" role="status">
@@ -122,4 +114,34 @@
</div>
</div>
</div>
<!-- Batch Ignore Confirmation Modal -->
<div class="modal fade" id="batchIgnoreModal" tabindex="-1" aria-labelledby="batchIgnoreModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-secondary text-white">
<h5 class="modal-title" id="batchIgnoreModalLabel">
<i class="bi bi-eye-slash"></i> Confirm Batch Ignore
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-2">You are about to ignore <strong id="batchIgnoreCount">0</strong> contacts:</p>
<div class="list-group mb-3" id="batchIgnoreList" style="max-height: 300px; overflow-y: auto;">
</div>
<div class="alert alert-warning mb-0">
<i class="bi bi-info-circle"></i> Ignored contacts will not trigger new pending requests. You can unignore them later from the Existing Contacts page.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" id="confirmBatchIgnoreBtn">
<i class="bi bi-eye-slash"></i> Ignore All
</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -3,208 +3,6 @@
{% block title %}Contact Management - mc-webui{% endblock %}
{% block extra_head %}
<style>
/* Mobile-first custom styles for Contact Management */
/* Compact manual approval section */
.compact-setting {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.info-icon {
color: #6c757d;
cursor: help;
font-size: 1.1rem;
}
.info-icon:hover {
color: #0d6efd;
}
.pending-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.contact-name {
font-size: 1.1rem;
font-weight: 600;
color: #212529;
margin-bottom: 0.5rem;
word-wrap: break-word;
}
.contact-key {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #6c757d;
word-break: break-all;
margin-bottom: 0.75rem;
}
.btn-action {
min-height: 44px; /* Touch-friendly size */
font-size: 1rem;
}
.empty-state {
text-align: center;
padding: 1.5rem 1rem;
color: #6c757d;
}
.empty-state i {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.empty-state.compact {
padding: 1rem;
}
.empty-state.compact i {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.info-badge {
display: inline-block;
background-color: #e7f3ff;
color: #0c5460;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.9rem;
margin-top: 0.5rem;
}
/* Existing Contacts Styles */
.existing-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.existing-contact-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.type-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
}
.contact-info-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.counter-badge {
font-size: 1rem;
padding: 0.35rem 0.75rem;
}
.counter-ok {
background-color: #28a745;
}
.counter-warning {
background-color: #ffc107;
color: #212529;
}
.counter-alarm {
background-color: #dc3545;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.search-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-toolbar input,
.search-toolbar select {
flex: 1;
min-width: 150px;
}
/* Scrollable contacts lists */
#pendingList {
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
#existingList {
/* No max-height limit - let it use available space */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
/* Dynamic height based on viewport */
max-height: calc(100vh - 400px);
min-height: 300px;
}
@media (max-width: 768px) {
#existingList {
max-height: calc(100vh - 450px);
}
}
/* Custom scrollbar styling */
#existingList::-webkit-scrollbar,
#pendingList::-webkit-scrollbar {
width: 8px;
}
#existingList::-webkit-scrollbar-track,
#pendingList::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb,
#pendingList::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb:hover,
#pendingList::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Compact section headers */
.section-compact {
margin-bottom: 0.75rem;
}
</style>
{% endblock %}
{% block content %}
@@ -286,7 +84,7 @@
<input type="text" class="form-control" id="searchInput" placeholder="Search by name or public key...">
<select class="form-select" id="typeFilter" style="max-width: 150px;">
<option value="ALL">All Types</option>
<option value="CLI">CLI</option>
<option value="COM">COM</option>
<option value="REP">REP</option>
<option value="ROOM">ROOM</option>
<option value="SENS">SENS</option>

View File

@@ -1,10 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{% block title %}Contact Management - mc-webui{% endblock %}</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
@@ -24,126 +33,11 @@
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
/* Mobile-first custom styles for Contact Management */
/* Compact manual approval section */
.compact-setting {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.info-icon {
color: #6c757d;
cursor: help;
font-size: 1.1rem;
}
.info-icon:hover {
color: #0d6efd;
}
.pending-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.contact-name {
font-size: 1.1rem;
font-weight: 600;
color: #212529;
margin-bottom: 0.5rem;
word-wrap: break-word;
}
.contact-key {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #6c757d;
word-break: break-all;
margin-bottom: 0.75rem;
}
.contact-key.clickable-key {
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
padding: 0.15rem 0.3rem;
margin-left: -0.3rem;
border-radius: 0.25rem;
}
.contact-key.clickable-key:hover {
color: #0d6efd;
background-color: #e7f1ff;
}
.contact-key.clickable-key.copied {
color: #198754;
background-color: #d1e7dd;
}
.empty-state {
text-align: center;
padding: 1.5rem 1rem;
color: #6c757d;
}
.empty-state i {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.empty-state.compact {
padding: 1rem;
}
.empty-state.compact i {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.info-badge {
display: inline-block;
background-color: #e7f3ff;
color: #0c5460;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.9rem;
margin-top: 0.5rem;
}
/* Existing Contacts Styles */
.existing-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.existing-contact-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.type-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
}
/* Protected contact styling */
/* Contact Management page layout overrides */
.protection-indicator {
font-size: 0.85rem;
}
@@ -165,19 +59,19 @@
user-select: none;
}
.type-filter-badge[data-type="CLI"] {
.type-filter-badge[data-type="COM"] {
color: #0d6efd;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #0d6efd;
}
.type-filter-badge[data-type="CLI"].active {
.type-filter-badge[data-type="COM"].active {
color: white;
background-color: #0d6efd;
}
.type-filter-badge[data-type="REP"] {
color: #198754;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #198754;
}
.type-filter-badge[data-type="REP"].active {
@@ -187,7 +81,7 @@
.type-filter-badge[data-type="ROOM"] {
color: #0dcaf0;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #0dcaf0;
}
.type-filter-badge[data-type="ROOM"].active {
@@ -197,7 +91,7 @@
.type-filter-badge[data-type="SENS"] {
color: #ffc107;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #ffc107;
}
.type-filter-badge[data-type="SENS"].active {
@@ -205,108 +99,29 @@
background-color: #ffc107;
}
.contact-info-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.counter-badge {
font-size: 1rem;
padding: 0.35rem 0.75rem;
}
.counter-ok {
background-color: #28a745;
}
.counter-warning {
background-color: #ffc107;
color: #212529;
}
.counter-alarm {
background-color: #dc3545;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.search-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-toolbar input,
.search-toolbar select {
flex: 1;
min-width: 150px;
}
/* Scrollable contacts lists */
#pendingList {
height: calc(100vh - 280px);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
min-height: 300px;
}
/* Scrollable contacts lists - use flexbox to fill remaining space */
#pendingList,
#existingList {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: calc(100vh - 260px);
min-height: 300px;
}
/* Custom scrollbar styling */
#existingList::-webkit-scrollbar,
#pendingList::-webkit-scrollbar {
width: 8px;
}
#existingList::-webkit-scrollbar-track,
#pendingList::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb,
#pendingList::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb:hover,
#pendingList::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Compact section headers */
.section-compact {
margin-bottom: 0.75rem;
}
/* NEW: Full-screen lists for dedicated pages */
/* Full-screen lists for dedicated pages */
.contacts-list-fullscreen {
height: calc(100vh - 240px);
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0;
}
/* NEW: Navigation cards on manage page */
/* Navigation cards on manage page */
.nav-card {
background: white;
border: 1px solid #dee2e6;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1rem;
@@ -318,7 +133,7 @@
}
.nav-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--card-shadow-hover);
}
.nav-card h6 {
@@ -340,40 +155,7 @@
font-size: 0.85rem;
}
.sort-buttons {
display: flex;
gap: 0.375rem;
}
.sort-btn {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.35rem 0.5rem;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
white-space: nowrap;
}
.sort-btn:hover {
background: #e9ecef;
}
.sort-btn.active {
background-color: #0d6efd;
color: white;
border-color: #0d6efd;
}
.sort-btn i {
font-size: 0.85rem;
}
/* NEW: Back buttons */
/* Back buttons */
.back-buttons {
display: flex;
gap: 0.5rem;
@@ -386,7 +168,7 @@
min-height: 44px;
}
/* NEW: Cleanup section on manage page */
/* Cleanup section on manage page */
.cleanup-section {
background-color: #fff3cd;
border: 1px solid #ffc107;
@@ -395,6 +177,11 @@
margin-bottom: 1.5rem;
}
[data-theme="dark"] .cleanup-section {
background-color: rgba(255, 193, 7, 0.1);
border-color: rgba(255, 193, 7, 0.3);
}
.cleanup-section h6 {
color: #856404;
margin-bottom: 0.75rem;
@@ -403,28 +190,46 @@
gap: 0.5rem;
}
[data-theme="dark"] .cleanup-section h6 {
color: #ffc107;
}
/* Override global overflow: hidden from style.css for Contact Management pages */
html, body {
overflow: auto !important;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
main {
overflow: auto !important;
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
#existingList {
height: calc(100vh - 300px);
}
main > .container-fluid,
main > .container-fluid > .row,
main > .container-fluid > .row > .col-12 {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
#pendingList {
height: calc(100vh - 320px);
}
.contacts-list-fullscreen {
height: calc(100vh - 300px);
}
#pendingPageContent,
#existingPageContent {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>
@@ -496,7 +301,7 @@
<div id="mapTypeFilter" class="d-none px-3 py-2 border-bottom bg-light">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="small text-muted me-1">Show:</span>
<span class="map-filter-badge active" data-type="1" id="mapFilterCLI">CLI</span>
<span class="map-filter-badge active" data-type="1" id="mapFilterCOM">COM</span>
<span class="map-filter-badge active" data-type="2" id="mapFilterREP">REP</span>
<span class="map-filter-badge active" data-type="3" id="mapFilterROOM">ROOM</span>
<span class="map-filter-badge active" data-type="4" id="mapFilterSENS">SENS</span>

View File

@@ -1,10 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Direct Messages - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
@@ -17,146 +26,308 @@
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (for repeater map picker) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<!-- Emoji Picker (local) -->
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
<style>
emoji-picker {
--emoji-size: 1.5rem;
--num-columns: 8;
}
.emoji-picker-container {
position: relative;
}
.emoji-picker-popup {
position: absolute;
bottom: 100%;
right: 0;
z-index: 1000;
margin-bottom: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
overflow: hidden;
}
.emoji-picker-popup.hidden {
display: none;
}
/* Mobile responsive adjustments */
@media (max-width: 576px) {
emoji-picker {
--emoji-size: 1.25rem;
--num-columns: 6;
}
.emoji-picker-popup {
right: auto;
left: 0;
width: 100%;
max-width: 100%;
}
}
</style>
<!-- Inline styles removed - now in style.css -->
</head>
<body>
<!-- Main Content -->
<main>
<div class="container-fluid d-flex flex-column" style="height: 100vh;">
<!-- Conversation Selector Bar -->
<div class="row border-bottom bg-light">
<div class="col-12 p-2">
<select id="dmConversationSelector" class="form-select" title="Select conversation">
<option value="">Select chat...</option>
<!-- Conversations loaded dynamically via JavaScript -->
</select>
</div>
</div>
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
<div class="col-12 position-relative" style="height: 100%;">
<!-- Filter bar overlay -->
<div id="dmFilterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<span id="dmFilterMatchCount" class="filter-match-count"></span>
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- DM Sidebar (visible on lg+ screens) -->
<div id="dmSidebar" class="dm-sidebar">
<div class="dm-sidebar-header">
<input type="text"
id="dmSidebarSearch"
class="form-control form-control-sm"
placeholder="Search contacts..."
autocomplete="off">
</div>
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="dmMessagesList">
<!-- Placeholder shown when no conversation selected -->
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
<div class="dm-sidebar-list" id="dmSidebarList">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Conversation Selector Bar (mobile only, hidden on lg+) -->
<div class="dm-mobile-selector border-bottom bg-light">
<div class="p-2">
<div class="d-flex align-items-center gap-2">
<!-- Searchable contact selector -->
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
<input type="text"
id="dmContactSearchInput"
class="form-control"
placeholder="Select chat..."
autocomplete="off">
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
</div>
<!-- Clear search button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmClearSearchBtn"
title="Clear selection"
style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
<!-- Contact info button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmContactInfoBtn"
title="Contact info"
disabled>
<i class="bi bi-info-circle"></i>
</button>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<form id="dmSendForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="dmMessageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
disabled
></textarea>
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
<!-- Desktop contact header (visible on lg+ when sidebar is shown) -->
<div class="dm-desktop-header border-bottom bg-light">
<div class="p-2 d-flex align-items-center gap-2">
<span id="dmDesktopContactName" class="fw-medium flex-grow-1 text-truncate"></span>
<button type="button"
class="btn btn-outline-secondary btn-sm flex-shrink-0"
id="dmDesktopInfoBtn"
title="Contact info"
disabled>
<i class="bi bi-info-circle"></i>
</button>
</div>
</div>
<!-- Messages Container -->
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="dmFilterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<span id="dmFilterMatchCount" class="filter-match-count"></span>
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
<i class="bi bi-send"></i>
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
</div>
<div class="d-flex justify-content-end">
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="dmMessagesList">
<!-- Placeholder shown when no conversation selected -->
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
<!-- Send Message Form -->
<div class="border-top bg-light">
<form id="dmSendForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="dmMessageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
disabled
></textarea>
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
<i class="bi bi-send"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
</div>
<div class="d-flex justify-content-end">
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
</div>
</form>
</div>
<!-- Status Bar -->
<div class="border-top">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="dmStatusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="dmLastRefresh">Updated: Never</span>
</div>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="dmStatusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="dmLastRefresh">Updated: Never</span>
</div>
</div>
</div>
</div>
<!-- Floating Action Button for Filter -->
<div class="fab-container">
<!-- Floating Action Buttons -->
<div class="fab-container" id="dmFabContainer">
<button class="fab fab-toggle" id="dmFabToggle" title="Hide buttons">
<i class="bi bi-chevron-right"></i>
</button>
<button class="fab fab-filter" id="dmFilterFab" title="Filter Messages">
<i class="bi bi-funnel-fill"></i>
</button>
</div>
</main>
<!-- Contact Info Modal -->
<div class="modal fade" id="dmContactInfoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title"><i class="bi bi-person-circle"></i> Contact Info</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="dmContactInfoBody"></div>
<!-- Path management section (populated dynamically) -->
<div class="modal-body border-top pt-2 pb-1" id="dmPathSection" style="display: none;">
<div class="path-section-header">
<h6><i class="bi bi-signpost-split"></i> Paths</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="dmAddPathBtn" title="Add path">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div id="dmPathList"></div>
<!-- Path action buttons -->
<div class="d-flex justify-content-end gap-2 mt-1">
<button type="button" class="btn btn-outline-secondary btn-sm" id="dmClearPathsBtn"
title="Delete all configured paths from database">
<i class="bi bi-trash"></i> Clear Paths
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="dmResetFloodBtn"
title="Reset device path to FLOOD mode">
<i class="bi bi-broadcast"></i> Reset to FLOOD
</button>
</div>
</div>
<div class="modal-footer">
<div class="d-flex align-items-center justify-content-between w-100">
<div class="d-flex gap-3">
<div class="form-check form-switch" title="Auto Retry: resend DM if no ACK received">
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
<label class="form-check-label small" for="dmAutoRetryToggle">Auto Retry</label>
</div>
<div class="form-check form-switch" title="Keep path: don't auto-reset to FLOOD after failed retries">
<input class="form-check-input" type="checkbox" id="dmNoAutoFloodToggle">
<label class="form-check-label small" for="dmNoAutoFloodToggle">Keep path</label>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
<!-- Repeater Map Picker Modal -->
<div class="modal fade" id="repeaterMapModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Select Repeater from Map</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
<label class="form-check-label small" for="rptMapCachedSwitch">Cached</label>
</div>
<span class="text-muted small ms-auto" id="rptMapCount"></span>
</div>
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="rptMapSelected">Click a repeater on the map</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>Add</button>
</div>
</div>
</div>
</div>
<!-- Add Path Modal -->
<div class="modal fade" id="addPathModal" tabindex="-1" style="z-index: 1070;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> Add Path</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="dmAddPathForm">
<div class="mb-2">
<label class="form-label small mb-1">Hash Size</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
<label class="btn btn-outline-secondary" for="pathHash1">1B (max 64)</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
<label class="btn btn-outline-secondary" for="pathHash2">2B (max 32)</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
<label class="btn btn-outline-secondary" for="pathHash3">3B (max 21)</label>
</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Path (hex)</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control font-monospace" id="dmPathHexInput"
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterBtn"
title="Pick repeater from list">
<i class="bi bi-plus-circle"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterMapBtn"
title="Pick repeater from map">
<i class="bi bi-geo-alt"></i>
</button>
</div>
<div id="dmPathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
</div>
<!-- Repeater picker (hidden by default) -->
<div id="dmRepeaterPicker" style="display: none;" class="border rounded mb-2">
<div class="d-flex border-bottom">
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">Name</label>
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">ID</label>
</div>
<input type="text" class="form-control form-control-sm border-0"
id="dmRepeaterSearch" placeholder="Search by name..." autocomplete="off">
</div>
<div id="dmRepeaterList" style="max-height: 180px; overflow-y: auto;"></div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Label (optional)</label>
<input type="text" class="form-control form-control-sm" id="dmPathLabelInput"
placeholder="e.g. via Mountain RPT" maxlength="50">
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="dmCancelPathBtn" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="dmSavePathBtn">Add Path</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<div id="notificationToast" class="toast" role="alert">
@@ -177,6 +348,14 @@
<!-- Filter Utilities (must load before dm.js) -->
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
<!-- Leaflet JS (for repeater map picker) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>

View File

@@ -5,158 +5,119 @@
{% block extra_head %}
<!-- Emoji Picker (local) -->
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
<style>
emoji-picker {
--emoji-size: 1.5rem;
--num-columns: 8;
}
.emoji-picker-container {
position: relative;
}
.emoji-picker-popup {
position: absolute;
bottom: 100%;
right: 0;
z-index: 1000;
margin-bottom: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
overflow: hidden;
}
.emoji-picker-popup.hidden {
display: none;
}
/* Mobile responsive adjustments */
@media (max-width: 576px) {
emoji-picker {
--emoji-size: 1.25rem;
--num-columns: 6;
}
.emoji-picker-popup {
right: auto;
left: 0;
width: 100%;
max-width: 100%;
}
}
/* Modal fullscreen - remove all margins and padding */
#dmModal .modal-dialog.modal-fullscreen,
#contactsModal .modal-dialog.modal-fullscreen,
#consoleModal .modal-dialog.modal-fullscreen {
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
}
#dmModal .modal-content,
#contactsModal .modal-content,
#consoleModal .modal-content {
border: none !important;
border-radius: 0 !important;
height: 100vh !important;
}
#dmModal .modal-body,
#contactsModal .modal-body,
#consoleModal .modal-body {
overflow: hidden !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid d-flex flex-column" style="height: 100%;">
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
<div class="col-12 position-relative" style="height: 100%;">
<!-- Filter bar overlay -->
<div id="filterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<span id="filterMatchCount" class="filter-match-count"></span>
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="button" id="filterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- Channel Sidebar (visible on lg+ screens) -->
<div id="channelSidebar" class="channel-sidebar">
<div class="channel-sidebar-header">
<i class="bi bi-broadcast-pin"></i> Channels
</div>
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="messagesList">
<!-- Messages will be loaded here via JavaScript -->
<div class="text-center text-muted py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
<div class="channel-sidebar-list" id="channelSidebarList">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Messages Container -->
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="filterBar" class="filter-bar">
<div class="filter-bar-inner">
<div class="filter-input-wrapper">
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<!-- Filter mentions autocomplete popup -->
<div id="filterMentionsPopup" class="mentions-popup filter-mentions-popup hidden">
<div class="mentions-list" id="filterMentionsList"></div>
</div>
</div>
<p class="mt-3">Loading messages...</p>
<button type="button" id="filterMeBtn" class="filter-bar-btn filter-bar-btn-me" title="Filter my messages">
<i class="bi bi-person-fill"></i>
</button>
<span id="filterMatchCount" class="filter-match-count"></span>
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="button" id="filterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="messagesList">
<!-- Messages will be loaded here via JavaScript -->
<div class="text-center text-muted py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Loading messages...</p>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
<!-- Scroll to bottom button -->
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<form id="sendMessageForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="messageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
required
></textarea>
<button type="button" class="btn btn-outline-secondary" id="emojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
<i class="bi bi-send"></i>
</button>
<!-- Send Message Form -->
<div class="border-top bg-light">
<form id="sendMessageForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="messageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
required
></textarea>
<button type="button" class="btn btn-outline-secondary" id="emojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
<i class="bi bi-send"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
<!-- Mentions autocomplete popup (hidden by default) -->
<div id="mentionsPopup" class="mentions-popup hidden">
<div class="mentions-list" id="mentionsList"></div>
</div>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
<!-- Mentions autocomplete popup (hidden by default) -->
<div id="mentionsPopup" class="mentions-popup hidden">
<div class="mentions-list" id="mentionsList"></div>
<div class="d-flex justify-content-end">
<small id="charCounter" class="text-muted">0 / 135</small>
</div>
</form>
</div>
<!-- Status Bar -->
<div class="border-top">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="statusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="lastRefresh">Updated: Never</span>
</div>
<div class="d-flex justify-content-end">
<small id="charCounter" class="text-muted">0 / 135</small>
</div>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="statusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="lastRefresh">Updated: Never</span>
</div>
</div>
</div>
</div>
<!-- Floating Action Buttons -->
<div class="fab-container">
<div class="fab-container" id="fabContainer">
<button class="fab fab-toggle" id="fabToggle" title="Hide buttons">
<i class="bi bi-chevron-right"></i>
</button>
<button class="fab fab-filter" id="filterFab" title="Filter Messages">
<i class="bi bi-funnel-fill"></i>
</button>
<button class="fab fab-search" id="globalSearchBtn" data-bs-toggle="modal" data-bs-target="#searchModal" title="Search Messages">
<i class="bi bi-search"></i>
</button>
<button class="fab fab-dm" data-bs-toggle="modal" data-bs-target="#dmModal" title="Direct Messages">
<i class="bi bi-envelope-fill"></i>
</button>
@@ -215,6 +176,23 @@
</div>
</div>
</div>
<!-- System Log Modal (Full Screen) -->
<div class="modal fade" id="logsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content" style="background-color: #1a1a2e;">
<div class="modal-header" style="background-color: #16213e; border-bottom: 1px solid #0f3460;">
<h5 class="modal-title text-white"><i class="bi bi-journal-text"></i> System Log</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Close
</button>
</div>
<div class="modal-body p-0">
<iframe id="logsFrame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
@@ -287,6 +265,23 @@
}
});
}
// System Log modal - load iframe on open, clear on close
const logsModal = document.getElementById('logsModal');
if (logsModal) {
logsModal.addEventListener('show.bs.modal', function () {
const logsFrame = document.getElementById('logsFrame');
if (logsFrame) {
logsFrame.src = '/logs';
}
});
logsModal.addEventListener('hidden.bs.modal', function () {
const logsFrame = document.getElementById('logsFrame');
if (logsFrame) {
logsFrame.src = ''; // disconnect WebSocket when closed
}
});
}
});
</script>
{% endblock %}

202
app/templates/logs.html Normal file
View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>System Log - mc-webui</title>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
html, body {
height: 100%;
margin: 0;
background-color: #1a1a2e;
}
.log-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.log-header {
background-color: #16213e;
border-bottom: 1px solid #0f3460;
padding: 0.5rem 1rem;
flex-shrink: 0;
}
.log-filters {
background-color: #16213e;
border-bottom: 1px solid #0f3460;
padding: 0.5rem 1rem;
flex-shrink: 0;
}
.log-entries {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
background-color: #1a1a2e;
min-height: 0;
font-family: 'Courier New', Consolas, monospace;
font-size: 0.82rem;
line-height: 1.4;
}
.log-line {
padding: 1px 4px;
white-space: pre-wrap;
word-break: break-word;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.log-line:hover {
background-color: rgba(255,255,255,0.05);
}
.log-ts { color: #6c757d; }
.log-logger { color: #4ecdc4; }
.log-level-DEBUG { color: #6c757d; }
.log-level-INFO { color: #e0e0e0; }
.log-level-WARNING { color: #ffd93d; }
.log-level-ERROR { color: #ff6b6b; font-weight: bold; }
.log-level-CRITICAL { color: #ff3333; font-weight: bold; background-color: rgba(255,0,0,0.1); }
.log-msg-DEBUG { color: #888; }
.log-msg-INFO { color: #c8c8c8; }
.log-msg-WARNING { color: #e8d44d; }
.log-msg-ERROR { color: #ff8888; }
.log-msg-CRITICAL { color: #ff6666; }
/* Filter controls */
.filter-select, .filter-input {
background-color: #0f3460;
border: 1px solid #1a1a2e;
color: #e0e0e0;
font-size: 0.82rem;
padding: 0.25rem 0.5rem;
}
.filter-select:focus, .filter-input:focus {
background-color: #0f3460;
border-color: #4ecdc4;
color: #e0e0e0;
box-shadow: 0 0 0 0.15rem rgba(78, 205, 196, 0.25);
}
.filter-select option {
background-color: #16213e;
}
.btn-log {
background-color: #0f3460;
border: 1px solid #1a1a2e;
color: #4ecdc4;
font-size: 0.82rem;
padding: 0.25rem 0.5rem;
}
.btn-log:hover, .btn-log:focus {
background-color: #1a1a4e;
border-color: #4ecdc4;
color: #4ecdc4;
}
.btn-log.active {
background-color: #4ecdc4;
color: #1a1a2e;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-indicator.live { background-color: #00ff88; }
.status-indicator.paused { background-color: #ffd93d; }
.status-indicator.disconnected { background-color: #ff6b6b; }
.log-count {
color: #6c757d;
font-size: 0.75rem;
}
/* Highlight search matches */
mark {
background-color: rgba(255, 217, 61, 0.3);
color: inherit;
padding: 0;
}
@media (max-width: 576px) {
.log-header { padding: 0.4rem 0.5rem; }
.log-filters { padding: 0.4rem 0.5rem; }
.log-entries { font-size: 0.75rem; padding: 0.25rem; }
}
</style>
</head>
<body>
<div class="log-container">
<!-- Header -->
<div class="log-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<span class="log-count" id="logCount">0 entries</span>
<span class="status-indicator" id="statusDot"></span>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-log" id="pauseBtn" title="Pause/Resume">
<i class="bi bi-pause-fill" id="pauseIcon"></i>
</button>
<button class="btn btn-sm btn-log" id="clearBtn" title="Clear display">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<!-- Filters -->
<div class="log-filters">
<div class="d-flex gap-2 flex-wrap align-items-center">
<select class="form-select form-select-sm filter-select" id="levelFilter" style="width: auto; min-width: 90px;">
<option value="">All levels</option>
<option value="DEBUG">DEBUG</option>
<option value="INFO" selected>INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<select class="form-select form-select-sm filter-select" id="loggerFilter" style="width: auto; min-width: 120px;">
<option value="">All modules</option>
</select>
<input type="text" class="form-control form-control-sm filter-input" id="searchFilter"
placeholder="Search..." style="width: auto; min-width: 150px; flex: 1;">
<button class="btn btn-sm btn-log" id="resetFilters" title="Reset filters">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
<!-- Log entries -->
<div class="log-entries" id="logEntries">
<div class="text-muted text-center py-3" id="loadingMsg">Loading logs...</div>
</div>
</div>
<!-- Socket.IO client -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- Bootstrap JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Log Viewer JS -->
<script src="{{ url_for('static', filename='js/logs.js') }}"></script>
</body>
</html>

View File

@@ -1,71 +1,38 @@
# mc-webui v2 — single container with direct device access
services:
# MeshCore Bridge - Handles USB communication with meshcli
meshcore-bridge:
build:
context: ./meshcore-bridge
dockerfile: Dockerfile
container_name: meshcore-bridge
mc-webui:
build: .
container_name: mc-webui
restart: unless-stopped
ports:
- "${FLASK_PORT:-5000}:${FLASK_PORT:-5000}"
# Grant access to serial devices for auto-detection
# This allows MC_SERIAL_PORT=auto to work without specifying device upfront
# Major 188 = ttyUSB (CP2102, CH340, etc.), Major 166 = ttyACM (ESP32-S3, etc.)
# Major 188 = ttyUSB (CP2102, CH340), Major 166 = ttyACM (ESP32-S3)
device_cgroup_rules:
- 'c 188:* rmw'
- 'c 166:* rmw'
volumes:
- "${MC_CONFIG_DIR}:/root/.config/meshcore:rw"
- "${MC_CONFIG_DIR:-./data}:/data:rw"
- "/dev:/dev"
environment:
- MC_SERIAL_PORT=${MC_SERIAL_PORT:-auto}
- MC_CONFIG_DIR=/root/.config/meshcore
- MC_DEVICE_NAME=${MC_DEVICE_NAME:-auto}
- TZ=${TZ:-UTC}
networks:
- meshcore-net
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Main Web UI - Communicates with bridge via HTTP
mc-webui:
build:
context: .
dockerfile: Dockerfile
container_name: mc-webui
restart: unless-stopped
ports:
- "${FLASK_PORT:-5000}:5000"
volumes:
- "${MC_CONFIG_DIR}:/root/.config/meshcore:rw"
- "${MC_ARCHIVE_DIR:-./archive}:/root/.archive/meshcore:rw"
environment:
- MC_BRIDGE_URL=http://meshcore-bridge:5001/cli
- MC_DEVICE_NAME=${MC_DEVICE_NAME}
- MC_CONFIG_DIR=/root/.config/meshcore
- MC_ARCHIVE_DIR=/root/.archive/meshcore
- MC_ARCHIVE_ENABLED=${MC_ARCHIVE_ENABLED:-true}
- MC_ARCHIVE_RETENTION_DAYS=${MC_ARCHIVE_RETENTION_DAYS:-7}
- MC_DEVICE_NAME=${MC_DEVICE_NAME:-MeshCore}
- MC_CONFIG_DIR=/data
- MC_TCP_HOST=${MC_TCP_HOST:-}
- MC_TCP_PORT=${MC_TCP_PORT:-5555}
- MC_BACKUP_ENABLED=${MC_BACKUP_ENABLED:-true}
- MC_BACKUP_HOUR=${MC_BACKUP_HOUR:-2}
- MC_BACKUP_RETENTION_DAYS=${MC_BACKUP_RETENTION_DAYS:-7}
- FLASK_HOST=${FLASK_HOST:-0.0.0.0}
- FLASK_PORT=${FLASK_PORT:-5000}
- FLASK_DEBUG=${FLASK_DEBUG:-false}
- TZ=${TZ:-UTC}
env_file:
- .env
depends_on:
meshcore-bridge:
condition: service_healthy
networks:
- meshcore-net
- path: .env
required: false
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/status')"]
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
meshcore-net:
driver: bridge
start_period: 15s

View File

@@ -0,0 +1,45 @@
# Migration Guide: v1 to v2
## Overview
v2 replaces the `meshcore-cli` bridge with direct `meshcore` library communication. This changes how contacts and DMs work at a fundamental level.
## Breaking Changes
### 1. DM contacts must exist on the device firmware
**Symptom**: Sending a DM to an existing contact fails with "Contact not on device".
**Why**: In v2, `meshcore` communicates directly with the device firmware, which **requires** the contact to exist in its internal contact table (max 350 entries) to send a DM. The mc-webui database may contain hundreds of contacts from advertisement history, but only a handful are actually present on the device.
This can happen after:
- **Firmware reflash** — wipes the device contact table while the DB retains all contacts
- **Migration from v1** — v1 `meshcore-cli` bridge managed contacts independently; many DB contacts may have never been added to the device
- **Device reset** — any factory reset clears the firmware contact table
**How to verify**: Check the startup log for `Synced N contacts from device to database`. This N is the actual number of contacts on the device — likely much smaller than the total in the DB.
**Fix**: For each contact you want to DM:
1. Delete the contact from the Contacts page
2. Wait for their next advertisement
3. Approve the contact when it appears in the pending list
This adds the contact to the device's firmware table, enabling DM sending.
**Note**: Incoming DMs from any contact still work regardless — this only affects *sending* DMs.
### 2. Contact soft-delete preserves DM history
In v2, deleting a contact is a soft-delete (marked as `source='deleted'` in the database). This preserves DM conversation history. When the contact is re-added, it automatically "undeletes" and all previous DMs are visible again.
### 3. Database schema
v2 uses SQLite with WAL mode instead of flat JSON files. The migration from v1 data happens automatically on first startup (see `app/migrate_v1.py`). The v1 data files are preserved and not modified.
## Post-Migration Checklist
- [ ] Verify device connection (green "Connected" indicator)
- [ ] Check that channel messages are flowing normally
- [ ] Check startup log: `Synced N contacts from device to database` — this is your actual device contact count
- [ ] For each DM contact you need: delete, wait for advert, re-approve
- [ ] Verify DM sending works with a test message

View File

@@ -6,318 +6,271 @@ Technical documentation for mc-webui, covering system architecture, project stru
- [Tech Stack](#tech-stack)
- [Container Architecture](#container-architecture)
- [Bridge Session Architecture](#bridge-session-architecture)
- [DeviceManager Architecture](#devicemanager-architecture)
- [Project Structure](#project-structure)
- [Message File Format](#message-file-format)
- [Database Architecture](#database-architecture)
- [API Reference](#api-reference)
- [WebSocket API](#websocket-api)
- [Offline Support](#offline-support)
---
## Tech Stack
- **Backend:** Python 3.11+, Flask, Flask-SocketIO (gevent)
- **Backend:** Python 3.11+, Flask, Flask-SocketIO (gevent), SQLite
- **Frontend:** HTML5, Bootstrap 5, vanilla JavaScript, Socket.IO client
- **Deployment:** Docker / Docker Compose (2-container architecture)
- **Communication:** HTTP bridge to meshcore-cli, WebSocket for interactive console
- **Data source:** `~/.config/meshcore/<device_name>.msgs` (JSON Lines)
- **Deployment:** Docker / Docker Compose (Single-container architecture)
- **Communication:** Direct hardware access (USB, BLE, or TCP) via `meshcore` library
- **Data source:** SQLite Database (`./data/meshcore/<pubkey_prefix>.db`)
---
## Container Architecture
mc-webui uses a **2-container architecture** for improved USB stability:
mc-webui uses a **single-container architecture** for simplified deployment and direct hardware communication:
```
```text
┌─────────────────────────────────────────────────────────────┐
│ Docker Network │
│ │
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
│ │ meshcore-bridge mc-webui
│ │ │ │
│ │ - USB device access│ HTTP │ - Flask web app │ │
│ │ - meshcli process │◄────►│ - User interface │ │
│ │ - Port 5001 - Port 5000 │ │
│ │ │ │
│ └─────────┬───────────┘ └─────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐
│ │ mc-webui
│ │
│ │ - Flask web app (Port 5000)
│ │ - DeviceManager (Direct USB/TCP access)
│ │ - Database (SQLite)
│ │
│ └─────────┬─────────────────────────────────────────────┘
│ │ │
└────────────┼─────────────────────────────────────────────────┘
┌──────────────┐
│ USB Device
(Heltec V4)
│ USB/TCP
Device
└──────────────┘
```
### meshcore-bridge (Port 5001 - internal)
Lightweight service with exclusive USB device access:
- Maintains a **persistent meshcli session** (single long-lived process)
- Multiplexes stdout: JSON adverts → `.adverts.jsonl` log, CLI commands → HTTP responses
- Real-time message reception via `msgs_subscribe` (no polling)
- Thread-safe command queue with event-based synchronization
- Watchdog thread for automatic crash recovery
- Exposes HTTP API on port 5001 (internal only)
### mc-webui (Port 5000 - external)
Main web application:
- Flask-based web interface with Flask-SocketIO
- Communicates with bridge via HTTP API
- WebSocket support for interactive Console (`/console` namespace)
- No direct USB access (prevents device locking)
This separation solves USB timeout/deadlock issues common in Docker + VM environments.
This v2 architecture eliminates the need for a separate bridge container and relies on the native `meshcore` Python library for direct communication, ensuring lower latency and greater stability.
---
## Bridge Session Architecture
## DeviceManager Architecture
The meshcore-bridge maintains a **single persistent meshcli session** instead of spawning new processes per request:
The `DeviceManager` handles the connection to the MeshCore device via a direct session:
- **Single subprocess.Popen** - One long-lived meshcli process with stdin/stdout pipes
- **Multiplexing** - Intelligently routes output:
- JSON adverts (with `payload_typename: "ADVERT"`) → logged to `{device_name}.adverts.jsonl`
- CLI command responses → returned via HTTP API
- **Real-time messages** - `msgs_subscribe` command enables instant message reception without polling
- **Thread-safe queue** - Commands are serialized through a queue.Queue for FIFO execution
- **Timeout-based detection** - Response completion detected when no new lines arrive for 300ms
- **Auto-restart watchdog** - Monitors process health and restarts on crash
This architecture enables advanced features like pending contact management (`manual_add_contacts`) and provides better stability and performance.
- **Single persistent session** - One long-lived connection utilizing the `meshcore` library
- **Event-driven** - Subscribes to device events (e.g., incoming messages, advert receptions, ACKs) and triggers appropriate handlers
- **Direct Database integration** - Seamlessly syncs contacts, messages, and device settings to the SQLite database
- **Real-time messages** - Instant message processing via callback events without polling
- **Thread-safe queue** - Commands are serialized to prevent device lockups
- **Auto-restart watchdog** - Monitors connection health and restarts the session on crash
---
## Project Structure
```
```text
mc-webui/
├── Dockerfile # Main app Docker image
├── docker-compose.yml # Multi-container orchestration
├── meshcore-bridge/
│ ├── Dockerfile # Bridge service image
│ ├── bridge.py # HTTP API wrapper for meshcli
│ └── requirements.txt # Bridge dependencies (Flask only)
├── docker-compose.yml # Single-container orchestration
├── app/
│ ├── __init__.py
│ ├── main.py # Flask entry point
│ ├── main.py # Flask entry point + Socket.IO handlers
│ ├── config.py # Configuration from env vars
│ ├── read_status.py # Server-side read status manager
│ ├── database.py # SQLite database models and CRUD operations
│ ├── device_manager.py # Core logic for meshcore communication
│ ├── contacts_cache.py # Persistent contacts cache (DB-backed)
│ ├── read_status.py # Server-side read status manager (DB-backed)
│ ├── version.py # Git-based version management
│ ├── migrate_v1.py # Migration script from v1 flat files to v2 SQLite
│ ├── meshcore/
│ │ ├── __init__.py
│ │ ├── cli.py # HTTP client for bridge API
│ │ └── parser.py # .msgs file parser
│ │ ├── cli.py # Meshcore library wrapper interface
│ │ └── parser.py # Data parsers
│ ├── archiver/
│ │ └── manager.py # Archive scheduler and management
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # REST API endpoints
│ │ └── views.py # HTML views
│ ├── static/
│ ├── css/
│ │ │ └── style.css # Custom styles
│ │ ├── js/
│ │ │ ├── app.js # Main page frontend logic
│ │ │ ├── dm.js # Direct Messages page logic
│ │ │ ├── contacts.js # Contact Management logic
│ │ │ ├── console.js # Interactive console WebSocket client
│ │ │ ├── message-utils.js # Message content processing
│ │ │ └── sw.js # Service Worker for PWA
│ │ ├── vendor/ # Local vendor libraries (offline)
│ │ │ ├── bootstrap/ # Bootstrap CSS/JS
│ │ │ ├── bootstrap-icons/ # Icon fonts
│ │ │ ├── socket.io/ # Socket.IO client library
│ │ │ └── emoji-picker-element/
│ │ └── manifest.json # PWA manifest
│ └── templates/
│ ├── base.html # Base template
│ ├── index.html # Main chat view
│ ├── dm.html # Direct Messages view
│ ├── console.html # Interactive meshcli console
│ ├── contacts_base.html # Contact pages base template
│ ├── contacts-manage.html # Contact Management settings
│ ├── contacts-pending.html # Pending contacts view
│ └── contacts-existing.html # Existing contacts view
│ ├── static/ # Frontend assets (CSS, JS, images, vendors)
└── templates/ # HTML templates
├── docs/ # Documentation
├── images/ # Screenshots and diagrams
├── requirements.txt # Python dependencies
├── .env.example # Example environment config
├── scripts/ # Utility scripts (update, watchdog, updater)
└── README.md
```
---
## Message File Format
## Database Architecture
Location: `~/.config/meshcore/<device_name>.msgs` (JSON Lines)
mc-webui v2 uses a robust **SQLite Database** with WAL (Write-Ahead Logging) enabled.
### Message Types
Location: `./data/meshcore/<pubkey_prefix>.db`
**Channel messages:**
```json
{"type": "CHAN", "text": "User: message", "timestamp": 1766300846}
{"type": "SENT_CHAN", "text": "my message", "name": "DeviceName", "timestamp": 1766309432}
```
Key tables:
- `messages` - All channel and direct messages (with FTS5 index for full-text search)
- `contacts` - Contact list with sync status, types, block/ignore flags
- `channels` - Channel configuration and keys
- `echoes` - Sent message tracking and repeater paths
- `acks` - DM delivery status
- `settings` - Application settings (migrated from .webui_settings.json)
**Private messages:**
```json
{"type": "PRIV", "text": "message", "sender_timestamp": 1766300846, "pubkey_prefix": "abc123", "sender": "User"}
{"type": "SENT_MSG", "text": "message", "recipient": "User", "expected_ack": "xyz", "suggested_timeout": 30000}
```
**Note on SENT_MSG:** Requires meshcore-cli >= 1.3.12 for correct format with both `recipient` and `sender` fields.
The use of SQLite allows for fast queries, reliable data storage, full-text search, and complex filtering (such as contact ignoring/blocking) without the risk of file corruption inherent to flat JSON files.
---
## API Reference
### Main Web UI Endpoints
### Messages
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/messages` | List messages (supports `?archive_date`, `?days`, `?channel_idx`) |
| GET | `/api/messages` | List messages (`?archive_date`, `?days`, `?channel_idx`) |
| POST | `/api/messages` | Send message (`{text, channel_idx, reply_to?}`) |
| GET | `/api/messages/updates` | Check for new messages (smart refresh) |
| GET | `/api/status` | Connection status |
| GET | `/api/messages/<id>/meta` | Get message metadata (echoes, paths) |
| GET | `/api/messages/search` | Full-text search (`?q=`, `?channel_idx=`, `?limit=`) |
### Contacts
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/contacts` | List contacts |
| GET | `/api/contacts/detailed` | Full contact_info data |
| POST | `/api/contacts/delete` | Delete contact by name |
| GET | `/api/contacts/pending` | List pending contacts |
| GET | `/api/contacts/detailed` | Full contact data (includes protection, ignore, block flags) |
| GET | `/api/contacts/cached` | Get cached contacts (superset of device contacts) |
| POST | `/api/contacts/delete` | Soft-delete contact (`{selector}`) |
| POST | `/api/contacts/cached/delete` | Delete cached contact |
| GET | `/api/contacts/protected` | List protected public keys |
| POST | `/api/contacts/<key>/protect` | Toggle contact protection |
| POST | `/api/contacts/<key>/ignore` | Toggle contact ignore |
| POST | `/api/contacts/<key>/block` | Toggle contact block |
| GET | `/api/contacts/blocked-names` | Get blocked names count |
| POST | `/api/contacts/block-name` | Block a name pattern |
| GET | `/api/contacts/blocked-names-list` | List blocked name patterns |
| POST | `/api/contacts/preview-cleanup` | Preview cleanup criteria |
| POST | `/api/contacts/cleanup` | Remove contacts by filter |
| GET | `/api/contacts/cleanup-settings` | Get auto-cleanup settings |
| POST | `/api/contacts/cleanup-settings` | Update auto-cleanup settings |
| GET | `/api/contacts/pending` | Pending contacts (`?types=1&types=2`) |
| POST | `/api/contacts/pending/approve` | Approve pending contact |
| POST | `/api/contacts/preview-cleanup` | Preview cleanup matches |
| POST | `/api/contacts/cleanup` | Execute contact cleanup |
| POST | `/api/contacts/pending/reject` | Reject pending contact |
| POST | `/api/contacts/pending/clear` | Clear all pending contacts |
| POST | `/api/contacts/manual-add` | Add contact from URI or params |
| POST | `/api/contacts/<key>/push-to-device` | Push cached contact to device |
| POST | `/api/contacts/<key>/move-to-cache` | Move device contact to cache |
| GET | `/api/contacts/repeaters` | List repeater contacts (for path picker) |
| GET | `/api/contacts/<key>/paths` | Get contact paths |
| POST | `/api/contacts/<key>/paths` | Add path to contact |
| PUT | `/api/contacts/<key>/paths/<id>` | Update path (star, label) |
| DELETE | `/api/contacts/<key>/paths/<id>` | Delete path |
| POST | `/api/contacts/<key>/paths/reorder` | Reorder paths |
| POST | `/api/contacts/<key>/paths/reset_flood` | Reset to FLOOD routing |
| POST | `/api/contacts/<key>/paths/clear` | Clear all paths |
| GET | `/api/contacts/<key>/no_auto_flood` | Get "Keep path" flag |
| PUT | `/api/contacts/<key>/no_auto_flood` | Set "Keep path" flag |
### Channels
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/channels` | List all channels |
| POST | `/api/channels` | Create new channel |
| POST | `/api/channels/join` | Join existing channel |
| DELETE | `/api/channels/<index>` | Remove channel |
| GET | `/api/channels/<index>/qr` | Generate QR code |
| GET | `/api/dm/conversations` | List DM conversations |
| GET | `/api/dm/messages` | Get messages for conversation |
| POST | `/api/dm/messages` | Send DM |
| GET | `/api/dm/updates` | Check for new DMs |
| GET | `/api/device/info` | Device information |
| GET | `/api/device/settings` | Get device settings |
| POST | `/api/device/settings` | Update device settings |
| POST | `/api/device/command` | Execute special command |
| GET | `/api/read_status` | Get server-side read status |
| POST | `/api/read_status/mark_read` | Mark messages as read |
| GET | `/api/archives` | List available archives |
| POST | `/api/archive/trigger` | Manually trigger archiving |
| GET | `/api/channels/<index>/qr` | QR code (`?format=json\|png`) |
| GET | `/api/channels/muted` | Get muted channels |
| POST | `/api/channels/<index>/mute` | Toggle channel mute |
### WebSocket API (Console)
Interactive meshcli console via Socket.IO WebSocket connection.
**Namespace:** `/console`
| Event | Direction | Description |
|-------|-----------|-------------|
| `send_command` | Client → Server | Execute command (`{command: "infos"}`) |
| `console_status` | Server → Client | Connection status message |
| `command_response` | Server → Client | Command result (`{success, command, output}`) |
**Features:**
- Command history navigation (up/down arrows)
- Auto-reconnection on disconnect
- Output cleaning (removes prompts like "DeviceName|*")
- Slow command timeouts: `node_discover` (15s), `recv` (60s), `send` (15s)
### Bridge Internal API (Port 5001)
### Direct Messages
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/cli` | Execute meshcli command (`{args: ["cmd"], timeout?}`) |
| GET | `/health` | Bridge health check |
| GET | `/pending_contacts` | List pending contacts |
| POST | `/add_pending` | Approve pending contact |
| GET | `/device/settings` | Get device settings |
| POST | `/device/settings` | Update device settings |
| GET | `/api/dm/conversations` | List DM conversations |
| GET | `/api/dm/messages` | Get messages (`?conversation_id=`, `?limit=`) |
| POST | `/api/dm/messages` | Send DM (`{recipient, text}`) |
| GET | `/api/dm/updates` | Check for new DMs |
| GET | `/api/dm/auto_retry` | Get DM retry configuration |
| POST | `/api/dm/auto_retry` | Update DM retry configuration |
**Base URL:** `http://meshcore-bridge:5001` (internal Docker network only)
### Device & Settings
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/status` | Connection status (device name, serial port) |
| GET | `/api/device/info` | Device information |
| GET | `/api/device/stats` | Device statistics |
| GET | `/api/device/settings` | Get device settings |
| POST | `/api/device/settings` | Update device settings |
| POST | `/api/device/command` | Execute command (advert, floodadv) |
| GET | `/api/device/commands` | List available special commands |
| GET | `/api/chat/settings` | Get chat settings (quote length) |
| POST | `/api/chat/settings` | Update chat settings |
| GET | `/api/retention-settings` | Get message retention settings |
| POST | `/api/retention-settings` | Update retention settings |
### Archives & Backup
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/archives` | List archives |
| POST | `/api/archive/trigger` | Manual archive |
| GET | `/api/backup/list` | List database backups |
| POST | `/api/backup/create` | Create database backup |
| GET | `/api/backup/download` | Download backup file |
### Other
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/read_status` | Get server-side read status |
| POST | `/api/read_status/mark_read` | Mark messages as read |
| POST | `/api/read_status/mark_all_read` | Mark all messages as read |
| GET | `/api/version` | Get app version |
| GET | `/api/check-update` | Check for available updates |
| GET | `/api/updater/status` | Get updater service status |
| POST | `/api/updater/trigger` | Trigger remote update |
| GET | `/api/advertisements` | Get recent advertisements |
| GET | `/api/console/history` | Get console command history |
| POST | `/api/console/history` | Save console command |
| DELETE | `/api/console/history` | Clear console history |
| GET | `/api/logs` | Get application logs |
---
## WebSocket API
### Console Namespace (`/console`)
Interactive console via Socket.IO WebSocket connection.
**Client → Server:**
- `send_command` - Execute command (`{command: "infos"}`)
**Server → Client:**
- `console_status` - Connection status
- `command_response` - Command result (`{success, command, output}`)
### Chat Namespace (`/chat`)
Real-time message delivery via Socket.IO.
**Server → Client:**
- `new_channel_message` - New channel message received
- `new_dm_message` - New DM received
- `message_echo` - Echo/ACK update for sent message
- `dm_ack` - DM delivery confirmation
### Logs Namespace (`/logs`)
Real-time log streaming via Socket.IO.
**Server → Client:**
- `log_line` - New log line
---
## Offline Support
The application works completely offline without internet connection - perfect for mesh networks in remote or emergency scenarios.
### Local Vendor Libraries
| Library | Size | Location |
|---------|------|----------|
| Bootstrap 5.3.2 CSS | ~227 KB | `static/vendor/bootstrap/css/` |
| Bootstrap 5.3.2 JS | ~80 KB | `static/vendor/bootstrap/js/` |
| Bootstrap Icons 1.11.2 | ~398 KB | `static/vendor/bootstrap-icons/` |
| Emoji Picker Element | ~529 KB | `static/vendor/emoji-picker-element/` |
**Total offline package size:** ~1.2 MB
### Service Worker Caching
- **Cache version:** `mc-webui-v3`
- **Strategy:** Hybrid caching
- **Cache-first** for vendor libraries (static, unchanging)
- **Network-first** for app code (dynamic, needs updates)
### How It Works
1. On first visit (online), Service Worker installs and caches all assets
2. Vendor libraries (Bootstrap, Icons, Emoji Picker) loaded from cache instantly
3. App code checks network first, falls back to cache if offline
4. Complete UI functionality available offline
5. Only API calls (messages, channels, contacts) require connectivity
---
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `MC_SERIAL_PORT` | Serial device path | - |
| `MC_DEVICE_NAME` | Device name (for files) | - |
| `MC_CONFIG_DIR` | Configuration directory | `./data/meshcore` |
| `MC_ARCHIVE_DIR` | Archive directory | `./data/archive` |
| `MC_ARCHIVE_ENABLED` | Enable automatic archiving | `true` |
| `MC_ARCHIVE_RETENTION_DAYS` | Days to show in live view | `7` |
| `FLASK_HOST` | Listen address | `0.0.0.0` |
| `FLASK_PORT` | Web server port | `5000` |
| `FLASK_DEBUG` | Debug mode | `false` |
| `TZ` | Timezone for logs | `UTC` |
---
## Persistent Settings
### Settings File
**Location:** `MC_CONFIG_DIR/.webui_settings.json`
```json
{
"manual_add_contacts": false
}
```
### Read Status File
**Location:** `MC_CONFIG_DIR/.read_status.json`
Stores per-channel and per-conversation read timestamps for cross-device synchronization.
```json
{
"channels": {"0": 1735900000, "1": 1735900100},
"dm": {"name_User1": 1735900200}
}
```
---
## Related Documentation
- [User Guide](user-guide.md) - How to use all features
- [Troubleshooting](troubleshooting.md) - Common issues and solutions
- [Docker Installation](docker-install.md) - How to install Docker
The application works completely offline without internet connection. Vendor libraries (Bootstrap, Bootstrap Icons, Socket.IO, Emoji Picker) are bundled locally. A Service Worker provides hybrid caching to ensure functionality without connectivity.

View File

@@ -19,7 +19,7 @@ This guide explains how to manage a MeshCore repeater device directly from the m
<img src="../images/RPT-Mgmt-03-new-rpt-approve.png" alt="Approve repeater" width="200px">
- Reset your search filter (it is recommended to leave CLI selected only) and return to the main chat view
- Reset your search filter (it is recommended to leave COM selected only) and return to the main chat view
<img src="../images/RPT-Mgmt-04-back-to-home.png" alt="Return to home" width="200px">

View File

@@ -5,8 +5,8 @@ Common issues and solutions for mc-webui.
## Table of Contents
- [Common Issues](#common-issues)
- [Device Not Responding](#device-not-responding)
- [Docker Commands](#docker-commands)
- [Testing Bridge API](#testing-bridge-api)
- [Backup and Restore](#backup-and-restore)
- [Next Steps](#next-steps)
- [Getting Help](#getting-help)
@@ -19,8 +19,7 @@ Common issues and solutions for mc-webui.
**Check logs:**
```bash
docker compose logs meshcore-bridge
docker compose logs mc-webui
docker compose logs -f mc-webui
```
**Common causes:**
@@ -52,18 +51,19 @@ docker compose ps
### No messages appearing
**Verify meshcli is working:**
**Check device connection:**
```bash
# Test meshcli directly in bridge container
docker compose exec meshcore-bridge meshcli -s /dev/ttyUSB0 infos
# Check container logs for device communication
docker compose logs -f mc-webui
```
**Check .msgs file:**
**Check database:**
```bash
docker compose exec mc-webui cat /root/.config/meshcore/YourDeviceName.msgs
# Verify the database file exists
ls -la data/meshcore/*.db
```
Replace `YourDeviceName` with your `MC_DEVICE_NAME`.
**Check System Log in the web UI** (Menu → System Log) for real-time device event information.
---
@@ -86,9 +86,9 @@ sudo chmod 666 /dev/serial/by-id/usb-Espressif*
ls -l /dev/serial/by-id/
```
**Restart bridge container:**
**Restart container:**
```bash
docker compose restart meshcore-bridge
docker compose restart mc-webui
```
**Check device permissions:**
@@ -100,40 +100,33 @@ Should show `crw-rw----` with group `dialout`.
---
### USB Communication Issues
### Device not responding
The 2-container architecture resolves common USB timeout/deadlock problems:
- **meshcore-bridge** has exclusive USB access
- **mc-webui** uses HTTP (no direct device access)
- Restarting `mc-webui` **does not** affect USB connection
- If bridge has USB issues, restart only that service:
```bash
docker compose restart meshcore-bridge
**Symptoms:**
- Container logs show repeated `no_event_received` errors and restarts:
```
ERROR:meshcore:Error while querying device: Event(type=<EventType.ERROR: 'command_error'>, payload={'reason': 'no_event_received'})
```
- Device name not detected (auto-detection fails)
- All commands timeout in the Console
---
**What this means:**
### Bridge connection errors
The serial connection to the USB adapter (e.g. CP2102) is working, but the MeshCore device firmware is not responding to protocol commands. The device boots (serial port connects), but the application code is not running properly.
```bash
# Check bridge health
docker compose exec mc-webui curl http://meshcore-bridge:5001/health
**What does NOT help:**
- Restarting Docker containers
- Restarting the host machine
- USB reset or USB power cycle (only resets the USB-to-UART adapter, not the MeshCore radio module)
# Bridge logs
docker compose logs -f meshcore-bridge
**Fix: Re-flash the firmware**
# Test meshcli directly in bridge container
docker compose exec meshcore-bridge meshcli -s /dev/ttyUSB0 infos
```
The MeshCore device firmware is likely corrupted. Re-flash the latest firmware using the MeshCore Flasher:
1. Download the latest firmware from [MeshCore releases](https://github.com/ripplebiz/MeshCore/releases)
2. Flash using [MeshCore Flasher](https://flasher.meshcore.co) or esptool
3. Restart mc-webui: `docker compose up -d`
---
### Messages not updating
- Check that `.msgs` file exists in `MC_CONFIG_DIR`
- Verify bridge service is healthy: `docker compose ps`
- Check bridge logs for command errors
This can happen after a power failure during OTA update, flash memory corruption, or other hardware anomalies.
---
@@ -143,15 +136,9 @@ docker compose exec meshcore-bridge meshcli -s /dev/ttyUSB0 infos
```bash
# mc-webui container logs
docker compose logs -f mc-webui
# meshcore-bridge container logs (where settings are applied)
docker compose logs -f meshcore-bridge
```
**Look for:**
- "Loaded webui settings" - confirms settings file is being read
- "manual_add_contacts set to on/off" - confirms setting is applied to meshcli session
- "Saved manual_add_contacts=..." - confirms setting is persisted to file
You can also check the System Log in the web UI (Menu → System Log) for real-time information about contact events and settings changes.
---
@@ -160,17 +147,13 @@ docker compose logs -f meshcore-bridge
### View logs
```bash
docker compose logs -f # All services
docker compose logs -f mc-webui # Main app only
docker compose logs -f meshcore-bridge # Bridge only
docker compose logs -f mc-webui
```
### Restart services
### Restart
```bash
docker compose restart # Restart both
docker compose restart mc-webui # Restart main app only
docker compose restart meshcore-bridge # Restart bridge only
docker compose restart mc-webui
```
### Start / Stop
@@ -196,69 +179,22 @@ docker compose ps
```bash
docker compose exec mc-webui sh
docker compose exec meshcore-bridge sh
```
---
## Testing Bridge API
The `meshcore-bridge` container exposes HTTP endpoints for diagnostics.
### Test endpoints
```bash
# List pending contacts (from inside mc-webui container or server)
curl -s http://meshcore-bridge:5001/pending_contacts | jq
# Add a pending contact
curl -s -X POST http://meshcore-bridge:5001/add_pending \
-H 'Content-Type: application/json' \
-d '{"selector":"Skyllancer"}' | jq
# Check bridge health
docker compose exec mc-webui curl http://meshcore-bridge:5001/health
```
### Example responses
**GET /pending_contacts:**
```json
{
"success": true,
"pending": [
{
"name": "Skyllancer",
"public_key": "f9ef..."
},
{
"name": "KRA Reksio mob2🐕",
"public_key": "41d5..."
}
],
"raw_stdout": "Skyllancer: f9ef...\nKRA Reksio mob2🐕: 41d5..."
}
```
**POST /add_pending:**
```json
{
"success": true,
"stdout": "Contact added successfully",
"stderr": "",
"returncode": 0
}
```
**Note:** These endpoints require `manual_add_contacts` mode to be enabled.
---
## Backup and Restore
**All important data is in the `data/` directory.**
### Create backup
### UI Backup (recommended)
You can create and download database backups directly from the web UI:
1. Click the menu icon (☰) → "Backup"
2. Click "Create Backup" to create a timestamped backup
3. Click "Download" to save a backup to your local machine
### Manual backup (CLI)
```bash
cd ~/mc-webui
@@ -296,7 +232,7 @@ After successful installation:
1. **Join channels** - Create or join encrypted channels with other users
2. **Configure contacts** - Enable manual approval if desired
3. **Test Direct Messages** - Send DM to other CLI contacts
3. **Test Direct Messages** - Send DM to other COM contacts
4. **Set up backups** - Schedule regular backups of `data/` directory
5. **Read full documentation** - See [User Guide](user-guide.md) for all features
@@ -309,9 +245,8 @@ After successful installation:
- [Architecture](architecture.md) - Technical documentation
- [README](../README.md) - Installation guide
- MeshCore docs: https://meshcore.org
- meshcore-cli docs: https://github.com/meshcore-dev/meshcore-cli
**Issues:**
- GitHub Issues: https://github.com/MarekWo/mc-webui/issues
- Check existing issues before creating new ones
- Include logs when reporting problems
- Include logs when reporting problems (use Menu → System Log for easy access)

View File

@@ -10,7 +10,15 @@ This guide covers all features and functionality of mc-webui. For installation i
- [Sending Messages](#sending-messages)
- [Message Content Features](#message-content-features)
- [Direct Messages (DM)](#direct-messages-dm)
- [Global Search](#global-search)
- [Contact Management](#contact-management)
- [Adding Contacts](#adding-contacts)
- [DM Path Management](#dm-path-management)
- [Interactive Console](#interactive-console)
- [Device Dashboard](#device-dashboard)
- [Settings](#settings)
- [System Log](#system-log)
- [Database Backup](#database-backup)
- [Network Commands](#network-commands)
- [PWA Notifications](#pwa-notifications)
@@ -29,6 +37,8 @@ The main page displays chat history from the currently selected channel. The app
By default, the live view shows messages from the last 7 days. Older messages are automatically archived and can be accessed via the date selector.
On wide screens (tablets/desktops), a sidebar shows the channel list on the left side for quick switching.
---
## Managing Channels
@@ -146,11 +156,12 @@ Access the Direct Messages feature:
### Using the DM Page
1. **Select a recipient** from the dropdown at the top:
1. **Select a recipient** using the searchable contact selector at the top:
- Type to search contacts by name (fuzzy matching)
- **Existing conversations** are shown first (with message history)
- Separator: "--- Available contacts ---"
- **All client contacts** from your device (only CLI type, no repeaters/rooms)
- You can start a new conversation with anyone in your contacts list
- **All companion contacts** from your device (only COM type, no repeaters/rooms)
- Click the info icon next to a contact to view their details (public key, type, location)
- Use the (x) button to clear the search and select a different contact
2. Type your message in the input field (max 140 bytes, same as channels)
3. Use the emoji picker button to insert emojis
4. Press Enter or click Send
@@ -162,12 +173,13 @@ Access the Direct Messages feature:
- When you return to the DM page, it automatically opens the last conversation you were viewing
- This works similarly to how the main page remembers your selected channel
**Note:** Only client contacts (CLI) are shown in the dropdown. Repeaters (REP), rooms (ROOM), and sensors (SENS) are automatically filtered out.
**Note:** Only companion contacts (COM) are shown in the selector. Repeaters (REP), rooms (ROOM), and sensors (SENS) are automatically filtered out.
### Message Status Indicators
-**Delivered** (green checkmark) - Recipient confirmed receipt (ACK). Tap/hover for SNR and route details
- ? **Unknown** (gray question mark) - No ACK received. Message may still have been delivered — ACK packets are often lost over multi-hop routes. Tap the icon for details
-**Pending** (clock icon, yellow) - Message sent, awaiting delivery confirmation
- Note: Due to meshcore-cli limitations, we cannot track actual delivery status
### DM Notifications
@@ -175,6 +187,27 @@ Access the Direct Messages feature:
- Each conversation shows unread indicator (*) in the dropdown
- DM badge in the menu shows total unread DM count
### Desktop Sidebar
On wide screens (tablets/desktops), the DM page shows a sidebar with the contact list on the left side, making it easy to switch between conversations without using the dropdown selector.
---
## Global Search
Search across all your messages (channels and DMs) using full-text search:
1. Click the menu icon (☰) in the navbar
2. Select "Search" from the menu
3. Type your search query and press Enter or click the search button
**Features:**
- **Full-text search** powered by SQLite FTS5 for fast results
- **FTS5 syntax support** - Use quotes for exact phrases (`"hello world"`), prefix matching (`mesh*`), boolean operators (`hello AND world`)
- Results show message content, sender, channel/conversation, and timestamp
- Click a result to navigate to that channel or DM conversation
- Syntax help available via the (?) icon next to the search field
---
## Contact Management
@@ -205,8 +238,8 @@ When manual approval is enabled, new contacts appear in the Pending Contacts lis
**View contact details:**
- Contact name with emoji (if present)
- Type badge (CLI, REP, ROOM, SENS) with color coding:
- CLI (blue): Regular clients
- Type badge (COM, REP, ROOM, SENS) with color coding:
- COM (blue): Companions (clients)
- REP (green): Repeaters
- ROOM (cyan): Room servers
- SENS (yellow): Sensors
@@ -215,7 +248,7 @@ When manual approval is enabled, new contacts appear in the Pending Contacts lis
- Map button (when GPS coordinates are available)
**Filter contacts:**
- By type: Use checkboxes to show only specific contact types (default: CLI only)
- By type: Use checkboxes to show only specific contact types (default: COM only)
- By name or key: Search by partial contact name or public key prefix
**Approve contacts:**
@@ -224,8 +257,12 @@ When manual approval is enabled, new contacts appear in the Pending Contacts lis
- Confirmation modal shows list of contacts to be approved
- Progress indicator during batch approval
**Ignore contacts:**
- **Batch ignore:** Click "Ignore Filtered" to ignore all filtered contacts at once
- **Single ignore:** Click "Ignore" on individual contacts
**Other actions:**
- Click "Map" button to view contact location on Google Maps (when GPS data available)
- Click "Map" button to view contact location on the map (when GPS data available)
- Click "Copy Key" to copy full public key to clipboard
- Click "Refresh" to reload pending contacts list
@@ -233,7 +270,7 @@ When manual approval is enabled, new contacts appear in the Pending Contacts lis
### Existing Contacts
The Existing Contacts section displays all contacts currently stored on your device (CLI, REP, ROOM, SENS types).
The Existing Contacts section displays all contacts currently stored on your device (COM, REP, ROOM, SENS types).
**Features:**
- **Counter badge** - Shows current contact count vs. 350 limit (MeshCore device max)
@@ -241,7 +278,7 @@ The Existing Contacts section displays all contacts currently stored on your dev
- Yellow: Warning (300-339 contacts)
- Red (pulsing): Alarm (≥ 340 contacts)
- **Search** - Filter contacts by name or public key prefix
- **Type filter** - Show only specific contact types (All / CLI / REP / ROOM / SENS)
- **Type filter** - Show only specific contact types (All / COM / REP / ROOM / SENS)
- **Contact cards** - Display name, type badge, public key prefix, path info, and last seen timestamp
- **Last Seen** - Shows when each contact was last active with activity indicators:
- 🟢 **Active** (seen < 5 minutes ago)
@@ -252,10 +289,16 @@ The Existing Contacts section displays all contacts currently stored on your dev
**Managing contacts:**
1. **Search contacts:** Type in the search box to filter by name or public key prefix
2. **Filter by type:** Use the type dropdown to show only CLI, REP, ROOM, or SENS
2. **Filter by type:** Use the type dropdown to show only COM, REP, ROOM, or SENS
3. **Copy public key:** Click "Copy Key" button to copy the public key prefix to clipboard
4. **Delete a contact:** Click the "Delete" button (red trash icon) and confirm
**Ignoring and Blocking Contacts:**
- **Ignore**: The contact is hidden from the main view and their messages do not trigger notifications.
- **Block**: The contact is completely blocked. Their messages are dropped and will not appear anywhere.
To ignore or block a contact, click the "Ignore" or "Block" button on their contact card. To restore them, switch the type filter to "Ignored" or "Blocked" and click the "Restore" button.
**Contact capacity monitoring:**
- MeshCore devices have a limit of 350 contacts
- The counter badge changes color as you approach the limit:
@@ -263,6 +306,13 @@ The Existing Contacts section displays all contacts currently stored on your dev
- **300-339**: Yellow warning (nearing limit)
- **340-350**: Red alarm (critical - delete some contacts soon)
### Contact Map
Access the map from the main menu to view the GPS locations of your contacts.
- Contacts with known GPS coordinates will be displayed as markers on OpenStreetMap.
- Click a marker to see the contact name and details.
- Use the **Cached** switch to toggle the display of cache-only contacts (contacts that are saved in your database but no longer present in the device's internal memory).
### Contact Cleanup Tool
The advanced cleanup tool allows you to filter and remove contacts based on multiple criteria:
@@ -272,7 +322,7 @@ The advanced cleanup tool allows you to filter and remove contacts based on mult
3. Configure filters:
- **Name Filter:** Enter partial contact name to search (optional)
- **Advanced Filters** (collapsible):
- **Contact Types:** Select which types to include (CLI, REP, ROOM, SENS)
- **Contact Types:** Select which types to include (COM, REP, ROOM, SENS)
- **Date Field:** Choose between "Last Advert" (recommended) or "Last Modified"
- **Days of Inactivity:** Contacts inactive for more than X days (0 = ignore)
4. Click **Preview Cleanup** to see matching contacts
@@ -303,6 +353,206 @@ You can schedule automatic cleanup to run daily at a specified hour:
---
## Adding Contacts
Add new contacts to your device from the Contact Management page:
1. Click the "Add Contact" button at the top of the Contact Management page
2. Opens a dedicated page with three methods:
### Paste URI
1. Paste a MeshCore contact URI (`meshcore://...`) into the text field
2. The contact details (name, public key, type) are automatically parsed and previewed
3. Click "Add to Device" to add the contact
### Scan QR Code
1. Click "Scan QR" to open the camera
2. Point at a MeshCore QR code (from another user's Share tab)
3. The URI is decoded and contact details are previewed
4. Click "Add to Device" to add the contact
### Manual Entry
1. Enter the contact's public key (64 hex characters)
2. Optionally enter name, type (COM/REP/ROOM/SENS), and location
3. Click "Add to Device"
### Cache vs Device Contacts
- **Device contacts** are stored on the MeshCore hardware (limit: 350)
- **Cache contacts** are stored only in the database (unlimited)
- Use "Push to Device" to promote a cache contact to the device
- Use "Move to Cache" to free a device slot while keeping the contact in the database
---
## DM Path Management
Configure message routing paths for individual contacts:
1. Open a DM conversation
2. Click the contact info icon next to the contact name
3. In the Contact Info modal, navigate to the "Paths" section
### Path Configuration
- **Add Path** - Add a repeater to the routing path using:
- **Repeater picker** - Browse available repeaters by name or ID
- **Map picker** - Select repeaters from a map view showing their GPS locations
- **Import current path** - Import the path currently stored on the device
- **Reorder** - Drag paths to change priority (starred path is used first)
- **Star** - Mark a preferred primary path (used first in retry rotation)
- **Delete** - Remove individual paths
### Keep Path Toggle
- Enable "Keep path" to prevent the device from automatically switching to FLOOD routing
- When enabled, the device will always use the configured DIRECT path(s)
- Useful when you know the optimal route and don't want the device to override it
### Path Operations
- **Reset to FLOOD** - Clear all paths and switch to FLOOD routing
- **Clear Paths** - Remove all configured paths without changing routing mode
---
## Interactive Console
Access the interactive console for direct MeshCore command execution:
1. Click the menu icon (☰) in the navbar
2. Select "Console" from the menu
3. Opens in a fullscreen modal with a command prompt
### Available Command Categories
The console supports a comprehensive set of MeshCore commands organized into categories:
**Repeater Management:**
- `req_owner <name>` - Request repeater owner info
- `req_regions <name>` - Request repeater regions
- `req_clock <name>` - Request repeater clock
- `req_neighbours <name>` - Request repeater neighbors list
- `set_owner <name> <value>` - Set repeater owner
- `set_regions <name> <value>` - Set repeater regions
- `set_clock <name>` - Sync repeater clock
**Contact Management:**
- `contacts` - List all device contacts
- `.contacts` - List contacts (JSON format)
- `.pending_contacts` - List pending contacts
- `add_pending <key>` - Approve pending contact
- `remove_contact <name>` - Remove contact
**Device & Channel Management:**
- `infos` / `ver` - Device info / firmware version
- `stats` - Device statistics
- `self_telemetry` - Own device telemetry
- `get_channels` - List channels
- `get <param>` / `set <param> <value>` - Get/set device parameters
- `trace <name>` - Trace route to contact
- `neighbours` - Request neighbor list from device
### Console Features
- **Command history** - Navigate with up/down arrows, or use the history dropdown
- **Persistent history** - Saved on server, accessible across sessions
- **Auto-reconnect** - WebSocket reconnects automatically on disconnect
- **Status indicator** - Green/yellow/red dot shows connection status
- **Human-readable output** - Clock times, statistics, and telemetry formatted for readability
---
## Device Dashboard
Access device information and statistics:
1. Click the menu icon (☰) in the navbar
2. Select "Device Info" from the menu
### Info Tab
Displays device parameters in a readable table:
- Device name, type, public key
- Location coordinates with map button
- Radio parameters (frequency, bandwidth, spreading factor, coding rate)
- TX power, multi-acks, location sharing settings
### Stats Tab
Shows live device statistics:
- Uptime, free memory, battery voltage
- Message counters (sent, received, forwarded)
- Current airtime usage
### Share Tab
Share your device contact with others:
- **QR Code** - Scannable QR code containing your contact URI
- **URI** - Copyable `meshcore://` URI that others can paste into their Add Contact page
---
## Settings
Access the Settings modal to configure application behavior:
1. Click the menu icon (☰) in the navbar
2. Select "Settings" from the menu
### DM Retry Settings
Configure how direct messages are retried when delivery is not confirmed:
- **Retry count** - Number of retry attempts (includes initial send)
- **Retry interval** - Seconds between retries
- **Flood fallback attempt** - After which attempt to switch from DIRECT to FLOOD routing
- **Grace period** - Seconds to wait for late ACKs after all retries complete
### Quote Settings
- **Max quote length** - Maximum number of bytes to include when quoting a message
### Message Retention
- **Live view days** - Number of days of messages shown in the live view (older messages are archived)
### Theme
- **Dark / Light** - Toggle between dark and light UI themes. The preference is saved in local browser storage
---
## System Log
View real-time application logs:
1. Click the menu icon (☰) in the navbar
2. Select "System Log" from the menu
3. Opens in a fullscreen modal with streaming log output
The log viewer shows the most recent application log entries and streams new entries in real-time. Useful for monitoring device events, debugging issues, and verifying message delivery.
---
## Database Backup
Create and manage database backups:
1. Click the menu icon (☰) in the navbar
2. Select "Backup" from the menu
**Features:**
- **Create backup** - Creates a timestamped copy of the current database
- **List backups** - View all available backups with timestamps and file sizes
- **Download** - Download any backup file to your local machine
Backups are stored in the `./data/` directory alongside the main database.
---
## Network Commands
Access network commands from the slide-out menu under "Network Commands" section:
@@ -418,6 +668,6 @@ To get the full PWA experience with app badge counters:
- **Repeater Management:** [rpt-mgmt.md](rpt-mgmt.md)
- **Troubleshooting:** [troubleshooting.md](troubleshooting.md)
- **Architecture:** [architecture.md](architecture.md)
- **Container Watchdog:** [watchdog.md](watchdog.md)
- **MeshCore docs:** https://meshcore.org
- **meshcore-cli docs:** https://github.com/meshcore-dev/meshcore-cli
- **GitHub Issues:** https://github.com/MarekWo/mc-webui/issues

View File

@@ -1,14 +1,16 @@
# Container Watchdog
The Container Watchdog is a systemd service that monitors Docker containers and automatically restarts unhealthy or stopped ones. This is useful for ensuring reliability, especially on resource-constrained systems.
The Container Watchdog is a systemd service that monitors the `mc-webui` Docker container and automatically restarts it if it becomes unhealthy or if the LoRa device becomes unresponsive. This is useful for ensuring reliability, especially on resource-constrained systems or when the LoRa hardware hangs.
## Features
- **Health monitoring** - Checks container status every 30 seconds
- **Automatic restart** - Restarts containers that become unhealthy
- **Auto-start stopped containers** - Starts containers that have stopped (configurable)
- **Log monitoring** - Monitors `mc-webui` logs for specific "unresponsive LoRa device" errors
- **Automatic restart** - Restarts the container when issues are detected
- **Auto-start stopped container** - Starts the container if it has stopped (configurable)
- **Hardware USB reset** - Performs a low-level USB bus reset (unbind/bind or DTR/RTS) if the LoRa device freezes. *Note: USB reset is automatically skipped if a TCP connection is used.*
- **Diagnostic logging** - Captures container logs before restart for troubleshooting
- **HTTP status endpoint** - Query container status via HTTP API
- **HTTP status endpoint** - Query watchdog status via HTTP API
- **Restart history** - Tracks all automatic restarts with timestamps
## Installation
@@ -20,9 +22,9 @@ sudo ./scripts/watchdog/install.sh
The installer will:
- Create a systemd service `mc-webui-watchdog`
- Start monitoring containers immediately
- Start monitoring the container immediately
- Enable automatic startup on boot
- Create log file at `/var/log/mc-webui-watchdog.log`
- Create a log file at `/var/log/mc-webui-watchdog.log`
## Usage
@@ -59,9 +61,9 @@ curl http://localhost:5051/history
### Diagnostic Files
When a container is restarted, diagnostic information is saved to:
When the container is restarted, diagnostic information is saved to:
```
/tmp/mc-webui-watchdog-{container}-{timestamp}.log
/tmp/mc-webui-watchdog-mc-webui-{timestamp}.log
```
These files contain:
@@ -81,7 +83,8 @@ If you need to customize the behavior, the service supports these environment va
| `CHECK_INTERVAL` | `30` | Seconds between health checks |
| `LOG_FILE` | `/var/log/mc-webui-watchdog.log` | Path to log file |
| `HTTP_PORT` | `5051` | HTTP status port (0 to disable) |
| `AUTO_START` | `true` | Start stopped containers (set to `false` to disable) |
| `AUTO_START` | `true` | Start stopped container (set to `false` to disable) |
| `USB_DEVICE_PATH` | *(auto-detected)* | Path to the LoRa device for hardware USB bus reset |
To modify defaults, create an override file:
```bash
@@ -105,29 +108,3 @@ Note: The log file is preserved after uninstall. Remove manually if needed:
```bash
sudo rm /var/log/mc-webui-watchdog.log
```
## Troubleshooting
### Service won't start
Check the logs:
```bash
journalctl -u mc-webui-watchdog -n 50
```
Common issues:
- Docker not running
- Python 3 not installed
- Permission issues
### Containers keep restarting
Check the diagnostic files in `/tmp/mc-webui-watchdog-*.log` to see what's causing the containers to become unhealthy.
### HTTP endpoint not responding
Verify the service is running and check if port 5051 is available:
```bash
systemctl status mc-webui-watchdog
ss -tlnp | grep 5051
```

BIN
gallery/DM_Settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 207 KiB

BIN
gallery/global_search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
gallery/sytem_log.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -1,28 +0,0 @@
# MeshCore Bridge Dockerfile
FROM python:3.11-slim
LABEL maintainer="mc-webui"
LABEL description="MeshCore CLI Bridge - HTTP API wrapper for meshcli"
WORKDIR /bridge
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Install meshcore-cli (from PyPI)
RUN pip install --no-cache-dir meshcore-cli==1.3.21
# Copy bridge application
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bridge.py .
# Expose bridge API port
EXPOSE 5001
# Run bridge
CMD ["python", "bridge.py"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +0,0 @@
# MeshCore Bridge - Minimal dependencies
Flask==3.0.0
Werkzeug==3.0.1
# WebSocket support for console
flask-socketio==5.3.6
python-socketio==5.10.0
python-engineio==4.8.1
gevent==23.9.1
gevent-websocket==0.10.1

View File

@@ -23,7 +23,13 @@ Pillow==10.1.0
# HTTP Client for MeshCore Bridge communication
requests==2.31.0
# Cryptography for pkt_payload computation (AES-128-ECB)
pycryptodome==3.21.0
# WebSocket support for console (threading mode - no gevent needed)
flask-socketio==5.3.6
python-socketio==5.10.0
python-engineio==4.8.1
# v2: Direct MeshCore device communication (replaces bridge subprocess)
meshcore>=2.2.0

558
scripts/check_compat.py Normal file
View File

@@ -0,0 +1,558 @@
#!/usr/bin/env python3
"""
meshcore-cli compatibility checker for mc-webui
Tests all meshcli commands and response formats used by mc-webui
against the currently running meshcore-bridge instance.
Usage (from host, piped into mc-webui container):
cd ~/mc-webui
cat scripts/check_compat.py | docker compose exec -T mc-webui python -
# Full mode (includes advert test):
cat scripts/check_compat.py | docker compose exec -T mc-webui env FULL=1 python -
"""
import json
import os
import re
import sys
import time
import requests
DEFAULT_BRIDGE_URL = "http://meshcore-bridge:5001"
# Expected fields in .contacts JSON response (per contact entry)
EXPECTED_CONTACT_FIELDS = {
"public_key", "type", "adv_name", "flags",
"out_path_len", "out_path", "last_advert",
"adv_lat", "adv_lon", "lastmod"
}
# Valid contact types in text format
VALID_CONTACT_TYPES = {"COM", "REP", "ROOM", "SENS"}
# Expected fields in /health response
EXPECTED_HEALTH_FIELDS = {
"status", "serial_port", "device_name", "device_name_source"
}
# Channel line format: "0: Public [8b3387e9c5cdea6ac9e5edbaa115cd72]"
CHANNEL_REGEX = re.compile(r'^(\d+):\s+(.+?)\s+\[([a-f0-9]{32})\]$')
# Contacts text format: columns separated by 2+ spaces
CONTACTS_SPLIT_REGEX = re.compile(r'\s{2,}')
class CompatChecker:
"""Checks meshcore-cli compatibility with mc-webui"""
PASS = "PASS"
WARN = "WARN"
FAIL = "FAIL"
SKIP = "SKIP"
ERROR = "ERROR"
def __init__(self, bridge_url, full_mode=False):
self.bridge_url = bridge_url.rstrip('/')
self.full_mode = full_mode
self.results = []
def run_command(self, args, timeout=10):
"""Send command to bridge /cli endpoint. Returns parsed JSON response."""
resp = requests.post(
f"{self.bridge_url}/cli",
json={"args": args, "timeout": timeout},
headers={"Connection": "close"},
timeout=timeout + 5
)
resp.raise_for_status()
return resp.json()
def add(self, status, category, detail):
"""Record a test result."""
self.results.append((status, category, detail))
# ── Test methods ──────────────────────────────────────────────
def test_health(self):
"""Test GET /health endpoint"""
cat = "Bridge Health"
try:
resp = requests.get(f"{self.bridge_url}/health", timeout=5)
resp.raise_for_status()
data = resp.json()
missing = EXPECTED_HEALTH_FIELDS - set(data.keys())
if missing:
self.add(self.FAIL, cat, f"missing fields: {', '.join(sorted(missing))}")
return
if data["status"] != "healthy":
self.add(self.FAIL, cat, f"status={data['status']} (expected 'healthy')")
return
extra = set(data.keys()) - EXPECTED_HEALTH_FIELDS - {
"serial_port_source", "advert_log", "echoes_log"
}
detail = f"status=healthy, device={data['device_name']}"
if extra:
self.add(self.WARN, cat, f"{detail} (new fields: {', '.join(sorted(extra))})")
else:
self.add(self.PASS, cat, detail)
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_device_info(self):
"""Test infos and .infos commands"""
for cmd in ["infos", ".infos"]:
cat = f"Device Info ({cmd})"
try:
data = self.run_command([cmd], timeout=5)
if not data.get("success"):
self.add(self.FAIL, cat, f"command failed: {data.get('stderr', '')}")
continue
stdout = data.get("stdout", "").strip()
if not stdout:
self.add(self.FAIL, cat, "empty response")
continue
# Try to parse JSON from output
json_obj = self._extract_json_object(stdout)
if json_obj is None:
self.add(self.FAIL, cat, "no JSON object found in response")
continue
if "name" not in json_obj:
self.add(self.FAIL, cat, f"'name' field missing from JSON (keys: {', '.join(json_obj.keys())})")
else:
self.add(self.PASS, cat, f"JSON valid, name='{json_obj['name']}'")
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_contacts_text(self):
"""Test contacts command (text format)"""
cat = "Contacts (text)"
try:
data = self.run_command(["contacts"])
if not data.get("success"):
self.add(self.FAIL, cat, f"command failed: {data.get('stderr', '')}")
return
stdout = data.get("stdout", "").strip()
if not stdout:
self.add(self.WARN, cat, "empty response (no contacts on device)")
return
# Parse using same logic as cli.py parse_contacts()
type_counts = {"COM": 0, "REP": 0, "ROOM": 0, "SENS": 0}
parsed = 0
unparsed_lines = []
for line in stdout.split('\n'):
line_stripped = line.strip()
if not line_stripped or line_stripped.startswith('---') or \
line.lower().startswith('contact') or line.startswith('INFO:') or \
self._is_prompt_line(line_stripped):
continue
parts = CONTACTS_SPLIT_REGEX.split(line)
if len(parts) >= 2:
contact_type = parts[1].strip()
if contact_type in VALID_CONTACT_TYPES:
type_counts[contact_type] += 1
parsed += 1
continue
unparsed_lines.append(line_stripped[:60])
if parsed == 0:
self.add(self.FAIL, cat, "no contacts parsed - format may have changed")
if unparsed_lines:
self.add(self.FAIL, cat, f"unparsed lines: {unparsed_lines[:3]}")
return
types_str = ", ".join(f"{k}={v}" for k, v in type_counts.items() if v > 0)
detail = f"{parsed} contacts parsed, types: {types_str}"
if unparsed_lines:
self.add(self.WARN, cat, f"{detail} ({len(unparsed_lines)} unparsed lines: {unparsed_lines[:3]})")
else:
self.add(self.PASS, cat, detail)
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_contacts_json(self):
"""Test .contacts command (JSON format)"""
cat = "Contacts (JSON)"
try:
data = self.run_command([".contacts"])
if not data.get("success"):
self.add(self.FAIL, cat, f"command failed: {data.get('stderr', '')}")
return
stdout = data.get("stdout", "").strip()
if not stdout:
self.add(self.WARN, cat, "empty response (no contacts on device)")
return
# Parse JSON using brace-matching (same as cli.py)
json_obj = self._extract_json_object(stdout)
if json_obj is None:
self.add(self.FAIL, cat, "no JSON object found in response")
return
if not isinstance(json_obj, dict):
self.add(self.FAIL, cat, f"expected dict, got {type(json_obj).__name__}")
return
if len(json_obj) == 0:
self.add(self.WARN, cat, "JSON valid but empty (no contacts)")
return
# Check fields in first contact entry
first_key = next(iter(json_obj))
first_contact = json_obj[first_key]
if not isinstance(first_contact, dict):
self.add(self.FAIL, cat, f"contact entry is {type(first_contact).__name__}, expected dict")
return
actual_fields = set(first_contact.keys())
missing = EXPECTED_CONTACT_FIELDS - actual_fields
extra = actual_fields - EXPECTED_CONTACT_FIELDS
detail = f"{len(json_obj)} contacts, all expected fields present"
if missing:
self.add(self.FAIL, cat, f"missing fields: {', '.join(sorted(missing))}")
elif extra:
self.add(self.WARN, cat, f"{len(json_obj)} contacts OK (new fields: {', '.join(sorted(extra))})")
else:
self.add(self.PASS, cat, detail)
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_contact_info(self):
"""Test apply_to t=1 contact_info command"""
cat = "Contact Info (apply_to)"
try:
data = self.run_command(["apply_to", "t=1", "contact_info"])
if not data.get("success"):
self.add(self.FAIL, cat, f"command failed: {data.get('stderr', '')}")
return
stdout = data.get("stdout", "").strip()
if not stdout:
self.add(self.WARN, cat, "empty response (no COM contacts)")
return
# contact_info returns multiple JSON objects (one per contact)
json_count = 0
for line in stdout.split('\n'):
line = line.strip()
if line.startswith('{'):
try:
json.loads(line)
json_count += 1
except json.JSONDecodeError:
pass
if json_count > 0:
self.add(self.PASS, cat, f"{json_count} contact info entries parsed")
else:
# Try brace-matching for multi-line JSON
json_obj = self._extract_json_object(stdout)
if json_obj is not None:
self.add(self.PASS, cat, "contact info JSON parsed (multi-line)")
else:
self.add(self.WARN, cat, "command succeeded but no JSON found in output")
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_channels(self):
"""Test get_channels command"""
cat = "Channels"
try:
data = self.run_command(["get_channels"])
if not data.get("success"):
self.add(self.FAIL, cat, f"command failed: {data.get('stderr', '')}")
return
stdout = data.get("stdout", "").strip()
if not stdout:
self.add(self.FAIL, cat, "empty response (device should have at least Public channel)")
return
channels = []
unparsed = []
for line in stdout.split('\n'):
line = line.strip()
if not line or self._is_prompt_line(line):
continue
match = CHANNEL_REGEX.match(line)
if match:
channels.append({
'index': int(match.group(1)),
'name': match.group(2),
'key': match.group(3)
})
else:
unparsed.append(line[:60])
if not channels:
self.add(self.FAIL, cat, "no channels parsed - format may have changed")
if unparsed:
self.add(self.FAIL, cat, f"unparsed lines: {unparsed[:3]}")
return
names = ", ".join(f"{c['name']}(#{c['index']})" for c in channels)
detail = f"{len(channels)} channels: {names}"
if unparsed:
self.add(self.WARN, cat, f"{detail} ({len(unparsed)} unparsed lines: {unparsed[:3]})")
else:
self.add(self.PASS, cat, detail)
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_recv(self):
"""Test recv command (short timeout)"""
cat = "Recv"
try:
# Use short timeout - we just want to verify the command is accepted
data = self.run_command(["recv"], timeout=5)
if not data.get("success"):
stderr = data.get("stderr", "")
# Timeout is acceptable for recv (no new messages)
if "timeout" in stderr.lower():
self.add(self.PASS, cat, "command accepted (timed out - no new messages)")
else:
self.add(self.FAIL, cat, f"command failed: {stderr}")
return
stdout = data.get("stdout", "").strip()
if stdout:
self.add(self.PASS, cat, f"command accepted ({len(stdout.split(chr(10)))} lines)")
else:
self.add(self.PASS, cat, "command accepted (no new messages)")
except requests.exceptions.Timeout:
# Timeout is acceptable for recv
self.add(self.PASS, cat, "command accepted (HTTP timeout - normal for recv)")
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_settings(self):
"""Test set commands used during bridge initialization"""
settings = [
(["set", "json_log_rx", "on"], "Settings (json_log_rx)"),
(["set", "print_adverts", "on"], "Settings (print_adverts)"),
(["msgs_subscribe"], "Settings (msgs_subscribe)"),
]
for args, cat in settings:
try:
data = self.run_command(args, timeout=5)
if data.get("success"):
self.add(self.PASS, cat, "accepted")
else:
stderr = data.get("stderr", "")
stdout = data.get("stdout", "")
# Some settings return output but bridge marks as timeout
if "timeout" in stderr.lower() and not stdout:
self.add(self.WARN, cat, "possible timeout (no output)")
else:
self.add(self.FAIL, cat, f"failed: {stderr or stdout}")
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_pending_contacts(self):
"""Test GET /pending_contacts bridge endpoint"""
cat = "Pending Contacts"
try:
resp = requests.get(f"{self.bridge_url}/pending_contacts", timeout=10)
resp.raise_for_status()
data = resp.json()
if "success" not in data:
self.add(self.FAIL, cat, "response missing 'success' field")
return
if data.get("success"):
contacts = data.get("contacts", data.get("pending", []))
self.add(self.PASS, cat, f"endpoint OK ({len(contacts)} pending)")
else:
self.add(self.WARN, cat, f"endpoint returned success=false: {data.get('error', '')}")
except Exception as e:
self.add(self.ERROR, cat, str(e))
def test_advert(self):
"""Test advert command (has network side-effect)"""
cat = "Advert"
if not self.full_mode:
self.add(self.SKIP, cat, "skipped (use --full to enable)")
return
try:
data = self.run_command(["advert"], timeout=10)
if data.get("success"):
self.add(self.PASS, cat, "advertisement sent")
else:
self.add(self.FAIL, cat, f"failed: {data.get('stderr', '')}")
except Exception as e:
self.add(self.ERROR, cat, str(e))
# ── Helpers ───────────────────────────────────────────────────
@staticmethod
def _is_prompt_line(line):
"""Check if line is a meshcli prompt or summary (not actual data)."""
# Prompt lines: "DeviceName|* command" or "DeviceName|*"
if '|*' in line:
return True
# Summary lines: "> 310 contacts in device"
if line.startswith('>'):
return True
return False
def _extract_json_object(self, text):
"""Extract first complete JSON object from text using brace-matching."""
depth = 0
start_idx = None
for i, char in enumerate(text):
if char == '{':
if depth == 0:
start_idx = i
depth += 1
elif char == '}':
depth -= 1
if depth == 0 and start_idx is not None:
try:
return json.loads(text[start_idx:i + 1])
except json.JSONDecodeError:
start_idx = None
continue
return None
def _get_meshcli_version(self):
"""Try to get meshcore-cli version from bridge container."""
try:
data = self.run_command(["version"], timeout=5)
if data.get("success") and data.get("stdout"):
return data["stdout"].strip()
except Exception:
pass
return "unknown"
# ── Main runner ───────────────────────────────────────────────
def run_all(self):
"""Run all tests and print report. Returns exit code."""
print()
print("meshcore-cli Compatibility Report")
print("=" * 50)
print(f"Bridge URL: {self.bridge_url}")
print(f"Mode: {'full' if self.full_mode else 'safe (read-only)'}")
print(f"Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}")
print()
# Check bridge is reachable first
try:
requests.get(f"{self.bridge_url}/health", timeout=3)
except Exception as e:
print(f"[ERROR] Cannot reach bridge at {self.bridge_url}: {e}")
print()
print("Make sure meshcore-bridge is running:")
print(" docker compose ps")
print(" docker compose logs meshcore-bridge")
return 1
# Run all tests
tests = [
self.test_health,
self.test_device_info,
self.test_contacts_text,
self.test_contacts_json,
self.test_contact_info,
self.test_channels,
self.test_recv,
self.test_settings,
self.test_pending_contacts,
self.test_advert,
]
for test in tests:
test()
# Print results
for status, category, detail in self.results:
print(f"[{status:5s}] {category} - {detail}")
# Summary
counts = {s: 0 for s in [self.PASS, self.WARN, self.FAIL, self.SKIP, self.ERROR]}
for status, _, _ in self.results:
counts[status] += 1
total_tests = counts[self.PASS] + counts[self.FAIL] + counts[self.ERROR]
print()
print(f"Result: {counts[self.PASS]}/{total_tests} PASS", end="")
if counts[self.WARN]:
print(f", {counts[self.WARN]} WARN", end="")
if counts[self.FAIL]:
print(f", {counts[self.FAIL]} FAIL", end="")
if counts[self.ERROR]:
print(f", {counts[self.ERROR]} ERROR", end="")
if counts[self.SKIP]:
print(f", {counts[self.SKIP]} SKIP", end="")
print()
has_failures = counts[self.FAIL] > 0 or counts[self.ERROR] > 0
if has_failures:
print()
print("COMPATIBILITY ISSUES DETECTED - review FAIL/ERROR results above")
return 1 if has_failures else 0
def main():
bridge_url = os.environ.get("BRIDGE_URL", DEFAULT_BRIDGE_URL)
full_mode = os.environ.get("FULL", "").lower() in ("1", "true", "yes")
# Support --bridge-url and --full from command line too
args = sys.argv[1:]
i = 0
while i < len(args):
if args[i] == "--bridge-url" and i + 1 < len(args):
bridge_url = args[i + 1]
i += 2
elif args[i] == "--full":
full_mode = True
i += 1
elif args[i] in ("-h", "--help"):
print("Usage: check_compat.py [--bridge-url URL] [--full]")
print(f" --bridge-url Bridge URL (default: {DEFAULT_BRIDGE_URL})")
print(f" Or set BRIDGE_URL env var")
print(f" --full Include tests with network side-effects")
print(f" Or set FULL=1 env var")
print()
print("Run from host:")
print(" cat scripts/check_compat.py | docker compose exec -T mc-webui python -")
print(" cat scripts/check_compat.py | docker compose exec -T mc-webui env FULL=1 python -")
sys.exit(0)
else:
i += 1
checker = CompatChecker(bridge_url, full_mode)
sys.exit(checker.run_all())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,21 @@
# mc-webui Container Watchdog
The `watchdog` service is a utility designed to run on the host machine running the Docker containers for the `mc-webui` project. Its primary purpose is to continuously monitor the health of the application's containers, specifically the `mc-webui` container, which handles the physical connection to the LoRa device (like Heltec V3 or V4).
## Key Capabilities
- **Automated Restarts:** If a container becomes `unhealthy`, stops, or reports device connection issues in its logs, the watchdog automatically restarts it to restore service without human intervention.
- **Hardware USB Bus Reset:** If the `mc-webui` container fails to recover after three successive restarts (e.g., due to a hardware freeze on the LoRa device itself), the watchdog will intelligently simulate a physical disconnection and reconnection of the device via a low-level USB bus reset, completely resolving hardware lockups.
## Installation / Update
You can easily install or update the watchdog by running the provided installer script with root privileges:
```bash
cd ~/mc-webui/scripts/watchdog
sudo ./install.sh
```
## Detailed Documentation
For full details on configuration, logs, troubleshooting, and more advanced features, please refer to the main [Container Watchdog Documentation](../../docs/watchdog.md) located in the `docs` folder.

View File

@@ -98,6 +98,7 @@ Environment=CHECK_INTERVAL=30
Environment=LOG_FILE=${LOG_FILE}
Environment=HTTP_PORT=5051
Environment=AUTO_START=true
Environment=USB_DEVICE_PATH=${USB_DEVICE_PATH}
ExecStart=/usr/bin/python3 -u ${SCRIPT_DIR}/watchdog.py
Restart=always
RestartSec=10
@@ -144,6 +145,7 @@ echo "Features:"
echo " - Checks container health every 30 seconds"
echo " - Automatically restarts unhealthy containers"
echo " - Saves diagnostic logs before restart"
echo " - Performs hardware USB bus reset if LoRa device is stuck"
echo ""
echo "Useful commands:"
echo " systemctl status $SERVICE_NAME # Check service status"

View File

@@ -11,6 +11,7 @@ Environment=MCWEBUI_DIR=/home/marek/mc-webui
Environment=CHECK_INTERVAL=30
Environment=LOG_FILE=/var/log/mc-webui-watchdog.log
Environment=HTTP_PORT=5051
Environment=USB_DEVICE_PATH=
ExecStart=/usr/bin/python3 -u /home/marek/mc-webui/scripts/watchdog/watchdog.py
Restart=always
RestartSec=10

View File

@@ -26,6 +26,7 @@ import json
import subprocess
import threading
import time
import fcntl
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
@@ -37,8 +38,8 @@ LOG_FILE = os.environ.get('LOG_FILE', '/var/log/mc-webui-watchdog.log')
HTTP_PORT = int(os.environ.get('HTTP_PORT', '5051'))
AUTO_START = os.environ.get('AUTO_START', 'true').lower() != 'false'
# Containers to monitor
CONTAINERS = ['meshcore-bridge', 'mc-webui']
# Containers to monitor (v2: single container, no meshcore-bridge)
CONTAINERS = ['mc-webui']
# Global state
last_check_time = None
@@ -59,6 +60,218 @@ def log(message: str, level: str = 'INFO'):
print(f"[{timestamp}] [ERROR] Failed to write to log file: {e}")
# USB Device Reset Constant
USBDEVFS_RESET = 21780 # 0x5514
def auto_detect_serial_port() -> str:
"""Detect the serial port (e.g., /dev/ttyACM0) from environment or by-id."""
env_file = os.path.join(MCWEBUI_DIR, '.env')
serial_port = 'auto'
if os.path.exists(env_file):
try:
with open(env_file, 'r') as f:
for line in f:
if line.startswith('MC_SERIAL_PORT='):
serial_port = line.split('=', 1)[1].strip().strip('"\'')
break
except Exception as e:
log(f"Failed to read .env file for serial port: {e}", "WARN")
if serial_port.lower() == 'auto':
by_id_path = Path('/dev/serial/by-id')
if by_id_path.exists():
devices = list(by_id_path.iterdir())
if len(devices) == 1:
serial_port = str(devices[0])
elif len(devices) > 1:
log("Multiple serial devices found, cannot auto-detect single port", "WARN")
return None
else:
log("No serial devices found in /dev/serial/by-id", "WARN")
return None
else:
log("/dev/serial/by-id does not exist", "WARN")
return None
if not serial_port or not os.path.exists(serial_port):
log(f"Serial port {serial_port} not found", "WARN")
return None
try:
real_tty = os.path.realpath(serial_port)
return real_tty
except Exception as e:
log(f"Error resolving serial port {serial_port}: {e}", "ERROR")
return None
def auto_detect_usb_device() -> str:
"""Attempt to auto-detect the physical USB device path (e.g., /dev/bus/usb/001/002) for LoRa."""
real_tty = auto_detect_serial_port()
if not real_tty:
return None
try:
tty_name = os.path.basename(real_tty)
# Find USB bus and dev number via sysfs
sysfs_path = f"/sys/class/tty/{tty_name}/device"
if not os.path.exists(sysfs_path):
log(f"Sysfs path {sysfs_path} not found", "WARN")
return None
usb_dev_dir = os.path.dirname(os.path.realpath(sysfs_path))
busnum_file = os.path.join(usb_dev_dir, "busnum")
devnum_file = os.path.join(usb_dev_dir, "devnum")
if os.path.exists(busnum_file) and os.path.exists(devnum_file):
with open(busnum_file) as f:
busnum = int(f.read().strip())
with open(devnum_file) as f:
devnum = int(f.read().strip())
return f"/dev/bus/usb/{busnum:03d}/{devnum:03d}"
log("Could not find busnum/devnum files in sysfs", "WARN")
return None
except Exception as e:
log(f"Error during USB device auto-detection: {e}", "ERROR")
return None
def is_tcp_connection() -> bool:
"""Check if the application is configured to use a TCP connection instead of a serial port."""
env_file = os.path.join(MCWEBUI_DIR, '.env')
if os.path.exists(env_file):
try:
with open(env_file, 'r') as f:
for line in f:
if line.startswith('MC_TCP_HOST='):
val = line.split('=', 1)[1].strip().strip('"\'')
if val:
return True
except Exception as e:
log(f"Failed to read .env file for TCP host: {e}", "WARN")
return False
def reset_esp32_device():
"""Perform a hardware reset on ESP32/LoRa device using DTR/RTS lines via ioctl."""
serial_port = auto_detect_serial_port()
if not serial_port:
log("Cannot perform ESP32 reset: serial port could not be determined", "WARN")
return False
log(f"Performing ESP32 hard reset via DTR/RTS on {serial_port}", "WARN")
try:
import struct
import termios
fd = os.open(serial_port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
TIOCM_DTR = 0x002
TIOCM_RTS = 0x004
TIOCMBIS = getattr(termios, 'TIOCMBIS', 0x5416)
TIOCMBIC = getattr(termios, 'TIOCMBIC', 0x5417)
# Reset sequence used by esptool
# DTR=False (Clear), RTS=True (Set) -> EN=Low
fcntl.ioctl(fd, TIOCMBIC, struct.pack('i', TIOCM_DTR))
fcntl.ioctl(fd, TIOCMBIS, struct.pack('i', TIOCM_RTS))
time.sleep(0.1)
# DTR=False (Clear), RTS=False (Clear) -> EN=High
fcntl.ioctl(fd, TIOCMBIC, struct.pack('i', TIOCM_DTR | TIOCM_RTS))
time.sleep(0.05)
os.close(fd)
log("ESP32 DTR/RTS reset sent successfully", "INFO")
return True
except Exception as e:
log(f"ESP32 DTR/RTS reset failed: {e}", "ERROR")
return False
def reset_usb_device():
"""Perform a hardware USB bus reset on the LoRa device."""
# First get the sysfs path if possible
real_tty = auto_detect_serial_port()
usb_sysfs_dir = None
usb_device_id = None
if real_tty:
try:
tty_name = os.path.basename(real_tty)
sysfs_path = f"/sys/class/tty/{tty_name}/device"
if os.path.exists(sysfs_path):
usb_intf_dir = os.path.realpath(sysfs_path)
usb_sysfs_dir = os.path.dirname(usb_intf_dir)
usb_device_id = os.path.basename(usb_sysfs_dir)
except Exception as e:
log(f"Error finding sysfs path: {e}", "WARN")
if usb_sysfs_dir:
# 1. Try toggling authorized flag
authorized_path = os.path.join(usb_sysfs_dir, "authorized")
if os.path.exists(authorized_path):
log(f"Toggling USB authorized flag on {usb_sysfs_dir}", "WARN")
try:
with open(authorized_path, 'w') as f:
f.write("0")
time.sleep(2)
with open(authorized_path, 'w') as f:
f.write("1")
log("USB authorized toggle successful", "INFO")
time.sleep(2)
except Exception as e:
log(f"USB authorized toggle failed: {e}", "ERROR")
# 2. Try unbind/bind from usb driver
unbind_path = "/sys/bus/usb/drivers/usb/unbind"
bind_path = "/sys/bus/usb/drivers/usb/bind"
if os.path.exists(unbind_path) and usb_device_id:
log(f"Unbinding USB device {usb_device_id} from usb driver", "WARN")
try:
with open(unbind_path, 'w') as f:
f.write(usb_device_id)
time.sleep(2)
with open(bind_path, 'w') as f:
f.write(usb_device_id)
log("USB unbind/bind successful", "INFO")
time.sleep(2)
except Exception as e:
log(f"USB unbind/bind failed: {e}", "ERROR")
# 3. Fallback to ioctl USBDEVFS_RESET
device_path = os.environ.get('USB_DEVICE_PATH')
if not device_path:
device_path = auto_detect_usb_device()
if not device_path:
log("Cannot perform USB ioctl reset: device path could not be determined", "WARN")
return False
log(f"Performing hardware USB bus reset via ioctl on {device_path}", "WARN")
try:
with open(device_path, 'w') as fd:
fcntl.ioctl(fd, USBDEVFS_RESET, 0)
log("USB ioctl bus reset successful", "INFO")
return True
except Exception as e:
log(f"USB ioctl reset failed: {e}", "ERROR")
return False
def count_recent_restarts(container_name: str, minutes: int = 8) -> int:
"""Count how many times a container was restarted in the last N minutes due to unhealthiness."""
cutoff_time = time.time() - (minutes * 60)
count = 0
for entry in restart_history:
if entry.get('container') == container_name and 'restart_success' in entry:
try:
dt = datetime.fromisoformat(entry['timestamp'])
if dt.timestamp() >= cutoff_time:
count += 1
except ValueError:
pass
return count
def run_docker_command(args: list, timeout: int = 30) -> tuple:
"""Run docker command and return (success, stdout, stderr)."""
try:
@@ -216,8 +429,25 @@ def handle_unhealthy_container(container_name: str, status: dict):
except Exception as e:
log(f"Failed to save diagnostic info: {e}", 'ERROR')
# Restart the container
restart_success = restart_container(container_name)
# v2: mc-webui owns the device connection directly — USB reset if repeated failures
restart_success = False
if container_name == 'mc-webui':
recent_restarts = count_recent_restarts(container_name, minutes=8)
if recent_restarts >= 3 and not is_tcp_connection():
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Attempting hardware USB reset.", "WARN")
# Stop the container first so it releases the serial port
run_compose_command(['stop', container_name])
reset_esp32_device()
if reset_usb_device():
time.sleep(5) # Give OS time to re-enumerate the device
restart_success = start_container(container_name)
else:
if recent_restarts >= 3 and is_tcp_connection():
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. TCP connection used, skipping hardware USB reset.", "WARN")
restart_success = restart_container(container_name)
else:
# Restart the container
restart_success = restart_container(container_name)
# Record in history
restart_history.append({
@@ -233,6 +463,85 @@ def handle_unhealthy_container(container_name: str, status: dict):
restart_history = restart_history[-50:]
def check_device_unresponsive(container_name: str) -> bool:
"""Check if the container logs indicate the USB device is unresponsive."""
success, stdout, stderr = run_compose_command([
'logs', '--since', '1m', container_name
])
if not success:
return False
error_patterns = [
"No response from meshcore node, disconnecting",
"Device connected but self_info is empty",
"Failed to connect after 10 attempts"
]
for pattern in error_patterns:
if pattern in stdout:
return True
return False
def handle_unresponsive_device(container_name: str, status: dict):
"""Handle an unresponsive device - log details, possibly reset USB, and restart container."""
global restart_history
log(f"Container {container_name} device is unresponsive! Status: {status}", 'WARN')
# Capture logs before restart
log(f"Capturing logs from {container_name} before restart...")
logs = get_container_logs(container_name, lines=200)
# Save detailed diagnostic info
diag_file = f"/tmp/mc-webui-watchdog-{container_name}-unresponsive-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
try:
with open(diag_file, 'w') as f:
f.write(f"=== Container Diagnostic Report (Unresponsive Device) ===\n")
f.write(f"Timestamp: {datetime.now().isoformat()}\n")
f.write(f"Container: {container_name}\n")
f.write(f"Status: {json.dumps(status, indent=2)}\n")
f.write(f"\n=== Recent Logs ===\n")
f.write(logs)
log(f"Diagnostic info saved to: {diag_file}")
except Exception as e:
log(f"Failed to save diagnostic info: {e}", 'ERROR')
# v2: mc-webui owns the device connection directly — USB reset if repeated failures
restart_success = False
if container_name == 'mc-webui':
recent_restarts = count_recent_restarts(container_name, minutes=8)
if recent_restarts >= 3 and not is_tcp_connection():
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Attempting hardware USB reset.", "WARN")
# Stop the container first so it releases the serial port
run_compose_command(['stop', container_name])
reset_esp32_device()
if reset_usb_device():
time.sleep(5) # Give OS time to re-enumerate the device
restart_success = start_container(container_name)
else:
if recent_restarts >= 3 and is_tcp_connection():
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. TCP connection used, skipping hardware USB reset.", "WARN")
restart_success = restart_container(container_name)
else:
# Restart the container
restart_success = restart_container(container_name)
# Record in history
restart_history.append({
'timestamp': datetime.now().isoformat(),
'container': container_name,
'reason': 'unresponsive_device',
'status_before': status,
'restart_success': restart_success,
'diagnostic_file': diag_file
})
# Keep only last 50 entries
if len(restart_history) > 50:
restart_history = restart_history[-50:]
def check_containers():
"""Check all monitored containers."""
global last_check_time, last_check_results
@@ -254,6 +563,8 @@ def check_containers():
log(f"Container {container_name} is not running (status: {status['status']}), AUTO_START disabled", 'WARN')
elif status['health'] == 'unhealthy':
handle_unhealthy_container(container_name, status)
elif container_name == 'mc-webui' and check_device_unresponsive(container_name):
handle_unresponsive_device(container_name, status)
last_check_results = results
return results

530
tests/test_database.py Normal file
View File

@@ -0,0 +1,530 @@
"""
Integration tests for mc-webui v2 Database class.
Run: python -m pytest tests/test_database.py -v
"""
import tempfile
import time
from pathlib import Path
import pytest
from app.database import Database
@pytest.fixture
def db():
"""Create a temporary database for each test."""
with tempfile.TemporaryDirectory() as tmp:
yield Database(Path(tmp) / 'test.db')
# ================================================================
# Schema & Initialization
# ================================================================
class TestInitialization:
def test_creates_database_file(self, db):
assert db.db_path.exists()
def test_all_tables_exist(self, db):
stats = db.get_stats()
expected_tables = [
'device', 'contacts', 'channels', 'channel_messages',
'direct_messages', 'acks', 'echoes', 'paths',
'advertisements', 'read_status'
]
for table in expected_tables:
assert table in stats, f"Missing table: {table}"
assert stats[table] == 0
def test_wal_mode_enabled(self, db):
import sqlite3
conn = sqlite3.connect(str(db.db_path))
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
conn.close()
assert mode == 'wal'
def test_db_size_in_stats(self, db):
stats = db.get_stats()
assert stats['db_size_bytes'] > 0
# ================================================================
# Device
# ================================================================
class TestDevice:
def test_set_and_get_device_info(self, db):
db.set_device_info(public_key='abc123', name='TestDevice')
info = db.get_device_info()
assert info is not None
assert info['public_key'] == 'abc123'
assert info['name'] == 'TestDevice'
def test_update_device_info(self, db):
db.set_device_info(public_key='key1', name='Name1')
db.set_device_info(public_key='key2', name='Name2')
info = db.get_device_info()
assert info['public_key'] == 'key2'
assert info['name'] == 'Name2'
def test_get_device_info_empty(self, db):
assert db.get_device_info() is None
# ================================================================
# Contacts
# ================================================================
class TestContacts:
def test_upsert_and_get(self, db):
db.upsert_contact('AABB', name='Alice')
contacts = db.get_contacts()
assert len(contacts) == 1
assert contacts[0]['public_key'] == 'aabb' # lowercased
assert contacts[0]['name'] == 'Alice'
def test_upsert_updates_existing(self, db):
db.upsert_contact('AABB', name='Alice')
db.upsert_contact('AABB', name='Alice Updated', source='device')
contacts = db.get_contacts()
assert len(contacts) == 1
assert contacts[0]['name'] == 'Alice Updated'
def test_upsert_preserves_name_on_empty(self, db):
db.upsert_contact('AABB', name='Alice')
db.upsert_contact('AABB', name='') # empty name should not overwrite
contact = db.get_contact('AABB')
assert contact['name'] == 'Alice'
def test_get_contact_by_key(self, db):
db.upsert_contact('AABB', name='Alice')
contact = db.get_contact('aabb')
assert contact is not None
assert contact['name'] == 'Alice'
def test_get_contact_not_found(self, db):
assert db.get_contact('nonexistent') is None
def test_delete_contact(self, db):
db.upsert_contact('AABB', name='Alice')
assert db.delete_contact('AABB') is True
assert db.get_contact('AABB') is None
def test_delete_nonexistent(self, db):
assert db.delete_contact('nonexistent') is False
def test_protect_contact(self, db):
db.upsert_contact('AABB', name='Alice')
db.set_contact_protected('AABB', True)
contact = db.get_contact('AABB')
assert contact['is_protected'] == 1
def test_protected_not_overwritten(self, db):
db.upsert_contact('AABB', name='Alice')
db.set_contact_protected('AABB', True)
db.upsert_contact('AABB', name='Alice', is_protected=0)
contact = db.get_contact('AABB')
assert contact['is_protected'] == 1 # stays protected
def test_contact_with_gps(self, db):
db.upsert_contact('CC', name='Bob', adv_lat=52.23, adv_lon=21.01)
contact = db.get_contact('CC')
assert abs(contact['adv_lat'] - 52.23) < 0.001
assert abs(contact['adv_lon'] - 21.01) < 0.001
def test_get_protected_keys(self, db):
db.upsert_contact('AA', name='Alice')
db.upsert_contact('BB', name='Bob')
db.set_contact_protected('AA', True)
keys = db.get_protected_keys()
assert 'aa' in keys
assert 'bb' not in keys
# ================================================================
# App Settings
# ================================================================
class TestAppSettings:
def test_set_and_get_setting(self, db):
db.set_setting('test_key', 'test_value')
assert db.get_setting('test_key') == 'test_value'
def test_get_nonexistent_setting(self, db):
assert db.get_setting('nonexistent') is None
def test_set_and_get_json(self, db):
db.set_setting_json('cleanup', {'enabled': True, 'days': 7})
result = db.get_setting_json('cleanup')
assert result == {'enabled': True, 'days': 7}
def test_get_json_default(self, db):
result = db.get_setting_json('missing', {'default': True})
assert result == {'default': True}
def test_setting_upsert(self, db):
db.set_setting_json('key', 'old')
db.set_setting_json('key', 'new')
assert db.get_setting_json('key') == 'new'
# ================================================================
# Channels
# ================================================================
class TestChannels:
def test_upsert_and_list(self, db):
db.upsert_channel(0, 'Public')
db.upsert_channel(1, 'Private', secret='abc123')
channels = db.get_channels()
assert len(channels) == 2
assert channels[0]['idx'] == 0
assert channels[1]['name'] == 'Private'
def test_delete_channel(self, db):
db.upsert_channel(0, 'Public')
assert db.delete_channel(0) is True
assert len(db.get_channels()) == 0
# ================================================================
# Channel Messages
# ================================================================
class TestChannelMessages:
def test_insert_and_get(self, db):
ts = int(time.time())
msg_id = db.insert_channel_message(
channel_idx=0, sender='Alice', content='Hello!',
timestamp=ts, snr=-5.5, path_len=2
)
assert msg_id > 0
messages = db.get_channel_messages(0)
assert len(messages) == 1
assert messages[0]['sender'] == 'Alice'
assert messages[0]['content'] == 'Hello!'
assert messages[0]['snr'] == -5.5
def test_limit_and_offset(self, db):
ts = int(time.time())
for i in range(10):
db.insert_channel_message(0, f'User{i}', f'Msg {i}', ts + i)
messages = db.get_channel_messages(0, limit=3)
assert len(messages) == 3
# Should be the last 3 messages
assert messages[0]['content'] == 'Msg 7'
assert messages[2]['content'] == 'Msg 9'
def test_filter_by_channel(self, db):
ts = int(time.time())
db.insert_channel_message(0, 'A', 'Chan 0 msg', ts)
db.insert_channel_message(1, 'B', 'Chan 1 msg', ts + 1)
ch0 = db.get_channel_messages(0)
ch1 = db.get_channel_messages(1)
assert len(ch0) == 1
assert len(ch1) == 1
assert ch0[0]['content'] == 'Chan 0 msg'
def test_delete_channel_messages(self, db):
ts = int(time.time())
db.insert_channel_message(0, 'A', 'Keep', ts)
db.insert_channel_message(1, 'B', 'Delete', ts)
deleted = db.delete_channel_messages(1)
assert deleted == 1
assert len(db.get_channel_messages(0)) == 1
assert len(db.get_channel_messages(1)) == 0
def test_own_message(self, db):
ts = int(time.time())
db.insert_channel_message(0, 'Me', 'My msg', ts, is_own=True)
messages = db.get_channel_messages(0)
assert messages[0]['is_own'] == 1
# ================================================================
# Direct Messages
# ================================================================
class TestDirectMessages:
def test_insert_and_get(self, db):
db.upsert_contact('aabb', name='Alice')
ts = int(time.time())
dm_id = db.insert_direct_message('aabb', 'in', 'Hello', ts)
assert dm_id > 0
messages = db.get_dm_messages('aabb')
assert len(messages) == 1
assert messages[0]['direction'] == 'in'
assert messages[0]['content'] == 'Hello'
def test_conversations_list(self, db):
db.upsert_contact('aa', name='Alice')
db.upsert_contact('bb', name='Bob')
ts = int(time.time())
db.insert_direct_message('aa', 'in', 'Hi from Alice', ts)
db.insert_direct_message('bb', 'out', 'Hi to Bob', ts + 1)
convos = db.get_dm_conversations()
assert len(convos) == 2
# Most recent first
assert convos[0]['display_name'] == 'Bob'
assert convos[1]['display_name'] == 'Alice'
def test_dm_with_ack(self, db):
db.upsert_contact('aa', name='Alice')
ts = int(time.time())
dm_id = db.insert_direct_message('aa', 'out', 'Test', ts, expected_ack='ACK123')
db.insert_ack('ACK123', snr=-3.0, dm_id=dm_id)
ack = db.get_ack_for_dm('ACK123')
assert ack is not None
assert ack['snr'] == -3.0
def test_dm_with_pkt_payload(self, db):
db.upsert_contact('cc', name='Charlie')
ts = int(time.time())
dm_id = db.insert_direct_message(
'cc', 'in', 'Hello', ts, pkt_payload='deadbeef01020304'
)
messages = db.get_dm_messages('cc')
assert len(messages) == 1
assert messages[0]['pkt_payload'] == 'deadbeef01020304'
# ================================================================
# Echoes
# ================================================================
class TestEchoes:
def test_insert_and_get(self, db):
ts = int(time.time())
cm_id = db.insert_channel_message(0, 'Me', 'Test', ts, pkt_payload='PKT1')
db.insert_echo('PKT1', path='Me>Node1>Node2', snr=-4.0, cm_id=cm_id)
echoes = db.get_echoes_for_message('PKT1')
assert len(echoes) == 1
assert echoes[0]['path'] == 'Me>Node1>Node2'
# ================================================================
# Full-Text Search (FTS5)
# ================================================================
class TestFTS:
def test_search_channel_messages(self, db):
ts = int(time.time())
db.insert_channel_message(0, 'Alice', 'MeshCore is awesome', ts)
db.insert_channel_message(0, 'Bob', 'Hello world', ts + 1)
results = db.search_messages('awesome')
assert len(results) == 1
assert results[0]['content'] == 'MeshCore is awesome'
def test_search_direct_messages(self, db):
db.upsert_contact('aa', name='Alice')
ts = int(time.time())
db.insert_direct_message('aa', 'in', 'Secret mesh network', ts)
results = db.search_messages('mesh network')
assert len(results) == 1
assert results[0]['msg_source'] == 'dm'
def test_search_combined(self, db):
db.upsert_contact('aa', name='Alice')
ts = int(time.time())
db.insert_channel_message(0, 'Bob', 'Testing mesh', ts)
db.insert_direct_message('aa', 'in', 'Testing mesh too', ts + 1)
results = db.search_messages('testing mesh')
assert len(results) == 2
def test_search_no_results(self, db):
results = db.search_messages('nonexistent')
assert len(results) == 0
# ================================================================
# Read Status
# ================================================================
class TestReadStatus:
def test_mark_and_get(self, db):
db.mark_read('chan_0', 1000)
status = db.get_read_status()
assert 'chan_0' in status
assert status['chan_0']['last_seen_ts'] == 1000
def test_mark_keeps_max_timestamp(self, db):
db.mark_read('chan_0', 2000)
db.mark_read('chan_0', 1000) # older — should not downgrade
status = db.get_read_status()
assert status['chan_0']['last_seen_ts'] == 2000
def test_mute_channel(self, db):
db.set_channel_muted(0, True)
status = db.get_read_status()
assert status['chan_0']['is_muted'] == 1
db.set_channel_muted(0, False)
status = db.get_read_status()
assert status['chan_0']['is_muted'] == 0
# ================================================================
# Backup
# ================================================================
class TestBackup:
def test_create_backup(self, db):
db.insert_channel_message(0, 'Test', 'Backup test', int(time.time()))
backup_dir = db.db_path.parent / 'backups'
backup_path = db.create_backup(backup_dir)
assert backup_path.exists()
assert backup_path.stat().st_size > 0
def test_list_backups(self, db):
backup_dir = db.db_path.parent / 'backups'
db.create_backup(backup_dir)
backups = db.list_backups(backup_dir)
assert len(backups) == 1
assert backups[0]['filename'].endswith('.db')
def test_list_backups_empty_dir(self, db):
with tempfile.TemporaryDirectory() as tmp:
backups = db.list_backups(Path(tmp))
assert len(backups) == 0
# ================================================================
# Maintenance
# ================================================================
class TestMaintenance:
def test_cleanup_old_messages(self, db):
now = int(time.time())
old = now - 86400 * 10 # 10 days ago
db.insert_channel_message(0, 'Old', 'Old msg', old)
db.insert_channel_message(0, 'New', 'New msg', now)
deleted = db.cleanup_old_messages(days=5)
assert deleted == 1
remaining = db.get_channel_messages(0)
assert len(remaining) == 1
assert remaining[0]['content'] == 'New msg'
def test_stats(self, db):
db.upsert_contact('aa', name='Alice')
db.insert_channel_message(0, 'A', 'Test', int(time.time()))
stats = db.get_stats()
assert stats['contacts'] == 1
assert stats['channel_messages'] == 1
# ================================================================
# Advertisements
# ================================================================
class TestAdvertisements:
def test_insert(self, db):
db.insert_advertisement(
'AABB', 'Alice', type=1, lat=52.23, lon=21.01,
timestamp=int(time.time()), snr=-3.0
)
stats = db.get_stats()
assert stats['advertisements'] == 1
# ================================================================
# Paths
# ================================================================
class TestPaths:
def test_insert(self, db):
db.insert_path('aa', pkt_payload='PKT', path='A>B>C', snr=-5.0, path_len=3)
stats = db.get_stats()
assert stats['paths'] == 1
# ================================================================
# v1 Migration
# ================================================================
class TestV1Migration:
def _write_msgs(self, path, lines):
"""Write JSONL lines to a .msgs file."""
import json
with open(path, 'w') as f:
for line in lines:
f.write(json.dumps(line) + '\n')
def test_migrate_channel_messages(self, db):
import tempfile, json
from app.migrate_v1 import migrate_v1_data, should_migrate
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp)
self._write_msgs(data_dir / 'TestDevice.msgs', [
{'type': 'CHAN', 'channel_idx': 0, 'text': 'Alice: Hello world', 'timestamp': 1000, 'SNR': -5.0, 'path_len': 2},
{'type': 'SENT_CHAN', 'channel_idx': 0, 'text': 'My message', 'timestamp': 1001, 'sender': 'TestDevice'},
{'type': 'CHAN', 'channel_idx': 1, 'text': 'Bob: On channel 1', 'timestamp': 1002},
])
assert should_migrate(db, data_dir, 'TestDevice')
result = migrate_v1_data(db, data_dir, 'TestDevice')
assert result['status'] == 'completed'
assert result['channel_messages'] == 3
msgs = db.get_channel_messages()
assert len(msgs) == 3
assert msgs[0]['sender'] == 'Alice'
assert msgs[0]['content'] == 'Hello world'
assert msgs[1]['sender'] == 'TestDevice'
assert msgs[1]['content'] == 'My message'
assert msgs[1]['is_own'] == 1
assert msgs[2]['sender'] == 'Bob'
assert msgs[2]['channel_idx'] == 1
def test_migrate_dm_messages(self, db):
import tempfile, json
from app.migrate_v1 import migrate_v1_data
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp)
self._write_msgs(data_dir / 'TestDevice.msgs', [
{'type': 'PRIV', 'text': 'Hello from Alice', 'timestamp': 2000, 'pubkey_prefix': 'aabb', 'name': 'Alice'},
{'type': 'SENT_MSG', 'text': 'Reply to Alice', 'timestamp': 2001, 'recipient': 'Alice', 'txt_type': 0},
{'type': 'SENT_MSG', 'text': 'Channel sent', 'timestamp': 2002, 'txt_type': 1}, # should be skipped
])
result = migrate_v1_data(db, data_dir, 'TestDevice')
assert result['status'] == 'completed'
assert result['direct_messages'] == 2
assert result['skipped'] == 1
def test_should_migrate_false_when_db_has_data(self, db):
import tempfile
from app.migrate_v1 import should_migrate
with tempfile.TemporaryDirectory() as tmp:
data_dir = Path(tmp)
self._write_msgs(data_dir / 'Dev.msgs', [
{'type': 'CHAN', 'text': 'Test: msg', 'timestamp': 1000},
])
# Add a message to DB first
db.insert_channel_message(0, 'X', 'Existing', int(time.time()))
assert not should_migrate(db, data_dir, 'Dev')
def test_should_migrate_false_when_no_msgs_file(self, db):
import tempfile
from app.migrate_v1 import should_migrate
with tempfile.TemporaryDirectory() as tmp:
assert not should_migrate(db, Path(tmp), 'NoDevice')