feat: implement v1.2.1 fixes #95

Merged
sarman merged 11 commits from fix/proxmox-v1.2.1 into master 2026-06-13 03:50:35 +00:00
7 changed files with 594 additions and 399 deletions
Showing only changes of commit 88bd5a8c95 - Show all commits

View File

@ -2,24 +2,16 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { MoreHorizontal } from 'lucide-react';
interface AclInfo {
id: string;
path: string;
type: 'user' | 'group' | 'role';
principal: string;
roles: string[];
propagate: boolean;
}
import { Pencil, Trash2, PlusCircle, RefreshCw } from 'lucide-react';
import { AclEntry } from '@/lib/proxmoxClient';
interface AclListProps {
acls: AclInfo[];
acls: AclEntry[];
onRefresh?: () => void;
isLoading?: boolean;
onAdd?: () => void;
onEdit?: (acl: AclInfo) => void;
onDelete?: (acl: AclInfo) => void;
onEdit?: (acl: AclEntry) => void;
onDelete?: (acl: AclEntry) => void;
}
export function AclList({
@ -36,11 +28,12 @@ export function AclList({
<CardTitle>Access Control Lists (ACL)</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
{onAdd && (
<Button size="sm" onClick={onAdd}>
<span className="mr-2 h-4 w-4">+</span>
<PlusCircle className="mr-2 h-4 w-4" />
New ACL
</Button>
)}
@ -54,14 +47,21 @@ export function AclList({
<TableHead>Path</TableHead>
<TableHead>Type</TableHead>
<TableHead>Principal</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Role</TableHead>
<TableHead>Propagate</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{acls.map((acl) => (
<TableRow key={acl.id}>
{acls.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No ACL entries configured
</TableCell>
</TableRow>
) : (
acls.map((acl, index) => (
<TableRow key={`${acl.path}-${acl.ugid}-${acl.roleid}-${index}`}>
<TableCell className="font-mono text-xs">{acl.path}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
@ -72,15 +72,11 @@ export function AclList({
{acl.type}
</span>
</TableCell>
<TableCell>{acl.principal}</TableCell>
<TableCell>{acl.ugid}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{acl.roles.map((role) => (
<span key={role} className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-800">
{role}
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-800">
{acl.roleid}
</span>
))}
</div>
</TableCell>
<TableCell>{acl.propagate ? 'Yes' : 'No'}</TableCell>
<TableCell className="text-right">
@ -90,25 +86,20 @@ export function AclList({
onClick={() => onEdit?.(acl)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
<Pencil className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(acl)}
title="Delete"
>
<span className="h-4 w-4 text-xs">🗑</span>
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
))
)}
</TableBody>
</Table>
</div>

View File

@ -2,35 +2,25 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { MoreHorizontal, Trash2 } from 'lucide-react';
interface HAGroupInfo {
id: string;
name: string;
resources: number;
managed: number;
failed: number;
status: string;
}
import { Trash2, Pencil, PlusCircle, RefreshCw } from 'lucide-react';
import { HaGroup } from '@/lib/proxmoxClient';
interface HAGroupsListProps {
groups: HAGroupInfo[];
groups: HaGroup[];
onRefresh?: () => void;
isLoading?: boolean;
onEdit?: (group: HAGroupInfo) => void;
onDelete?: (group: HAGroupInfo) => void;
onEnable?: (group: HAGroupInfo) => void;
onDisable?: (group: HAGroupInfo) => void;
onCreate?: () => void;
onEdit?: (group: HaGroup) => void;
onDelete?: (id: string) => void;
}
export function HAGroupsList({
groups,
onRefresh,
isLoading,
onCreate,
onEdit,
onDelete,
onEnable,
onDisable,
}: HAGroupsListProps) {
return (
<Card>
@ -38,11 +28,12 @@ export function HAGroupsList({
<CardTitle>HA Groups</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button size="sm">
<span className="mr-2 h-4 w-4">+</span>
New Group
<Button size="sm" onClick={onCreate}>
<PlusCircle className="mr-2 h-4 w-4" />
Add Group
</Button>
</div>
</CardHeader>
@ -52,29 +43,38 @@ export function HAGroupsList({
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Resources</TableHead>
<TableHead>Managed</TableHead>
<TableHead>Failed</TableHead>
<TableHead>Status</TableHead>
<TableHead>Nodes</TableHead>
<TableHead>Restricted</TableHead>
<TableHead>No-Quorum Policy</TableHead>
<TableHead>Comment</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell>{group.resources}</TableCell>
<TableCell>{group.managed}</TableCell>
<TableCell>{group.failed}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
group.status === 'healthy' ? 'bg-green-100 text-green-800' :
group.status === 'error' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{group.status}
</span>
{groups.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No HA groups configured
</TableCell>
</TableRow>
) : (
groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.id}</TableCell>
<TableCell className="font-mono text-xs">{group.nodes}</TableCell>
<TableCell>
{group.restricted ? (
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800">
Yes
</span>
) : (
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600">
No
</span>
)}
</TableCell>
<TableCell>{group.noQuorumPolicy ?? '-'}</TableCell>
<TableCell className="text-muted-foreground text-sm">{group.comment ?? '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
<button
@ -82,36 +82,20 @@ export function HAGroupsList({
onClick={() => onEdit?.(group)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
</button>
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => group.managed > 0 ? onDisable?.(group) : onEnable?.(group)}
title={group.managed > 0 ? 'Disable' : 'Enable'}
>
{group.managed > 0 ? (
<span className="h-4 w-4 text-xs"></span>
) : (
<span className="h-4 w-4 text-xs"></span>
)}
<Pencil className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(group)}
onClick={() => onDelete?.(group.id)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
))
)}
</TableBody>
</Table>
</div>

View File

@ -2,53 +2,31 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { MoreHorizontal } from 'lucide-react';
interface HAResourceInfo {
id: string;
name: string;
type: string;
group: string;
node: string;
managed: boolean;
failed: boolean;
status: string;
}
import { Play, Trash2, RefreshCw } from 'lucide-react';
import { HaResource } from '@/lib/proxmoxClient';
interface HAResourcesListProps {
resources: HAResourceInfo[];
resources: HaResource[];
onRefresh?: () => void;
isLoading?: boolean;
onManage?: (resource: HAResourceInfo) => void;
onUnmanage?: (resource: HAResourceInfo) => void;
onFailover?: (resource: HAResourceInfo) => void;
onEnable?: (resource: HaResource) => void;
onRemove?: (resource: HaResource) => void;
}
export function HAResourcesList({
resources,
onRefresh,
isLoading,
onManage,
onUnmanage,
onFailover,
onEnable,
onRemove,
}: HAResourcesListProps) {
const managedCount = resources.filter((r) => r.managed).length;
const failedCount = resources.filter((r) => r.failed).length;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>HA Resources</CardTitle>
<div className="flex space-x-2">
<div className="flex items-center space-x-2 text-sm">
<span className="text-green-500"></span>
<span>{managedCount} Managed</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<span className="text-red-500"></span>
<span>{failedCount} Failed</span>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
@ -58,66 +36,59 @@ export function HAResourcesList({
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Resource ID</TableHead>
<TableHead>Group</TableHead>
<TableHead>Node</TableHead>
<TableHead>Status</TableHead>
<TableHead>State</TableHead>
<TableHead>Max Restart</TableHead>
<TableHead>Max Relocate</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.map((resource) => (
<TableRow key={resource.id}>
<TableCell className="font-medium">{resource.name}</TableCell>
<TableCell>{resource.type}</TableCell>
<TableCell>{resource.group}</TableCell>
<TableCell>{resource.node}</TableCell>
{resources.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No HA resources configured
</TableCell>
</TableRow>
) : (
resources.map((resource) => (
<TableRow key={resource.sid}>
<TableCell className="font-medium font-mono text-xs">{resource.sid}</TableCell>
<TableCell>{resource.group ?? '-'}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
resource.failed ? 'bg-red-100 text-red-800' :
resource.managed ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
resource.state === 'started' ? 'bg-green-100 text-green-800' :
resource.state === 'stopped' ? 'bg-gray-100 text-gray-600' :
resource.state === 'error' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{resource.failed ? 'Failed' : resource.managed ? 'Managed' : 'Unmanaged'}
{resource.state}
</span>
</TableCell>
<TableCell>{resource.maxRestart ?? '-'}</TableCell>
<TableCell>{resource.maxRelocate ?? '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
{resource.managed ? (
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onUnmanage?.(resource)}
title="Unmanage"
>
<span className="h-4 w-4 text-xs"></span>
</button>
) : (
<button
className="rounded-md p-1 hover:bg-green-100 hover:text-green-600"
onClick={() => onManage?.(resource)}
title="Manage"
onClick={() => onEnable?.(resource)}
title="Enable"
>
<span className="h-4 w-4 text-xs"></span>
</button>
)}
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onFailover?.(resource)}
title="Failover"
>
<span className="h-4 w-4 text-xs">🔄</span>
<Play className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onRemove?.(resource)}
title="Remove"
>
<MoreHorizontal className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
))
)}
</TableBody>
</Table>
</div>

View File

@ -2,32 +2,25 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { MoreHorizontal, Trash2 } from 'lucide-react';
interface RealmInfo {
id: string;
type: 'pam' | 'ldap' | 'ad' | 'openid';
server?: string;
baseDn?: string;
status: string;
}
import { Pencil, Trash2, PlusCircle, RefreshCw } from 'lucide-react';
import { AuthRealm } from '@/lib/proxmoxClient';
interface RealmListProps {
realms: RealmInfo[];
realms: AuthRealm[];
onRefresh?: () => void;
isLoading?: boolean;
onEdit?: (realm: RealmInfo) => void;
onDelete?: (realm: RealmInfo) => void;
onSync?: (realm: RealmInfo) => void;
onCreate?: () => void;
onEdit?: (realm: AuthRealm) => void;
onDelete?: (realm: AuthRealm) => void;
}
export function RealmList({
realms,
onRefresh,
isLoading,
onCreate,
onEdit,
onDelete,
onSync,
}: RealmListProps) {
return (
<Card>
@ -35,10 +28,11 @@ export function RealmList({
<CardTitle>Authentication Realms</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button size="sm">
<span className="mr-2 h-4 w-4">+</span>
<Button size="sm" onClick={onCreate}>
<PlusCircle className="mr-2 h-4 w-4" />
New Realm
</Button>
</div>
@ -48,30 +42,29 @@ export function RealmList({
<Table>
<TableHeader>
<TableRow>
<TableHead>Realm ID</TableHead>
<TableHead>Realm Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Server</TableHead>
<TableHead>Base DN</TableHead>
<TableHead>Status</TableHead>
<TableHead>Comment</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{realms.map((realm) => (
<TableRow key={realm.id}>
<TableCell className="font-medium">{realm.id}</TableCell>
{realms.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
No auth realms configured
</TableCell>
</TableRow>
) : (
realms.map((realm) => (
<TableRow key={realm.realm}>
<TableCell className="font-medium">{realm.realm}</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800">
{realm.type.toUpperCase()}
</span>
</TableCell>
<TableCell>{realm.server || '-'}</TableCell>
<TableCell>{realm.baseDn || '-'}</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-green-100 text-green-800">
Active
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm">{realm.comment ?? '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
<button
@ -79,14 +72,7 @@ export function RealmList({
onClick={() => onEdit?.(realm)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
</button>
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onSync?.(realm)}
title="Sync Users"
>
<span className="h-4 w-4 text-xs">🔄</span>
<Pencil className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
@ -95,16 +81,11 @@ export function RealmList({
>
<Trash2 className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
))
)}
</TableBody>
</Table>
</div>

View File

@ -2,29 +2,35 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { MoreHorizontal, Trash2 } from 'lucide-react';
interface UserInfo {
id: string;
email?: string;
enabled: boolean;
lastLogin?: string;
}
import { Pencil, Trash2, PlusCircle, RefreshCw, Play, Pause } from 'lucide-react';
import { ProxmoxUser } from '@/lib/proxmoxClient';
interface UserListProps {
users: UserInfo[];
users: ProxmoxUser[];
onRefresh?: () => void;
isLoading?: boolean;
onEdit?: (user: UserInfo) => void;
onDelete?: (user: UserInfo) => void;
onEnable?: (user: UserInfo) => void;
onDisable?: (user: UserInfo) => void;
onCreate?: () => void;
onEdit?: (user: ProxmoxUser) => void;
onDelete?: (user: ProxmoxUser) => void;
onEnable?: (user: ProxmoxUser) => void;
onDisable?: (user: ProxmoxUser) => void;
}
function formatExpiry(expire?: number): string {
if (!expire || expire === 0) return 'Never';
return new Date(expire * 1000).toLocaleDateString();
}
function deriveRealm(userid: string): string {
const parts = userid.split('@');
return parts.length > 1 ? parts[1] : '-';
}
export function UserList({
users,
onRefresh,
isLoading,
onCreate,
onEdit,
onDelete,
onEnable,
@ -37,20 +43,21 @@ export function UserList({
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Users</CardTitle>
<div className="flex space-x-2">
<div className="flex items-center space-x-2 text-sm">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<span className="text-green-500"></span>
<span>{enabledCount} Enabled</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<span className="text-gray-500"></span>
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
<span className="text-gray-400"></span>
<span>{disabledCount} Disabled</span>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button size="sm">
<span className="mr-2 h-4 w-4">+</span>
<Button size="sm" onClick={onCreate}>
<PlusCircle className="mr-2 h-4 w-4" />
New User
</Button>
</div>
@ -61,25 +68,38 @@ export function UserList({
<TableHeader>
<TableRow>
<TableHead>User ID</TableHead>
<TableHead>Realm</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<TableHead>Enabled</TableHead>
<TableHead>Expire</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.id}</TableCell>
<TableCell>{user.email || '-'}</TableCell>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No users found
</TableCell>
</TableRow>
) : (
users.map((user) => {
const fullName = [user.firstname, user.lastname].filter(Boolean).join(' ') || '-';
return (
<TableRow key={user.userid}>
<TableCell className="font-medium font-mono text-xs">{user.userid}</TableCell>
<TableCell>{deriveRealm(user.userid)}</TableCell>
<TableCell>{fullName}</TableCell>
<TableCell>{user.email ?? '-'}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
user.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
user.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}>
{user.enabled ? 'Enabled' : 'Disabled'}
</span>
</TableCell>
<TableCell>{user.lastLogin || '-'}</TableCell>
<TableCell>{formatExpiry(user.expire)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
<button
@ -87,19 +107,17 @@ export function UserList({
onClick={() => onEdit?.(user)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
<Pencil className="h-4 w-4" />
</button>
<button
className={`rounded-md p-1 hover:bg-accent ${
user.enabled ? 'text-green-600' : 'text-gray-600'
}`}
className="rounded-md p-1 hover:bg-accent"
onClick={() => user.enabled ? onDisable?.(user) : onEnable?.(user)}
title={user.enabled ? 'Disable' : 'Enable'}
>
{user.enabled ? (
<span className="h-4 w-4 text-xs"></span>
<Pause className="h-4 w-4 text-yellow-600" />
) : (
<span className="h-4 w-4 text-xs"></span>
<Play className="h-4 w-4 text-green-600" />
)}
</button>
<button
@ -109,16 +127,12 @@ export function UserList({
>
<Trash2 className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
);
})
)}
</TableBody>
</Table>
</div>

View File

@ -1,34 +1,173 @@
import React from 'react';
import { Button } from '@/components/ui/index';
import React, { useState, useEffect, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import { AclList } from '@/components/Proxmox';
import { Button, Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/index';
import { AclList, UserList, RealmList } from '@/components/Proxmox';
import {
listProxmoxClusters,
listAcls,
listUsers,
listRealms,
AclEntry,
ProxmoxUser,
AuthRealm,
} from '@/lib/proxmoxClient';
import { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner';
export function ProxmoxACLPage() {
const acls = [
{ id: '1', path: '/nodes/pve1', type: 'user' as const, principal: 'admin@pam', roles: ['PVEAdmin'], propagate: true },
{ id: '2', path: '/storage/local', type: 'group' as const, principal: 'admins', roles: ['PVEDataStoreAdmin'], propagate: false },
{ id: '3', path: '/vms/100', type: 'user' as const, principal: 'developer@pam', roles: ['PVEVMUser'], propagate: false },
];
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [activeTab, setActiveTab] = useState<string>('acl');
const [acls, setAcls] = useState<AclEntry[]>([]);
const [users, setUsers] = useState<ProxmoxUser[]>([]);
const [realms, setRealms] = useState<AuthRealm[]>([]);
const [isLoadingAcls, setIsLoadingAcls] = useState(false);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingRealms, setIsLoadingRealms] = useState(false);
// Load clusters on mount, auto-select the first
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
setClusters(cls);
if (cls.length > 0) {
setSelectedClusterId(cls[0].id);
}
})
.catch((err) => {
console.error('Failed to load clusters:', err);
toast.error('Failed to load clusters');
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadAcls = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingAcls(true);
try {
const data = await listAcls(clusterId);
setAcls(data);
} catch (err) {
console.error('Failed to load ACLs:', err);
toast.error('Failed to load ACLs');
} finally {
setIsLoadingAcls(false);
}
}, []);
const loadUsers = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingUsers(true);
try {
const data = await listUsers(clusterId);
setUsers(data);
} catch (err) {
console.error('Failed to load users:', err);
toast.error('Failed to load users');
} finally {
setIsLoadingUsers(false);
}
}, []);
const loadRealms = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingRealms(true);
try {
const data = await listRealms(clusterId);
setRealms(data);
} catch (err) {
console.error('Failed to load realms:', err);
toast.error('Failed to load auth realms');
} finally {
setIsLoadingRealms(false);
}
}, []);
useEffect(() => {
if (selectedClusterId) {
loadAcls(selectedClusterId);
loadUsers(selectedClusterId);
loadRealms(selectedClusterId);
}
}, [selectedClusterId, loadAcls, loadUsers, loadRealms]);
const handleRefreshAll = () => {
loadAcls(selectedClusterId);
loadUsers(selectedClusterId);
loadRealms(selectedClusterId);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Access Control Lists</h1>
<p className="text-muted-foreground">Manage permissions and access control</p>
<h1 className="text-2xl font-bold">Access Control &amp; Users</h1>
<p className="text-muted-foreground">Manage permissions, users, and authentication realms</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<div className="flex items-center space-x-2">
{clusters.length > 1 && (
<select
className="rounded-md border px-3 py-1.5 text-sm bg-background"
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
<Button variant="outline" size="sm" onClick={handleRefreshAll}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="acl">ACLs</TabsTrigger>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="realms">Auth Realms</TabsTrigger>
</TabsList>
<TabsContent value="acl">
<AclList
acls={acls}
onRefresh={() => {}}
isLoading={isLoadingAcls}
onRefresh={() => loadAcls(selectedClusterId)}
onAdd={() => toast.info('Add ACL — not yet implemented')}
onEdit={() => toast.info('Edit ACL — not yet implemented')}
onDelete={() => toast.info('Delete ACL — not yet implemented')}
/>
</TabsContent>
<TabsContent value="users">
<UserList
users={users}
isLoading={isLoadingUsers}
onRefresh={() => loadUsers(selectedClusterId)}
onCreate={() => toast.info('Create user — not yet implemented')}
onEdit={() => toast.info('Edit user — not yet implemented')}
onDelete={() => toast.info('Delete user — not yet implemented')}
onEnable={() => toast.info('Enable user — not yet implemented')}
onDisable={() => toast.info('Disable user — not yet implemented')}
/>
</TabsContent>
<TabsContent value="realms">
<RealmList
realms={realms}
isLoading={isLoadingRealms}
onRefresh={() => loadRealms(selectedClusterId)}
onCreate={() => toast.info('Create realm — not yet implemented')}
onEdit={() => toast.info('Edit realm — not yet implemented')}
onDelete={() => toast.info('Delete realm — not yet implemented')}
/>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1,10 +1,119 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import React, { useState, useEffect, useCallback } from 'react';
import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/index';
import { HAGroupsList, HAResourcesList } from '@/components/Proxmox';
import {
listProxmoxClusters,
listHaGroups,
listHaResources,
deleteHaGroup,
enableHaResource,
HaGroup,
HaResource,
} from '@/lib/proxmoxClient';
import { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner';
export function ProxmoxHAPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [groups, setGroups] = useState<HaGroup[]>([]);
const [resources, setResources] = useState<HaResource[]>([]);
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
const [isLoadingResources, setIsLoadingResources] = useState(false);
// Load clusters on mount and auto-select the first one
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
setClusters(cls);
if (cls.length > 0 && !selectedClusterId) {
setSelectedClusterId(cls[0].id);
}
})
.catch((err) => {
console.error('Failed to load clusters:', err);
toast.error('Failed to load clusters');
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadGroups = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingGroups(true);
try {
const data = await listHaGroups(clusterId);
setGroups(data);
} catch (err) {
console.error('Failed to load HA groups:', err);
toast.error('Failed to load HA groups');
} finally {
setIsLoadingGroups(false);
}
}, []);
const loadResources = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingResources(true);
try {
const data = await listHaResources(clusterId);
setResources(data);
} catch (err) {
console.error('Failed to load HA resources:', err);
toast.error('Failed to load HA resources');
} finally {
setIsLoadingResources(false);
}
}, []);
useEffect(() => {
if (selectedClusterId) {
loadGroups(selectedClusterId);
loadResources(selectedClusterId);
}
}, [selectedClusterId, loadGroups, loadResources]);
const handleRefreshAll = () => {
loadGroups(selectedClusterId);
loadResources(selectedClusterId);
};
const handleDeleteGroup = async (id: string) => {
try {
await deleteHaGroup(selectedClusterId, id);
toast.success(`HA group "${id}" deleted`);
await loadGroups(selectedClusterId);
} catch (err) {
console.error('Failed to delete HA group:', err);
toast.error('Failed to delete HA group');
}
};
const handleEditGroup = (group: HaGroup) => {
// Placeholder: edit dialog integration to be wired when dialog component is available
toast.info(`Edit group: ${group.id}`);
};
const handleCreateGroup = () => {
// Placeholder: create dialog integration to be wired when dialog component is available
toast.info('Create HA group — not yet implemented');
};
const handleEnableResource = async (resource: HaResource) => {
try {
await enableHaResource(selectedClusterId, resource.sid);
toast.success(`HA resource "${resource.sid}" enabled`);
await loadResources(selectedClusterId);
} catch (err) {
console.error('Failed to enable HA resource:', err);
toast.error('Failed to enable HA resource');
}
};
const handleRemoveResource = async (resource: HaResource) => {
// Placeholder: removal command to be wired when backend command is available
toast.info(`Remove resource: ${resource.sid}`);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@ -12,38 +121,44 @@ export function ProxmoxHAPage() {
<h1 className="text-2xl font-bold">High Availability</h1>
<p className="text-muted-foreground">Manage HA groups and resources</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm">
<div className="flex items-center space-x-2">
{clusters.length > 1 && (
<select
className="rounded-md border px-3 py-1.5 text-sm bg-background"
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
<Button variant="outline" size="sm" onClick={handleRefreshAll}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>HA Groups</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4">
<HAGroupsList
groups={[]}
onRefresh={() => {}}
groups={groups}
isLoading={isLoadingGroups}
onRefresh={() => loadGroups(selectedClusterId)}
onCreate={handleCreateGroup}
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>HA Resources</CardTitle>
</CardHeader>
<CardContent>
<HAResourcesList
resources={[]}
onRefresh={() => {}}
resources={resources}
isLoading={isLoadingResources}
onRefresh={() => loadResources(selectedClusterId)}
onEnable={handleEnableResource}
onRemove={handleRemoveResource}
/>
</CardContent>
</Card>
</div>
</div>
);