forked from iarv/meshcore-hub
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 741dd3ce84 | |||
| 0a12f389df | |||
| 8240c2fd57 | |||
| 38f7fe291e | |||
| e4087efbf0 | |||
| 3051984fb9 | |||
| eea2c90ea4 | |||
| d52c23fc29 |
@@ -458,6 +458,7 @@ Key variables:
|
||||
- `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection
|
||||
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `WEB_ADMIN_ENABLED` - Enable admin interface at /a/ (default: `false`, requires auth proxy)
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
The database defaults to `sqlite:///{DATA_HOME}/collector/meshcore.db` and does not typically need to be configured.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MeshCore Hub
|
||||
|
||||
Python 3.11+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
|
||||
|
||||

|
||||
|
||||
@@ -376,6 +376,7 @@ The collector automatically cleans up old event data and inactive nodes:
|
||||
| `WEB_HOST` | `0.0.0.0` | Web server bind address |
|
||||
| `WEB_PORT` | `8080` | Web server port |
|
||||
| `API_BASE_URL` | `http://localhost:8000` | API endpoint URL |
|
||||
| `WEB_ADMIN_ENABLED` | `false` | Enable admin interface at /a/ (requires auth proxy) |
|
||||
| `NETWORK_NAME` | `MeshCore Network` | Display name for the network |
|
||||
| `NETWORK_CITY` | *(none)* | City where network is located |
|
||||
| `NETWORK_COUNTRY` | *(none)* | Country code (ISO 3166-1 alpha-2) |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add lat lon columns to nodes
|
||||
|
||||
Revision ID: 4e2e787a1660
|
||||
Revises: aa1162502616
|
||||
Create Date: 2026-01-09 20:04:04.273741+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4e2e787a1660"
|
||||
down_revision: Union[str, None] = "aa1162502616"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("lat", sa.Float(), nullable=True))
|
||||
batch_op.add_column(sa.Column("lon", sa.Float(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("nodes", schema=None) as batch_op:
|
||||
batch_op.drop_column("lon")
|
||||
batch_op.drop_column("lat")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -252,6 +252,7 @@ services:
|
||||
- API_KEY=${API_READ_KEY:-}
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- WEB_ADMIN_ENABLED=${WEB_ADMIN_ENABLED:-false}
|
||||
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
||||
- NETWORK_CITY=${NETWORK_CITY:-}
|
||||
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
|
||||
|
||||
@@ -96,6 +96,9 @@ async def list_advertisements(
|
||||
received_by: Optional[str] = Query(
|
||||
None, description="Filter by receiver node public key"
|
||||
),
|
||||
member_id: Optional[str] = Query(
|
||||
None, description="Filter by member_id tag value of source node"
|
||||
),
|
||||
since: Optional[datetime] = Query(None, description="Start timestamp"),
|
||||
until: Optional[datetime] = Query(None, description="End timestamp"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Page size"),
|
||||
@@ -143,6 +146,16 @@ async def list_advertisements(
|
||||
if received_by:
|
||||
query = query.where(ReceiverNode.public_key == received_by)
|
||||
|
||||
if member_id:
|
||||
# Filter advertisements from nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
SourceNode.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if since:
|
||||
query = query.where(Advertisement.received_at >= since)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ async def list_nodes(
|
||||
None, description="Search in name tag, node name, or public key"
|
||||
),
|
||||
adv_type: Optional[str] = Query(None, description="Filter by advertisement type"),
|
||||
member_id: Optional[str] = Query(None, description="Filter by member_id tag value"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Page size"),
|
||||
offset: int = Query(0, ge=0, description="Page offset"),
|
||||
) -> NodeList:
|
||||
@@ -48,6 +49,16 @@ async def list_nodes(
|
||||
if adv_type:
|
||||
query = query.where(Node.adv_type == adv_type)
|
||||
|
||||
if member_id:
|
||||
# Filter nodes that have a member_id tag with the specified value
|
||||
query = query.where(
|
||||
Node.id.in_(
|
||||
select(NodeTag.node_id).where(
|
||||
NodeTag.key == "member_id", NodeTag.value == member_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
@@ -47,6 +47,10 @@ def handle_contact(
|
||||
# Device uses 'adv_name' for the advertised name
|
||||
name = payload.get("adv_name") or payload.get("name")
|
||||
|
||||
# GPS coordinates (optional)
|
||||
lat = payload.get("adv_lat")
|
||||
lon = payload.get("adv_lon")
|
||||
|
||||
logger.info(f"Processing contact: {contact_key[:12]}... adv_name={name}")
|
||||
|
||||
# Device uses numeric 'type' field, convert to string
|
||||
@@ -73,6 +77,11 @@ def handle_contact(
|
||||
node.name = name
|
||||
if node_type and not node.adv_type:
|
||||
node.adv_type = node_type
|
||||
# Update GPS coordinates if provided
|
||||
if lat is not None:
|
||||
node.lat = lat
|
||||
if lon is not None:
|
||||
node.lon = lon
|
||||
# Do NOT update last_seen for contact sync - only advertisement events
|
||||
# should update last_seen since that's when the node was actually seen
|
||||
else:
|
||||
@@ -84,6 +93,8 @@ def handle_contact(
|
||||
adv_type=node_type,
|
||||
first_seen=now,
|
||||
last_seen=None, # Will be set when we receive an advertisement
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
session.add(node)
|
||||
logger.info(f"Created node from contact: {contact_key[:12]}... ({name})")
|
||||
|
||||
@@ -253,6 +253,12 @@ class WebSettings(CommonSettings):
|
||||
web_host: str = Field(default="0.0.0.0", description="Web server host")
|
||||
web_port: int = Field(default=8080, description="Web server port")
|
||||
|
||||
# Admin interface (disabled by default for security)
|
||||
web_admin_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Enable admin interface at /a/ (requires OAuth2Proxy in front)",
|
||||
)
|
||||
|
||||
# API connection
|
||||
api_base_url: str = Field(
|
||||
default="http://localhost:8000",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String
|
||||
from sqlalchemy import DateTime, Float, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
@@ -23,6 +23,8 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
flags: Capability/status flags bitmask
|
||||
first_seen: Timestamp of first advertisement
|
||||
last_seen: Timestamp of most recent activity
|
||||
lat: GPS latitude coordinate (if available)
|
||||
lon: GPS longitude coordinate (if available)
|
||||
created_at: Record creation timestamp
|
||||
updated_at: Record update timestamp
|
||||
"""
|
||||
@@ -57,6 +59,14 @@ class Node(Base, UUIDMixin, TimestampMixin):
|
||||
default=None,
|
||||
nullable=True,
|
||||
)
|
||||
lat: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
lon: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tags: Mapped[list["NodeTag"]] = relationship(
|
||||
|
||||
@@ -62,6 +62,8 @@ class NodeRead(BaseModel):
|
||||
last_seen: Optional[datetime] = Field(
|
||||
default=None, description="Last activity timestamp"
|
||||
)
|
||||
lat: Optional[float] = Field(default=None, description="GPS latitude coordinate")
|
||||
lon: Optional[float] = Field(default=None, description="GPS longitude coordinate")
|
||||
created_at: datetime = Field(..., description="Record creation timestamp")
|
||||
updated_at: datetime = Field(..., description="Record update timestamp")
|
||||
tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags")
|
||||
|
||||
@@ -50,6 +50,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
def create_app(
|
||||
api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
admin_enabled: bool | None = None,
|
||||
network_name: str | None = None,
|
||||
network_city: str | None = None,
|
||||
network_country: str | None = None,
|
||||
@@ -67,6 +68,7 @@ def create_app(
|
||||
Args:
|
||||
api_url: Base URL of the MeshCore Hub API
|
||||
api_key: API key for authentication
|
||||
admin_enabled: Enable admin interface at /a/
|
||||
network_name: Display name for the network
|
||||
network_city: City where the network is located
|
||||
network_country: Country where the network is located
|
||||
@@ -96,6 +98,9 @@ def create_app(
|
||||
# Store configuration in app state (use args if provided, else settings)
|
||||
app.state.api_url = api_url or settings.api_base_url
|
||||
app.state.api_key = api_key or settings.api_key
|
||||
app.state.admin_enabled = (
|
||||
admin_enabled if admin_enabled is not None else settings.web_admin_enabled
|
||||
)
|
||||
app.state.network_name = network_name or settings.network_name
|
||||
app.state.network_city = network_city or settings.network_city
|
||||
app.state.network_country = network_country or settings.network_country
|
||||
|
||||
@@ -9,6 +9,7 @@ from meshcore_hub.web.routes.messages import router as messages_router
|
||||
from meshcore_hub.web.routes.advertisements import router as advertisements_router
|
||||
from meshcore_hub.web.routes.map import router as map_router
|
||||
from meshcore_hub.web.routes.members import router as members_router
|
||||
from meshcore_hub.web.routes.admin import router as admin_router
|
||||
|
||||
# Create main web router
|
||||
web_router = APIRouter()
|
||||
@@ -21,5 +22,6 @@ web_router.include_router(messages_router)
|
||||
web_router.include_router(advertisements_router)
|
||||
web_router.include_router(map_router)
|
||||
web_router.include_router(members_router)
|
||||
web_router.include_router(admin_router)
|
||||
|
||||
__all__ = ["web_router"]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Admin page route."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from meshcore_hub.web.app import get_network_context, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/a", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def admin_home(request: Request) -> HTMLResponse:
|
||||
"""Render the admin page with OAuth2Proxy user info."""
|
||||
# Check if admin interface is enabled
|
||||
if not getattr(request.app.state, "admin_enabled", False):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
|
||||
templates = get_templates(request)
|
||||
context = get_network_context(request)
|
||||
context["request"] = request
|
||||
|
||||
# Extract OAuth2Proxy headers
|
||||
context["auth_user"] = request.headers.get("X-Forwarded-User")
|
||||
context["auth_groups"] = request.headers.get("X-Forwarded-Groups")
|
||||
context["auth_email"] = request.headers.get("X-Forwarded-Email")
|
||||
context["auth_username"] = request.headers.get("X-Forwarded-Preferred-Username")
|
||||
|
||||
return templates.TemplateResponse("admin.html", context)
|
||||
@@ -15,6 +15,8 @@ router = APIRouter()
|
||||
async def advertisements_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
public_key: str | None = Query(None, description="Filter by node public key"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
@@ -30,12 +32,41 @@ async def advertisements_list(
|
||||
params: dict[str, int | str] = {"limit": limit, "offset": offset}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
if public_key:
|
||||
params["public_key"] = public_key
|
||||
|
||||
# Fetch advertisements from API
|
||||
advertisements = []
|
||||
total = 0
|
||||
members = []
|
||||
nodes = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
# Fetch nodes for dropdown
|
||||
nodes_response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params={"limit": 500}
|
||||
)
|
||||
if nodes_response.status_code == 200:
|
||||
nodes = nodes_response.json().get("items", [])
|
||||
|
||||
# Sort nodes alphabetically by display name
|
||||
def get_node_display_name(node: dict) -> str:
|
||||
for tag in node.get("tags") or []:
|
||||
if tag.get("key") == "name":
|
||||
return str(tag.get("value", "")).lower()
|
||||
return str(node.get("name") or node.get("public_key", "")).lower()
|
||||
|
||||
nodes.sort(key=get_node_display_name)
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/advertisements", params=params
|
||||
)
|
||||
@@ -58,6 +89,10 @@ async def advertisements_list(
|
||||
"limit": limit,
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"member_id": member_id or "",
|
||||
"public_key": public_key or "",
|
||||
"members": members,
|
||||
"nodes": nodes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -30,28 +30,27 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
"""
|
||||
nodes_with_location: list[dict[str, Any]] = []
|
||||
members_list: list[dict[str, Any]] = []
|
||||
members_by_key: dict[str, dict[str, Any]] = {}
|
||||
members_by_id: dict[str, dict[str, Any]] = {}
|
||||
error: str | None = None
|
||||
total_nodes = 0
|
||||
nodes_with_coords = 0
|
||||
|
||||
try:
|
||||
# Fetch all members to build lookup by public_key
|
||||
# Fetch all members to build lookup by member_id
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 500}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members_data = members_response.json()
|
||||
for member in members_data.get("items", []):
|
||||
# Only include members with public_key (required for node ownership)
|
||||
if member.get("public_key"):
|
||||
member_info = {
|
||||
"public_key": member.get("public_key"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
members_by_key[member["public_key"]] = member_info
|
||||
member_info = {
|
||||
"member_id": member.get("member_id"),
|
||||
"name": member.get("name"),
|
||||
"callsign": member.get("callsign"),
|
||||
}
|
||||
members_list.append(member_info)
|
||||
if member.get("member_id"):
|
||||
members_by_id[member["member_id"]] = member_info
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to fetch members: status {members_response.status_code}"
|
||||
@@ -73,6 +72,7 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
lon = None
|
||||
friendly_name = None
|
||||
role = None
|
||||
node_member_id = None
|
||||
|
||||
for tag in tags:
|
||||
key = tag.get("key")
|
||||
@@ -90,6 +90,8 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
friendly_name = tag.get("value")
|
||||
elif key == "role":
|
||||
role = tag.get("value")
|
||||
elif key == "member_id":
|
||||
node_member_id = tag.get("value")
|
||||
|
||||
if lat is not None and lon is not None:
|
||||
nodes_with_coords += 1
|
||||
@@ -101,8 +103,10 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
)
|
||||
public_key = node.get("public_key")
|
||||
|
||||
# Find owner member if exists
|
||||
owner = members_by_key.get(public_key)
|
||||
# Find owner member by member_id tag
|
||||
owner = (
|
||||
members_by_id.get(node_member_id) if node_member_id else None
|
||||
)
|
||||
|
||||
nodes_with_location.append(
|
||||
{
|
||||
@@ -114,6 +118,7 @@ async def map_data(request: Request) -> JSONResponse:
|
||||
"last_seen": node.get("last_seen"),
|
||||
"role": role,
|
||||
"is_infra": role == "infra",
|
||||
"member_id": node_member_id,
|
||||
"owner": owner,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ async def nodes_list(
|
||||
request: Request,
|
||||
search: str | None = Query(None, description="Search term"),
|
||||
adv_type: str | None = Query(None, description="Filter by node type"),
|
||||
member_id: str | None = Query(None, description="Filter by member"),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
limit: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
) -> HTMLResponse:
|
||||
@@ -33,12 +34,22 @@ async def nodes_list(
|
||||
params["search"] = search
|
||||
if adv_type:
|
||||
params["adv_type"] = adv_type
|
||||
if member_id:
|
||||
params["member_id"] = member_id
|
||||
|
||||
# Fetch nodes from API
|
||||
nodes = []
|
||||
total = 0
|
||||
members = []
|
||||
|
||||
try:
|
||||
# Fetch members for dropdown
|
||||
members_response = await request.app.state.http_client.get(
|
||||
"/api/v1/members", params={"limit": 100}
|
||||
)
|
||||
if members_response.status_code == 200:
|
||||
members = members_response.json().get("items", [])
|
||||
|
||||
response = await request.app.state.http_client.get(
|
||||
"/api/v1/nodes", params=params
|
||||
)
|
||||
@@ -62,6 +73,8 @@ async def nodes_list(
|
||||
"total_pages": total_pages,
|
||||
"search": search or "",
|
||||
"adv_type": adv_type or "",
|
||||
"member_id": member_id or "",
|
||||
"members": members,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -56,8 +56,23 @@ function populateReceiverTooltips() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate <time> elements with data-relative-time attribute
|
||||
* Uses the datetime attribute as the timestamp source
|
||||
*/
|
||||
function populateRelativeTimeElements() {
|
||||
document.querySelectorAll('time[data-relative-time]').forEach(el => {
|
||||
const timestamp = el.getAttribute('datetime');
|
||||
if (timestamp) {
|
||||
const relTime = formatRelativeTime(timestamp);
|
||||
el.textContent = relTime ? `${relTime} ago` : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-populate when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
populateRelativeTimestamps();
|
||||
populateReceiverTooltips();
|
||||
populateRelativeTimeElements();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{# Reusable macros for templates #}
|
||||
|
||||
{#
|
||||
Pagination macro
|
||||
|
||||
Parameters:
|
||||
- page: Current page number
|
||||
- total_pages: Total number of pages
|
||||
- params: Dict of query parameters to preserve (e.g., {"search": "foo", "limit": 50})
|
||||
#}
|
||||
{% macro pagination(page, total_pages, params={}) %}
|
||||
{% if total_pages > 1 %}
|
||||
{% set query_parts = [] %}
|
||||
{% for key, value in params.items() %}
|
||||
{% if value is not none and value != '' %}
|
||||
{% set _ = query_parts.append(key ~ '=' ~ value) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set base_query = query_parts|join('&') %}
|
||||
{% set query_prefix = '&' if base_query else '' %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}{{ query_prefix }}{{ base_query }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ network_name }} - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Admin</h1>
|
||||
<a href="/oauth2/sign_out" class="btn btn-outline btn-sm">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Authenticated User</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-64">X-Forwarded-User</th>
|
||||
<td>{{ auth_user or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>X-Forwarded-Preferred-Username</th>
|
||||
<td>{{ auth_username or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>X-Forwarded-Email</th>
|
||||
<td>{{ auth_email or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>X-Forwarded-Groups</th>
|
||||
<td>{{ auth_groups or '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Advertisements{% endblock %}
|
||||
|
||||
@@ -27,14 +28,87 @@
|
||||
</label>
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Search by name, ID, or public key..." class="input input-bordered input-sm w-80" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Search</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% if nodes %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Node</span>
|
||||
</label>
|
||||
<select name="public_key" class="select select-bordered select-sm">
|
||||
<option value="">All Nodes</option>
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<option value="{{ node.public_key }}" {% if public_key == node.public_key %}selected{% endif %}>{{ ns.tag_name or node.name or node.public_key[:12] + '...' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/advertisements" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Table -->
|
||||
<div class="overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<!-- Advertisements List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for ad in advertisements %}
|
||||
<a href="/nodes/{{ ad.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ ad.adv_type or 'Unknown' }}">{% if ad.adv_type and ad.adv_type|lower == 'chat' %}💬{% elif ad.adv_type and ad.adv_type|lower == 'repeater' %}📡{% elif ad.adv_type and ad.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ad.node_tag_name or ad.node_name or ad.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ad.node_tag_name or ad.node_name or ad.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ ad.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{{ ad.received_at[:16].replace('T', ' ') if ad.received_at else '-' }}
|
||||
</div>
|
||||
{% if ad.receivers and ad.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5 justify-end mt-1">
|
||||
{% for recv in ad.receivers %}
|
||||
<span class="text-sm" title="{{ recv.tag_name or recv.name or recv.public_key[:12] }}">📡</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif ad.received_by %}
|
||||
<span class="text-sm" title="{{ ad.receiver_tag_name or ad.receiver_name or ad.received_by[:12] }}">📡</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No advertisements found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Advertisements Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto overflow-y-visible bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -85,32 +159,5 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"search": search, "public_key": public_key, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Owner</span>
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select id="filter-owner" class="select select-bordered select-sm">
|
||||
<option value="">All Owners</option>
|
||||
<select id="filter-member" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
@@ -190,16 +190,16 @@
|
||||
// Core filter logic - returns filtered nodes and updates markers
|
||||
function applyFiltersCore() {
|
||||
const typeFilter = document.getElementById('filter-type').value;
|
||||
const ownerFilter = document.getElementById('filter-owner').value;
|
||||
const memberFilter = document.getElementById('filter-member').value;
|
||||
|
||||
// Filter nodes
|
||||
const filteredNodes = allNodes.filter(node => {
|
||||
// Type filter (case-insensitive)
|
||||
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
|
||||
|
||||
// Owner filter
|
||||
if (ownerFilter) {
|
||||
if (!node.owner || node.owner.public_key !== ownerFilter) return false;
|
||||
// Member filter - match node's member_id tag to selected member_id
|
||||
if (memberFilter) {
|
||||
if (node.member_id !== memberFilter) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -249,45 +249,36 @@
|
||||
applyFiltersCore();
|
||||
}
|
||||
|
||||
// Populate owner filter dropdown
|
||||
function populateOwnerFilter() {
|
||||
const select = document.getElementById('filter-owner');
|
||||
// Populate member filter dropdown
|
||||
function populateMemberFilter() {
|
||||
const select = document.getElementById('filter-member');
|
||||
|
||||
// Get unique owners from nodes that have locations
|
||||
const ownersWithNodes = new Set();
|
||||
allNodes.forEach(node => {
|
||||
if (node.owner) {
|
||||
ownersWithNodes.add(node.owner.public_key);
|
||||
// Sort members by name
|
||||
const sortedMembers = [...allMembers].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options for all members
|
||||
sortedMembers.forEach(member => {
|
||||
if (member.member_id) {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.member_id;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter members to only those who own nodes on the map
|
||||
const relevantMembers = allMembers.filter(m => ownersWithNodes.has(m.public_key));
|
||||
|
||||
// Sort by name
|
||||
relevantMembers.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Add options
|
||||
relevantMembers.forEach(member => {
|
||||
const option = document.createElement('option');
|
||||
option.value = member.public_key;
|
||||
option.textContent = member.callsign
|
||||
? `${member.name} (${member.callsign})`
|
||||
: member.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all filters
|
||||
function clearFilters() {
|
||||
document.getElementById('filter-type').value = '';
|
||||
document.getElementById('filter-owner').value = '';
|
||||
document.getElementById('filter-member').value = '';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// Event listeners for filters
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-owner').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-member').addEventListener('change', applyFilters);
|
||||
document.getElementById('clear-filters').addEventListener('click', clearFilters);
|
||||
|
||||
// Fetch and display nodes
|
||||
@@ -318,8 +309,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate owner filter
|
||||
populateOwnerFilter();
|
||||
// Populate member filter
|
||||
populateMemberFilter();
|
||||
|
||||
// Initial display - center map on nodes if available
|
||||
if (allNodes.length > 0) {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
📦
|
||||
{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if display_name %}
|
||||
<div class="font-medium text-sm">{{ display_name }}</div>
|
||||
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
|
||||
@@ -58,6 +58,9 @@
|
||||
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.last_seen %}
|
||||
<time class="text-xs opacity-60 whitespace-nowrap" datetime="{{ node.last_seen }}" title="{{ node.last_seen[:19].replace('T', ' ') }}" data-relative-time>-</time>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Messages{% endblock %}
|
||||
|
||||
@@ -42,8 +43,10 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/messages" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,9 +79,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{% if msg.snr is not none %}
|
||||
<span class="badge badge-ghost badge-xs">{{ "%.1f"|format(msg.snr) }}dB</span>
|
||||
{% endif %}
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-0.5">
|
||||
{% for recv in msg.receivers %}
|
||||
@@ -107,7 +107,6 @@
|
||||
<th>Time</th>
|
||||
<th>From/Channel</th>
|
||||
<th>Message</th>
|
||||
<th class="text-center">SNR</th>
|
||||
<th>Receivers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -132,13 +131,6 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="break-words max-w-md" style="white-space: pre-wrap;">{{ msg.text or '-' }}</td>
|
||||
<td class="text-center whitespace-nowrap">
|
||||
{% if msg.snr is not none %}
|
||||
<span class="badge badge-ghost badge-sm">{{ "%.1f"|format(msg.snr) }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if msg.receivers and msg.receivers|length >= 1 %}
|
||||
<div class="flex gap-1">
|
||||
@@ -155,39 +147,12 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
<td colspan="5" class="text-center py-8 opacity-70">No messages found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&message_type={{ message_type }}&channel_idx={{ channel_idx or '' }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"message_type": message_type, "channel_idx": channel_idx, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import pagination %}
|
||||
|
||||
{% block title %}{{ network_name }} - Nodes{% endblock %}
|
||||
|
||||
@@ -38,14 +39,79 @@
|
||||
<option value="room" {% if adv_type == 'room' %}selected{% endif %}>Room</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
{% if members %}
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text">Member</span>
|
||||
</label>
|
||||
<select name="member_id" class="select select-bordered select-sm">
|
||||
<option value="">All Members</option>
|
||||
{% for member in members %}
|
||||
<option value="{{ member.member_id }}" {% if member_id == member.member_id %}selected{% endif %}>{{ member.name }}{% if member.callsign %} ({{ member.callsign }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-2 w-full sm:w-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<a href="/nodes" class="btn btn-ghost btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table -->
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<!-- Nodes List - Mobile Card View -->
|
||||
<div class="lg:hidden space-y-3">
|
||||
{% for node in nodes %}
|
||||
{% set ns = namespace(tag_name=none) %}
|
||||
{% for tag in node.tags or [] %}
|
||||
{% if tag.key == 'name' %}
|
||||
{% set ns.tag_name = tag.value %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="card bg-base-100 shadow-sm block">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-lg flex-shrink-0" title="{{ node.adv_type or 'Unknown' }}">{% if node.adv_type and node.adv_type|lower == 'chat' %}💬{% elif node.adv_type and node.adv_type|lower == 'repeater' %}📡{% elif node.adv_type and node.adv_type|lower == 'room' %}🪧{% else %}📍{% endif %}</span>
|
||||
<div class="min-w-0">
|
||||
{% if ns.tag_name or node.name %}
|
||||
<div class="font-medium text-sm truncate">{{ ns.tag_name or node.name }}</div>
|
||||
<div class="text-xs font-mono opacity-60 truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm truncate">{{ node.public_key[:16] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs opacity-60">
|
||||
{% if node.last_seen %}
|
||||
{{ node.last_seen[:10] }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if node.tags %}
|
||||
<div class="flex gap-1 justify-end mt-1">
|
||||
{% for tag in node.tags[:2] %}
|
||||
<span class="badge badge-ghost badge-xs">{{ tag.key }}</span>
|
||||
{% endfor %}
|
||||
{% if node.tags|length > 2 %}
|
||||
<span class="badge badge-ghost badge-xs">+{{ node.tags|length - 2 }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-center py-8 opacity-70">No nodes found.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Nodes Table - Desktop View -->
|
||||
<div class="hidden lg:block overflow-x-auto bg-base-100 rounded-box shadow">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -107,32 +173,5 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Previous</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Previous</button>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button class="join-item btn btn-sm btn-active">{{ p }}</button>
|
||||
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||
<a href="?page={{ p }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">{{ p }}</a>
|
||||
{% elif p == 2 or p == total_pages - 1 %}
|
||||
<button class="join-item btn btn-sm btn-disabled">...</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&adv_type={{ adv_type }}&limit={{ limit }}" class="join-item btn btn-sm">Next</a>
|
||||
{% else %}
|
||||
<button class="join-item btn btn-sm btn-disabled">Next</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ pagination(page, total_pages, {"search": search, "adv_type": adv_type, "member_id": member_id, "limit": limit}) }}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user