Refactor frontend views
All checks were successful
Build and push image / build-containers (push) Successful in 10m14s

This commit is contained in:
2025-06-14 21:56:17 +02:00
parent b3debd3ed2
commit bce372a1d1
32 changed files with 1230 additions and 458 deletions

View File

@ -2,43 +2,20 @@
# pyright: reportUnusedFunction=false
import logging
import math
from typing import Annotated, cast
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel
from sshecret.backend import AuditFilter, Operation
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
from .common import PagingInfo
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
class PagingInfo(BaseModel):
page: int
limit: int
total: int
offset: int = 0
@property
def first(self) -> int:
"""The first result number."""
return self.offset + 1
@property
def last(self) -> int:
"""Return the last result number."""
return self.offset + self.limit
@property
def total_pages(self) -> int:
"""Return total pages."""
return math.ceil(self.total / self.limit)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router."""

View File

@ -5,11 +5,12 @@ import ipaddress
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, Response
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
from sshecret_admin.frontend.views.common import PagingInfo
from sshecret.backend import ClientFilter
from sshecret.backend.models import FilterType
from sshecret.backend.models import Client, ClientQueryResult, FilterType
from sshecret.crypto import validate_public_key
from sshecret_admin.auth import LocalUserInfo
from sshecret_admin.services import AdminBackend
@ -18,6 +19,8 @@ from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
CLIENTS_PER_PAGE = 20
class ClientUpdate(BaseModel):
id: uuid.UUID
@ -34,6 +37,38 @@ class ClientCreate(BaseModel):
sources: str | None
class LocatedClient(BaseModel):
"""A located client."""
client: Client
results: ClientQueryResult
pages: PagingInfo
async def locate_client(admin: AdminBackend, client_id: str) -> LocatedClient | None:
"""Locate a client in a paginated dataset."""
offset = 0
page = 1
total_clients = await admin.get_client_count()
while offset < total_clients:
filter = ClientFilter(limit=CLIENTS_PER_PAGE, offset=offset)
results = await admin.query_clients(filter)
matches = [client for client in results.clients if str(client.id) == client_id]
if matches:
client = matches[0]
pages = PagingInfo(
page=page,
limit=CLIENTS_PER_PAGE,
total=results.total_results,
offset=offset,
)
return LocatedClient(client=client, results=results, pages=pages)
offset += CLIENTS_PER_PAGE
page += 1
return None
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router."""
@ -41,45 +76,116 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
templates = dependencies.templates
@app.get("/clients")
async def get_clients(
@app.get("/clients/")
async def get_client_tree(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Get clients."""
clients = await admin.get_clients()
LOG.info("Clients %r", clients)
"""Get client tree view."""
page = 1
per_page = CLIENTS_PER_PAGE
offset = 0
client_filter = ClientFilter(offset=offset, limit=per_page)
results = await admin.query_clients(client_filter)
paginate = PagingInfo(
page=page, limit=per_page, total=results.total_results, offset=offset
)
LOG.info("Results %r", results)
return templates.TemplateResponse(
request,
"clients/index.html.j2",
{
"page_title": "Clients",
"clients": clients,
"offset": offset,
"pages": paginate,
"clients": results.clients,
"user": current_user,
"results": results,
},
)
@app.post("/clients/query")
async def query_clients(
@app.get("/clients/page/{page}")
async def get_client_page(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
query: Annotated[str, Form()],
page: int,
) -> Response:
"""Query for a client."""
query_filter: ClientFilter | None = None
if query:
name = f"%{query}%"
query_filter = ClientFilter(name=name, filter_name=FilterType.LIKE)
clients = await admin.get_clients(query_filter)
"""Get client tree view."""
per_page = CLIENTS_PER_PAGE
offset = 0
if page > 1:
offset = (page - 1) * per_page
client_filter = ClientFilter(offset=offset, limit=per_page)
results = await admin.query_clients(client_filter)
paginate = PagingInfo(
page=page,
limit=per_page,
offset=offset,
total=results.total_results,
)
LOG.info("Results %r", results)
template = "clients/index.html.j2"
if request.headers.get("HX-Request"):
# This is a HTMX request.
template = "clients/partials/tree.html.j2"
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
template,
{
"clients": clients,
"page_title": "Clients",
"offset": offset,
"last_num": offset + per_page,
"pages": paginate,
"clients": results.clients,
"user": current_user,
"results": results,
},
)
@app.get("/clients/client/{id}")
async def get_client(
request: Request,
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
id: str,
) -> Response:
"""Fetch a client."""
results = await locate_client(admin, id)
if not results:
raise HTTPException(status_code=404, detail="Client not found.")
events = await admin.get_audit_log_detailed(
limit=10, client_name=results.client.name
)
template = "clients/client.html.j2"
headers: dict[str, str] = {}
if request.headers.get("HX-Request"):
headers["HX-Push-Url"] = request.url.path
template = "clients/partials/client_details.html.j2"
return templates.TemplateResponse(
request,
template,
{
"page_title": f"Client {results.client.name}",
"pages": results.pages,
"clients": results.results.clients,
"client": results.client,
"user": current_user,
"results": results.results,
"events": events,
},
headers=headers,
)
@app.put("/clients/{id}")
async def update_client(
request: Request,
@ -111,36 +217,17 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
LOG.info("Fields: %r", client_fields)
updated_client = original_client.model_copy(update=client_fields)
await admin.update_client(updated_client)
final_client = await admin.update_client(updated_client)
events = await admin.get_audit_log_detailed(limit=10, client_name=client.name)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
"clients/partials/client_details.html.j2",
{
"clients": clients,
"client": final_client,
"events": events,
},
headers=headers,
)
@app.delete("/clients/{id}")
async def delete_client(
request: Request,
id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Delete a client."""
await admin.delete_client(("id", id))
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
)
@app.post("/clients/")
@ -153,20 +240,22 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
sources: list[str] | None = None
if client.sources:
sources = [source.strip() for source in client.sources.split(",")]
await admin.create_client(
client.name, client.public_key.rstrip(), client.description, sources
)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
return Response(
headers=headers,
)
@app.delete("/clients/{id}")
async def delete_client(
id: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Delete a client."""
await admin.delete_client(("id", id))
headers = {"Hx-Refresh": "true"}
return Response(headers=headers)
@app.post("/clients/validate/source")
async def validate_client_source(
request: Request,
@ -215,4 +304,38 @@ def create_router(dependencies: FrontendDependencies) -> APIRouter:
{"explanation": "Invalid value. Not a valid SSH RSA Public Key."},
)
@app.post("/clients/query")
async def query_clients(
request: Request,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
query: Annotated[str, Form()],
) -> Response:
"""Query for a client."""
query_filter = ClientFilter(limit=CLIENTS_PER_PAGE)
if query:
name = f"%{query}%"
query_filter = ClientFilter(
name=name, filter_name=FilterType.LIKE, limit=CLIENTS_PER_PAGE
)
results = await admin.query_clients(query_filter)
pages: PagingInfo | None = None
if not query:
pages = PagingInfo(
page=1, limit=CLIENTS_PER_PAGE, offset=0, total=results.total_results
)
more_results: int | None = None
if query and results.total_results > CLIENTS_PER_PAGE:
more_results = results.total_results - CLIENTS_PER_PAGE
return templates.TemplateResponse(
request,
"clients/partials/tree_items.html.j2",
{
"clients": results.clients,
"pages": pages,
"results": results,
"more_results": more_results,
},
)
return app

View File

@ -0,0 +1,41 @@
"""Common utilities."""
import math
from pydantic import BaseModel
class PagingInfo(BaseModel):
page: int
limit: int
total: int
offset: int = 0
@property
def first(self) -> int:
"""The first result number."""
return self.offset + 1
@property
def last(self) -> int:
"""Return the last result number."""
return self.offset + self.limit
@property
def total_pages(self) -> int:
"""Return total pages."""
return math.ceil(self.total / self.limit)
@property
def pages(self) -> list[int]:
"""Return all page numbers."""
return [page for page in range(1, self.total_pages + 1)]
@property
def is_last(self) -> bool:
"""Is this the last page?"""
return self.page == self.total_pages
@property
def is_first(self) -> bool:
"""Is this the first page?"""
return self.page == 1