Browse Source

Add editable configuration UI for orly-launcher (v0.56.0)

- Add JSON config file persistence (~/.config/orly/launcher.json)
- Implement POST /api/config endpoint for saving configuration
- Make Config.svelte fully editable with edit/save/cancel buttons
- Add config file loading with env var override support
- Fix embedded web UI serving (direct file serving instead of http.FileServer)
- Add restart button after config save for applying changes

Files modified:
- cmd/orly-launcher/config.go: Add ConfigFile type, load/save functions
- cmd/orly-launcher/server.go: Implement handleSetConfig endpoint
- cmd/orly-launcher/web.go: Fix file serving without redirects
- cmd/orly-launcher/web/src/api.js: Add saveConfig function
- cmd/orly-launcher/web/src/pages/Config.svelte: Full edit mode support
- pkg/version/version: Bump to v0.56.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.56.0
woikos 4 months ago
parent
commit
14b691998f
No known key found for this signature in database
  1. 6
      .gitea/workflows/go.yml
  2. 197
      cmd/orly-launcher/config.go
  3. 127
      cmd/orly-launcher/server.go
  4. 67
      cmd/orly-launcher/web.go
  5. 2
      cmd/orly-launcher/web/dist/bundle.css
  6. 20
      cmd/orly-launcher/web/dist/bundle.js
  7. 18
      cmd/orly-launcher/web/src/api.js
  8. 404
      cmd/orly-launcher/web/src/pages/Config.svelte
  9. 28
      cmd/orly-launcher/web/src/pages/Update.svelte
  10. 2
      pkg/version/version

6
.gitea/workflows/go.yml

@ -36,6 +36,12 @@ jobs:
echo "Cloned successfully. Last commit:" echo "Cloned successfully. Last commit:"
git log -1 git log -1
- name: Install dependencies
run: |
set -e
echo "Installing jq..."
sudo apt-get update && sudo apt-get install -y jq
- name: Set up Go - name: Set up Go
run: | run: |
set -e set -e

197
cmd/orly-launcher/config.go

@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -10,6 +11,102 @@ import (
"github.com/adrg/xdg" "github.com/adrg/xdg"
) )
// ConfigFile is the JSON structure for persistent configuration.
type ConfigFile struct {
DBBackend string `json:"db_backend,omitempty"`
DBBinary string `json:"db_binary,omitempty"`
RelayBinary string `json:"relay_binary,omitempty"`
ACLBinary string `json:"acl_binary,omitempty"`
DBListen string `json:"db_listen,omitempty"`
ACLListen string `json:"acl_listen,omitempty"`
ACLEnabled *bool `json:"acl_enabled,omitempty"`
ACLMode string `json:"acl_mode,omitempty"`
DataDir string `json:"data_dir,omitempty"`
LogLevel string `json:"log_level,omitempty"`
AdminPort *int `json:"admin_port,omitempty"`
AdminOwners []string `json:"admin_owners,omitempty"`
BinDir string `json:"bin_dir,omitempty"`
RelayPort *int `json:"relay_port,omitempty"`
RelayHost string `json:"relay_host,omitempty"`
TLSDomains string `json:"tls_domains,omitempty"`
AuthToWrite *bool `json:"auth_to_write,omitempty"`
AuthRequired *bool `json:"auth_required,omitempty"`
// Sync services
DistributedSyncEnabled *bool `json:"distributed_sync_enabled,omitempty"`
ClusterSyncEnabled *bool `json:"cluster_sync_enabled,omitempty"`
RelayGroupEnabled *bool `json:"relay_group_enabled,omitempty"`
NegentropyEnabled *bool `json:"negentropy_enabled,omitempty"`
NegentropyBinary string `json:"negentropy_binary,omitempty"`
NegentropyListen string `json:"negentropy_listen,omitempty"`
}
// configFilePath returns the path to the config file.
func configFilePath() string {
return filepath.Join(xdg.ConfigHome, "orly", "launcher.json")
}
// loadConfigFile loads configuration from the JSON file if it exists.
func loadConfigFile() (*ConfigFile, error) {
path := configFilePath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &ConfigFile{}, nil
}
return nil, err
}
var cf ConfigFile
if err := json.Unmarshal(data, &cf); err != nil {
return nil, err
}
return &cf, nil
}
// SaveConfigFile saves the configuration to the JSON file.
func SaveConfigFile(cf *ConfigFile) error {
path := configFilePath()
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(cf, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// ConfigToFile converts a Config to a ConfigFile for persistence.
func ConfigToFile(cfg *Config) *ConfigFile {
return &ConfigFile{
DBBackend: cfg.DBBackend,
DBBinary: cfg.DBBinary,
RelayBinary: cfg.RelayBinary,
ACLBinary: cfg.ACLBinary,
DBListen: cfg.DBListen,
ACLListen: cfg.ACLListen,
ACLEnabled: &cfg.ACLEnabled,
ACLMode: cfg.ACLMode,
DataDir: cfg.DataDir,
LogLevel: cfg.LogLevel,
AdminPort: &cfg.AdminPort,
AdminOwners: cfg.AdminOwners,
BinDir: cfg.BinDir,
DistributedSyncEnabled: &cfg.DistributedSyncEnabled,
ClusterSyncEnabled: &cfg.ClusterSyncEnabled,
RelayGroupEnabled: &cfg.RelayGroupEnabled,
NegentropyEnabled: &cfg.NegentropyEnabled,
NegentropyBinary: cfg.NegentropyBinary,
NegentropyListen: cfg.NegentropyListen,
}
}
// Config holds the launcher configuration. // Config holds the launcher configuration.
type Config struct { type Config struct {
// DBBackend is the database backend: badger or neo4j // DBBackend is the database backend: badger or neo4j
@ -97,61 +194,117 @@ type Config struct {
} }
func loadConfig() (*Config, error) { func loadConfig() (*Config, error) {
// Get backend and mode first to compute default binary names // Load config file first (provides defaults)
dbBackend := getEnvOrDefault("ORLY_LAUNCHER_DB_BACKEND", "badger") cf, err := loadConfigFile()
aclMode := getEnvOrDefault("ORLY_ACL_MODE", "follows") if err != nil {
// Log but don't fail - env vars are still valid
cf = &ConfigFile{}
}
// Get backend and mode - file first, then env
dbBackend := stringOr(cf.DBBackend, getEnvOrDefault("ORLY_LAUNCHER_DB_BACKEND", "badger"))
aclMode := stringOr(cf.ACLMode, getEnvOrDefault("ORLY_ACL_MODE", "follows"))
// Compute default binary names based on backend/mode // Compute default binary names based on backend/mode
defaultDBBinary := "orly-db-" + dbBackend defaultDBBinary := "orly-db-" + dbBackend
defaultACLBinary := "orly-acl-" + aclMode defaultACLBinary := "orly-acl-" + aclMode
// Parse admin owners (comma-separated hex pubkeys) // Parse admin owners - env takes precedence, then file
adminOwners := parseOwnersList(getEnvOrDefault("ORLY_LAUNCHER_OWNERS", "")) envOwners := getEnvOrDefault("ORLY_LAUNCHER_OWNERS", "")
var adminOwners []string
if envOwners != "" {
adminOwners = parseOwnersList(envOwners)
} else if len(cf.AdminOwners) > 0 {
adminOwners = cf.AdminOwners
}
cfg := &Config{ cfg := &Config{
DBBackend: dbBackend, DBBackend: dbBackend,
DBBinary: getEnvOrDefault("ORLY_LAUNCHER_DB_BINARY", defaultDBBinary), DBBinary: envOrFileOrDefault("ORLY_LAUNCHER_DB_BINARY", cf.DBBinary, defaultDBBinary),
RelayBinary: getEnvOrDefault("ORLY_LAUNCHER_RELAY_BINARY", "orly"), RelayBinary: envOrFileOrDefault("ORLY_LAUNCHER_RELAY_BINARY", cf.RelayBinary, "orly"),
ACLBinary: getEnvOrDefault("ORLY_LAUNCHER_ACL_BINARY", defaultACLBinary), ACLBinary: envOrFileOrDefault("ORLY_LAUNCHER_ACL_BINARY", cf.ACLBinary, defaultACLBinary),
DBListen: getEnvOrDefault("ORLY_LAUNCHER_DB_LISTEN", "127.0.0.1:50051"), DBListen: envOrFileOrDefault("ORLY_LAUNCHER_DB_LISTEN", cf.DBListen, "127.0.0.1:50051"),
ACLListen: getEnvOrDefault("ORLY_LAUNCHER_ACL_LISTEN", "127.0.0.1:50052"), ACLListen: envOrFileOrDefault("ORLY_LAUNCHER_ACL_LISTEN", cf.ACLListen, "127.0.0.1:50052"),
ACLEnabled: getEnvOrDefault("ORLY_LAUNCHER_ACL_ENABLED", "false") == "true", ACLEnabled: boolEnvOrFile("ORLY_LAUNCHER_ACL_ENABLED", cf.ACLEnabled, false),
ACLMode: aclMode, ACLMode: aclMode,
DBReadyTimeout: parseDuration("ORLY_LAUNCHER_DB_READY_TIMEOUT", 30*time.Second), DBReadyTimeout: parseDuration("ORLY_LAUNCHER_DB_READY_TIMEOUT", 30*time.Second),
ACLReadyTimeout: parseDuration("ORLY_LAUNCHER_ACL_READY_TIMEOUT", 120*time.Second), ACLReadyTimeout: parseDuration("ORLY_LAUNCHER_ACL_READY_TIMEOUT", 120*time.Second),
StopTimeout: parseDuration("ORLY_LAUNCHER_STOP_TIMEOUT", 30*time.Second), // Increased for DB flush StopTimeout: parseDuration("ORLY_LAUNCHER_STOP_TIMEOUT", 30*time.Second),
DataDir: getEnvOrDefault("ORLY_DATA_DIR", filepath.Join(xdg.DataHome, "ORLY")), DataDir: envOrFileOrDefault("ORLY_DATA_DIR", cf.DataDir, filepath.Join(xdg.DataHome, "ORLY")),
LogLevel: getEnvOrDefault("ORLY_LOG_LEVEL", "info"), LogLevel: envOrFileOrDefault("ORLY_LOG_LEVEL", cf.LogLevel, "info"),
// Sync services configuration // Sync services configuration
DistributedSyncEnabled: getEnvOrDefault("ORLY_LAUNCHER_SYNC_DISTRIBUTED_ENABLED", "false") == "true", DistributedSyncEnabled: boolEnvOrFile("ORLY_LAUNCHER_SYNC_DISTRIBUTED_ENABLED", cf.DistributedSyncEnabled, false),
DistributedSyncBinary: getEnvOrDefault("ORLY_LAUNCHER_SYNC_DISTRIBUTED_BINARY", "orly-sync-distributed"), DistributedSyncBinary: getEnvOrDefault("ORLY_LAUNCHER_SYNC_DISTRIBUTED_BINARY", "orly-sync-distributed"),
DistributedSyncListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_DISTRIBUTED_LISTEN", "127.0.0.1:50061"), DistributedSyncListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_DISTRIBUTED_LISTEN", "127.0.0.1:50061"),
ClusterSyncEnabled: getEnvOrDefault("ORLY_LAUNCHER_SYNC_CLUSTER_ENABLED", "false") == "true", ClusterSyncEnabled: boolEnvOrFile("ORLY_LAUNCHER_SYNC_CLUSTER_ENABLED", cf.ClusterSyncEnabled, false),
ClusterSyncBinary: getEnvOrDefault("ORLY_LAUNCHER_SYNC_CLUSTER_BINARY", "orly-sync-cluster"), ClusterSyncBinary: getEnvOrDefault("ORLY_LAUNCHER_SYNC_CLUSTER_BINARY", "orly-sync-cluster"),
ClusterSyncListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_CLUSTER_LISTEN", "127.0.0.1:50062"), ClusterSyncListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_CLUSTER_LISTEN", "127.0.0.1:50062"),
RelayGroupEnabled: getEnvOrDefault("ORLY_LAUNCHER_SYNC_RELAYGROUP_ENABLED", "false") == "true", RelayGroupEnabled: boolEnvOrFile("ORLY_LAUNCHER_SYNC_RELAYGROUP_ENABLED", cf.RelayGroupEnabled, false),
RelayGroupBinary: getEnvOrDefault("ORLY_LAUNCHER_SYNC_RELAYGROUP_BINARY", "orly-sync-relaygroup"), RelayGroupBinary: getEnvOrDefault("ORLY_LAUNCHER_SYNC_RELAYGROUP_BINARY", "orly-sync-relaygroup"),
RelayGroupListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_RELAYGROUP_LISTEN", "127.0.0.1:50063"), RelayGroupListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_RELAYGROUP_LISTEN", "127.0.0.1:50063"),
NegentropyEnabled: getEnvOrDefault("ORLY_LAUNCHER_SYNC_NEGENTROPY_ENABLED", "false") == "true", NegentropyEnabled: boolEnvOrFile("ORLY_LAUNCHER_SYNC_NEGENTROPY_ENABLED", cf.NegentropyEnabled, false),
NegentropyBinary: getEnvOrDefault("ORLY_LAUNCHER_SYNC_NEGENTROPY_BINARY", "orly-sync-negentropy"), NegentropyBinary: envOrFileOrDefault("ORLY_LAUNCHER_SYNC_NEGENTROPY_BINARY", cf.NegentropyBinary, "orly-sync-negentropy"),
NegentropyListen: getEnvOrDefault("ORLY_LAUNCHER_SYNC_NEGENTROPY_LISTEN", "127.0.0.1:50064"), NegentropyListen: envOrFileOrDefault("ORLY_LAUNCHER_SYNC_NEGENTROPY_LISTEN", cf.NegentropyListen, "127.0.0.1:50064"),
SyncReadyTimeout: parseDuration("ORLY_LAUNCHER_SYNC_READY_TIMEOUT", 30*time.Second), SyncReadyTimeout: parseDuration("ORLY_LAUNCHER_SYNC_READY_TIMEOUT", 30*time.Second),
// Admin UI configuration // Admin UI configuration
AdminEnabled: getEnvOrDefault("ORLY_LAUNCHER_ADMIN_ENABLED", "true") == "true", AdminEnabled: getEnvOrDefault("ORLY_LAUNCHER_ADMIN_ENABLED", "true") == "true",
AdminPort: parseInt("ORLY_LAUNCHER_ADMIN_PORT", 8080), AdminPort: intEnvOrFile("ORLY_LAUNCHER_ADMIN_PORT", cf.AdminPort, 8080),
AdminOwners: adminOwners, AdminOwners: adminOwners,
BinDir: getEnvOrDefault("ORLY_LAUNCHER_BIN_DIR", filepath.Join(xdg.DataHome, "orly", "bin")), BinDir: envOrFileOrDefault("ORLY_LAUNCHER_BIN_DIR", cf.BinDir, filepath.Join(xdg.DataHome, "orly", "bin")),
} }
return cfg, nil return cfg, nil
} }
// stringOr returns the first non-empty string.
func stringOr(a, b string) string {
if a != "" {
return a
}
return b
}
// envOrFileOrDefault returns env var if set, then file value if set, then default.
func envOrFileOrDefault(envKey, fileValue, defaultValue string) string {
if v := os.Getenv(envKey); v != "" {
return v
}
if fileValue != "" {
return fileValue
}
return defaultValue
}
// boolEnvOrFile returns env var if set, then file value if set, then default.
func boolEnvOrFile(envKey string, fileValue *bool, defaultValue bool) bool {
if v := os.Getenv(envKey); v != "" {
return v == "true"
}
if fileValue != nil {
return *fileValue
}
return defaultValue
}
// intEnvOrFile returns env var if set, then file value if set, then default.
func intEnvOrFile(envKey string, fileValue *int, defaultValue int) int {
if v := os.Getenv(envKey); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
if fileValue != nil {
return *fileValue
}
return defaultValue
}
func parseOwnersList(s string) []string { func parseOwnersList(s string) []string {
if s == "" { if s == "" {
return nil return nil

127
cmd/orly-launcher/server.go

@ -158,9 +158,132 @@ func (s *AdminServer) handleGetConfig(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
// SetConfigRequest is the request body for POST /api/config
type SetConfigRequest struct {
DBBackend string `json:"db_backend,omitempty"`
DBBinary string `json:"db_binary,omitempty"`
RelayBinary string `json:"relay_binary,omitempty"`
ACLBinary string `json:"acl_binary,omitempty"`
DBListen string `json:"db_listen,omitempty"`
ACLListen string `json:"acl_listen,omitempty"`
ACLEnabled *bool `json:"acl_enabled,omitempty"`
ACLMode string `json:"acl_mode,omitempty"`
DataDir string `json:"data_dir,omitempty"`
LogLevel string `json:"log_level,omitempty"`
AdminPort *int `json:"admin_port,omitempty"`
AdminOwners []string `json:"admin_owners,omitempty"`
BinDir string `json:"bin_dir,omitempty"`
DistributedSyncEnabled *bool `json:"distributed_sync_enabled,omitempty"`
ClusterSyncEnabled *bool `json:"cluster_sync_enabled,omitempty"`
RelayGroupEnabled *bool `json:"relay_group_enabled,omitempty"`
NegentropyEnabled *bool `json:"negentropy_enabled,omitempty"`
}
// SetConfigResponse is the response for POST /api/config
type SetConfigResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
RestartNeeded bool `json:"restart_needed"`
ConfigFilePath string `json:"config_file_path"`
}
func (s *AdminServer) handleSetConfig(w http.ResponseWriter, r *http.Request) { func (s *AdminServer) handleSetConfig(w http.ResponseWriter, r *http.Request) {
// TODO: Implement config update (requires restart) var req SetConfigRequest
http.Error(w, "Config update not implemented yet", http.StatusNotImplemented) if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Load existing config file or create new
cf, err := loadConfigFile()
if chk.E(err) {
cf = &ConfigFile{}
}
// Update only fields that were provided
if req.DBBackend != "" {
cf.DBBackend = req.DBBackend
}
if req.DBBinary != "" {
cf.DBBinary = req.DBBinary
}
if req.RelayBinary != "" {
cf.RelayBinary = req.RelayBinary
}
if req.ACLBinary != "" {
cf.ACLBinary = req.ACLBinary
}
if req.DBListen != "" {
cf.DBListen = req.DBListen
}
if req.ACLListen != "" {
cf.ACLListen = req.ACLListen
}
if req.ACLEnabled != nil {
cf.ACLEnabled = req.ACLEnabled
}
if req.ACLMode != "" {
cf.ACLMode = req.ACLMode
}
if req.DataDir != "" {
cf.DataDir = req.DataDir
}
if req.LogLevel != "" {
cf.LogLevel = req.LogLevel
}
if req.AdminPort != nil {
cf.AdminPort = req.AdminPort
}
if req.AdminOwners != nil {
cf.AdminOwners = req.AdminOwners
}
if req.BinDir != "" {
cf.BinDir = req.BinDir
}
if req.DistributedSyncEnabled != nil {
cf.DistributedSyncEnabled = req.DistributedSyncEnabled
}
if req.ClusterSyncEnabled != nil {
cf.ClusterSyncEnabled = req.ClusterSyncEnabled
}
if req.RelayGroupEnabled != nil {
cf.RelayGroupEnabled = req.RelayGroupEnabled
}
if req.NegentropyEnabled != nil {
cf.NegentropyEnabled = req.NegentropyEnabled
}
// Save to file
if err := SaveConfigFile(cf); chk.E(err) {
response := SetConfigResponse{
Success: false,
Message: fmt.Sprintf("Failed to save config: %v", err),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(response)
return
}
// Update auth middleware if owners changed
if req.AdminOwners != nil {
for _, owner := range s.auth.Owners() {
s.auth.RemoveOwner(owner)
}
for _, owner := range req.AdminOwners {
s.auth.AddOwner(owner)
}
}
response := SetConfigResponse{
Success: true,
Message: "Configuration saved. Restart required for most changes to take effect.",
RestartNeeded: true,
ConfigFilePath: configFilePath(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} }
// BinariesResponse is the response for GET /api/binaries // BinariesResponse is the response for GET /api/binaries

67
cmd/orly-launcher/web.go

@ -2,6 +2,7 @@ package main
import ( import (
"embed" "embed"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"path" "path"
@ -11,29 +12,25 @@ import (
//go:embed all:web/dist all:web/public //go:embed all:web/dist all:web/public
var adminFS embed.FS var adminFS embed.FS
// getAdminFS returns the embedded filesystem for the admin UI. // getAdminSubFS returns the embedded filesystem for the admin UI.
func getAdminFS() (http.FileSystem, error) { func getAdminSubFS() (fs.FS, error) {
// Try dist first (built assets) // Try dist first (built assets)
distFS, err := fs.Sub(adminFS, "web/dist") distFS, err := fs.Sub(adminFS, "web/dist")
if err == nil { if err == nil {
// Check if dist has content // Check if dist has content
entries, _ := fs.ReadDir(distFS, ".") entries, _ := fs.ReadDir(distFS, ".")
if len(entries) > 0 { if len(entries) > 0 {
return http.FS(distFS), nil return distFS, nil
} }
} }
// Fall back to public (template) // Fall back to public (template)
publicFS, err := fs.Sub(adminFS, "web/public") return fs.Sub(adminFS, "web/public")
if err != nil {
return nil, err
}
return http.FS(publicFS), nil
} }
// serveAdminUI serves the embedded admin web UI. // serveAdminUI serves the embedded admin web UI.
func (s *AdminServer) serveAdminUI(w http.ResponseWriter, r *http.Request) { func (s *AdminServer) serveAdminUI(w http.ResponseWriter, r *http.Request) {
fsys, err := getAdminFS() fsys, err := getAdminSubFS()
if err != nil { if err != nil {
http.Error(w, "Admin UI not available", http.StatusInternalServerError) http.Error(w, "Admin UI not available", http.StatusInternalServerError)
return return
@ -43,32 +40,47 @@ func (s *AdminServer) serveAdminUI(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path urlPath := r.URL.Path
if strings.HasPrefix(urlPath, "/admin") { if strings.HasPrefix(urlPath, "/admin") {
urlPath = strings.TrimPrefix(urlPath, "/admin") urlPath = strings.TrimPrefix(urlPath, "/admin")
if urlPath == "" {
urlPath = "/"
}
} }
urlPath = strings.TrimPrefix(urlPath, "/")
// Try to serve the file // Default to index.html
filePath := strings.TrimPrefix(urlPath, "/") if urlPath == "" {
if filePath == "" { urlPath = "index.html"
filePath = "index.html" }
// Try to open the file
f, err := fsys.Open(urlPath)
if err != nil {
// For SPA routing, serve index.html for non-existent paths
urlPath = "index.html"
f, err = fsys.Open(urlPath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
} }
defer f.Close()
// Check if file exists // Check if it's a directory
f, err := fsys.Open(filePath) stat, err := f.Stat()
if err != nil { if err != nil {
// Serve index.html for SPA routing http.Error(w, "Internal error", http.StatusInternalServerError)
filePath = "index.html" return
f, err = fsys.Open(filePath) }
if stat.IsDir() {
// Try index.html in the directory
f.Close()
urlPath = path.Join(urlPath, "index.html")
f, err = fsys.Open(urlPath)
if err != nil { if err != nil {
http.Error(w, "Not found", http.StatusNotFound) http.Error(w, "Not found", http.StatusNotFound)
return return
} }
defer f.Close()
} }
f.Close()
// Set content type // Set content type based on extension
switch path.Ext(filePath) { switch path.Ext(urlPath) {
case ".html": case ".html":
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
case ".css": case ".css":
@ -81,9 +93,10 @@ func (s *AdminServer) serveAdminUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Content-Type", "image/svg+xml")
case ".png": case ".png":
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
case ".ico":
w.Header().Set("Content-Type", "image/x-icon")
} }
// Serve the file // Serve the file content directly
r.URL.Path = "/" + filePath io.Copy(w, f)
http.FileServer(fsys).ServeHTTP(w, r)
} }

2
cmd/orly-launcher/web/dist/bundle.css vendored

@ -2,6 +2,6 @@ header.svelte-1bc06ax{background:var(--card-bg);border-bottom:1px solid var(--bo
.modal-overlay.svelte-rhbu32.svelte-rhbu32{position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0, 0, 0, 0.5);display:flex;justify-content:center;align-items:center;z-index:1000}.modal.svelte-rhbu32.svelte-rhbu32{background:var(--card-bg, #fff);border-radius:8px;box-shadow:0 4px 20px rgba(0, 0, 0, 0.3);width:90%;max-width:450px;border:1px solid var(--border-color, #e0e0e0)}.modal-header.svelte-rhbu32.svelte-rhbu32{display:flex;justify-content:space-between;align-items:center;padding:20px;border-bottom:1px solid var(--border-color, #e0e0e0)}.modal-header.svelte-rhbu32 h2.svelte-rhbu32{margin:0;color:var(--text-color, #333);font-size:1.25rem}.close-btn.svelte-rhbu32.svelte-rhbu32{background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--text-color, #333);padding:0;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:50%}.close-btn.svelte-rhbu32.svelte-rhbu32:hover{background-color:var(--border-color, #e0e0e0)}.tab-container.svelte-rhbu32.svelte-rhbu32{padding:20px}.tabs.svelte-rhbu32.svelte-rhbu32{display:flex;border-bottom:1px solid var(--border-color, #e0e0e0);margin-bottom:20px}.tab-btn.svelte-rhbu32.svelte-rhbu32{flex:1;padding:12px 16px;background:none;border:none;cursor:pointer;color:var(--text-color, #333);font-size:1rem;border-bottom:2px solid transparent}.tab-btn.svelte-rhbu32.svelte-rhbu32:hover{background-color:var(--border-color, #e0e0e0)}.tab-btn.active.svelte-rhbu32.svelte-rhbu32{border-bottom-color:var(--primary, #00bcd4);color:var(--primary, #00bcd4)}.tab-content.svelte-rhbu32.svelte-rhbu32{min-height:180px}.extension-login.svelte-rhbu32.svelte-rhbu32,.nsec-login.svelte-rhbu32.svelte-rhbu32{display:flex;flex-direction:column;gap:16px}.extension-login.svelte-rhbu32 p.svelte-rhbu32,.nsec-login.svelte-rhbu32 p.svelte-rhbu32{margin:0;color:var(--muted-color, #666);line-height:1.5}.login-btn.svelte-rhbu32.svelte-rhbu32{padding:12px 24px;background:var(--primary, #00bcd4);color:white;border:none;border-radius:6px;cursor:pointer;font-size:1rem}.login-btn.svelte-rhbu32.svelte-rhbu32:hover:not(:disabled){background:var(--primary-hover, #00acc1)}.login-btn.svelte-rhbu32.svelte-rhbu32:disabled{background:#ccc;cursor:not-allowed}.nsec-input.svelte-rhbu32.svelte-rhbu32{padding:12px;border:1px solid var(--border-color, #e0e0e0);border-radius:6px;font-size:1rem;background:var(--card-bg, #fff);color:var(--text-color, #333)}.nsec-input.svelte-rhbu32.svelte-rhbu32:focus{outline:none;border-color:var(--primary, #00bcd4)}.generate-btn.svelte-rhbu32.svelte-rhbu32{padding:10px 20px;background:var(--success, #4caf50);color:white;border:none;border-radius:6px;cursor:pointer;font-size:0.95rem}.generate-btn.svelte-rhbu32.svelte-rhbu32:hover:not(:disabled){opacity:0.9}.generate-btn.svelte-rhbu32.svelte-rhbu32:disabled{background:#ccc;cursor:not-allowed}.generated-info.svelte-rhbu32.svelte-rhbu32{background:var(--bg-color, #f5f5f5);padding:12px;border-radius:6px;border:1px solid var(--border-color, #e0e0e0)}.generated-info.svelte-rhbu32 label.svelte-rhbu32{display:block;font-size:0.85rem;color:var(--muted-color, #666);margin-bottom:6px}.generated-info.svelte-rhbu32 code.svelte-rhbu32{display:block;word-break:break-all;font-size:0.8rem;color:var(--text-color, #333)}.message.svelte-rhbu32.svelte-rhbu32{padding:10px;border-radius:4px;margin-top:16px;text-align:center}.error-message.svelte-rhbu32.svelte-rhbu32{background:#ffebee;color:#c62828;border:1px solid #ffcdd2}.success-message.svelte-rhbu32.svelte-rhbu32{background:#e8f5e9;color:#2e7d32;border:1px solid #c8e6c9}.dark-theme.svelte-rhbu32 .error-message.svelte-rhbu32{background:#4a2c2a;color:#ffcdd2}.dark-theme.svelte-rhbu32 .success-message.svelte-rhbu32{background:#2e4a2e;color:#a5d6a7} .modal-overlay.svelte-rhbu32.svelte-rhbu32{position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0, 0, 0, 0.5);display:flex;justify-content:center;align-items:center;z-index:1000}.modal.svelte-rhbu32.svelte-rhbu32{background:var(--card-bg, #fff);border-radius:8px;box-shadow:0 4px 20px rgba(0, 0, 0, 0.3);width:90%;max-width:450px;border:1px solid var(--border-color, #e0e0e0)}.modal-header.svelte-rhbu32.svelte-rhbu32{display:flex;justify-content:space-between;align-items:center;padding:20px;border-bottom:1px solid var(--border-color, #e0e0e0)}.modal-header.svelte-rhbu32 h2.svelte-rhbu32{margin:0;color:var(--text-color, #333);font-size:1.25rem}.close-btn.svelte-rhbu32.svelte-rhbu32{background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--text-color, #333);padding:0;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:50%}.close-btn.svelte-rhbu32.svelte-rhbu32:hover{background-color:var(--border-color, #e0e0e0)}.tab-container.svelte-rhbu32.svelte-rhbu32{padding:20px}.tabs.svelte-rhbu32.svelte-rhbu32{display:flex;border-bottom:1px solid var(--border-color, #e0e0e0);margin-bottom:20px}.tab-btn.svelte-rhbu32.svelte-rhbu32{flex:1;padding:12px 16px;background:none;border:none;cursor:pointer;color:var(--text-color, #333);font-size:1rem;border-bottom:2px solid transparent}.tab-btn.svelte-rhbu32.svelte-rhbu32:hover{background-color:var(--border-color, #e0e0e0)}.tab-btn.active.svelte-rhbu32.svelte-rhbu32{border-bottom-color:var(--primary, #00bcd4);color:var(--primary, #00bcd4)}.tab-content.svelte-rhbu32.svelte-rhbu32{min-height:180px}.extension-login.svelte-rhbu32.svelte-rhbu32,.nsec-login.svelte-rhbu32.svelte-rhbu32{display:flex;flex-direction:column;gap:16px}.extension-login.svelte-rhbu32 p.svelte-rhbu32,.nsec-login.svelte-rhbu32 p.svelte-rhbu32{margin:0;color:var(--muted-color, #666);line-height:1.5}.login-btn.svelte-rhbu32.svelte-rhbu32{padding:12px 24px;background:var(--primary, #00bcd4);color:white;border:none;border-radius:6px;cursor:pointer;font-size:1rem}.login-btn.svelte-rhbu32.svelte-rhbu32:hover:not(:disabled){background:var(--primary-hover, #00acc1)}.login-btn.svelte-rhbu32.svelte-rhbu32:disabled{background:#ccc;cursor:not-allowed}.nsec-input.svelte-rhbu32.svelte-rhbu32{padding:12px;border:1px solid var(--border-color, #e0e0e0);border-radius:6px;font-size:1rem;background:var(--card-bg, #fff);color:var(--text-color, #333)}.nsec-input.svelte-rhbu32.svelte-rhbu32:focus{outline:none;border-color:var(--primary, #00bcd4)}.generate-btn.svelte-rhbu32.svelte-rhbu32{padding:10px 20px;background:var(--success, #4caf50);color:white;border:none;border-radius:6px;cursor:pointer;font-size:0.95rem}.generate-btn.svelte-rhbu32.svelte-rhbu32:hover:not(:disabled){opacity:0.9}.generate-btn.svelte-rhbu32.svelte-rhbu32:disabled{background:#ccc;cursor:not-allowed}.generated-info.svelte-rhbu32.svelte-rhbu32{background:var(--bg-color, #f5f5f5);padding:12px;border-radius:6px;border:1px solid var(--border-color, #e0e0e0)}.generated-info.svelte-rhbu32 label.svelte-rhbu32{display:block;font-size:0.85rem;color:var(--muted-color, #666);margin-bottom:6px}.generated-info.svelte-rhbu32 code.svelte-rhbu32{display:block;word-break:break-all;font-size:0.8rem;color:var(--text-color, #333)}.message.svelte-rhbu32.svelte-rhbu32{padding:10px;border-radius:4px;margin-top:16px;text-align:center}.error-message.svelte-rhbu32.svelte-rhbu32{background:#ffebee;color:#c62828;border:1px solid #ffcdd2}.success-message.svelte-rhbu32.svelte-rhbu32{background:#e8f5e9;color:#2e7d32;border:1px solid #c8e6c9}.dark-theme.svelte-rhbu32 .error-message.svelte-rhbu32{background:#4a2c2a;color:#ffcdd2}.dark-theme.svelte-rhbu32 .success-message.svelte-rhbu32{background:#2e4a2e;color:#a5d6a7}
.process-card.svelte-xh5u5u{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:16px}.process-header.svelte-xh5u5u{display:flex;align-items:center;gap:8px;margin-bottom:12px}.status-indicator.svelte-xh5u5u{font-size:1.2rem}.process-name.svelte-xh5u5u{font-weight:600;font-size:1rem;color:var(--text-color)}.process-details.svelte-xh5u5u{display:flex;flex-direction:column;gap:6px}.detail-row.svelte-xh5u5u{display:flex;justify-content:space-between;font-size:0.85rem}.label.svelte-xh5u5u{color:var(--muted-color)}.value.svelte-xh5u5u{color:var(--text-color);font-family:monospace}.value.binary.svelte-xh5u5u{font-size:0.75rem;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.value.warning.svelte-xh5u5u{color:var(--warning)} .process-card.svelte-xh5u5u{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:16px}.process-header.svelte-xh5u5u{display:flex;align-items:center;gap:8px;margin-bottom:12px}.status-indicator.svelte-xh5u5u{font-size:1.2rem}.process-name.svelte-xh5u5u{font-weight:600;font-size:1rem;color:var(--text-color)}.process-details.svelte-xh5u5u{display:flex;flex-direction:column;gap:6px}.detail-row.svelte-xh5u5u{display:flex;justify-content:space-between;font-size:0.85rem}.label.svelte-xh5u5u{color:var(--muted-color)}.value.svelte-xh5u5u{color:var(--text-color);font-family:monospace}.value.binary.svelte-xh5u5u{font-size:0.75rem;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.value.warning.svelte-xh5u5u{color:var(--warning)}
.dashboard.svelte-17dya06.svelte-17dya06{padding:20px 0}.page-header.svelte-17dya06.svelte-17dya06{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header.svelte-17dya06 h2.svelte-17dya06{font-size:1.5rem;color:var(--text-color)}.actions.svelte-17dya06.svelte-17dya06{display:flex;gap:8px}.refresh-btn.svelte-17dya06.svelte-17dya06,.restart-btn.svelte-17dya06.svelte-17dya06{padding:8px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}.refresh-btn.svelte-17dya06.svelte-17dya06{background:var(--card-bg);border:1px solid var(--border-color);color:var(--text-color)}.refresh-btn.svelte-17dya06.svelte-17dya06:hover:not(:disabled){background:var(--border-color)}.restart-btn.svelte-17dya06.svelte-17dya06{background:var(--warning);border:none;color:white}.restart-btn.svelte-17dya06.svelte-17dya06:hover:not(:disabled){opacity:0.9}.restart-btn.svelte-17dya06.svelte-17dya06:disabled,.refresh-btn.svelte-17dya06.svelte-17dya06:disabled{opacity:0.5;cursor:not-allowed}.error-banner.svelte-17dya06.svelte-17dya06{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.status-summary.svelte-17dya06.svelte-17dya06{display:grid;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr));gap:16px;margin-bottom:32px}.summary-card.svelte-17dya06.svelte-17dya06{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:16px;display:flex;flex-direction:column;gap:4px}.summary-card.svelte-17dya06 .label.svelte-17dya06{font-size:0.85rem;color:var(--muted-color)}.summary-card.svelte-17dya06 .value.svelte-17dya06{font-size:1.25rem;font-weight:600;color:var(--text-color)}h3.svelte-17dya06.svelte-17dya06{font-size:1.1rem;color:var(--text-color);margin-bottom:16px}.processes-grid.svelte-17dya06.svelte-17dya06{display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:16px}.loading.svelte-17dya06.svelte-17dya06{text-align:center;color:var(--muted-color);padding:40px} .dashboard.svelte-17dya06.svelte-17dya06{padding:20px 0}.page-header.svelte-17dya06.svelte-17dya06{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header.svelte-17dya06 h2.svelte-17dya06{font-size:1.5rem;color:var(--text-color)}.actions.svelte-17dya06.svelte-17dya06{display:flex;gap:8px}.refresh-btn.svelte-17dya06.svelte-17dya06,.restart-btn.svelte-17dya06.svelte-17dya06{padding:8px 16px;border-radius:4px;cursor:pointer;font-size:0.9rem}.refresh-btn.svelte-17dya06.svelte-17dya06{background:var(--card-bg);border:1px solid var(--border-color);color:var(--text-color)}.refresh-btn.svelte-17dya06.svelte-17dya06:hover:not(:disabled){background:var(--border-color)}.restart-btn.svelte-17dya06.svelte-17dya06{background:var(--warning);border:none;color:white}.restart-btn.svelte-17dya06.svelte-17dya06:hover:not(:disabled){opacity:0.9}.restart-btn.svelte-17dya06.svelte-17dya06:disabled,.refresh-btn.svelte-17dya06.svelte-17dya06:disabled{opacity:0.5;cursor:not-allowed}.error-banner.svelte-17dya06.svelte-17dya06{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.status-summary.svelte-17dya06.svelte-17dya06{display:grid;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr));gap:16px;margin-bottom:32px}.summary-card.svelte-17dya06.svelte-17dya06{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:16px;display:flex;flex-direction:column;gap:4px}.summary-card.svelte-17dya06 .label.svelte-17dya06{font-size:0.85rem;color:var(--muted-color)}.summary-card.svelte-17dya06 .value.svelte-17dya06{font-size:1.25rem;font-weight:600;color:var(--text-color)}h3.svelte-17dya06.svelte-17dya06{font-size:1.1rem;color:var(--text-color);margin-bottom:16px}.processes-grid.svelte-17dya06.svelte-17dya06{display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:16px}.loading.svelte-17dya06.svelte-17dya06{text-align:center;color:var(--muted-color);padding:40px}
.config-page.svelte-1kruta9.svelte-1kruta9{padding:20px 0}.page-header.svelte-1kruta9.svelte-1kruta9{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header.svelte-1kruta9 h2.svelte-1kruta9{font-size:1.5rem;color:var(--text-color)}.refresh-btn.svelte-1kruta9.svelte-1kruta9{padding:8px 16px;background:var(--card-bg);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-size:0.9rem}.refresh-btn.svelte-1kruta9.svelte-1kruta9:hover:not(:disabled){background:var(--border-color)}.refresh-btn.svelte-1kruta9.svelte-1kruta9:disabled{opacity:0.5;cursor:not-allowed}.error-banner.svelte-1kruta9.svelte-1kruta9{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.config-sections.svelte-1kruta9.svelte-1kruta9{display:flex;flex-direction:column;gap:24px}.config-section.svelte-1kruta9.svelte-1kruta9{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:20px}.config-section.svelte-1kruta9 h3.svelte-1kruta9{font-size:1.1rem;color:var(--text-color);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-color)}.config-grid.svelte-1kruta9.svelte-1kruta9{display:grid;grid-template-columns:repeat(auto-fill, minmax(250px, 1fr));gap:16px}.config-item.svelte-1kruta9.svelte-1kruta9{display:flex;flex-direction:column;gap:4px}.config-item.full-width.svelte-1kruta9.svelte-1kruta9{grid-column:1 / -1}.config-item.svelte-1kruta9 .label.svelte-1kruta9{font-size:0.85rem;color:var(--muted-color)}.config-item.svelte-1kruta9 .value.svelte-1kruta9{font-size:0.95rem;color:var(--text-color)}.config-item.svelte-1kruta9 .value.mono.svelte-1kruta9{font-family:monospace;font-size:0.85rem}.config-item.svelte-1kruta9 .value.bool.svelte-1kruta9{font-weight:500}.config-item.svelte-1kruta9 .value.bool.enabled.svelte-1kruta9{color:var(--success)}.owners-list.svelte-1kruta9.svelte-1kruta9{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}.owner.svelte-1kruta9.svelte-1kruta9{font-size:0.75rem;background:var(--bg-color);padding:4px 8px;border-radius:4px;word-break:break-all}.no-owners.svelte-1kruta9.svelte-1kruta9{color:var(--muted-color);font-style:italic}.config-note.svelte-1kruta9.svelte-1kruta9{margin-top:24px;padding:16px;background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px}.config-note.svelte-1kruta9 p.svelte-1kruta9{color:var(--muted-color);font-size:0.9rem;margin:0}.loading.svelte-1kruta9.svelte-1kruta9{text-align:center;color:var(--muted-color);padding:40px} .config-page.svelte-my2rpu.svelte-my2rpu{padding:20px 0}.page-header.svelte-my2rpu.svelte-my2rpu{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header.svelte-my2rpu h2.svelte-my2rpu{font-size:1.5rem;color:var(--text-color)}.header-buttons.svelte-my2rpu.svelte-my2rpu{display:flex;gap:8px}.refresh-btn.svelte-my2rpu.svelte-my2rpu,.edit-btn.svelte-my2rpu.svelte-my2rpu,.cancel-btn.svelte-my2rpu.svelte-my2rpu,.save-btn.svelte-my2rpu.svelte-my2rpu{padding:8px 16px;background:var(--card-bg);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-size:0.9rem}.edit-btn.svelte-my2rpu.svelte-my2rpu{background:var(--primary);border-color:var(--primary);color:white}.save-btn.svelte-my2rpu.svelte-my2rpu{background:var(--success);border-color:var(--success);color:white}.cancel-btn.svelte-my2rpu.svelte-my2rpu:hover:not(:disabled){background:var(--border-color)}.edit-btn.svelte-my2rpu.svelte-my2rpu:hover:not(:disabled),.save-btn.svelte-my2rpu.svelte-my2rpu:hover:not(:disabled){opacity:0.9}button.svelte-my2rpu.svelte-my2rpu:disabled{opacity:0.5;cursor:not-allowed}.error-banner.svelte-my2rpu.svelte-my2rpu{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.message-banner.svelte-my2rpu.svelte-my2rpu{padding:12px 16px;border-radius:6px;margin-bottom:20px;display:flex;align-items:center;gap:12px}.message-banner.success.svelte-my2rpu.svelte-my2rpu{background:#e8f5e9;color:#2e7d32;border:1px solid #c8e6c9}.message-banner.error.svelte-my2rpu.svelte-my2rpu{background:#ffebee;color:#c62828;border:1px solid #ffcdd2}.restart-btn-inline.svelte-my2rpu.svelte-my2rpu{padding:4px 12px;background:var(--primary);border:none;color:white;border-radius:4px;cursor:pointer;font-size:0.85rem}.config-sections.svelte-my2rpu.svelte-my2rpu{display:flex;flex-direction:column;gap:24px}.config-section.svelte-my2rpu.svelte-my2rpu{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:20px}.config-section.svelte-my2rpu h3.svelte-my2rpu{font-size:1.1rem;color:var(--text-color);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-color)}.config-grid.svelte-my2rpu.svelte-my2rpu{display:grid;grid-template-columns:repeat(auto-fill, minmax(250px, 1fr));gap:16px}.config-item.svelte-my2rpu.svelte-my2rpu{display:flex;flex-direction:column;gap:4px}.config-item.full-width.svelte-my2rpu.svelte-my2rpu{grid-column:1 / -1}.config-item.svelte-my2rpu .label.svelte-my2rpu{font-size:0.85rem;color:var(--muted-color);display:flex;align-items:center;gap:8px}.config-item.svelte-my2rpu .value.svelte-my2rpu{font-size:0.95rem;color:var(--text-color)}.config-item.svelte-my2rpu .value.mono.svelte-my2rpu{font-family:monospace;font-size:0.85rem}.config-item.svelte-my2rpu .value.bool.svelte-my2rpu{font-weight:500}.config-item.svelte-my2rpu .value.bool.enabled.svelte-my2rpu{color:var(--success)}.config-item.svelte-my2rpu input[type="text"].svelte-my2rpu,.config-item.svelte-my2rpu select.svelte-my2rpu{padding:8px 12px;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-color);color:var(--text-color);font-size:0.9rem}.config-item.svelte-my2rpu input[type="text"].svelte-my2rpu:focus,.config-item.svelte-my2rpu select.svelte-my2rpu:focus{outline:none;border-color:var(--primary)}.toggle.svelte-my2rpu.svelte-my2rpu{display:flex;align-items:center;gap:8px;cursor:pointer}.toggle.svelte-my2rpu input[type="checkbox"].svelte-my2rpu{width:18px;height:18px}.owners-list.svelte-my2rpu.svelte-my2rpu{display:flex;flex-wrap:wrap;gap:8px;margin-top:4px}.owner-item.svelte-my2rpu.svelte-my2rpu{display:flex;align-items:center;gap:4px}.owner.svelte-my2rpu.svelte-my2rpu{font-size:0.75rem;background:var(--bg-color);padding:4px 8px;border-radius:4px;word-break:break-all}.remove-owner-btn.svelte-my2rpu.svelte-my2rpu{padding:2px 6px;background:#ffebee;border:none;color:#c62828;border-radius:4px;cursor:pointer;font-size:0.8rem}.add-owner-btn.svelte-my2rpu.svelte-my2rpu{padding:2px 8px;background:var(--primary);border:none;color:white;border-radius:4px;cursor:pointer;font-size:0.75rem}.no-owners.svelte-my2rpu.svelte-my2rpu{color:var(--muted-color);font-style:italic}.config-note.svelte-my2rpu.svelte-my2rpu{margin-top:24px;padding:16px;background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px}.config-note.svelte-my2rpu p.svelte-my2rpu{color:var(--muted-color);font-size:0.9rem;margin:0}.config-note.svelte-my2rpu code.svelte-my2rpu{background:var(--bg-color);padding:2px 6px;border-radius:4px;font-size:0.85rem}.loading.svelte-my2rpu.svelte-my2rpu{text-align:center;color:var(--muted-color);padding:40px}
.update-page.svelte-1ig49gt.svelte-1ig49gt{padding:20px 0}.page-header.svelte-1ig49gt.svelte-1ig49gt{margin-bottom:24px}.page-header.svelte-1ig49gt h2.svelte-1ig49gt{font-size:1.5rem;color:var(--text-color)}.error-banner.svelte-1ig49gt.svelte-1ig49gt{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.success-banner.svelte-1ig49gt.svelte-1ig49gt{background:#e8f5e9;color:#2e7d32;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #c8e6c9}.current-version.svelte-1ig49gt.svelte-1ig49gt,.update-form.svelte-1ig49gt.svelte-1ig49gt,.versions-list.svelte-1ig49gt.svelte-1ig49gt{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:20px;margin-bottom:24px}h3.svelte-1ig49gt.svelte-1ig49gt{font-size:1.1rem;color:var(--text-color);margin-bottom:16px}.version-info.svelte-1ig49gt.svelte-1ig49gt{display:flex;align-items:center;justify-content:space-between}.version.svelte-1ig49gt.svelte-1ig49gt{font-size:1.5rem;font-weight:600;font-family:monospace;color:var(--text-color)}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt{padding:8px 16px;background:var(--warning);border:none;color:white;border-radius:4px;cursor:pointer}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){opacity:0.9}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt:disabled{opacity:0.5;cursor:not-allowed}.form-group.svelte-1ig49gt.svelte-1ig49gt{margin-bottom:20px}.form-group.svelte-1ig49gt>label.svelte-1ig49gt{display:block;font-size:0.9rem;color:var(--text-color);margin-bottom:8px;font-weight:500}.form-group.svelte-1ig49gt input[type="text"].svelte-1ig49gt{width:100%;padding:10px 12px;border:1px solid var(--border-color);border-radius:4px;font-size:0.95rem;background:var(--bg-color);color:var(--text-color)}.form-group.svelte-1ig49gt input.svelte-1ig49gt:focus{outline:none;border-color:var(--primary)}.url-header.svelte-1ig49gt.svelte-1ig49gt{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.url-header.svelte-1ig49gt label.svelte-1ig49gt{font-size:0.9rem;color:var(--text-color);font-weight:500}.helper-btn.svelte-1ig49gt.svelte-1ig49gt{padding:4px 12px;font-size:0.8rem;background:var(--card-bg);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);cursor:pointer}.helper-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){background:var(--border-color)}.url-input.svelte-1ig49gt.svelte-1ig49gt{display:flex;gap:12px;align-items:center;margin-bottom:8px}.binary-name.svelte-1ig49gt.svelte-1ig49gt{width:140px;font-family:monospace;font-size:0.85rem;color:var(--muted-color)}.url-input.svelte-1ig49gt input.svelte-1ig49gt{flex:1;padding:8px 12px;border:1px solid var(--border-color);border-radius:4px;font-size:0.85rem;background:var(--bg-color);color:var(--text-color)}.update-btn.svelte-1ig49gt.svelte-1ig49gt{width:100%;padding:12px;background:var(--primary);border:none;color:white;border-radius:6px;font-size:1rem;cursor:pointer}.update-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){background:var(--primary-hover)}.update-btn.svelte-1ig49gt.svelte-1ig49gt:disabled{opacity:0.5;cursor:not-allowed}table.svelte-1ig49gt.svelte-1ig49gt{width:100%;border-collapse:collapse}th.svelte-1ig49gt.svelte-1ig49gt,td.svelte-1ig49gt.svelte-1ig49gt{padding:10px 12px;text-align:left;border-bottom:1px solid var(--border-color)}th.svelte-1ig49gt.svelte-1ig49gt{font-size:0.85rem;color:var(--muted-color);font-weight:500}td.svelte-1ig49gt.svelte-1ig49gt{font-size:0.9rem;color:var(--text-color)}.version-cell.svelte-1ig49gt.svelte-1ig49gt{font-family:monospace}tr.current.svelte-1ig49gt.svelte-1ig49gt{background:rgba(0, 188, 212, 0.1)}.current-badge.svelte-1ig49gt.svelte-1ig49gt{background:var(--primary);color:white;padding:2px 8px;border-radius:4px;font-size:0.75rem} .update-page.svelte-1ig49gt.svelte-1ig49gt{padding:20px 0}.page-header.svelte-1ig49gt.svelte-1ig49gt{margin-bottom:24px}.page-header.svelte-1ig49gt h2.svelte-1ig49gt{font-size:1.5rem;color:var(--text-color)}.error-banner.svelte-1ig49gt.svelte-1ig49gt{background:#ffebee;color:#c62828;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #ffcdd2}.success-banner.svelte-1ig49gt.svelte-1ig49gt{background:#e8f5e9;color:#2e7d32;padding:12px 16px;border-radius:6px;margin-bottom:20px;border:1px solid #c8e6c9}.current-version.svelte-1ig49gt.svelte-1ig49gt,.update-form.svelte-1ig49gt.svelte-1ig49gt,.versions-list.svelte-1ig49gt.svelte-1ig49gt{background:var(--card-bg);border:1px solid var(--border-color);border-radius:8px;padding:20px;margin-bottom:24px}h3.svelte-1ig49gt.svelte-1ig49gt{font-size:1.1rem;color:var(--text-color);margin-bottom:16px}.version-info.svelte-1ig49gt.svelte-1ig49gt{display:flex;align-items:center;justify-content:space-between}.version.svelte-1ig49gt.svelte-1ig49gt{font-size:1.5rem;font-weight:600;font-family:monospace;color:var(--text-color)}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt{padding:8px 16px;background:var(--warning);border:none;color:white;border-radius:4px;cursor:pointer}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){opacity:0.9}.rollback-btn.svelte-1ig49gt.svelte-1ig49gt:disabled{opacity:0.5;cursor:not-allowed}.form-group.svelte-1ig49gt.svelte-1ig49gt{margin-bottom:20px}.form-group.svelte-1ig49gt>label.svelte-1ig49gt{display:block;font-size:0.9rem;color:var(--text-color);margin-bottom:8px;font-weight:500}.form-group.svelte-1ig49gt input[type="text"].svelte-1ig49gt{width:100%;padding:10px 12px;border:1px solid var(--border-color);border-radius:4px;font-size:0.95rem;background:var(--bg-color);color:var(--text-color)}.form-group.svelte-1ig49gt input.svelte-1ig49gt:focus{outline:none;border-color:var(--primary)}.url-header.svelte-1ig49gt.svelte-1ig49gt{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.url-header.svelte-1ig49gt label.svelte-1ig49gt{font-size:0.9rem;color:var(--text-color);font-weight:500}.helper-btn.svelte-1ig49gt.svelte-1ig49gt{padding:4px 12px;font-size:0.8rem;background:var(--card-bg);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);cursor:pointer}.helper-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){background:var(--border-color)}.url-input.svelte-1ig49gt.svelte-1ig49gt{display:flex;gap:12px;align-items:center;margin-bottom:8px}.binary-name.svelte-1ig49gt.svelte-1ig49gt{width:140px;font-family:monospace;font-size:0.85rem;color:var(--muted-color)}.url-input.svelte-1ig49gt input.svelte-1ig49gt{flex:1;padding:8px 12px;border:1px solid var(--border-color);border-radius:4px;font-size:0.85rem;background:var(--bg-color);color:var(--text-color)}.update-btn.svelte-1ig49gt.svelte-1ig49gt{width:100%;padding:12px;background:var(--primary);border:none;color:white;border-radius:6px;font-size:1rem;cursor:pointer}.update-btn.svelte-1ig49gt.svelte-1ig49gt:hover:not(:disabled){background:var(--primary-hover)}.update-btn.svelte-1ig49gt.svelte-1ig49gt:disabled{opacity:0.5;cursor:not-allowed}table.svelte-1ig49gt.svelte-1ig49gt{width:100%;border-collapse:collapse}th.svelte-1ig49gt.svelte-1ig49gt,td.svelte-1ig49gt.svelte-1ig49gt{padding:10px 12px;text-align:left;border-bottom:1px solid var(--border-color)}th.svelte-1ig49gt.svelte-1ig49gt{font-size:0.85rem;color:var(--muted-color);font-weight:500}td.svelte-1ig49gt.svelte-1ig49gt{font-size:0.9rem;color:var(--text-color)}.version-cell.svelte-1ig49gt.svelte-1ig49gt{font-family:monospace}tr.current.svelte-1ig49gt.svelte-1ig49gt{background:rgba(0, 188, 212, 0.1)}.current-badge.svelte-1ig49gt.svelte-1ig49gt{background:var(--primary);color:white;padding:2px 8px;border-radius:4px;font-size:0.75rem}
*{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;background:var(--bg-color);color:var(--text-color);min-height:100vh}main.svelte-4k9oqz.svelte-4k9oqz{--bg-color:#f5f5f5;--card-bg:#ffffff;--text-color:#333333;--muted-color:#666666;--border-color:#e0e0e0;--primary:#00bcd4;--primary-hover:#00acc1;--success:#4caf50;--error:#f44336;--warning:#ff9800;min-height:100vh;background:var(--bg-color)}main.dark-theme.svelte-4k9oqz.svelte-4k9oqz{--bg-color:#1a1a1a;--card-bg:#2d2d2d;--text-color:#e0e0e0;--muted-color:#999999;--border-color:#444444}.content.svelte-4k9oqz.svelte-4k9oqz{max-width:1200px;margin:0 auto;padding:20px}.login-prompt.svelte-4k9oqz.svelte-4k9oqz{text-align:center;padding:60px 20px}.login-prompt.svelte-4k9oqz h2.svelte-4k9oqz{font-size:2rem;margin-bottom:16px;color:var(--text-color)}.login-prompt.svelte-4k9oqz p.svelte-4k9oqz{color:var(--muted-color);margin-bottom:24px}.login-btn.svelte-4k9oqz.svelte-4k9oqz{padding:12px 32px;font-size:1rem;background:var(--primary);color:white;border:none;border-radius:6px;cursor:pointer;transition:background 0.2s}.login-btn.svelte-4k9oqz.svelte-4k9oqz:hover{background:var(--primary-hover)} *{box-sizing:border-box;margin:0;padding:0}body{font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;background:var(--bg-color);color:var(--text-color);min-height:100vh}main.svelte-4k9oqz.svelte-4k9oqz{--bg-color:#f5f5f5;--card-bg:#ffffff;--text-color:#333333;--muted-color:#666666;--border-color:#e0e0e0;--primary:#00bcd4;--primary-hover:#00acc1;--success:#4caf50;--error:#f44336;--warning:#ff9800;min-height:100vh;background:var(--bg-color)}main.dark-theme.svelte-4k9oqz.svelte-4k9oqz{--bg-color:#1a1a1a;--card-bg:#2d2d2d;--text-color:#e0e0e0;--muted-color:#999999;--border-color:#444444}.content.svelte-4k9oqz.svelte-4k9oqz{max-width:1200px;margin:0 auto;padding:20px}.login-prompt.svelte-4k9oqz.svelte-4k9oqz{text-align:center;padding:60px 20px}.login-prompt.svelte-4k9oqz h2.svelte-4k9oqz{font-size:2rem;margin-bottom:16px;color:var(--text-color)}.login-prompt.svelte-4k9oqz p.svelte-4k9oqz{color:var(--muted-color);margin-bottom:24px}.login-btn.svelte-4k9oqz.svelte-4k9oqz{padding:12px 32px;font-size:1rem;background:var(--primary);color:white;border:none;border-radius:6px;cursor:pointer;transition:background 0.2s}.login-btn.svelte-4k9oqz.svelte-4k9oqz:hover{background:var(--primary-hover)}

20
cmd/orly-launcher/web/dist/bundle.js vendored

File diff suppressed because one or more lines are too long

18
cmd/orly-launcher/web/src/api.js

@ -93,6 +93,24 @@ export async function fetchConfig(signer, pubkey) {
return response.json(); return response.json();
} }
/**
* Save launcher configuration
* @param {object} config - Configuration object to save
*/
export async function saveConfig(signer, pubkey, config) {
const response = await authFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
}, signer, pubkey);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || `Save failed: ${response.statusText}`);
}
return response.json();
}
/** /**
* Fetch available binaries * Fetch available binaries
*/ */

404
cmd/orly-launcher/web/src/pages/Config.svelte

@ -1,7 +1,13 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { userSigner, userPubkey, configData, isLoading, error } from '../stores.js'; import { userSigner, userPubkey, configData, isLoading, error } from '../stores.js';
import { fetchConfig } from '../api.js'; import { fetchConfig, saveConfig, restartServices } from '../api.js';
let editMode = false;
let editedConfig = {};
let saveMessage = '';
let saveSuccess = false;
let isSaving = false;
onMount(async () => { onMount(async () => {
await loadConfig(); await loadConfig();
@ -11,6 +17,7 @@
$isLoading = true; $isLoading = true;
try { try {
$configData = await fetchConfig($userSigner, $userPubkey); $configData = await fetchConfig($userSigner, $userPubkey);
editedConfig = JSON.parse(JSON.stringify($configData)); // Deep copy
$error = ''; $error = '';
} catch (e) { } catch (e) {
$error = e.message; $error = e.message;
@ -18,40 +25,134 @@
$isLoading = false; $isLoading = false;
} }
} }
function startEdit() {
editedConfig = JSON.parse(JSON.stringify($configData));
editMode = true;
saveMessage = '';
}
function cancelEdit() {
editedConfig = JSON.parse(JSON.stringify($configData));
editMode = false;
saveMessage = '';
}
async function handleSave() {
isSaving = true;
saveMessage = '';
try {
const result = await saveConfig($userSigner, $userPubkey, editedConfig);
saveSuccess = result.success;
saveMessage = result.message;
if (result.success) {
$configData = { ...editedConfig };
editMode = false;
}
} catch (e) {
saveSuccess = false;
saveMessage = e.message;
} finally {
isSaving = false;
}
}
async function handleRestart() {
if (!confirm('Restart all services? This will briefly interrupt the relay.')) {
return;
}
try {
await restartServices($userSigner, $userPubkey);
saveMessage = 'Restart initiated. Services are restarting...';
saveSuccess = true;
} catch (e) {
saveMessage = e.message;
saveSuccess = false;
}
}
function addOwner() {
const newOwner = prompt('Enter hex pubkey for new admin owner:');
if (newOwner && newOwner.match(/^[0-9a-fA-F]{64}$/)) {
editedConfig.admin_owners = [...(editedConfig.admin_owners || []), newOwner.toLowerCase()];
} else if (newOwner) {
alert('Invalid pubkey. Must be 64 hex characters.');
}
}
function removeOwner(index) {
editedConfig.admin_owners = editedConfig.admin_owners.filter((_, i) => i !== index);
}
</script> </script>
<div class="config-page"> <div class="config-page">
<div class="page-header"> <div class="page-header">
<h2>Configuration</h2> <h2>Configuration</h2>
<button class="refresh-btn" on:click={loadConfig} disabled={$isLoading}> <div class="header-buttons">
Refresh {#if editMode}
</button> <button class="cancel-btn" on:click={cancelEdit} disabled={isSaving}>Cancel</button>
<button class="save-btn" on:click={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</button>
{:else}
<button class="refresh-btn" on:click={loadConfig} disabled={$isLoading}>Refresh</button>
<button class="edit-btn" on:click={startEdit} disabled={$isLoading || !$configData}>Edit</button>
{/if}
</div>
</div> </div>
{#if $error} {#if $error}
<div class="error-banner">{$error}</div> <div class="error-banner">{$error}</div>
{/if} {/if}
{#if saveMessage}
<div class="message-banner" class:success={saveSuccess} class:error={!saveSuccess}>
{saveMessage}
{#if saveSuccess && saveMessage.includes('Restart required')}
<button class="restart-btn-inline" on:click={handleRestart}>Restart Now</button>
{/if}
</div>
{/if}
{#if $configData} {#if $configData}
<div class="config-sections"> <div class="config-sections">
<section class="config-section"> <section class="config-section">
<h3>Database</h3> <h3>Database</h3>
<div class="config-grid"> <div class="config-grid">
<div class="config-item"> <div class="config-item">
<span class="label">Backend</span> <label class="label">Backend</label>
<span class="value">{$configData.db_backend}</span> {#if editMode}
<select bind:value={editedConfig.db_backend}>
<option value="badger">Badger</option>
<option value="neo4j">Neo4j</option>
</select>
{:else}
<span class="value">{$configData.db_backend}</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Binary</span> <label class="label">Binary</label>
<span class="value mono">{$configData.db_binary}</span> {#if editMode}
<input type="text" bind:value={editedConfig.db_binary} placeholder="orly-db-badger" />
{:else}
<span class="value mono">{$configData.db_binary}</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Listen Address</span> <label class="label">Listen Address</label>
<span class="value mono">{$configData.db_listen}</span> {#if editMode}
<input type="text" bind:value={editedConfig.db_listen} placeholder="127.0.0.1:50051" />
{:else}
<span class="value mono">{$configData.db_listen}</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Data Directory</span> <label class="label">Data Directory</label>
<span class="value mono">{$configData.data_dir}</span> {#if editMode}
<input type="text" bind:value={editedConfig.data_dir} />
{:else}
<span class="value mono">{$configData.data_dir}</span>
{/if}
</div> </div>
</div> </div>
</section> </section>
@ -60,22 +161,45 @@
<h3>ACL</h3> <h3>ACL</h3>
<div class="config-grid"> <div class="config-grid">
<div class="config-item"> <div class="config-item">
<span class="label">Enabled</span> <label class="label">Enabled</label>
<span class="value bool" class:enabled={$configData.acl_enabled}> {#if editMode}
{$configData.acl_enabled ? 'Yes' : 'No'} <label class="toggle">
</span> <input type="checkbox" bind:checked={editedConfig.acl_enabled} />
<span>{editedConfig.acl_enabled ? 'Enabled' : 'Disabled'}</span>
</label>
{:else}
<span class="value bool" class:enabled={$configData.acl_enabled}>
{$configData.acl_enabled ? 'Yes' : 'No'}
</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Mode</span> <label class="label">Mode</label>
<span class="value">{$configData.acl_mode}</span> {#if editMode}
<select bind:value={editedConfig.acl_mode}>
<option value="follows">Follows</option>
<option value="managed">Managed</option>
<option value="curation">Curation</option>
</select>
{:else}
<span class="value">{$configData.acl_mode}</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Binary</span> <label class="label">Binary</label>
<span class="value mono">{$configData.acl_binary}</span> {#if editMode}
<input type="text" bind:value={editedConfig.acl_binary} />
{:else}
<span class="value mono">{$configData.acl_binary}</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Listen Address</span> <label class="label">Listen Address</label>
<span class="value mono">{$configData.acl_listen}</span> {#if editMode}
<input type="text" bind:value={editedConfig.acl_listen} placeholder="127.0.0.1:50052" />
{:else}
<span class="value mono">{$configData.acl_listen}</span>
{/if}
</div> </div>
</div> </div>
</section> </section>
@ -84,12 +208,26 @@
<h3>Relay</h3> <h3>Relay</h3>
<div class="config-grid"> <div class="config-grid">
<div class="config-item"> <div class="config-item">
<span class="label">Binary</span> <label class="label">Binary</label>
<span class="value mono">{$configData.relay_binary}</span> {#if editMode}
<input type="text" bind:value={editedConfig.relay_binary} placeholder="orly" />
{:else}
<span class="value mono">{$configData.relay_binary}</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Log Level</span> <label class="label">Log Level</label>
<span class="value">{$configData.log_level}</span> {#if editMode}
<select bind:value={editedConfig.log_level}>
<option value="trace">Trace</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
{:else}
<span class="value">{$configData.log_level}</span>
{/if}
</div> </div>
</div> </div>
</section> </section>
@ -98,28 +236,56 @@
<h3>Sync Services</h3> <h3>Sync Services</h3>
<div class="config-grid"> <div class="config-grid">
<div class="config-item"> <div class="config-item">
<span class="label">Distributed Sync</span> <label class="label">Distributed Sync</label>
<span class="value bool" class:enabled={$configData.distributed_sync_enabled}> {#if editMode}
{$configData.distributed_sync_enabled ? 'Enabled' : 'Disabled'} <label class="toggle">
</span> <input type="checkbox" bind:checked={editedConfig.distributed_sync_enabled} />
<span>{editedConfig.distributed_sync_enabled ? 'Enabled' : 'Disabled'}</span>
</label>
{:else}
<span class="value bool" class:enabled={$configData.distributed_sync_enabled}>
{$configData.distributed_sync_enabled ? 'Enabled' : 'Disabled'}
</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Cluster Sync</span> <label class="label">Cluster Sync</label>
<span class="value bool" class:enabled={$configData.cluster_sync_enabled}> {#if editMode}
{$configData.cluster_sync_enabled ? 'Enabled' : 'Disabled'} <label class="toggle">
</span> <input type="checkbox" bind:checked={editedConfig.cluster_sync_enabled} />
<span>{editedConfig.cluster_sync_enabled ? 'Enabled' : 'Disabled'}</span>
</label>
{:else}
<span class="value bool" class:enabled={$configData.cluster_sync_enabled}>
{$configData.cluster_sync_enabled ? 'Enabled' : 'Disabled'}
</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Relay Group</span> <label class="label">Relay Group</label>
<span class="value bool" class:enabled={$configData.relay_group_enabled}> {#if editMode}
{$configData.relay_group_enabled ? 'Enabled' : 'Disabled'} <label class="toggle">
</span> <input type="checkbox" bind:checked={editedConfig.relay_group_enabled} />
<span>{editedConfig.relay_group_enabled ? 'Enabled' : 'Disabled'}</span>
</label>
{:else}
<span class="value bool" class:enabled={$configData.relay_group_enabled}>
{$configData.relay_group_enabled ? 'Enabled' : 'Disabled'}
</span>
{/if}
</div> </div>
<div class="config-item"> <div class="config-item">
<span class="label">Negentropy</span> <label class="label">Negentropy</label>
<span class="value bool" class:enabled={$configData.negentropy_enabled}> {#if editMode}
{$configData.negentropy_enabled ? 'Enabled' : 'Disabled'} <label class="toggle">
</span> <input type="checkbox" bind:checked={editedConfig.negentropy_enabled} />
<span>{editedConfig.negentropy_enabled ? 'Enabled' : 'Disabled'}</span>
</label>
{:else}
<span class="value bool" class:enabled={$configData.negentropy_enabled}>
{$configData.negentropy_enabled ? 'Enabled' : 'Disabled'}
</span>
{/if}
</div> </div>
</div> </div>
</section> </section>
@ -128,14 +294,28 @@
<h3>Admin</h3> <h3>Admin</h3>
<div class="config-grid"> <div class="config-grid">
<div class="config-item"> <div class="config-item">
<span class="label">Binary Directory</span> <label class="label">Binary Directory</label>
<span class="value mono">{$configData.bin_dir}</span> {#if editMode}
<input type="text" bind:value={editedConfig.bin_dir} />
{:else}
<span class="value mono">{$configData.bin_dir}</span>
{/if}
</div> </div>
<div class="config-item full-width"> <div class="config-item full-width">
<span class="label">Admin Owners</span> <label class="label">
Admin Owners
{#if editMode}
<button class="add-owner-btn" on:click={addOwner}>+ Add</button>
{/if}
</label>
<div class="owners-list"> <div class="owners-list">
{#each $configData.admin_owners || [] as owner} {#each (editMode ? editedConfig.admin_owners : $configData.admin_owners) || [] as owner, index}
<code class="owner">{owner}</code> <div class="owner-item">
<code class="owner">{owner}</code>
{#if editMode}
<button class="remove-owner-btn" on:click={() => removeOwner(index)}>x</button>
{/if}
</div>
{:else} {:else}
<span class="no-owners">No owners configured</span> <span class="no-owners">No owners configured</span>
{/each} {/each}
@ -145,9 +325,11 @@
</section> </section>
</div> </div>
<div class="config-note"> {#if !editMode}
<p>Configuration is loaded from environment variables. To change settings, update the environment and restart the launcher.</p> <div class="config-note">
</div> <p>Configuration is saved to <code>{$configData.bin_dir?.replace(/\/bin$/, '')}/launcher.json</code>. Environment variables override file settings.</p>
</div>
{/if}
{:else if !$error} {:else if !$error}
<div class="loading">Loading configuration...</div> <div class="loading">Loading configuration...</div>
{/if} {/if}
@ -170,7 +352,12 @@
color: var(--text-color); color: var(--text-color);
} }
.refresh-btn { .header-buttons {
display: flex;
gap: 8px;
}
.refresh-btn, .edit-btn, .cancel-btn, .save-btn {
padding: 8px 16px; padding: 8px 16px;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -180,11 +367,27 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.refresh-btn:hover:not(:disabled) { .edit-btn {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.save-btn {
background: var(--success);
border-color: var(--success);
color: white;
}
.cancel-btn:hover:not(:disabled) {
background: var(--border-color); background: var(--border-color);
} }
.refresh-btn:disabled { .edit-btn:hover:not(:disabled), .save-btn:hover:not(:disabled) {
opacity: 0.9;
}
button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
@ -198,6 +401,37 @@
border: 1px solid #ffcdd2; border: 1px solid #ffcdd2;
} }
.message-banner {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.message-banner.success {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.message-banner.error {
background: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.restart-btn-inline {
padding: 4px 12px;
background: var(--primary);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.config-sections { .config-sections {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -238,6 +472,9 @@
.config-item .label { .config-item .label {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted-color); color: var(--muted-color);
display: flex;
align-items: center;
gap: 8px;
} }
.config-item .value { .config-item .value {
@ -258,6 +495,34 @@
color: var(--success); color: var(--success);
} }
.config-item input[type="text"],
.config-item select {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
}
.config-item input[type="text"]:focus,
.config-item select:focus {
outline: none;
border-color: var(--primary);
}
.toggle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle input[type="checkbox"] {
width: 18px;
height: 18px;
}
.owners-list { .owners-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -265,6 +530,12 @@
margin-top: 4px; margin-top: 4px;
} }
.owner-item {
display: flex;
align-items: center;
gap: 4px;
}
.owner { .owner {
font-size: 0.75rem; font-size: 0.75rem;
background: var(--bg-color); background: var(--bg-color);
@ -273,6 +544,26 @@
word-break: break-all; word-break: break-all;
} }
.remove-owner-btn {
padding: 2px 6px;
background: #ffebee;
border: none;
color: #c62828;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.add-owner-btn {
padding: 2px 8px;
background: var(--primary);
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.no-owners { .no-owners {
color: var(--muted-color); color: var(--muted-color);
font-style: italic; font-style: italic;
@ -292,6 +583,13 @@
margin: 0; margin: 0;
} }
.config-note code {
background: var(--bg-color);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85rem;
}
.loading { .loading {
text-align: center; text-align: center;
color: var(--muted-color); color: var(--muted-color);

28
cmd/orly-launcher/web/src/pages/Update.svelte

@ -86,19 +86,31 @@
function setReleaseUrls() { function setReleaseUrls() {
// Helper to fill in URLs from a release base // Helper to fill in URLs from a release base
const baseUrl = prompt('Enter release base URL (e.g., https://git.mleku.dev/mleku/next.orly.dev/releases/download/v0.55.11):'); let inputUrl = prompt('Enter release URL (e.g., https://git.mleku.dev/mleku/next.orly.dev/releases/tag/v0.56.0):');
if (!baseUrl) return; if (!inputUrl) return;
// Normalize the URL - convert /releases/tag/ to /releases/download/
let cleanBase = inputUrl.replace(/\/$/, '');
if (cleanBase.includes('/releases/tag/')) {
cleanBase = cleanBase.replace('/releases/tag/', '/releases/download/');
} else if (!cleanBase.includes('/releases/download/')) {
// If it's just a repo URL, construct the download path
const ver = version.trim() || 'v0.56.0';
cleanBase = cleanBase.replace(/\/$/, '') + '/releases/download/' + ver;
}
const cleanBase = baseUrl.replace(/\/$/, '');
const arch = prompt('Enter architecture (amd64 or arm64):', 'amd64'); const arch = prompt('Enter architecture (amd64 or arm64):', 'amd64');
if (!arch) return; if (!arch) return;
const ver = version.trim() || baseUrl.split('/').pop(); // Extract version from URL
const urlParts = cleanBase.split('/');
const ver = urlParts[urlParts.length - 1];
const verNum = ver.replace('v', '');
urls['orly'] = `${cleanBase}/orly-${ver.replace('v', '')}-linux-${arch}`; urls['orly'] = `${cleanBase}/orly-${verNum}-linux-${arch}`;
urls['orly-db-badger'] = `${cleanBase}/orly-db-badger-${ver.replace('v', '')}-linux-${arch}`; urls['orly-db-badger'] = `${cleanBase}/orly-db-badger-${verNum}-linux-${arch}`;
urls['orly-acl-follows'] = `${cleanBase}/orly-acl-follows-${ver.replace('v', '')}-linux-${arch}`; urls['orly-acl-follows'] = `${cleanBase}/orly-acl-follows-${verNum}-linux-${arch}`;
urls['orly-launcher'] = `${cleanBase}/orly-launcher-${ver.replace('v', '')}-linux-${arch}`; urls['orly-launcher'] = `${cleanBase}/orly-launcher-${verNum}-linux-${arch}`;
if (!version.trim()) { if (!version.trim()) {
version = ver; version = ver;

2
pkg/version/version

@ -1 +1 @@
v0.55.11 v0.56.0

Loading…
Cancel
Save