Add router provisioning workflow and filtering UI

This commit is contained in:
litoral05
2026-05-06 10:43:24 +01:00
parent 90bfc090bd
commit 69540cb4c4
2 changed files with 145 additions and 25 deletions
+57 -22
View File
@@ -242,8 +242,11 @@ nav button:hover {
.panel { .panel {
padding: 0; padding: 0;
overflow: visible;
}
.panel table {
overflow: hidden; overflow: hidden;
overflow-x: auto;
} }
table { table {
@@ -428,20 +431,6 @@ td {
} }
} }
.router-form select {
height: 40px;
border: 1px solid #dfe3e8;
border-radius: 8px;
padding: 0 12px;
background: #fbfbfc;
color: #111827;
}
.router-form select:focus {
outline: 2px solid rgba(93, 168, 255, 0.25);
border-color: #5da8ff;
}
.custom-select { .custom-select {
position: relative; position: relative;
} }
@@ -462,7 +451,7 @@ td {
.custom-select-menu { .custom-select-menu {
position: absolute; position: absolute;
z-index: 100; z-index: 9999;
top: calc(100% + 6px); top: calc(100% + 6px);
left: 0; left: 0;
right: 0; right: 0;
@@ -593,12 +582,6 @@ td {
background: #fef9c3; background: #fef9c3;
} }
.table-action:disabled,
.small-action:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.deployment-modal { .deployment-modal {
width: 720px; width: 720px;
max-width: calc(100vw - 48px); max-width: calc(100vw - 48px);
@@ -643,4 +626,56 @@ td {
line-height: 1.5; line-height: 1.5;
white-space: pre-wrap; white-space: pre-wrap;
font-family: "JetBrains Mono", Consolas, monospace; font-family: "JetBrains Mono", Consolas, monospace;
}
.table-toolbar {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 16px;
border-bottom: 1px solid #edf0f3;
background: white;
position: relative;
z-index: 20;
}
.search-box {
height: 40px;
min-width: 320px;
display: flex;
align-items: center;
gap: 9px;
background: #fbfbfc;
border: 1px solid #dfe3e8;
border-radius: 10px;
padding: 0 12px;
color: #64748b;
}
.search-box input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #111827;
font-weight: 700;
}
.toolbar-select {
width: 200px;
}
.toolbar-select .custom-select-button {
height: 40px;
}
.chevron {
color: #64748b;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevron.open {
transform: rotate(180deg);
} }
+88 -3
View File
@@ -1,5 +1,7 @@
import { useMemo, useState } from "react";
import type { RouterItem } from "../types/router"; import type { RouterItem } from "../types/router";
import { Download, Play, RotateCcw, Trash2 } from "lucide-react"; import { Download, Play, RotateCcw, Search, Trash2 } from "lucide-react";
import { ChevronDown } from "lucide-react";
type Props = { type Props = {
routers: RouterItem[]; routers: RouterItem[];
@@ -22,7 +24,33 @@ export function RoutersPage({
onRemove, onRemove,
onDownloadBundle, onDownloadBundle,
}: Props) { }: Props) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [statusOpen, setStatusOpen] = useState(false);
const filteredRouters = useMemo(() => {
return routers.filter((router) => {
const query = search.toLowerCase().trim();
const matchesSearch =
router.name.toLowerCase().includes(query) ||
router.serialNumber?.toLowerCase().includes(query) ||
router.lanIp.toLowerCase().includes(query) ||
router.lanSubnet.toLowerCase().includes(query) ||
router.vpnIp?.toLowerCase().includes(query);
const matchesStatus =
statusFilter === "ALL" || router.status === statusFilter;
return matchesSearch && matchesStatus;
});
}, [routers, search, statusFilter]);
const selectedStatusLabel =
statusFilter === "ALL"
? "All Statuses"
: statusFilter.charAt(0) + statusFilter.slice(1).toLowerCase();
return ( return (
<> <>
<div className="page-header"> <div className="page-header">
@@ -37,9 +65,64 @@ export function RoutersPage({
</div> </div>
<div className="panel"> <div className="panel">
<div className="table-toolbar">
<div className="search-box">
<Search size={16} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search routers..."
/>
</div>
<div className="custom-select toolbar-select">
<button
type="button"
className="custom-select-button"
onClick={() => setStatusOpen(!statusOpen)}
>
<span>{selectedStatusLabel}</span>
<ChevronDown
size={16}
className={statusOpen ? "chevron open" : "chevron"}
/>
</button>
{statusOpen && (
<div className="custom-select-menu">
{[
"ALL",
"PENDING",
"PROVISIONING",
"PROVISIONED",
"FAILED",
"REMOVING",
"REMOVED",
].map((status) => (
<button
key={status}
type="button"
className="custom-select-option"
onClick={() => {
setStatusFilter(status);
setStatusOpen(false);
}}
>
{status === "ALL"
? "All Statuses"
: status.charAt(0) +
status.slice(1).toLowerCase()}
</button>
))}
</div>
)}
</div>
</div>
{loading ? ( {loading ? (
<p className="panel-empty">Loading routers...</p> <p className="panel-empty">Loading routers...</p>
) : routers.length === 0 ? ( ) : filteredRouters.length === 0 ? (
<p className="panel-empty">No routers found</p> <p className="panel-empty">No routers found</p>
) : ( ) : (
<table> <table>
@@ -56,16 +139,18 @@ export function RoutersPage({
</thead> </thead>
<tbody> <tbody>
{routers.map((router) => { {filteredRouters.map((router) => {
const canProvision = const canProvision =
router.status === "PENDING" || router.status === "FAILED"; router.status === "PENDING" || router.status === "FAILED";
const isProvisioned = router.status === "PROVISIONED"; const isProvisioned = router.status === "PROVISIONED";
const isBusy = actionLoading === router.id; const isBusy = actionLoading === router.id;
const canDelete = const canDelete =
router.status === "PENDING" || router.status === "PENDING" ||
router.status === "FAILED" || router.status === "FAILED" ||
router.status === "REMOVED"; router.status === "REMOVED";
return ( return (
<tr key={router.id}> <tr key={router.id}>
<td>{router.name}</td> <td>{router.name}</td>