Implement status bars for Storage, Backups, and Hosts tabs #744
This commit is contained in:
parent
68ffa1bc0b
commit
0c7af34ad2
8 changed files with 768 additions and 648 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
4.32.4
|
4.32.5
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ interface BackupsFilterProps {
|
||||||
setSortDirection: (value: 'asc' | 'desc') => void;
|
setSortDirection: (value: 'asc' | 'desc') => void;
|
||||||
sortOptions?: { value: string; label: string }[];
|
sortOptions?: { value: string; label: string }[];
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
|
statusFilter?: () => 'all' | 'verified' | 'unverified';
|
||||||
|
setStatusFilter?: (value: 'all' | 'verified' | 'unverified') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
|
|
@ -305,8 +307,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setViewMode('all')}
|
onClick={() => props.setViewMode('all')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'all'
|
||||||
props.viewMode() === 'all'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -316,8 +317,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setViewMode(props.viewMode() === 'snapshot' ? 'all' : 'snapshot')}
|
onClick={() => props.setViewMode(props.viewMode() === 'snapshot' ? 'all' : 'snapshot')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'snapshot'
|
||||||
props.viewMode() === 'snapshot'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-yellow-600 dark:text-yellow-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -327,8 +327,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setViewMode(props.viewMode() === 'pve' ? 'all' : 'pve')}
|
onClick={() => props.setViewMode(props.viewMode() === 'pve' ? 'all' : 'pve')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'pve'
|
||||||
props.viewMode() === 'pve'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -338,8 +337,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setViewMode(props.viewMode() === 'pbs' ? 'all' : 'pbs')}
|
onClick={() => props.setViewMode(props.viewMode() === 'pbs' ? 'all' : 'pbs')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'pbs'
|
||||||
props.viewMode() === 'pbs'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-purple-600 dark:text-purple-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -363,8 +361,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setTypeFilter!('all')}
|
onClick={() => props.setTypeFilter!('all')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'all'
|
||||||
props.typeFilter!() === 'all'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -374,8 +371,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'VM' ? 'all' : 'VM')}
|
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'VM' ? 'all' : 'VM')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'VM'
|
||||||
props.typeFilter!() === 'VM'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -385,8 +381,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'LXC' ? 'all' : 'LXC')}
|
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'LXC' ? 'all' : 'LXC')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'LXC'
|
||||||
props.typeFilter!() === 'LXC'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -396,8 +391,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'Host' ? 'all' : 'Host')}
|
onClick={() => props.setTypeFilter!(props.typeFilter!() === 'Host' ? 'all' : 'Host')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.typeFilter!() === 'Host'
|
||||||
props.typeFilter!() === 'Host'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-orange-600 dark:text-orange-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -413,8 +407,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setGroupBy('date')}
|
onClick={() => props.setGroupBy('date')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy() === 'date'
|
||||||
props.groupBy() === 'date'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -424,8 +417,7 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setGroupBy(props.groupBy() === 'guest' ? 'date' : 'guest')}
|
onClick={() => props.setGroupBy(props.groupBy() === 'guest' ? 'date' : 'guest')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy() === 'guest'
|
||||||
props.groupBy() === 'guest'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -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>
|
<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 */}
|
{/* Reset Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -485,16 +513,17 @@ export const BackupsFilter: Component<BackupsFilterProps> = (props) => {
|
||||||
props.setSearch('');
|
props.setSearch('');
|
||||||
props.setViewMode('all');
|
props.setViewMode('all');
|
||||||
props.setGroupBy('date');
|
props.setGroupBy('date');
|
||||||
|
if (props.setStatusFilter) props.setStatusFilter('all');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Reset all filters"
|
title="Reset all filters"
|
||||||
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
|
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search().trim() !== '' ||
|
||||||
props.search().trim() !== '' ||
|
|
||||||
props.viewMode() !== 'all' ||
|
props.viewMode() !== 'all' ||
|
||||||
props.groupBy() !== 'date' ||
|
props.groupBy() !== 'date' ||
|
||||||
props.sortKey() !== 'backupTime' ||
|
props.sortKey() !== 'backupTime' ||
|
||||||
props.sortDirection() !== 'desc' ||
|
props.sortDirection() !== 'desc' ||
|
||||||
(props.typeFilter && props.typeFilter() !== 'all')
|
(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-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-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ const UnifiedBackups: Component = () => {
|
||||||
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
|
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
|
||||||
const [typeFilter, setTypeFilter] = createSignal<'all' | FilterableGuestType>('all');
|
const [typeFilter, setTypeFilter] = createSignal<'all' | FilterableGuestType>('all');
|
||||||
const [backupTypeFilter, setBackupTypeFilter] = createSignal<'all' | BackupType>('all');
|
const [backupTypeFilter, setBackupTypeFilter] = createSignal<'all' | BackupType>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = createSignal<'all' | 'verified' | 'unverified'>('all');
|
||||||
const [groupByMode, setGroupByMode] = createSignal<'date' | 'guest'>('date');
|
const [groupByMode, setGroupByMode] = createSignal<'date' | 'guest'>('date');
|
||||||
|
|
||||||
// Convert between UI filter and internal filter for BackupsFilter component
|
// Convert between UI filter and internal filter for BackupsFilter component
|
||||||
|
|
@ -429,6 +430,7 @@ const UnifiedBackups: Component = () => {
|
||||||
const search = searchTerm().toLowerCase();
|
const search = searchTerm().toLowerCase();
|
||||||
const type = typeFilter();
|
const type = typeFilter();
|
||||||
const backupType = backupTypeFilter();
|
const backupType = backupTypeFilter();
|
||||||
|
const status = statusFilter();
|
||||||
const dateRange = selectedDateRange();
|
const dateRange = selectedDateRange();
|
||||||
const nodeFilter = selectedNode();
|
const nodeFilter = selectedNode();
|
||||||
|
|
||||||
|
|
@ -533,6 +535,15 @@ const UnifiedBackups: Component = () => {
|
||||||
data = data.filter((item) => item.backupType === backupType);
|
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
|
// Sort
|
||||||
const key = sortKey();
|
const key = sortKey();
|
||||||
const dir = sortDirection();
|
const dir = sortDirection();
|
||||||
|
|
@ -734,6 +745,7 @@ const UnifiedBackups: Component = () => {
|
||||||
setIsSearchLocked(false);
|
setIsSearchLocked(false);
|
||||||
setTypeFilter('all');
|
setTypeFilter('all');
|
||||||
setBackupTypeFilter('all');
|
setBackupTypeFilter('all');
|
||||||
|
setStatusFilter('all');
|
||||||
setGroupByMode('date');
|
setGroupByMode('date');
|
||||||
setSortKey('backupTime');
|
setSortKey('backupTime');
|
||||||
setSortDirection('desc');
|
setSortDirection('desc');
|
||||||
|
|
@ -764,6 +776,7 @@ const UnifiedBackups: Component = () => {
|
||||||
selectedNode() ||
|
selectedNode() ||
|
||||||
typeFilter() !== 'all' ||
|
typeFilter() !== 'all' ||
|
||||||
backupTypeFilter() !== 'all' ||
|
backupTypeFilter() !== 'all' ||
|
||||||
|
statusFilter() !== 'all' ||
|
||||||
selectedDateRange() !== null ||
|
selectedDateRange() !== null ||
|
||||||
sortKey() !== 'backupTime' ||
|
sortKey() !== 'backupTime' ||
|
||||||
sortDirection() !== 'desc'
|
sortDirection() !== 'desc'
|
||||||
|
|
@ -1149,8 +1162,7 @@ const UnifiedBackups: Component = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
class={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors ${
|
class={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors ${isSelected() ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||||
isSelected() ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentSearch = searchTerm();
|
const currentSearch = searchTerm();
|
||||||
|
|
@ -1199,8 +1211,7 @@ const UnifiedBackups: Component = () => {
|
||||||
<td class="p-0.5 px-1.5 whitespace-nowrap">
|
<td class="p-0.5 px-1.5 whitespace-nowrap">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
class={`h-2 w-2 rounded-full ${
|
class={`h-2 w-2 rounded-full ${isOnline() ? 'bg-green-500' : 'bg-red-500'
|
||||||
isOnline() ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
|
@ -1341,8 +1352,7 @@ const UnifiedBackups: Component = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(7)}
|
onClick={() => setChartTimeRange(7)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 7
|
||||||
chartTimeRange() === 7
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1352,8 +1362,7 @@ const UnifiedBackups: Component = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(30)}
|
onClick={() => setChartTimeRange(30)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 30
|
||||||
chartTimeRange() === 30
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1363,8 +1372,7 @@ const UnifiedBackups: Component = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(90)}
|
onClick={() => setChartTimeRange(90)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 90
|
||||||
chartTimeRange() === 90
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1374,8 +1382,7 @@ const UnifiedBackups: Component = () => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setChartTimeRange(365)}
|
onClick={() => setChartTimeRange(365)}
|
||||||
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${
|
class={`p-0.5 px-1.5 text-xs border rounded transition-colors ${chartTimeRange() === 365
|
||||||
chartTimeRange() === 365
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700'
|
? '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'
|
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1695,8 +1702,7 @@ const UnifiedBackups: Component = () => {
|
||||||
let tooltipText = `${formattedDate}`;
|
let tooltipText = `${formattedDate}`;
|
||||||
|
|
||||||
if (d.total > 0) {
|
if (d.total > 0) {
|
||||||
tooltipText += `\nAvailable: ${d.total} backup${
|
tooltipText += `\nAvailable: ${d.total} backup${d.total > 1 ? 's' : ''
|
||||||
d.total > 1 ? 's' : ''
|
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const breakdown: string[] = [];
|
const breakdown: string[] = [];
|
||||||
|
|
@ -1846,6 +1852,8 @@ const UnifiedBackups: Component = () => {
|
||||||
searchInputRef={(el) => (searchInputRef = el)}
|
searchInputRef={(el) => (searchInputRef = el)}
|
||||||
typeFilter={typeFilter}
|
typeFilter={typeFilter}
|
||||||
setTypeFilter={setTypeFilter}
|
setTypeFilter={setTypeFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
setStatusFilter={setStatusFilter}
|
||||||
hasHostBackups={hasHostBackups}
|
hasHostBackups={hasHostBackups}
|
||||||
sortOptions={sortKeyOptions}
|
sortOptions={sortKeyOptions}
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
|
|
@ -1945,8 +1953,7 @@ const UnifiedBackups: Component = () => {
|
||||||
<div class="flex items-center justify-between gap-2 mb-1">
|
<div class="flex items-center justify-between gap-2 mb-1">
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${item.type === 'VM'
|
||||||
item.type === 'VM'
|
|
||||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
? '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'
|
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1960,8 +1967,7 @@ const UnifiedBackups: Component = () => {
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${item.backupType === 'snapshot'
|
||||||
item.backupType === 'snapshot'
|
|
||||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||||
: item.backupType === 'local'
|
: item.backupType === 'local'
|
||||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||||
|
|
@ -2157,8 +2163,7 @@ 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 pl-5 pr-1.5 text-sm align-middle">{item.vmid}</td>
|
||||||
<td class="p-0.5 px-1.5 align-middle">
|
<td class="p-0.5 px-1.5 align-middle">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.type === 'VM'
|
||||||
item.type === 'VM'
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||||
: item.type === 'Host'
|
: item.type === 'Host'
|
||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
||||||
|
|
@ -2196,8 +2201,7 @@ const UnifiedBackups: Component = () => {
|
||||||
<td class="p-0.5 px-1.5 align-middle">
|
<td class="p-0.5 px-1.5 align-middle">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
|
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.backupType === 'snapshot'
|
||||||
item.backupType === 'snapshot'
|
|
||||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300'
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300'
|
||||||
: item.backupType === 'local'
|
: item.backupType === 'local'
|
||||||
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300'
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ interface DashboardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'all' | 'vm' | 'lxc';
|
type ViewMode = 'all' | 'vm' | 'lxc';
|
||||||
type StatusMode = 'all' | 'running' | 'stopped';
|
type StatusMode = 'all' | 'running' | 'degraded' | 'stopped';
|
||||||
type GroupingMode = 'grouped' | 'flat';
|
type GroupingMode = 'grouped' | 'flat';
|
||||||
|
|
||||||
export function Dashboard(props: DashboardProps) {
|
export function Dashboard(props: DashboardProps) {
|
||||||
|
|
@ -232,7 +232,9 @@ export function Dashboard(props: DashboardProps) {
|
||||||
|
|
||||||
const [statusMode, setStatusMode] = usePersistentSignal<StatusMode>('dashboardStatusMode', 'all', {
|
const [statusMode, setStatusMode] = usePersistentSignal<StatusMode>('dashboardStatusMode', 'all', {
|
||||||
deserialize: (raw) =>
|
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
|
// Grouping mode - grouped by node or flat list
|
||||||
|
|
@ -487,6 +489,14 @@ export function Dashboard(props: DashboardProps) {
|
||||||
// Filter by status
|
// Filter by status
|
||||||
if (statusMode() === 'running') {
|
if (statusMode() === 'running') {
|
||||||
guests = guests.filter((g) => g.status === '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') {
|
} else if (statusMode() === 'stopped') {
|
||||||
guests = guests.filter((g) => g.status !== 'running');
|
guests = guests.filter((g) => g.status !== 'running');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ interface DashboardFilterProps {
|
||||||
isSearchLocked: () => boolean;
|
isSearchLocked: () => boolean;
|
||||||
viewMode: () => 'all' | 'vm' | 'lxc';
|
viewMode: () => 'all' | 'vm' | 'lxc';
|
||||||
setViewMode: (value: 'all' | 'vm' | 'lxc') => void;
|
setViewMode: (value: 'all' | 'vm' | 'lxc') => void;
|
||||||
statusMode: () => 'all' | 'running' | 'stopped';
|
statusMode: () => 'all' | 'running' | 'degraded' | 'stopped';
|
||||||
setStatusMode: (value: 'all' | 'running' | 'stopped') => void;
|
setStatusMode: (value: 'all' | 'running' | 'degraded' | 'stopped') => void;
|
||||||
groupingMode: () => 'grouped' | 'flat';
|
groupingMode: () => 'grouped' | 'flat';
|
||||||
setGroupingMode: (value: 'grouped' | 'flat') => void;
|
setGroupingMode: (value: 'grouped' | 'flat') => void;
|
||||||
setSortKey: (value: string) => void;
|
setSortKey: (value: string) => void;
|
||||||
|
|
@ -292,8 +292,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setViewMode('all')}
|
onClick={() => props.setViewMode('all')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'all'
|
||||||
props.viewMode() === 'all'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -303,8 +302,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setViewMode(props.viewMode() === 'vm' ? 'all' : 'vm')}
|
onClick={() => props.setViewMode(props.viewMode() === 'vm' ? 'all' : 'vm')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'vm'
|
||||||
props.viewMode() === 'vm'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-blue-600 dark:text-blue-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -314,8 +312,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setViewMode(props.viewMode() === 'lxc' ? 'all' : 'lxc')}
|
onClick={() => props.setViewMode(props.viewMode() === 'lxc' ? 'all' : 'lxc')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.viewMode() === 'lxc'
|
||||||
props.viewMode() === 'lxc'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -331,8 +328,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setStatusMode('all')}
|
onClick={() => props.setStatusMode('all')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'all'
|
||||||
props.statusMode() === 'all'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -342,19 +338,27 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setStatusMode(props.statusMode() === 'running' ? 'all' : 'running')}
|
onClick={() => props.setStatusMode(props.statusMode() === 'running' ? 'all' : 'running')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'running'
|
||||||
props.statusMode() === 'running'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Running
|
Running
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setStatusMode(props.statusMode() === 'stopped' ? 'all' : 'stopped')}
|
onClick={() => props.setStatusMode(props.statusMode() === 'stopped' ? 'all' : 'stopped')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusMode() === 'stopped'
|
||||||
props.statusMode() === 'stopped'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -370,8 +374,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setGroupingMode('grouped')}
|
onClick={() => props.setGroupingMode('grouped')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode() === 'grouped'
|
||||||
props.groupingMode() === 'grouped'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -382,8 +385,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setGroupingMode(props.groupingMode() === 'flat' ? 'grouped' : 'flat')}
|
onClick={() => props.setGroupingMode(props.groupingMode() === 'flat' ? 'grouped' : 'flat')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupingMode() === 'flat'
|
||||||
props.groupingMode() === 'flat'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -411,8 +413,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
|
||||||
props.setGroupingMode('grouped');
|
props.setGroupingMode('grouped');
|
||||||
}}
|
}}
|
||||||
title="Reset all filters"
|
title="Reset all filters"
|
||||||
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
|
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search() ||
|
||||||
props.search() ||
|
|
||||||
props.viewMode() !== 'all' ||
|
props.viewMode() !== 'all' ||
|
||||||
props.statusMode() !== 'all' ||
|
props.statusMode() !== 'all' ||
|
||||||
props.groupingMode() !== 'grouped'
|
props.groupingMode() !== 'grouped'
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export interface DockerSummaryStats {
|
||||||
containers: {
|
containers: {
|
||||||
total: number;
|
total: number;
|
||||||
running: number;
|
running: number;
|
||||||
|
degraded: number;
|
||||||
stopped: number;
|
stopped: number;
|
||||||
error: number;
|
error: number;
|
||||||
};
|
};
|
||||||
|
|
@ -123,6 +124,7 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
|
||||||
// Container stats
|
// Container stats
|
||||||
let totalContainers = 0;
|
let totalContainers = 0;
|
||||||
let runningContainers = 0;
|
let runningContainers = 0;
|
||||||
|
let degradedContainers = 0;
|
||||||
let stoppedContainers = 0;
|
let stoppedContainers = 0;
|
||||||
let errorContainers = 0;
|
let errorContainers = 0;
|
||||||
|
|
||||||
|
|
@ -144,8 +146,15 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
|
||||||
|
|
||||||
containers.forEach(container => {
|
containers.forEach(container => {
|
||||||
const state = container.state?.toLowerCase();
|
const state = container.state?.toLowerCase();
|
||||||
|
const health = container.health?.toLowerCase();
|
||||||
|
|
||||||
if (state === 'running') {
|
if (state === 'running') {
|
||||||
|
if (health === 'unhealthy') {
|
||||||
|
degradedContainers++;
|
||||||
|
} else {
|
||||||
runningContainers++;
|
runningContainers++;
|
||||||
|
}
|
||||||
|
|
||||||
// Sum CPU/Memory for running containers
|
// Sum CPU/Memory for running containers
|
||||||
if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) {
|
if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) {
|
||||||
totalCpu += container.cpuPercent;
|
totalCpu += container.cpuPercent;
|
||||||
|
|
@ -187,6 +196,7 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
|
||||||
containers: {
|
containers: {
|
||||||
total: totalContainers,
|
total: totalContainers,
|
||||||
running: runningContainers,
|
running: runningContainers,
|
||||||
|
degraded: degradedContainers,
|
||||||
stopped: stoppedContainers,
|
stopped: stoppedContainers,
|
||||||
error: errorContainers,
|
error: errorContainers,
|
||||||
},
|
},
|
||||||
|
|
@ -305,6 +315,16 @@ export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props)
|
||||||
isActive={isActive('container-state', 'running')}
|
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}>
|
<Show when={summary().containers.stopped > 0}>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Stopped"
|
label="Stopped"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@ const Storage: Component = () => {
|
||||||
deserialize: (raw) => (raw === 'desc' ? 'desc' : 'asc'),
|
deserialize: (raw) => (raw === 'desc' ? 'desc' : 'asc'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const [statusFilter, setStatusFilter] = usePersistentSignal<'all' | 'available' | 'offline'>(
|
||||||
|
'storageStatusFilter',
|
||||||
|
'all',
|
||||||
|
{
|
||||||
|
deserialize: (raw) =>
|
||||||
|
raw === 'all' || raw === 'available' || raw === 'offline' ? raw : 'all',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Create a mapping from node instance ID to node object
|
// Create a mapping from node instance ID to node object
|
||||||
const nodeByInstance = createMemo(() => {
|
const nodeByInstance = createMemo(() => {
|
||||||
|
|
@ -316,6 +324,18 @@ const Storage: Component = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
if (statusFilter() !== 'all') {
|
||||||
|
storage = storage.filter((s) => {
|
||||||
|
if (statusFilter() === 'available') {
|
||||||
|
return s.status === 'available';
|
||||||
|
} else {
|
||||||
|
// Offline includes anything not available
|
||||||
|
return s.status !== 'available';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
let search = searchTerm().toLowerCase().trim();
|
let search = searchTerm().toLowerCase().trim();
|
||||||
if (search) {
|
if (search) {
|
||||||
|
|
@ -477,6 +497,7 @@ const Storage: Component = () => {
|
||||||
setViewMode('node');
|
setViewMode('node');
|
||||||
setSortKey('name');
|
setSortKey('name');
|
||||||
setSortDirection('asc');
|
setSortDirection('asc');
|
||||||
|
setStatusFilter('all');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
// Handle keyboard shortcuts
|
||||||
|
|
@ -495,7 +516,12 @@ const Storage: Component = () => {
|
||||||
// Escape key behavior
|
// Escape key behavior
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
// Clear search and reset filters
|
// Clear search and reset filters
|
||||||
if (searchTerm().trim() || selectedNode() || viewMode() !== 'node') {
|
if (
|
||||||
|
searchTerm().trim() ||
|
||||||
|
selectedNode() ||
|
||||||
|
viewMode() !== 'node' ||
|
||||||
|
statusFilter() !== 'all'
|
||||||
|
) {
|
||||||
resetFilters();
|
resetFilters();
|
||||||
|
|
||||||
// Blur the search input if it's focused
|
// Blur the search input if it's focused
|
||||||
|
|
@ -539,8 +565,7 @@ const Storage: Component = () => {
|
||||||
<button
|
<button
|
||||||
onClick={() => setTabView('pools')}
|
onClick={() => setTabView('pools')}
|
||||||
type="button"
|
type="button"
|
||||||
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${
|
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'pools'
|
||||||
tabView() === 'pools'
|
|
||||||
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
||||||
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -550,8 +575,7 @@ const Storage: Component = () => {
|
||||||
<button
|
<button
|
||||||
onClick={() => setTabView('disks')}
|
onClick={() => setTabView('disks')}
|
||||||
type="button"
|
type="button"
|
||||||
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${
|
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'disks'
|
||||||
tabView() === 'disks'
|
|
||||||
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
||||||
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -573,6 +597,8 @@ const Storage: Component = () => {
|
||||||
setSortKey={setSortKey}
|
setSortKey={setSortKey}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
setSortDirection={setSortDirection}
|
setSortDirection={setSortDirection}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
setStatusFilter={setStatusFilter}
|
||||||
searchInputRef={(el) => (searchInputRef = el)}
|
searchInputRef={(el) => (searchInputRef = el)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -1025,8 +1051,7 @@ const Storage: Component = () => {
|
||||||
<td class={`${firstCellClass()} align-middle`}>
|
<td class={`${firstCellClass()} align-middle`}>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span
|
<span
|
||||||
class={`text-sm font-medium text-gray-900 dark:text-gray-100 truncate ${
|
class={`text-sm font-medium text-gray-900 dark:text-gray-100 truncate ${canExpand() ? 'max-w-[180px]' : 'max-w-[200px]'
|
||||||
canExpand() ? 'max-w-[180px]' : 'max-w-[200px]'
|
|
||||||
}`}
|
}`}
|
||||||
title={storage.name}
|
title={storage.name}
|
||||||
>
|
>
|
||||||
|
|
@ -1035,8 +1060,7 @@ const Storage: Component = () => {
|
||||||
{/* ZFS Health Badge */}
|
{/* ZFS Health Badge */}
|
||||||
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
|
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
|
||||||
<span
|
<span
|
||||||
class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${zfsPool?.state === 'DEGRADED'
|
||||||
zfsPool?.state === 'DEGRADED'
|
|
||||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1100,8 +1124,7 @@ const Storage: Component = () => {
|
||||||
</td>
|
</td>
|
||||||
<td class="p-0.5 px-1.5 text-xs hidden sm:table-cell whitespace-nowrap">
|
<td class="p-0.5 px-1.5 text-xs hidden sm:table-cell whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
class={`${
|
class={`${storage.status === 'available'
|
||||||
storage.status === 'available'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
: 'text-red-600 dark:text-red-400'
|
: 'text-red-600 dark:text-red-400'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -1142,16 +1165,14 @@ const Storage: Component = () => {
|
||||||
</tr>
|
</tr>
|
||||||
<Show when={isCephStorage() && isExpanded()}>
|
<Show when={isCephStorage() && isExpanded()}>
|
||||||
<tr
|
<tr
|
||||||
class={`text-[11px] border-t border-gray-200 dark:border-gray-700 ${
|
class={`text-[11px] border-t border-gray-200 dark:border-gray-700 ${drawerDisabled()
|
||||||
drawerDisabled()
|
|
||||||
? 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
? 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
||||||
: 'bg-gray-50/60 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
|
: 'bg-gray-50/60 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td colSpan={9} class="px-4 py-3">
|
<td colSpan={9} class="px-4 py-3">
|
||||||
<div
|
<div
|
||||||
class={`grid gap-3 md:grid-cols-2 xl:grid-cols-2 ${
|
class={`grid gap-3 md:grid-cols-2 xl:grid-cols-2 ${drawerDisabled() ? 'opacity-60 pointer-events-none' : ''
|
||||||
drawerDisabled() ? 'opacity-60 pointer-events-none' : ''
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div class="rounded-lg border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-600/60 dark:bg-gray-900/30">
|
<div class="rounded-lg border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-600/60 dark:bg-gray-900/30">
|
||||||
|
|
@ -1211,8 +1232,7 @@ const Storage: Component = () => {
|
||||||
ZFS Pool Status:
|
ZFS Pool Status:
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`px-1.5 py-0.5 rounded text-xs font-medium ${
|
class={`px-1.5 py-0.5 rounded text-xs font-medium ${storage.zfsPool!.state === 'ONLINE'
|
||||||
storage.zfsPool!.state === 'ONLINE'
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
: storage.zfsPool!.state === 'DEGRADED'
|
: storage.zfsPool!.state === 'DEGRADED'
|
||||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ interface StorageFilterProps {
|
||||||
setSortDirection: (value: 'asc' | 'desc') => void;
|
setSortDirection: (value: 'asc' | 'desc') => void;
|
||||||
sortOptions?: { value: string; label: string }[];
|
sortOptions?: { value: string; label: string }[];
|
||||||
searchInputRef?: (el: HTMLInputElement) => void;
|
searchInputRef?: (el: HTMLInputElement) => void;
|
||||||
|
statusFilter?: () => 'all' | 'available' | 'offline';
|
||||||
|
setStatusFilter?: (value: 'all' | 'available' | 'offline') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StorageFilter: Component<StorageFilterProps> = (props) => {
|
export const StorageFilter: Component<StorageFilterProps> = (props) => {
|
||||||
|
|
@ -299,8 +301,7 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setGroupBy!('node')}
|
onClick={() => props.setGroupBy!('node')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy!() === 'node'
|
||||||
props.groupBy!() === 'node'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -310,8 +311,7 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.setGroupBy!(props.groupBy!() === 'storage' ? 'node' : 'storage')}
|
onClick={() => props.setGroupBy!(props.groupBy!() === 'storage' ? 'node' : 'storage')}
|
||||||
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.groupBy!() === 'storage'
|
||||||
props.groupBy!() === 'storage'
|
|
||||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
? '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'
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -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>
|
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
|
||||||
</Show>
|
</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 */}
|
{/* Sort controls */}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
|
@ -368,13 +403,14 @@ export const StorageFilter: Component<StorageFilterProps> = (props) => {
|
||||||
props.setSortKey('name');
|
props.setSortKey('name');
|
||||||
props.setSortDirection('asc');
|
props.setSortDirection('asc');
|
||||||
if (props.setGroupBy) props.setGroupBy('node');
|
if (props.setGroupBy) props.setGroupBy('node');
|
||||||
|
if (props.setStatusFilter) props.setStatusFilter('all');
|
||||||
}}
|
}}
|
||||||
title="Reset all filters"
|
title="Reset all filters"
|
||||||
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${
|
class={`flex items-center justify-center px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${props.search().trim() !== '' ||
|
||||||
props.search().trim() !== '' ||
|
|
||||||
props.sortKey() !== 'name' ||
|
props.sortKey() !== 'name' ||
|
||||||
props.sortDirection() !== 'asc' ||
|
props.sortDirection() !== 'asc' ||
|
||||||
(props.groupBy && props.groupBy!() !== 'node')
|
(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-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-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue