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:
+166
-3
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<!DOCTYPE html><html><body>{% block content %}{% endblock %}</body></html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "base.html" %}{% block content %}detail{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "base.html" %}{% block content %}form{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "base.html" %}{% block content %}index{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "base.html" %}{% block content %}logs{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% extends "base.html" %}{% block content %}sub{% endblock %}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user