diff --git a/llama-swap.go b/llama-swap.go index 3bdcef13..cab34484 100644 --- a/llama-swap.go +++ b/llama-swap.go @@ -65,7 +65,8 @@ func main() { } // Support for watching config and reloading when it changes - reloadProxyManager := func() { + var reloadProxyManager func() + reloadProxyManager = func() { if currentPM, ok := srv.Handler.(*proxy.ProxyManager); ok { config, err = proxy.LoadConfig(*configPath) if err != nil { @@ -75,7 +76,9 @@ func main() { fmt.Println("Configuration Changed") currentPM.Shutdown() - srv.Handler = proxy.New(config) + newPM := proxy.New(config) + newPM.SetAdminControls(*configPath, *watchConfig, reloadProxyManager) + srv.Handler = newPM fmt.Println("Configuration Reloaded") // wait a few seconds and tell any UI to reload @@ -90,7 +93,9 @@ func main() { fmt.Printf("Error, unable to load configuration: %v\n", err) os.Exit(1) } - srv.Handler = proxy.New(config) + newPM := proxy.New(config) + newPM.SetAdminControls(*configPath, *watchConfig, reloadProxyManager) + srv.Handler = newPM } } diff --git a/proxy/fileutil.go b/proxy/fileutil.go new file mode 100644 index 00000000..aeefe99b --- /dev/null +++ b/proxy/fileutil.go @@ -0,0 +1,92 @@ +package proxy + +import ( + "os" + "path/filepath" +) + +// WriteFileAtomic writes data to path atomically. +// It writes to a temporary file in the same directory, fsyncs it, +// sets the desired mode, closes it, fsyncs the directory (best-effort), +// then renames over the destination. +// +// Mode preservation: +// - If the destination path already exists, its mode is preserved regardless of the provided mode. +// - If the destination does not exist and the provided mode is non-zero, that mode is used. +// - Otherwise, 0644 is used. +func WriteFileAtomic(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + + // Determine effective mode + effectiveMode := mode + if fi, err := os.Stat(path); err == nil { + effectiveMode = fi.Mode() + } else { + if effectiveMode == 0 { + effectiveMode = 0o644 + } + } + + // Create a temp file in the same directory for atomic rename + tmpFile, err := os.CreateTemp(dir, ".tmp-config-*.yaml") + if err != nil { + return err + } + + tmpName := tmpFile.Name() + cleanup := func() { + _ = tmpFile.Close() + _ = os.Remove(tmpName) + } + + // Ensure cleanup on any failure path + defer func() { + // If tmpFile still exists (e.g., rename failed), best-effort remove it + _ = os.Remove(tmpName) + }() + + // Write data + if _, err = tmpFile.Write(data); err != nil { + cleanup() + return err + } + + // Flush file contents + if err = tmpFile.Sync(); err != nil { + cleanup() + return err + } + + // Set mode + if err = tmpFile.Chmod(effectiveMode); err != nil { + cleanup() + return err + } + + // Close the temp file before rename + if err = tmpFile.Close(); err != nil { + cleanup() + return err + } + + // Best-effort fsync the directory before rename (not strictly required) + if dirFD, err := os.Open(dir); err == nil { + _ = dirFD.Sync() + _ = dirFD.Close() + } + + // Atomic rename + if err = os.Rename(tmpName, path); err != nil { + // best-effort cleanup + _ = os.Remove(tmpName) + return err + } + + // Best-effort fsync the directory after rename to strengthen durability + if dirFD, err := os.Open(dir); err == nil { + _ = dirFD.Sync() + _ = dirFD.Close() + } + + return nil +} diff --git a/proxy/proxymanager.go b/proxy/proxymanager.go index 67da3762..db59f4a4 100644 --- a/proxy/proxymanager.go +++ b/proxy/proxymanager.go @@ -42,6 +42,11 @@ type ProxyManager struct { // shutdown signaling shutdownCtx context.Context shutdownCancel context.CancelFunc + + // admin controls wiring + configPath string + reloadCallback func() + watchConfigEnabled bool } func New(config Config) *ProxyManager { @@ -631,3 +636,11 @@ func (pm *ProxyManager) findGroupByModelName(modelName string) *ProcessGroup { } return nil } + +// SetAdminControls wires admin-related controls into the ProxyManager. +// These are carried for future admin APIs and safe reload operations. +func (pm *ProxyManager) SetAdminControls(configPath string, watchEnabled bool, reload func()) { + pm.configPath = configPath + pm.watchConfigEnabled = watchEnabled + pm.reloadCallback = reload +} diff --git a/proxy/proxymanager_api.go b/proxy/proxymanager_api.go index f133e4c0..9e3014ab 100644 --- a/proxy/proxymanager_api.go +++ b/proxy/proxymanager_api.go @@ -25,6 +25,8 @@ func addApiHandlers(pm *ProxyManager) { apiGroup.POST("/models/unload", pm.apiUnloadAllModels) apiGroup.GET("/events", pm.apiSendEvents) apiGroup.GET("/metrics", pm.apiGetMetrics) + apiGroup.GET("/config", pm.apiGetConfig) + apiGroup.PUT("/config", pm.apiPutConfig) } } diff --git a/proxy/proxymanager_config_api.go b/proxy/proxymanager_config_api.go new file mode 100644 index 00000000..74cc739f --- /dev/null +++ b/proxy/proxymanager_config_api.go @@ -0,0 +1,65 @@ +package proxy + +import ( + "bytes" + "io" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/mostlygeek/llama-swap/event" +) + +// apiGetConfig returns the raw YAML configuration file contents. +func (pm *ProxyManager) apiGetConfig(c *gin.Context) { + data, err := os.ReadFile(pm.configPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Data(http.StatusOK, "text/plain; charset=utf-8", data) +} + +// apiPutConfig validates and atomically writes the configuration file. +// It triggers reload behavior based on the watchConfigEnabled setting. +func (pm *ProxyManager) apiPutConfig(c *gin.Context) { + // Read entire body as text (accept text/plain or application/x-yaml, but don't hard fail on content-type) + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"}) + return + } + + // Validate YAML using existing loader + if _, err := LoadConfigFromReader(bytes.NewReader(body)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Preserve existing file mode when possible; default to 0644 + var mode os.FileMode = 0o644 + if fi, err := os.Stat(pm.configPath); err == nil { + mode = fi.Mode() + } + + // Atomic write + if err := WriteFileAtomic(pm.configPath, body, mode); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config file"}) + return + } + + // Trigger reload based on watch behavior + if pm.watchConfigEnabled { + // Do not call reloadCallback; fsnotify watcher will emit start event and handle reload + } else { + // Emit start event then call reload callback + event.Emit(ConfigFileChangedEvent{ + ReloadingState: ReloadingStateStart, + }) + if pm.reloadCallback != nil { + pm.reloadCallback() + } + } + + c.JSON(http.StatusOK, gin.H{"msg": "ok"}) +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 12dc322b..d2d218ac 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from "react"; +import { useEffect, useCallback, type FocusEvent, type KeyboardEvent } from "react"; import { BrowserRouter as Router, Routes, Route, Navigate, NavLink } from "react-router-dom"; import { useTheme } from "./contexts/ThemeProvider"; import { useAPI } from "./contexts/APIProvider"; @@ -7,6 +7,7 @@ import ModelPage from "./pages/Models"; import ActivityPage from "./pages/Activity"; import ConnectionStatusIcon from "./components/ConnectionStatus"; import { RiSunFill, RiMoonFill } from "react-icons/ri"; +import ConfigEditor from "./pages/ConfigEditor"; function App() { const { isNarrow, toggleTheme, isDarkMode, appTitle, setAppTitle, setConnectionState } = useTheme(); @@ -22,7 +23,7 @@ function App() { // Synchronize the window.title connections state with the actual connection state useEffect(() => { setConnectionState(connectionStatus); - }, [connectionStatus]); + }, [connectionStatus, setConnectionState]); return ( @@ -34,12 +35,14 @@ function App() { contentEditable suppressContentEditableWarning className="flex items-center p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded px-1" - onBlur={(e) => handleTitleChange(e.currentTarget.textContent || "(set title)")} - onKeyDown={(e) => { + onBlur={(e: FocusEvent) => + handleTitleChange(e.currentTarget.textContent || "(set title)") + } + onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleTitleChange(e.currentTarget.textContent || "(set title)"); - e.currentTarget.blur(); + (e.currentTarget as HTMLElement).blur(); } }} > @@ -47,13 +50,28 @@ function App() { )}
- (isActive ? "navlink active" : "navlink")}> + (isActive ? "navlink active" : "navlink")} + > Logs - (isActive ? "navlink active" : "navlink")}> + (isActive ? "navlink active" : "navlink")} + > Models - (isActive ? "navlink active" : "navlink")}> + (isActive ? "navlink active" : "navlink")} + > + Config + + (isActive ? "navlink active" : "navlink")} + > Activity + + +
+ + +
+ {loading && Loading...} + {!loading && successMsg && {successMsg}} + {!loading && errorMsg && {errorMsg}} + {!loading && !successMsg && !errorMsg && ( + {isDirty ? "Unsaved changes" : "Up to date"} + )} +
+ + +
+