diff --git a/app-instance/backend/nanobot/web/server.py b/app-instance/backend/nanobot/web/server.py index 480919f..6b4a06d 100644 --- a/app-instance/backend/nanobot/web/server.py +++ b/app-instance/backend/nanobot/web/server.py @@ -18,6 +18,7 @@ from urllib.parse import urlsplit, urlunsplit import httpx from fastapi import ( + BackgroundTasks, FastAPI, File, Form, @@ -112,6 +113,13 @@ async def _reconcile_managed_outlook_mcp(config: Config) -> bool: return before != after +def _terminate_process_after_delay(delay_seconds: float = 1.0, exit_code: int = 1) -> None: + if delay_seconds > 0: + time.sleep(delay_seconds) + logger.warning("Self-restart requested; exiting backend process with code {}", exit_code) + os._exit(exit_code) + + # ============================================================================ # Request/Response models # ============================================================================ @@ -1534,6 +1542,20 @@ def _register_routes(app: FastAPI) -> None: app.state.auth_tokens.pop(token, None) return {"ok": True} + @app.post("/api/system/restart", status_code=202) + async def restart_system( + background_tasks: BackgroundTasks, + authorization: str | None = Header(default=None), + ): + username = _require_web_user(app, authorization) + logger.warning("Restart requested by user {}", username) + background_tasks.add_task(_terminate_process_after_delay, 1.0, 1) + return { + "ok": True, + "restarting": True, + "detail": "Restart scheduled", + } + # ------ Chat ------ @app.post("/api/chat") diff --git a/app-instance/frontend/app/(app)/status/page.tsx b/app-instance/frontend/app/(app)/status/page.tsx index 854718d..b4cfc46 100644 --- a/app-instance/frontend/app/(app)/status/page.tsx +++ b/app-instance/frontend/app/(app)/status/page.tsx @@ -12,17 +12,29 @@ import { Key, Loader2, } from 'lucide-react'; -import { getStatus } from '@/lib/api'; +import { getStatus, restartSystem } from '@/lib/api'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; import type { SystemStatus } from '@/types'; export default function StatusPage() { const [status, setStatus] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [restarting, setRestarting] = useState(false); + const [restartError, setRestartError] = useState(null); const loadStatus = async () => { setLoading(true); @@ -41,6 +53,36 @@ export default function StatusPage() { loadStatus(); }, []); + useEffect(() => { + if (!restarting) { + return; + } + + const intervalId = window.setInterval(async () => { + try { + await getStatus(); + window.location.reload(); + } catch { + // Ignore failures until the container is back. + } + }, 3000); + + return () => { + window.clearInterval(intervalId); + }; + }, [restarting]); + + const handleRestart = async () => { + setRestartError(null); + try { + await restartSystem(); + setRestartDialogOpen(false); + setRestarting(true); + } catch (err: any) { + setRestartError(err.message || '重启失败'); + } + }; + if (loading) { return (
@@ -80,7 +122,7 @@ export default function StatusPage() {

系统状态

- @@ -94,17 +136,48 @@ export default function StatusPage() { 系统信息 - - - + +
+
+

重启当前实例

+

+ {restarting + ? '正在重启当前 docker,服务恢复后页面会自动刷新。' + : '会重启当前 docker 容器。重启完成后需要重新登录。'} +

+ {restartError ? ( +

{restartError}

+ ) : null} +
+ + + + + 确认重启当前实例? + + 这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。 + + + + 取消 + + {restarting ? '重启中...' : '确认 Restart'} + + + + +
diff --git a/app-instance/frontend/lib/api.ts b/app-instance/frontend/lib/api.ts index b14d68c..ab13957 100644 --- a/app-instance/frontend/lib/api.ts +++ b/app-instance/frontend/lib/api.ts @@ -529,6 +529,16 @@ export async function getStatus(): Promise { return fetchJSON('/api/status'); } +export async function restartSystem(): Promise<{ + ok: boolean; + restarting: boolean; + detail: string; +}> { + return fetchJSON('/api/system/restart', { + method: 'POST', + }); +} + // --------------------------------------------------------------------------- // Cron (proxied) // --------------------------------------------------------------------------- diff --git a/部署指南.md b/部署指南.md index 8cd9b25..e6416ae 100644 --- a/部署指南.md +++ b/部署指南.md @@ -149,6 +149,47 @@ export NANO_DEPLOY_URL='http://nano-deploy-control:8090' 如果这里不填,新用户注册时虽然页面可能能走到一半,但自动创建 `app-instance` 时大概率失败,因为实例配置里需要 `APP_INSTANCE_API_KEY`。 +`NANO_AUTHZ_URL` 和 `NANO_DEPLOY_URL` 也不能留空,而且必须带协议头。 + +正确写法: + +```text +http://nano-authz-service:19090 +http://nano-deploy-control:8090 +``` + +错误写法: + +```text +nano-authz-service:19090 +nano-deploy-control:8090 +172.19.207.13:19090 +172.19.207.13:8090 +``` + +如果这里漏了 `http://`,注册页很容易直接报: + +```text +502: Request URL is missing an 'http://' or 'https://' protocol. +``` + +还有一个很容易忽略的点: + +- 你在 shell 里重新 `export NANO_DEPLOY_URL=...` +- 不会自动修改已经在运行中的 `nano-authz-service` 和 `nano-auth-portal` + +也就是说: + +- 变量改对了 +- 但容器没重建 + +注册页还是会继续报同一个 502。 + +改完变量以后,至少要重建这些容器: + +- `nano-authz-service` +- `nano-auth-portal` + --- ## 3. 创建运行目录 @@ -271,6 +312,26 @@ http://127.0.0.1:19090 http://nano-authz-service:19090 ``` +`DEPLOY_API_BASE_URL` 也一样,不能空,不能只写 `host:port`。 + +这里如果传成空字符串,或者写成: + +```text +nano-deploy-control:8090 +``` + +注册页会在 `authz-service -> deploy-control` 这一步直接报: + +```text +502: Request URL is missing an 'http://' or 'https://' protocol. +``` + +启动完可以立刻确认: + +```bash +docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)=' +``` + --- ## 8. 启动 `deploy-control` @@ -344,6 +405,14 @@ docker run -d \ 这个页面就是用户看到的登录/注册入口。 +虽然注册入口主要依赖 `AUTHZ_API_BASE_URL`,这里还是建议把 `DEPLOY_API_BASE_URL` 一起带上并确认非空,避免后面运行态调用 deploy-control 时再踩同一个坑。 + +启动完可以确认: + +```bash +docker inspect nano-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)=' +``` + --- ## 10. 做健康检查 @@ -487,6 +556,75 @@ cd "$PROJECT_ROOT/app-instance" docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance ``` +如果注册页弹出: + +```text +502: Request URL is missing an 'http://' or 'https://' protocol. +``` + +优先查这两条: + +```bash +docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)=' +docker inspect nano-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)=' +``` + +重点看: + +- `nano-authz-service` 里的 `DEPLOY_API_BASE_URL` +- `nano-auth-portal` 里的 `AUTHZ_API_BASE_URL` + +它们都必须是完整 URL,不能是空字符串,也不能是裸 `host:port`。 + +如果你已经改过 `export NANO_DEPLOY_URL=...`,但这里查出来还是空,说明你只是改了当前 shell 变量,没有把容器重建掉。 + +这时直接按下面重建: + +```bash +export NANO_AUTHZ_URL='http://nano-authz-service:19090' +export NANO_DEPLOY_URL='http://nano-deploy-control:8090' + +export NANO_DEPLOY_TOKEN="$(docker inspect nano-deploy-control --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DEPLOY_CONTROL_API_TOKEN=//p')" +export NANO_AUTHZ_INTERNAL_TOKEN="$(docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^AUTHZ_INTERNAL_TOKEN=//p')" + +docker rm -f nano-authz-service >/dev/null 2>&1 || true +docker run -d \ + --name nano-authz-service \ + --restart unless-stopped \ + --network "$NANO_NET" \ + -p 19090:19090 \ + -v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \ + -e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \ + -e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \ + -e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \ + -e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \ + nano/authz-service:latest + +docker rm -f nano-auth-portal >/dev/null 2>&1 || true +docker run -d \ + --name nano-auth-portal \ + --restart unless-stopped \ + --network "$NANO_NET" \ + -p 3081:3081 \ + -e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \ + -e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \ + -e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \ + nano/auth-portal:latest +``` + +重建后再确认: + +```bash +docker inspect nano-authz-service --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_ISSUER|DEPLOY_API_BASE_URL)=' +docker inspect nano-auth-portal --format '{{range .Config.Env}}{{println .}}{{end}}' | egrep '^(AUTHZ_API_BASE_URL|DEPLOY_API_BASE_URL)=' +``` + +你必须看到: + +```text +DEPLOY_API_BASE_URL=http://nano-deploy-control:8090 +``` + --- ## 15. 最常见的坑 @@ -539,7 +677,28 @@ http://nano-authz-service:19090 - `APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance"` - `ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy"` -### 5. `nip.io` 解析失败 +### 5. `NANO_DEPLOY_URL` 或 `NANO_AUTHZ_URL` 没带 `http://` + +典型现象: + +- 注册页直接弹: + - `502: Request URL is missing an 'http://' or 'https://' protocol.` + +原因: + +- `authz-service` 用 `DEPLOY_API_BASE_URL` 去调 `deploy-control` +- `auth-portal` 用 `AUTHZ_API_BASE_URL` 去调 `authz-service` +- 这些值如果是空,或者写成 `nano-deploy-control:8090` 这种不带协议的字符串,请求会直接失败 +- 就算你后来在 shell 里改对了,如果没重建相关容器,老的错误值仍然会继续生效 + +正确写法: + +```text +http://nano-authz-service:19090 +http://nano-deploy-control:8090 +``` + +### 6. `nip.io` 解析失败 如果实例跳转地址打不开,先试: @@ -549,7 +708,7 @@ ping 127.0.0.1.nip.io 如果你本地网络把 `nip.io` 拦了,这套子域名测试方式就会失效。 -### 6. 端口被占用 +### 7. 端口被占用 默认会用到这些端口: