Implement status bars for Storage, Backups, and Hosts tabs #744

This commit is contained in:
courtmanr@gmail.com 2025-11-23 22:53:33 +00:00
parent 68ffa1bc0b
commit 0c7af34ad2
8 changed files with 768 additions and 648 deletions

View file

@ -1 +1 @@
4.32.4
4.32.5

View file

@ -21,6 +21,8 @@ interface BackupsFilterProps {
setSortDirection: (value: 'asc' | 'desc') => void;
sortOptions?: { value: string; label: string }[];
onReset?: () => void;
statusFilter?: () => 'all' | 'verified' | 'unverified';
setStatusFilter?: (value: 'all' | 'verified' | 'unverified') => void;
}
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
@ -305,44 +307,40 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'snapshot' ? 'all' : 'snapshot')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'snapshot'
? 'bg-white dark:bg-gray-800 text-yellow-600 dark:text-yellow-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'snapshot'
? 'bg-white dark:bg-gray-800 text-yellow-600 dark:text-yellow-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Snapshots
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'pve' ? 'all' : 'pve')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'pve'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'pve'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PVE
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'pbs' ? 'all' : 'pbs')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'pbs'
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'pbs'
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PBS
</button>
@ -363,44 +361,40 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setTypeFilter!('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All Types
</button>
<button
type="button"
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'VM' ? 'all' : 'VM')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'VM'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'VM'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
VM
</button>
<button
type="button"
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'LXC' ? 'all' : 'LXC')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'LXC'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'LXC'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
LXC
</button>
<button
type="button"
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'Host' ? 'all' : 'Host')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.typeFilter!() === 'Host'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'Host'
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
PMG
</button>
@ -413,22 +407,20 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setGroupBy('date')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy() === 'date'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy() === 'date'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Date
</button>
<button
type="button"
onClick={() => props.setGroupBy(props.groupBy() === 'guest' ? 'date' : 'guest')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy() === 'guest'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy() === 'guest'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Guest
</button>
@ -476,6 +468,42 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setStatusFilter && props.setStatusFilter('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter && props.statusFilter() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setStatusFilter && props.setStatusFilter('verified')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter && props.statusFilter() === 'verified'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Verified
</button>
<button
type="button"
onClick={() => props.setStatusFilter && props.setStatusFilter('unverified')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter && props.statusFilter() === 'unverified'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Unverified
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {
@ -485,19 +513,20 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
props.setSearch('');
props.setViewMode('all');
props.setGroupBy('date');
if (props.setStatusFilter) props.setStatusFilter('all');
}
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
props.search().trim() !== '' ||
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc' ||
(props.typeFilter && props.typeFilter() !== 'all')
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search().trim() !== '' ||
props.viewMode() !== 'all' ||
props.groupBy() !== 'date' ||
props.sortKey() !== 'backupTime' ||
props.sortDirection() !== 'desc' ||
(props.typeFilter && props.typeFilter() !== 'all') ||
(props.statusFilter && props.statusFilter() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
}`}
>
<svg
width="14"

View file

@ -58,6 +58,7 @@ const UnifiedBackups: Component = () => {
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [typeFilter, setTypeFilter] = createSignal<'all' | FilterableGuestType>('all');
const [backupTypeFilter, setBackupTypeFilter] = createSignal<'all' | BackupType>('all');
const [statusFilter, setStatusFilter] = createSignal<'all' | 'verified' | 'unverified'>('all');
const [groupByMode, setGroupByMode] = createSignal<'date' | 'guest'>('date');
// Convert between UI filter and internal filter for BackupsFilter component
@ -429,6 +430,7 @@ const UnifiedBackups: Component = () => {
const search = searchTerm().toLowerCase();
const type = typeFilter();
const backupType = backupTypeFilter();
const status = statusFilter();
const dateRange = selectedDateRange();
const nodeFilter = selectedNode();
@ -533,6 +535,15 @@ const UnifiedBackups: Component = () => {
data = data.filter((item) => item.backupType === backupType);
}
// Status filter
if (status !== 'all') {
data = data.filter((item) => {
if (status === 'verified') return item.verified === true;
if (status === 'unverified') return item.verified === false;
return true;
});
}
// Sort
const key = sortKey();
const dir = sortDirection();
@ -734,6 +745,7 @@ const UnifiedBackups: Component = () => {
setIsSearchLocked(false);
setTypeFilter('all');
setBackupTypeFilter('all');
setStatusFilter('all');
setGroupByMode('date');
setSortKey('backupTime');
setSortDirection('desc');
@ -764,6 +776,7 @@ const UnifiedBackups: Component = () => {
selectedNode() ||
typeFilter() !== 'all' ||
backupTypeFilter() !== 'all' ||
statusFilter() !== 'all' ||
selectedDateRange() !== null ||
sortKey() !== 'backupTime' ||
sortDirection() !== 'desc'
@ -1149,9 +1162,8 @@ const UnifiedBackups: Component = () => {
return (
<tr
class={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors ${
isSelected() ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
class={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors ${isSelected() ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
onClick={() => {
const currentSearch = searchTerm();
const nodeFilter = `node:${pbs.name}`;
@ -1199,9 +1211,8 @@ const UnifiedBackups: Component = () => {
<td class="p-0.5 px-1.5 whitespace-nowrap">
<div class="flex items-center gap-1">
<span
class={`h-2 w-2 rounded-full ${
isOnline() ? 'bg-green-500' : 'bg-red-500'
}`}
class={`h-2 w-2 rounded-full ${isOnline() ? 'bg-green-500' : 'bg-red-500'
}`}
/>
<span class="text-xs text-gray-600 dark:text-gray-400">
{isOnline() ? 'Online' : 'Offline'}
@ -1341,44 +1352,40 @@ const UnifiedBackups: Component = () => {
<button
type="button"
onClick={() => setChartTimeRange(7)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
chartTimeRange() === 7
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 7
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
}`}
>
7d
</button>
<button
type="button"
onClick={() => setChartTimeRange(30)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
chartTimeRange() === 30
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 30
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
}`}
>
30d
</button>
<button
type="button"
onClick={() => setChartTimeRange(90)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
chartTimeRange() === 90
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 90
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
}`}
>
90d
</button>
<button
type="button"
onClick={() => setChartTimeRange(365)}
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
chartTimeRange() === 365
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 365
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
}`}
>
1y
</button>
@ -1695,9 +1702,8 @@ const UnifiedBackups: Component = () => {
let tooltipText = `${formattedDate}`;
if (d.total > 0) {
tooltipText += `\nAvailable: ${d.total} backup${
d.total > 1 ? 's' : ''
}`;
tooltipText += `\nAvailable: ${d.total} backup${d.total > 1 ? 's' : ''
}`;
const breakdown: string[] = [];
if (d.snapshots > 0) breakdown.push(`Snapshots: ${d.snapshots}`);
@ -1846,6 +1852,8 @@ const UnifiedBackups: Component = () => {
searchInputRef={(el) => (searchInputRef = el)}
typeFilter={typeFilter}
setTypeFilter={setTypeFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
hasHostBackups={hasHostBackups}
sortOptions={sortKeyOptions}
sortKey={sortKey}
@ -1945,11 +1953,10 @@ const UnifiedBackups: Component = () => {
<div class="flex items-center justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${
item.type === 'VM'
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${item.type === 'VM'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}
}`}
>
{item.type}
</span>
@ -1960,13 +1967,12 @@ const UnifiedBackups: Component = () => {
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${
item.backupType === 'snapshot'
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${item.backupType === 'snapshot'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: item.backupType === 'local'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
}`}
}`}
>
{item.backupType === 'snapshot'
? 'SNAP'
@ -2157,13 +2163,12 @@ const UnifiedBackups: Component = () => {
<td class="p-0.5 pl-5 pr-1.5 text-sm align-middle">{item.vmid}</td>
<td class="p-0.5 px-1.5 align-middle">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
item.type === 'VM'
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.type === 'VM'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: item.type === 'Host'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
}`}
>
{item.type}
</span>
@ -2196,13 +2201,12 @@ const UnifiedBackups: Component = () => {
<td class="p-0.5 px-1.5 align-middle">
<div class="flex items-center gap-1">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
item.backupType === 'snapshot'
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.backupType === 'snapshot'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300'
: item.backupType === 'local'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
}`}
}`}
>
{item.backupType === 'snapshot'
? 'Snapshot'

View file

@ -196,7 +196,7 @@ interface DashboardProps {
}
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'stopped';
type StatusMode = 'all' | 'running' | 'degraded' | 'stopped';
type GroupingMode = 'grouped' | 'flat';
export function Dashboard(props: DashboardProps) {
@ -232,7 +232,9 @@ export function Dashboard(props: DashboardProps) {
const [statusMode, setStatusMode] = usePersistentSignal<StatusMode>('dashboardStatusMode', 'all', {
deserialize: (raw) =>
raw === 'all' || raw === 'running' || raw === 'stopped' ? raw : ('all' as StatusMode),
raw === 'all' || raw === 'running' || raw === 'degraded' || raw === 'stopped'
? (raw as StatusMode)
: 'all',
});
// Grouping mode - grouped by node or flat list
@ -487,6 +489,14 @@ export function Dashboard(props: DashboardProps) {
// Filter by status
if (statusMode() === 'running') {
guests = guests.filter((g) => g.status === 'running');
} else if (statusMode() === 'degraded') {
guests = guests.filter((g) => {
const status = (g.status || '').toLowerCase();
return (
DEGRADED_HEALTH_STATUSES.has(status) ||
(status !== 'running' && !OFFLINE_HEALTH_STATUSES.has(status))
);
});
} else if (statusMode() === 'stopped') {
guests = guests.filter((g) => g.status !== 'running');
}

View file

@ -11,8 +11,8 @@ interface DashboardFilterProps {
isSearchLocked: () => boolean;
viewMode: () => 'all' | 'vm' | 'lxc';
setViewMode: (value: 'all' | 'vm' | 'lxc') => void;
statusMode: () => 'all' | 'running' | 'stopped';
setStatusMode: (value: 'all' | 'running' | 'stopped') => void;
statusMode: () => 'all' | 'running' | 'degraded' | 'stopped';
setStatusMode: (value: 'all' | 'running' | 'degraded' | 'stopped') => void;
groupingMode: () => 'grouped' | 'flat';
setGroupingMode: (value: 'grouped' | 'flat') => void;
setSortKey: (value: string) => void;
@ -292,33 +292,30 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setViewMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'vm' ? 'all' : 'vm')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'vm'
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
VMs
</button>
<button
type="button"
onClick={() => props.setViewMode(props.viewMode() === 'lxc' ? 'all' : 'lxc')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'lxc'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
LXCs
</button>
@ -331,33 +328,40 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setStatusMode('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'running' ? 'all' : 'running')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'running'
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'running'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
}`}
>
Running
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'degraded' ? 'all' : 'degraded')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'degraded'
? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Degraded
</button>
<button
type="button"
onClick={() => props.setStatusMode(props.statusMode() === 'stopped' ? 'all' : 'stopped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.statusMode() === 'stopped'
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'stopped'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
}`}
>
Stopped
</button>
@ -370,11 +374,10 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setGroupingMode('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode() === 'grouped'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Group by node"
>
Grouped
@ -382,11 +385,10 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setGroupingMode(props.groupingMode() === 'flat' ? 'grouped' : 'flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode() === 'flat'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Flat list view"
>
List
@ -411,14 +413,13 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
props.setGroupingMode('grouped');
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
props.search() ||
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search() ||
props.viewMode() !== 'all' ||
props.statusMode() !== 'all' ||
props.groupingMode() !== 'grouped'
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<svg
width="14"

View file

@ -67,6 +67,7 @@ export interface DockerSummaryStats {
containers: {
total: number;
running: number;
degraded: number;
stopped: number;
error: number;
};
@ -123,6 +124,7 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
// Container stats
let totalContainers = 0;
let runningContainers = 0;
let degradedContainers = 0;
let stoppedContainers = 0;
let errorContainers = 0;
@ -144,8 +146,15 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
containers.forEach(container => {
const state = container.state?.toLowerCase();
const health = container.health?.toLowerCase();
if (state === 'running') {
runningContainers++;
if (health === 'unhealthy') {
degradedContainers++;
} else {
runningContainers++;
}
// Sum CPU/Memory for running containers
if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) {
totalCpu += container.cpuPercent;
@ -187,6 +196,7 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
containers: {
total: totalContainers,
running: runningContainers,
degraded: degradedContainers,
stopped: stoppedContainers,
error: errorContainers,
},
@ -305,6 +315,16 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
isActive={isActive('container-state', 'running')}
/>
<Show when={summary().containers.degraded > 0}>
<StatCard
label="Degraded"
value={summary().containers.degraded}
variant="warning"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'degraded' })}
isActive={isActive('container-state', 'degraded')}
/>
</Show>
<Show when={summary().containers.stopped > 0}>
<StatCard
label="Stopped"

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,8 @@ interface StorageFilterProps {
setSortDirection: (value: 'asc' | 'desc') => void;
sortOptions?: { value: string; label: string }[];
searchInputRef?: (el: HTMLInputElement) => void;
statusFilter?: () => 'all' | 'available' | 'offline';
setStatusFilter?: (value: 'all' | 'available' | 'offline') => void;
}
export const StorageFilter: Component<StorageFilterProps> = (props) => {
@ -299,22 +301,20 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
<button
type="button"
onClick={() => props.setGroupBy!('node')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy!() === 'node'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy!() === 'node'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Node
</button>
<button
type="button"
onClick={() => props.setGroupBy!(props.groupBy!() === 'storage' ? 'node' : 'storage')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupBy!() === 'storage'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy!() === 'storage'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Storage
</button>
@ -322,6 +322,41 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Status Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => props.setStatusFilter && props.setStatusFilter('all')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter && props.statusFilter() === 'all'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
All
</button>
<button
type="button"
onClick={() => props.setStatusFilter && props.setStatusFilter('available')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter && props.statusFilter() === 'available'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Available
</button>
<button
type="button"
onClick={() => props.setStatusFilter && props.setStatusFilter('offline')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter && props.statusFilter() === 'offline'
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Offline
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Sort controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
@ -368,16 +403,17 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
props.setSortKey('name');
props.setSortDirection('asc');
if (props.setGroupBy) props.setGroupBy('node');
if (props.setStatusFilter) props.setStatusFilter('all');
}}
title="Reset all filters"
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
props.search().trim() !== '' ||
props.sortKey() !== 'name' ||
props.sortDirection() !== 'asc' ||
(props.groupBy && props.groupBy!() !== 'node')
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search().trim() !== '' ||
props.sortKey() !== 'name' ||
props.sortDirection() !== 'asc' ||
(props.groupBy && props.groupBy!() !== 'node') ||
(props.statusFilter && props.statusFilter!() !== 'all')
? 'text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900/70'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
}`}
>
<svg
width="14"