mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
feat: replace MeshCore card with Members widget and add footer branding
This commit is contained in:
@@ -244,7 +244,6 @@ Homepage-specific content:
|
||||
| `all_discovered_nodes` | All discovered nodes | Stat description |
|
||||
| `network_info` | Network Info | Network info card title |
|
||||
| `network_activity` | Network Activity | Activity chart title |
|
||||
| `meshcore_attribution` | Our local off-grid mesh network is made possible by | Attribution text before MeshCore logo |
|
||||
| `frequency` | Frequency | Radio frequency label |
|
||||
| `bandwidth` | Bandwidth | Radio bandwidth label |
|
||||
| `spreading_factor` | Spreading Factor | LoRa spreading factor label |
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
# Plan: Members Widget on Homepage + MeshCore Logo in Footer
|
||||
|
||||
**Date**: 2026-05-06
|
||||
**Status**: Draft
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Two changes to the homepage and footer:
|
||||
|
||||
1. **Members Widget** — Replace the MeshCore logo/links card on the homepage with a new panel containing two stat tiles showing **operator count** and **member count**, derived from role data in the `UserProfile` table.
|
||||
|
||||
2. **MeshCore Logo in Footer** — Move the existing MeshCore logo and project links from the homepage card into the footer bar. The footer becomes a two-column layout: MeshCore branding left-aligned, existing IPNet/MeshCore Hub info right-aligned. On mobile, MeshCore content stacks above the Hub info.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Homepage — MeshCore Attribution Card
|
||||
|
||||
At `home.js:222–241`, a full-width card in the second row of the grid shows:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ │
|
||||
│ "Off-Grid, Open-Source │
|
||||
│ Encrypted Messaging" │
|
||||
│ │
|
||||
│ [MeshCore Logo] │
|
||||
│ │
|
||||
│ [Website btn] [GitHub btn] │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
This card occupies one slot in the `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` bottom row alongside the Network Info card and Activity Chart card.
|
||||
|
||||
### Homepage — Stats Panel
|
||||
|
||||
At `home.js:126–151`, the right column of the hero area shows three stat cards: Total Nodes, Advertisements (7d), Messages (7d). There are **no member/operator counts**.
|
||||
|
||||
### Dashboard Stats API
|
||||
|
||||
`GET /api/v1/dashboard/stats` (`dashboard.py:24–180`) returns `DashboardStats` (`messages.py:244–269`) with node, message, and advertisement counts. **No member/profile counts** are included.
|
||||
|
||||
### Footer
|
||||
|
||||
`spa.html:112–140` — A `footer-center` footer showing:
|
||||
- Network name + city/country
|
||||
- Contact links (email, discord, github, youtube)
|
||||
- "Powered by MeshCore Hub {version}"
|
||||
|
||||
All content is center-aligned. No MeshCore branding present.
|
||||
|
||||
### UserProfile Model
|
||||
|
||||
`user_profile.py:14–74` — `roles` field is a `Text` column containing comma-separated role strings (e.g. `"operator,member"`). The `role_list` property parses these into a `list[str]`.
|
||||
|
||||
### Members Page — Role Grouping Logic
|
||||
|
||||
`members.js:69–92` — Fetches `/api/v1/user/profiles?limit=500`, then groups:
|
||||
- **Operators**: profiles where `roles` includes the operator role name
|
||||
- **Members**: profiles where `roles` includes the member role name AND excludes operators
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/home.js` | Homepage layout; MeshCore card at lines 222–241; stats panel at lines 126–151 |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/members.js` | Members page; role grouping logic at lines 69–92 |
|
||||
| `src/meshcore_hub/web/templates/spa.html` | Footer at lines 112–140 |
|
||||
| `src/meshcore_hub/web/static/css/app.css` | Color palette, panel glow, footer styles |
|
||||
| `src/meshcore_hub/web/static/js/spa/components.js` | `renderStatCard()`, `pageColors`, `t()` |
|
||||
| `src/meshcore_hub/web/static/js/spa/icons.js` | SVG icon functions |
|
||||
| `src/meshcore_hub/api/routes/dashboard.py` | Dashboard stats endpoint at line 24 |
|
||||
| `src/meshcore_hub/common/schemas/messages.py` | `DashboardStats` schema at line 244 |
|
||||
| `src/meshcore_hub/common/models/user_profile.py` | `UserProfile` model with `roles` field |
|
||||
| `src/meshcore_hub/web/static/locales/en.json` | i18n strings |
|
||||
|
||||
---
|
||||
|
||||
## Target State
|
||||
|
||||
### 1. Homepage — Replace MeshCore Card with Members Widget
|
||||
|
||||
The MeshCore attribution card (currently `home.js:222–241`) is replaced with a **Members panel** containing two stat tiles:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 👥 Members │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ [icon] │ │ [icon] │ │
|
||||
│ │ Operators │ │ Members │ │
|
||||
│ │ 3 │ │ 12 │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each tile uses the existing `renderStatCard()` component (or a simplified variant) with the `--color-members` accent. The tiles show:
|
||||
- **Operators**: count of `UserProfile` rows where `roles` contains the operator role
|
||||
- **Members**: count of `UserProfile` rows where `roles` contains the member role AND excludes operators
|
||||
|
||||
When OIDC is disabled (no `user_profiles` table populated), the panel shows "0" for both counts (graceful degradation — no error).
|
||||
|
||||
### 2. Footer — Two-Column Layout with MeshCore Branding
|
||||
|
||||
The footer changes from a centered single-column to a **horizontal two-column layout**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [MeshCore Logo] Website · GitHub IPNet Network │
|
||||
│ City, Country │
|
||||
│ email | discord | github │
|
||||
│ Powered by MeshCore Hub │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Desktop (`lg+`)**:
|
||||
- **Left side**: Small MeshCore logo + plain text links ("Website" and "GitHub")
|
||||
- **Right side**: Existing footer content (network name, contacts, powered-by)
|
||||
|
||||
**Mobile (< `lg`)**:
|
||||
- MeshCore content stacks on top
|
||||
- Hub/network content below
|
||||
- Both center-aligned (matching current mobile behaviour)
|
||||
|
||||
**MeshCore logo changes**:
|
||||
- Smaller than the homepage version: `h-5` (20px) instead of `h-8`
|
||||
- Links are plain `<a>` text links with `link link-hover` class — not `btn btn-outline btn-sm` buttons
|
||||
- Separator between links is a middot (`·`)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Add Member Counts to Dashboard Stats API
|
||||
|
||||
**Files**: `dashboard.py`, `messages.py`
|
||||
|
||||
#### 1a. Extend `DashboardStats` schema
|
||||
|
||||
Add two optional fields to `DashboardStats` in `messages.py:244`:
|
||||
|
||||
```python
|
||||
total_operators: int = Field(default=0, description="Number of operator-role users")
|
||||
total_members: int = Field(default=0, description="Number of member-role users (excluding operators)")
|
||||
```
|
||||
|
||||
Both default to `0` so the response is backward-compatible when OIDC is disabled or the table is empty.
|
||||
|
||||
#### 1b. Add count queries to `get_stats()`
|
||||
|
||||
In `dashboard.py:24`, add queries after the existing stats:
|
||||
|
||||
```python
|
||||
from meshcore_hub.common.models import UserProfile
|
||||
from meshcore_hub.common.config import Settings
|
||||
|
||||
settings = Settings()
|
||||
operator_role = settings.oidc_role_operator
|
||||
member_role = settings.oidc_role_member
|
||||
|
||||
# Count operators (roles contains operator role)
|
||||
total_operators = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(UserProfile)
|
||||
.where(UserProfile.roles.contains(operator_role))
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Count members (roles contains member role AND does NOT contain operator role)
|
||||
total_members = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(UserProfile)
|
||||
.where(
|
||||
UserProfile.roles.contains(member_role),
|
||||
~UserProfile.roles.contains(operator_role),
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
```
|
||||
|
||||
Include `total_operators` and `total_members` in the returned `DashboardStats` instance.
|
||||
|
||||
**Note**: The `roles` column is comma-separated text. Using `contains()` is a SQLite `LIKE` substring match — the same pattern already used in `metrics.py:309` for `UserProfile.roles.contains(role)`. This is acceptable for the small dataset sizes expected.
|
||||
|
||||
### Step 2: Replace MeshCore Card with Members Panel in Homepage
|
||||
|
||||
**File**: `home.js`
|
||||
|
||||
#### 2a. Add new `renderMembersPanel()` function
|
||||
|
||||
Create a new function that renders two stat tiles for operators and members:
|
||||
|
||||
```javascript
|
||||
function renderMembersPanel({ features, stats }) {
|
||||
if (features.members === false) return nothing;
|
||||
return html`
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconMembers('h-6 w-6')}
|
||||
${t('entities.members')}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-4 mt-2">
|
||||
${renderStatCard({
|
||||
icon: iconAntenna('h-6 w-6'),
|
||||
color: pageColors.members,
|
||||
title: t('members_page.operators'),
|
||||
value: stats.total_operators ?? 0,
|
||||
})}
|
||||
${renderStatCard({
|
||||
icon: iconUsers('h-6 w-6'),
|
||||
color: pageColors.members,
|
||||
title: t('members_page.members'),
|
||||
value: stats.total_members ?? 0,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2b. Replace the MeshCore attribution card and update grid condition
|
||||
|
||||
In the `render()` function's template (lines 210–244):
|
||||
|
||||
1. Replace the inline MeshCore card HTML (lines 223–241) with:
|
||||
```javascript
|
||||
${renderMembersPanel({ features, stats })}
|
||||
```
|
||||
|
||||
2. Update the grid class at line 210. Currently the `lg:grid-cols-3` condition only checks `showActivityChart`. Since the Members panel is now feature-gated (was always-present MeshCore card), the 3-column layout should only apply when BOTH the Members panel AND the Activity Chart are visible:
|
||||
```javascript
|
||||
const showMembersPanel = features.members !== false;
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 ${showMembersPanel && showActivityChart ? 'lg:grid-cols-3' : ''} gap-6 mt-6">
|
||||
```
|
||||
|
||||
#### 2c. Update imports
|
||||
|
||||
- **Add**: `iconAntenna` and `iconUsers` to the import from `icons.js`
|
||||
- **Remove**: `iconGlobe` and `iconGithub` from the import (only used by the now-removed MeshCore card)
|
||||
|
||||
### Step 3: Move MeshCore Branding to Footer
|
||||
|
||||
**File**: `spa.html`
|
||||
|
||||
#### 3a. Restructure footer markup
|
||||
|
||||
Replace the current footer (lines 112–140) with a two-column layout:
|
||||
|
||||
```html
|
||||
<footer class="footer p-4 bg-base-100 text-base-content mt-auto">
|
||||
<!-- Left: MeshCore branding (stacks on top on mobile) -->
|
||||
<div class="flex flex-col items-center lg:items-start gap-1 order-2 lg:order-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
|
||||
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo theme-logo--invert-light h-5" />
|
||||
</a>
|
||||
<span class="text-xs opacity-50">Off-Grid, Open-Source Encrypted Messaging</span>
|
||||
</div>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="link link-hover opacity-70">{{ t('links.website') }}</a>
|
||||
<span class="opacity-30">·</span>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="link link-hover opacity-70">{{ t('links.github') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Network info + Hub attribution -->
|
||||
<div class="flex flex-col items-center lg:items-end gap-1 order-1 lg:order-2">
|
||||
<p>
|
||||
{{ network_name }}
|
||||
{% if network_city and network_country %}
|
||||
— {{ network_city }}, {{ network_country }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
|
||||
{% endif %}
|
||||
{% if network_contact_email and network_contact_discord %} | {% endif %}
|
||||
{% if network_contact_discord %}
|
||||
<a href="{{ network_contact_discord }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.discord') }}</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord) and network_contact_github %} | {% endif %}
|
||||
{% if network_contact_github %}
|
||||
<a href="{{ network_contact_github }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.github') }}</a>
|
||||
{% endif %}
|
||||
{% if (network_contact_email or network_contact_discord or network_contact_github) and network_contact_youtube %} | {% endif %}
|
||||
{% if network_contact_youtube %}
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-1">{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
```
|
||||
|
||||
**Key changes**:
|
||||
- `footer-center` → `footer` (remove center alignment to allow two columns)
|
||||
- DaisyUI `footer` class provides `flex flex-wrap` which enables the two-column layout
|
||||
- `order-2 lg:order-1` / `order-1 lg:order-2` ensures MeshCore stacks on top on mobile
|
||||
- Logo is `h-5` (smaller than the `h-8` from the homepage card)
|
||||
- Links are plain `<a class="link link-hover">` — no buttons
|
||||
|
||||
#### 3b. Add footer CSS (if needed)
|
||||
|
||||
The DaisyUI `footer` component already provides flex-wrap layout. Minimal custom CSS should be needed. If the default spacing needs adjustment, add to `app.css`:
|
||||
|
||||
```css
|
||||
/* Footer two-column layout */
|
||||
footer.footer {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: i18n Updates
|
||||
|
||||
**File**: `en.json` (and check `nl.json` for the same key)
|
||||
|
||||
No new keys required — reuse existing:
|
||||
- `entities.members` = "Members"
|
||||
- `members_page.operators` = "Operators"
|
||||
- `members_page.members` = "Members"
|
||||
- `links.website` = "Website"
|
||||
- `links.github` = "GitHub"
|
||||
- `footer.powered_by` = "Powered by"
|
||||
|
||||
Remove orphaned key (no longer referenced after MeshCore card removal):
|
||||
- `home.meshcore_attribution` = "Our local off-grid mesh network is made possible by"
|
||||
|
||||
### Step 5: Update `docs/i18n.md`
|
||||
|
||||
Remove the `home.meshcore_attribution` key from the i18n reference if documented.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed — Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/meshcore_hub/common/schemas/messages.py` | Add `total_operators` and `total_members` fields to `DashboardStats` |
|
||||
| `src/meshcore_hub/api/routes/dashboard.py` | Add UserProfile count queries for operators and members; import `UserProfile` model and `Settings` |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/home.js` | Replace MeshCore card with `renderMembersPanel()`; add `iconAntenna`/`iconUsers` imports; remove `iconGlobe`/`iconGithub` imports; update grid `lg:grid-cols-3` condition |
|
||||
| `src/meshcore_hub/web/templates/spa.html` | Restructure footer into two-column layout with MeshCore branding left, network/hub info right |
|
||||
| `src/meshcore_hub/web/static/css/app.css` | Add footer layout styles if DaisyUI defaults insufficient |
|
||||
| `src/meshcore_hub/web/static/locales/en.json` | Remove orphaned `home.meshcore_attribution` key |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Visual Verification
|
||||
|
||||
1. Start the app with OIDC enabled and seeded user profiles
|
||||
2. Navigate to `/` (home page):
|
||||
- Verify the MeshCore card is gone
|
||||
- Verify the Members panel shows with operator and member counts
|
||||
- Verify counts match the `/members` page grouping
|
||||
3. Scroll to footer:
|
||||
- Verify MeshCore logo (small) and text links appear left-aligned
|
||||
- Verify network info and "Powered by" appear right-aligned
|
||||
- Resize to mobile width — verify MeshCore stacks above network info, both center-aligned
|
||||
4. Test with OIDC disabled — verify Members panel shows "0" for both counts (no error)
|
||||
|
||||
### API Verification
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8000/api/v1/dashboard/stats -H "Authorization: Bearer $API_READ_KEY" | jq '.total_operators, .total_members'
|
||||
```
|
||||
|
||||
### Automated Tests
|
||||
|
||||
```bash
|
||||
# API tests (dashboard stats changes)
|
||||
pytest tests/test_api/ -v
|
||||
|
||||
# Web tests (homepage rendering)
|
||||
pytest tests/test_web/ -v
|
||||
|
||||
# Quality checks
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
### Cross-Theme Testing
|
||||
|
||||
- Toggle between dark and light themes
|
||||
- Verify footer MeshCore logo inverts correctly in light mode (existing `theme-logo--invert-light` class)
|
||||
- Verify Members panel tiles use `--color-members` accent in both themes
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| `UserProfile.roles.contains()` is a substring match — role "member" could match "nonmember" | In practice, roles are standard OIDC values (`admin`, `operator`, `member`). The comma-separated format (`"operator,member"`) prevents false positives since `contains("member")` matches the whole token. For extra safety, could use `LIKE '%,member,%' OR roles LIKE 'member,%' OR roles LIKE '%,member' OR roles = 'member'`, but this is over-engineering for the current data model. |
|
||||
| Footer two-column layout may not look balanced with minimal network info | The `justify-between` flex layout with center-aligned fallback on mobile adapts well. If the left side is too sparse, the tagline text fills the space. |
|
||||
| Dashboard stats endpoint re-reads Settings for role names | `Settings` is a Pydantic BaseSettings class — instantiation is cheap and reads from env vars. Alternatively, pass role names via dependency injection from the app lifespan. For now, direct instantiation is acceptable. |
|
||||
| Removing the MeshCore card reduces attribution visibility | Moving it to the footer (present on every page, not just home) increases overall visibility. The footer is persistent across all routes. |
|
||||
@@ -0,0 +1,50 @@
|
||||
# Task List: Members Widget on Homepage + MeshCore Logo in Footer
|
||||
|
||||
**Plan**: [plan.md](./plan.md)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. Extend `DashboardStats` schema
|
||||
|
||||
- [ ] Add `total_operators: int = Field(default=0, ...)` and `total_members: int = Field(default=0, ...)` to `DashboardStats` in `src/meshcore_hub/common/schemas/messages.py:244`
|
||||
|
||||
### 2. Add member count queries to dashboard endpoint
|
||||
|
||||
- [ ] In `src/meshcore_hub/api/routes/dashboard.py`, import `UserProfile` from `meshcore_hub.common.models` and `Settings` from `meshcore_hub.common.config`
|
||||
- [ ] In `get_stats()`, add two count queries: operators (`roles.contains(operator_role)`) and members (`roles.contains(member_role) AND ~roles.contains(operator_role)`)
|
||||
- [ ] Pass `total_operators` and `total_members` to the `DashboardStats()` return at line 203
|
||||
|
||||
### 3. Replace MeshCore card with Members panel in `home.js`
|
||||
|
||||
- [ ] Add `showMembersPanel = features.members !== false` variable (after `showActivityChart` at line 188)
|
||||
- [ ] Create `renderMembersPanel({ features, stats })` function with two `renderStatCard()` tiles (operators/members)
|
||||
- [ ] Replace the inline MeshCore card HTML (lines 223–241) with `${renderMembersPanel({ features, stats })}`
|
||||
- [ ] Update grid class (line 210): change `${showActivityChart ? 'lg:grid-cols-3' : ''}` to `${showMembersPanel && showActivityChart ? 'lg:grid-cols-3' : ''}`
|
||||
- [ ] Update imports: add `iconAntenna`, `iconUsers`; remove `iconGlobe`, `iconGithub`
|
||||
|
||||
### 4. Restructure footer in `spa.html`
|
||||
|
||||
- [ ] Replace `footer footer-center` (line 113) with two-column layout: left side = MeshCore branding (logo `h-5` + text links), right side = existing network/hub info
|
||||
- [ ] Use `order-2 lg:order-1` / `order-1 lg:order-2` for mobile stacking (MeshCore on top)
|
||||
|
||||
### 5. Add footer CSS
|
||||
|
||||
- [ ] Add `footer.footer { justify-content: space-between; align-items: center; }` to `src/meshcore_hub/web/static/css/app.css`
|
||||
|
||||
### 6. Remove orphaned i18n key
|
||||
|
||||
- [ ] Remove `home.meshcore_attribution` from `src/meshcore_hub/web/static/locales/en.json`
|
||||
- [ ] Remove `home.meshcore_attribution` from `src/meshcore_hub/web/static/locales/nl.json`
|
||||
|
||||
### 7. Update i18n docs
|
||||
|
||||
- [ ] Remove `meshcore_attribution` entry from `docs/i18n.md:247`
|
||||
|
||||
### 8. Test
|
||||
|
||||
- [ ] Run `pytest tests/test_api/ -v` (dashboard stats changes)
|
||||
- [ ] Run `pytest tests/test_web/ -v` (homepage rendering)
|
||||
- [ ] Run `pre-commit run --all-files`
|
||||
- [ ] Visual: verify Members panel renders with correct counts, footer shows two-column layout, mobile stacks correctly, OIDC-disabled shows zeros
|
||||
@@ -7,7 +7,13 @@ from sqlalchemy import func, select
|
||||
|
||||
from meshcore_hub.api.auth import RequireRead
|
||||
from meshcore_hub.api.dependencies import DbSession
|
||||
from meshcore_hub.common.models import Advertisement, Message, Node, NodeTag
|
||||
from meshcore_hub.common.models import (
|
||||
Advertisement,
|
||||
Message,
|
||||
Node,
|
||||
NodeTag,
|
||||
UserProfile,
|
||||
)
|
||||
from meshcore_hub.common.schemas.messages import (
|
||||
ChannelMessage,
|
||||
DailyActivity,
|
||||
@@ -200,6 +206,33 @@ async def get_stats(
|
||||
for m in channel_msgs
|
||||
]
|
||||
|
||||
from meshcore_hub.common.config import get_web_settings
|
||||
|
||||
web_settings = get_web_settings()
|
||||
operator_role = web_settings.oidc_role_operator
|
||||
member_role = web_settings.oidc_role_member
|
||||
|
||||
total_operators = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(UserProfile)
|
||||
.where(UserProfile.roles.contains(operator_role))
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
total_members = (
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(UserProfile)
|
||||
.where(
|
||||
UserProfile.roles.contains(member_role),
|
||||
~UserProfile.roles.contains(operator_role),
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return DashboardStats(
|
||||
total_nodes=total_nodes,
|
||||
active_nodes=active_nodes,
|
||||
@@ -212,6 +245,8 @@ async def get_stats(
|
||||
recent_advertisements=recent_advertisements,
|
||||
channel_message_counts=channel_message_counts,
|
||||
channel_messages=channel_messages,
|
||||
total_operators=total_operators,
|
||||
total_members=total_members,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -267,6 +267,10 @@ class DashboardStats(BaseModel):
|
||||
default_factory=dict,
|
||||
description="Recent messages per channel (up to 5 each)",
|
||||
)
|
||||
total_operators: int = Field(default=0, description="Number of operator-role users")
|
||||
total_members: int = Field(
|
||||
default=0, description="Number of member-role users (excluding operators)"
|
||||
)
|
||||
|
||||
|
||||
class DailyActivityPoint(BaseModel):
|
||||
|
||||
@@ -404,3 +404,21 @@
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Footer Two-Column Layout
|
||||
========================================================================== */
|
||||
|
||||
footer.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
footer.footer {
|
||||
gap: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '../components.js';
|
||||
import {
|
||||
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMembers, iconMap,
|
||||
iconPage, iconInfo, iconChart, iconGlobe, iconGithub,
|
||||
iconPage, iconInfo, iconChart, iconAntenna, iconUsers,
|
||||
iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower,
|
||||
} from '../icons.js';
|
||||
|
||||
@@ -166,6 +166,33 @@ function renderActivityChartCard({ showAdvertSeries, showMessageSeries }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMembersPanel({ features, stats }) {
|
||||
if (features.members === false) return nothing;
|
||||
return html`
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
${iconMembers('h-6 w-6')}
|
||||
${t('entities.members')}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 mt-2">
|
||||
${renderStatCard({
|
||||
icon: iconAntenna('h-6 w-6'),
|
||||
color: pageColors.members,
|
||||
title: t('members_page.operators'),
|
||||
value: stats.total_operators ?? 0,
|
||||
})}
|
||||
${renderStatCard({
|
||||
icon: iconUsers('h-6 w-6'),
|
||||
color: pageColors.members,
|
||||
title: t('members_page.members'),
|
||||
value: stats.total_members ?? 0,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function render(container, params, router) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
@@ -186,6 +213,7 @@ export async function render(container, params, router) {
|
||||
const showAdvertSeries = features.advertisements !== false;
|
||||
const showMessageSeries = features.messages !== false;
|
||||
const showActivityChart = showAdvertSeries || showMessageSeries;
|
||||
const showMembersPanel = features.members !== false;
|
||||
|
||||
const heroSection = renderHeroSection({
|
||||
networkName, logoUrl, logoInvertLight,
|
||||
@@ -207,7 +235,7 @@ export async function render(container, params, router) {
|
||||
${showStats ? statsPanel : nothing}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 ${showActivityChart ? 'lg:grid-cols-3' : ''} gap-6 mt-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 ${showMembersPanel && showActivityChart ? 'lg:grid-cols-3' : ''} gap-6 mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
@@ -220,25 +248,7 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body flex flex-col items-center justify-center">
|
||||
<p class="text-sm opacity-70 mb-4 text-center">${t('home.meshcore_attribution')}</p>
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity">
|
||||
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo theme-logo--invert-light h-8" />
|
||||
</a>
|
||||
<p class="text-xs opacity-50 mt-4 text-center">Off-Grid, Open-Source Encrypted Messaging</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
${iconGlobe('h-4 w-4 mr-1')}
|
||||
${t('links.website')}
|
||||
</a>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">
|
||||
${iconGithub('h-4 w-4 mr-1')}
|
||||
${t('links.github')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderMembersPanel({ features, stats })}
|
||||
|
||||
${showActivityChart ? activityChartCard : nothing}
|
||||
</div>`, container);
|
||||
|
||||
@@ -141,7 +141,6 @@
|
||||
"all_discovered_nodes": "All discovered nodes",
|
||||
"network_info": "Network Info",
|
||||
"network_activity": "Network Activity",
|
||||
"meshcore_attribution": "Our local off-grid mesh network is made possible by",
|
||||
"frequency": "Frequency",
|
||||
"bandwidth": "Bandwidth",
|
||||
"spreading_factor": "Spreading Factor",
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
"all_discovered_nodes": "Alle ontdekte knooppunten",
|
||||
"network_info": "Netwerkinfo",
|
||||
"network_activity": "Netwerkactiviteit",
|
||||
"meshcore_attribution": "Ons lokale off-grid mesh-netwerk is mogelijk gemaakt door",
|
||||
"frequency": "Frequentie",
|
||||
"bandwidth": "Bandbreedte",
|
||||
"spreading_factor": "Spreading Factor",
|
||||
|
||||
@@ -110,14 +110,29 @@
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center p-4 bg-base-100 text-base-content mt-auto">
|
||||
<aside>
|
||||
<footer class="footer p-4 bg-base-100 text-base-content mt-auto">
|
||||
<!-- Left: MeshCore branding (stacks on top on mobile) -->
|
||||
<div class="flex flex-col items-center gap-1 order-2 lg:order-1">
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="hover:opacity-80 transition-opacity flex mb-1">
|
||||
<img src="/static/img/meshcore.svg" alt="MeshCore" class="theme-logo theme-logo--invert-light h-4" />
|
||||
</a>
|
||||
<span class="text-xs opacity-50">Off-Grid, Open-Source Encrypted Messaging</span>
|
||||
<p class="text-sm opacity-70">
|
||||
<a href="https://meshcore.io/" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.website') }}</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/meshcore-dev/MeshCore" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.github') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right: Network info + Hub attribution -->
|
||||
<div class="flex flex-col items-center gap-1 order-1 lg:order-2">
|
||||
<p>
|
||||
{{ network_name }}
|
||||
{% if network_city and network_country %}
|
||||
- {{ network_city }}, {{ network_country }}
|
||||
| {{ network_city }}, {{ network_country }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50">{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
<p class="text-sm opacity-70">
|
||||
{% if network_contact_email %}
|
||||
<a href="mailto:{{ network_contact_email }}" class="link link-hover">{{ network_contact_email }}</a>
|
||||
@@ -135,8 +150,7 @@
|
||||
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs opacity-50 mt-2">{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Leaflet JS for maps -->
|
||||
|
||||
Reference in New Issue
Block a user