diff --git a/src/meshcore_hub/api/routes/node_tags.py b/src/meshcore_hub/api/routes/node_tags.py index 116530a..ff93fc3 100644 --- a/src/meshcore_hub/api/routes/node_tags.py +++ b/src/meshcore_hub/api/routes/node_tags.py @@ -6,7 +6,12 @@ from sqlalchemy import select from meshcore_hub.api.auth import RequireAdmin, RequireRead from meshcore_hub.api.dependencies import DbSession from meshcore_hub.common.models import Node, NodeTag -from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate +from meshcore_hub.common.schemas.nodes import ( + NodeTagCreate, + NodeTagMove, + NodeTagRead, + NodeTagUpdate, +) router = APIRouter() @@ -130,6 +135,58 @@ async def update_node_tag( return NodeTagRead.model_validate(node_tag) +@router.put("/nodes/{public_key}/tags/{key}/move", response_model=NodeTagRead) +async def move_node_tag( + _: RequireAdmin, + session: DbSession, + public_key: str, + key: str, + data: NodeTagMove, +) -> NodeTagRead: + """Move a node tag to a different node.""" + # Find source node + source_query = select(Node).where(Node.public_key == public_key) + source_node = session.execute(source_query).scalar_one_or_none() + + if not source_node: + raise HTTPException(status_code=404, detail="Source node not found") + + # Find tag + tag_query = select(NodeTag).where( + (NodeTag.node_id == source_node.id) & (NodeTag.key == key) + ) + node_tag = session.execute(tag_query).scalar_one_or_none() + + if not node_tag: + raise HTTPException(status_code=404, detail="Tag not found") + + # Find destination node + dest_query = select(Node).where(Node.public_key == data.new_public_key) + dest_node = session.execute(dest_query).scalar_one_or_none() + + if not dest_node: + raise HTTPException(status_code=404, detail="Destination node not found") + + # Check if tag already exists on destination node + conflict_query = select(NodeTag).where( + (NodeTag.node_id == dest_node.id) & (NodeTag.key == key) + ) + conflict = session.execute(conflict_query).scalar_one_or_none() + + if conflict: + raise HTTPException( + status_code=409, + detail=f"Tag '{key}' already exists on destination node", + ) + + # Move tag to destination node + node_tag.node_id = dest_node.id + session.commit() + session.refresh(node_tag) + + return NodeTagRead.model_validate(node_tag) + + @router.delete("/nodes/{public_key}/tags/{key}", status_code=204) async def delete_node_tag( _: RequireAdmin, diff --git a/src/meshcore_hub/common/schemas/nodes.py b/src/meshcore_hub/common/schemas/nodes.py index 2f6fdbf..04ab6bc 100644 --- a/src/meshcore_hub/common/schemas/nodes.py +++ b/src/meshcore_hub/common/schemas/nodes.py @@ -38,6 +38,17 @@ class NodeTagUpdate(BaseModel): ) +class NodeTagMove(BaseModel): + """Schema for moving a node tag to a different node.""" + + new_public_key: str = Field( + ..., + min_length=64, + max_length=64, + description="Public key of the destination node", + ) + + class NodeTagRead(BaseModel): """Schema for reading a node tag.""" diff --git a/src/meshcore_hub/web/routes/admin.py b/src/meshcore_hub/web/routes/admin.py index dd5b7ef..3704db5 100644 --- a/src/meshcore_hub/web/routes/admin.py +++ b/src/meshcore_hub/web/routes/admin.py @@ -1,9 +1,10 @@ -"""Admin page route.""" +"""Admin page routes.""" import logging +from typing import Optional -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Form, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse from meshcore_hub.web.app import get_network_context, get_templates @@ -11,21 +12,236 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/a", tags=["admin"]) +def _check_admin_enabled(request: Request) -> None: + """Check if admin interface is enabled, raise 404 if not.""" + if not getattr(request.app.state, "admin_enabled", False): + raise HTTPException(status_code=404, detail="Not Found") + + +def _get_auth_context(request: Request) -> dict: + """Extract OAuth2Proxy authentication headers.""" + return { + "auth_user": request.headers.get("X-Forwarded-User"), + "auth_groups": request.headers.get("X-Forwarded-Groups"), + "auth_email": request.headers.get("X-Forwarded-Email"), + "auth_username": request.headers.get("X-Forwarded-Preferred-Username"), + } + + @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") + _check_admin_enabled(request) templates = get_templates(request) context = get_network_context(request) context["request"] = request + context.update(_get_auth_context(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/index.html", context) - return templates.TemplateResponse("admin.html", context) + +@router.get("/node-tags", response_class=HTMLResponse) +async def admin_node_tags( + request: Request, + public_key: Optional[str] = Query(None), + message: Optional[str] = Query(None), + error: Optional[str] = Query(None), +) -> HTMLResponse: + """Admin page for managing node tags.""" + _check_admin_enabled(request) + + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + context.update(_get_auth_context(request)) + + # Flash messages from redirects + context["message"] = message + context["error"] = error + + # Fetch all nodes for dropdown + nodes = [] + try: + response = await request.app.state.http_client.get( + "/api/v1/nodes", + params={"limit": 100}, + ) + if response.status_code == 200: + data = response.json() + nodes = data.get("items", []) + except Exception as e: + logger.exception("Failed to fetch nodes: %s", e) + context["error"] = "Failed to fetch nodes" + + context["nodes"] = nodes + context["selected_public_key"] = public_key + + # Fetch tags for selected node + tags = [] + selected_node = None + if public_key: + # Find the selected node in the list + for node in nodes: + if node.get("public_key") == public_key: + selected_node = node + break + + try: + response = await request.app.state.http_client.get( + f"/api/v1/nodes/{public_key}/tags", + ) + if response.status_code == 200: + tags = response.json() + elif response.status_code == 404: + context["error"] = "Node not found" + except Exception as e: + logger.exception("Failed to fetch tags: %s", e) + context["error"] = "Failed to fetch tags" + + context["tags"] = tags + context["selected_node"] = selected_node + + return templates.TemplateResponse("admin/node_tags.html", context) + + +@router.post("/node-tags", response_class=RedirectResponse) +async def admin_create_node_tag( + request: Request, + public_key: str = Form(...), + key: str = Form(...), + value: str = Form(""), + value_type: str = Form("string"), +) -> RedirectResponse: + """Create a new node tag.""" + _check_admin_enabled(request) + + redirect_url = f"/a/node-tags?public_key={public_key}" + + try: + response = await request.app.state.http_client.post( + f"/api/v1/nodes/{public_key}/tags", + json={ + "key": key, + "value": value or None, + "value_type": value_type, + }, + ) + if response.status_code == 201: + redirect_url += f"&message=Tag '{key}' created successfully" + elif response.status_code == 409: + redirect_url += f"&error=Tag '{key}' already exists" + elif response.status_code == 404: + redirect_url += "&error=Node not found" + else: + detail = response.json().get("detail", "Unknown error") + redirect_url += f"&error={detail}" + except Exception as e: + logger.exception("Failed to create tag: %s", e) + redirect_url += "&error=Failed to create tag" + + return RedirectResponse(url=redirect_url, status_code=303) + + +@router.post("/node-tags/update", response_class=RedirectResponse) +async def admin_update_node_tag( + request: Request, + public_key: str = Form(...), + key: str = Form(...), + value: str = Form(""), + value_type: str = Form("string"), +) -> RedirectResponse: + """Update an existing node tag.""" + _check_admin_enabled(request) + + redirect_url = f"/a/node-tags?public_key={public_key}" + + try: + response = await request.app.state.http_client.put( + f"/api/v1/nodes/{public_key}/tags/{key}", + json={ + "value": value or None, + "value_type": value_type, + }, + ) + if response.status_code == 200: + redirect_url += f"&message=Tag '{key}' updated successfully" + elif response.status_code == 404: + redirect_url += f"&error=Tag '{key}' not found" + else: + detail = response.json().get("detail", "Unknown error") + redirect_url += f"&error={detail}" + except Exception as e: + logger.exception("Failed to update tag: %s", e) + redirect_url += "&error=Failed to update tag" + + return RedirectResponse(url=redirect_url, status_code=303) + + +@router.post("/node-tags/move", response_class=RedirectResponse) +async def admin_move_node_tag( + request: Request, + public_key: str = Form(...), + key: str = Form(...), + new_public_key: str = Form(...), +) -> RedirectResponse: + """Move a node tag to a different node.""" + _check_admin_enabled(request) + + # Redirect to the destination node after move + redirect_url = f"/a/node-tags?public_key={new_public_key}" + + try: + response = await request.app.state.http_client.put( + f"/api/v1/nodes/{public_key}/tags/{key}/move", + json={"new_public_key": new_public_key}, + ) + if response.status_code == 200: + redirect_url += f"&message=Tag '{key}' moved successfully" + elif response.status_code == 404: + # Stay on source node if not found + redirect_url = f"/a/node-tags?public_key={public_key}" + detail = response.json().get("detail", "Not found") + redirect_url += f"&error={detail}" + elif response.status_code == 409: + redirect_url = f"/a/node-tags?public_key={public_key}" + redirect_url += f"&error=Tag '{key}' already exists on destination node" + else: + redirect_url = f"/a/node-tags?public_key={public_key}" + detail = response.json().get("detail", "Unknown error") + redirect_url += f"&error={detail}" + except Exception as e: + logger.exception("Failed to move tag: %s", e) + redirect_url = f"/a/node-tags?public_key={public_key}" + redirect_url += "&error=Failed to move tag" + + return RedirectResponse(url=redirect_url, status_code=303) + + +@router.post("/node-tags/delete", response_class=RedirectResponse) +async def admin_delete_node_tag( + request: Request, + public_key: str = Form(...), + key: str = Form(...), +) -> RedirectResponse: + """Delete a node tag.""" + _check_admin_enabled(request) + + redirect_url = f"/a/node-tags?public_key={public_key}" + + try: + response = await request.app.state.http_client.delete( + f"/api/v1/nodes/{public_key}/tags/{key}", + ) + if response.status_code == 204: + redirect_url += f"&message=Tag '{key}' deleted successfully" + elif response.status_code == 404: + redirect_url += f"&error=Tag '{key}' not found" + else: + detail = response.json().get("detail", "Unknown error") + redirect_url += f"&error={detail}" + except Exception as e: + logger.exception("Failed to delete tag: %s", e) + redirect_url += "&error=Failed to delete tag" + + return RedirectResponse(url=redirect_url, status_code=303) diff --git a/src/meshcore_hub/web/templates/admin.html b/src/meshcore_hub/web/templates/admin/index.html similarity index 54% rename from src/meshcore_hub/web/templates/admin.html rename to src/meshcore_hub/web/templates/admin/index.html index 7ab6db3..72ecd63 100644 --- a/src/meshcore_hub/web/templates/admin.html +++ b/src/meshcore_hub/web/templates/admin/index.html @@ -4,10 +4,34 @@ {% block content %}
+ + + +{{ selected_public_key }}
+ {% if selected_node.adv_type %} + {{ selected_node.adv_type }} + {% endif %} +| Key | +Value | +Type | +Updated | +Actions | +
|---|---|---|---|---|
| {{ tag.key }} | +{{ tag.value or '-' }} | ++ {{ tag.value_type }} + | +{{ tag.updated_at[:10] if tag.updated_at else '-' }} | +
+
+
+
+
+
+ |
+
No tags found for this node.
+Add a new tag below.
+Choose a node from the dropdown above to view and manage its tags.
+