Skip to content

Commit 95ba869

Browse files
committed
feat: implement User Groups page with detailed views
1 parent ee41004 commit 95ba869

File tree

9 files changed

+703
-4
lines changed

9 files changed

+703
-4
lines changed

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import KuviewBackground from "./backgrounds/kuview";
99
import Debug from "./pages/debug";
1010
import { PREFIX } from "./lib/const";
1111
import { AppSidebar } from "./components/app-sidebar";
12+
import UserGroupsPage from "./pages/usergroups";
1213

1314
export default function Page() {
1415
return (
@@ -23,6 +24,7 @@ export default function Page() {
2324
<Route path={`${PREFIX}/pods`} component={Pod} />
2425
<Route path={`${PREFIX}/namespaces`} component={Namespace} />
2526
<Route path={`${PREFIX}/services`} component={Service} />
27+
<Route path={`${PREFIX}/usergroups`} component={UserGroupsPage} />
2628
<Route path={`${PREFIX}/debug`} component={Debug} />
2729
</Switch>
2830
</div>

src/backgrounds/userGroup.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,28 +104,28 @@ function getStatus(roles: (RoleObject | ClusterRoleObject)[]): Condition {
104104
if (roles.length === 0) {
105105
return {
106106
status: Status.Error,
107-
reason: "Having no roles",
107+
reason: "has no roles",
108108
};
109109
}
110110

111111
// Error: all roles have no rules
112112
if (roles.every((role) => (role.rules ?? []).length === 0)) {
113113
return {
114114
status: Status.Error,
115-
reason: `Have ${roles.length} roles, but all of them have no rules`,
115+
reason: `has ${roles.length} roles, but all of them have no rules`,
116116
};
117117
}
118118

119119
// Warn: if has ClusterRole named cluster-admin
120120
if (roles.some((role) => role.metadata.name === "cluster-admin")) {
121121
return {
122122
status: Status.Warning,
123-
reason: "Having cluster-admin role",
123+
reason: "has cluster-admin role, which is too powerful",
124124
};
125125
}
126126

127127
return {
128128
status: Status.Running,
129-
reason: `Have ${roles.length} roles`,
129+
reason: `has ${roles.length} roles`,
130130
};
131131
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { Badge } from "@/components/ui/badge";
2+
import { useMemo, useState } from "react";
3+
import type { ReactNode } from "react";
4+
import { ChevronUp, ChevronDown } from "lucide-react";
5+
import {
6+
Table,
7+
TableBody,
8+
TableCell,
9+
TableHead,
10+
TableHeader,
11+
TableRow,
12+
} from "@/components/ui/table";
13+
14+
// Common Rules Table Component
15+
export interface RulesTableProps {
16+
rules: Array<{
17+
verbs: string[];
18+
resources?: string[];
19+
apiGroups?: string[];
20+
}>;
21+
showRoleInfo?: boolean;
22+
roleTypes?: string[];
23+
roleNamespaces?: (string | undefined)[];
24+
}
25+
26+
export function RulesTable({
27+
rules,
28+
showRoleInfo = false,
29+
roleTypes = [],
30+
roleNamespaces = [],
31+
}: RulesTableProps) {
32+
type SortField = "scope" | "apiGroups" | "resources" | "verbs";
33+
type SortDirection = "ascending" | "descending";
34+
35+
interface SortState {
36+
key: SortField;
37+
direction: SortDirection;
38+
}
39+
40+
const [sortConfig, setSortConfig] = useState<SortState>({
41+
key: "verbs",
42+
direction: "descending",
43+
});
44+
45+
const verbOrder: { [key: string]: number } = {
46+
delete: 5,
47+
create: 4,
48+
list: 3,
49+
get: 2,
50+
watch: 1,
51+
};
52+
53+
const handleSort = (key: SortField) => {
54+
setSortConfig((prev) => ({
55+
key,
56+
direction:
57+
prev.key === key && prev.direction === "descending"
58+
? "ascending"
59+
: "descending",
60+
}));
61+
};
62+
63+
const sortedData = useMemo(() => {
64+
const sortableItems = rules.map((rule, index) => ({
65+
...rule,
66+
roleType: showRoleInfo ? roleTypes[index] : undefined,
67+
roleNamespace: showRoleInfo ? roleNamespaces[index] : undefined,
68+
}));
69+
70+
sortableItems.sort((a, b) => {
71+
let aValue: string | number;
72+
let bValue: string | number;
73+
74+
switch (sortConfig.key) {
75+
case "scope": {
76+
const aIsClusterRole = a.roleType === "ClusterRole";
77+
const bIsClusterRole = b.roleType === "ClusterRole";
78+
if (aIsClusterRole !== bIsClusterRole) {
79+
aValue = aIsClusterRole ? 1 : 0;
80+
bValue = bIsClusterRole ? 1 : 0;
81+
} else {
82+
aValue = a.roleNamespace || "default";
83+
bValue = b.roleNamespace || "default";
84+
}
85+
break;
86+
}
87+
case "apiGroups":
88+
aValue = (a.apiGroups || []).sort().join(", ");
89+
bValue = (b.apiGroups || []).sort().join(", ");
90+
break;
91+
case "resources":
92+
aValue = (a.resources || []).sort().join(", ");
93+
bValue = (b.resources || []).sort().join(", ");
94+
break;
95+
case "verbs":
96+
aValue = Math.max(0, ...a.verbs.map((v) => verbOrder[v] || 0));
97+
bValue = Math.max(0, ...b.verbs.map((v) => verbOrder[v] || 0));
98+
break;
99+
default:
100+
aValue = 0;
101+
bValue = 0;
102+
}
103+
104+
if (typeof aValue === "string" && typeof bValue === "string") {
105+
return sortConfig.direction === "ascending"
106+
? aValue.localeCompare(bValue)
107+
: bValue.localeCompare(aValue);
108+
} else {
109+
const numA = Number(aValue);
110+
const numB = Number(bValue);
111+
return sortConfig.direction === "ascending" ? numA - numB : numB - numA;
112+
}
113+
});
114+
115+
return sortableItems.map((rule) => ({
116+
...rule,
117+
apiGroups: [...(rule.apiGroups || [])].sort(),
118+
resources: [...(rule.resources || [])].sort(),
119+
verbs: [...rule.verbs].sort(
120+
(a, b) => (verbOrder[b] || 0) - (verbOrder[a] || 0),
121+
),
122+
}));
123+
}, [rules, roleTypes, roleNamespaces, showRoleInfo, sortConfig]);
124+
125+
return (
126+
<div className="overflow-x-auto">
127+
<Table>
128+
<TableHeader>
129+
<TableRow>
130+
{showRoleInfo && (
131+
<SortableHeader
132+
field="scope"
133+
currentSort={sortConfig}
134+
onSort={handleSort}
135+
>
136+
Scope
137+
</SortableHeader>
138+
)}
139+
<SortableHeader
140+
field="apiGroups"
141+
currentSort={sortConfig}
142+
onSort={handleSort}
143+
>
144+
API Groups
145+
</SortableHeader>
146+
<SortableHeader
147+
field="resources"
148+
currentSort={sortConfig}
149+
onSort={handleSort}
150+
>
151+
Resources
152+
</SortableHeader>
153+
<SortableHeader
154+
field="verbs"
155+
currentSort={sortConfig}
156+
onSort={handleSort}
157+
>
158+
Verbs
159+
</SortableHeader>
160+
</TableRow>
161+
</TableHeader>
162+
<TableBody>
163+
{sortedData.map((rule, ruleIndex) => (
164+
<TableRow key={ruleIndex} className="border-b last:border-b-0">
165+
{showRoleInfo && (
166+
<TableCell className="p-2 align-top">
167+
{rule.roleType === "Role" && (
168+
<Badge variant="outline" className="text-xs">
169+
{rule.roleNamespace || "default"}
170+
</Badge>
171+
)}
172+
{rule.roleType === "ClusterRole" && (
173+
<p className="m-auto">*</p>
174+
)}
175+
</TableCell>
176+
)}
177+
<TableCell className="p-2 align-top">
178+
<div className="flex flex-wrap gap-1">
179+
{rule.apiGroups?.map((apiGroup, apiIndex) => (
180+
<Badge key={apiIndex} variant="outline" className="text-xs">
181+
{apiGroup || "core"}
182+
</Badge>
183+
))}
184+
</div>
185+
</TableCell>
186+
<TableCell className="p-2 align-top">
187+
<div className="flex flex-wrap gap-1">
188+
{rule.resources?.map((resource, resIndex) => (
189+
<Badge key={resIndex} variant="outline" className="text-xs">
190+
{resource}
191+
</Badge>
192+
))}
193+
</div>
194+
</TableCell>
195+
196+
<TableCell className="p-2 align-top">
197+
<div className="flex flex-wrap gap-1">
198+
{rule.verbs.map((verb, verbIndex) => (
199+
<Badge
200+
key={verbIndex}
201+
variant="outline"
202+
className="text-xs"
203+
>
204+
{verb}
205+
</Badge>
206+
))}
207+
</div>
208+
</TableCell>
209+
</TableRow>
210+
))}
211+
</TableBody>
212+
</Table>
213+
</div>
214+
);
215+
}
216+
217+
function SortableHeader({
218+
children,
219+
field,
220+
currentSort,
221+
onSort,
222+
className = "",
223+
}: {
224+
children: ReactNode;
225+
field: "scope" | "apiGroups" | "resources" | "verbs";
226+
currentSort: { key: string; direction: string };
227+
onSort: (field: "scope" | "apiGroups" | "resources" | "verbs") => void;
228+
className?: string;
229+
}) {
230+
const isActive = currentSort.key === field;
231+
232+
return (
233+
<TableHead
234+
className={`cursor-pointer hover:bg-muted/50 select-none ${className}`}
235+
onClick={() => onSort(field)}
236+
>
237+
<div className="flex items-center gap-1 p-2">
238+
{children}
239+
<div className="flex flex-col">
240+
<ChevronUp
241+
className={`w-3 h-3 transition-colors ${
242+
isActive && currentSort.direction === "ascending"
243+
? "text-primary"
244+
: "text-muted-foreground/30"
245+
}`}
246+
/>
247+
<ChevronDown
248+
className={`w-3 h-3 -mt-1 transition-colors ${
249+
isActive && currentSort.direction === "descending"
250+
? "text-primary"
251+
: "text-muted-foreground/30"
252+
}`}
253+
/>
254+
</div>
255+
</div>
256+
</TableHead>
257+
);
258+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
2+
import type { UserGroupObject } from "@/lib/kuview";
3+
import { RulesTable } from "./rules-table";
4+
5+
interface UserGroupAllRulesProps {
6+
userGroup: UserGroupObject;
7+
}
8+
9+
export default function UserGroupAllRules({
10+
userGroup,
11+
}: UserGroupAllRulesProps) {
12+
// Flatten all rules from all roles for the "All Rules" section
13+
const allRules: Array<{
14+
verbs: string[];
15+
resources?: string[];
16+
apiGroups?: string[];
17+
}> = [];
18+
const allRuleTypes: string[] = [];
19+
const allRuleNamespaces: (string | undefined)[] = [];
20+
21+
userGroup.spec.roles.forEach((role) => {
22+
if (role.rules) {
23+
role.rules.forEach((rule) => {
24+
allRules.push(rule);
25+
allRuleTypes.push(role.kind);
26+
allRuleNamespaces.push(role.metadata.namespace);
27+
});
28+
}
29+
});
30+
31+
return (
32+
<Card>
33+
<CardHeader>
34+
<CardTitle>All Rules ({allRules.length})</CardTitle>
35+
</CardHeader>
36+
<CardContent>
37+
{allRules.length > 0 ? (
38+
<RulesTable
39+
rules={allRules}
40+
showRoleInfo={true}
41+
roleTypes={allRuleTypes}
42+
roleNamespaces={allRuleNamespaces}
43+
/>
44+
) : (
45+
<p className="text-sm text-muted-foreground">No rules found</p>
46+
)}
47+
</CardContent>
48+
</Card>
49+
);
50+
}

0 commit comments

Comments
 (0)