瀏覽代碼

Add remote API restart capability with UI button and confirmation dialog

- Add POST /restart endpoint to app.controller for graceful service shutdown
- Pass NestJS app instance from main.ts to controller for restart functionality
- Add restart button to ApiHealth widget in dashboard with ArrowPathIcon
- Implement confirmation dialog with guarded restart action
- Toast notifications for restart success/failure
- Auto-refetch health status after restart to detect when service is back online
Timothy Pomeroy 3 周之前
父節點
當前提交
6608bcb8bb
共有 3 個文件被更改,包括 106 次插入2 次删除
  1. 37 0
      apps/service/src/app.controller.ts
  2. 5 0
      apps/service/src/main.ts
  3. 64 2
      apps/web/src/app/components/ApiHealth.tsx

+ 37 - 0
apps/service/src/app.controller.ts

@@ -7,7 +7,9 @@ import {
   Post,
   Put,
   Query,
+  Inject,
 } from '@nestjs/common';
+import { INestApplication } from '@nestjs/common';
 import * as fs from 'fs';
 import * as path from 'path';
 import { AppService } from './app.service';
@@ -22,11 +24,21 @@ interface FileRecord {
 
 @Controller()
 export class AppController {
+  private app: INestApplication | null = null;
+
   constructor(
     private readonly appService: AppService,
     private readonly eventsGateway: EventsGateway,
   ) {}
 
+  /**
+   * Set the NestJS application instance for graceful shutdown
+   * This is called from main.ts after app creation
+   */
+  setApp(app: INestApplication) {
+    this.app = app;
+  }
+
   // List available datasets
   @Get('files')
   listDatasets() {
@@ -71,6 +83,31 @@ export class AppController {
     return { status: 'healthy', datetime: new Date().toISOString() };
   }
 
+  @Post('restart')
+  async restart() {
+    if (!this.app) {
+      return { success: false, message: 'Application not properly initialized' };
+    }
+
+    // Schedule restart for next tick to allow response to complete
+    setImmediate(async () => {
+      try {
+        await this.app!.close();
+        // Exit with code 0 to let process manager restart us
+        process.exit(0);
+      } catch (error) {
+        console.error('Error during graceful restart:', error);
+        process.exit(1);
+      }
+    });
+
+    return {
+      success: true,
+      message: 'API service restarting...',
+      datetime: new Date().toISOString(),
+    };
+  }
+
   // --- Unified files CRUD endpoints below ---
 
   // Create a file record

+ 5 - 0
apps/service/src/main.ts

@@ -1,5 +1,6 @@
 import { NestFactory } from '@nestjs/core';
 import { AppModule } from './app.module';
+import { AppController } from './app.controller';
 
 async function bootstrap() {
   const app = await NestFactory.create(AppModule);
@@ -7,6 +8,10 @@ async function bootstrap() {
   // Enable CORS for WebSocket connections
   app.enableCors();
 
+  // Set app instance on controller for restart functionality
+  const appController = app.get(AppController);
+  appController.setApp(app);
+
   const port = process.env.PORT ? Number(process.env.PORT) : 3001;
   console.log(`Starting API service with WebSocket support on port ${port}`);
   await app.listen(port);

+ 64 - 2
apps/web/src/app/components/ApiHealth.tsx

@@ -1,10 +1,14 @@
 "use client";
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
-import { get } from "../../lib/api";
+import { get, post } from "../../lib/api";
+import { ArrowPathIcon } from "@heroicons/react/24/outline";
+import toast from "react-hot-toast";
 
 export default function ApiHealth() {
   const [responseTime, setResponseTime] = useState<number | null>(null);
+  const [showRestartConfirm, setShowRestartConfirm] = useState(false);
+  const queryClient = useQueryClient();
 
   const { data, isLoading, error } = useQuery({
     queryKey: ["api", "health"],
@@ -18,6 +22,21 @@ export default function ApiHealth() {
     refetchInterval: 30000 // Check every 30 seconds
   });
 
+  const restartMutation = useMutation({
+    mutationFn: () => post("/restart", {}),
+    onSuccess: () => {
+      toast.success("API service restarting...");
+      setShowRestartConfirm(false);
+      // Retry health check after a delay to see if service is back
+      setTimeout(() => {
+        queryClient.invalidateQueries({ queryKey: ["api", "health"] });
+      }, 3000);
+    },
+    onError: (error: any) => {
+      toast.error("Failed to restart API service");
+    }
+  });
+
   const isHealthy = data?.status === "healthy";
   const lastChecked = data?.datetime
     ? new Date(data.datetime).toLocaleTimeString()
@@ -41,6 +60,18 @@ export default function ApiHealth() {
               {isHealthy && !error ? "Healthy" : "Unhealthy"}
             </span>
           )}
+          <button
+            onClick={() => setShowRestartConfirm(true)}
+            disabled={restartMutation.isPending}
+            className="ml-2 p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+            title="Restart API service"
+          >
+            <ArrowPathIcon
+              className={`w-4 h-4 ${
+                restartMutation.isPending ? "animate-spin" : ""
+              }`}
+            />
+          </button>
         </div>
       </div>
 
@@ -90,6 +121,37 @@ export default function ApiHealth() {
           </div>
         )}
       </div>
+
+      {/* Restart Confirmation Dialog */}
+      {showRestartConfirm && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
+          <div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 max-w-sm mx-4">
+            <h2 className="text-lg font-semibold mb-2">Restart API Service?</h2>
+            <p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
+              This will gracefully restart the API service. The service will be temporarily unavailable.
+            </p>
+            <div className="flex justify-end gap-3">
+              <button
+                onClick={() => setShowRestartConfirm(false)}
+                disabled={restartMutation.isPending}
+                className="px-4 py-2 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50 transition-colors"
+              >
+                Cancel
+              </button>
+              <button
+                onClick={() => restartMutation.mutate()}
+                disabled={restartMutation.isPending}
+                className="px-4 py-2 rounded bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white disabled:opacity-50 transition-colors flex items-center gap-2"
+              >
+                {restartMutation.isPending && (
+                  <ArrowPathIcon className="w-4 h-4 animate-spin" />
+                )}
+                Restart
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }