feat: add admin UI routes

Add CRUD routes for configs and subscriptions, force-refresh and logs endpoints, stub Jinja2 templates for test coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 00:12:57 +03:00
parent 5416ae7565
commit 43d5820474
8 changed files with 313 additions and 3 deletions
+166 -3
View File
@@ -4,11 +4,12 @@ from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from typing import AsyncGenerator from typing import AsyncGenerator
from fastapi import FastAPI, Depends, HTTPException import uuid
from fastapi.responses import Response from fastapi import FastAPI, Depends, HTTPException, Form, Request
from fastapi.responses import Response, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, make_url from sqlalchemy import select, desc, make_url
from models import Base, Config, Subscription, ExportLog, make_engine, make_session_factory from models import Base, Config, Subscription, ExportLog, make_engine, make_session_factory
from mihomo import MihomoClient from mihomo import MihomoClient
@@ -130,3 +131,165 @@ async def get_config(
await db.commit() await db.commit()
return Response(content=expanded, media_type="application/x-yaml") return Response(content=expanded, media_type="application/x-yaml")
@app.get("/")
async def index(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Config))
configs = result.scalars().all()
last_logs: dict[int, ExportLog | None] = {}
for cfg in configs:
log_result = await db.execute(
select(ExportLog)
.where(ExportLog.config_id == cfg.id)
.order_by(desc(ExportLog.fetched_at))
.limit(1)
)
last_logs[cfg.id] = log_result.scalar_one_or_none()
return templates.TemplateResponse(
"index.html",
{"request": request, "configs": configs, "last_logs": last_logs},
)
@app.get("/configs/new")
async def new_config_form(request: Request):
return templates.TemplateResponse("config_form.html", {"request": request, "config": None})
@app.post("/configs/new")
async def create_config(
request: Request,
name: str = Form(...),
base_yaml: str = Form(...),
db: AsyncSession = Depends(get_db),
):
config = Config(name=name, token=str(uuid.uuid4()), base_yaml=base_yaml)
db.add(config)
await db.commit()
await write_and_reload_mihomo(db)
return RedirectResponse(f"/configs/{config.id}", status_code=303)
@app.get("/configs/{config_id}")
async def config_detail(config_id: int, request: Request, db: AsyncSession = Depends(get_db)):
config = await db.get(Config, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
result = await db.execute(
select(Subscription).where(Subscription.config_id == config_id)
)
subscriptions = result.scalars().all()
return templates.TemplateResponse(
"config_detail.html",
{"request": request, "config": config, "subscriptions": subscriptions},
)
@app.post("/configs/{config_id}")
async def update_config(
config_id: int,
base_yaml: str = Form(...),
db: AsyncSession = Depends(get_db),
):
config = await db.get(Config, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
config.base_yaml = base_yaml
config.updated_at = datetime.utcnow()
await db.commit()
await write_and_reload_mihomo(db)
return RedirectResponse(f"/configs/{config_id}", status_code=303)
@app.post("/configs/{config_id}/delete")
async def delete_config(config_id: int, db: AsyncSession = Depends(get_db)):
config = await db.get(Config, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
await db.delete(config)
await db.commit()
await write_and_reload_mihomo(db)
return RedirectResponse("/", status_code=303)
@app.get("/configs/{config_id}/subscriptions/new")
async def new_sub_form(config_id: int, request: Request, db: AsyncSession = Depends(get_db)):
config = await db.get(Config, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
return templates.TemplateResponse(
"sub_form.html", {"request": request, "config": config}
)
@app.post("/configs/{config_id}/subscriptions/new")
async def create_subscription(
config_id: int,
name: str = Form(...),
url: str = Form(...),
db: AsyncSession = Depends(get_db),
):
config = await db.get(Config, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
sub = Subscription(config_id=config_id, name=name, url=url)
db.add(sub)
await db.commit()
return RedirectResponse(f"/configs/{config_id}", status_code=303)
@app.post("/subscriptions/{sub_id}/delete")
async def delete_subscription(sub_id: int, db: AsyncSession = Depends(get_db)):
sub = await db.get(Subscription, sub_id)
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found")
config_id = sub.config_id
await db.delete(sub)
await db.commit()
return RedirectResponse(f"/configs/{config_id}", status_code=303)
@app.get("/configs/{config_id}/logs")
async def config_logs(config_id: int, request: Request, db: AsyncSession = Depends(get_db)):
config = await db.get(Config, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
result = await db.execute(
select(ExportLog)
.where(ExportLog.config_id == config_id)
.order_by(desc(ExportLog.fetched_at))
.limit(50)
)
logs = result.scalars().all()
return templates.TemplateResponse(
"logs.html", {"request": request, "config": config, "logs": logs}
)
@app.post("/configs/{config_id}/refresh")
async def force_refresh(config_id: int, db: AsyncSession = Depends(get_db)):
config = await db.get(Config, config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
result = await db.execute(
select(Subscription).where(Subscription.config_id == config_id)
)
subscriptions = result.scalars().all()
for sub in subscriptions:
try:
await mihomo_client.refresh_and_collect(sub.name, timeout=30)
sub.last_fetched_at = datetime.utcnow()
except Exception as e:
logger.error("Force-refresh failed for provider %s: %s", sub.name, e)
await db.commit()
return RedirectResponse(f"/configs/{config_id}", status_code=303)
+1
View File
@@ -0,0 +1 @@
<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}detail{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}form{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}index{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}logs{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends "base.html" %}{% block content %}sub{% endblock %}
+141
View File
@@ -128,3 +128,144 @@ async def test_get_config_with_subscription_expands_nodes(http_client, db_sessio
assert "node1" in resp.text assert "node1" in resp.text
assert "proxy-providers" not in resp.text assert "proxy-providers" not in resp.text
assert "alive" not in resp.text assert "alive" not in resp.text
# --- Admin UI tests ---
async def test_index_returns_200(http_client):
resp = await http_client.get("/")
assert resp.status_code == 200
async def test_create_config_redirects(http_client, db_session):
from sqlalchemy import select
with patch("main.write_and_reload_mihomo", new_callable=AsyncMock):
resp = await http_client.post(
"/configs/new",
data={"name": "MyConfig", "base_yaml": "proxies: []\nrules: []\nproxy-groups: []\n"},
follow_redirects=False,
)
assert resp.status_code in (302, 303)
result = await db_session.execute(select(Config).where(Config.name == "MyConfig"))
config = result.scalar_one_or_none()
assert config is not None
assert config.token is not None
async def test_config_detail_returns_200(http_client, db_session):
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []\n")
db_session.add(config)
await db_session.commit()
resp = await http_client.get(f"/configs/{config.id}")
assert resp.status_code == 200
async def test_config_detail_404_for_missing(http_client):
resp = await http_client.get("/configs/99999")
assert resp.status_code == 404
async def test_update_config_base_yaml(http_client, db_session):
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []\n")
db_session.add(config)
await db_session.commit()
with patch("main.write_and_reload_mihomo", new_callable=AsyncMock):
resp = await http_client.post(
f"/configs/{config.id}",
data={"base_yaml": "proxies: []\nrules: []\n"},
follow_redirects=False,
)
assert resp.status_code in (302, 303)
await db_session.refresh(config)
assert "rules" in config.base_yaml
async def test_delete_config(http_client, db_session):
from sqlalchemy import select
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []\n")
db_session.add(config)
await db_session.commit()
config_id = config.id
with patch("main.write_and_reload_mihomo", new_callable=AsyncMock):
resp = await http_client.post(f"/configs/{config_id}/delete", follow_redirects=False)
assert resp.status_code in (302, 303)
result = await db_session.execute(select(Config).where(Config.id == config_id))
assert result.scalar_one_or_none() is None
async def test_add_subscription(http_client, db_session):
from sqlalchemy import select
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []\n")
db_session.add(config)
await db_session.commit()
resp = await http_client.post(
f"/configs/{config.id}/subscriptions/new",
data={"name": "mysub", "url": "https://example.com/sub"},
follow_redirects=False,
)
assert resp.status_code in (302, 303)
result = await db_session.execute(
select(Subscription).where(Subscription.config_id == config.id)
)
subs = result.scalars().all()
assert len(subs) == 1
assert subs[0].name == "mysub"
async def test_delete_subscription(http_client, db_session):
from sqlalchemy import select
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []\n")
db_session.add(config)
await db_session.flush()
sub = Subscription(config_id=config.id, name="s", url="https://example.com/sub")
db_session.add(sub)
await db_session.commit()
sub_id = sub.id
resp = await http_client.post(f"/subscriptions/{sub_id}/delete", follow_redirects=False)
assert resp.status_code in (302, 303)
result = await db_session.execute(select(Subscription).where(Subscription.id == sub_id))
assert result.scalar_one_or_none() is None
async def test_logs_page_returns_200(http_client, db_session):
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []\n")
db_session.add(config)
await db_session.flush()
log = ExportLog(config_id=config.id, node_count=3, success=True)
db_session.add(log)
await db_session.commit()
resp = await http_client.get(f"/configs/{config.id}/logs")
assert resp.status_code == 200
async def test_force_refresh(http_client, db_session):
config = Config(name="c", token=str(uuid.uuid4()), base_yaml="proxies: []\n")
db_session.add(config)
await db_session.flush()
sub = Subscription(config_id=config.id, name="p", url="https://example.com/sub")
db_session.add(sub)
await db_session.commit()
with patch("main.mihomo_client") as mock_mc:
mock_mc.refresh_and_collect = AsyncMock(return_value=[])
resp = await http_client.post(f"/configs/{config.id}/refresh", follow_redirects=False)
assert resp.status_code in (302, 303)