diff --git a/app/main.py b/app/main.py index 1f89a99..3561ee1 100644 --- a/app/main.py +++ b/app/main.py @@ -4,11 +4,12 @@ from contextlib import asynccontextmanager from datetime import datetime from typing import AsyncGenerator -from fastapi import FastAPI, Depends, HTTPException -from fastapi.responses import Response +import uuid +from fastapi import FastAPI, Depends, HTTPException, Form, Request +from fastapi.responses import Response, RedirectResponse from fastapi.templating import Jinja2Templates 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 mihomo import MihomoClient @@ -130,3 +131,165 @@ async def get_config( await db.commit() 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) diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..7f16efb --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1 @@ +{% block content %}{% endblock %} diff --git a/app/templates/config_detail.html b/app/templates/config_detail.html new file mode 100644 index 0000000..50ad955 --- /dev/null +++ b/app/templates/config_detail.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block content %}detail{% endblock %} diff --git a/app/templates/config_form.html b/app/templates/config_form.html new file mode 100644 index 0000000..f95561e --- /dev/null +++ b/app/templates/config_form.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block content %}form{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..148a828 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block content %}index{% endblock %} diff --git a/app/templates/logs.html b/app/templates/logs.html new file mode 100644 index 0000000..bc545dd --- /dev/null +++ b/app/templates/logs.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block content %}logs{% endblock %} diff --git a/app/templates/sub_form.html b/app/templates/sub_form.html new file mode 100644 index 0000000..6a0fe35 --- /dev/null +++ b/app/templates/sub_form.html @@ -0,0 +1 @@ +{% extends "base.html" %}{% block content %}sub{% endblock %} diff --git a/app/tests/test_routes.py b/app/tests/test_routes.py index f83d7e8..cf1e62f 100644 --- a/app/tests/test_routes.py +++ b/app/tests/test_routes.py @@ -128,3 +128,144 @@ async def test_get_config_with_subscription_expands_nodes(http_client, db_sessio assert "node1" in resp.text assert "proxy-providers" 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)