mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Merge pull request #125 from ipnet-mesh/feat/auto-update-lists
Add configurable auto-refresh for list pages
This commit is contained in:
@@ -216,6 +216,11 @@ WEB_PORT=8080
|
||||
# Supported: en (see src/meshcore_hub/web/static/locales/ for available translations)
|
||||
# WEB_LOCALE=en
|
||||
|
||||
# Auto-refresh interval in seconds for list pages (nodes, advertisements, messages)
|
||||
# Set to 0 to disable auto-refresh
|
||||
# Default: 30
|
||||
# WEB_AUTO_REFRESH_SECONDS=30
|
||||
|
||||
# Enable admin interface at /a/ (requires auth proxy in front)
|
||||
# Default: false
|
||||
# WEB_ADMIN_ENABLED=false
|
||||
|
||||
@@ -606,6 +606,7 @@ Key variables:
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
|
||||
- `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage.
|
||||
- `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable)
|
||||
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
|
||||
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
@@ -348,6 +348,7 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `API_KEY` | *(none)* | API key for web dashboard queries (optional) |
|
||||
| `WEB_THEME` | `dark` | Default theme (`dark` or `light`). Users can override via theme toggle in navbar. |
|
||||
| `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) |
|
||||
| `WEB_AUTO_REFRESH_SECONDS` | `30` | Auto-refresh interval in seconds for list pages (0 to disable) |
|
||||
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
|
||||
| `TZ` | `UTC` | Timezone for displaying dates/times (e.g., `America/New_York`, `Europe/London`) |
|
||||
| `NETWORK_DOMAIN` | *(none)* | Network domain name (optional) |
|
||||
|
||||
@@ -268,6 +268,13 @@ class WebSettings(CommonSettings):
|
||||
description="Locale/language for the web dashboard (e.g. 'en')",
|
||||
)
|
||||
|
||||
# Auto-refresh interval for list pages
|
||||
web_auto_refresh_seconds: int = Field(
|
||||
default=30,
|
||||
description="Auto-refresh interval in seconds for list pages (0 to disable)",
|
||||
ge=0,
|
||||
)
|
||||
|
||||
# Admin interface (disabled by default for security)
|
||||
web_admin_enabled: bool = Field(
|
||||
default=False,
|
||||
|
||||
@@ -117,6 +117,7 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
|
||||
"is_authenticated": bool(request.headers.get("X-Forwarded-User")),
|
||||
"default_theme": app.state.web_theme,
|
||||
"locale": app.state.web_locale,
|
||||
"auto_refresh_seconds": app.state.auto_refresh_seconds,
|
||||
}
|
||||
|
||||
return json.dumps(config)
|
||||
@@ -184,6 +185,9 @@ def create_app(
|
||||
app.state.web_locale = settings.web_locale or "en"
|
||||
load_locale(app.state.web_locale)
|
||||
|
||||
# Auto-refresh interval
|
||||
app.state.auto_refresh_seconds = settings.web_auto_refresh_seconds
|
||||
|
||||
# Store configuration in app state (use args if provided, else settings)
|
||||
app.state.web_theme = (
|
||||
settings.web_theme if settings.web_theme in ("dark", "light") else "dark"
|
||||
|
||||
87
src/meshcore_hub/web/static/js/spa/auto-refresh.js
Normal file
87
src/meshcore_hub/web/static/js/spa/auto-refresh.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Auto-refresh utility for list pages.
|
||||
*
|
||||
* Reads `auto_refresh_seconds` from the app config. When the interval is > 0
|
||||
* it sets up a periodic timer that calls the provided `fetchAndRender` callback
|
||||
* and renders a pause/play toggle button into the given container element.
|
||||
*/
|
||||
|
||||
import { html, litRender, getConfig, t } from './components.js';
|
||||
|
||||
/**
|
||||
* Create an auto-refresh controller.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Function} options.fetchAndRender - Async function that fetches data and re-renders the page.
|
||||
* @param {HTMLElement} options.toggleContainer - Element to render the pause/play toggle into.
|
||||
* @returns {{ cleanup: Function }} cleanup function to stop the timer.
|
||||
*/
|
||||
export function createAutoRefresh({ fetchAndRender, toggleContainer }) {
|
||||
const config = getConfig();
|
||||
const intervalSeconds = config.auto_refresh_seconds || 0;
|
||||
|
||||
if (!intervalSeconds || !toggleContainer) {
|
||||
return { cleanup() {} };
|
||||
}
|
||||
|
||||
let paused = false;
|
||||
let isPending = false;
|
||||
let timerId = null;
|
||||
|
||||
function renderToggle() {
|
||||
const pauseIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z"/></svg>`;
|
||||
const playIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z"/></svg>`;
|
||||
|
||||
const tooltip = paused ? t('auto_refresh.resume') : t('auto_refresh.pause');
|
||||
const icon = paused ? playIcon : pauseIcon;
|
||||
|
||||
litRender(html`
|
||||
<button class="btn btn-ghost btn-xs gap-1 opacity-60 hover:opacity-100"
|
||||
title=${tooltip}
|
||||
@click=${onToggle}>
|
||||
${icon}
|
||||
<span class="text-xs">${intervalSeconds}s</span>
|
||||
</button>
|
||||
`, toggleContainer);
|
||||
}
|
||||
|
||||
function onToggle() {
|
||||
paused = !paused;
|
||||
if (paused) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
} else {
|
||||
startTimer();
|
||||
}
|
||||
renderToggle();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (isPending || paused) return;
|
||||
isPending = true;
|
||||
try {
|
||||
await fetchAndRender();
|
||||
} catch (_e) {
|
||||
// Errors are handled inside fetchAndRender; don't stop the timer.
|
||||
} finally {
|
||||
isPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
timerId = setInterval(tick, intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
// Initial render and start
|
||||
renderToggle();
|
||||
startTimer();
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
truncateKey, errorAlert,
|
||||
pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
@@ -27,6 +28,7 @@ export async function render(container, params, router) {
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('entities.advertisements')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="auto-refresh-toggle"></span>
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
@@ -37,6 +39,7 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/advertisements', { limit, offset, search, public_key, member_id }),
|
||||
@@ -214,4 +217,14 @@ ${paginationBlock}`, { total });
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
pagination, timezoneIndicator,
|
||||
createFilterHandler, autoSubmit, submitOnEnter
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
@@ -24,6 +25,7 @@ export async function render(container, params, router) {
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('entities.messages')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="auto-refresh-toggle"></span>
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
@@ -34,6 +36,7 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const data = await apiGet('/api/v1/messages', { limit, offset, message_type });
|
||||
const messages = data.items || [];
|
||||
@@ -186,4 +189,14 @@ ${paginationBlock}`, { total });
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
pagination, timezoneIndicator,
|
||||
createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
|
||||
export async function render(container, params, router) {
|
||||
const query = params.query || {};
|
||||
@@ -28,6 +29,7 @@ export async function render(container, params, router) {
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('entities.nodes')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="auto-refresh-toggle"></span>
|
||||
${tzBadge}
|
||||
${total !== null ? html`<span class="badge badge-lg">${t('common.total', { count: total })}</span>` : nothing}
|
||||
</div>
|
||||
@@ -38,6 +40,7 @@ ${content}`, container);
|
||||
// Render page header immediately (old content stays visible until data loads)
|
||||
renderPage(nothing);
|
||||
|
||||
async function fetchAndRenderData() {
|
||||
try {
|
||||
const requests = [
|
||||
apiGet('/api/v1/nodes', { limit, offset, search, adv_type, member_id }),
|
||||
@@ -193,4 +196,14 @@ ${paginationBlock}`, { total });
|
||||
} catch (e) {
|
||||
renderPage(errorAlert(e.message));
|
||||
}
|
||||
}
|
||||
|
||||
await fetchAndRenderData();
|
||||
|
||||
const toggleEl = container.querySelector('#auto-refresh-toggle');
|
||||
const { cleanup } = createAutoRefresh({
|
||||
fetchAndRender: fetchAndRenderData,
|
||||
toggleContainer: toggleEl,
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
"youtube": "YouTube",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"pause": "Pause auto-refresh",
|
||||
"resume": "Resume auto-refresh"
|
||||
},
|
||||
"time": {
|
||||
"days_ago": "{{count}}d ago",
|
||||
"hours_ago": "{{count}}h ago",
|
||||
|
||||
@@ -191,7 +191,16 @@ Platform and external link labels:
|
||||
| `youtube` | YouTube | YouTube link label (preserve capitalization) |
|
||||
| `profile` | Profile | Radio profile label |
|
||||
|
||||
### 4. `time`
|
||||
### 4. `auto_refresh`
|
||||
|
||||
Auto-refresh controls for list pages (nodes, advertisements, messages):
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `pause` | Pause auto-refresh | Tooltip on pause button when auto-refresh is active |
|
||||
| `resume` | Resume auto-refresh | Tooltip on play button when auto-refresh is paused |
|
||||
|
||||
### 5. `time`
|
||||
|
||||
Time-related labels and formats:
|
||||
|
||||
@@ -206,7 +215,7 @@ Time-related labels and formats:
|
||||
| `over_time_last_7_days` | Over time (last 7 days) | Over time last 7 days |
|
||||
| `activity_per_day_last_7_days` | Activity per day (last 7 days) | Activity chart label |
|
||||
|
||||
### 5. `node_types`
|
||||
### 6. `node_types`
|
||||
|
||||
Mesh network node type labels:
|
||||
|
||||
@@ -217,7 +226,7 @@ Mesh network node type labels:
|
||||
| `room` | Room | Room/group node type |
|
||||
| `unknown` | Unknown | Unknown node type fallback |
|
||||
|
||||
### 6. `home`
|
||||
### 7. `home`
|
||||
|
||||
Homepage-specific content:
|
||||
|
||||
@@ -238,7 +247,7 @@ Homepage-specific content:
|
||||
|
||||
**Note:** MeshCore tagline "Connecting people and things, without using the internet" is hardcoded in English and should not be translated (trademark).
|
||||
|
||||
### 7. `dashboard`
|
||||
### 8. `dashboard`
|
||||
|
||||
Dashboard page content:
|
||||
|
||||
@@ -248,7 +257,7 @@ Dashboard page content:
|
||||
| `recent_channel_messages` | Recent Channel Messages | Recent messages card title |
|
||||
| `channel` | Channel {{number}} | Channel label with number |
|
||||
|
||||
### 8. `nodes`
|
||||
### 9. `nodes`
|
||||
|
||||
Node-specific labels:
|
||||
|
||||
@@ -256,11 +265,11 @@ Node-specific labels:
|
||||
|-----|---------|---------|
|
||||
| `scan_to_add` | Scan to add as contact | QR code instruction |
|
||||
|
||||
### 9. `advertisements`
|
||||
### 10. `advertisements`
|
||||
|
||||
Currently empty - advertisements page uses common patterns.
|
||||
|
||||
### 10. `messages`
|
||||
### 11. `messages`
|
||||
|
||||
Message type labels:
|
||||
|
||||
@@ -271,7 +280,7 @@ Message type labels:
|
||||
| `type_contact` | Contact | Contact message type |
|
||||
| `type_public` | Public | Public message type |
|
||||
|
||||
### 11. `map`
|
||||
### 12. `map`
|
||||
|
||||
Map page content:
|
||||
|
||||
@@ -289,7 +298,7 @@ Map page content:
|
||||
| `role` | Role: | Member role label |
|
||||
| `select_destination_node` | -- Select destination node -- | Dropdown placeholder |
|
||||
|
||||
### 12. `members`
|
||||
### 13. `members`
|
||||
|
||||
Members page content:
|
||||
|
||||
@@ -300,7 +309,7 @@ Members page content:
|
||||
| `members_file_description` | Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure: | File creation instructions |
|
||||
| `members_import_instructions` | Run <code>meshcore-hub collector seed</code> to import members.<br/>To associate nodes with members, add a <code>member_id</code> tag to nodes in <code>node_tags.yaml</code>. | Import instructions (HTML allowed) |
|
||||
|
||||
### 13. `not_found`
|
||||
### 14. `not_found`
|
||||
|
||||
404 page content:
|
||||
|
||||
@@ -308,7 +317,7 @@ Members page content:
|
||||
|-----|---------|---------|
|
||||
| `description` | The page you're looking for doesn't exist or has been moved. | 404 description |
|
||||
|
||||
### 14. `custom_page`
|
||||
### 15. `custom_page`
|
||||
|
||||
Custom markdown page errors:
|
||||
|
||||
@@ -316,7 +325,7 @@ Custom markdown page errors:
|
||||
|-----|---------|---------|
|
||||
| `failed_to_load` | Failed to load page | Page load error |
|
||||
|
||||
### 15. `admin`
|
||||
### 16. `admin`
|
||||
|
||||
Admin panel content:
|
||||
|
||||
@@ -331,7 +340,7 @@ Admin panel content:
|
||||
| `members_description` | Manage network members and operators. | Members card description |
|
||||
| `tags_description` | Manage custom tags and metadata for network nodes. | Tags card description |
|
||||
|
||||
### 16. `admin_members`
|
||||
### 17. `admin_members`
|
||||
|
||||
Admin members page:
|
||||
|
||||
@@ -344,7 +353,7 @@ Admin members page:
|
||||
|
||||
**Note:** Confirmation and success messages use `common.*` patterns.
|
||||
|
||||
### 17. `admin_node_tags`
|
||||
### 18. `admin_node_tags`
|
||||
|
||||
Admin node tags page:
|
||||
|
||||
@@ -368,7 +377,7 @@ Admin node tags page:
|
||||
|
||||
**Note:** Titles, confirmations, and success messages use `common.*` patterns.
|
||||
|
||||
### 18. `footer`
|
||||
### 19. `footer`
|
||||
|
||||
Footer content:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user