mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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:
parent
5f56efa88a
commit
f378a36a5e
4 changed files with 101 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue