diff --git a/deploy/Containerfile.api b/deploy/Containerfile.api index 33002e7..8daaa48 100644 --- a/deploy/Containerfile.api +++ b/deploy/Containerfile.api @@ -13,7 +13,7 @@ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build \ - -ldflags="-s -w -X main.Version=${VERSION}" \ + -ldflags="-s -w -X github.com/narvanalabs/control-plane/internal/api.Version=${VERSION}" \ -o /api ./cmd/api # Runtime diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 1ad0eb9..e820ab8 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -28,6 +28,8 @@ services: ports: - "${API_PORT:-8080}:8080" - "${GRPC_PORT:-9090}:9090" + volumes: + - /var/lib/narvana:/var/lib/narvana # Shared volume for update flags environment: DATABASE_URL: postgres://narvana:${POSTGRES_PASSWORD}@postgres:5432/narvana?sslmode=disable JWT_SECRET: ${JWT_SECRET} diff --git a/deploy/narvana-updater.service b/deploy/narvana-updater.service new file mode 100644 index 0000000..697fbb3 --- /dev/null +++ b/deploy/narvana-updater.service @@ -0,0 +1,25 @@ +[Unit] +Description=Narvana Update Watcher +Documentation=https://github.com/narvanalabs/control-plane +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/narvana +ExecStart=/opt/narvana/update-watcher.sh +Restart=always +RestartSec=10 + +# Environment +Environment=UPDATE_FLAG_FILE=/var/lib/narvana/.update-requested +Environment=COMPOSE_FILE=/opt/narvana/compose.yaml +Environment=ENV_FILE=/opt/narvana/.env + +# Security +NoNewPrivileges=false +PrivateTmp=yes + +[Install] +WantedBy=multi-user.target + diff --git a/go.mod b/go.mod index e513799..079a539 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( require ( filippo.io/hpke v0.4.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index ca20107..18f11da 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Oudwins/tailwind-merge-go v0.2.1 h1:jxRaEqGtwwwF48UuFIQ8g8XT7YSualNuGzCvQ89nPFE= github.com/Oudwins/tailwind-merge-go v0.2.1/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U= github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= diff --git a/internal/api/handlers/github.go b/internal/api/handlers/github.go index 97f4660..d929920 100644 --- a/internal/api/handlers/github.go +++ b/internal/api/handlers/github.go @@ -367,6 +367,18 @@ func (h *GitHubHandler) AppInstall(w http.ResponseWriter, r *http.Request) { return } + // Ensure this is an App configuration (not OAuth) + if config.ConfigType != "app" { + WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "Not a GitHub App configuration"}) + return + } + + // Check if slug is available + if config.Slug == nil || *config.Slug == "" { + WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "GitHub App slug not available"}) + return + } + installURL := fmt.Sprintf("https://github.com/settings/apps/%s/installations/new", *config.Slug) WriteJSON(w, http.StatusOK, map[string]string{"url": installURL}) } diff --git a/internal/api/handlers/server_stats.go b/internal/api/handlers/server_stats.go index 6017d7d..8a48726 100644 --- a/internal/api/handlers/server_stats.go +++ b/internal/api/handlers/server_stats.go @@ -16,11 +16,15 @@ import ( ) type ServerStatsHandler struct { - logger *slog.Logger + logger *slog.Logger + version string } -func NewServerStatsHandler(logger *slog.Logger) *ServerStatsHandler { - return &ServerStatsHandler{logger: logger} +func NewServerStatsHandler(logger *slog.Logger, version string) *ServerStatsHandler { + return &ServerStatsHandler{ + logger: logger, + version: version, + } } type SystemStats struct { @@ -28,6 +32,7 @@ type SystemStats struct { Uptime float64 `json:"uptime"` OS string `json:"os"` Hostname string `json:"hostname"` + Version string `json:"version"` Timestamp int64 `json:"timestamp"` } @@ -91,16 +96,54 @@ func (h *ServerStatsHandler) collectStats() SystemStats { } hostname, _ := os.Hostname() + osName := detectOS() return SystemStats{ Resources: resources, Uptime: uptime, - OS: "Linux", + OS: osName, Hostname: hostname, + Version: h.version, Timestamp: time.Now().Unix(), } } +// detectOS reads /etc/os-release to get the actual OS name and version +func detectOS() string { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "Linux" // Fallback + } + + lines := strings.Split(string(data), "\n") + prettyName := "" + name := "" + version := "" + + for _, line := range lines { + if strings.HasPrefix(line, "PRETTY_NAME=") { + prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`) + } else if strings.HasPrefix(line, "NAME=") { + name = strings.Trim(strings.TrimPrefix(line, "NAME="), `"`) + } else if strings.HasPrefix(line, "VERSION=") { + version = strings.Trim(strings.TrimPrefix(line, "VERSION="), `"`) + } + } + + // Prefer PRETTY_NAME (e.g. "Ubuntu 24.04 LTS") + if prettyName != "" { + return prettyName + } + // Fallback to NAME + VERSION + if name != "" { + if version != "" { + return name + " " + version + } + return name + } + return "Linux" +} + type memInfo struct { Total int64 Available int64 diff --git a/internal/api/handlers/updates.go b/internal/api/handlers/updates.go new file mode 100644 index 0000000..7077251 --- /dev/null +++ b/internal/api/handlers/updates.go @@ -0,0 +1,80 @@ +// Package handlers provides HTTP handlers for the update system. +package handlers + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/narvanalabs/control-plane/internal/updater" +) + +// UpdatesHandler handles update-related requests. +type UpdatesHandler struct { + updater *updater.Service + logger *slog.Logger +} + +// NewUpdatesHandler creates a new updates handler. +func NewUpdatesHandler(updaterService *updater.Service, logger *slog.Logger) *UpdatesHandler { + return &UpdatesHandler{ + updater: updaterService, + logger: logger, + } +} + +// CheckForUpdates handles GET /v1/updates/check +func (h *UpdatesHandler) CheckForUpdates(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + info, err := h.updater.CheckForUpdates(ctx) + if err != nil { + h.logger.Error("failed to check for updates", "error", err) + WriteJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Failed to check for updates", + }) + return + } + + WriteJSON(w, http.StatusOK, info) +} + +// TriggerUpdate handles POST /v1/updates/apply +func (h *UpdatesHandler) TriggerUpdate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req struct { + Version string `json:"version"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + return + } + + if req.Version == "" { + WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Version is required", + }) + return + } + + // Start the update process + if err := h.updater.ApplyUpdate(ctx, req.Version); err != nil { + h.logger.Error("failed to apply update", "error", err, "version", req.Version) + WriteJSON(w, http.StatusInternalServerError, map[string]string{ + "error": err.Error(), + }) + return + } + + h.logger.Info("update initiated successfully", "version", req.Version) + WriteJSON(w, http.StatusOK, map[string]interface{}{ + "status": "success", + "message": "Update initiated. Services will restart shortly.", + "version": req.Version, + }) +} + diff --git a/internal/api/server.go b/internal/api/server.go index d045250..f67d66d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -19,6 +19,7 @@ import ( "github.com/narvanalabs/control-plane/internal/queue" "github.com/narvanalabs/control-plane/internal/secrets" "github.com/narvanalabs/control-plane/internal/store" + "github.com/narvanalabs/control-plane/internal/updater" "github.com/narvanalabs/control-plane/pkg/config" ) @@ -336,10 +337,16 @@ func (s *Server) setupRouter() { r.Post("/server/restart", serverLogsHandler.Restart) r.Get("/server/console/ws", serverLogsHandler.TerminalWS) - serverStatsHandler := handlers.NewServerStatsHandler(s.logger) + serverStatsHandler := handlers.NewServerStatsHandler(s.logger, Version) r.Get("/server/stats", serverStatsHandler.Get) r.Get("/server/stats/stream", serverStatsHandler.Stream) + // Update routes + updaterService := updater.NewService(Version, "narvanalabs/control-plane", s.logger) + updatesHandler := handlers.NewUpdatesHandler(updaterService, s.logger) + r.Get("/updates/check", updatesHandler.CheckForUpdates) + r.Post("/updates/apply", updatesHandler.TriggerUpdate) + // Admin cleanup routes // Requirements: 19.1, 19.2, 19.3, 19.4, 25.4, 26.4 podmanClientForCleanup := podman.NewClient(s.config.Worker.PodmanSocket, s.logger) diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..d4bb07a --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,214 @@ +// Package updater provides functionality to check for and apply updates. +package updater + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/Masterminds/semver/v3" +) + +// Service handles version checking and updates. +type Service struct { + currentVersion string + githubRepo string + logger *slog.Logger + httpClient *http.Client +} + +// NewService creates a new updater service. +func NewService(currentVersion, githubRepo string, logger *slog.Logger) *Service { + return &Service{ + currentVersion: currentVersion, + githubRepo: githubRepo, + logger: logger, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// UpdateInfo contains information about available updates. +type UpdateInfo struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + UpdateAvailable bool `json:"update_available"` + ReleaseURL string `json:"release_url,omitempty"` + ReleaseNotes string `json:"release_notes,omitempty"` + PublishedAt string `json:"published_at,omitempty"` +} + +// GitHubRelease represents a GitHub release from the API. +type GitHubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +// CheckForUpdates queries GitHub for the latest release and compares with current version. +func (s *Service) CheckForUpdates(ctx context.Context) (*UpdateInfo, error) { + info := &UpdateInfo{ + CurrentVersion: s.currentVersion, + } + + // Skip check if version is "dev" or empty + if s.currentVersion == "" || s.currentVersion == "dev" { + s.logger.Debug("skipping update check for dev version") + return info, nil + } + + // Fetch latest release from GitHub + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", s.githubRepo) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return info, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", "Narvana-Control-Plane") + + resp, err := s.httpClient.Do(req) + if err != nil { + return info, fmt.Errorf("failed to fetch releases: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return info, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var release GitHubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return info, fmt.Errorf("failed to decode release: %w", err) + } + + // Skip drafts and prereleases for stable version checks + if release.Draft || release.Prerelease { + s.logger.Debug("latest release is draft or prerelease, skipping", "tag", release.TagName) + return info, nil + } + + info.LatestVersion = release.TagName + info.ReleaseURL = release.HTMLURL + info.ReleaseNotes = release.Body + info.PublishedAt = release.PublishedAt.Format(time.RFC3339) + + // Compare versions + updateAvailable, err := s.isNewerVersion(release.TagName) + if err != nil { + s.logger.Warn("failed to compare versions", "error", err) + return info, nil // Don't fail, just return what we have + } + + info.UpdateAvailable = updateAvailable + return info, nil +} + +// isNewerVersion compares the latest version with current version. +func (s *Service) isNewerVersion(latestVersion string) (bool, error) { + // Normalize version strings (remove 'v' prefix if present) + current := strings.TrimPrefix(s.currentVersion, "v") + latest := strings.TrimPrefix(latestVersion, "v") + + currentVer, err := semver.NewVersion(current) + if err != nil { + return false, fmt.Errorf("invalid current version %q: %w", current, err) + } + + latestVer, err := semver.NewVersion(latest) + if err != nil { + return false, fmt.Errorf("invalid latest version %q: %w", latest, err) + } + + return latestVer.GreaterThan(currentVer), nil +} + +// ApplyUpdate performs the update by pulling new container images and restarting services. +// This is designed to work in containerized deployments using Podman/Docker Compose. +func (s *Service) ApplyUpdate(ctx context.Context, version string) error { + s.logger.Info("applying update", "version", version) + + // Determine deployment method + deployMethod := s.detectDeploymentMethod() + s.logger.Info("detected deployment method", "method", deployMethod) + + switch deployMethod { + case "compose": + return s.updateCompose(ctx, version) + case "systemd": + return s.updateSystemd(ctx, version) + default: + return fmt.Errorf("unsupported deployment method: %s", deployMethod) + } +} + +// detectDeploymentMethod checks how Narvana is deployed. +func (s *Service) detectDeploymentMethod() string { + // Check if running in a container (presence of /.dockerenv or /run/.containerenv) + if _, err := os.Stat("/.dockerenv"); err == nil { + return "compose" + } + if _, err := os.Stat("/run/.containerenv"); err == nil { + return "compose" + } + + // Check for systemd service + if _, err := exec.LookPath("systemctl"); err == nil { + cmd := exec.Command("systemctl", "is-active", "narvana-api") + if err := cmd.Run(); err == nil { + return "systemd" + } + } + + return "unknown" +} + +// updateCompose updates a Docker/Podman Compose deployment. +func (s *Service) updateCompose(ctx context.Context, version string) error { + // In a containerized environment, we need to trigger an external update script + // or communicate with the host system to restart the compose stack. + + // Set the desired version in the environment + composeFile := os.Getenv("COMPOSE_FILE") + if composeFile == "" { + composeFile = "/app/compose.yaml" // Default location in container + } + + // Write a flag file that the host can monitor to trigger updates + updateFlagFile := "/var/lib/narvana/.update-requested" + content := fmt.Sprintf("version=%s\ntimestamp=%s\n", version, time.Now().Format(time.RFC3339)) + + if err := os.WriteFile(updateFlagFile, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write update flag: %w", err) + } + + s.logger.Info("update flag written, waiting for external updater", "file", updateFlagFile, "version", version) + + // Note: The actual container restart must be done externally (by systemd, cron, or a watcher) + // because the API can't restart its own container from inside. + return nil +} + +// updateSystemd updates a systemd-based deployment. +func (s *Service) updateSystemd(ctx context.Context, version string) error { + // For systemd deployments, we would: + // 1. Download new binaries + // 2. Restart services + // This is more complex and depends on the installation method + + return fmt.Errorf("systemd-based updates not yet implemented") +} + diff --git a/scripts/install.sh b/scripts/install.sh index d52a849..36fa07c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -140,8 +140,66 @@ start_services() { success "Services started" } +wait_for_postgres() { + info "Waiting for PostgreSQL to be ready..." + local max_wait=30 + local waited=0 + + while [[ $waited -lt $max_wait ]]; do + if $RUNTIME exec narvana-postgres pg_isready -U narvana -d narvana &>/dev/null; then + success "PostgreSQL is ready" + return 0 + fi + sleep 2 + waited=$((waited + 2)) + echo -n "." + done + echo "" + warn "PostgreSQL health check timed out" + return 1 +} + +run_migrations() { + info "Running database migrations..." + + # List of migration files in order + local migrations=( + "001_initial_schema.sql" + "002_build_queue.sql" + "003_deployment_depends_on.sql" + "004_users.sql" + "005_service_source_config.sql" + "006_remove_app_build_type.sql" + "007_flexible_build_strategies.sql" + "008_github_app_settings.sql" + "009_add_config_type_to_github_app_settings.sql" + "010_github_accounts.sql" + "011_settings.sql" + "012_add_user_profile_fields.sql" + "013_add_app_icon_url.sql" + "014_add_auto_database_strategy.sql" + "015_add_domains_table.sql" + "016_organizations.sql" + "017_user_roles.sql" + "018_invitations.sql" + "019_domain_wildcard_verified.sql" + "020_app_version_optimistic_locking.sql" + "021_buildjob_source_fields.sql" + "022_node_disk_metrics.sql" + "023_replace_resource_tier_with_resources.sql" + "024_build_detection_result.sql" + ) + + for migration in "${migrations[@]}"; do + curl -fsSL "$GITHUB_RAW/migrations/$migration" 2>/dev/null | \ + $RUNTIME exec -i narvana-postgres psql -U narvana -d narvana -q 2>/dev/null || true + done + + success "Migrations completed" +} + wait_for_healthy() { - info "Waiting for services to be healthy..." + info "Waiting for API to be healthy..." local max_wait=60 local waited=0 @@ -217,8 +275,23 @@ main() { generate_env pull_images start_services + wait_for_postgres + run_migrations wait_for_healthy + # Install update watcher (optional, requires systemd) + if command -v systemctl &>/dev/null; then + info "Installing update watcher service..." + curl -fsSL "$GITHUB_RAW/scripts/update-watcher.sh" -o update-watcher.sh + chmod +x update-watcher.sh + curl -fsSL "$GITHUB_RAW/deploy/narvana-updater.service" -o /etc/systemd/system/narvana-updater.service + sed -i "s|/opt/narvana|$INSTALL_DIR|g" /etc/systemd/system/narvana-updater.service + systemctl daemon-reload + systemctl enable narvana-updater + systemctl start narvana-updater + success "Update watcher service installed" + fi + print_success } diff --git a/scripts/update-watcher.sh b/scripts/update-watcher.sh new file mode 100755 index 0000000..083a916 --- /dev/null +++ b/scripts/update-watcher.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Narvana Update Watcher +# This script monitors for update requests and performs the actual container restart + +set -euo pipefail + +UPDATE_FLAG_FILE="${UPDATE_FLAG_FILE:-/var/lib/narvana/.update-requested}" +COMPOSE_FILE="${COMPOSE_FILE:-/opt/narvana/compose.yaml}" +ENV_FILE="${ENV_FILE:-/opt/narvana/.env}" +COMPOSE_CMD="${COMPOSE_CMD:-podman-compose}" + +log() { + echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" +} + +perform_update() { + local version=$1 + log "Performing update to version $version..." + + # Update the version in .env + if [[ -f "$ENV_FILE" ]]; then + if grep -q "^NARVANA_VERSION=" "$ENV_FILE"; then + sed -i "s/^NARVANA_VERSION=.*/NARVANA_VERSION=$version/" "$ENV_FILE" + else + echo "NARVANA_VERSION=$version" >> "$ENV_FILE" + fi + log "Updated .env with version $version" + fi + + # Pull new images + log "Pulling new container images..." + cd "$(dirname "$COMPOSE_FILE")" + $COMPOSE_CMD pull || { + log "ERROR: Failed to pull images" + return 1 + } + + # Restart services + log "Restarting services..." + $COMPOSE_CMD down || log "WARN: Failed to stop services cleanly" + $COMPOSE_CMD up -d || { + log "ERROR: Failed to start services" + return 1 + } + + log "Update to $version completed successfully" + + # Remove the flag file + rm -f "$UPDATE_FLAG_FILE" +} + +# Main loop +log "Narvana Update Watcher started" +log "Monitoring: $UPDATE_FLAG_FILE" +log "Compose file: $COMPOSE_FILE" + +while true; do + if [[ -f "$UPDATE_FLAG_FILE" ]]; then + log "Update request detected" + + # Read the requested version + version=$(grep "^version=" "$UPDATE_FLAG_FILE" | cut -d= -f2) + + if [[ -z "$version" ]]; then + log "ERROR: No version specified in update flag" + rm -f "$UPDATE_FLAG_FILE" + else + perform_update "$version" + fi + fi + + sleep 10 +done + diff --git a/web/api/client.go b/web/api/client.go index 1247312..8895998 100644 --- a/web/api/client.go +++ b/web/api/client.go @@ -874,6 +874,29 @@ func (c *Client) ListGitHubRepos(ctx context.Context) ([]GitHubRepository, error // Settings Methods // ============================================================================ +// UpdateInfo contains information about available updates. +type UpdateInfo struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + UpdateAvailable bool `json:"update_available"` + ReleaseURL string `json:"release_url,omitempty"` + ReleaseNotes string `json:"release_notes,omitempty"` + PublishedAt string `json:"published_at,omitempty"` +} + +// CheckForUpdates checks if a new version is available. +func (c *Client) CheckForUpdates(ctx context.Context) (*UpdateInfo, error) { + var info UpdateInfo + err := c.Get(ctx, "/v1/updates/check", &info) + return &info, err +} + +// ApplyUpdate triggers an update to the specified version. +func (c *Client) ApplyUpdate(ctx context.Context, version string) error { + req := map[string]string{"version": version} + return c.post(ctx, "/v1/updates/apply", req, nil) +} + // GetSettings fetches global settings. func (c *Client) GetSettings(ctx context.Context) (map[string]string, error) { var settings map[string]string diff --git a/web/assets/js/ui-polish.js b/web/assets/js/ui-polish.js index e254d37..e7ff220 100644 --- a/web/assets/js/ui-polish.js +++ b/web/assets/js/ui-polish.js @@ -196,12 +196,14 @@ if (data.url) { window.location.href = data.url; + } else if (data.error) { + throw new Error(data.error); } else { - throw new Error(data.error || 'Failed to connect'); + throw new Error('Failed to get connection URL'); } } catch (err) { console.error('Connection failed:', err); - alert('Error: ' + err.message); + alert('Error connecting to GitHub: ' + err.message); btnConnectInstance.disabled = false; btnConnectInstance.innerHTML = 'Connect'; } diff --git a/web/pages/settings/server_stats.templ b/web/pages/settings/server_stats.templ index 80f3fc5..9c00990 100644 --- a/web/pages/settings/server_stats.templ +++ b/web/pages/settings/server_stats.templ @@ -22,6 +22,35 @@ templ ServerStats(data ServerStatsData) {

Monitor your server's resource usage and health in real-time

+ // Update Available Banner + + // Navigation tabs
@button.Button(button.Props{ @@ -116,7 +145,7 @@ templ ServerStats(data ServerStatsData) {
Control Plane Version - v0.1.0-alpha + --
@@ -208,8 +237,69 @@ templ ServerStats(data ServerStatsData) { document.getElementById('os-name').innerText = data.os; document.getElementById('hostname').innerText = data.hostname; document.getElementById('uptime').innerText = formatUptime(data.uptime); + document.getElementById('version').innerText = data.version || 'dev'; }; + // Check for updates + async function checkForUpdates() { + try { + const resp = await fetch('/api/updates/check'); + if (!resp.ok) return; + + const updateInfo = await resp.json(); + if (updateInfo.update_available) { + const banner = document.getElementById('update-banner'); + document.getElementById('update-latest-version').innerText = updateInfo.latest_version; + document.getElementById('update-current-version').innerText = updateInfo.current_version; + const releaseLink = document.getElementById('update-release-url'); + releaseLink.href = updateInfo.release_url; + banner.classList.remove('hidden'); + + // Store version for update button + document.getElementById('btn-apply-update').dataset.version = updateInfo.latest_version; + } + } catch (err) { + console.error('Failed to check for updates:', err); + } + } + + // Handle update button click + document.getElementById('btn-apply-update')?.addEventListener('click', async function() { + const version = this.dataset.version; + if (!version) return; + + if (!confirm(`Update to ${version}? This will restart the Narvana services.`)) return; + + this.disabled = true; + this.innerHTML = ' Updating...'; + + try { + const resp = await fetch('/api/updates/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version }) + }); + + const data = await resp.json(); + if (resp.ok) { + alert('Update initiated successfully. The services will restart in a moment.'); + // Reload page after a delay to show new version + setTimeout(() => window.location.reload(), 5000); + } else { + alert('Update failed: ' + (data.error || 'Unknown error')); + this.disabled = false; + this.innerHTML = '... Update Now'; + } + } catch (err) { + console.error('Update failed:', err); + alert('Update failed: ' + err.message); + this.disabled = false; + } + }); + + // Check for updates on page load + checkForUpdates(); + window.onbeforeunload = function() { eventSource.close(); };