feat: add network management page and inline accordion device details

This commit is contained in:
Ubuntu
2026-05-07 19:59:21 +00:00
parent 9a5e023ec3
commit 16fb2b4e41
10 changed files with 4301 additions and 357 deletions
+5 -152
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Network,
Plus,
@@ -7,14 +8,10 @@ import {
MoreHorizontal,
ChevronRight,
Users,
Monitor,
Clock,
Shield,
Trash2,
Pencil,
Eye,
CheckCircle,
XCircle,
Ban,
Zap,
Download,
@@ -55,7 +52,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast";
import {
@@ -63,8 +59,6 @@ import {
ApiError,
AvailableZtNetwork,
PortalNetwork,
DeviceNetworkMembership,
UserNetworkApproval,
NetworkEnvironment,
NetworkRequestMode,
} from "@/lib/api";
@@ -119,6 +113,7 @@ function cn(...classes: (string | boolean | undefined | null)[]) {
export default function NetworksPage() {
const { orgId } = useCurrentOrganizationId();
const { toast } = useToast();
const navigate = useNavigate();
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -136,11 +131,6 @@ export default function NetworksPage() {
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [selectedNetwork, setSelectedNetwork] = useState<PortalNetwork | null>(null);
const [networkMembers, setNetworkMembers] = useState<DeviceNetworkMembership[]>([]);
const [networkRequests, setNetworkRequests] = useState<UserNetworkApproval[]>([]);
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
const [editingNetwork, setEditingNetwork] = useState<PortalNetwork | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState("");
@@ -180,31 +170,6 @@ export default function NetworksPage() {
fetchNetworks();
}, [fetchNetworks]);
const openNetworkDrawer = async (network: PortalNetwork) => {
setSelectedNetwork(network);
setIsDrawerLoading(true);
setNetworkMembers([]);
setNetworkRequests([]);
try {
const [membersRes, requestsRes] = await Promise.allSettled([
api.zerotier.getNetworkMembers(orgId!, network.id),
api.zerotier.getNetworkPendingRequests(orgId!, network.id),
]);
if (membersRes.status === "fulfilled") setNetworkMembers(membersRes.value.memberships || []);
if (requestsRes.status === "fulfilled") setNetworkRequests(requestsRes.value.requests || []);
} catch {
// non-fatal
} finally {
setIsDrawerLoading(false);
}
};
const closeDrawer = () => {
setSelectedNetwork(null);
setNetworkMembers([]);
setNetworkRequests([]);
};
const openZtPicker = async () => {
if (!orgId) return;
setShowZtPicker(true);
@@ -355,7 +320,7 @@ export default function NetworksPage() {
Portal Networks
{!isLoading && <Badge variant="secondary" className="ml-1">{networks.length}</Badge>}
</CardTitle>
<CardDescription>Click a network to view members, requests, and manage access</CardDescription>
<CardDescription>Click a network to manage members, devices, and access requests</CardDescription>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
@@ -375,7 +340,7 @@ export default function NetworksPage() {
<button
key={network.id}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
onClick={() => openNetworkDrawer(network)}
onClick={() => navigate(`/org/zerotier/networks/${network.id}`)}
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Network className="w-5 h-5 text-primary" />
@@ -410,7 +375,7 @@ export default function NetworksPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openNetworkDrawer(network); }}>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); navigate(`/org/zerotier/networks/${network.id}`); }}>
<Eye className="w-4 h-4 mr-2" /> View details
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEditDialog(network); }}>
@@ -696,118 +661,6 @@ export default function NetworksPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Network Detail Drawer */}
<Sheet open={!!selectedNetwork} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
{selectedNetwork && (
<>
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<Network className="w-5 h-5 text-primary" />
</div>
{selectedNetwork.name}
</SheetTitle>
<SheetDescription className="font-mono">{selectedNetwork.zerotier_network_id}</SheetDescription>
</SheetHeader>
<div className="space-y-4 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<EnvironmentBadge env={selectedNetwork.environment} />
<RequestModeBadge mode={selectedNetwork.request_mode} />
</div>
{selectedNetwork.description && (
<p className="text-sm text-muted-foreground">{selectedNetwork.description}</p>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Default activation</span>
<p className="font-medium">{selectedNetwork.default_activation_lifetime_minutes} min</p>
</div>
<div>
<span className="text-muted-foreground">Max activation</span>
<p className="font-medium">{selectedNetwork.max_activation_lifetime_minutes ? `${selectedNetwork.max_activation_lifetime_minutes} min` : "No limit"}</p>
</div>
<div>
<span className="text-muted-foreground">Approved users</span>
<p className="font-medium flex items-center gap-1"><Users className="w-3 h-3" />{selectedNetwork.approved_user_count ?? 0}</p>
</div>
<div>
<span className="text-muted-foreground">Active devices</span>
<p className="font-medium flex items-center gap-1 text-green-600"><Zap className="w-3 h-3" />{selectedNetwork.active_membership_count ?? 0}</p>
</div>
</div>
</div>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs defaultValue="members" className="w-full">
<TabsList className="mb-3">
<TabsTrigger value="members">
Members ({networkMembers.length})
</TabsTrigger>
<TabsTrigger value="requests">
Requests ({networkRequests.length})
</TabsTrigger>
</TabsList>
<TabsContent value="members">
{networkMembers.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No members yet.</div>
) : (
<div className="space-y-2">
{networkMembers.map((m) => (
<div key={m.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.device_id}</p>
<p className="text-xs text-muted-foreground">
State: {m.state} · Join seen: {m.join_seen ? "Yes" : "No"}
</p>
</div>
<div className="flex items-center gap-1">
{m.currently_authorized ? (
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-xs text-green-600">Authorized</span></>
) : (
<><XCircle className="w-4 h-4 text-muted-foreground" /><span className="text-xs text-muted-foreground">Inactive</span></>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="requests">
{networkRequests.length === 0 ? (
<div className="p-6 text-center text-muted-foreground text-sm">No pending requests.</div>
) : (
<div className="space-y-2">
{networkRequests.map((r) => (
<div key={r.id} className="flex items-center gap-3 p-3 border rounded-lg text-sm">
<Clock className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{r.user_id}</p>
<p className="text-xs text-muted-foreground">
{r.grant_type} · {r.state}
</p>
{r.justification && <p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
)}
</>
)}
</SheetContent>
</Sheet>
</div>
);
}