feat: add security group membership with workload assignment, sg: prefix resolution in policy rules, and searchable select component
Add SecurityGroupMember model with security_group_id/workload_id foreign keys and unique constraint, implement security_group_members table with timestamps, add SecurityGroupMemberCreate/SecurityGroupMemberRead schemas with workload_name/workload_external_id fields, implement workload_provider_targets helper to expand sg: prefix into multiple workload targets with
This commit is contained in:
@@ -99,6 +99,15 @@ export type SecurityGroup = {
|
||||
project_id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
members: SecurityGroupMember[];
|
||||
};
|
||||
|
||||
export type SecurityGroupMember = {
|
||||
id: string;
|
||||
security_group_id: string;
|
||||
workload_id: string;
|
||||
workload_name: string | null;
|
||||
workload_external_id: string | null;
|
||||
};
|
||||
|
||||
export type SecurityRule = {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { inputClass } from "./FormControls";
|
||||
|
||||
export type SearchableOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type SearchableSelectProps = {
|
||||
options: SearchableOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function SearchableSelect({ options, value, onChange, placeholder = "Search..." }: SearchableSelectProps) {
|
||||
const selected = options.find((option) => option.value === value);
|
||||
const [query, setQuery] = useState(selected?.label ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized || selected?.label === query) {
|
||||
return options.slice(0, 12);
|
||||
}
|
||||
return options
|
||||
.filter((option) => `${option.label} ${option.detail ?? ""}`.toLowerCase().includes(normalized))
|
||||
.slice(0, 12);
|
||||
}, [options, query, selected?.label]);
|
||||
|
||||
function choose(option: SearchableOption) {
|
||||
onChange(option.value);
|
||||
setQuery(option.label);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={open ? query : selected?.label ?? query}
|
||||
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setQuery(selected?.label ?? "");
|
||||
setOpen(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{open ? (
|
||||
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-panel shadow-lg">
|
||||
{filtered.length ? filtered.map((option) => (
|
||||
<button
|
||||
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
key={option.value}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
choose(option);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="block font-medium">{option.label}</span>
|
||||
{option.detail ? <span className="block text-xs text-slate-500">{option.detail}</span> : null}
|
||||
</button>
|
||||
)) : <div className="px-3 py-2 text-sm text-slate-500">No matches.</div>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Save, Wand2 } from "lucide-react";
|
||||
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
|
||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { SearchableSelect } from "../components/SearchableSelect";
|
||||
|
||||
type TargetOption = {
|
||||
label: string;
|
||||
@@ -47,7 +48,11 @@ export function PolicyDesigner() {
|
||||
{ label: "Any", value: "any" },
|
||||
{ label: "Custom IP/CIDR", value: customTargetValue },
|
||||
...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })),
|
||||
...(securityGroups.data ?? []).map((group) => ({ label: `Security Group: ${group.name}`, value: `sg:${group.name}` })),
|
||||
...(securityGroups.data ?? []).map((group) => ({
|
||||
label: `Security Group: ${group.name}`,
|
||||
value: `sg:${group.id}`,
|
||||
detail: `${group.members?.length ?? 0} member${group.members?.length === 1 ? "" : "s"}`,
|
||||
})),
|
||||
...(networks.data ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })),
|
||||
];
|
||||
}, [networks.data, securityGroups.data, workloads.data]);
|
||||
@@ -126,13 +131,12 @@ export function PolicyDesigner() {
|
||||
</Field>
|
||||
<div />
|
||||
<Field label="Source">
|
||||
<select
|
||||
className={selectClass}
|
||||
<SearchableSelect
|
||||
options={targets}
|
||||
value={endpointSelectValue(form.source, targets)}
|
||||
onChange={(event) => setForm({ ...form, source: event.target.value === customTargetValue ? "" : event.target.value })}
|
||||
>
|
||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
||||
</select>
|
||||
onChange={(value) => setForm({ ...form, source: value === customTargetValue ? "" : value })}
|
||||
placeholder="Search source..."
|
||||
/>
|
||||
{isCustomEndpoint(form.source, targets) ? (
|
||||
<input
|
||||
className={`${inputClass} mt-2`}
|
||||
@@ -144,13 +148,12 @@ export function PolicyDesigner() {
|
||||
) : null}
|
||||
</Field>
|
||||
<Field label="Destination">
|
||||
<select
|
||||
className={selectClass}
|
||||
<SearchableSelect
|
||||
options={targets}
|
||||
value={endpointSelectValue(form.destination, targets)}
|
||||
onChange={(event) => setForm({ ...form, destination: event.target.value === customTargetValue ? "" : event.target.value })}
|
||||
>
|
||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
||||
</select>
|
||||
onChange={(value) => setForm({ ...form, destination: value === customTargetValue ? "" : value })}
|
||||
placeholder="Search destination..."
|
||||
/>
|
||||
{isCustomEndpoint(form.destination, targets) ? (
|
||||
<input
|
||||
className={`${inputClass} mt-2`}
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Shield } from "lucide-react";
|
||||
import { Plus, Shield, Trash2, UserPlus } from "lucide-react";
|
||||
|
||||
import { api, Project, SecurityGroup, SecurityRule } from "../api/client";
|
||||
import { api, Project, SecurityGroup, SecurityGroupMember, SecurityRule, Workload } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
||||
import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { SearchableSelect, SearchableOption } from "../components/SearchableSelect";
|
||||
|
||||
export function SecurityGroups() {
|
||||
const queryClient = useQueryClient();
|
||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
||||
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
|
||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]);
|
||||
const selectedGroupRecord = useMemo(() => (groups.data ?? []).find((group) => group.id === selectedGroup), [groups.data, selectedGroup]);
|
||||
const memberOptions = useMemo<SearchableOption[]>(() => {
|
||||
const existing = new Set((selectedGroupRecord?.members ?? []).map((member) => member.workload_id));
|
||||
return (workloads.data ?? [])
|
||||
.filter((workload) => !existing.has(workload.id))
|
||||
.map((workload) => ({
|
||||
label: workload.name,
|
||||
value: workload.id,
|
||||
detail: `${workload.kind} · VMID ${workload.external_id} · ${workload.status}`,
|
||||
}));
|
||||
}, [selectedGroupRecord?.members, workloads.data]);
|
||||
const rules = useQuery({
|
||||
queryKey: ["security-rules", selectedGroup],
|
||||
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
|
||||
@@ -33,6 +46,8 @@ export function SecurityGroups() {
|
||||
});
|
||||
const [groupOpen, setGroupOpen] = useState(false);
|
||||
const [ruleOpen, setRuleOpen] = useState(false);
|
||||
const [memberOpen, setMemberOpen] = useState(false);
|
||||
const [memberWorkloadId, setMemberWorkloadId] = useState("");
|
||||
|
||||
const createGroup = useMutation({
|
||||
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
|
||||
@@ -48,6 +63,18 @@ export function SecurityGroups() {
|
||||
queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] });
|
||||
},
|
||||
});
|
||||
const addMember = useMutation({
|
||||
mutationFn: () => api<SecurityGroupMember>(`/security-groups/${selectedGroup}/members`, { method: "POST", body: JSON.stringify({ workload_id: memberWorkloadId }) }),
|
||||
onSuccess: () => {
|
||||
setMemberOpen(false);
|
||||
setMemberWorkloadId("");
|
||||
queryClient.invalidateQueries({ queryKey: ["security-groups"] });
|
||||
},
|
||||
});
|
||||
const removeMember = useMutation({
|
||||
mutationFn: (member: SecurityGroupMember) => api<{ status: string }>(`/security-groups/${member.security_group_id}/members/${member.id}`, { method: "DELETE" }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-groups"] }),
|
||||
});
|
||||
|
||||
async function submitGroup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -67,6 +94,7 @@ export function SecurityGroups() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
|
||||
<button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button>
|
||||
<button className={secondaryButtonClass} onClick={() => setMemberOpen(true)} disabled={!selectedGroup || !memberOptions.length}><UserPlus size={16} /> Add Member</button>
|
||||
</div>
|
||||
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
|
||||
<form onSubmit={submitGroup}>
|
||||
@@ -84,6 +112,26 @@ export function SecurityGroups() {
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal title="Add Group Member" open={memberOpen} onClose={() => setMemberOpen(false)}>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await addMember.mutateAsync();
|
||||
}}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><UserPlus size={18} /> Add Member</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-md border border-border bg-canvas p-3 text-sm">
|
||||
<div className="text-xs text-slate-500">Security Group</div>
|
||||
<div className="mt-1 font-medium">{selectedGroupRecord?.name ?? "No group selected"}</div>
|
||||
</div>
|
||||
<Field label="VM/LXC">
|
||||
<SearchableSelect options={memberOptions} value={memberWorkloadId} onChange={setMemberWorkloadId} placeholder="Search VM/LXC..." />
|
||||
</Field>
|
||||
<button className={buttonClass} disabled={!memberWorkloadId || addMember.isPending}><Plus size={16} /> Add Member</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
|
||||
<form onSubmit={submitRule}>
|
||||
<div className="mb-4 font-medium">Add Rule</div>
|
||||
@@ -108,7 +156,66 @@ export function SecurityGroups() {
|
||||
</form>
|
||||
</Modal>
|
||||
<section className="space-y-4">
|
||||
<DataTable rows={(groups.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} />
|
||||
<DataTable
|
||||
rows={(groups.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
selectedId={selectedGroup}
|
||||
onRowClick={(row) => setSelectedGroupId(String(row.id))}
|
||||
columns={[
|
||||
{ key: "name", label: "Group" },
|
||||
{ key: "description", label: "Description" },
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
render: (row) => {
|
||||
const group = row as unknown as SecurityGroup;
|
||||
const members = group.members ?? [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.slice(0, 5).map((member) => (
|
||||
<span key={member.id} className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-slate-500">
|
||||
{member.workload_name ?? member.workload_id}
|
||||
<button
|
||||
className="text-slate-400 hover:text-danger"
|
||||
disabled={removeMember.isPending}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeMember.mutate(member);
|
||||
}}
|
||||
title="Remove member"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{members.length > 5 ? <span className="rounded-md border border-border px-2 py-1 text-xs text-slate-500">+{members.length - 5}</span> : null}
|
||||
{!members.length ? <span className="text-xs text-slate-500">No members</span> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
render: (row) => (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className={iconButtonClass}
|
||||
title="Add member"
|
||||
aria-label="Add member"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedGroupId(String(row.id));
|
||||
setMemberOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user