diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 606c8351..67b05523 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -2,6 +2,7 @@ package api import ( "bufio" + "bytes" "encoding/json" "errors" "fmt" @@ -10,7 +11,9 @@ import ( "net/http" "os" "os/exec" + "reflect" "runtime" + "sort" "strconv" "strings" "sync" @@ -431,6 +434,10 @@ func computeConfigSignature(cfg *config.Config) string { } if cfg.Tools.Web.Enabled { toolSignatures = append(toolSignatures, "web") + webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web))) + if err == nil { + parts = append(parts, "webcfg:"+string(webConfig)) + } } if cfg.Tools.WebFetch.Enabled { toolSignatures = append(toolSignatures, "web_fetch") @@ -474,9 +481,175 @@ func computeConfigSignature(cfg *config.Config) string { if len(toolSignatures) > 0 { parts = append(parts, "tools:"+strings.Join(toolSignatures, ",")) } + channelSignatures := computeChannelSignatures(cfg.Channels) + if len(channelSignatures) > 0 { + parts = append(parts, "channels:"+strings.Join(channelSignatures, ",")) + } return strings.Join(parts, ";") } +func computeChannelSignatures(channels config.ChannelsConfig) []string { + if len(channels) == 0 { + return nil + } + + keys := make([]string, 0, len(channels)) + for name := range channels { + keys = append(keys, name) + } + sort.Strings(keys) + + signatures := make([]string, 0, len(keys)) + for _, name := range keys { + channel := channels[name] + if channel == nil { + signatures = append(signatures, name+":") + continue + } + + payload := struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id,omitempty"` + GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing config.TypingConfig `json:"typing,omitempty"` + Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"` + Settings json.RawMessage `json:"settings,omitempty"` + }{ + Enabled: channel.Enabled, + Type: channel.Type, + AllowFrom: channel.AllowFrom, + ReasoningChannelID: channel.ReasoningChannelID, + GroupTrigger: channel.GroupTrigger, + Typing: channel.Typing, + Placeholder: channel.Placeholder, + Settings: normalizeChannelSettings(channel), + } + + encoded, err := json.Marshal(payload) + if err != nil { + signatures = append(signatures, name+":") + continue + } + signatures = append(signatures, name+":"+string(encoded)) + } + + return signatures +} + +func normalizeChannelSettings(channel *config.Channel) json.RawMessage { + if channel == nil { + return nil + } + + decoded, err := channel.GetDecoded() + if err == nil && decoded != nil { + normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded))) + if err == nil { + return normalized + } + } + + return normalizeRawJSON(channel.Settings) +} + +func normalizeRawJSON(raw config.RawNode) json.RawMessage { + if len(raw) == 0 { + return nil + } + + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return bytes.TrimSpace(raw) + } + + normalized, err := json.Marshal(value) + if err != nil { + return bytes.TrimSpace(raw) + } + return normalized +} + +func canonicalizeSignatureValue(value reflect.Value) any { + if !value.IsValid() { + return nil + } + + if value.CanInterface() { + switch typed := value.Interface().(type) { + case config.SecureString: + return typed.String() + case *config.SecureString: + if typed == nil { + return "" + } + return typed.String() + case config.SecureStrings: + return typed.Values() + case *config.SecureStrings: + if typed == nil { + return nil + } + return typed.Values() + } + } + + switch value.Kind() { + case reflect.Interface, reflect.Pointer: + if value.IsNil() { + return nil + } + return canonicalizeSignatureValue(value.Elem()) + case reflect.Struct: + result := make(map[string]any) + valueType := value.Type() + for i := 0; i < value.NumField(); i++ { + field := valueType.Field(i) + if field.PkgPath != "" { + continue + } + tag := field.Tag.Get("json") + name := field.Name + if tag != "" { + if comma := strings.Index(tag, ","); comma >= 0 { + tag = tag[:comma] + } + if tag == "-" { + continue + } + if tag != "" { + name = tag + } + } + result[name] = canonicalizeSignatureValue(value.Field(i)) + } + return result + case reflect.Slice, reflect.Array: + length := value.Len() + result := make([]any, 0, length) + for i := 0; i < length; i++ { + result = append(result, canonicalizeSignatureValue(value.Index(i))) + } + return result + case reflect.Map: + if value.Type().Key().Kind() != reflect.String { + return value.Interface() + } + result := make(map[string]any, value.Len()) + iter := value.MapRange() + for iter.Next() { + result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value()) + } + return result + default: + if value.CanInterface() { + return value.Interface() + } + return nil + } +} + func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool { if gatewayStatus != "running" { return false @@ -742,6 +915,11 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int // Already holding gateway.mu from caller. if changed { refreshPicoTokensLocked(h.configPath) + cfg, err = config.LoadConfig(h.configPath) + if err != nil { + return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err) + } + defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) } if err := cmd.Start(); err != nil { diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 998ed331..1d935297 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -286,6 +286,61 @@ func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T) } } +func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sleep command differs on Windows") + } + + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + delete(cfg.Channels, "pico") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd { + return exec.Command("sleep", "30") + } + + originalSignature := computeConfigSignature(cfg) + pid, err := h.startGatewayLocked("starting", 0) + if err != nil { + t.Fatalf("startGatewayLocked() error = %v", err) + } + if pid <= 0 { + t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid) + } + + gateway.mu.Lock() + cmd := gateway.cmd + bootSignature := gateway.bootConfigSignature + gateway.mu.Unlock() + t.Cleanup(func() { + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + if cmd != nil { + _ = cmd.Wait() + } + }) + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + expectedSignature := computeConfigSignature(updatedCfg) + if expectedSignature == originalSignature { + t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start") + } + if bootSignature != expectedSignature { + t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature) + } +} + func TestGatewayStartReady_NoDefaultModel(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -1108,6 +1163,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) { } } +func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegram := updatedCfg.Channels.Get("telegram") + if telegram == nil { + t.Fatalf("expected default telegram channel config") + } + telegram.Enabled = !telegram.Enabled + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + +func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) { + resetGatewayTestState(t) + + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].SetAPIKey("test-key") + cfg.Tools.Web.Enabled = true + cfg.Tools.Web.Provider = "sogou" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + process, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess() error = %v", err) + } + + bootSignature := computeConfigSignature(cfg) + gateway.mu.Lock() + gateway.cmd = &exec.Cmd{Process: process} + gateway.bootDefaultModel = cfg.ModelList[0].ModelName + gateway.bootConfigSignature = bootSignature + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + + updatedCfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + updatedCfg.Tools.Web.Provider = "duckduckgo" + if err := config.SaveConfig(configPath, updatedCfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { + return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got := body["gateway_status"]; got != "running" { + t.Fatalf("gateway_status = %#v, want %q", got, "running") + } + if got := body["gateway_restart_required"]; got != true { + t.Fatalf("gateway_restart_required = %#v, want true", got) + } +} + func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) { resetGatewayTestState(t) diff --git a/web/frontend/src/components/agent/tools/tools-page.tsx b/web/frontend/src/components/agent/tools/tools-page.tsx index 7d2d0fac..c221f911 100644 --- a/web/frontend/src/components/agent/tools/tools-page.tsx +++ b/web/frontend/src/components/agent/tools/tools-page.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useRef } from "react" import { useTranslation } from "react-i18next" + import { PageHeader } from "@/components/page-header" import { ToolLibraryTab } from "./tool-library-tab" @@ -26,6 +27,7 @@ export function ToolsPage() { isToolsLoading, isWebSearchLoading, isWebSearchSaving, + isWebSearchDirty, setActiveTab, setSearchQuery, setStatusFilter, @@ -72,6 +74,7 @@ export function ToolsPage() { isLoading={isWebSearchLoading} hasError={hasWebSearchError} isSaving={isWebSearchSaving} + isDirty={isWebSearchDirty} onSave={saveWebSearchConfig} onToggleProviderExpand={toggleExpandedProvider} onUpdateDraft={updateWebSearchDraft} diff --git a/web/frontend/src/components/agent/tools/use-tools-page.ts b/web/frontend/src/components/agent/tools/use-tools-page.ts index 07f9d50d..ecc433b0 100644 --- a/web/frontend/src/components/agent/tools/use-tools-page.ts +++ b/web/frontend/src/components/agent/tools/use-tools-page.ts @@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { + type WebSearchConfigResponse, getTools, getWebSearchConfig, setToolEnabled, updateWebSearchConfig, - type WebSearchConfigResponse, } from "@/api/tools" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types" @@ -35,24 +36,38 @@ export function useToolsPage() { queryFn: getWebSearchConfig, }) - const tools = useMemo(() => toolsQuery.data?.tools ?? [], [toolsQuery.data?.tools]) + const tools = useMemo( + () => toolsQuery.data?.tools ?? [], + [toolsQuery.data?.tools], + ) const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null + const isWebSearchDirty = useMemo(() => { + if (!webSearchDraft || !webSearchQuery.data) { + return false + } + return ( + JSON.stringify(webSearchDraft) !== JSON.stringify(webSearchQuery.data) + ) + }, [webSearchDraft, webSearchQuery.data]) const toggleToolMutation = useMutation({ mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => setToolEnabled(name, enabled), - onSuccess: (_, variables) => { - toast.success( + onSuccess: async (_, variables) => { + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, variables.enabled ? t("pages.agent.tools.enable_success", "Tool enabled successfully") : t( "pages.agent.tools.disable_success", "Tool disabled successfully", ), + t("navigation.tools", "Tools"), + gateway?.restartRequired === true, ) void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) }, onError: (error) => { toast.error( @@ -65,20 +80,23 @@ export function useToolsPage() { const saveWebSearchMutation = useMutation({ mutationFn: updateWebSearchConfig, - onSuccess: (updatedConfig) => { + onSuccess: async (updatedConfig) => { queryClient.setQueryData(["tools", "web-search-config"], updatedConfig) setWebSearchDraftOverride(null) - toast.success( + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, t( "pages.agent.tools.web_search.save_success", "Settings saved successfully", ), + t("pages.agent.tools.web_search.title", "Web Search Configuration"), + gateway?.restartRequired === true, ) void queryClient.invalidateQueries({ queryKey: ["tools", "web-search-config"], }) void queryClient.invalidateQueries({ queryKey: ["tools"] }) - void refreshGatewayState({ force: true }) }, onError: (error) => { toast.error( @@ -105,7 +123,9 @@ export function useToolsPage() { } if (normalizedSearchQuery) { - const matchesName = tool.name.toLowerCase().includes(normalizedSearchQuery) + const matchesName = tool.name + .toLowerCase() + .includes(normalizedSearchQuery) const matchesDescription = (tool.description || "") .toLowerCase() .includes(normalizedSearchQuery) @@ -177,6 +197,7 @@ export function useToolsPage() { isToolsLoading: toolsQuery.isLoading, isWebSearchLoading: webSearchQuery.isLoading, isWebSearchSaving: saveWebSearchMutation.isPending, + isWebSearchDirty, setActiveTab, setSearchQuery, setStatusFilter, diff --git a/web/frontend/src/components/agent/tools/web-search-tab.tsx b/web/frontend/src/components/agent/tools/web-search-tab.tsx index b3f9d075..866e0f27 100644 --- a/web/frontend/src/components/agent/tools/web-search-tab.tsx +++ b/web/frontend/src/components/agent/tools/web-search-tab.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next" import type { WebSearchConfigResponse } from "@/api/tools" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" @@ -15,6 +16,7 @@ interface WebSearchTabProps { isLoading: boolean hasError: boolean isSaving: boolean + isDirty: boolean onSave: () => void onToggleProviderExpand: (providerId: string) => void onUpdateDraft: WebSearchDraftUpdater @@ -27,6 +29,7 @@ export function WebSearchTab({ isLoading, hasError, isSaving, + isDirty, onSave, onToggleProviderExpand, onUpdateDraft, @@ -66,13 +69,21 @@ export function WebSearchTab({ + {isDirty && ( + + )} +
()) + const loadRequestIdRef = useRef(0) + + const resetPageState = useCallback(() => { + arrayFieldFlushersRef.current.clear() + setChannel(null) + setBaseConfig({}) + setEditConfig({}) + setConfiguredSecrets([]) + setEnabled(false) + setFetchError("") + setServerError("") + setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) + }, []) const loadData = useCallback( async (silent = false) => { + const requestId = loadRequestIdRef.current + 1 + loadRequestIdRef.current = requestId if (!silent) setLoading(true) try { const catalog = await getChannelsCatalog() + if (loadRequestIdRef.current !== requestId) return const matched = catalog.channels.find((item) => item.name === channelName) ?? null if (!matched) { - setChannel(null) - setBaseConfig({}) - setEditConfig({}) - setConfiguredSecrets([]) - setEnabled(false) + resetPageState() setFetchError( t("channels.page.notFound", { name: channelName, @@ -320,6 +335,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { } const channelConfig = await getChannelConfig(channelName) + if (loadRequestIdRef.current !== requestId) return const raw = asRecord(channelConfig.config) const normalized = normalizeConfig(matched, raw) @@ -332,18 +348,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) } catch (e) { + if (loadRequestIdRef.current !== requestId) return setConfiguredSecrets([]) setFetchError(e instanceof Error ? e.message : t("channels.loadError")) } finally { - if (!silent) setLoading(false) + if (!silent && loadRequestIdRef.current === requestId) { + setLoading(false) + } } }, - [channelName, t], + [channelName, resetPageState, t], ) useEffect(() => { + resetPageState() + setLoading(true) loadData() - }, [loadData]) + }, [loadData, resetPageState]) const previousGatewayStatusRef = useRef(gatewayState) useEffect(() => { @@ -359,6 +380,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { return isConfigured(channel, editConfig, configuredSecrets) }, [channel, configuredSecrets, editConfig]) + const isDirty = useMemo(() => { + if (loading || !channel || channel.name !== channelName) return false + const basePayload = buildSavePayload( + channel, + buildEditConfig(channel.name, baseConfig), + asBool(baseConfig.enabled), + ) + const currentPayload = buildSavePayload(channel, editConfig, enabled) + return JSON.stringify(basePayload) !== JSON.stringify(currentPayload) + }, [baseConfig, channel, channelName, editConfig, enabled, loading]) + const docsUrl = useMemo(() => { if (!channel) return "" if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" @@ -479,6 +511,13 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }, }) await loadData() + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("channels.page.saveSuccess"), + channelDisplayName, + gateway?.restartRequired === true, + ) } catch (e) { const message = e instanceof Error ? e.message : t("channels.page.saveError") @@ -674,11 +713,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {

{serverError}

)} + {isDirty && ( + + )} +
- -
diff --git a/web/frontend/src/components/config-change-notice.tsx b/web/frontend/src/components/config-change-notice.tsx new file mode 100644 index 00000000..27e5eed7 --- /dev/null +++ b/web/frontend/src/components/config-change-notice.tsx @@ -0,0 +1,48 @@ +import { + IconAlertCircle, + IconDeviceFloppy, + IconRefresh, +} from "@tabler/icons-react" + +import { cn } from "@/lib/utils" + +interface ConfigChangeNoticeProps { + kind: "save" | "restart" + title: string + description?: string + className?: string +} + +export function ConfigChangeNotice({ + kind, + title, + description, + className, +}: ConfigChangeNoticeProps) { + const Icon = + kind === "restart" + ? IconRefresh + : kind === "save" + ? IconDeviceFloppy + : IconAlertCircle + + return ( +
+ +
+

{title}

+ {description && ( +

{description}

+ )} +
+
+ ) +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index cc1a4624..0b566564 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -15,6 +15,7 @@ import { setAutoStartEnabled as updateAutoStartEnabled, setLauncherConfig as updateLauncherConfig, } from "@/api/system" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { AgentDefaultsSection, CronSection, @@ -36,6 +37,7 @@ import { import { PageHeader } from "@/components/page-header" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" export function ConfigPage() { @@ -334,8 +336,13 @@ export function ConfigPage() { queryClient.setQueryData(["system", "autostart"], status) } - toast.success(t("pages.config.save_success")) - void refreshGatewayState({ force: true }) + const gateway = await refreshGatewayState({ force: true }) + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) } catch (err) { toast.error( err instanceof Error ? err.message : t("pages.config.save_error"), @@ -433,8 +440,12 @@ export function ConfigPage() { {isDirty && (
-
- {t("pages.config.unsaved_changes")} +
+
{actionButtons}
diff --git a/web/frontend/src/components/config/raw-config-page.tsx b/web/frontend/src/components/config/raw-config-page.tsx index f8f98765..c06e5fe4 100644 --- a/web/frontend/src/components/config/raw-config-page.tsx +++ b/web/frontend/src/components/config/raw-config-page.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { launcherFetch } from "@/api/http" +import { ConfigChangeNotice } from "@/components/config-change-notice" import { PageHeader } from "@/components/page-header" import { AlertDialog, @@ -20,6 +21,7 @@ import { } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Textarea } from "@/components/ui/textarea" +import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" export function RawConfigPage() { @@ -49,7 +51,6 @@ export function RawConfigPage() { } }, onSuccess: (_, submittedConfig) => { - toast.success(t("pages.config.save_success")) try { const savedConfig = JSON.parse(submittedConfig) setLastSavedConfig(savedConfig) @@ -58,7 +59,14 @@ export function RawConfigPage() { } catch { queryClient.invalidateQueries({ queryKey: ["config"] }) } - void refreshGatewayState({ force: true }) + void refreshGatewayState({ force: true }).then((gateway) => { + showSaveSuccessOrRestartToast( + t, + t("pages.config.save_success"), + t("navigation.config"), + gateway?.restartRequired === true, + ) + }) }, onError: () => { toast.error(t("pages.config.save_error")) @@ -141,9 +149,12 @@ export function RawConfigPage() { ) : (
{isDirty && ( -
- {t("pages.config.unsaved_changes")} -
+ )}