Commit 861de8a9 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: enhance federation management with partner details, zones, and subscription dialogs

parent 06fd546d
Loading
Loading
Loading
Loading
+74 −0
Changes for portal-gui/src/components/federation/confirm-unsubscribe-zone-dialog.tsx: 74 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 { PartnerZone } from '@/types/federation';

export function ConfirmUnsubscribeZoneDialog({
  zone,
  onOpenChange,
  onConfirm,
}: {
  zone: PartnerZone;
  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 unsubscribe.');
    } finally {
      setPending(false);
    }
  };

  return (
    <AlertDialog
      open
      onOpenChange={(open) => {
        if (!pending) onOpenChange(open);
      }}
    >
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Unsubscribe from zone?</AlertDialogTitle>
          <AlertDialogDescription>
            The partner releases the resources it reserved for us in "
            {zone.geographyDetails}".
          </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 ? 'Unsubscribing…' : 'Unsubscribe'}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
+103 −0
Changes for portal-gui/src/components/federation/partner-columns.tsx: 103 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { ColumnDef } from '@tanstack/react-table';
import { EllipsisVerticalIcon } from 'lucide-react';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import type { Partner } from '@/types/federation';
import { PartnerStatusBadge } from './partner-status-badge';

export function getPartnerColumns(
  onDetails: (partner: Partner) => void,
  onZones: (partner: Partner) => void,
): ColumnDef<Partner>[] {
  return [
    {
      id: 'drag',
      header: () => null,
      enableHiding: false,
      size: 40,
    },
    {
      id: 'name',
      accessorKey: 'name',
      header: 'Name',
      cell: ({ row }) => {
        const partner = row.original;
        return (
          <>
            <button
              type="button"
              onClick={() => onDetails(partner)}
              className="block max-w-full truncate text-left font-medium hover:underline"
              title={partner.name}
            >
              {partner.name}
            </button>
            <p className="mt-1 font-mono text-xs text-muted-foreground">
              {partner.mcc_mnc}
            </p>
          </>
        );
      },
      size: 200,
    },
    {
      id: 'status',
      accessorKey: 'status',
      header: 'Registration',
      filterFn: 'equalsString',
      cell: ({ row }) => <PartnerStatusBadge status={row.original.status} />,
      size: 120,
    },
    {
      id: 'endpoint',
      header: 'EWBI endpoint',
      cell: ({ row }) => (
        <span
          className="block max-w-[320px] truncate font-mono text-xs"
          title={row.original.base_url ?? undefined}
        >
          {row.original.base_url ?? '—'}
        </span>
      ),
      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,
      cell: ({ row }) => (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="ghost" size="icon">
              <EllipsisVerticalIcon className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onClick={() => onDetails(row.original)}>
              Details
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => onZones(row.original)}>
              Zones
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      ),
      size: 70,
    },
  ];
}
+143 −0
Changes for portal-gui/src/components/federation/partner-details-dialog.tsx: 143 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { ReactNode } from 'react';
import { Loader2 } from 'lucide-react';

import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { usePartnerFederation } from '@/hooks/usePartnerFederation';
import type {
  MobileNetworkIds,
  Partner,
  ServiceEndpoint,
} from '@/types/federation';
import { PartnerStatusBadge } from './partner-status-badge';

function endpoint(value?: ServiceEndpoint): string {
  if (!value) return '—';
  const host =
    value.fqdn ?? value.ipv4Addresses?.[0] ?? value.ipv6Addresses?.[0] ?? '?';
  return `${host}:${value.port}`;
}

function networks(value?: MobileNetworkIds): string {
  if (!value?.mcc && !value?.mncs?.length) return '—';
  return `MCC ${value.mcc ?? '?'} · MNC ${value.mncs?.join(', ') ?? '?'}`;
}

function Fields({ 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>
  );
}

export function PartnerDetailsDialog({
  partner,
  onClose,
}: {
  partner: Partner;
  onClose: () => void;
}) {
  const { federation, loading, error } = usePartnerFederation(partner.id);

  return (
    <Dialog
      open
      onOpenChange={(open) => {
        if (!open) onClose();
      }}
    >
      <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
        <DialogHeader>
          <DialogTitle className="break-words">{partner.name}</DialogTitle>
          <DialogDescription>
            Partner registration and our federation with it.
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-5 text-sm break-words">
          <section className="space-y-3">
            <h3 className="font-semibold">Registration</h3>
            <Fields
              fields={{
                Registration: <PartnerStatusBadge status={partner.status} />,
                'Partner ID': partner.id,
                'MCC-MNC': partner.mcc_mnc,
                'EWBI endpoint': partner.base_url ?? '—',
                'Their client ID': partner.oauth2_client_id,
                'Our client ID': partner.our_client_id ?? '—',
                'Token endpoint': partner.token_endpoint ?? '—',
                'Secret file': partner.our_client_secret_ref ?? '—',
              }}
            />
          </section>

          <section className="space-y-3">
            <h3 className="font-semibold">Federation</h3>
            {loading ? (
              <p className="flex items-center gap-2 text-muted-foreground">
                <Loader2 className="h-4 w-4 animate-spin" />
                Loading federation…
              </p>
            ) : error ? (
              <p className="text-destructive">{error}</p>
            ) : federation ? (
              <div className="rounded border p-3">
                <Fields
                  fields={{
                    'Context ID': federation.contextId,
                    'Edge discovery': endpoint(
                      federation.details.edgeDiscoveryServiceEndPoint,
                    ),
                    'LCM service': endpoint(
                      federation.details.lcmServiceEndPoint,
                    ),
                    'Platform capabilities': federation.details.platformCaps
                      ?.length ? (
                      <span className="flex flex-wrap gap-1">
                        {federation.details.platformCaps.map((capability) => (
                          <Badge key={capability} variant="outline">
                            {capability}
                          </Badge>
                        ))}
                      </span>
                    ) : (
                      '—'
                    ),
                    'Allowed networks': networks(
                      federation.details.allowedMobileNetworkIds,
                    ),
                  }}
                />
              </div>
            ) : (
              <p className="text-muted-foreground">
                {partner.status === 'active'
                  ? 'Not federated yet.'
                  : `Not federated. The partner is ${partner.status}; federation needs it active.`}
              </p>
            )}
          </section>
        </div>

        <div className="flex justify-end">
          <Button variant="outline" onClick={onClose}>
            Close
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}
+13 −0
Changes for portal-gui/src/components/federation/partner-status-badge.tsx: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { Badge } from '@/components/ui/badge';
import type { PartnerStatus } from '@/types/federation';

const VARIANTS = {
  active: 'default',
  pending: 'secondary',
  suspended: 'outline',
  decommissioned: 'destructive',
} as const satisfies Record<PartnerStatus, string>;

export function PartnerStatusBadge({ status }: { status: PartnerStatus }) {
  return <Badge variant={VARIANTS[status]}>{status}</Badge>;
}
+155 −0
Changes for portal-gui/src/components/federation/partner-table.tsx: 155 added lines, 0 removed lines.
Original line number Diff line number Diff line
import React from 'react';
import {
  closestCenter,
  DndContext,
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  useSensor,
  useSensors,
  type DragEndEvent,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
  type ColumnFiltersState,
  type SortingState,
  type VisibilityState,
} from '@tanstack/react-table';

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { TablePagination } from '../data-table/table-pagination';
import { DraggableRow } from '../data-table/dragable-row';
import type { PartnerTableProps } from '@/types/federation';
import { getPartnerColumns } from './partner-columns';
import { PartnerToolbar } from './partner-toolbar';

export function PartnerTable({
  partners,
  onDetails,
  onZones,
  onRefresh,
}: PartnerTableProps) {
  const [data, setData] = React.useState(partners);
  const [source, setSource] = React.useState(partners);
  if (partners !== source) {
    setSource(partners);
    setData(partners);
  }

  const [sorting, setSorting] = React.useState<SortingState>([]);
  const [columnVisibility, setColumnVisibility] =
    React.useState<VisibilityState>({});
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    [],
  );
  const sortableId = React.useId();
  const sensors = useSensors(
    useSensor(MouseSensor, {}),
    useSensor(TouchSensor, {}),
    useSensor(KeyboardSensor, {}),
  );
  const columns = React.useMemo(
    () => getPartnerColumns(onDetails, onZones),
    [onDetails, onZones],
  );
  const table = useReactTable({
    data,
    columns,
    getRowId: (row) => row.id,
    state: { sorting, columnVisibility, columnFilters },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    setData((current) => {
      const oldIndex = current.findIndex((partner) => partner.id === active.id);
      const newIndex = current.findIndex((partner) => partner.id === over.id);
      if (oldIndex === -1 || newIndex === -1) return current;
      return arrayMove(current, oldIndex, newIndex);
    });
  }

  return (
    <>
      <PartnerToolbar table={table} onRefresh={onRefresh} />
      <div className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6">
        <div className="overflow-x-auto rounded-lg border">
          <DndContext
            collisionDetection={closestCenter}
            modifiers={[restrictToVerticalAxis]}
            onDragEnd={handleDragEnd}
            sensors={sensors}
            id={sortableId}
          >
            <Table className="min-w-[760px]">
              <TableHeader className="sticky top-0 z-10 bg-muted">
                {table.getHeaderGroups().map((headerGroup) => (
                  <TableRow key={headerGroup.id}>
                    {headerGroup.headers.map((header) => (
                      <TableHead key={header.id} colSpan={header.colSpan}>
                        {header.isPlaceholder
                          ? null
                          : flexRender(
                              header.column.columnDef.header,
                              header.getContext(),
                            )}
                      </TableHead>
                    ))}
                  </TableRow>
                ))}
              </TableHeader>
              <TableBody className="**:data-[slot=table-cell]:first:w-8">
                {table.getRowModel().rows.length ? (
                  <SortableContext
                    items={data.map((partner) => partner.id)}
                    strategy={verticalListSortingStrategy}
                  >
                    {table.getRowModel().rows.map((row) => (
                      <DraggableRow key={row.id} row={row} />
                    ))}
                  </SortableContext>
                ) : (
                  <TableRow>
                    <TableCell
                      colSpan={columns.length}
                      className="h-24 text-center"
                    >
                      No results.
                    </TableCell>
                  </TableRow>
                )}
              </TableBody>
            </Table>
          </DndContext>
        </div>
        <TablePagination table={table} />
      </div>
    </>
  );
}
Loading