checkpoint: remove unified resources UI, keep backend model
- Removed /resources page and associated frontend components - Removed ResourcesOverview.tsx, UnifiedResourceRow.tsx, columns.ts - Removed frontend types/resource.ts - Updated unified-resource-architecture.md to mark Phase 4 as ABANDONED - Removed unified-view-migration-plan.md - Backend unified resource model remains for AI context This is a checkpoint before attempting full frontend migration to unified model.
This commit is contained in:
parent
cac109b497
commit
ef35120afb
8 changed files with 49 additions and 660 deletions
|
|
@ -401,36 +401,37 @@ func (m *Monitor) pollPVENode(...) (models.Node, string, error) {
|
|||
- Reduced API load (skip redundant polling)
|
||||
- More AI capabilities (command execution via agents)
|
||||
|
||||
### Phase 4: Optional Unified View (Future)
|
||||
### Phase 4: Unified Monitoring Page (ABANDONED)
|
||||
|
||||
**Goal:** Add a consolidated "All Resources" view for power users.
|
||||
**Goal:** Replace separate platform pages with ONE unified monitoring experience.
|
||||
|
||||
**Status:** ✅ Basic implementation complete
|
||||
**Status:** ❌ **ABANDONED** - Prototyped but reverted in favor of existing pages
|
||||
|
||||
**Completed Tasks:**
|
||||
1. ✅ Created `src/types/resource.ts` - TypeScript types matching Go backend
|
||||
2. ✅ Created `src/components/Resources/ResourcesOverview.tsx` - Unified view component
|
||||
3. ✅ Route added: `/resources`
|
||||
4. ✅ Filtering by type, platform, status
|
||||
5. ✅ Grouping by type, platform, or parent
|
||||
**Decision (2025-12-07):**
|
||||
After building and testing the unified resources view, we determined that the **existing platform-specific pages are superior** in terms of:
|
||||
- User experience and layout
|
||||
- Feature richness
|
||||
- Familiarity for existing users
|
||||
|
||||
**Remaining Tasks:**
|
||||
- [ ] Add navigation link to UI (currently hidden power-user route)
|
||||
- [ ] Add resource detail drawer/modal
|
||||
- [ ] Add AI context integration (click to add to AI chat)
|
||||
- [ ] Add tag-based filtering
|
||||
- [ ] Add export functionality
|
||||
The backend unified resource model (Phases 1-3) remains valuable for:
|
||||
- AI context enhancement (cross-platform intelligence)
|
||||
- Deduplication logic (avoiding duplicate alerts)
|
||||
- Future API consumers
|
||||
|
||||
**Features:**
|
||||
- Fetches from `/api/resources` endpoint
|
||||
- Auto-refreshes every 10 seconds
|
||||
- Filtering by search, type, platform, status
|
||||
- Grouping by type/platform/parent
|
||||
- Status indicators with alert badges
|
||||
- CPU/Memory/Disk progress bars
|
||||
**What was removed:**
|
||||
- `src/components/Resources/ResourcesOverview.tsx`
|
||||
- `src/components/Resources/UnifiedResourceRow.tsx`
|
||||
- `src/components/Resources/columns.ts`
|
||||
- `src/types/resource.ts`
|
||||
- `/resources` route
|
||||
|
||||
**Access:**
|
||||
Navigate to `/resources` directly (not yet in main navigation)
|
||||
**What remains:**
|
||||
- Backend `/api/resources` endpoint (for AI and API consumers)
|
||||
- Backend unified resource store (`internal/resources/`)
|
||||
- Platform-specific frontend pages (Dashboard, Docker, Hosts)
|
||||
|
||||
**Lesson Learned:**
|
||||
Sometimes the best architecture is to keep what works. The existing pages have years of refinement and user-driven features. A "unified" view lost much of that polish.
|
||||
|
||||
### Phase 5: New Platform Support (Ongoing)
|
||||
|
||||
|
|
|
|||
|
|
@ -67,11 +67,7 @@ const HostsOverview = lazy(() =>
|
|||
default: module.HostsOverview,
|
||||
})),
|
||||
);
|
||||
const ResourcesOverview = lazy(() =>
|
||||
import('./components/Resources/ResourcesOverview').then((module) => ({
|
||||
default: module.ResourcesOverview,
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
// Enhanced store type with proper typing
|
||||
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
|
||||
|
|
@ -242,7 +238,7 @@ function App() {
|
|||
() => import('./components/Replication/Replication'),
|
||||
() => import('./components/PMG/MailGateway'),
|
||||
() => import('./components/Hosts/HostsOverview'),
|
||||
() => import('./components/Resources/ResourcesOverview'),
|
||||
|
||||
() => import('./pages/Alerts'),
|
||||
() => import('./components/Settings/Settings'),
|
||||
() => import('./components/Docker/DockerHosts'),
|
||||
|
|
@ -878,7 +874,7 @@ function App() {
|
|||
<Route path="/backups" component={() => <Navigate href="/proxmox/backups" />} />
|
||||
<Route path="/docker" component={DockerRoute} />
|
||||
<Route path="/hosts" component={HostsRoute} />
|
||||
<Route path="/resources" component={ResourcesOverview} />
|
||||
|
||||
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
|
||||
<Route path="/alerts/*" component={AlertsPage} />
|
||||
<Route path="/settings/*" component={SettingsRoute} />
|
||||
|
|
|
|||
|
|
@ -1,462 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { For, Show, createMemo, createSignal, onMount, onCleanup, createResource } from 'solid-js';
|
||||
import type { Resource, ResourceType, PlatformType, ResourceStatus, ResourcesResponse } from '@/types/resource';
|
||||
import {
|
||||
RESOURCE_TYPE_LABELS,
|
||||
PLATFORM_LABELS,
|
||||
STATUS_LABELS,
|
||||
getStatusVariant,
|
||||
isInfrastructureType,
|
||||
isWorkloadType
|
||||
} from '@/types/resource';
|
||||
import { formatBytes, formatUptime, formatRelativeTime } from '@/utils/format';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
// Fetch resources from API
|
||||
async function fetchResources(): Promise<ResourcesResponse> {
|
||||
try {
|
||||
const response = await apiFetchJSON<ResourcesResponse>('/api/resources');
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch resources:', error);
|
||||
return { resources: [], count: 0, stats: { totalResources: 0, byType: {}, byPlatform: {}, byStatus: {}, withAlerts: 0, lastUpdated: '' } };
|
||||
}
|
||||
}
|
||||
|
||||
// Filter panel component
|
||||
interface ResourceFilterProps {
|
||||
search: () => string;
|
||||
setSearch: (value: string) => void;
|
||||
typeFilter: () => ResourceType | 'all';
|
||||
setTypeFilter: (value: ResourceType | 'all') => void;
|
||||
platformFilter: () => PlatformType | 'all';
|
||||
setPlatformFilter: (value: PlatformType | 'all') => void;
|
||||
statusFilter: () => ResourceStatus | 'all';
|
||||
setStatusFilter: (value: ResourceStatus | 'all') => void;
|
||||
groupBy: () => 'none' | 'type' | 'platform' | 'parent';
|
||||
setGroupBy: (value: 'none' | 'type' | 'platform' | 'parent') => void;
|
||||
stats: () => ResourcesResponse['stats'] | undefined;
|
||||
}
|
||||
|
||||
const ResourceFilter: Component<ResourceFilterProps> = (props) => {
|
||||
const selectClass = 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
|
||||
|
||||
return (
|
||||
<Card padding="sm" tone="glass" class="mb-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search resources..."
|
||||
value={props.search()}
|
||||
onInput={(e) => props.setSearch(e.currentTarget.value)}
|
||||
class="w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type filter */}
|
||||
<select
|
||||
value={props.typeFilter()}
|
||||
onChange={(e) => props.setTypeFilter(e.currentTarget.value as ResourceType | 'all')}
|
||||
class={selectClass}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<For each={Object.entries(RESOURCE_TYPE_LABELS)}>
|
||||
{([key, label]) => <option value={key}>{label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
|
||||
{/* Platform filter */}
|
||||
<select
|
||||
value={props.platformFilter()}
|
||||
onChange={(e) => props.setPlatformFilter(e.currentTarget.value as PlatformType | 'all')}
|
||||
class={selectClass}
|
||||
>
|
||||
<option value="all">All Platforms</option>
|
||||
<For each={Object.entries(PLATFORM_LABELS)}>
|
||||
{([key, label]) => <option value={key}>{label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
|
||||
{/* Status filter */}
|
||||
<select
|
||||
value={props.statusFilter()}
|
||||
onChange={(e) => props.setStatusFilter(e.currentTarget.value as ResourceStatus | 'all')}
|
||||
class={selectClass}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<For each={Object.entries(STATUS_LABELS)}>
|
||||
{([key, label]) => <option value={key}>{label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
|
||||
{/* Group by */}
|
||||
<select
|
||||
value={props.groupBy()}
|
||||
onChange={(e) => props.setGroupBy(e.currentTarget.value as 'none' | 'type' | 'platform' | 'parent')}
|
||||
class={selectClass}
|
||||
>
|
||||
<option value="none">No Grouping</option>
|
||||
<option value="type">Group by Type</option>
|
||||
<option value="platform">Group by Platform</option>
|
||||
<option value="parent">Group by Parent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<Show when={props.stats()}>
|
||||
<div class="flex flex-wrap gap-4 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">{props.stats()!.totalResources} resources</span>
|
||||
<Show when={props.stats()!.withAlerts > 0}>
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
{props.stats()!.withAlerts} with alerts
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-gray-400">•</span>
|
||||
<For each={Object.entries(props.stats()!.byStatus)}>
|
||||
{([status, count]) => (
|
||||
<span class="flex items-center gap-1">
|
||||
<StatusDot variant={getStatusVariant(status as ResourceStatus)} size="xs" />
|
||||
{count} {status}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Resource row component
|
||||
interface ResourceRowProps {
|
||||
resource: Resource;
|
||||
showParent?: boolean;
|
||||
onSelect?: (resource: Resource) => void;
|
||||
}
|
||||
|
||||
const ResourceRow: Component<ResourceRowProps> = (props) => {
|
||||
const { resource } = props;
|
||||
|
||||
const cpuPercent = resource.cpu?.current ?? 0;
|
||||
const memPercent = resource.memory?.current ?? 0;
|
||||
const diskPercent = resource.disk?.current ?? 0;
|
||||
|
||||
const getProgressColor = (value: number) => {
|
||||
if (value >= 90) return 'bg-red-500';
|
||||
if (value >= 75) return 'bg-yellow-500';
|
||||
return 'bg-blue-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors cursor-pointer"
|
||||
onClick={() => props.onSelect?.(resource)}
|
||||
>
|
||||
{/* Name & Status */}
|
||||
<td class="pl-4 pr-2 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusDot
|
||||
variant={getStatusVariant(resource.status)}
|
||||
size="xs"
|
||||
title={STATUS_LABELS[resource.status]}
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{resource.displayName || resource.name}
|
||||
</p>
|
||||
<Show when={resource.displayName && resource.displayName !== resource.name}>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400">{resource.name}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={resource.alerts && resource.alerts.length > 0}>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
|
||||
{resource.alerts.length} alert{resource.alerts.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Type */}
|
||||
<td class="px-2 py-2">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Platform */}
|
||||
<td class="px-2 py-2">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{PLATFORM_LABELS[resource.platform]}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Source */}
|
||||
<td class="px-2 py-2">
|
||||
<span class={`text-xs ${resource.sourceType === 'agent' ? 'text-green-600 dark:text-green-400' : 'text-gray-500'}`}>
|
||||
{resource.sourceType}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* CPU */}
|
||||
<td class="px-2 py-2 w-[100px]">
|
||||
<Show when={resource.cpu} fallback={<span class="text-gray-400">—</span>}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class={`h-full ${getProgressColor(cpuPercent)} rounded-full`}
|
||||
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 w-[36px] text-right">
|
||||
{cpuPercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
|
||||
{/* Memory */}
|
||||
<td class="px-2 py-2 w-[100px]">
|
||||
<Show when={resource.memory} fallback={<span class="text-gray-400">—</span>}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class={`h-full ${getProgressColor(memPercent)} rounded-full`}
|
||||
style={{ width: `${Math.min(memPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 w-[36px] text-right">
|
||||
{memPercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
|
||||
{/* Disk */}
|
||||
<td class="px-2 py-2 w-[100px]">
|
||||
<Show when={resource.disk} fallback={<span class="text-gray-400">—</span>}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class={`h-full ${getProgressColor(diskPercent)} rounded-full`}
|
||||
style={{ width: `${Math.min(diskPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 w-[36px] text-right">
|
||||
{diskPercent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
|
||||
{/* Uptime */}
|
||||
<td class="px-2 py-2">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{resource.uptime ? formatUptime(resource.uptime) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Last Seen */}
|
||||
<td class="px-2 py-2 pr-4">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||
{resource.lastSeen ? formatRelativeTime(resource.lastSeen) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Resources Overview component
|
||||
export const ResourcesOverview: Component = () => {
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [typeFilter, setTypeFilter] = createSignal<ResourceType | 'all'>('all');
|
||||
const [platformFilter, setPlatformFilter] = createSignal<PlatformType | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = createSignal<ResourceStatus | 'all'>('all');
|
||||
const [groupBy, setGroupBy] = createSignal<'none' | 'type' | 'platform' | 'parent'>('none');
|
||||
|
||||
// Fetch resources with auto-refresh
|
||||
const [data, { refetch }] = createResource(fetchResources);
|
||||
|
||||
// Auto-refresh every 10 seconds
|
||||
let refreshInterval: number;
|
||||
onMount(() => {
|
||||
refreshInterval = window.setInterval(() => refetch(), 10000);
|
||||
});
|
||||
onCleanup(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
});
|
||||
|
||||
// Filter resources
|
||||
const filteredResources = createMemo(() => {
|
||||
if (!data()) return [];
|
||||
let resources = data()!.resources;
|
||||
|
||||
// Apply search
|
||||
if (search()) {
|
||||
const term = search().toLowerCase();
|
||||
resources = resources.filter(r =>
|
||||
r.name.toLowerCase().includes(term) ||
|
||||
r.displayName.toLowerCase().includes(term) ||
|
||||
r.identity?.hostname?.toLowerCase().includes(term) ||
|
||||
r.identity?.primaryIp?.includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
if (typeFilter() !== 'all') {
|
||||
resources = resources.filter(r => r.type === typeFilter());
|
||||
}
|
||||
|
||||
// Apply platform filter
|
||||
if (platformFilter() !== 'all') {
|
||||
resources = resources.filter(r => r.platform === platformFilter());
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (statusFilter() !== 'all') {
|
||||
resources = resources.filter(r => r.status === statusFilter());
|
||||
}
|
||||
|
||||
return resources;
|
||||
});
|
||||
|
||||
// Group resources
|
||||
const groupedResources = createMemo(() => {
|
||||
const resources = filteredResources();
|
||||
const mode = groupBy();
|
||||
|
||||
if (mode === 'none') {
|
||||
return [{ key: 'all', label: 'All Resources', resources }];
|
||||
}
|
||||
|
||||
const groups: Record<string, Resource[]> = {};
|
||||
|
||||
for (const r of resources) {
|
||||
let key: string;
|
||||
switch (mode) {
|
||||
case 'type':
|
||||
key = r.type;
|
||||
break;
|
||||
case 'platform':
|
||||
key = r.platform;
|
||||
break;
|
||||
case 'parent':
|
||||
key = r.parentId || 'no-parent';
|
||||
break;
|
||||
default:
|
||||
key = 'all';
|
||||
}
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(r);
|
||||
}
|
||||
|
||||
return Object.entries(groups).map(([key, resources]) => ({
|
||||
key,
|
||||
label: mode === 'type' ? RESOURCE_TYPE_LABELS[key as ResourceType] || key :
|
||||
mode === 'platform' ? PLATFORM_LABELS[key as PlatformType] || key :
|
||||
key === 'no-parent' ? 'Standalone Resources' : key,
|
||||
resources
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
});
|
||||
|
||||
const thClass = "px-2 py-2 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">All Resources</h1>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ResourceFilter
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
typeFilter={typeFilter}
|
||||
setTypeFilter={setTypeFilter}
|
||||
platformFilter={platformFilter}
|
||||
setPlatformFilter={setPlatformFilter}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
groupBy={groupBy}
|
||||
setGroupBy={setGroupBy}
|
||||
stats={() => data()?.stats}
|
||||
/>
|
||||
|
||||
<Show when={data.loading && !data()}>
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
}
|
||||
title="Loading resources..."
|
||||
description="Fetching unified resource data."
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={data() && filteredResources().length === 0}>
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
title="No resources found"
|
||||
description={search() ? "No resources match your search." : "No resources are being monitored."}
|
||||
/>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={data() && filteredResources().length > 0}>
|
||||
<For each={groupedResources()}>
|
||||
{(group) => (
|
||||
<Card padding="none" tone="glass" class="overflow-hidden mb-4">
|
||||
<Show when={groupBy() !== 'none'}>
|
||||
<div class="px-4 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-medium text-sm text-gray-700 dark:text-gray-300">
|
||||
{group.label}
|
||||
<span class="ml-2 text-xs text-gray-500">({group.resources.length})</span>
|
||||
</h3>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<th class={`${thClass} pl-4`}>Name</th>
|
||||
<th class={thClass}>Type</th>
|
||||
<th class={thClass}>Platform</th>
|
||||
<th class={thClass}>Source</th>
|
||||
<th class={thClass}>CPU</th>
|
||||
<th class={thClass}>Memory</th>
|
||||
<th class={thClass}>Disk</th>
|
||||
<th class={thClass}>Uptime</th>
|
||||
<th class={`${thClass} pr-4`}>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={group.resources}>
|
||||
{(resource) => <ResourceRow resource={resource} />}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourcesOverview;
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
// Unified Resource type definitions - matches backend internal/resources/resource.go
|
||||
|
||||
import type { StatusIndicatorVariant } from '@/utils/status';
|
||||
|
||||
export type ResourceType =
|
||||
| 'node'
|
||||
| 'vm'
|
||||
| 'container'
|
||||
| 'host'
|
||||
| 'docker_host'
|
||||
| 'docker_container'
|
||||
| 'pbs_instance'
|
||||
| 'storage'
|
||||
| 'datastore';
|
||||
|
||||
export type PlatformType =
|
||||
| 'proxmox_pve'
|
||||
| 'proxmox_pbs'
|
||||
| 'proxmox_pmg'
|
||||
| 'docker'
|
||||
| 'host_agent'
|
||||
| 'multi'
|
||||
| 'unknown';
|
||||
|
||||
export type ResourceStatus =
|
||||
| 'online'
|
||||
| 'offline'
|
||||
| 'running'
|
||||
| 'stopped'
|
||||
| 'paused'
|
||||
| 'degraded'
|
||||
| 'unknown';
|
||||
|
||||
export type SourceType = 'api' | 'agent' | 'hybrid';
|
||||
|
||||
export interface MetricValue {
|
||||
current: number; // Current value as percentage
|
||||
total?: number; // Total capacity (bytes for memory/disk)
|
||||
used?: number; // Used amount (bytes for memory/disk)
|
||||
free?: number; // Free amount (bytes for memory/disk)
|
||||
}
|
||||
|
||||
export interface NetworkMetric {
|
||||
inBytes: number;
|
||||
outBytes: number;
|
||||
inRate: number;
|
||||
outRate: number;
|
||||
}
|
||||
|
||||
export interface ResourceAlert {
|
||||
id: string;
|
||||
severity: 'warning' | 'critical';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ResourceIdentity {
|
||||
hostname?: string;
|
||||
machineId?: string;
|
||||
primaryIp?: string;
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type: ResourceType;
|
||||
platform: PlatformType;
|
||||
sourceType: SourceType;
|
||||
sourceId: string;
|
||||
parentId?: string;
|
||||
status: ResourceStatus;
|
||||
cpu?: MetricValue;
|
||||
memory?: MetricValue;
|
||||
disk?: MetricValue;
|
||||
network?: NetworkMetric;
|
||||
uptime?: number;
|
||||
temperature?: number;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, string>;
|
||||
alerts?: ResourceAlert[];
|
||||
identity?: ResourceIdentity;
|
||||
lastSeen?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
// Platform-specific data
|
||||
platformData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StoreStats {
|
||||
totalResources: number;
|
||||
byType: Record<string, number>;
|
||||
byPlatform: Record<string, number>;
|
||||
byStatus: Record<string, number>;
|
||||
withAlerts: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface ResourcesResponse {
|
||||
resources: Resource[];
|
||||
count: number;
|
||||
stats: StoreStats;
|
||||
}
|
||||
|
||||
// Type guards
|
||||
export function isInfrastructureType(type: ResourceType): boolean {
|
||||
return ['node', 'host', 'docker_host', 'pbs_instance'].includes(type);
|
||||
}
|
||||
|
||||
export function isWorkloadType(type: ResourceType): boolean {
|
||||
return ['vm', 'container', 'docker_container'].includes(type);
|
||||
}
|
||||
|
||||
// Display helpers
|
||||
export const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||
node: 'Proxmox Node',
|
||||
vm: 'Virtual Machine',
|
||||
container: 'LXC Container',
|
||||
host: 'Host Machine',
|
||||
docker_host: 'Docker Host',
|
||||
docker_container: 'Docker Container',
|
||||
pbs_instance: 'PBS Instance',
|
||||
storage: 'Storage',
|
||||
datastore: 'Datastore',
|
||||
};
|
||||
|
||||
export const PLATFORM_LABELS: Record<PlatformType, string> = {
|
||||
proxmox_pve: 'Proxmox VE',
|
||||
proxmox_pbs: 'Proxmox PBS',
|
||||
proxmox_pmg: 'Proxmox PMG',
|
||||
docker: 'Docker',
|
||||
host_agent: 'Host Agent',
|
||||
multi: 'Multiple',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<ResourceStatus, string> = {
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
running: 'Running',
|
||||
stopped: 'Stopped',
|
||||
paused: 'Paused',
|
||||
degraded: 'Degraded',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
export function getStatusVariant(status: ResourceStatus): StatusIndicatorVariant {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
case 'running':
|
||||
return 'success';
|
||||
case 'paused':
|
||||
case 'degraded':
|
||||
return 'warning';
|
||||
case 'offline':
|
||||
case 'stopped':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
|
@ -249,8 +249,10 @@ class ApiClient {
|
|||
const response = await fetch(url, finalOptions);
|
||||
|
||||
// If we get a 401 on an API call (not during initial auth check), redirect to login
|
||||
// Skip redirect for specific auth-check endpoints to avoid loops
|
||||
if (response.status === 401 && !url.includes('/api/security/status') && !url.includes('/api/state') && !url.includes('/api/settings/ai')) {
|
||||
// Skip redirect for specific auth-check endpoints and background data fetching to avoid loops
|
||||
const skipRedirectUrls = ['/api/security/status', '/api/state', '/api/settings/ai', '/api/charts', '/api/resources'];
|
||||
const shouldSkipRedirect = skipRedirectUrls.some(path => url.includes(path));
|
||||
if (response.status === 401 && !shouldSkipRedirect) {
|
||||
logger.warn('Authentication expired - redirecting to login');
|
||||
// Clear auth and redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
|
|
|
|||
|
|
@ -114,4 +114,7 @@ export const STORAGE_KEYS = {
|
|||
DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns',
|
||||
HOSTS_HIDDEN_COLUMNS: 'hostsHiddenColumns',
|
||||
DOCKER_HIDDEN_COLUMNS: 'dockerHiddenColumns',
|
||||
|
||||
// Resources search
|
||||
RESOURCES_SEARCH_HISTORY: 'resourcesSearchHistory',
|
||||
} as const;
|
||||
|
|
|
|||
BIN
internal/monitoring/metrics.db
Normal file
BIN
internal/monitoring/metrics.db
Normal file
Binary file not shown.
|
|
@ -210,11 +210,18 @@ func (s *Store) GetStats() StoreStats {
|
|||
SuppressedResources: len(s.mergedFrom),
|
||||
ByType: make(map[ResourceType]int),
|
||||
ByPlatform: make(map[PlatformType]int),
|
||||
ByStatus: make(map[ResourceStatus]int),
|
||||
WithAlerts: 0,
|
||||
LastUpdated: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
for _, r := range s.resources {
|
||||
stats.ByType[r.Type]++
|
||||
stats.ByPlatform[r.PlatformType]++
|
||||
stats.ByStatus[r.Status]++
|
||||
if len(r.Alerts) > 0 {
|
||||
stats.WithAlerts++
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
|
|
@ -222,10 +229,13 @@ func (s *Store) GetStats() StoreStats {
|
|||
|
||||
// StoreStats contains statistics about the resource store.
|
||||
type StoreStats struct {
|
||||
TotalResources int
|
||||
SuppressedResources int
|
||||
ByType map[ResourceType]int
|
||||
ByPlatform map[PlatformType]int
|
||||
TotalResources int `json:"totalResources"`
|
||||
SuppressedResources int `json:"suppressedResources"`
|
||||
ByType map[ResourceType]int `json:"byType"`
|
||||
ByPlatform map[PlatformType]int `json:"byPlatform"`
|
||||
ByStatus map[ResourceStatus]int `json:"byStatus"`
|
||||
WithAlerts int `json:"withAlerts"`
|
||||
LastUpdated string `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// GetPreferredResourceFor returns the preferred resource for a given ID.
|
||||
|
|
|
|||
Loading…
Reference in a new issue