Feat: Docker Unified Table and frontend UI improvements

- Implemented Docker Unified Table and Cluster Services view
- Improved date/time formatting utilities
- Updated agent tests for multi-tenancy support in Pulse Unified Agent
This commit is contained in:
rcourtman 2026-01-22 16:43:41 +00:00
parent 5f56efa88a
commit f378a36a5e
4 changed files with 101 additions and 18 deletions

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"flag"
"io"
"net/http"
"net/http/httptest"
"os"
@ -180,6 +181,81 @@ func TestGatherCSV(t *testing.T) {
}
}
func TestApplyRemoteSettings(t *testing.T) {
originalLevel := zerolog.GlobalLevel()
defer zerolog.SetGlobalLevel(originalLevel)
logger := zerolog.New(io.Discard).Level(zerolog.InfoLevel)
cfg := &Config{
Interval: time.Second,
Logger: &logger,
}
settings := map[string]interface{}{
"enable_host": true,
"enable_docker": true,
"enable_kubernetes": true,
"enable_proxmox": true,
"proxmox_type": "Auto",
"docker_runtime": "PoDmAn",
"log_level": "debug",
"interval": "45s",
"disable_auto_update": true,
"disable_docker_update_checks": true,
"kube_include_all_pods": true,
"kube_include_all_deployments": true,
"report_ip": "10.0.0.1",
"disable_ceph": true,
"unknown_key_should_be_ignored": true,
}
applyRemoteSettings(cfg, settings, &logger)
if !cfg.EnableHost || !cfg.EnableDocker || !cfg.EnableKubernetes || !cfg.EnableProxmox {
t.Fatalf("expected module flags enabled, got host=%v docker=%v kube=%v proxmox=%v", cfg.EnableHost, cfg.EnableDocker, cfg.EnableKubernetes, cfg.EnableProxmox)
}
if !cfg.DockerConfigured {
t.Fatalf("expected DockerConfigured to be true")
}
if cfg.ProxmoxType != "" {
t.Fatalf("expected proxmox type to normalize to empty for auto, got %q", cfg.ProxmoxType)
}
if cfg.DockerRuntime != "podman" {
t.Fatalf("expected docker runtime to be normalized, got %q", cfg.DockerRuntime)
}
if cfg.LogLevel != zerolog.DebugLevel {
t.Fatalf("expected log level debug, got %v", cfg.LogLevel)
}
if cfg.Logger == nil {
t.Fatalf("expected logger to be updated")
}
if cfg.Interval != 45*time.Second {
t.Fatalf("expected interval 45s, got %v", cfg.Interval)
}
if !cfg.DisableAutoUpdate || !cfg.DisableDockerUpdateChecks {
t.Fatalf("expected auto-update disables to be true")
}
if !cfg.KubeIncludeAllPods || !cfg.KubeIncludeAllDeployments {
t.Fatalf("expected kube include flags to be true")
}
if cfg.ReportIP != "10.0.0.1" || !cfg.DisableCeph {
t.Fatalf("unexpected report ip / disable ceph: %q %v", cfg.ReportIP, cfg.DisableCeph)
}
}
func TestApplyRemoteSettingsIntervalFloat(t *testing.T) {
logger := zerolog.New(io.Discard)
cfg := &Config{}
applyRemoteSettings(cfg, map[string]interface{}{
"interval": float64(12),
}, &logger)
if cfg.Interval != 12*time.Second {
t.Fatalf("expected interval 12s, got %v", cfg.Interval)
}
}
func TestDefaultInt(t *testing.T) {
tests := []struct {
name string

View file

@ -3,7 +3,7 @@ import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import { formatRelativeTime } from '@/utils/format';
import { formatRelativeTime, getShortImageName } from '@/utils/format';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import {
groupHostsByCluster,
@ -171,8 +171,8 @@ const ServiceRow: Component<{ service: ClusterService }> = (props) => {
{props.service.service.name}
</span>
<Show when={props.service.service.image}>
<span class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
{props.service.service.image?.split('@')[0]}
<span class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]" title={props.service.service.image || undefined}>
{getShortImageName(props.service.service.image)}
</span>
</Show>
</div>

View file

@ -3,7 +3,7 @@ import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/t
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { formatBytes, formatPercent, formatUptime, formatRelativeTime, formatAbsoluteTime } from '@/utils/format';
import { formatBytes, formatPercent, formatUptime, formatRelativeTime, formatAbsoluteTime, getShortImageName } from '@/utils/format';
import type { DockerMetadata } from '@/api/dockerMetadata';
import { DockerMetadataAPI } from '@/api/dockerMetadata';
import type { DockerHostMetadata } from '@/api/dockerHostMetadata';
@ -42,14 +42,6 @@ const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => {
}
};
// Extract just the image name + tag from a full image path
// e.g., "ghcr.io/org/image-name:tag" → "image-name:tag"
const getShortImageName = (fullImage: string | undefined): string => {
if (!fullImage) return '—';
// Get everything after the last slash
const lastSlash = fullImage.lastIndexOf('/');
return lastSlash >= 0 ? fullImage.slice(lastSlash + 1) : fullImage;
};
type StatsFilter =
| { type: 'host-status'; value: string }
@ -139,7 +131,7 @@ interface DockerColumnDef extends ColumnConfig {
// - supplementary: Visible on large screens and up (lg: 1024px+)
// - detailed: Visible on extra large screens and up (xl: 1280px+)
export const DOCKER_COLUMNS: DockerColumnDef[] = [
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource', width: '18%' },
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource', width: '22%' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'type', width: '70px' },
{ id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: '80px', maxWidth: '200px', sortKey: 'image', width: '12%' },
{ id: 'status', label: 'Status', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'status', width: '90px' },
@ -1137,10 +1129,10 @@ const DockerContainerRow: Component<{
ariaLabel={containerStatusIndicator().label}
size="xs"
/>
<div class="flex-1 min-w-0 truncate">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
<span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate min-w-[60px] flex-shrink"
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate min-w-0 flex-1"
title={containerTitle()}
>
{container.name || container.id}
@ -1321,7 +1313,7 @@ const DockerContainerRow: Component<{
<For each={DOCKER_COLUMNS}>
{(column) => (
<td
class={`py-0.5 align-middle whitespace-nowrap ${column.id === 'resource' ? 'max-w-[300px]' : ''}`}
class={`py-0.5 align-middle whitespace-nowrap ${column.id === 'resource' ? 'max-w-[400px]' : ''}`}
style={{
"min-width": (column.id === 'cpu' || column.id === 'memory') ? (props.isMobile() ? '60px' : '140px') : undefined,
"width": (column.id === 'cpu' || column.id === 'memory') && !props.isMobile() ? '140px' : undefined,
@ -1972,7 +1964,7 @@ const DockerServiceRow: Component<{
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
<span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none"
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate min-w-0 flex-1"
title={serviceTitle()}
>
{service.name || service.id || 'Service'}
@ -2091,7 +2083,7 @@ const DockerServiceRow: Component<{
<For each={DOCKER_COLUMNS}>
{(column) => (
<td
class={`py-0.5 align-middle whitespace-nowrap ${column.id === 'resource' ? 'max-w-[300px]' : ''}`}
class={`py-0.5 align-middle whitespace-nowrap ${column.id === 'resource' ? 'max-w-[400px]' : ''}`}
style={{
"min-width": (column.id === 'cpu' || column.id === 'memory') ? (props.isMobile() ? '60px' : '140px') : undefined,
"width": (column.id === 'cpu' || column.id === 'memory') && !props.isMobile() ? '140px' : undefined,

View file

@ -198,3 +198,18 @@ export function getBackupInfo(
ageFormatted: formatTimeDiff(ageMs),
};
}
/**
* Shorten image registry URLs to show only the last two name components (repo/name).
* e.g., "ghcr.io/rcourtman/pulse:latest" -> "rcourtman/pulse:latest"
*/
export function getShortImageName(fullImage: string | undefined): string {
if (!fullImage) return '—';
// Handle case with @sha256: digests
const cleanImage = fullImage.split('@')[0];
const parts = cleanImage.split('/');
if (parts.length >= 2) {
return parts.slice(-2).join('/');
}
return cleanImage;
}