feat: implement v1.2.1 fixes #95
@ -2,24 +2,16 @@ import React from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
import { Pencil, Trash2, PlusCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { AclEntry } from '@/lib/proxmoxClient';
|
||||||
interface AclInfo {
|
|
||||||
id: string;
|
|
||||||
path: string;
|
|
||||||
type: 'user' | 'group' | 'role';
|
|
||||||
principal: string;
|
|
||||||
roles: string[];
|
|
||||||
propagate: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AclListProps {
|
interface AclListProps {
|
||||||
acls: AclInfo[];
|
acls: AclEntry[];
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
onEdit?: (acl: AclInfo) => void;
|
onEdit?: (acl: AclEntry) => void;
|
||||||
onDelete?: (acl: AclInfo) => void;
|
onDelete?: (acl: AclEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AclList({
|
export function AclList({
|
||||||
@ -36,11 +28,12 @@ export function AclList({
|
|||||||
<CardTitle>Access Control Lists (ACL)</CardTitle>
|
<CardTitle>Access Control Lists (ACL)</CardTitle>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<Button size="sm" onClick={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
|
New ACL
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@ -54,61 +47,59 @@ export function AclList({
|
|||||||
<TableHead>Path</TableHead>
|
<TableHead>Path</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>Principal</TableHead>
|
<TableHead>Principal</TableHead>
|
||||||
<TableHead>Roles</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
<TableHead>Propagate</TableHead>
|
<TableHead>Propagate</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{acls.map((acl) => (
|
{acls.length === 0 ? (
|
||||||
<TableRow key={acl.id}>
|
<TableRow>
|
||||||
<TableCell className="font-mono text-xs">{acl.path}</TableCell>
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
<TableCell>
|
No ACL entries configured
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
acl.type === 'user' ? 'bg-blue-100 text-blue-800' :
|
|
||||||
acl.type === 'group' ? 'bg-purple-100 text-purple-800' :
|
|
||||||
'bg-orange-100 text-orange-800'
|
|
||||||
}`}>
|
|
||||||
{acl.type}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{acl.principal}</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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{acl.propagate ? 'Yes' : 'No'}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end space-x-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-accent"
|
|
||||||
onClick={() => onEdit?.(acl)}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<span className="h-4 w-4 text-xs">✏️</span>
|
|
||||||
</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" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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 ${
|
||||||
|
acl.type === 'user' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
acl.type === 'group' ? 'bg-purple-100 text-purple-800' :
|
||||||
|
'bg-orange-100 text-orange-800'
|
||||||
|
}`}>
|
||||||
|
{acl.type}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{acl.ugid}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-800">
|
||||||
|
{acl.roleid}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{acl.propagate ? 'Yes' : 'No'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-accent"
|
||||||
|
onClick={() => onEdit?.(acl)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,35 +2,25 @@ import React from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
import { MoreHorizontal, Trash2 } from 'lucide-react';
|
import { Trash2, Pencil, PlusCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { HaGroup } from '@/lib/proxmoxClient';
|
||||||
interface HAGroupInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
resources: number;
|
|
||||||
managed: number;
|
|
||||||
failed: number;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HAGroupsListProps {
|
interface HAGroupsListProps {
|
||||||
groups: HAGroupInfo[];
|
groups: HaGroup[];
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEdit?: (group: HAGroupInfo) => void;
|
onCreate?: () => void;
|
||||||
onDelete?: (group: HAGroupInfo) => void;
|
onEdit?: (group: HaGroup) => void;
|
||||||
onEnable?: (group: HAGroupInfo) => void;
|
onDelete?: (id: string) => void;
|
||||||
onDisable?: (group: HAGroupInfo) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HAGroupsList({
|
export function HAGroupsList({
|
||||||
groups,
|
groups,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
onCreate,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEnable,
|
|
||||||
onDisable,
|
|
||||||
}: HAGroupsListProps) {
|
}: HAGroupsListProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@ -38,11 +28,12 @@ export function HAGroupsList({
|
|||||||
<CardTitle>HA Groups</CardTitle>
|
<CardTitle>HA Groups</CardTitle>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Button size="sm" onClick={onCreate}>
|
||||||
<span className="mr-2 h-4 w-4">+</span>
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
New Group
|
Add Group
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -52,66 +43,59 @@ export function HAGroupsList({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Resources</TableHead>
|
<TableHead>Nodes</TableHead>
|
||||||
<TableHead>Managed</TableHead>
|
<TableHead>Restricted</TableHead>
|
||||||
<TableHead>Failed</TableHead>
|
<TableHead>No-Quorum Policy</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Comment</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{groups.map((group) => (
|
{groups.length === 0 ? (
|
||||||
<TableRow key={group.id}>
|
<TableRow>
|
||||||
<TableCell className="font-medium">{group.name}</TableCell>
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
<TableCell>{group.resources}</TableCell>
|
No HA groups configured
|
||||||
<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>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end space-x-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-accent"
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
|
||||||
onClick={() => onDelete?.(group)}
|
|
||||||
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>
|
</TableCell>
|
||||||
</TableRow>
|
</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
|
||||||
|
className="rounded-md p-1 hover:bg-accent"
|
||||||
|
onClick={() => onEdit?.(group)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
||||||
|
onClick={() => onDelete?.(group.id)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,53 +2,31 @@ import React from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
import { MoreHorizontal } from 'lucide-react';
|
import { Play, Trash2, RefreshCw } from 'lucide-react';
|
||||||
|
import { HaResource } from '@/lib/proxmoxClient';
|
||||||
interface HAResourceInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
group: string;
|
|
||||||
node: string;
|
|
||||||
managed: boolean;
|
|
||||||
failed: boolean;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HAResourcesListProps {
|
interface HAResourcesListProps {
|
||||||
resources: HAResourceInfo[];
|
resources: HaResource[];
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onManage?: (resource: HAResourceInfo) => void;
|
onEnable?: (resource: HaResource) => void;
|
||||||
onUnmanage?: (resource: HAResourceInfo) => void;
|
onRemove?: (resource: HaResource) => void;
|
||||||
onFailover?: (resource: HAResourceInfo) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HAResourcesList({
|
export function HAResourcesList({
|
||||||
resources,
|
resources,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isLoading,
|
isLoading,
|
||||||
onManage,
|
onEnable,
|
||||||
onUnmanage,
|
onRemove,
|
||||||
onFailover,
|
|
||||||
}: HAResourcesListProps) {
|
}: HAResourcesListProps) {
|
||||||
const managedCount = resources.filter((r) => r.managed).length;
|
|
||||||
const failedCount = resources.filter((r) => r.failed).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>HA Resources</CardTitle>
|
<CardTitle>HA Resources</CardTitle>
|
||||||
<div className="flex space-x-2">
|
<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}>
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -58,66 +36,59 @@ export function HAResourcesList({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Resource ID</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
|
||||||
<TableHead>Group</TableHead>
|
<TableHead>Group</TableHead>
|
||||||
<TableHead>Node</TableHead>
|
<TableHead>State</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Max Restart</TableHead>
|
||||||
|
<TableHead>Max Relocate</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{resources.map((resource) => (
|
{resources.length === 0 ? (
|
||||||
<TableRow key={resource.id}>
|
<TableRow>
|
||||||
<TableCell className="font-medium">{resource.name}</TableCell>
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
<TableCell>{resource.type}</TableCell>
|
No HA resources configured
|
||||||
<TableCell>{resource.group}</TableCell>
|
|
||||||
<TableCell>{resource.node}</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.failed ? 'Failed' : resource.managed ? 'Managed' : 'Unmanaged'}
|
|
||||||
</span>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-accent"
|
|
||||||
title="More"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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.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.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">
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-green-100 hover:text-green-600"
|
||||||
|
onClick={() => onEnable?.(resource)}
|
||||||
|
title="Enable"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
||||||
|
onClick={() => onRemove?.(resource)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,32 +2,25 @@ import React from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
import { MoreHorizontal, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2, PlusCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { AuthRealm } from '@/lib/proxmoxClient';
|
||||||
interface RealmInfo {
|
|
||||||
id: string;
|
|
||||||
type: 'pam' | 'ldap' | 'ad' | 'openid';
|
|
||||||
server?: string;
|
|
||||||
baseDn?: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RealmListProps {
|
interface RealmListProps {
|
||||||
realms: RealmInfo[];
|
realms: AuthRealm[];
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEdit?: (realm: RealmInfo) => void;
|
onCreate?: () => void;
|
||||||
onDelete?: (realm: RealmInfo) => void;
|
onEdit?: (realm: AuthRealm) => void;
|
||||||
onSync?: (realm: RealmInfo) => void;
|
onDelete?: (realm: AuthRealm) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RealmList({
|
export function RealmList({
|
||||||
realms,
|
realms,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
onCreate,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSync,
|
|
||||||
}: RealmListProps) {
|
}: RealmListProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@ -35,10 +28,11 @@ export function RealmList({
|
|||||||
<CardTitle>Authentication Realms</CardTitle>
|
<CardTitle>Authentication Realms</CardTitle>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Button size="sm" onClick={onCreate}>
|
||||||
<span className="mr-2 h-4 w-4">+</span>
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
New Realm
|
New Realm
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -48,63 +42,50 @@ export function RealmList({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Realm ID</TableHead>
|
<TableHead>Realm Name</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>Server</TableHead>
|
<TableHead>Comment</TableHead>
|
||||||
<TableHead>Base DN</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{realms.map((realm) => (
|
{realms.length === 0 ? (
|
||||||
<TableRow key={realm.id}>
|
<TableRow>
|
||||||
<TableCell className="font-medium">{realm.id}</TableCell>
|
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||||
<TableCell>
|
No auth realms configured
|
||||||
<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-right">
|
|
||||||
<div className="flex items-center justify-end space-x-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-accent"
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
|
||||||
onClick={() => onDelete?.(realm)}
|
|
||||||
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>
|
</TableCell>
|
||||||
</TableRow>
|
</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 className="text-muted-foreground text-sm">{realm.comment ?? '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-accent"
|
||||||
|
onClick={() => onEdit?.(realm)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
||||||
|
onClick={() => onDelete?.(realm)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,29 +2,35 @@ import React from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
import { MoreHorizontal, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2, PlusCircle, RefreshCw, Play, Pause } from 'lucide-react';
|
||||||
|
import { ProxmoxUser } from '@/lib/proxmoxClient';
|
||||||
interface UserInfo {
|
|
||||||
id: string;
|
|
||||||
email?: string;
|
|
||||||
enabled: boolean;
|
|
||||||
lastLogin?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserListProps {
|
interface UserListProps {
|
||||||
users: UserInfo[];
|
users: ProxmoxUser[];
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onEdit?: (user: UserInfo) => void;
|
onCreate?: () => void;
|
||||||
onDelete?: (user: UserInfo) => void;
|
onEdit?: (user: ProxmoxUser) => void;
|
||||||
onEnable?: (user: UserInfo) => void;
|
onDelete?: (user: ProxmoxUser) => void;
|
||||||
onDisable?: (user: UserInfo) => 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({
|
export function UserList({
|
||||||
users,
|
users,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
onCreate,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEnable,
|
onEnable,
|
||||||
@ -37,20 +43,21 @@ export function UserList({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Users</CardTitle>
|
<CardTitle>Users</CardTitle>
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||||
<span className="text-green-500">●</span>
|
<span className="text-green-500">●</span>
|
||||||
<span>{enabledCount} Enabled</span>
|
<span>{enabledCount} Enabled</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 text-sm">
|
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||||
<span className="text-gray-500">●</span>
|
<span className="text-gray-400">●</span>
|
||||||
<span>{disabledCount} Disabled</span>
|
<span>{disabledCount} Disabled</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Button size="sm" onClick={onCreate}>
|
||||||
<span className="mr-2 h-4 w-4">+</span>
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
New User
|
New User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -61,64 +68,71 @@ export function UserList({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>User ID</TableHead>
|
<TableHead>User ID</TableHead>
|
||||||
|
<TableHead>Realm</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Enabled</TableHead>
|
||||||
<TableHead>Last Login</TableHead>
|
<TableHead>Expire</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
{users.length === 0 ? (
|
||||||
<TableRow key={user.id}>
|
<TableRow>
|
||||||
<TableCell className="font-medium">{user.id}</TableCell>
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||||
<TableCell>{user.email || '-'}</TableCell>
|
No users found
|
||||||
<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 ? 'Enabled' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{user.lastLogin || '-'}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end space-x-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-accent"
|
|
||||||
onClick={() => onEdit?.(user)}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<span className="h-4 w-4 text-xs">✏️</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`rounded-md p-1 hover:bg-accent ${
|
|
||||||
user.enabled ? 'text-green-600' : 'text-gray-600'
|
|
||||||
}`}
|
|
||||||
onClick={() => user.enabled ? onDisable?.(user) : onEnable?.(user)}
|
|
||||||
title={user.enabled ? 'Disable' : 'Enable'}
|
|
||||||
>
|
|
||||||
{user.enabled ? (
|
|
||||||
<span className="h-4 w-4 text-xs">⏸️</span>
|
|
||||||
) : (
|
|
||||||
<span className="h-4 w-4 text-xs">▶️</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
|
||||||
onClick={() => onDelete?.(user)}
|
|
||||||
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>
|
</TableCell>
|
||||||
</TableRow>
|
</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-600'
|
||||||
|
}`}>
|
||||||
|
{user.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatExpiry(user.expire)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-accent"
|
||||||
|
onClick={() => onEdit?.(user)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-accent"
|
||||||
|
onClick={() => user.enabled ? onDisable?.(user) : onEnable?.(user)}
|
||||||
|
title={user.enabled ? 'Disable' : 'Enable'}
|
||||||
|
>
|
||||||
|
{user.enabled ? (
|
||||||
|
<Pause className="h-4 w-4 text-yellow-600" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4 text-green-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
|
||||||
|
onClick={() => onDelete?.(user)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,34 +1,173 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Button } from '@/components/ui/index';
|
|
||||||
import { RefreshCw } from 'lucide-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() {
|
export function ProxmoxACLPage() {
|
||||||
const acls = [
|
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
|
||||||
{ id: '1', path: '/nodes/pve1', type: 'user' as const, principal: 'admin@pam', roles: ['PVEAdmin'], propagate: true },
|
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
|
||||||
{ id: '2', path: '/storage/local', type: 'group' as const, principal: 'admins', roles: ['PVEDataStoreAdmin'], propagate: false },
|
const [activeTab, setActiveTab] = useState<string>('acl');
|
||||||
{ id: '3', path: '/vms/100', type: 'user' as const, principal: 'developer@pam', roles: ['PVEVMUser'], propagate: false },
|
|
||||||
];
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Access Control Lists</h1>
|
<h1 className="text-2xl font-bold">Access Control & Users</h1>
|
||||||
<p className="text-muted-foreground">Manage permissions and access control</p>
|
<p className="text-muted-foreground">Manage permissions, users, and authentication realms</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button variant="outline" size="sm">
|
{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" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AclList
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
acls={acls}
|
<TabsList>
|
||||||
onRefresh={() => {}}
|
<TabsTrigger value="acl">ACLs</TabsTrigger>
|
||||||
/>
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
|
<TabsTrigger value="realms">Auth Realms</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="acl">
|
||||||
|
<AclList
|
||||||
|
acls={acls}
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,119 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
|
||||||
import { Button } from '@/components/ui/index';
|
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/index';
|
||||||
import { HAGroupsList, HAResourcesList } from '@/components/Proxmox';
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -12,38 +121,44 @@ export function ProxmoxHAPage() {
|
|||||||
<h1 className="text-2xl font-bold">High Availability</h1>
|
<h1 className="text-2xl font-bold">High Availability</h1>
|
||||||
<p className="text-muted-foreground">Manage HA groups and resources</p>
|
<p className="text-muted-foreground">Manage HA groups and resources</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button variant="outline" size="sm">
|
{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" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<Card>
|
<HAGroupsList
|
||||||
<CardHeader>
|
groups={groups}
|
||||||
<CardTitle>HA Groups</CardTitle>
|
isLoading={isLoadingGroups}
|
||||||
</CardHeader>
|
onRefresh={() => loadGroups(selectedClusterId)}
|
||||||
<CardContent>
|
onCreate={handleCreateGroup}
|
||||||
<HAGroupsList
|
onEdit={handleEditGroup}
|
||||||
groups={[]}
|
onDelete={handleDeleteGroup}
|
||||||
onRefresh={() => {}}
|
/>
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<HAResourcesList
|
||||||
<CardHeader>
|
resources={resources}
|
||||||
<CardTitle>HA Resources</CardTitle>
|
isLoading={isLoadingResources}
|
||||||
</CardHeader>
|
onRefresh={() => loadResources(selectedClusterId)}
|
||||||
<CardContent>
|
onEnable={handleEnableResource}
|
||||||
<HAResourcesList
|
onRemove={handleRemoveResource}
|
||||||
resources={[]}
|
/>
|
||||||
onRefresh={() => {}}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user