diff --git a/app/src/api.ts b/app/src/api.ts index 11d8cb6..e5d4621 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -525,6 +525,8 @@ const realApi = { installJob: () => request<{ job: InstallJob }>("/api/wizard/job"), wizardComplete: () => request<{ status: string }>("/api/wizard/complete", { method: "POST" }), + wizardReset: () => + request("/api/wizard/reset", { method: "POST" }), drift: () => request("/api/drift"), vlans: () => diff --git a/app/src/components/system/WizardRelaunchCard.tsx b/app/src/components/system/WizardRelaunchCard.tsx new file mode 100644 index 0000000..08e70aa --- /dev/null +++ b/app/src/components/system/WizardRelaunchCard.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ListRestart } from "lucide-react"; +import { api, isDemo } from "../../api"; +import { Button, Card, ConfirmDialog, useToast } from "../ui"; + +/** + * #415: relaunch the first-run wizard on demand. Resetting the flag has no + * side effects on its own: the panel reloads and the wizard gates on + * wizardState().completed again. Steps read live probes, so an already + * configured router shows its current state (no false "not installed"). + * In demo it reuses the ?wizard=1 preview path (App.tsx). + */ +export function WizardRelaunchCard({ index = 1 }: { index?: number }) { + const { t } = useTranslation(); + const { push } = useToast(); + const [confirm, setConfirm] = useState(false); + const [busy, setBusy] = useState(false); + + const relaunch = async () => { + setConfirm(false); + setBusy(true); + try { + if (isDemo()) { + const url = new URL(window.location.href); + url.searchParams.set("wizard", "1"); + window.location.href = url.toString(); + return; + } + await api.wizardReset(); + window.location.reload(); + } catch (e) { + push({ tone: "danger", text: e instanceof Error ? e.message : t("system.wizardFailed") }); + setBusy(false); + } + }; + + return ( + +

{t("system.wizardDesc")}

+
+ +
+ setConfirm(false)} + onConfirm={relaunch} + title={t("system.wizardConfirmTitle")} + consequence={t("system.wizardConsequence")} + confirmLabel={t("system.wizardConfirmBtn")} + /> +
+ ); +} diff --git a/app/src/demo/index.ts b/app/src/demo/index.ts index a57aef1..76f3c0a 100644 --- a/app/src/demo/index.ts +++ b/app/src/demo/index.ts @@ -163,6 +163,7 @@ export const demoApi: typeof api = { wizardSetup: () => get(D.demoWizardSetup), installWizardSetup: async () => { await wait(800, 1500); return { job: { phase: "done", total: 1, done: 1, installed: ["ethtool-full"] } }; }, wizardComplete: async () => { await wait(400, 800); return { status: "ok" }; }, + wizardReset: async () => { await wait(400, 800); return { ...D.demoWizard, completed: false }; }, // núcleo board: () => get(D.demoBoard), diff --git a/app/src/locales/en.ts b/app/src/locales/en.ts index b6dcf4b..6eae424 100644 --- a/app/src/locales/en.ts +++ b/app/src/locales/en.ts @@ -361,6 +361,13 @@ export default { rebootBanner: "The router is rebooting. The page will reload when it's back.", rebootFailed: "Could not reboot", waitBackground: "Keep waiting in the background", + wizardTitle: "Setup wizard", + wizardDesc: "Run the initial setup again (mode, packages, NetPulse, guest wifi) starting from the router's current state.", + wizardRelaunch: "Run the setup wizard", + wizardConfirmTitle: "Back to the setup wizard", + wizardConsequence: "The panel will reload showing the setup wizard. Nothing changes on the router until you act on each step.", + wizardConfirmBtn: "Open the wizard", + wizardFailed: "Could not open the wizard", }, wan: { title: "Internet connection", diff --git a/app/src/locales/es.ts b/app/src/locales/es.ts index 48c242c..1394a19 100644 --- a/app/src/locales/es.ts +++ b/app/src/locales/es.ts @@ -361,6 +361,13 @@ export default { rebootBanner: "El router se está reiniciando. La página se recargará sola cuando vuelva.", rebootFailed: "No se pudo reiniciar", waitBackground: "Seguir esperando en segundo plano", + wizardTitle: "Asistente de configuración", + wizardDesc: "Vuelve a ejecutar el asistente inicial (modo, paquetes, NetPulse, wifi de invitados) partiendo del estado actual del router.", + wizardRelaunch: "Ejecutar el asistente", + wizardConfirmTitle: "Volver al asistente inicial", + wizardConsequence: "El panel se recargará mostrando el asistente. Nada cambia en el router hasta que actúes en cada paso.", + wizardConfirmBtn: "Abrir el asistente", + wizardFailed: "No se pudo abrir el asistente", }, wan: { title: "Conexión a Internet", diff --git a/app/src/pages/System.tsx b/app/src/pages/System.tsx index 128d1be..56c7621 100644 --- a/app/src/pages/System.tsx +++ b/app/src/pages/System.tsx @@ -12,6 +12,7 @@ import { MQTTCard } from "../components/system/MQTTCard"; import { ConfigBackupCard } from "../components/ConfigBackupCard"; import { UpdateCard } from "../components/system/UpdateCard"; import { SelfUpdateCard } from "../components/system/SelfUpdateCard"; +import { WizardRelaunchCard } from "../components/system/WizardRelaunchCard"; import { OptionsCard } from "../components/system/OptionsCard"; import { NetPulseStandaloneBanner, NetPulseStatusChip } from "../components/system/NetPulseStatus"; import { useTranslation } from "react-i18next"; @@ -60,7 +61,8 @@ export function System({ board, update, onUpdateChange, onLogout }: { {/* Mantenimiento (#155): actualización de la imagen OpenWrt. La rama de paquetes se oculta por diseño: la paquetería se gestiona desde - LuCI o CLI (`apk upgrade`), no desde esta tarjeta. */} + LuCI o CLI (`apk upgrade`), no desde esta tarjeta. #415: relanzar + el asistente inicial bajo demanda. */}
{t("system.groupMaintenance")} +
{/* Opciones (#158): preferencias de la interfaz (idioma, densidad, diff --git a/internal/modules/wizard.go b/internal/modules/wizard.go index 2f50d94..49a20ce 100644 --- a/internal/modules/wizard.go +++ b/internal/modules/wizard.go @@ -20,25 +20,43 @@ func ProbeWizard() *WizardState { return &WizardState{Completed: completed, Mode: mode} } -func CompleteWizard() error { - if !uciSectionExists("netgrip.wizard") { - if !uciSectionExists("netgrip.main") { - cmd := exec.Command("uci", "import", "netgrip") - cmd.Stdin = strings.NewReader("config panel 'main'\n\toption panel 'panel'\nconfig wizard 'wizard'\n\toption completed '0'\n") - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("init netgrip wizard config: %s", strings.TrimSpace(string(out))) - } - } else { - cmd := exec.Command("uci", "import", "netgrip") - cmd.Stdin = strings.NewReader("config wizard 'wizard'\n\toption completed '0'\n") - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("add wizard section: %s", strings.TrimSpace(string(out))) - } +func ensureWizardSection() error { + if uciSectionExists("netgrip.wizard") { + return nil + } + if !uciSectionExists("netgrip.main") { + cmd := exec.Command("uci", "import", "netgrip") + cmd.Stdin = strings.NewReader("config panel 'main'\n\toption panel 'panel'\nconfig wizard 'wizard'\n\toption completed '0'\n") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("init netgrip wizard config: %s", strings.TrimSpace(string(out))) } + return nil + } + cmd := exec.Command("uci", "import", "netgrip") + cmd.Stdin = strings.NewReader("config wizard 'wizard'\n\toption completed '0'\n") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("add wizard section: %s", strings.TrimSpace(string(out))) + } + return nil +} + +func setWizardCompleted(v string) error { + if err := ensureWizardSection(); err != nil { + return err } - out, err := exec.Command("uci", "set", "netgrip.wizard.completed=1").CombinedOutput() + out, err := exec.Command("uci", "set", "netgrip.wizard.completed="+v).CombinedOutput() if err != nil { return fmt.Errorf("set wizard completed: %s", strings.TrimSpace(string(out))) } return exec.Command("uci", "commit", "netgrip").Run() } + +func CompleteWizard() error { + return setWizardCompleted("1") +} + +// ResetWizard marks the first-run wizard as pending again so it can be +// relaunched from the panel (#415). No other setting is touched. +func ResetWizard() error { + return setWizardCompleted("0") +} diff --git a/internal/server/server.go b/internal/server/server.go index 0691b08..deecb85 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -211,6 +211,7 @@ func New(rpcdURL, version string) *Server { s.mux.HandleFunc("POST /api/wizard/packages", s.requireAuth(s.handleWizardPackages)) s.mux.HandleFunc("GET /api/wizard/job", s.requireAuth(s.handleInstallJobGet)) s.mux.HandleFunc("POST /api/wizard/complete", s.requireAuth(s.handleWizardComplete)) + s.mux.HandleFunc("POST /api/wizard/reset", s.requireAuth(s.handleWizardReset)) s.mux.HandleFunc("GET /api/drift", s.requireAuth(s.handleDriftGet)) s.mux.HandleFunc("GET /api/telegram", s.requireAuth(s.handleTelegramGet)) s.mux.HandleFunc("POST /api/telegram", s.requireAuth(s.handleTelegramSet)) @@ -1934,6 +1935,14 @@ func (s *Server) handleWizardComplete(w http.ResponseWriter, _ *http.Request) { writeJSON(w, map[string]string{"status": "completed"}) } +func (s *Server) handleWizardReset(w http.ResponseWriter, _ *http.Request) { + if err := modules.ResetWizard(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, modules.ProbeWizard()) +} + func (s *Server) handleDriftGet(w http.ResponseWriter, _ *http.Request) { probe, _ := modules.CachedRead("drift", probeCacheTTL, func() (*modules.DriftProbe, error) { return modules.ProbeDrift(), nil