From 5cef4d58a0155401880e0dd7160cdde5ede61d1e Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 15 Jul 2026 22:40:56 +1000 Subject: [PATCH 1/8] refactor(web): receipt-style config panel and deployment status bars - Rework service config overview into a mono, divider-separated spec sheet - Restyle pending changes and deployment progress bars: status dot header, mono change table, accent tint, stage progress line - Attach bars below the service overview card as a slide-out sheet, width-matched to the rollout history container - Make Abort destructive; drop stage text duplicated by rollout rows Co-Authored-By: Claude Fable 5 --- .../[env]/services/[serviceId]/page.tsx | 4 +- .../service/details/deployment-progress.tsx | 107 ++++++++++-------- .../details/pending-changes-banner.tsx | 105 +++++++++-------- .../details/service-details-overview.tsx | 81 +++++++------ 4 files changed, 155 insertions(+), 142 deletions(-) diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx index 278395c1..c0a41eaa 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx @@ -126,6 +126,8 @@ export default function DeploymentsPage() { return (
+ + - -
0 ? Math.min((current + 1) / total, 1) : 0; + + return ( +
+
+
+ ); +} + interface DeploymentProgressProps { service: Service; changes: ConfigChange[]; @@ -253,19 +266,17 @@ export const DeploymentProgress = memo(function DeploymentProgress({ let content: React.ReactNode = null; if (barState.mode === "building") { + const buildStageIndex = ACTIVE_BUILD_STATUSES.indexOf(barState.buildStatus); + content = ( -
-
-
-
- -
-
-

Building

-

- {BUILD_STATUS_LABELS[barState.buildStatus] || "Building"} -

-
+
+
+
+ + Building + + {BUILD_STATUS_LABELS[barState.buildStatus] || "Building"} +
+
); } if (barState.mode === "deploying") { - const currentStage = STAGES[barState.stageIndex]; const isMigrating = !!service.migrationStatus; const isMigrationFailed = service.migrationStatus === "failed"; - let status = currentStage?.label || "Deploying"; - if (barState.stage === "health_check" && !service.healthCheckCmd) { - status = "Starting container"; - } - if (isMigrating && service.migrationStatus) { - status = - MIGRATION_STAGES[service.migrationStatus] || - service.migrationStatus || - "Migrating"; - } + const migrationStatus = + isMigrating && service.migrationStatus + ? MIGRATION_STAGES[service.migrationStatus] || service.migrationStatus + : null; content = ( -
-
-
- {isMigrationFailed ? ( -
- -
- ) : ( -
- -
- )} -
-

- {isMigrating ? "Migrating" : "Deploying"} -

-

{status}

-
+
+
+
+ + + {isMigrating ? "Migrating" : "Deploying"} + + {migrationStatus ? ( + + {migrationStatus} + + ) : null}
{isMigrating ? (
+ {!isMigrating ? ( + + ) : null}
); } @@ -351,9 +368,7 @@ export const DeploymentProgress = memo(function DeploymentProgress({ opacity: isVisible ? 1 : 0, }} > -
- {content &&
{content}
} -
+
{content}
); }); diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx index e776a8e2..b7ca45c6 100644 --- a/web/components/service/details/pending-changes-banner.tsx +++ b/web/components/service/details/pending-changes-banner.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertTriangle, ArrowRight, Rocket } from "lucide-react"; +import { Rocket } from "lucide-react"; import { useRouter } from "next/navigation"; import { memo, useState } from "react"; import { useSWRConfig } from "swr"; @@ -72,60 +72,59 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ }} >
-
-
-
-
-
- -
-
-

- {hasChanges - ? `${changes.length} pending change${changes.length !== 1 ? "s" : ""}` - : "Ready to deploy"} -

- {hasChanges ? ( -
- {changes.map((change, index) => ( -
- - {change.field}: - - - {change.from} - - - - {change.to} - -
- ))} -
- ) : ( -

- This service has no active deployments. -

- )} -
-
- +
+
+
+ + + {hasChanges + ? `${changes.length} pending change${changes.length !== 1 ? "s" : ""}` + : "Ready to deploy"} +
+
+ {hasChanges ? ( +
+ {changes.map((change, index) => ( +
+ + {change.field} + + + + {change.from} + + + + {change.to} + + +
+ ))} +
+ ) : ( +

+ This service has no active deployments. +

+ )}
diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 5bc2672b..517dda7a 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -440,16 +440,31 @@ function ServiceConfigPanel({ return (
-
-
- - - {overview.status.label} +
+
+
+ + + {overview.status.label} + +
+ + {formatInstanceSummary(overview)}
- - {formatInstanceSummary(overview)} - + {overview.serverSummaries.length === 0 ? ( +

No servers configured

+ ) : ( + overview.serverSummaries.map((server) => ( + + + {server.configured > 0 + ? `${server.running}/${server.configured} running` + : `${server.running} running`} + + + )) + )}
@@ -457,6 +472,13 @@ function ServiceConfigPanel({ + {service.volumes && service.volumes.length > 0 ? ( + + {service.volumes + .map((volume) => `${volume.name} → ${volume.containerPath}`) + .join(", ")} + + ) : null} {overview.source.branch ? ( {overview.source.branch} ) : null} @@ -465,31 +487,15 @@ function ServiceConfigPanel({ {service.githubRootDir} ) : null} - - {service.startCommand ? "Custom" : "Image default"} - - - {service.healthCheckCmd ? "Configured" : "None"} - - - {hasResourceLimits ? formatResources(service) : "Not set"} - -
- -
- {overview.serverSummaries.length === 0 ? ( -

No servers configured

- ) : ( - overview.serverSummaries.map((server) => ( - - - {server.configured > 0 - ? `${server.running}/${server.configured} running` - : `${server.running} running`} - - - )) - )} + {service.startCommand ? ( + Custom + ) : null} + {service.healthCheckCmd ? ( + Configured + ) : null} + {hasResourceLimits ? ( + {formatResources(service)} + ) : null}
@@ -513,21 +519,14 @@ function ServiceConfigPanel({ function ConfigRow({ label, children, - muted = false, }: { label: string; children: ReactNode; - muted?: boolean; }) { return (
{label} - + {children}
From 204d1b7c5806d26500d11a8a18a8d4c8771e55a7 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:29:11 +1000 Subject: [PATCH 2/8] Fix rollout routing convergence --- agent/internal/agent/agent.go | 2 +- agent/internal/agent/drift.go | 111 +++++---- agent/internal/agent/reporting.go | 52 +++- agent/internal/http/client.go | 27 ++- agent/internal/traefik/reload.go | 167 +++++++++++++ agent/internal/traefik/reload_test.go | 223 ++++++++++++++++++ docs/architecture.mdx | 25 +- docs/services/configuration.mdx | 2 +- .../service/details/health-check-section.tsx | 7 + .../service/details/rollout-details.tsx | 2 +- .../service/details/rollout-history.tsx | 2 +- web/db/schema.ts | 4 + web/lib/agent-status.ts | 35 +-- web/lib/agent/expected-state.ts | 39 ++- web/lib/inngest/functions/rollout-workflow.ts | 46 +++- web/lib/routing-sync.ts | 28 +++ web/tests/routing-sync.test.ts | 59 +++++ 17 files changed, 723 insertions(+), 108 deletions(-) create mode 100644 agent/internal/traefik/reload.go create mode 100644 agent/internal/traefik/reload_test.go create mode 100644 web/lib/routing-sync.ts create mode 100644 web/tests/routing-sync.test.ts diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index fa85f72d..b3365e3b 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -46,6 +46,7 @@ type ActualState struct { TraefikConfigHash string L4ConfigHash string CertificatesHash string + TraefikReloaded bool ChallengeRouteWritten bool WireguardHash string } @@ -87,7 +88,6 @@ type Agent struct { currentBuildID string IsProxy bool serverlessGatewayRunning atomic.Bool - dnsInSync bool DisableDNS bool } diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go index 18216d87..969269e4 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -28,7 +28,6 @@ const ( actionRedeployContainer reconcileActionKind = "redeploy_container" actionUpdateDNS reconcileActionKind = "update_dns" actionUpdateTraefik reconcileActionKind = "update_traefik" - actionUpdateCertificates reconcileActionKind = "update_certificates" actionWriteChallengeRoute reconcileActionKind = "write_challenge_route" actionUpdateWireGuard reconcileActionKind = "update_wireguard" actionStartWireGuard reconcileActionKind = "start_wireguard" @@ -112,8 +111,6 @@ func (a *Agent) handleIdle() { return } - a.updateDnsInSync(expected, actual) - actions := a.planReconcile(expected, actual) if len(actions) > 0 { log.Printf("[idle] drift detected, %d change(s) to apply:", len(actions)) @@ -142,7 +139,6 @@ func (a *Agent) handleProcessing() { return } - a.updateDnsInSync(a.expectedState, actual) actions := a.planReconcile(a.expectedState, actual) if len(actions) == 0 { @@ -163,18 +159,6 @@ func (a *Agent) handleProcessing() { a.RequestStatusReport("reconcile completed") } -func (a *Agent) updateDnsInSync(expected *agenthttp.ExpectedState, actual *ActualState) { - if a.DisableDNS { - a.dnsInSync = true - return - } - expectedDnsRecords := make([]dns.DnsRecord, len(expected.Dns.Records)) - for i, r := range expected.Dns.Records { - expectedDnsRecords[i] = dns.DnsRecord{Name: r.Name, Ips: r.Ips} - } - a.dnsInSync = dns.HashRecords(expectedDnsRecords) == actual.DnsConfigHash -} - func (a *Agent) getActualState() (*ActualState, error) { containers, err := container.List() if err != nil { @@ -191,6 +175,10 @@ func (a *Agent) getActualState() (*ActualState, error) { state.TraefikConfigHash = traefik.GetCurrentConfigHash() state.L4ConfigHash = traefik.GetCurrentL4ConfigHash() state.CertificatesHash = traefik.GetCurrentCertificatesHash() + state.TraefikReloaded, err = traefik.DynamicConfigReloaded(a.DataDir) + if err != nil { + log.Printf("[traefik] failed to determine dynamic config reload state: %v", err) + } state.ChallengeRouteWritten = traefik.ChallengeRouteExists() } return state, nil @@ -332,32 +320,27 @@ func (a *Agent) planReconcile(expected *agenthttp.ExpectedState, actual *ActualS if a.IsProxy { expectedHttpRoutes := ConvertToHttpRoutes(expected.Traefik.HttpRoutes) expectedTraefikHash := traefik.HashRoutesWithServerName(expectedHttpRoutes, expected.ServerName) - if expectedTraefikHash != actual.TraefikConfigHash { - actions = append(actions, reconcileAction{ - Kind: actionUpdateTraefik, - Description: fmt.Sprintf("UPDATE Traefik HTTP (%d routes)", len(expected.Traefik.HttpRoutes)), - }) - } - tcpRoutes := ConvertToTCPRoutes(expected.Traefik.TCPRoutes) udpRoutes := ConvertToUDPRoutes(expected.Traefik.UDPRoutes) expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes) - if expectedL4Hash != actual.L4ConfigHash { - actions = append(actions, reconcileAction{ - Kind: actionUpdateTraefik, - Description: fmt.Sprintf("UPDATE Traefik L4 (%d TCP, %d UDP)", len(tcpRoutes), len(udpRoutes)), - }) - } - expectedCerts := make([]traefik.Certificate, len(expected.Traefik.Certificates)) for i, c := range expected.Traefik.Certificates { expectedCerts[i] = traefik.Certificate{Domain: c.Domain, Certificate: c.Certificate, CertificateKey: c.CertificateKey} } expectedCertsHash := traefik.HashCertificates(expectedCerts) - if expectedCertsHash != actual.CertificatesHash { + if expectedTraefikHash != actual.TraefikConfigHash || + expectedL4Hash != actual.L4ConfigHash || + expectedCertsHash != actual.CertificatesHash || + !actual.TraefikReloaded { actions = append(actions, reconcileAction{ - Kind: actionUpdateCertificates, - Description: fmt.Sprintf("UPDATE Certificates (%d certs)", len(expected.Traefik.Certificates)), + Kind: actionUpdateTraefik, + Description: fmt.Sprintf( + "UPDATE Traefik (%d HTTP, %d TCP, %d UDP routes; %d certificates)", + len(expected.Traefik.HttpRoutes), + len(tcpRoutes), + len(udpRoutes), + len(expected.Traefik.Certificates), + ), }) } @@ -517,16 +500,6 @@ func (a *Agent) applyReconcileAction(action reconcileAction) error { case actionUpdateTraefik: return a.updateTraefik() - case actionUpdateCertificates: - expectedCerts := make([]traefik.Certificate, len(a.expectedState.Traefik.Certificates)) - for i, c := range a.expectedState.Traefik.Certificates { - expectedCerts[i] = traefik.Certificate{Domain: c.Domain, Certificate: c.Certificate, CertificateKey: c.CertificateKey} - } - if err := traefik.UpdateCertificates(expectedCerts); err != nil { - return fmt.Errorf("failed to update certificates: %w", err) - } - return nil - case actionWriteChallengeRoute: if a.expectedState.Traefik.ChallengeRoute == nil { return nil @@ -589,19 +562,57 @@ func (a *Agent) updateTraefik() error { } needsRestart = needsRestart || entryPointsRestart } - - log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes)) - if err := traefik.UpdateHttpRoutesWithL4(expectedHttpRoutes, tcpRoutes, udpRoutes, a.expectedState.ServerName); err != nil { - return fmt.Errorf("failed to update Traefik: %w", err) - } - if needsRestart { - log.Printf("[reconcile] restarting Traefik to apply new entry points") + log.Printf("[reconcile] restarting Traefik to apply static configuration") if err := traefik.ReloadTraefik(); err != nil { return fmt.Errorf("failed to restart Traefik: %w", err) } } + expectedCerts := make([]traefik.Certificate, len(a.expectedState.Traefik.Certificates)) + for i, certificate := range a.expectedState.Traefik.Certificates { + expectedCerts[i] = traefik.Certificate{ + Domain: certificate.Domain, + Certificate: certificate.Certificate, + CertificateKey: certificate.CertificateKey, + } + } + expectedTraefikHash := traefik.HashRoutesWithServerName(expectedHttpRoutes, a.expectedState.ServerName) + expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes) + routesChanged := expectedTraefikHash != traefik.GetCurrentConfigHash() || + expectedL4Hash != traefik.GetCurrentL4ConfigHash() + certificatesChanged := traefik.HashCertificates(expectedCerts) != traefik.GetCurrentCertificatesHash() + if !routesChanged && !certificatesChanged { + if err := traefik.EnsureDynamicConfigReloaded(a.DataDir, 15*time.Second); err != nil { + return fmt.Errorf("failed to recover Traefik config reload: %w", err) + } + return nil + } + + baselineReload, err := traefik.LastSuccessfulReload() + if err != nil { + return fmt.Errorf("failed to capture Traefik reload baseline: %w", err) + } + if err := traefik.MarkDynamicConfigReloadPending(a.DataDir); err != nil { + return fmt.Errorf("failed to mark Traefik config reload pending: %w", err) + } + + if certificatesChanged { + if err := traefik.UpdateCertificates(expectedCerts); err != nil { + return fmt.Errorf("failed to update Traefik certificates: %w", err) + } + } + if routesChanged { + log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes)) + if err := traefik.UpdateHttpRoutesWithL4(expectedHttpRoutes, tcpRoutes, udpRoutes, a.expectedState.ServerName); err != nil { + return fmt.Errorf("failed to update Traefik: %w", err) + } + } + + if err := traefik.WaitForSuccessfulReloadAfter(a.DataDir, baselineReload, 15*time.Second); err != nil { + return fmt.Errorf("failed to confirm Traefik config reload: %w", err) + } + return nil } diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go index 70f75cc3..9a3b5cb3 100644 --- a/agent/internal/agent/reporting.go +++ b/agent/internal/agent/reporting.go @@ -8,9 +8,11 @@ import ( "time" "techulus/cloud-agent/internal/container" + "techulus/cloud-agent/internal/dns" "techulus/cloud-agent/internal/health" agenthttp "techulus/cloud-agent/internal/http" "techulus/cloud-agent/internal/logs" + "techulus/cloud-agent/internal/traefik" "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/mem" @@ -31,7 +33,6 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport PublicIP: a.PublicIP, PrivateIP: a.PrivateIP, Containers: []agenthttp.ContainerStatus{}, - DnsInSync: a.dnsInSync, AgentHealth: &agenthttp.AgentHealth{ Version: Version, UptimeSecs: int64(time.Since(agentStartTime).Seconds()), @@ -111,6 +112,7 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport } report.DeploymentErrors = a.SnapshotDeploymentErrors() + report.RoutingSyncedRolloutIds = a.routingSyncedRolloutIds() return report } @@ -122,6 +124,54 @@ func (a *Agent) agentCapabilities() []string { return []string{serverlessGatewayCapability} } +func (a *Agent) routingSyncedRolloutIds() []string { + expected := a.ExpectedState() + if expected == nil || len(expected.RoutingSyncRolloutIds) == 0 { + return nil + } + + if !a.DisableDNS { + expectedRecords := make([]dns.DnsRecord, len(expected.Dns.Records)) + for i, record := range expected.Dns.Records { + expectedRecords[i] = dns.DnsRecord{Name: record.Name, Ips: record.Ips} + } + if dns.HashRecords(expectedRecords) != dns.GetCurrentConfigHash() { + return nil + } + } + + if a.IsProxy && !a.proxyRoutingStateConverged(expected) { + return nil + } + + return append([]string(nil), expected.RoutingSyncRolloutIds...) +} + +func (a *Agent) proxyRoutingStateConverged(expected *agenthttp.ExpectedState) bool { + httpRoutes := ConvertToHttpRoutes(expected.Traefik.HttpRoutes) + if traefik.HashRoutesWithServerName(httpRoutes, expected.ServerName) != traefik.GetCurrentConfigHash() { + return false + } + tcpRoutes := ConvertToTCPRoutes(expected.Traefik.TCPRoutes) + udpRoutes := ConvertToUDPRoutes(expected.Traefik.UDPRoutes) + if traefik.HashTCPRoutes(tcpRoutes)+traefik.HashUDPRoutes(udpRoutes) != traefik.GetCurrentL4ConfigHash() { + return false + } + certificates := make([]traefik.Certificate, len(expected.Traefik.Certificates)) + for i, certificate := range expected.Traefik.Certificates { + certificates[i] = traefik.Certificate{ + Domain: certificate.Domain, + Certificate: certificate.Certificate, + CertificateKey: certificate.CertificateKey, + } + } + if traefik.HashCertificates(certificates) != traefik.GetCurrentCertificatesHash() { + return false + } + reloaded, err := traefik.DynamicConfigReloaded(a.DataDir) + return err == nil && reloaded +} + func (a *Agent) RecordDeploymentError(deploymentID string, err error) { if deploymentID == "" || err == nil { return diff --git a/agent/internal/http/client.go b/agent/internal/http/client.go index 22ee9dd4..47dbbcbe 100644 --- a/agent/internal/http/client.go +++ b/agent/internal/http/client.go @@ -153,9 +153,10 @@ type WireGuardPeer struct { } type ExpectedState struct { - ServerName string `json:"serverName"` - Containers []ExpectedContainer `json:"containers"` - Dns struct { + ServerName string `json:"serverName"` + RoutingSyncRolloutIds []string `json:"routingSyncRolloutIds,omitempty"` + Containers []ExpectedContainer `json:"containers"` + Dns struct { Records []DnsRecord `json:"records"` } `json:"dns"` Serverless struct { @@ -274,16 +275,16 @@ type AgentHealth struct { } type StatusReport struct { - Resources *Resources `json:"resources,omitempty"` - PublicIP string `json:"publicIp,omitempty"` - PrivateIP string `json:"privateIp,omitempty"` - Meta map[string]string `json:"meta,omitempty"` - Containers []ContainerStatus `json:"containers"` - DeploymentErrors []DeploymentError `json:"deploymentErrors,omitempty"` - DnsInSync bool `json:"dnsInSync,omitempty"` - NetworkHealth *health.NetworkHealth `json:"networkHealth,omitempty"` - ContainerHealth *health.ContainerHealth `json:"containerHealth,omitempty"` - AgentHealth *AgentHealth `json:"agentHealth,omitempty"` + Resources *Resources `json:"resources,omitempty"` + PublicIP string `json:"publicIp,omitempty"` + PrivateIP string `json:"privateIp,omitempty"` + Meta map[string]string `json:"meta,omitempty"` + Containers []ContainerStatus `json:"containers"` + DeploymentErrors []DeploymentError `json:"deploymentErrors,omitempty"` + RoutingSyncedRolloutIds []string `json:"routingSyncedRolloutIds,omitempty"` + NetworkHealth *health.NetworkHealth `json:"networkHealth,omitempty"` + ContainerHealth *health.ContainerHealth `json:"containerHealth,omitempty"` + AgentHealth *AgentHealth `json:"agentHealth,omitempty"` } type CompletedWorkItem struct { diff --git a/agent/internal/traefik/reload.go b/agent/internal/traefik/reload.go new file mode 100644 index 00000000..6e786aa9 --- /dev/null +++ b/agent/internal/traefik/reload.go @@ -0,0 +1,167 @@ +package traefik + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + lastReloadSuccessMetric = "traefik_config_last_reload_success" + pendingReloadMarkerName = ".routing-reload-pending" +) + +var ( + traefikMetricsURL = "http://127.0.0.1:9100/metrics" + dynamicConfigDir = traefikDynamicDir + metricsHTTPClient = &http.Client{Timeout: 2 * time.Second} + readLastSuccessfulReload = fetchLastSuccessfulReload + restartTraefik = ReloadTraefik + reloadPollInterval = 250 * time.Millisecond +) + +func LastSuccessfulReload() (time.Time, error) { + return readLastSuccessfulReload() +} + +func fetchLastSuccessfulReload() (time.Time, error) { + response, err := metricsHTTPClient.Get(traefikMetricsURL) + if err != nil { + return time.Time{}, fmt.Errorf("failed to read Traefik metrics: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return time.Time{}, fmt.Errorf("Traefik metrics returned %s", response.Status) + } + return parseLastSuccessfulReload(response.Body) +} + +func parseLastSuccessfulReload(reader io.Reader) (time.Time, error) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 2 || fields[0] != lastReloadSuccessMetric { + continue + } + seconds, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + return time.Time{}, fmt.Errorf("invalid %s value: %w", lastReloadSuccessMetric, err) + } + wholeSeconds := int64(seconds) + nanoseconds := int64((seconds - float64(wholeSeconds)) * float64(time.Second)) + return time.Unix(wholeSeconds, nanoseconds), nil + } + if err := scanner.Err(); err != nil { + return time.Time{}, fmt.Errorf("failed to parse Traefik metrics: %w", err) + } + return time.Time{}, fmt.Errorf("%s metric not found", lastReloadSuccessMetric) +} + +func newestDynamicConfigModTime() (time.Time, error) { + var newest time.Time + for _, name := range []string{tlsFileName, routesFileName} { + info, err := os.Stat(filepath.Join(dynamicConfigDir, name)) + if err != nil { + if os.IsNotExist(err) { + continue + } + return time.Time{}, err + } + if info.ModTime().After(newest) { + newest = info.ModTime() + } + } + return newest, nil +} + +func DynamicConfigReloaded(stateDir string) (bool, error) { + if _, err := os.Stat(pendingReloadMarkerPath(stateDir)); err == nil { + return false, nil + } else if !os.IsNotExist(err) { + return false, fmt.Errorf("failed to inspect Traefik reload marker: %w", err) + } + + lastReload, err := LastSuccessfulReload() + if err != nil { + return false, err + } + return dynamicFilesReloaded(lastReload) +} + +func dynamicFilesReloaded(lastReload time.Time) (bool, error) { + newestConfig, err := newestDynamicConfigModTime() + if err != nil { + return false, fmt.Errorf("failed to inspect Traefik dynamic config: %w", err) + } + if newestConfig.IsZero() { + return true, nil + } + + // Traefik currently exposes this metric with second precision. Comparing + // second-truncated values preserves the durable post-restart check while the + // write path separately requires the metric to advance from its baseline. + return !lastReload.Before(newestConfig.Truncate(time.Second)), nil +} + +func MarkDynamicConfigReloadPending(stateDir string) error { + if err := os.MkdirAll(stateDir, 0755); err != nil { + return err + } + return atomicWrite(pendingReloadMarkerPath(stateDir), nil, 0644) +} + +func EnsureDynamicConfigReloaded(stateDir string, timeout time.Duration) error { + lastReload, err := LastSuccessfulReload() + if err != nil { + return err + } + reloaded, err := dynamicFilesReloaded(lastReload) + if err != nil { + return err + } + if reloaded { + return clearPendingReloadMarker(stateDir) + } + + if err := MarkDynamicConfigReloadPending(stateDir); err != nil { + return err + } + if err := restartTraefik(); err != nil { + return err + } + return WaitForSuccessfulReloadAfter(stateDir, lastReload, timeout) +} + +func WaitForSuccessfulReloadAfter(stateDir string, baseline time.Time, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + lastReload, err := LastSuccessfulReload() + if err == nil && lastReload.After(baseline) { + reloaded, reloadErr := dynamicFilesReloaded(lastReload) + if reloadErr == nil && reloaded { + return clearPendingReloadMarker(stateDir) + } + } + if time.Now().After(deadline) { + return fmt.Errorf("Traefik did not confirm a successful config reload within %s", timeout) + } + time.Sleep(reloadPollInterval) + } +} + +func pendingReloadMarkerPath(stateDir string) string { + return filepath.Join(stateDir, pendingReloadMarkerName) +} + +func clearPendingReloadMarker(stateDir string) error { + if err := os.Remove(pendingReloadMarkerPath(stateDir)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to clear Traefik reload marker: %w", err) + } + return nil +} diff --git a/agent/internal/traefik/reload_test.go b/agent/internal/traefik/reload_test.go new file mode 100644 index 00000000..9c550492 --- /dev/null +++ b/agent/internal/traefik/reload_test.go @@ -0,0 +1,223 @@ +package traefik + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestParseLastSuccessfulReload(t *testing.T) { + reload, err := parseLastSuccessfulReload(strings.NewReader(`# HELP traefik_config_last_reload_success Last config reload success +traefik_config_last_reload_success 1.725e+09 +`)) + if err != nil { + t.Fatalf("parseLastSuccessfulReload returned an error: %v", err) + } + if got, want := reload.Unix(), int64(1725000000); got != want { + t.Fatalf("reload timestamp = %d, want %d", got, want) + } +} + +func TestDynamicConfigReloadedRequiresReloadAtOrAfterNewestFile(t *testing.T) { + originalDir := dynamicConfigDir + originalReader := readLastSuccessfulReload + t.Cleanup(func() { + dynamicConfigDir = originalDir + readLastSuccessfulReload = originalReader + }) + + var reloadTimestamp int64 = 100 + readLastSuccessfulReload = func() (time.Time, error) { + return time.Unix(reloadTimestamp, 0), nil + } + dynamicConfigDir = t.TempDir() + stateDir := t.TempDir() + + routesPath := filepath.Join(dynamicConfigDir, routesFileName) + if err := os.WriteFile(routesPath, []byte("http: {}\n"), 0644); err != nil { + t.Fatal(err) + } + configTime := time.Unix(101, 500) + if err := os.Chtimes(routesPath, configTime, configTime); err != nil { + t.Fatal(err) + } + + reloaded, err := DynamicConfigReloaded(stateDir) + if err != nil { + t.Fatal(err) + } + if reloaded { + t.Fatal("config was reported as reloaded before the file modification time") + } + + reloadTimestamp = 102 + markerPath := pendingReloadMarkerPath(stateDir) + if err := os.WriteFile(markerPath, []byte("pending"), 0644); err != nil { + t.Fatal(err) + } + reloaded, err = DynamicConfigReloaded(stateDir) + if err != nil { + t.Fatal(err) + } + if reloaded { + t.Fatal("config was reported as reloaded while its durable marker was pending") + } + if err := os.Remove(markerPath); err != nil { + t.Fatal(err) + } + + reloaded, err = DynamicConfigReloaded(stateDir) + if err != nil { + t.Fatal(err) + } + if !reloaded { + t.Fatal("config was not reported as reloaded after the file modification time") + } +} + +func TestWaitForSuccessfulReloadClearsPendingMarker(t *testing.T) { + originalDir := dynamicConfigDir + originalReader := readLastSuccessfulReload + t.Cleanup(func() { + dynamicConfigDir = originalDir + readLastSuccessfulReload = originalReader + }) + + dynamicConfigDir = t.TempDir() + stateDir := t.TempDir() + baseline := time.Unix(100, 0) + readLastSuccessfulReload = func() (time.Time, error) { + return baseline.Add(2 * time.Second), nil + } + routesPath := filepath.Join(dynamicConfigDir, routesFileName) + if err := os.WriteFile(routesPath, []byte("http: {}\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(routesPath, baseline.Add(time.Second), baseline.Add(time.Second)); err != nil { + t.Fatal(err) + } + if err := MarkDynamicConfigReloadPending(stateDir); err != nil { + t.Fatal(err) + } + + if err := WaitForSuccessfulReloadAfter(stateDir, baseline, time.Second); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(pendingReloadMarkerPath(stateDir)); !os.IsNotExist(err) { + t.Fatalf("pending marker was not removed: %v", err) + } +} + +func TestEnsureDynamicConfigReloadedRecoversIdenticalConfigWithoutRestart(t *testing.T) { + originalDir := dynamicConfigDir + originalReader := readLastSuccessfulReload + originalRestart := restartTraefik + t.Cleanup(func() { + dynamicConfigDir = originalDir + readLastSuccessfulReload = originalReader + restartTraefik = originalRestart + }) + + dynamicConfigDir = t.TempDir() + stateDir := t.TempDir() + configTime := time.Unix(101, 0) + routesPath := filepath.Join(dynamicConfigDir, routesFileName) + if err := os.WriteFile(routesPath, []byte("http: {}\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(routesPath, configTime, configTime); err != nil { + t.Fatal(err) + } + readLastSuccessfulReload = func() (time.Time, error) { + return configTime.Add(time.Second), nil + } + restartCount := 0 + restartTraefik = func() error { + restartCount++ + return nil + } + if err := MarkDynamicConfigReloadPending(stateDir); err != nil { + t.Fatal(err) + } + + if err := EnsureDynamicConfigReloaded(stateDir, time.Second); err != nil { + t.Fatal(err) + } + if restartCount != 0 { + t.Fatalf("Traefik restarted %d times for an already-loaded config", restartCount) + } + if _, err := os.Stat(pendingReloadMarkerPath(stateDir)); !os.IsNotExist(err) { + t.Fatalf("pending marker was not removed: %v", err) + } +} + +func TestEnsureDynamicConfigReloadedRestartsForStaleConfig(t *testing.T) { + originalDir := dynamicConfigDir + originalReader := readLastSuccessfulReload + originalRestart := restartTraefik + t.Cleanup(func() { + dynamicConfigDir = originalDir + readLastSuccessfulReload = originalReader + restartTraefik = originalRestart + }) + + dynamicConfigDir = t.TempDir() + stateDir := t.TempDir() + configTime := time.Unix(101, 0) + routesPath := filepath.Join(dynamicConfigDir, routesFileName) + if err := os.WriteFile(routesPath, []byte("http: {}\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(routesPath, configTime, configTime); err != nil { + t.Fatal(err) + } + reloadTime := time.Unix(100, 0) + readLastSuccessfulReload = func() (time.Time, error) { + return reloadTime, nil + } + restartCount := 0 + restartTraefik = func() error { + restartCount++ + reloadTime = configTime.Add(time.Second) + return nil + } + + if err := EnsureDynamicConfigReloaded(stateDir, time.Second); err != nil { + t.Fatal(err) + } + if restartCount != 1 { + t.Fatalf("Traefik restarted %d times, want 1", restartCount) + } + if _, err := os.Stat(pendingReloadMarkerPath(stateDir)); !os.IsNotExist(err) { + t.Fatalf("pending marker was not removed: %v", err) + } +} + +func TestWaitForSuccessfulReloadTimesOutAndKeepsMarker(t *testing.T) { + originalReader := readLastSuccessfulReload + originalPollInterval := reloadPollInterval + t.Cleanup(func() { + readLastSuccessfulReload = originalReader + reloadPollInterval = originalPollInterval + }) + + stateDir := t.TempDir() + baseline := time.Unix(100, 0) + readLastSuccessfulReload = func() (time.Time, error) { + return baseline, nil + } + reloadPollInterval = time.Millisecond + if err := MarkDynamicConfigReloadPending(stateDir); err != nil { + t.Fatal(err) + } + + err := WaitForSuccessfulReloadAfter(stateDir, baseline, 5*time.Millisecond) + if err == nil { + t.Fatal("reload wait unexpectedly succeeded without a newer metric") + } + if _, statErr := os.Stat(pendingReloadMarkerPath(stateDir)); statErr != nil { + t.Fatalf("pending marker should remain after timeout: %v", statErr) + } +} diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 90dd7a29..0bc4d385 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -95,7 +95,7 @@ The agent uses hash comparisons for deterministic drift detection: - **Containers**: missing, orphaned, wrong state, or image mismatch. - **DNS**: hash of sorted records versus current DNS config. -- **Traefik**: hash of sorted routes versus current Traefik config on proxy nodes. +- **Traefik**: hash of sorted routes and certificates versus current config on proxy nodes, plus confirmation that Traefik successfully reloaded the newest dynamic files. - **WireGuard**: hash of sorted peers versus current `wg0.conf`. ### Container Reconciliation Order @@ -105,25 +105,24 @@ The agent uses hash comparisons for deterministic drift detection: 3. Deploy missing containers. 4. Redeploy containers with wrong state or image mismatch. 5. Update DNS records. -6. Update Traefik routes on proxy nodes. +6. Update Traefik certificates and routes on proxy nodes, then confirm a successful reload. 7. Update WireGuard peers. ## Rollout Stages ```text -pending -> pulling -> starting -> healthy -> dns_updating -> traefik_updating -> stopping_old -> running +queued -> preparing -> certificates -> deploying -> health_check -> dns_sync -> completed ``` | Stage | Description | | --- | --- | -| `pending` | Deployment created and waiting for an agent | -| `pulling` | Agent is pulling the container image | -| `starting` | Container started and waiting for health checks | -| `healthy` | Health check passed, or no health check is configured | -| `dns_updating` | DNS records are being updated | -| `traefik_updating` | Traefik routes are being updated | -| `stopping_old` | Old deployment containers are being stopped | -| `running` | Deployment is complete and serving traffic | +| `queued` | Rollout is waiting for the previous rollout of the service to finish | +| `preparing` | Placements and target servers are being validated | +| `certificates` | Certificates for public domains are being provisioned | +| `deploying` | Agents are creating and starting candidate containers | +| `health_check` | Configured health checks are being evaluated; without one, a running container satisfies this stage | +| `dns_sync` | Displayed as **Routing traffic**. Every frozen workload target must have matching DNS; every frozen proxy target must also have matching routes and certificates loaded by Traefik | +| `completed` | Routing convergence is confirmed and old deployments can be stopped | Special states: @@ -132,6 +131,10 @@ Special states: - `failed`: the deployment failed, such as during health checks. - `rolled_back`: rollout failed and reverted to the previous deployment. +For each rollout, the control plane freezes the required target set immediately before promotion. Private services wait for their workload servers. Public services also wait for every proxy that was online at promotion. Agents acknowledge the exact rollout ID only after their expected snapshot converges; unrelated or stale acknowledgements cannot complete another rollout. + +With no configured health check, `healthy` means only that the container is running. Routing convergence prevents completion before network configuration is live, but application-level readiness remains the responsibility of a user-configured health check. + ## Networking ### IP Address Scheme diff --git a/docs/services/configuration.mdx b/docs/services/configuration.mdx index 17282c23..d16fed89 100644 --- a/docs/services/configuration.mdx +++ b/docs/services/configuration.mdx @@ -60,4 +60,4 @@ Health check statuses: | `healthy` | Check is passing | | `unhealthy` | Check has failed after retries | -If no health check command is set, the deployment proceeds immediately after the container starts. +If no health check command is set, deployment readiness only confirms that the container started. The platform still waits for DNS, proxy routes, certificates, and a successful Traefik configuration reload before marking the rollout complete, but it does not verify that the application inside the container accepts requests. Configure a health check when application readiness matters. diff --git a/web/components/service/details/health-check-section.tsx b/web/components/service/details/health-check-section.tsx index 88c483d6..5a56de4a 100644 --- a/web/components/service/details/health-check-section.tsx +++ b/web/components/service/details/health-check-section.tsx @@ -153,6 +153,13 @@ export const HealthCheckSection = memo(function HealthCheckSection({

Exit 0 = healthy, non-zero = unhealthy

+ {!state.cmd.trim() && ( +

+ Without a health check, deployment only confirms that the + container started. It does not verify that the application accepts + requests. +

+ )}
+
+
+
+ + + Agent update available:{" "} + {currentVersion} + {" → "} + {latestVersion} + +
+ +
+
diff --git a/web/components/server/server-health-details.tsx b/web/components/server/server-health-details.tsx index de6973e0..a423d703 100644 --- a/web/components/server/server-health-details.tsx +++ b/web/components/server/server-health-details.tsx @@ -1,118 +1,532 @@ "use client"; +import { type ReactNode, useMemo, useState } from "react"; import { - Activity, - Container, - Cpu, - HardDrive, - MemoryStick, - Network, -} from "lucide-react"; + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; import useSWR from "swr"; -import { HealthIndicator } from "@/components/cluster/health-indicator"; -import { ResourceBar } from "@/components/cluster/resource-bar"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import type { HealthStats, Server } from "@/db/types"; +import { ServerHeader } from "@/components/server/server-header"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { Server } from "@/db/types"; +import { + formatCompactDateTime, + formatRelativeTime, + getTimestamp, +} from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; +import { cn } from "@/lib/utils"; +import type { + NodeMetricPoint, + NodeMetricsHistory, + NodeMetricsSnapshot, +} from "@/lib/victoria-metrics"; -type ServerHealthData = { - healthStats: HealthStats | null; - networkHealth: Server["networkHealth"]; - containerHealth: Server["containerHealth"]; - agentHealth: Server["agentHealth"]; +type ServerOverview = Pick< + Server, + | "id" + | "name" + | "status" + | "isProxy" + | "publicIp" + | "privateIp" + | "wireguardIp" + | "lastHeartbeat" + | "resourcesCpu" + | "resourcesMemory" + | "resourcesDisk" + | "meta" + | "networkHealth" + | "containerHealth" + | "agentHealth" +>; + +type ServerMetricMode = "cpu" | "memory" | "disk"; + +type ServerMetricsResponse = { + current: NodeMetricsSnapshot | null; + history: NodeMetricsHistory; + range: string; + enabled?: boolean; }; type ClusterHealthResponse = { - servers: Array< - ServerHealthData & { - id: string; - name: string; - status: string; - } - >; + servers: Array<{ + id: string; + networkHealth: Server["networkHealth"]; + containerHealth: Server["containerHealth"]; + agentHealth: Server["agentHealth"]; + }>; }; -interface ServerHealthDetailsProps { - serverId: string; - initialData: ServerHealthData; -} +type ChartRow = { + timestamp: string; + percent?: number; + bytes?: number; +}; + +type TooltipPayload = { + dataKey?: string; + value?: unknown; +}; -export function ServerHealthDetails({ - serverId, - initialData, -}: ServerHealthDetailsProps) { - const { data } = useSWR( +type ServerMetricsTooltipProps = { + active?: boolean; + label?: string | number; + payload?: readonly TooltipPayload[]; +}; + +const MODE_OPTIONS: Array<{ value: ServerMetricMode; label: string }> = [ + { value: "cpu", label: "CPU" }, + { value: "memory", label: "Memory" }, + { value: "disk", label: "Disk" }, +]; + +export function ServerDetailsOverview({ + server, + initialMetrics, +}: { + server: ServerOverview; + initialMetrics: NodeMetricsSnapshot | null; +}) { + const metricsUrl = `/api/servers/${server.id}/metrics?range=24h`; + const { + data: metrics, + error: metricsError, + isLoading, + } = useSWR(metricsUrl, fetcher, { + refreshInterval: 60000, + }); + const { data: clusterHealth } = useSWR( "/api/cluster-health", fetcher, - { - refreshInterval: 10000, - }, + { refreshInterval: 10000 }, + ); + const liveHealth = clusterHealth?.servers.find( + (item) => item.id === server.id, ); - const serverData = data?.servers?.find((s) => s.id === serverId); - const healthStats = serverData?.healthStats ?? initialData.healthStats; - const networkHealth = serverData?.networkHealth ?? initialData.networkHealth; - const containerHealth = - serverData?.containerHealth ?? initialData.containerHealth; - const agentHealth = serverData?.agentHealth ?? initialData.agentHealth; + return ( + +
+ + +
+
+ ); +} - if (!healthStats && !networkHealth && !containerHealth && !agentHealth) { - return null; - } +function ServerMetricsPanel({ + metrics, + initialMetrics, + error, + isLoading, +}: { + metrics?: ServerMetricsResponse; + initialMetrics: NodeMetricsSnapshot | null; + error?: unknown; + isLoading: boolean; +}) { + const [mode, setMode] = useState("cpu"); + const rows = useMemo( + () => buildChartRows(metrics?.history, mode), + [metrics, mode], + ); + const current = metrics?.current ?? initialMetrics; + const isUnavailable = Boolean(error) || metrics?.enabled === false; + const percent = getCurrentPercent(current, mode); + const bytes = getCurrentBytes(current, mode); return ( - - - System Health - - - {healthStats && ( -
- } - /> - } - /> - } - /> -
- )} +
+
+
+ {isLoading && !metrics ? ( +
+ + {mode !== "cpu" ? : null} +
+ ) : ( +
+ + {mode !== "cpu" ? ( + + ) : null} +
+ )} +
+ +
- {(networkHealth || containerHealth || agentHealth) && ( - <> - -
- } +
+ {isLoading && !metrics ? ( + + ) : isUnavailable ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + - } + formatCompactDateTime(value)} + className="text-xs" /> - } + `${value}%`} + className="text-xs" /> -
- + {mode !== "cpu" ? ( + + ) : null} + ( + + )} + /> + + {mode !== "cpu" ? ( + + ) : null} + + )} - - +
+
+ ); +} + +function ServerMetricTabs({ + value, + onChange, + disabled, +}: { + value: ServerMetricMode; + onChange: (value: ServerMetricMode) => void; + disabled: boolean; +}) { + return ( +
+ {MODE_OPTIONS.map((option) => { + const isSelected = value === option.value; + return ( + + ); + })} +
+ ); +} + +function ServerOverviewPanel({ + server, + networkHealth, + containerHealth, + agentHealth, +}: { + server: ServerOverview; + networkHealth: Server["networkHealth"]; + containerHealth: Server["containerHealth"]; + agentHealth: Server["agentHealth"]; +}) { + return ( +
+
+
+
+ +
+ + {server.lastHeartbeat + ? formatRelativeTime(server.lastHeartbeat) + : "Never seen"} + +
+ + {formatHealth( + networkHealth?.tunnelUp, + `${networkHealth?.peerCount ?? 0} peers`, + )} + + + {formatHealth( + containerHealth?.runtimeResponsive, + `${containerHealth?.runningContainers ?? 0} running`, + )} + + {agentHealth?.version ?? "Unknown"} +
+ +
+
+ {server.publicIp || "—"} + {server.privateIp || "—"} + + {server.wireguardIp || "—"} + +
+
+ + {server.resourcesCpu !== null + ? `${server.resourcesCpu} cores` + : "—"} + + + {server.resourcesMemory !== null + ? `${Math.round((server.resourcesMemory / 1024) * 10) / 10} GB` + : "—"} + + + {server.resourcesDisk !== null ? `${server.resourcesDisk} GB` : "—"} + +
+ {server.meta ? ( +
+ + {server.meta.os || "—"} / {server.meta.arch || "—"} + + + {server.meta.hostname || "—"} + +
+ ) : null} +
+
+ ); +} + +function ConfigRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+ {label} + + {children} + +
+ ); +} + +function MetricSummary({ value, label }: { value: string; label: string }) { + return ( +
+

+ {value} +

+

{label}

+
+ ); +} + +function MetricsState({ message }: { message: string }) { + return ( +
+ {message} +
); } + +function ServerMetricsTooltip({ + active, + label, + payload, +}: ServerMetricsTooltipProps) { + if (!active || !payload?.length) return null; + return ( +
+

+ {formatCompactDateTime(label)} +

+
+ {payload.map((item) => ( +
+ + {item.dataKey} + + + {item.dataKey === "bytes" + ? formatBytes(Number(item.value)) + : formatPercent(Number(item.value))} + +
+ ))} +
+
+ ); +} + +function buildChartRows( + history: NodeMetricsHistory | undefined, + mode: ServerMetricMode, +): ChartRow[] { + if (!history) return []; + const percentPoints = history[`${mode}UsagePercent`]; + const bytePoints = mode === "cpu" ? [] : history[`${mode}UsedBytes`]; + const rows = new Map(); + addPoints(rows, percentPoints, "percent"); + addPoints(rows, bytePoints, "bytes"); + return Array.from(rows.values()).sort( + (a, b) => getTimestamp(a.timestamp, 0) - getTimestamp(b.timestamp, 0), + ); +} + +function addPoints( + rows: Map, + points: NodeMetricPoint[], + key: "percent" | "bytes", +) { + for (const point of points) { + const row = rows.get(point.timestamp) ?? { timestamp: point.timestamp }; + row[key] = point.value; + rows.set(point.timestamp, row); + } +} + +function getCurrentPercent( + current: NodeMetricsSnapshot | null, + mode: ServerMetricMode, +) { + return current?.[`${mode}UsagePercent`] ?? null; +} + +function getCurrentBytes( + current: NodeMetricsSnapshot | null, + mode: ServerMetricMode, +) { + return mode === "cpu" ? null : (current?.[`${mode}UsedBytes`] ?? null); +} + +function formatHealth(healthy: boolean | undefined, detail: string) { + if (healthy === undefined) return "Unknown"; + return healthy ? detail : "Unavailable"; +} + +function formatPercent(value: number | null) { + return value === null || !Number.isFinite(value) + ? "—" + : `${value.toFixed(1)}%`; +} + +function formatBytes(value: number | null) { + if (value === null || !Number.isFinite(value)) return "—"; + if (value >= 1024 ** 4) return `${(value / 1024 ** 4).toFixed(2)} TB`; + if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(2)} GB`; + if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`; + return `${Math.round(value / 1024)} KB`; +} + +function formatBytesCompact(value: number) { + if (value >= 1024 ** 4) return `${(value / 1024 ** 4).toFixed(1)}T`; + if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)}G`; + if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(0)}M`; + return `${Math.round(value / 1024)}K`; +} diff --git a/web/components/server/server-tabs.tsx b/web/components/server/server-tabs.tsx new file mode 100644 index 00000000..0f6e39d5 --- /dev/null +++ b/web/components/server/server-tabs.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +export function ServerTabs({ serverId }: { serverId: string }) { + const pathname = usePathname(); + const basePath = `/dashboard/servers/${serverId}`; + const tabs = [ + { name: "Overview", href: basePath }, + { name: "Logs", href: `${basePath}/logs` }, + { name: "Settings", href: `${basePath}/settings` }, + ]; + + return ( +
+ +
+ ); +} diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 517dda7a..019f8f41 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -565,7 +565,7 @@ function EndpointPrimary({ endpoint }: { endpoint: EndpointItem }) { href={endpoint.href} target="_blank" rel="noopener noreferrer" - className="hover:text-primary" + className="text-primary underline decoration-primary/40 underline-offset-4 transition-colors hover:decoration-primary focus-visible:decoration-primary focus-visible:outline-none" > {endpoint.label} From fb1e2b953e16253e6dbc2532962832294e246768 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 16 Jul 2026 17:51:44 +1000 Subject: [PATCH 4/8] fix(web): address server details review feedback --- .../dashboard/servers/[id]/loading.tsx | 31 ++++++++++++------- .../dashboard/servers/[id]/page.tsx | 2 +- ...etails.tsx => server-details-overview.tsx} | 9 +++--- web/components/server/server-tabs.tsx | 6 +++- web/db/queries.ts | 5 +-- 5 files changed, 33 insertions(+), 20 deletions(-) rename web/components/server/{server-health-details.tsx => server-details-overview.tsx} (98%) diff --git a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx index 7281f39e..629e0d32 100644 --- a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx +++ b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx @@ -3,20 +3,27 @@ import { Skeleton } from "@/components/ui/skeleton"; export default function Loading() { return ( <> -