Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions llama-swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down
92 changes: 92 additions & 0 deletions proxy/fileutil.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions proxy/proxymanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions proxy/proxymanager_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
65 changes: 65 additions & 0 deletions proxy/proxymanager_config_api.go
Original file line number Diff line number Diff line change
@@ -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"})
}
35 changes: 27 additions & 8 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand All @@ -22,7 +23,7 @@ function App() {
// Synchronize the window.title connections state with the actual connection state
useEffect(() => {
setConnectionState(connectionStatus);
}, [connectionStatus]);
}, [connectionStatus, setConnectionState]);

return (
<Router basename="/ui/">
Expand All @@ -34,26 +35,43 @@ 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<HTMLHeadingElement>) =>
handleTitleChange(e.currentTarget.textContent || "(set title)")
}
onKeyDown={(e: KeyboardEvent<HTMLHeadingElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleTitleChange(e.currentTarget.textContent || "(set title)");
e.currentTarget.blur();
(e.currentTarget as HTMLElement).blur();
}
}}
>
{appTitle}
</h1>
)}
<div className="flex items-center space-x-4">
<NavLink to="/" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
<NavLink
to="/"
className={({ isActive }: { isActive: boolean }) => (isActive ? "navlink active" : "navlink")}
>
Logs
</NavLink>
<NavLink to="/models" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
<NavLink
to="/models"
className={({ isActive }: { isActive: boolean }) => (isActive ? "navlink active" : "navlink")}
>
Models
</NavLink>
<NavLink to="/activity" className={({ isActive }) => (isActive ? "navlink active" : "navlink")}>
<NavLink
to="/config"
className={({ isActive }: { isActive: boolean }) => (isActive ? "navlink active" : "navlink")}
>
Config
</NavLink>
<NavLink
to="/activity"
className={({ isActive }: { isActive: boolean }) => (isActive ? "navlink active" : "navlink")}
>
Activity
</NavLink>
<button className="" onClick={toggleTheme}>
Expand All @@ -68,6 +86,7 @@ function App() {
<Routes>
<Route path="/" element={<LogViewerPage />} />
<Route path="/models" element={<ModelPage />} />
<Route path="/config" element={<ConfigEditor />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
Expand Down
Loading
Loading