Skip to content

Commit 5cdac2c

Browse files
RI-Kaiclaude
andcommitted
feat(admin): dual-list page for managing server group members
Replaces the search-popup-driven modal that became unwieldy with many servers (capped at 10 search hits, no multi-select, single-server add/remove only). The new page shows two side-by-side lists — available servers and group members — with independent fuzzy search above each, multi-select via click / Shift-click range / Ctrl-Cmd-click toggle, a "select all visible" checkbox per pane, and four move buttons in the center (selected and all-visible in both directions). Changes are staged locally and committed in a single bulk PUT on Save; navigating away with unsaved changes triggers a confirmation dialog (useBlocker for in-app nav, beforeunload for tab close / reload). Backend: - ServerGroupService.SetMembers replaces the entire member list of a group atomically inside a single transaction. - New endpoint PUT /api/v1/admin/server-groups/:id/members with body { "serverIds": [...] }. Frontend: - New page src/pages/ServerGroupMembers.tsx at route /admin/server-groups/:id/members. Reachable from the Admin → Server Groups tab via the existing "X servers" link or the server icon in the actions column. Not added to the sidebar nav. - Modal in Admin.tsx ServerGroupsTab removed entirely along with its handlers and the per-server-add/remove API calls. The tab now only fetches the groups list itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4f16e7d commit 5cdac2c

6 files changed

Lines changed: 525 additions & 203 deletions

File tree

‎backend/internal/handlers/handlers.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ func New(
276276
admin.Put("/server-groups/:id", h.updateServerGroup)
277277
admin.Delete("/server-groups/:id", h.deleteServerGroup)
278278
admin.Get("/server-groups/:id/members", h.getServerGroupMembers)
279+
admin.Put("/server-groups/:id/members", h.setServerGroupMembers)
279280
admin.Post("/server-groups/:id/members", h.addServerGroupMember)
280281
admin.Delete("/server-groups/:id/members/:serverId", h.removeServerGroupMember)
281282

@@ -1239,6 +1240,33 @@ func (h *Handler) addServerGroupMember(c *fiber.Ctx) error {
12391240
})
12401241
}
12411242

1243+
func (h *Handler) setServerGroupMembers(c *fiber.Ctx) error {
1244+
groupID, err := strconv.ParseInt(c.Params("id"), 10, 64)
1245+
if err != nil {
1246+
return fiber.NewError(fiber.StatusBadRequest, "Invalid group ID")
1247+
}
1248+
1249+
var input struct {
1250+
ServerIDs []int64 `json:"serverIds"`
1251+
}
1252+
if err := c.BodyParser(&input); err != nil {
1253+
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
1254+
}
1255+
if input.ServerIDs == nil {
1256+
input.ServerIDs = []int64{}
1257+
}
1258+
1259+
ctx := c.Context()
1260+
if err := h.serverGroupService.SetMembers(ctx, groupID, input.ServerIDs); err != nil {
1261+
return err
1262+
}
1263+
1264+
return c.JSON(fiber.Map{
1265+
"message": "Group members updated",
1266+
"memberCount": len(input.ServerIDs),
1267+
})
1268+
}
1269+
12421270
func (h *Handler) removeServerGroupMember(c *fiber.Ctx) error {
12431271
groupID, err := strconv.ParseInt(c.Params("id"), 10, 64)
12441272
if err != nil {

‎backend/internal/services/server_group_service.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,31 @@ func (s *ServerGroupService) RemoveMember(ctx context.Context, groupID, serverID
207207
return err
208208
}
209209

210+
// SetMembers replaces the entire member list of a group atomically.
211+
// Pass an empty slice to remove all members.
212+
func (s *ServerGroupService) SetMembers(ctx context.Context, groupID int64, serverIDs []int64) error {
213+
tx, err := s.db.Begin(ctx)
214+
if err != nil {
215+
return err
216+
}
217+
defer tx.Rollback(ctx)
218+
219+
if _, err := tx.Exec(ctx, `DELETE FROM server_group_members WHERE group_id = $1`, groupID); err != nil {
220+
return err
221+
}
222+
223+
for _, serverID := range serverIDs {
224+
if _, err := tx.Exec(ctx,
225+
`INSERT INTO server_group_members (group_id, server_id) VALUES ($1, $2)`,
226+
groupID, serverID,
227+
); err != nil {
228+
return err
229+
}
230+
}
231+
232+
return tx.Commit(ctx)
233+
}
234+
210235
// SetServerGroups replaces all group memberships for a server
211236
func (s *ServerGroupService) SetServerGroups(ctx context.Context, serverID int64, groupIDs []int64) error {
212237
// Start transaction

‎frontend/src/App.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Reports from './pages/Reports';
1515
import Admin from './pages/Admin';
1616
import OVALDatabase from './pages/OVALDatabase';
1717
import Scans from './pages/Scans';
18+
import ServerGroupMembers from './pages/ServerGroupMembers';
1819

1920
function AdminRoute({ children }: { children: React.ReactNode }) {
2021
const { user, authEnabled } = useAuth();
@@ -50,6 +51,10 @@ export default function App() {
5051
<Route path="reports" element={<Reports />} />
5152
<Route path="oval-database" element={<OVALDatabase />} />
5253
<Route path="admin" element={<AdminRoute><Admin /></AdminRoute>} />
54+
<Route
55+
path="admin/server-groups/:id/members"
56+
element={<AdminRoute><ServerGroupMembers /></AdminRoute>}
57+
/>
5358
</Route>
5459
<Route path="*" element={<Navigate to="/" replace />} />
5560
</Routes>

‎frontend/src/api/client.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,13 @@ export const addServerGroupMember = (groupId: number, serverId: number) =>
441441
export const removeServerGroupMember = (groupId: number, serverId: number) =>
442442
fetchAPI<void>(`/admin/server-groups/${groupId}/members/${serverId}`, { method: 'DELETE' });
443443

444+
// Replace the entire member list of a group atomically.
445+
export const setServerGroupMembers = (groupId: number, serverIds: number[]) =>
446+
fetchAPI<{ message: string; memberCount: number }>(`/admin/server-groups/${groupId}/members`, {
447+
method: 'PUT',
448+
body: JSON.stringify({ serverIds }),
449+
});
450+
444451
// Server group membership
445452
export const getServerGroupsForServer = (serverId: number) =>
446453
fetchAPI<{ groups: import('../types').ServerGroup[] }>(`/servers/${serverId}/groups`);

‎frontend/src/pages/Admin.tsx‎

Lines changed: 10 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useState } from 'react';
2+
import { Link } from 'react-router-dom';
23
import { Settings, FileText, Server, Users, Save, Plus, Pencil, Trash2, X, Check, Search, Key, Shield, Database, RefreshCw, AlertCircle, CheckCircle2, Clock, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, ClipboardCheck, AlertTriangle, RotateCcw } from 'lucide-react';
34
import { ConfirmModal } from '../components/ConfirmModal';
45
import {
@@ -12,9 +13,6 @@ import {
1213
createServerGroup,
1314
updateServerGroup,
1415
deleteServerGroup,
15-
getServerGroupMembers,
16-
addServerGroupMember,
17-
removeServerGroupMember,
1816
getServers,
1917
deleteServer,
2018
// New imports for agent-based architecture
@@ -591,35 +589,24 @@ function ReasonTemplatesTab() {
591589
// Server Groups Tab
592590
function ServerGroupsTab() {
593591
const [groups, setGroups] = useState<ServerGroup[]>([]);
594-
const [allServers, setAllServers] = useState<ServerType[]>([]);
595592
const [loading, setLoading] = useState(true);
596593
const [error, setError] = useState<string | null>(null);
597594
const [showNewForm, setShowNewForm] = useState(false);
598595
const [newForm, setNewForm] = useState({ name: '', description: '', color: '#4ade80' });
599596
const [editingId, setEditingId] = useState<number | null>(null);
600597
const [editForm, setEditForm] = useState({ name: '', description: '', color: '#4ade80' });
601598
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; group: ServerGroup | null }>({ isOpen: false, group: null });
602-
603-
// Member management modal
604-
const [managingGroup, setManagingGroup] = useState<ServerGroup | null>(null);
605-
const [groupMembers, setGroupMembers] = useState<ServerType[]>([]);
606-
const [searchQuery, setSearchQuery] = useState('');
607-
const [loadingMembers, setLoadingMembers] = useState(false);
608599

609600
useEffect(() => {
610601
fetchData();
611602
}, []);
612603

613604
async function fetchData() {
614605
try {
615-
const [groupsData, serversData] = await Promise.all([
616-
getServerGroups(),
617-
getServers(),
618-
]);
606+
const groupsData = await getServerGroups();
619607
setGroups(groupsData.groups || []);
620-
setAllServers(serversData.servers || []);
621608
} catch (err) {
622-
setError(err instanceof Error ? err.message : 'Failed to load data');
609+
setError(err instanceof Error ? err.message : 'Failed to load groups');
623610
} finally {
624611
setLoading(false);
625612
}
@@ -671,58 +658,6 @@ function ServerGroupsTab() {
671658
});
672659
}
673660

674-
async function openMemberManagement(group: ServerGroup) {
675-
setManagingGroup(group);
676-
setSearchQuery('');
677-
setLoadingMembers(true);
678-
try {
679-
const data = await getServerGroupMembers(group.id);
680-
setGroupMembers(data.servers || []);
681-
} catch (err) {
682-
setError(err instanceof Error ? err.message : 'Failed to load group members');
683-
} finally {
684-
setLoadingMembers(false);
685-
}
686-
}
687-
688-
async function handleAddServer(serverId: number) {
689-
if (!managingGroup) return;
690-
try {
691-
await addServerGroupMember(managingGroup.id, serverId);
692-
// Refresh members
693-
const data = await getServerGroupMembers(managingGroup.id);
694-
setGroupMembers(data.servers || []);
695-
fetchData(); // Refresh group counts
696-
} catch (err) {
697-
setError(err instanceof Error ? err.message : 'Failed to add server to group');
698-
}
699-
}
700-
701-
async function handleRemoveServer(serverId: number) {
702-
if (!managingGroup) return;
703-
try {
704-
await removeServerGroupMember(managingGroup.id, serverId);
705-
setGroupMembers(groupMembers.filter(s => s.id !== serverId));
706-
fetchData(); // Refresh group counts
707-
} catch (err) {
708-
setError(err instanceof Error ? err.message : 'Failed to remove server from group');
709-
}
710-
}
711-
712-
// Fuzzy search filter for servers not already in the group
713-
const memberIds = new Set(groupMembers.map(s => s.id));
714-
const availableServers = allServers.filter(s => !memberIds.has(s.id));
715-
const filteredServers = searchQuery.trim()
716-
? availableServers.filter(s => {
717-
const query = searchQuery.toLowerCase();
718-
return (
719-
s.name.toLowerCase().includes(query) ||
720-
s.osFamily?.toLowerCase().includes(query) ||
721-
s.ipv4Addrs?.some(ip => ip.includes(query))
722-
);
723-
})
724-
: [];
725-
726661
if (loading) {
727662
return <div className="text-[#a5d6a7]">Loading server groups...</div>;
728663
}
@@ -861,21 +796,21 @@ function ServerGroupsTab() {
861796
<td className="py-3 px-4 text-[#e8f5e9] font-medium">{group.name}</td>
862797
<td className="py-3 px-4 text-[#a5d6a7]">{group.description || '-'}</td>
863798
<td className="py-3 px-4">
864-
<button
865-
onClick={() => openMemberManagement(group)}
799+
<Link
800+
to={`/admin/server-groups/${group.id}/members`}
866801
className="text-[#4ade80] hover:underline"
867802
>
868803
{group.serverCount ?? 0} servers
869-
</button>
804+
</Link>
870805
</td>
871806
<td className="py-3 px-4 text-right">
872-
<button
873-
onClick={() => openMemberManagement(group)}
874-
className="p-1 text-[#6b7280] hover:text-[#4ade80]"
807+
<Link
808+
to={`/admin/server-groups/${group.id}/members`}
809+
className="inline-block p-1 text-[#6b7280] hover:text-[#4ade80]"
875810
title="Manage members"
876811
>
877812
<Server className="w-4 h-4" />
878-
</button>
813+
</Link>
879814
<button onClick={() => startEdit(group)} className="p-1 text-[#6b7280] hover:text-[#4ade80] ml-1">
880815
<Pencil className="w-4 h-4" />
881816
</button>
@@ -899,134 +834,6 @@ function ServerGroupsTab() {
899834
</div>
900835
</div>
901836

902-
{/* Member Management Modal */}
903-
{managingGroup && (
904-
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
905-
<div className="bg-[#0a0f0d] border border-[#2d3f36] rounded-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
906-
{/* Modal Header */}
907-
<div className="flex items-center justify-between p-4 border-b border-[#2d3f36]">
908-
<div className="flex items-center gap-3">
909-
<div
910-
className="w-4 h-4 rounded"
911-
style={{ backgroundColor: managingGroup.color }}
912-
/>
913-
<h3 className="text-lg font-semibold text-[#e8f5e9]">
914-
Manage Members: {managingGroup.name}
915-
</h3>
916-
</div>
917-
<button
918-
onClick={() => setManagingGroup(null)}
919-
className="p-1 text-[#6b7280] hover:text-[#e8f5e9]"
920-
>
921-
<X className="w-5 h-5" />
922-
</button>
923-
</div>
924-
925-
{/* Modal Content */}
926-
<div className="flex-1 overflow-auto p-4 space-y-4">
927-
{/* Add Server Search */}
928-
<div>
929-
<label className="block text-sm font-medium text-[#a5d6a7] mb-2">
930-
Add Server
931-
</label>
932-
<div className="relative">
933-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#6b7280]" />
934-
<input
935-
type="text"
936-
value={searchQuery}
937-
onChange={(e) => setSearchQuery(e.target.value)}
938-
placeholder="Search servers by name, OS, or IP..."
939-
className="w-full pl-10 pr-4 py-2 bg-[#1a2420] border border-[#2d3f36] rounded-lg text-[#e8f5e9] placeholder-[#6b7280] focus:outline-none focus:border-[#4ade80]"
940-
/>
941-
</div>
942-
943-
{/* Search Results */}
944-
{searchQuery.trim() && (
945-
<div className="mt-2 border border-[#2d3f36] rounded-lg max-h-48 overflow-y-auto">
946-
{filteredServers.length > 0 ? (
947-
filteredServers.slice(0, 10).map((server) => (
948-
<button
949-
key={server.id}
950-
onClick={() => {
951-
handleAddServer(server.id);
952-
setSearchQuery('');
953-
}}
954-
className="w-full flex items-center justify-between px-4 py-2 hover:bg-[#1a2420] text-left border-b border-[#2d3f36] last:border-b-0"
955-
>
956-
<div>
957-
<div className="text-[#e8f5e9] font-medium">{server.name}</div>
958-
<div className="text-xs text-[#6b7280]">
959-
{server.osFamily} {server.osRelease} • {server.ipv4Addrs?.[0] || 'No IP'}
960-
</div>
961-
</div>
962-
<Plus className="w-4 h-4 text-[#4ade80]" />
963-
</button>
964-
))
965-
) : (
966-
<div className="px-4 py-3 text-[#6b7280] text-sm">
967-
No servers found matching "{searchQuery}"
968-
</div>
969-
)}
970-
{filteredServers.length > 10 && (
971-
<div className="px-4 py-2 text-[#6b7280] text-xs bg-[#1a2420]">
972-
Showing 10 of {filteredServers.length} results. Refine your search.
973-
</div>
974-
)}
975-
</div>
976-
)}
977-
</div>
978-
979-
{/* Current Members */}
980-
<div>
981-
<label className="block text-sm font-medium text-[#a5d6a7] mb-2">
982-
Current Members ({groupMembers.length})
983-
</label>
984-
{loadingMembers ? (
985-
<div className="text-[#6b7280]">Loading...</div>
986-
) : groupMembers.length > 0 ? (
987-
<div className="border border-[#2d3f36] rounded-lg divide-y divide-[#2d3f36]">
988-
{groupMembers.map((server) => (
989-
<div
990-
key={server.id}
991-
className="flex items-center justify-between px-4 py-2"
992-
>
993-
<div>
994-
<div className="text-[#e8f5e9] font-medium">{server.name}</div>
995-
<div className="text-xs text-[#6b7280]">
996-
{server.osFamily} {server.osRelease} • {server.ipv4Addrs?.[0] || 'No IP'}
997-
</div>
998-
</div>
999-
<button
1000-
onClick={() => handleRemoveServer(server.id)}
1001-
className="p-1 text-[#6b7280] hover:text-red-400"
1002-
title="Remove from group"
1003-
>
1004-
<X className="w-4 h-4" />
1005-
</button>
1006-
</div>
1007-
))}
1008-
</div>
1009-
) : (
1010-
<div className="text-[#6b7280] text-sm py-4 text-center border border-[#2d3f36] rounded-lg">
1011-
No servers in this group yet. Use the search above to add servers.
1012-
</div>
1013-
)}
1014-
</div>
1015-
</div>
1016-
1017-
{/* Modal Footer */}
1018-
<div className="p-4 border-t border-[#2d3f36]">
1019-
<button
1020-
onClick={() => setManagingGroup(null)}
1021-
className="btn btn-secondary w-full"
1022-
>
1023-
Close
1024-
</button>
1025-
</div>
1026-
</div>
1027-
</div>
1028-
)}
1029-
1030837
{/* Delete Confirmation Modal */}
1031838
<ConfirmModal
1032839
isOpen={deleteModal.isOpen}

0 commit comments

Comments
 (0)