/ web / src / contexts / SystemActions.tsx
SystemActions.tsx
  1  import { useCallback, useEffect, useState } from "react";
  2  import { api } from "@/lib/api";
  3  import type { ActionStatusResponse } from "@/lib/api";
  4  import { Toast } from "@/components/Toast";
  5  import { useI18n } from "@/i18n";
  6  import {
  7    SystemActionsContext,
  8    type SystemAction,
  9  } from "./system-actions-context";
 10  
 11  const ACTION_NAMES: Record<SystemAction, string> = {
 12    restart: "gateway-restart",
 13    update: "hermes-update",
 14  };
 15  
 16  export function SystemActionsProvider({
 17    children,
 18  }: {
 19    children: React.ReactNode;
 20  }) {
 21    const [pendingAction, setPendingAction] = useState<SystemAction | null>(null);
 22    const [activeAction, setActiveAction] = useState<SystemAction | null>(null);
 23    const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
 24      null,
 25    );
 26    const [toast, setToast] = useState<ToastState | null>(null);
 27    const { t } = useI18n();
 28  
 29    useEffect(() => {
 30      if (!toast) return;
 31      const timer = setTimeout(() => setToast(null), 4000);
 32      return () => clearTimeout(timer);
 33    }, [toast]);
 34  
 35    useEffect(() => {
 36      if (!activeAction) return;
 37      const name = ACTION_NAMES[activeAction];
 38      let cancelled = false;
 39  
 40      const poll = async () => {
 41        try {
 42          const resp = await api.getActionStatus(name);
 43          if (cancelled) return;
 44          setActionStatus(resp);
 45          if (!resp.running) {
 46            const ok = resp.exit_code === 0;
 47            setToast({
 48              type: ok ? "success" : "error",
 49              message: ok
 50                ? t.status.actionFinished
 51                : `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
 52            });
 53            return;
 54          }
 55        } catch {
 56          // transient fetch error; keep polling
 57        }
 58        if (!cancelled) setTimeout(poll, 1500);
 59      };
 60  
 61      poll();
 62      return () => {
 63        cancelled = true;
 64      };
 65    }, [activeAction, t.status.actionFinished, t.status.actionFailed]);
 66  
 67    const runAction = useCallback(
 68      async (action: SystemAction) => {
 69        setPendingAction(action);
 70        setActionStatus(null);
 71        try {
 72          if (action === "restart") {
 73            await api.restartGateway();
 74          } else {
 75            await api.updateHermes();
 76          }
 77          setActiveAction(action);
 78        } catch (err) {
 79          const detail = err instanceof Error ? err.message : String(err);
 80          setToast({
 81            type: "error",
 82            message: `${t.status.actionFailed}: ${detail}`,
 83          });
 84        } finally {
 85          setPendingAction(null);
 86        }
 87      },
 88      [t.status.actionFailed],
 89    );
 90  
 91    const dismissLog = useCallback(() => {
 92      setActiveAction(null);
 93      setActionStatus(null);
 94    }, []);
 95  
 96    const isRunning = activeAction !== null && actionStatus?.running !== false;
 97    const isBusy = pendingAction !== null || isRunning;
 98  
 99    return (
100      <SystemActionsContext.Provider
101        value={{
102          actionStatus,
103          activeAction,
104          dismissLog,
105          isBusy,
106          isRunning,
107          pendingAction,
108          runAction,
109        }}
110      >
111        {children}
112        <Toast toast={toast} />
113      </SystemActionsContext.Provider>
114    );
115  }
116  
117  interface ToastState {
118    message: string;
119    type: "success" | "error";
120  }