feat(ui): SkillHubDashboard + policy impact components to shadcn

- SkillHubDashboard: antd Input + @ant-design/icons SearchOutlined +
  antd Select + @tremor Text \u2192 shadcn Input (with lucide Search
  prefix) + shadcn Select + semantic span. Domain filter uses
  '__all__' sentinel value.
- policies/impact_preview_alert: antd Alert + Tag + Typography \u2192
  plain semantic colored div (amber/blue warn/info palettes) +
  lucide AlertTriangle/Info + shadcn Badge outline for inline chips.
  File added to overrides.
- policies/impact_popover: @tremor Icon + @heroicons EyeIcon + antd
  Popover/Tooltip/Spin/Tag \u2192 shadcn Popover/Tooltip/Badge + lucide
  Eye/Loader2. Lazy-loads impact estimate on first open (unchanged
  behaviour).

Gates: TS \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 11:00:40 +00:00
parent 6b42172e99
commit 6d182db494
No known key found for this signature in database
4 changed files with 251 additions and 116 deletions

View file

@ -53,6 +53,8 @@
"src/components/permissions/VectorStorePermissions.tsx",
"src/components/permissions/AgentPermissions.tsx",
"src/components/permissions/MCPServerPermissions.tsx",
"src/components/policies/impact_preview_alert.tsx",
"src/components/policies/impact_popover.tsx",
"src/components/DeletedKeysPage/DeletedKeysPage.tsx",
"src/components/DeletedTeamsPage/DeletedTeamsPage.tsx"
],

View file

@ -1,7 +1,13 @@
import React, { useMemo, useState } from "react";
import { Text } from "@tremor/react";
import { SearchOutlined } from "@ant-design/icons";
import { Input, Select } from "antd";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search } from "lucide-react";
import { Plugin } from "@/components/claude_code_plugins/types";
import { ModelDataTable } from "@/components/model_dashboard/table";
import { skillHubColumns } from "@/components/skill_hub_table_columns";
@ -16,6 +22,8 @@ interface SkillHubDashboardProps {
onPublishSuccess?: () => void;
}
const ALL_DOMAINS = "__all__";
const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
skills,
isLoading,
@ -25,19 +33,25 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
onPublishSuccess,
}) => {
const [search, setSearch] = useState("");
const [domainFilter, setDomainFilter] = useState<string | undefined>(undefined);
const [domainFilter, setDomainFilter] = useState<string | undefined>(
undefined,
);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
// Derived stats
const totalSkills = skills.length;
const domains = useMemo(() => [...new Set(skills.map((s) => s.domain).filter(Boolean))], [skills]);
const namespaces = useMemo(() => [...new Set(skills.map((s) => s.namespace).filter(Boolean))], [skills]);
const domains = useMemo(
() => [...new Set(skills.map((s) => s.domain).filter(Boolean))],
[skills],
);
const namespaces = useMemo(
() => [...new Set(skills.map((s) => s.namespace).filter(Boolean))],
[skills],
);
// Filtered table data
const filteredSkills = useMemo(() => {
let result = skills;
if (domainFilter) {
@ -51,7 +65,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
s.description?.toLowerCase().includes(q) ||
s.domain?.toLowerCase().includes(q) ||
s.namespace?.toLowerCase().includes(q) ||
s.keywords?.some((k) => k.toLowerCase().includes(q))
s.keywords?.some((k) => k.toLowerCase().includes(q)),
);
}
return result;
@ -70,66 +84,86 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
}
if (isLoading) {
return <div className="text-center py-16 text-gray-400">Loading skills...</div>;
return (
<div className="text-center py-16 text-muted-foreground">
Loading skills...
</div>
);
}
return (
<div className="space-y-6">
{/* Stats row */}
<div className="grid grid-cols-3 gap-4">
<div className="border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Total Skills</div>
<div className="text-2xl font-semibold text-gray-900">{totalSkills}</div>
<div className="border border-border rounded-lg p-4">
<div className="text-xs text-muted-foreground mb-1">Total Skills</div>
<div className="text-2xl font-semibold text-foreground">
{totalSkills}
</div>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Namespaces</div>
<div className="text-2xl font-semibold text-gray-900">{namespaces.length}</div>
<div className="border border-border rounded-lg p-4">
<div className="text-xs text-muted-foreground mb-1">Namespaces</div>
<div className="text-2xl font-semibold text-foreground">
{namespaces.length}
</div>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<div className="text-xs text-gray-500 mb-1">Domains</div>
<div className="text-2xl font-semibold text-gray-900">{domains.length}</div>
<div className="border border-border rounded-lg p-4">
<div className="text-xs text-muted-foreground mb-1">Domains</div>
<div className="text-2xl font-semibold text-foreground">
{domains.length}
</div>
</div>
</div>
{/* Search + filters + table */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">
<h3 className="text-sm font-semibold text-foreground">
All {publicPage ? "Public " : ""}Skills
</h3>
<div className="flex items-center gap-2">
<Select
placeholder="All Domains"
allowClear
value={domainFilter}
onChange={(val) => setDomainFilter(val)}
style={{ width: 160 }}
options={domains.map((d) => ({ label: d, value: d }))}
/>
<Input
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Search by name, namespace, or tag…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 280 }}
allowClear
/>
value={domainFilter ?? ALL_DOMAINS}
onValueChange={(v) =>
setDomainFilter(v === ALL_DOMAINS ? undefined : v)
}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Domains" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_DOMAINS}>All Domains</SelectItem>
{domains.map((d) => (
<SelectItem key={d!} value={d!}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative w-72">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by name, namespace, or tag…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-9"
/>
</div>
</div>
</div>
<ModelDataTable
columns={skillHubColumns(
(skill) => setSelectedSkill(skill),
copyToClipboard,
publicPage
publicPage,
)}
data={filteredSkills}
isLoading={false}
defaultSorting={[{ id: "name", desc: false }]}
/>
<div className="mt-3 text-center">
<Text className="text-sm text-gray-500">
Showing {filteredSkills.length} of {totalSkills} skill{totalSkills !== 1 ? "s" : ""}
</Text>
<span className="text-sm text-muted-foreground">
Showing {filteredSkills.length} of {totalSkills} skill
{totalSkills !== 1 ? "s" : ""}
</span>
</div>
</div>
</div>

View file

@ -1,15 +1,32 @@
import React, { useState } from "react";
import { Icon } from "@tremor/react";
import { EyeIcon } from "@heroicons/react/outline";
import { Tooltip, Tag, Popover, Spin } from "antd";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Eye, Loader2 } from "lucide-react";
import { PolicyAttachment } from "./types";
import { estimateAttachmentImpactCall } from "../networking";
const ImpactPopover: React.FC<{ attachment: PolicyAttachment; accessToken: string | null }> = ({
attachment,
accessToken,
}) => {
const [impact, setImpact] = useState<any>(null);
interface ImpactInfo {
affected_keys_count: number;
affected_teams_count: number;
sample_keys: string[];
sample_teams: string[];
}
const ImpactPopover: React.FC<{
attachment: PolicyAttachment;
accessToken: string | null;
}> = ({ attachment, accessToken }) => {
const [impact, setImpact] = useState<ImpactInfo | null>(null);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
@ -34,49 +51,89 @@ const ImpactPopover: React.FC<{ attachment: PolicyAttachment; accessToken: strin
}
};
const content = loading ? (
<div className="p-2 text-center"><Spin size="small" /> Loading...</div>
) : impact ? (
<div className="text-xs" style={{ maxWidth: 280 }}>
{impact.affected_keys_count === -1 ? (
<p className="font-medium text-amber-600">Global scope — affects all keys and teams</p>
) : (
<>
<p className="mb-1">
<strong>{impact.affected_keys_count}</strong> key{impact.affected_keys_count !== 1 ? "s" : ""},{" "}
<strong>{impact.affected_teams_count}</strong> team{impact.affected_teams_count !== 1 ? "s" : ""} affected
</p>
{impact.sample_keys.length > 0 && (
<div className="mb-1">
<span className="text-gray-500">Keys: </span>
{impact.sample_keys.map((k: string) => (
<Tag key={k} style={{ fontSize: 10, margin: 1 }}>{k}</Tag>
))}
</div>
)}
{impact.sample_teams.length > 0 && (
<div>
<span className="text-gray-500">Teams: </span>
{impact.sample_teams.map((t: string) => (
<Tag key={t} style={{ fontSize: 10, margin: 1 }}>{t}</Tag>
))}
</div>
)}
{impact.affected_keys_count === 0 && impact.affected_teams_count === 0 && (
<p className="text-gray-400">No keys or teams currently affected</p>
)}
</>
)}
</div>
) : (
<p className="text-xs text-gray-400">Click to load</p>
);
return (
<Popover content={content} title="Blast Radius" trigger="click" onOpenChange={(open) => { if (open) loadImpact(); }}>
<Tooltip title="View blast radius">
<Icon icon={EyeIcon} size="sm" className="cursor-pointer hover:text-blue-500" />
</Tooltip>
<Popover
onOpenChange={(open) => {
if (open) loadImpact();
}}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center cursor-pointer text-muted-foreground hover:text-primary"
aria-label="View blast radius"
>
<Eye className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>View blast radius</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="max-w-[320px]">
<div className="font-semibold text-sm mb-2">Blast Radius</div>
{loading ? (
<div className="p-2 text-center flex items-center justify-center gap-2 text-sm">
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
</div>
) : impact ? (
<div className="text-xs">
{impact.affected_keys_count === -1 ? (
<p className="font-medium text-amber-600 dark:text-amber-400">
Global scope — affects all keys and teams
</p>
) : (
<>
<p className="mb-1">
<strong>{impact.affected_keys_count}</strong> key
{impact.affected_keys_count !== 1 ? "s" : ""},{" "}
<strong>{impact.affected_teams_count}</strong> team
{impact.affected_teams_count !== 1 ? "s" : ""} affected
</p>
{impact.sample_keys.length > 0 && (
<div className="mb-1">
<span className="text-muted-foreground">Keys: </span>
{impact.sample_keys.map((k: string) => (
<Badge
key={k}
variant="outline"
className="text-[10px] m-0.5"
>
{k}
</Badge>
))}
</div>
)}
{impact.sample_teams.length > 0 && (
<div>
<span className="text-muted-foreground">Teams: </span>
{impact.sample_teams.map((t: string) => (
<Badge
key={t}
variant="outline"
className="text-[10px] m-0.5"
>
{t}
</Badge>
))}
</div>
)}
{impact.affected_keys_count === 0 &&
impact.affected_teams_count === 0 && (
<p className="text-muted-foreground">
No keys or teams currently affected
</p>
)}
</>
)}
</div>
) : (
<p className="text-xs text-muted-foreground">Click to load</p>
)}
</PopoverContent>
</Popover>
);
};

View file

@ -1,7 +1,7 @@
import React from "react";
import { Alert, Tag, Typography } from "antd";
const { Text } = Typography;
import { Badge } from "@/components/ui/badge";
import { AlertTriangle, Info } from "lucide-react";
import { cn } from "@/lib/utils";
interface ImpactResult {
affected_keys_count: number;
@ -14,47 +14,89 @@ interface ImpactPreviewAlertProps {
impactResult: ImpactResult;
}
const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult }) => {
const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({
impactResult,
}) => {
const isWarning = impactResult.affected_keys_count === -1;
return (
<Alert
type={impactResult.affected_keys_count === -1 ? "warning" : "info"}
showIcon
className="mb-4"
message="Impact Preview"
description={
impactResult.affected_keys_count === -1 ? (
<Text>Global scope — this will affect <strong>all keys and teams</strong>.</Text>
<div
className={cn(
"mb-4 rounded-md border p-3 flex gap-2 items-start",
isWarning
? "bg-amber-50 border-amber-200 text-amber-900 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-200"
: "bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950/30 dark:border-blue-900 dark:text-blue-200",
)}
>
{isWarning ? (
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
) : (
<Info className="h-4 w-4 mt-0.5 shrink-0" />
)}
<div className="flex-1">
<div className="font-semibold">Impact Preview</div>
{isWarning ? (
<div className="text-sm mt-1">
Global scope — this will affect{" "}
<strong>all keys and teams</strong>.
</div>
) : (
<div>
<Text>
This attachment would affect <strong>{impactResult.affected_keys_count} key{impactResult.affected_keys_count !== 1 ? "s" : ""}</strong> and <strong>{impactResult.affected_teams_count} team{impactResult.affected_teams_count !== 1 ? "s" : ""}</strong>.
</Text>
<div className="text-sm mt-1">
<div>
This attachment would affect{" "}
<strong>
{impactResult.affected_keys_count} key
{impactResult.affected_keys_count !== 1 ? "s" : ""}
</strong>{" "}
and{" "}
<strong>
{impactResult.affected_teams_count} team
{impactResult.affected_teams_count !== 1 ? "s" : ""}
</strong>
.
</div>
{impactResult.sample_keys.length > 0 && (
<div className="mt-1">
<Text type="secondary" style={{ fontSize: 12 }}>Keys: </Text>
<span className="text-xs text-muted-foreground">Keys: </span>
{impactResult.sample_keys.slice(0, 5).map((k: string) => (
<Tag key={k} style={{ fontSize: 11 }}>{k}</Tag>
<Badge
key={k}
variant="outline"
className="text-[11px] mr-1"
>
{k}
</Badge>
))}
{impactResult.affected_keys_count > 5 && (
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_keys_count - 5} more...</Text>
<span className="text-[11px] text-muted-foreground">
and {impactResult.affected_keys_count - 5} more...
</span>
)}
</div>
)}
{impactResult.sample_teams.length > 0 && (
<div className="mt-1">
<Text type="secondary" style={{ fontSize: 12 }}>Teams: </Text>
<span className="text-xs text-muted-foreground">Teams: </span>
{impactResult.sample_teams.slice(0, 5).map((t: string) => (
<Tag key={t} style={{ fontSize: 11 }}>{t}</Tag>
<Badge
key={t}
variant="outline"
className="text-[11px] mr-1"
>
{t}
</Badge>
))}
{impactResult.affected_teams_count > 5 && (
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_teams_count - 5} more...</Text>
<span className="text-[11px] text-muted-foreground">
and {impactResult.affected_teams_count - 5} more...
</span>
)}
</div>
)}
</div>
)
}
/>
)}
</div>
</div>
);
};