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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/internal/bootstrap/services_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config
return dockerClient.GetClient(ctx)
})
svcs.Environment = services.NewEnvironmentService(db, httpClient, svcs.Docker, svcs.Event, svcs.Settings, svcs.ApiKey)
svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker)
svcs.Notification = services.NewNotificationService(db, cfg, svcs.Environment)
svcs.Apprise = services.NewAppriseService(db, cfg)
svcs.Vulnerability = services.NewVulnerabilityService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Notification)
Expand All @@ -83,7 +84,7 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config
svcs.BuildWorkspace = services.NewBuildWorkspaceService(svcs.Settings)
svcs.Project = services.NewProjectService(db, svcs.Settings, svcs.Event, svcs.Image, svcs.Docker, svcs.Build, cfg)
svcs.Container = services.NewContainerService(db, svcs.Event, svcs.Docker, svcs.Image, svcs.Settings, svcs.Project)
svcs.Dashboard = services.NewDashboardService(db, svcs.Docker, svcs.Container, svcs.Settings, svcs.Vulnerability, svcs.Environment)
svcs.Dashboard = services.NewDashboardService(db, svcs.Docker, svcs.Container, svcs.Settings, svcs.Vulnerability, svcs.Environment, svcs.Version)
svcs.Volume = services.NewVolumeService(db, svcs.Docker, svcs.Event, svcs.Settings, svcs.Container, svcs.Image, cfg.BackupVolumeName)
svcs.Network = services.NewNetworkService(db, svcs.Docker, svcs.Event)
svcs.Port = services.NewPortService(svcs.Docker)
Expand All @@ -92,7 +93,6 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config
svcs.Auth = services.NewAuthService(svcs.User, svcs.Settings, svcs.Event, cfg.JWTSecret, cfg)
svcs.Oidc = services.NewOidcService(svcs.Auth, cfg, httpClient)
svcs.System = services.NewSystemService(db, svcs.Docker, svcs.Container, svcs.Image, svcs.Volume, svcs.Network, svcs.Settings)
svcs.Version = services.NewVersionService(httpClient, cfg.UpdateCheckDisabled, config.Version, config.Revision, svcs.ContainerRegistry, svcs.Docker)
svcs.SystemUpgrade = services.NewSystemUpgradeService(svcs.Docker, svcs.Version, svcs.Event, svcs.Settings)
svcs.Updater = services.NewUpdaterService(db, svcs.Settings, svcs.Docker, svcs.Project, svcs.ImageUpdate, svcs.ContainerRegistry, svcs.Event, svcs.Image, svcs.Notification, svcs.SystemUpgrade)
svcs.GitOpsSync = services.NewGitOpsSyncService(db, svcs.GitRepository, svcs.Project, svcs.Event, svcs.Settings)
Expand Down
5 changes: 3 additions & 2 deletions backend/internal/huma/handlers/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func TestDashboardHandlerGetDashboardReturnsSnapshot(t *testing.T) {

dockerSvc := newDashboardHandlerTestDockerService(t, settingsSvc, containers, images)
handler := &DashboardHandler{
dashboardService: services.NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, nil),
dashboardService: services.NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, nil, nil),
}

output, err := handler.GetDashboard(context.Background(), &GetDashboardInput{EnvironmentID: "0"})
Expand Down Expand Up @@ -144,7 +144,7 @@ func TestDashboardHandlerGetEnvironmentsOverviewReturnsAggregateSummary(t *testi
}).Error)

handler := &DashboardHandler{
dashboardService: services.NewDashboardService(db, nil, nil, settingsSvc, nil, services.NewEnvironmentService(db, http.DefaultClient, nil, nil, settingsSvc, nil)),
dashboardService: services.NewDashboardService(db, nil, nil, settingsSvc, nil, services.NewEnvironmentService(db, http.DefaultClient, nil, nil, settingsSvc, nil), services.NewVersionService(nil, true, "1.2.3", "abcdef1234567890", nil, nil)),
}

output, err := handler.GetEnvironmentsOverview(context.Background(), &GetDashboardEnvironmentsOverviewInput{})
Expand All @@ -155,4 +155,5 @@ func TestDashboardHandlerGetEnvironmentsOverviewReturnsAggregateSummary(t *testi
require.Len(t, output.Body.Data.Environments, 1)
require.Equal(t, "0", output.Body.Data.Environments[0].Environment.ID)
require.Equal(t, dashboardtypes.EnvironmentSnapshotStateSkipped, output.Body.Data.Environments[0].SnapshotState)
require.Nil(t, output.Body.Data.Environments[0].VersionInfo)
}
78 changes: 75 additions & 3 deletions backend/internal/services/dashboard_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"time"

Expand All @@ -16,6 +17,7 @@ import (
dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard"
environmenttypes "github.com/getarcaneapp/arcane/types/environment"
imagetypes "github.com/getarcaneapp/arcane/types/image"
versiontypes "github.com/getarcaneapp/arcane/types/version"
dockercontainer "github.com/moby/moby/api/types/container"
dockerimage "github.com/moby/moby/api/types/image"
"github.com/moby/moby/client"
Expand All @@ -35,6 +37,7 @@ type DashboardService struct {
settingsService *SettingsService
vulnerabilityService *VulnerabilityService
environmentService *EnvironmentService
versionService *VersionService
}

type DashboardActionItemsOptions struct {
Expand All @@ -48,6 +51,7 @@ func NewDashboardService(
settingsService *SettingsService,
vulnerabilityService *VulnerabilityService,
environmentService *EnvironmentService,
versionService *VersionService,
) *DashboardService {
return &DashboardService{
db: db,
Expand All @@ -56,6 +60,7 @@ func NewDashboardService(
settingsService: settingsService,
vulnerabilityService: vulnerabilityService,
environmentService: environmentService,
versionService: versionService,
}
}

Expand Down Expand Up @@ -423,9 +428,32 @@ func (s *DashboardService) buildEnvironmentOverviewInternal(
return overview
}

snapshot, err := s.getSnapshotForEnvironmentInternal(ctx, env, options)
if err != nil {
message := err.Error()
var (
snapshot *dashboardtypes.Snapshot
snapshotErr error
versionInfo *versiontypes.Info
)

g, groupCtx := errgroup.WithContext(ctx)

g.Go(func() error {
snapshot, snapshotErr = s.getSnapshotForEnvironmentInternal(groupCtx, env, options)
return nil
})

g.Go(func() error {
versionInfo = s.getVersionInfoForEnvironmentInternal(groupCtx, env)
return nil
})

_ = g.Wait()

if versionInfo != nil {
overview.VersionInfo = versionInfo
}

if snapshotErr != nil {
message := snapshotErr.Error()
overview.SnapshotState = dashboardtypes.EnvironmentSnapshotStateError
overview.SnapshotError = &message
return overview
Expand Down Expand Up @@ -494,6 +522,50 @@ func buildEnvironmentDashboardProxyPathInternal(options DashboardActionItemsOpti
return fmt.Sprintf("/api/environments/%s/dashboard", localEnvironmentID)
}

func (s *DashboardService) getVersionInfoForEnvironmentInternal(
ctx context.Context,
env environmenttypes.Environment,
) *versiontypes.Info {
if s.versionService == nil {
return nil
}

reqCtx, cancel := context.WithTimeout(ctx, defaultAggregateDashboardTimeout)
defer cancel()

if env.ID == localEnvironmentID {
return s.versionService.GetAppVersionInfo(reqCtx)
}

if s.environmentService == nil {
return nil
}

respBody, statusCode, err := s.environmentService.ProxyRequest(
reqCtx,
env.ID,
"GET",
"/api/app-version",
nil,
)
if err != nil {
slog.DebugContext(reqCtx, "Failed to fetch environment version info for dashboard", "environment_id", env.ID, "error", err)
return nil
}
if statusCode < 200 || statusCode >= 300 {
slog.DebugContext(reqCtx, "Unexpected environment version status code for dashboard", "environment_id", env.ID, "status_code", statusCode)
return nil
}

var versionInfo versiontypes.Info
if err := json.Unmarshal(respBody, &versionInfo); err != nil {
slog.DebugContext(reqCtx, "Failed to decode environment version info for dashboard", "environment_id", env.ID, "error", err)
return nil
}

return &versionInfo
}

func (s *DashboardService) getStoppedContainersCountInternal(ctx context.Context) (int, error) {
if s.dockerService == nil {
return 0, nil
Expand Down
93 changes: 87 additions & 6 deletions backend/internal/services/dashboard_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
containertypes "github.com/getarcaneapp/arcane/types/container"
dashboardtypes "github.com/getarcaneapp/arcane/types/dashboard"
imagetypes "github.com/getarcaneapp/arcane/types/image"
versiontypes "github.com/getarcaneapp/arcane/types/version"
glsqlite "github.com/glebarez/sqlite"
dockercontainer "github.com/moby/moby/api/types/container"
dockerimage "github.com/moby/moby/api/types/image"
Expand Down Expand Up @@ -89,9 +90,13 @@ func newDashboardTestDockerService(
}
}

func newDashboardTestVersionServiceInternal() *VersionService {
return NewVersionService(nil, true, "1.2.3", "abcdef1234567890", nil, nil)
}

func TestDashboardService_GetActionItems_IncludesExpiringAPIKeys(t *testing.T) {
db, settingsSvc := setupDashboardServiceTestDB(t)
svc := NewDashboardService(db, nil, nil, settingsSvc, nil, nil)
svc := NewDashboardService(db, nil, nil, settingsSvc, nil, nil, nil)

now := time.Now()
createDashboardTestAPIKey(t, db, models.ApiKey{
Expand Down Expand Up @@ -135,7 +140,7 @@ func TestDashboardService_GetActionItems_IncludesExpiringAPIKeys(t *testing.T) {

func TestDashboardService_GetActionItems_DebugAllGoodReturnsNoItems(t *testing.T) {
db, settingsSvc := setupDashboardServiceTestDB(t)
svc := NewDashboardService(db, nil, nil, settingsSvc, nil, nil)
svc := NewDashboardService(db, nil, nil, settingsSvc, nil, nil, nil)

createDashboardTestAPIKey(t, db, models.ApiKey{
Name: "expiring-soon",
Expand Down Expand Up @@ -207,7 +212,7 @@ func TestDashboardService_GetSnapshot_ReturnsDashboardSnapshot(t *testing.T) {
})

dockerSvc := newDashboardTestDockerService(t, settingsSvc, containers, images)
svc := NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, nil)
svc := NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, nil, nil)

snapshot, err := svc.GetSnapshot(context.Background(), DashboardActionItemsOptions{})
require.NoError(t, err)
Expand Down Expand Up @@ -257,7 +262,7 @@ func TestDashboardService_GetSnapshot_DebugAllGoodOnlyClearsActionItems(t *testi
createDashboardTestImageUpdateRecord(t, db, models.ImageUpdateRecord{ID: "sha256:image-b", HasUpdate: true})

dockerSvc := newDashboardTestDockerService(t, settingsSvc, containers, images)
svc := NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, nil)
svc := NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, nil, nil)

snapshot, err := svc.GetSnapshot(context.Background(), DashboardActionItemsOptions{DebugAllGood: true})
require.NoError(t, err)
Expand Down Expand Up @@ -313,10 +318,24 @@ func TestDashboardService_GetEnvironmentsOverview_ReturnsLocalAndRemoteSummaries
},
}

remoteVersion := versiontypes.Info{
CurrentVersion: "v2.4.0",
DisplayVersion: "v2.4.0",
Revision: "1234567890abcdef",
ShortRevision: "12345678",
GoVersion: "go1.24.0",
IsSemverVersion: true,
UpdateAvailable: true,
NewestVersion: "v2.5.0",
ReleaseURL: "https://github.com/getarcaneapp/arcane/releases/tag/v2.5.0",
}

remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/environments/0/dashboard":
require.NoError(t, json.NewEncoder(w).Encode(remoteSnapshot))
case "/api/app-version":
require.NoError(t, json.NewEncoder(w).Encode(remoteVersion))
default:
http.NotFound(w, r)
}
Expand All @@ -340,7 +359,7 @@ func TestDashboardService_GetEnvironmentsOverview_ReturnsLocalAndRemoteSummaries

dockerSvc := newDashboardTestDockerService(t, settingsSvc, containers, images)
envSvc := NewEnvironmentService(db, remoteServer.Client(), nil, nil, settingsSvc, nil)
svc := NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, envSvc)
svc := NewDashboardService(db, dockerSvc, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal())

overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{})
require.NoError(t, err)
Expand All @@ -356,11 +375,15 @@ func TestDashboardService_GetEnvironmentsOverview_ReturnsLocalAndRemoteSummaries
require.Equal(t, "0", overview.Environments[0].Environment.ID)
require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[0].SnapshotState)
require.Equal(t, 1, overview.Environments[0].Containers.TotalContainers)
require.NotNil(t, overview.Environments[0].VersionInfo)
require.Equal(t, "v1.2.3", overview.Environments[0].VersionInfo.CurrentVersion)

require.Equal(t, "env-remote", overview.Environments[1].Environment.ID)
require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[1].SnapshotState)
require.Equal(t, 3, overview.Environments[1].Containers.TotalContainers)
require.Len(t, overview.Environments[1].ActionItems.Items, 1)
require.NotNil(t, overview.Environments[1].VersionInfo)
require.Equal(t, "v2.5.0", overview.Environments[1].VersionInfo.NewestVersion)
}

func TestDashboardService_GetEnvironmentsOverview_HandlesRemoteSnapshotFailure(t *testing.T) {
Expand All @@ -382,7 +405,7 @@ func TestDashboardService_GetEnvironmentsOverview_HandlesRemoteSnapshotFailure(t
})

envSvc := NewEnvironmentService(db, http.DefaultClient, nil, nil, settingsSvc, nil)
svc := NewDashboardService(db, nil, nil, settingsSvc, nil, envSvc)
svc := NewDashboardService(db, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal())

overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{})
require.NoError(t, err)
Expand All @@ -396,8 +419,66 @@ func TestDashboardService_GetEnvironmentsOverview_HandlesRemoteSnapshotFailure(t

require.Equal(t, dashboardtypes.EnvironmentSnapshotStateSkipped, byID["env-offline"].SnapshotState)
require.Nil(t, byID["env-offline"].SnapshotError)
require.Nil(t, byID["env-offline"].VersionInfo)

require.Equal(t, dashboardtypes.EnvironmentSnapshotStateError, byID["env-error"].SnapshotState)
require.NotNil(t, byID["env-error"].SnapshotError)
require.Contains(t, *byID["env-error"].SnapshotError, "failed to proxy dashboard snapshot")
require.Nil(t, byID["env-error"].VersionInfo)
}

func TestDashboardService_GetEnvironmentsOverview_OmitsVersionInfoWhenFetchFails(t *testing.T) {
db, settingsSvc := setupDashboardServiceTestDB(t)

remoteSnapshot := base.ApiResponse[dashboardtypes.Snapshot]{
Success: true,
Data: dashboardtypes.Snapshot{
Containers: dashboardtypes.SnapshotContainers{
Counts: containertypes.StatusCounts{
RunningContainers: 1,
StoppedContainers: 0,
TotalContainers: 1,
},
},
ImageUsageCounts: imagetypes.UsageCounts{
Inuse: 1,
Unused: 0,
Total: 1,
TotalSize: 128,
},
ActionItems: dashboardtypes.ActionItems{Items: []dashboardtypes.ActionItem{}},
},
}

remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/environments/0/dashboard":
require.NoError(t, json.NewEncoder(w).Encode(remoteSnapshot))
case "/api/app-version":
http.Error(w, "version unavailable", http.StatusInternalServerError)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(remoteServer.Close)

createDashboardTestEnvironment(t, db, models.Environment{
BaseModel: models.BaseModel{ID: "env-remote", CreatedAt: time.Now()},
Name: "Remote Alpha",
ApiUrl: remoteServer.URL,
Status: string(models.EnvironmentStatusOnline),
Enabled: true,
})

envSvc := NewEnvironmentService(db, remoteServer.Client(), nil, nil, settingsSvc, nil)
svc := NewDashboardService(db, nil, nil, settingsSvc, nil, envSvc, newDashboardTestVersionServiceInternal())

overview, err := svc.GetEnvironmentsOverview(context.Background(), DashboardActionItemsOptions{})
require.NoError(t, err)
require.NotNil(t, overview)
require.Len(t, overview.Environments, 1)

require.Equal(t, dashboardtypes.EnvironmentSnapshotStateReady, overview.Environments[0].SnapshotState)
require.Equal(t, 1, overview.Environments[0].Containers.TotalContainers)
require.Nil(t, overview.Environments[0].VersionInfo)
}
2 changes: 2 additions & 0 deletions frontend/src/lib/query/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const queryKeys = {
},
system: {
upgradeAvailable: (scope: 'mobile-nav' | 'sidebar') => ['system', 'upgrade-available', scope] as const,
environmentUpgradeAvailable: (environmentId: string) =>
['system', 'upgrade-available', 'environment', environmentId] as const,
upgradeHealth: (environmentId: string) => ['system', 'upgrade-health', environmentId] as const,
versionInfo: (environmentId: string) => ['system', 'version-info', environmentId] as const,
dockerInfo: (environmentId: string) => ['system', 'docker-info', environmentId] as const
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/types/dashboard.type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContainerStatusCounts, ContainerSummaryDto } from './container.type';
import type { AppVersionInformation } from './application-configuration';
import type { Environment } from './environment.type';
import type { ImageSummaryDto, ImageUsageCounts } from './image.type';
import type { Paginated } from './pagination.type';
Expand Down Expand Up @@ -35,6 +36,7 @@ export interface DashboardEnvironmentOverview {
imageUsageCounts: ImageUsageCounts;
actionItems: DashboardActionItems;
settings: DashboardSnapshotSettings;
versionInfo?: AppVersionInformation;
snapshotState: EnvironmentDashboardSnapshotState;
snapshotError?: string;
}
Expand Down Expand Up @@ -73,5 +75,4 @@ export interface DashboardOverviewSummary {

export interface DashboardEnvironmentCardState {
environment: Environment;
loadPromise: Promise<DashboardEnvironmentOverview> | null;
}
3 changes: 2 additions & 1 deletion frontend/src/routes/(app)/dashboard/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
let imageUsageCounts = $derived(currentDashboard.imageUsageCounts);
let dashboardActionItems = $derived(currentDashboard.actionItems);
let debugAllGood = $derived(data.debugAllGood ?? false);
let debugUpgrade = $state(false);
let currentUser = $state<User | null>(null);
const viewTabs = $derived.by((): TabItem[] => [
{ value: 'all', label: m.common_all(), icon: ContainersIcon },
Expand Down Expand Up @@ -433,7 +434,7 @@

{#if activeView === 'all'}
<Tabs.Content value="all">
<DashboardAllEnvironmentsView heroGreeting={dashboardHeroGreeting} {debugAllGood} />
<DashboardAllEnvironmentsView heroGreeting={dashboardHeroGreeting} {debugAllGood} {debugUpgrade} />
</Tabs.Content>
{:else}
<Tabs.Content value="current">
Expand Down
Loading
Loading