Loading portal-gui/src/components/federation/confirm-delete-federation-dialog.tsx 0 → 100644 +76 −0 Changes for portal-gui/src/components/federation/confirm-delete-federation-dialog.tsx: 76 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import type { Partner } from '@/types/federation'; export function ConfirmDeleteFederationDialog({ partner, onOpenChange, onConfirm, }: { partner: Partner; onOpenChange: (open: boolean) => void; onConfirm: () => Promise<void>; }) { const [pending, setPending] = useState(false); const [error, setError] = useState<string | null>(null); const handleConfirm = async () => { setPending(true); setError(null); try { await onConfirm(); onOpenChange(false); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to delete federation.', ); } finally { setPending(false); } }; return ( <AlertDialog open onOpenChange={(open) => { if (!pending) onOpenChange(open); }} > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Delete federation?</AlertDialogTitle> <AlertDialogDescription> Ends our federation with "{partner.name}", including the zones we subscribed to. The partner registration stays. </AlertDialogDescription> </AlertDialogHeader> {error && <p className="text-sm text-destructive">{error}</p>} <AlertDialogFooter> <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={(event) => { event.preventDefault(); void handleConfirm(); }} disabled={pending} className="text-destructive-foreground bg-destructive hover:bg-destructive/90" > {pending ? 'Deleting…' : 'Delete'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } portal-gui/src/components/federation/create-federation-dialog.tsx 0 → 100644 +132 −0 Changes for portal-gui/src/components/federation/create-federation-dialog.tsx: 132 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { federationRequestSchema } from '@/features/federation/federation-schema'; import type { FederationRequestData, Partner } from '@/types/federation'; function localNow(): string { const now = new Date(); now.setSeconds(0, 0); return new Date(now.getTime() - now.getTimezoneOffset() * 60_000) .toISOString() .slice(0, 16); } export function CreateFederationDialog({ partner, onOpenChange, onCreate, }: { partner: Partner; onOpenChange: (open: boolean) => void; onCreate: (request: FederationRequestData) => Promise<void>; }) { const [initialDate, setInitialDate] = useState(localNow); const [partnerStatusLink, setPartnerStatusLink] = useState(''); const [submitting, setSubmitting] = useState(false); const [errors, setErrors] = useState<string[]>([]); const handleSubmit = async () => { const result = federationRequestSchema.safeParse({ initialDate, partnerStatusLink: partnerStatusLink.trim(), }); if (!result.success) { setErrors(result.error.issues.map((issue) => issue.message)); return; } setSubmitting(true); setErrors([]); try { await onCreate({ initialDate: new Date(result.data.initialDate).toISOString(), partnerStatusLink: result.data.partnerStatusLink, }); onOpenChange(false); } catch (err) { setErrors([err instanceof Error ? err.message : 'The request failed.']); } finally { setSubmitting(false); } }; return ( <Dialog open onOpenChange={(value) => { if (!submitting) onOpenChange(value); }} > <DialogContent className="sm:max-w-lg"> <DialogHeader> <DialogTitle>Create Federation</DialogTitle> <DialogDescription> Our Federation Manager asks {partner.name} to federate with us. Our own identity is filled in by the Federation Manager. </DialogDescription> </DialogHeader> <div className="space-y-4 py-2"> <div className="space-y-2"> <Label htmlFor="federation-initial-date">Initial date *</Label> <Input id="federation-initial-date" type="datetime-local" value={initialDate} onChange={(e) => setInitialDate(e.target.value)} /> </div> <div className="space-y-2"> <Label htmlFor="federation-status-link"> Partner status link * </Label> <Input id="federation-status-link" className="font-mono" value={partnerStatusLink} onChange={(e) => setPartnerStatusLink(e.target.value)} placeholder="https://fm.oop.example.com/operatorplatform/federation/v1/callbacks/partner-status" /> <p className="text-xs text-muted-foreground"> Where the partner sends status updates about this federation. </p> </div> {errors.length > 0 && ( <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> {errors.join(' ')} </div> )} </div> <DialogFooter> <Button type="button" variant="outline" disabled={submitting} onClick={() => onOpenChange(false)} > Cancel </Button> <Button type="button" disabled={!partnerStatusLink.trim() || submitting} onClick={() => void handleSubmit()} > {submitting ? 'Creating…' : 'Create federation'} </Button> </DialogFooter> </DialogContent> </Dialog> ); } portal-gui/src/components/federation/detail-fields.tsx 0 → 100644 +18 −0 Changes for portal-gui/src/components/federation/detail-fields.tsx: 18 added lines, 0 removed lines. Original line number Diff line number Diff line import type { ReactNode } from 'react'; export function DetailFields({ fields, }: { fields: Record<string, ReactNode>; }) { return ( <dl className="grid gap-3 sm:grid-cols-2"> {Object.entries(fields).map(([label, value]) => ( <div key={label} className="min-w-0"> <dt className="text-muted-foreground">{label}</dt> <dd className="font-mono text-xs break-all">{value}</dd> </div> ))} </dl> ); } portal-gui/src/components/federation/federation-state-badge.tsx 0 → 100644 +18 −0 Changes for portal-gui/src/components/federation/federation-state-badge.tsx: 18 added lines, 0 removed lines. Original line number Diff line number Diff line import { Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import type { FederationState } from '@/types/federation'; export function FederationStateBadge({ state }: { state: FederationState }) { if (state === 'loading') { return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />; } if (state === 'unknown') { return <span className="text-muted-foreground">—</span>; } return ( <Badge variant={state === 'federated' ? 'default' : 'outline'}> {state === 'federated' ? 'Federated' : 'Not federated'} </Badge> ); } portal-gui/src/components/federation/partner-columns.tsx +23 −12 Changes for portal-gui/src/components/federation/partner-columns.tsx: 23 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -5,16 +5,20 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import type { Partner } from '@/types/federation'; import type { Partner, PartnerRow } from '@/types/federation'; import { FederationStateBadge } from './federation-state-badge'; import { PartnerStatusBadge } from './partner-status-badge'; export function getPartnerColumns( onDetails: (partner: Partner) => void, onFederation: (partner: Partner) => void, onZones: (partner: Partner) => void, ): ColumnDef<Partner>[] { onEdit: (partner: Partner) => void, ): ColumnDef<PartnerRow>[] { return [ { id: 'drag', Loading Loading @@ -54,6 +58,14 @@ export function getPartnerColumns( cell: ({ row }) => <PartnerStatusBadge status={row.original.status} />, size: 120, }, { id: 'federation', header: 'Federation', cell: ({ row }) => ( <FederationStateBadge state={row.original.federation} /> ), size: 140, }, { id: 'endpoint', header: 'EWBI endpoint', Loading @@ -67,16 +79,6 @@ export function getPartnerColumns( ), size: 320, }, { id: 'credentials', header: 'Outbound credentials', cell: ({ row }) => ( <span className="text-muted-foreground"> {row.original.our_client_id ? 'Configured' : 'Not set'} </span> ), size: 160, }, { id: 'actions', enableHiding: false, Loading @@ -91,9 +93,18 @@ export function getPartnerColumns( <DropdownMenuItem onClick={() => onDetails(row.original)}> Details </DropdownMenuItem> <DropdownMenuItem onClick={() => onFederation(row.original)}> Federation </DropdownMenuItem> {row.original.federation === 'federated' && ( <DropdownMenuItem onClick={() => onZones(row.original)}> Zones </DropdownMenuItem> )} <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => onEdit(row.original)}> Edit </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), Loading Loading
portal-gui/src/components/federation/confirm-delete-federation-dialog.tsx 0 → 100644 +76 −0 Changes for portal-gui/src/components/federation/confirm-delete-federation-dialog.tsx: 76 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import type { Partner } from '@/types/federation'; export function ConfirmDeleteFederationDialog({ partner, onOpenChange, onConfirm, }: { partner: Partner; onOpenChange: (open: boolean) => void; onConfirm: () => Promise<void>; }) { const [pending, setPending] = useState(false); const [error, setError] = useState<string | null>(null); const handleConfirm = async () => { setPending(true); setError(null); try { await onConfirm(); onOpenChange(false); } catch (err) { setError( err instanceof Error ? err.message : 'Failed to delete federation.', ); } finally { setPending(false); } }; return ( <AlertDialog open onOpenChange={(open) => { if (!pending) onOpenChange(open); }} > <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Delete federation?</AlertDialogTitle> <AlertDialogDescription> Ends our federation with "{partner.name}", including the zones we subscribed to. The partner registration stays. </AlertDialogDescription> </AlertDialogHeader> {error && <p className="text-sm text-destructive">{error}</p>} <AlertDialogFooter> <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={(event) => { event.preventDefault(); void handleConfirm(); }} disabled={pending} className="text-destructive-foreground bg-destructive hover:bg-destructive/90" > {pending ? 'Deleting…' : 'Delete'} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); }
portal-gui/src/components/federation/create-federation-dialog.tsx 0 → 100644 +132 −0 Changes for portal-gui/src/components/federation/create-federation-dialog.tsx: 132 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { federationRequestSchema } from '@/features/federation/federation-schema'; import type { FederationRequestData, Partner } from '@/types/federation'; function localNow(): string { const now = new Date(); now.setSeconds(0, 0); return new Date(now.getTime() - now.getTimezoneOffset() * 60_000) .toISOString() .slice(0, 16); } export function CreateFederationDialog({ partner, onOpenChange, onCreate, }: { partner: Partner; onOpenChange: (open: boolean) => void; onCreate: (request: FederationRequestData) => Promise<void>; }) { const [initialDate, setInitialDate] = useState(localNow); const [partnerStatusLink, setPartnerStatusLink] = useState(''); const [submitting, setSubmitting] = useState(false); const [errors, setErrors] = useState<string[]>([]); const handleSubmit = async () => { const result = federationRequestSchema.safeParse({ initialDate, partnerStatusLink: partnerStatusLink.trim(), }); if (!result.success) { setErrors(result.error.issues.map((issue) => issue.message)); return; } setSubmitting(true); setErrors([]); try { await onCreate({ initialDate: new Date(result.data.initialDate).toISOString(), partnerStatusLink: result.data.partnerStatusLink, }); onOpenChange(false); } catch (err) { setErrors([err instanceof Error ? err.message : 'The request failed.']); } finally { setSubmitting(false); } }; return ( <Dialog open onOpenChange={(value) => { if (!submitting) onOpenChange(value); }} > <DialogContent className="sm:max-w-lg"> <DialogHeader> <DialogTitle>Create Federation</DialogTitle> <DialogDescription> Our Federation Manager asks {partner.name} to federate with us. Our own identity is filled in by the Federation Manager. </DialogDescription> </DialogHeader> <div className="space-y-4 py-2"> <div className="space-y-2"> <Label htmlFor="federation-initial-date">Initial date *</Label> <Input id="federation-initial-date" type="datetime-local" value={initialDate} onChange={(e) => setInitialDate(e.target.value)} /> </div> <div className="space-y-2"> <Label htmlFor="federation-status-link"> Partner status link * </Label> <Input id="federation-status-link" className="font-mono" value={partnerStatusLink} onChange={(e) => setPartnerStatusLink(e.target.value)} placeholder="https://fm.oop.example.com/operatorplatform/federation/v1/callbacks/partner-status" /> <p className="text-xs text-muted-foreground"> Where the partner sends status updates about this federation. </p> </div> {errors.length > 0 && ( <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> {errors.join(' ')} </div> )} </div> <DialogFooter> <Button type="button" variant="outline" disabled={submitting} onClick={() => onOpenChange(false)} > Cancel </Button> <Button type="button" disabled={!partnerStatusLink.trim() || submitting} onClick={() => void handleSubmit()} > {submitting ? 'Creating…' : 'Create federation'} </Button> </DialogFooter> </DialogContent> </Dialog> ); }
portal-gui/src/components/federation/detail-fields.tsx 0 → 100644 +18 −0 Changes for portal-gui/src/components/federation/detail-fields.tsx: 18 added lines, 0 removed lines. Original line number Diff line number Diff line import type { ReactNode } from 'react'; export function DetailFields({ fields, }: { fields: Record<string, ReactNode>; }) { return ( <dl className="grid gap-3 sm:grid-cols-2"> {Object.entries(fields).map(([label, value]) => ( <div key={label} className="min-w-0"> <dt className="text-muted-foreground">{label}</dt> <dd className="font-mono text-xs break-all">{value}</dd> </div> ))} </dl> ); }
portal-gui/src/components/federation/federation-state-badge.tsx 0 → 100644 +18 −0 Changes for portal-gui/src/components/federation/federation-state-badge.tsx: 18 added lines, 0 removed lines. Original line number Diff line number Diff line import { Loader2 } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import type { FederationState } from '@/types/federation'; export function FederationStateBadge({ state }: { state: FederationState }) { if (state === 'loading') { return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />; } if (state === 'unknown') { return <span className="text-muted-foreground">—</span>; } return ( <Badge variant={state === 'federated' ? 'default' : 'outline'}> {state === 'federated' ? 'Federated' : 'Not federated'} </Badge> ); }
portal-gui/src/components/federation/partner-columns.tsx +23 −12 Changes for portal-gui/src/components/federation/partner-columns.tsx: 23 added lines, 12 removed lines. Original line number Diff line number Diff line Loading @@ -5,16 +5,20 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import type { Partner } from '@/types/federation'; import type { Partner, PartnerRow } from '@/types/federation'; import { FederationStateBadge } from './federation-state-badge'; import { PartnerStatusBadge } from './partner-status-badge'; export function getPartnerColumns( onDetails: (partner: Partner) => void, onFederation: (partner: Partner) => void, onZones: (partner: Partner) => void, ): ColumnDef<Partner>[] { onEdit: (partner: Partner) => void, ): ColumnDef<PartnerRow>[] { return [ { id: 'drag', Loading Loading @@ -54,6 +58,14 @@ export function getPartnerColumns( cell: ({ row }) => <PartnerStatusBadge status={row.original.status} />, size: 120, }, { id: 'federation', header: 'Federation', cell: ({ row }) => ( <FederationStateBadge state={row.original.federation} /> ), size: 140, }, { id: 'endpoint', header: 'EWBI endpoint', Loading @@ -67,16 +79,6 @@ export function getPartnerColumns( ), size: 320, }, { id: 'credentials', header: 'Outbound credentials', cell: ({ row }) => ( <span className="text-muted-foreground"> {row.original.our_client_id ? 'Configured' : 'Not set'} </span> ), size: 160, }, { id: 'actions', enableHiding: false, Loading @@ -91,9 +93,18 @@ export function getPartnerColumns( <DropdownMenuItem onClick={() => onDetails(row.original)}> Details </DropdownMenuItem> <DropdownMenuItem onClick={() => onFederation(row.original)}> Federation </DropdownMenuItem> {row.original.federation === 'federated' && ( <DropdownMenuItem onClick={() => onZones(row.original)}> Zones </DropdownMenuItem> )} <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => onEdit(row.original)}> Edit </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ), Loading