mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
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:
parent
6b42172e99
commit
6d182db494
4 changed files with 251 additions and 116 deletions
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue