Browse Source

Add launcher dashboard service controls and NIP-86 advertisement

- Add start/stop/restart buttons for individual services in launcher UI
- Show all available modules with categories (Database, ACL, Sync, Certs)
- Add enable/disable toggles with mutual exclusivity for DB/ACL backends
- Handle service dependencies when stopping (DB stops dependents first)
- Advertise NIP-86 in NIP-11 when ACL mode is managed or curating
- Add module descriptions showing API coverage

Files modified:
- app/handle-relayinfo.go: Add NIP-86 to supported NIPs for managed/curating modes
- cmd/orly-launcher/server.go: Add start-service/stop-service endpoints, ProcessStatus fields
- cmd/orly-launcher/supervisor.go: Add StartService, StopService with dependency handling
- cmd/orly-launcher/web/src/api.js: Add startService, stopService API functions
- cmd/orly-launcher/web/src/components/ProcessCard.svelte: Add toggles, categories, action buttons
- cmd/orly-launcher/web/src/pages/Dashboard.svelte: Add service control handlers
- pkg/version/version: Bump to v0.56.9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
main v0.56.9
woikos 4 months ago
parent
commit
9ecea8713f
No known key found for this signature in database
  1. 8
      app/handle-relayinfo.go
  2. 99
      cmd/orly-launcher/server.go
  3. 297
      cmd/orly-launcher/supervisor.go
  4. 2
      cmd/orly-launcher/web/dist/bundle.css
  5. 16
      cmd/orly-launcher/web/dist/bundle.js
  6. 36
      cmd/orly-launcher/web/src/api.js
  7. 269
      cmd/orly-launcher/web/src/components/ProcessCard.svelte
  8. 149
      cmd/orly-launcher/web/src/pages/Dashboard.svelte
  9. 2
      pkg/version/version

8
app/handle-relayinfo.go

@ -77,6 +77,10 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
if s.Config.NegentropyEnabled { if s.Config.NegentropyEnabled {
nips = append(nips, relayinfo.NIP{Number: 77, Description: "Negentropy-based sync"}) nips = append(nips, relayinfo.NIP{Number: 77, Description: "Negentropy-based sync"})
} }
// Add NIP-86 (Relay Management API) if ACL mode supports it
if s.Config.ACLMode == "managed" || s.Config.ACLMode == "curating" {
nips = append(nips, relayinfo.NIP{Number: 86, Description: "Relay Management API"})
}
supportedNIPs := relayinfo.GetList(nips...) supportedNIPs := relayinfo.GetList(nips...)
if s.Config.ACLMode != "none" { if s.Config.ACLMode != "none" {
nipsACL := []relayinfo.NIP{ nipsACL := []relayinfo.NIP{
@ -104,6 +108,10 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
if s.Config.NegentropyEnabled { if s.Config.NegentropyEnabled {
nipsACL = append(nipsACL, relayinfo.NIP{Number: 77, Description: "Negentropy-based sync"}) nipsACL = append(nipsACL, relayinfo.NIP{Number: 77, Description: "Negentropy-based sync"})
} }
// Add NIP-86 (Relay Management API) if ACL mode supports it
if s.Config.ACLMode == "managed" || s.Config.ACLMode == "curating" {
nipsACL = append(nipsACL, relayinfo.NIP{Number: 86, Description: "Relay Management API"})
}
supportedNIPs = relayinfo.GetList(nipsACL...) supportedNIPs = relayinfo.GetList(nipsACL...)
} }
sort.Sort(supportedNIPs) sort.Sort(supportedNIPs)

99
cmd/orly-launcher/server.go

@ -52,6 +52,8 @@ func (s *AdminServer) Start(ctx context.Context) error {
mux.HandleFunc("/api/rollback", s.auth.RequireAuth(s.handleRollback)) mux.HandleFunc("/api/rollback", s.auth.RequireAuth(s.handleRollback))
mux.HandleFunc("/api/start-services", s.auth.RequireAuth(s.handleStartServices)) mux.HandleFunc("/api/start-services", s.auth.RequireAuth(s.handleStartServices))
mux.HandleFunc("/api/stop-services", s.auth.RequireAuth(s.handleStopServices)) mux.HandleFunc("/api/stop-services", s.auth.RequireAuth(s.handleStopServices))
mux.HandleFunc("/api/start-service", s.auth.RequireAuth(s.handleStartService))
mux.HandleFunc("/api/stop-service", s.auth.RequireAuth(s.handleStopService))
addr := fmt.Sprintf(":%d", s.cfg.AdminPort) addr := fmt.Sprintf(":%d", s.cfg.AdminPort)
s.server = &http.Server{ s.server = &http.Server{
@ -84,7 +86,10 @@ type ProcessStatus struct {
Name string `json:"name"` Name string `json:"name"`
Binary string `json:"binary"` Binary string `json:"binary"`
Version string `json:"version"` Version string `json:"version"`
Status string `json:"status"` Status string `json:"status"` // running, stopped, disabled
Enabled bool `json:"enabled"`
Category string `json:"category"` // database, acl, sync, certs, relay
Description string `json:"description"`
PID int `json:"pid"` PID int `json:"pid"`
Restarts int `json:"restarts"` Restarts int `json:"restarts"`
StartedAt string `json:"started_at,omitempty"` StartedAt string `json:"started_at,omitempty"`
@ -654,6 +659,98 @@ func (s *AdminServer) handleStopServices(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
// StartServiceRequest is the request body for POST /api/start-service
type StartServiceRequest struct {
Service string `json:"service"`
}
// StartServiceResponse is the response for POST /api/start-service
type StartServiceResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func (s *AdminServer) handleStartService(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req StartServiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Service == "" {
http.Error(w, "Service name is required", http.StatusBadRequest)
return
}
// Start the service
go func() {
if err := s.supervisor.StartService(req.Service); chk.E(err) {
log.E.F("start service %s failed: %v", req.Service, err)
} else {
log.I.F("started service: %s", req.Service)
}
}()
response := StartServiceResponse{
Success: true,
Message: fmt.Sprintf("Start of %s initiated", req.Service),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// StopServiceRequest is the request body for POST /api/stop-service
type StopServiceRequest struct {
Service string `json:"service"`
}
// StopServiceResponse is the response for POST /api/stop-service
type StopServiceResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func (s *AdminServer) handleStopService(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req StopServiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Service == "" {
http.Error(w, "Service name is required", http.StatusBadRequest)
return
}
// Stop the service
go func() {
if err := s.supervisor.StopService(req.Service); chk.E(err) {
log.E.F("stop service %s failed: %v", req.Service, err)
} else {
log.I.F("stopped service: %s", req.Service)
}
}()
response := StopServiceResponse{
Success: true,
Message: fmt.Sprintf("Stop of %s initiated", req.Service),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (s *AdminServer) serveUI(w http.ResponseWriter, r *http.Request) { func (s *AdminServer) serveUI(w http.ResponseWriter, r *http.Request) {
s.serveAdminUI(w, r) s.serveAdminUI(w, r)
} }

297
cmd/orly-launcher/supervisor.go

@ -917,59 +917,142 @@ func (s *Supervisor) startCerts() error {
return nil return nil
} }
// GetProcessStatuses returns the status of all managed processes. // GetProcessStatuses returns the status of all available modules with categories.
// Modules are grouped by category, and some categories are mutually exclusive.
func (s *Supervisor) GetProcessStatuses() []ProcessStatus { func (s *Supervisor) GetProcessStatuses() []ProcessStatus {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
var statuses []ProcessStatus var statuses []ProcessStatus
// Database process // Database backends (mutually exclusive - only one can be active)
if s.dbProc != nil { isBadger := s.cfg.DBBackend == "badger"
statuses = append(statuses, s.getProcessStatus(s.dbProc, s.cfg.DBBinary)) isNeo4j := s.cfg.DBBackend == "neo4j"
}
statuses = append(statuses, s.getProcessStatusFull(
s.dbProc, "orly-db-badger", "orly-db-badger",
isBadger, "database", "Badger embedded key-value store (default)",
))
statuses = append(statuses, s.getProcessStatusFull(
nil, "orly-db-neo4j", "orly-db-neo4j",
isNeo4j, "database", "Neo4j graph database for WoT queries",
))
// ACL backends (mutually exclusive - only one can be active, can be disabled)
isFollows := s.cfg.ACLEnabled && s.cfg.ACLMode == "follows"
isManaged := s.cfg.ACLEnabled && s.cfg.ACLMode == "managed"
isCuration := s.cfg.ACLEnabled && (s.cfg.ACLMode == "curation" || s.cfg.ACLMode == "curating")
statuses = append(statuses, s.getProcessStatusFull(
s.aclProc, "orly-acl-follows", "orly-acl-follows",
isFollows, "acl", "Whitelist based on admin's follow list",
))
statuses = append(statuses, s.getProcessStatusFull(
nil, "orly-acl-managed", "orly-acl-managed",
isManaged, "acl", "NIP-86 fine-grained access control",
))
statuses = append(statuses, s.getProcessStatusFull(
nil, "orly-acl-curation", "orly-acl-curation",
isCuration, "acl", "Rate-limited trust tiers for curation",
))
// Sync services (independent - multiple can be enabled)
statuses = append(statuses, s.getProcessStatusFull(
s.distributedSyncProc, "orly-sync-distributed", s.cfg.DistributedSyncBinary,
s.cfg.DistributedSyncEnabled, "sync", "Distributed event synchronization",
))
statuses = append(statuses, s.getProcessStatusFull(
s.clusterSyncProc, "orly-sync-cluster", s.cfg.ClusterSyncBinary,
s.cfg.ClusterSyncEnabled, "sync", "Cluster synchronization for HA",
))
statuses = append(statuses, s.getProcessStatusFull(
s.relayGroupProc, "orly-sync-relaygroup", s.cfg.RelayGroupBinary,
s.cfg.RelayGroupEnabled, "sync", "NIP-29 relay group synchronization",
))
statuses = append(statuses, s.getProcessStatusFull(
s.negentropyProc, "orly-sync-negentropy", s.cfg.NegentropyBinary,
s.cfg.NegentropyEnabled, "sync", "NIP-77 negentropy reconciliation",
))
// Certificate service (standalone)
statuses = append(statuses, s.getProcessStatusFull(
s.certsProc, "orly-certs", s.cfg.CertsBinary,
s.cfg.CertsEnabled, "certs", "Let's Encrypt certificate management",
))
// Relay process - always enabled
statuses = append(statuses, s.getProcessStatusFull(
s.relayProc, "orly", s.cfg.RelayBinary,
true, "relay", "Main Nostr relay server",
))
// ACL process return statuses
if s.cfg.ACLEnabled && s.aclProc != nil {
statuses = append(statuses, s.getProcessStatus(s.aclProc, s.cfg.ACLBinary))
} }
// Sync services func (s *Supervisor) getProcessStatus(p *Process, binaryPath string, enabled bool) ProcessStatus {
if s.cfg.DistributedSyncEnabled && s.distributedSyncProc != nil { status := "stopped"
statuses = append(statuses, s.getProcessStatus(s.distributedSyncProc, s.cfg.DistributedSyncBinary)) pid := 0
p.mu.Lock()
defer p.mu.Unlock()
if p.cmd != nil && p.cmd.Process != nil {
// Check if process is still running
select {
case <-p.exited:
status = "stopped"
default:
status = "running"
pid = p.cmd.Process.Pid
} }
if s.cfg.ClusterSyncEnabled && s.clusterSyncProc != nil {
statuses = append(statuses, s.getProcessStatus(s.clusterSyncProc, s.cfg.ClusterSyncBinary))
} }
if s.cfg.RelayGroupEnabled && s.relayGroupProc != nil {
statuses = append(statuses, s.getProcessStatus(s.relayGroupProc, s.cfg.RelayGroupBinary)) return ProcessStatus{
Name: p.name,
Binary: binaryPath,
Version: "", // Will be filled by caller if needed
Status: status,
Enabled: enabled,
PID: pid,
Restarts: p.restarts,
} }
if s.cfg.NegentropyEnabled && s.negentropyProc != nil {
statuses = append(statuses, s.getProcessStatus(s.negentropyProc, s.cfg.NegentropyBinary))
} }
// Certificate service // getProcessStatusOrDisabled returns the status of a process, or a disabled status if the process is nil.
if s.cfg.CertsEnabled && s.certsProc != nil { func (s *Supervisor) getProcessStatusOrDisabled(p *Process, name, binaryPath string, enabled bool) ProcessStatus {
statuses = append(statuses, s.getProcessStatus(s.certsProc, s.cfg.CertsBinary)) if p != nil {
return s.getProcessStatus(p, binaryPath, enabled)
} }
// Relay process // Process doesn't exist - show as disabled or stopped
if s.relayProc != nil { status := "stopped"
statuses = append(statuses, s.getProcessStatus(s.relayProc, s.cfg.RelayBinary)) if !enabled {
status = "disabled"
} }
return statuses return ProcessStatus{
Name: name,
Binary: binaryPath,
Status: status,
Enabled: enabled,
}
} }
func (s *Supervisor) getProcessStatus(p *Process, binaryPath string) ProcessStatus { // getProcessStatusFull returns the status of a process with category and description.
status := "stopped" func (s *Supervisor) getProcessStatusFull(p *Process, name, binaryPath string, enabled bool, category, description string) ProcessStatus {
status := "disabled"
pid := 0 pid := 0
restarts := 0
if enabled {
status = "stopped"
}
if p != nil {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.cmd != nil && p.cmd.Process != nil { if p.cmd != nil && p.cmd.Process != nil {
// Check if process is still running
select { select {
case <-p.exited: case <-p.exited:
status = "stopped" status = "stopped"
@ -978,14 +1061,18 @@ func (s *Supervisor) getProcessStatus(p *Process, binaryPath string) ProcessStat
pid = p.cmd.Process.Pid pid = p.cmd.Process.Pid
} }
} }
restarts = p.restarts
}
return ProcessStatus{ return ProcessStatus{
Name: p.name, Name: name,
Binary: binaryPath, Binary: binaryPath,
Version: "", // Will be filled by caller if needed
Status: status, Status: status,
Enabled: enabled,
Category: category,
Description: description,
PID: pid, PID: pid,
Restarts: p.restarts, Restarts: restarts,
} }
} }
@ -1153,6 +1240,156 @@ func (s *Supervisor) RestartService(serviceName string) ([]string, error) {
return restarted, nil return restarted, nil
} }
// StartService starts a specific service if it's not already running.
func (s *Supervisor) StartService(serviceName string) error {
log.I.F("starting service: %s", serviceName)
switch serviceName {
case "orly-db", "db":
if err := s.startDB(); err != nil {
return fmt.Errorf("failed to start db: %w", err)
}
if err := s.waitForDBReady(s.cfg.DBReadyTimeout); err != nil {
return fmt.Errorf("db not ready: %w", err)
}
case "orly-acl", "acl":
if !s.cfg.ACLEnabled {
return fmt.Errorf("ACL is not enabled in configuration")
}
if err := s.startACL(); err != nil {
return fmt.Errorf("failed to start acl: %w", err)
}
if err := s.waitForACLReady(s.cfg.ACLReadyTimeout); err != nil {
return fmt.Errorf("acl not ready: %w", err)
}
case "orly-sync-distributed", "distributed-sync":
if !s.cfg.DistributedSyncEnabled {
return fmt.Errorf("distributed sync is not enabled in configuration")
}
if err := s.startDistributedSync(); err != nil {
return fmt.Errorf("failed to start distributed sync: %w", err)
}
case "orly-sync-cluster", "cluster-sync":
if !s.cfg.ClusterSyncEnabled {
return fmt.Errorf("cluster sync is not enabled in configuration")
}
if err := s.startClusterSync(); err != nil {
return fmt.Errorf("failed to start cluster sync: %w", err)
}
case "orly-sync-relaygroup", "relaygroup":
if !s.cfg.RelayGroupEnabled {
return fmt.Errorf("relaygroup is not enabled in configuration")
}
if err := s.startRelayGroup(); err != nil {
return fmt.Errorf("failed to start relaygroup: %w", err)
}
case "orly-sync-negentropy", "negentropy":
if !s.cfg.NegentropyEnabled {
return fmt.Errorf("negentropy is not enabled in configuration")
}
if err := s.startNegentropy(); err != nil {
return fmt.Errorf("failed to start negentropy: %w", err)
}
case "orly-certs", "certs":
if !s.cfg.CertsEnabled {
return fmt.Errorf("certificate service is not enabled in configuration")
}
if err := s.startCerts(); err != nil {
return fmt.Errorf("failed to start certificate service: %w", err)
}
case "orly", "relay":
if err := s.startRelay(); err != nil {
return fmt.Errorf("failed to start relay: %w", err)
}
default:
return fmt.Errorf("unknown service: %s", serviceName)
}
log.I.F("started service: %s", serviceName)
return nil
}
// StopService stops a specific service and its dependents.
// Dependency chain: db → acl, sync services → relay
// Stopping a service will first stop all services that depend on it.
func (s *Supervisor) StopService(serviceName string) error {
log.I.F("stopping service: %s", serviceName)
switch serviceName {
case "orly-db", "db":
// DB is the root - everything depends on it
// Stop in reverse dependency order: relay, sync, acl, then db
log.I.F("stopping relay (depends on db)")
s.stopProcess(s.relayProc, 5*time.Second)
log.I.F("stopping sync services (depend on db)")
s.stopSyncServices()
if s.cfg.ACLEnabled && s.aclProc != nil {
log.I.F("stopping acl (depends on db)")
s.stopProcess(s.aclProc, 5*time.Second)
}
log.I.F("stopping db")
s.stopProcess(s.dbProc, s.cfg.StopTimeout)
case "orly-acl", "acl":
// Relay depends on ACL when ACL is enabled
if s.cfg.ACLEnabled {
log.I.F("stopping relay (depends on acl)")
s.stopProcess(s.relayProc, 5*time.Second)
}
if s.aclProc != nil {
s.stopProcess(s.aclProc, 5*time.Second)
}
case "orly-sync-distributed", "distributed-sync":
// Relay may depend on sync services
if s.distributedSyncProc != nil {
s.stopProcess(s.distributedSyncProc, 5*time.Second)
}
case "orly-sync-cluster", "cluster-sync":
if s.clusterSyncProc != nil {
s.stopProcess(s.clusterSyncProc, 5*time.Second)
}
case "orly-sync-relaygroup", "relaygroup":
if s.relayGroupProc != nil {
s.stopProcess(s.relayGroupProc, 5*time.Second)
}
case "orly-sync-negentropy", "negentropy":
if s.negentropyProc != nil {
s.stopProcess(s.negentropyProc, 5*time.Second)
}
case "orly-certs", "certs":
// Certs is independent
if s.certsProc != nil {
s.stopProcess(s.certsProc, 5*time.Second)
}
case "orly", "relay":
// Relay is a leaf - nothing depends on it
s.stopProcess(s.relayProc, 5*time.Second)
default:
return fmt.Errorf("unknown service: %s", serviceName)
}
log.I.F("stopped service: %s", serviceName)
return nil
}
// RestartAll stops all processes and starts them again. // RestartAll stops all processes and starts them again.
func (s *Supervisor) RestartAll() error { func (s *Supervisor) RestartAll() error {
log.I.F("restarting all processes...") log.I.F("restarting all processes...")

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

@ -226,3 +226,39 @@ export async function stopServices(signer, pubkey) {
} }
return response.json(); return response.json();
} }
/**
* Start a specific service
* @param {string} service - The service name (e.g., 'orly-db', 'orly-acl', 'orly')
*/
export async function startService(signer, pubkey, service) {
const response = await authFetch('/api/start-service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
}, signer, pubkey);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || `Start failed: ${response.statusText}`);
}
return response.json();
}
/**
* Stop a specific service (and its dependents)
* @param {string} service - The service name (e.g., 'orly-db', 'orly-acl', 'orly')
*/
export async function stopService(signer, pubkey, service) {
const response = await authFetch('/api/stop-service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service }),
}, signer, pubkey);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || `Stop failed: ${response.statusText}`);
}
return response.json();
}

269
cmd/orly-launcher/web/src/components/ProcessCard.svelte

@ -1,10 +1,25 @@
<script> <script>
import { createEventDispatcher } from 'svelte';
export let process; export let process;
export let isLoading = false;
const dispatch = createEventDispatcher();
// Categories that are mutually exclusive (only one can be enabled)
const exclusiveCategories = ['database', 'acl'];
// Check if this process can be toggled (relay is always on)
$: canToggle = process.category !== 'relay';
// Check if this is in an exclusive category
$: isExclusive = exclusiveCategories.includes(process.category);
function getStatusColor(status) { function getStatusColor(status) {
switch (status) { switch (status) {
case 'running': return 'var(--success)'; case 'running': return 'var(--success)';
case 'stopped': return 'var(--muted-color)'; case 'stopped': return 'var(--muted-color)';
case 'disabled': return 'var(--muted-color)';
case 'crashed': return 'var(--error)'; case 'crashed': return 'var(--error)';
default: return 'var(--muted-color)'; default: return 'var(--muted-color)';
} }
@ -14,19 +29,75 @@
switch (status) { switch (status) {
case 'running': return '●'; case 'running': return '●';
case 'stopped': return '○'; case 'stopped': return '○';
case 'disabled': return '◌';
case 'crashed': return '✗'; case 'crashed': return '✗';
default: return '?'; default: return '?';
} }
} }
function getCategoryLabel(category) {
switch (category) {
case 'database': return 'Database';
case 'acl': return 'Access Control';
case 'sync': return 'Sync Service';
case 'certs': return 'Certificates';
case 'relay': return 'Relay';
default: return category;
}
}
function handleStart() {
dispatch('start', { service: process.name });
}
function handleStop() {
dispatch('stop', { service: process.name });
}
function handleRestart() {
dispatch('restart', { service: process.name });
}
function handleToggleEnabled(event) {
const newEnabled = event.target.checked;
dispatch('toggle-enabled', {
service: process.name,
enabled: newEnabled,
category: process.category,
isExclusive: isExclusive
});
}
$: canStart = process.enabled && process.status !== 'running';
$: canStop = process.status === 'running';
$: canRestart = process.status === 'running';
</script> </script>
<div class="process-card"> <div class="process-card" class:disabled={!process.enabled}>
<div class="process-header"> <div class="process-header">
<span class="status-indicator" style="color: {getStatusColor(process.status)}"> <span class="status-indicator" style="color: {getStatusColor(process.status)}">
{getStatusIcon(process.status)} {getStatusIcon(process.status)}
</span> </span>
<div class="name-section">
<span class="process-name">{process.name}</span> <span class="process-name">{process.name}</span>
<span class="category-badge" class:exclusive={isExclusive}>{getCategoryLabel(process.category)}</span>
</div> </div>
{#if canToggle}
<label class="enable-toggle" title={process.enabled ? 'Disable' : 'Enable'}>
<input
type="checkbox"
checked={process.enabled}
on:change={handleToggleEnabled}
disabled={isLoading || process.status === 'running'}
/>
<span class="toggle-slider"></span>
</label>
{:else}
<span class="badge required-badge">always on</span>
{/if}
</div>
<p class="description">{process.description}</p>
<div class="process-details"> <div class="process-details">
<div class="detail-row"> <div class="detail-row">
@ -43,11 +114,6 @@
</div> </div>
{/if} {/if}
<div class="detail-row">
<span class="label">Binary:</span>
<span class="value binary">{process.binary}</span>
</div>
{#if process.restarts > 0} {#if process.restarts > 0}
<div class="detail-row"> <div class="detail-row">
<span class="label">Restarts:</span> <span class="label">Restarts:</span>
@ -55,6 +121,27 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="process-actions">
{#if canStart}
<button class="action-btn start-btn" on:click={handleStart} disabled={isLoading} title="Start service">
</button>
{/if}
{#if canStop}
<button class="action-btn stop-btn" on:click={handleStop} disabled={isLoading} title="Stop service">
</button>
{/if}
{#if canRestart}
<button class="action-btn restart-btn" on:click={handleRestart} disabled={isLoading} title="Restart service">
</button>
{/if}
{#if !process.enabled && !canStart && !canStop}
<span class="hint">Enable to start</span>
{/if}
</div>
</div> </div>
<style> <style>
@ -63,35 +150,137 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.process-card.disabled {
opacity: 0.6;
} }
.process-header { .process-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px;
} }
.status-indicator { .status-indicator {
font-size: 1.2rem; font-size: 1.2rem;
flex-shrink: 0;
}
.name-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
} }
.process-name { .process-name {
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 0.95rem;
color: var(--text-color); color: var(--text-color);
} }
.category-badge {
font-size: 0.65rem;
padding: 1px 4px;
border-radius: 3px;
text-transform: uppercase;
background: var(--border-color);
color: var(--muted-color);
width: fit-content;
}
.category-badge.exclusive {
background: var(--warning, #ff9800);
color: white;
opacity: 0.8;
}
.description {
font-size: 0.8rem;
color: var(--muted-color);
margin: 0;
line-height: 1.3;
}
.badge {
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
flex-shrink: 0;
}
.required-badge {
background: var(--text-color);
color: var(--card-bg);
opacity: 0.4;
}
.enable-toggle {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.enable-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--muted-color);
border-radius: 20px;
transition: 0.2s;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: 0.2s;
}
.enable-toggle input:checked + .toggle-slider {
background-color: var(--success, #4caf50);
}
.enable-toggle input:checked + .toggle-slider:before {
transform: translateX(16px);
}
.enable-toggle input:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
.process-details { .process-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
} }
.detail-row { .detail-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.85rem; font-size: 0.8rem;
} }
.label { .label {
@ -103,15 +292,59 @@
font-family: monospace; font-family: monospace;
} }
.value.binary {
font-size: 0.75rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value.warning { .value.warning {
color: var(--warning); color: var(--warning);
} }
.process-actions {
display: flex;
gap: 8px;
align-items: center;
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--border-color);
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s, transform 0.1s;
}
.action-btn:hover:not(:disabled) {
transform: scale(1.05);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.start-btn {
background: var(--success, #4caf50);
color: white;
}
.stop-btn {
background: var(--error, #f44336);
color: white;
}
.restart-btn {
background: var(--warning, #ff9800);
color: white;
}
.hint {
font-size: 0.75rem;
color: var(--muted-color);
font-style: italic;
}
</style> </style>

149
cmd/orly-launcher/web/src/pages/Dashboard.svelte

@ -1,7 +1,7 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { userSigner, userPubkey, statusData, isLoading, error } from '../stores.js'; import { userSigner, userPubkey, statusData, isLoading, error } from '../stores.js';
import { fetchStatus, restartServices, startServices, stopServices } from '../api.js'; import { fetchStatus, restartServices, startServices, stopServices, startService, stopService, restartService, saveConfig } from '../api.js';
import ProcessCard from '../components/ProcessCard.svelte'; import ProcessCard from '../components/ProcessCard.svelte';
let refreshInterval; let refreshInterval;
@ -73,6 +73,138 @@
$isLoading = false; $isLoading = false;
} }
} }
async function handleServiceStart(event) {
const { service } = event.detail;
$isLoading = true;
try {
await startService($userSigner, $userPubkey, service);
// Wait a moment then refresh
setTimeout(loadStatus, 2000);
} catch (e) {
$error = e.message;
} finally {
$isLoading = false;
}
}
async function handleServiceStop(event) {
const { service } = event.detail;
// Check if this is a critical service with dependents
const hasDependents = ['orly-db', 'orly-acl'].includes(service);
if (hasDependents) {
if (!confirm(`Stopping ${service} will also stop its dependent services. Continue?`)) {
return;
}
}
$isLoading = true;
try {
await stopService($userSigner, $userPubkey, service);
// Wait a moment then refresh
setTimeout(loadStatus, 2000);
} catch (e) {
$error = e.message;
} finally {
$isLoading = false;
}
}
async function handleServiceRestart(event) {
const { service } = event.detail;
// Check if this is a critical service with dependents
const hasDependents = ['orly-db', 'orly-acl'].includes(service);
if (hasDependents) {
if (!confirm(`Restarting ${service} will also restart its dependent services. Continue?`)) {
return;
}
}
$isLoading = true;
try {
await restartService($userSigner, $userPubkey, service);
// Wait a moment then refresh
setTimeout(loadStatus, 2000);
} catch (e) {
$error = e.message;
} finally {
$isLoading = false;
}
}
// Map service names to config properties
function getConfigForService(service, enabled) {
switch (service) {
// Database backends (mutually exclusive)
case 'orly-db-badger':
return enabled ? { db_backend: 'badger' } : null;
case 'orly-db-neo4j':
return enabled ? { db_backend: 'neo4j' } : null;
// ACL backends (mutually exclusive)
case 'orly-acl-follows':
return enabled ? { acl_enabled: true, acl_mode: 'follows' } : { acl_enabled: false };
case 'orly-acl-managed':
return enabled ? { acl_enabled: true, acl_mode: 'managed' } : { acl_enabled: false };
case 'orly-acl-curation':
return enabled ? { acl_enabled: true, acl_mode: 'curation' } : { acl_enabled: false };
// Sync services (independent)
case 'orly-sync-distributed':
return { distributed_sync_enabled: enabled };
case 'orly-sync-cluster':
return { cluster_sync_enabled: enabled };
case 'orly-sync-relaygroup':
return { relay_group_enabled: enabled };
case 'orly-sync-negentropy':
return { negentropy_enabled: enabled };
// Certificate service
case 'orly-certs':
return { certs_enabled: enabled };
default:
return null;
}
}
async function handleToggleEnabled(event) {
const { service, enabled, category, isExclusive } = event.detail;
// For exclusive categories, warn if enabling will disable others
if (enabled && isExclusive) {
const currentlyEnabled = $statusData.processes
.filter(p => p.category === category && p.enabled && p.name !== service)
.map(p => p.name);
if (currentlyEnabled.length > 0) {
if (!confirm(`Enabling ${service} will disable ${currentlyEnabled.join(', ')}. Continue?`)) {
// Refresh to reset the checkbox
await loadStatus();
return;
}
}
}
const configUpdate = getConfigForService(service, enabled);
if (!configUpdate) {
$error = `Unknown service: ${service}`;
return;
}
$isLoading = true;
try {
await saveConfig($userSigner, $userPubkey, configUpdate);
// Refresh status after config change
setTimeout(loadStatus, 1000);
} catch (e) {
$error = e.message;
// Refresh to reset the checkbox
setTimeout(loadStatus, 500);
} finally {
$isLoading = false;
}
}
</script> </script>
<div class="dashboard"> <div class="dashboard">
@ -118,15 +250,22 @@
<span class="value">{$statusData.uptime}</span> <span class="value">{$statusData.uptime}</span>
</div> </div>
<div class="summary-card"> <div class="summary-card">
<span class="label">Processes</span> <span class="label">Running</span>
<span class="value">{$statusData.processes?.length || 0}</span> <span class="value">{$statusData.processes?.filter(p => p.status === 'running').length || 0} / {$statusData.processes?.filter(p => p.enabled).length || 0}</span>
</div> </div>
</div> </div>
<h3>Managed Processes</h3> <h3>Available Modules</h3>
<div class="processes-grid"> <div class="processes-grid">
{#each $statusData.processes || [] as process} {#each $statusData.processes || [] as process}
<ProcessCard {process} /> <ProcessCard
{process}
isLoading={$isLoading}
on:start={handleServiceStart}
on:stop={handleServiceStop}
on:restart={handleServiceRestart}
on:toggle-enabled={handleToggleEnabled}
/>
{/each} {/each}
</div> </div>
{:else if !$error} {:else if !$error}

2
pkg/version/version

@ -1 +1 @@
v0.56.8 v0.56.9

Loading…
Cancel
Save