mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Merge pull request #1882 from openclaw/pe/plugin-management-tools
Add plugin curation to management
This commit is contained in:
@@ -1112,9 +1112,18 @@ export const getByNameForStaff = query({
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
||||
const owner = toPublicPublisher(
|
||||
await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
package: pkg,
|
||||
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
|
||||
owner,
|
||||
highlighted: highlighted
|
||||
? {
|
||||
byUserId: highlighted.byUserId,
|
||||
|
||||
+151
-35
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
isSkillHighlighted,
|
||||
isSkillOfficial,
|
||||
} from "../lib/badges";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PublicPublisher } from "../lib/publicUser";
|
||||
import { isAdmin, isModerator } from "../lib/roles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
|
||||
@@ -75,6 +77,13 @@ type SkillBySlugResult = {
|
||||
} | null;
|
||||
} | null;
|
||||
|
||||
type PluginByNameResult = {
|
||||
package: Doc<"packages">;
|
||||
latestRelease: Doc<"packageReleases"> | null;
|
||||
owner: PublicPublisher | null;
|
||||
highlighted: { byUserId: Id<"users">; at: number } | null;
|
||||
} | null;
|
||||
|
||||
function resolveOwnerParam(
|
||||
handle: string | null | undefined,
|
||||
ownerId?: Id<"users"> | Id<"publishers">,
|
||||
@@ -99,6 +108,7 @@ function promptUnbanReason(label: string) {
|
||||
export const Route = createFileRoute("/management")({
|
||||
validateSearch: (search) => ({
|
||||
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
|
||||
plugin: typeof search.plugin === "string" && search.plugin.trim() ? search.plugin : undefined,
|
||||
}),
|
||||
component: Management,
|
||||
});
|
||||
@@ -106,14 +116,20 @@ export const Route = createFileRoute("/management")({
|
||||
function Management() {
|
||||
const { me } = useAuthStatus();
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const staff = isModerator(me);
|
||||
const admin = isAdmin(me);
|
||||
|
||||
const selectedSlug = search.skill?.trim();
|
||||
const selectedPluginName = search.plugin?.trim();
|
||||
const selectedSkill = useQuery(
|
||||
api.skills.getBySlugForStaff,
|
||||
staff && selectedSlug ? { slug: selectedSlug, auditLogLimit: SKILL_AUDIT_LOG_LIMIT } : "skip",
|
||||
) as SkillBySlugResult | undefined;
|
||||
const selectedPlugin = useQuery(
|
||||
api.packages.getByNameForStaff,
|
||||
staff && selectedPluginName ? { name: selectedPluginName } : "skip",
|
||||
) as PluginByNameResult | undefined;
|
||||
const selectedSkillId = selectedSkill?.skill?._id ?? null;
|
||||
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : "skip") as
|
||||
| RecentVersionEntry[]
|
||||
@@ -130,6 +146,7 @@ function Management() {
|
||||
const banUser = useMutation(api.users.banUser);
|
||||
const unbanUser = useMutation(api.users.unbanUser);
|
||||
const setBatch = useMutation(api.skills.setBatch);
|
||||
const setPackageBatch = useMutation(api.packages.setBatch);
|
||||
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
|
||||
const hardDelete = useMutation(api.skills.hardDelete);
|
||||
const changeOwner = useMutation(api.skills.changeOwner);
|
||||
@@ -145,6 +162,7 @@ function Management() {
|
||||
const [reportSearchDebounced, setReportSearchDebounced] = useState("");
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [userSearchDebounced, setUserSearchDebounced] = useState("");
|
||||
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
|
||||
const [skillOverrideNote, setSkillOverrideNote] = useState("");
|
||||
|
||||
const userQuery = userSearchDebounced.trim();
|
||||
@@ -166,6 +184,10 @@ function Management() {
|
||||
setSkillOverrideNote("");
|
||||
}, [selectedSkillId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPluginSearch(selectedPluginName ?? "");
|
||||
}, [selectedPluginName]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250);
|
||||
return () => clearTimeout(handle);
|
||||
@@ -257,15 +279,22 @@ function Management() {
|
||||
.catch((error) => window.alert(formatMutationError(error)));
|
||||
};
|
||||
|
||||
const managePlugin = () => {
|
||||
const name = pluginSearch.trim();
|
||||
if (!name) return;
|
||||
void navigate({
|
||||
to: "/management",
|
||||
search: { skill: undefined, plugin: name },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Management console</h1>
|
||||
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Reported skills
|
||||
</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
@@ -318,9 +347,7 @@ function Management() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="section-subtitle m-0">
|
||||
No report reasons yet.
|
||||
</div>
|
||||
<div className="section-subtitle m-0">No report reasons yet.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
@@ -365,9 +392,7 @@ function Management() {
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Skill tools
|
||||
</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Skill tools</h2>
|
||||
{selectedSlug ? (
|
||||
<div className="section-subtitle mt-2">
|
||||
Managing "{selectedSlug}" ·{" "}
|
||||
@@ -417,16 +442,12 @@ function Management() {
|
||||
{skill.moderationFlags?.length ? (
|
||||
<div className="management-tags">
|
||||
{skill.moderationFlags.map((flag: string) => (
|
||||
<Badge key={flag}>
|
||||
{flag}
|
||||
</Badge>
|
||||
<Badge key={flag}>{flag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="management-sublist">
|
||||
<div className="section-subtitle m-0">
|
||||
Manual overrides
|
||||
</div>
|
||||
<div className="section-subtitle m-0">Manual overrides</div>
|
||||
<section className="management-override-panel">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Current override</span>
|
||||
@@ -478,18 +499,14 @@ function Management() {
|
||||
</section>
|
||||
</div>
|
||||
<div className="management-sublist">
|
||||
<div className="section-subtitle m-0">
|
||||
Recent audit activity
|
||||
</div>
|
||||
<div className="section-subtitle m-0">Recent audit activity</div>
|
||||
<section className="management-override-panel management-audit-panel">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Window</span>
|
||||
<span>Last {SKILL_AUDIT_LOG_LIMIT} entries for this skill.</span>
|
||||
</div>
|
||||
{auditLogs.length === 0 ? (
|
||||
<div className="section-subtitle m-0">
|
||||
No audit activity yet.
|
||||
</div>
|
||||
<div className="section-subtitle m-0">No audit activity yet.</div>
|
||||
) : (
|
||||
<div className="management-audit-list">
|
||||
{auditLogs.map((entry) => {
|
||||
@@ -590,10 +607,7 @@ function Management() {
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Button asChild className="management-action-btn">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerParam, slug: skill.slug }}
|
||||
>
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -693,9 +707,115 @@ function Management() {
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Duplicate candidates
|
||||
</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Plugin tools</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Package</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="@scope/plugin-name or package-name"
|
||||
value={pluginSearch}
|
||||
onChange={(event) => setPluginSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
managePlugin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={managePlugin} disabled={!pluginSearch.trim()}>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
{selectedPluginName ? (
|
||||
<div className="section-subtitle mt-2">
|
||||
Managing "{selectedPluginName}" ·{" "}
|
||||
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
|
||||
Clear selection
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="management-list">
|
||||
{!selectedPluginName ? (
|
||||
<div className="stat">Enter a plugin package name to open tooling here.</div>
|
||||
) : selectedPlugin === undefined ? (
|
||||
<div className="stat">Loading plugin…</div>
|
||||
) : !selectedPlugin?.package ? (
|
||||
<div className="stat">No plugin found for "{selectedPluginName}".</div>
|
||||
) : (
|
||||
(() => {
|
||||
const plugin = selectedPlugin.package;
|
||||
const owner = selectedPlugin.owner;
|
||||
const latestRelease = selectedPlugin.latestRelease;
|
||||
const isHighlighted = Boolean(selectedPlugin.highlighted);
|
||||
|
||||
return (
|
||||
<div key={plugin._id} className="management-item management-item-detail">
|
||||
<div className="management-item-main">
|
||||
<Link to="/plugins/$name" params={{ name: plugin.name }}>
|
||||
{plugin.displayName}
|
||||
</Link>
|
||||
<div className="section-subtitle m-0">
|
||||
{owner?.handle ? `@${owner.handle}` : "unknown owner"} ·{" "}
|
||||
{familyLabel(plugin.family)} · v{latestRelease?.version ?? "—"} · updated{" "}
|
||||
{formatTimestamp(plugin.updatedAt)}
|
||||
{plugin.softDeletedAt ? " · hidden" : ""}
|
||||
{isHighlighted ? " · highlighted" : ""}
|
||||
</div>
|
||||
<div className="management-tags">
|
||||
<Badge>{plugin.channel}</Badge>
|
||||
{plugin.isOfficial ? <Badge>official</Badge> : null}
|
||||
{plugin.executesCode ? <Badge>executes code</Badge> : null}
|
||||
{plugin.runtimeId ? <Badge>{plugin.runtimeId}</Badge> : null}
|
||||
</div>
|
||||
<div className="management-sublist">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Package name</span>
|
||||
<span className="mono">{plugin.name}</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Summary</span>
|
||||
<span>{plugin.summary ?? "No summary provided."}</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Featured state</span>
|
||||
<span>
|
||||
{isHighlighted
|
||||
? `Highlighted ${formatTimestamp(selectedPlugin.highlighted?.at ?? 0)}`
|
||||
: "Not highlighted"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Button asChild className="management-action-btn">
|
||||
<Link to="/plugins/$name" params={{ name: plugin.name }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void setPackageBatch({
|
||||
packageId: plugin._id,
|
||||
batch: isHighlighted ? undefined : "highlighted",
|
||||
}).catch((error) => window.alert(formatMutationError(error)))
|
||||
}
|
||||
>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">Duplicate candidates</h2>
|
||||
<div className="management-list">
|
||||
{duplicateCandidates.length === 0 ? (
|
||||
<div className="stat">No duplicate candidates.</div>
|
||||
@@ -784,9 +904,7 @@ function Management() {
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Recent pushes
|
||||
</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Recent pushes</h2>
|
||||
<div className="management-list">
|
||||
{recentVersions.length === 0 ? (
|
||||
<div className="stat">No recent versions.</div>
|
||||
@@ -832,9 +950,7 @@ function Management() {
|
||||
|
||||
{admin ? (
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Users
|
||||
</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Users</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type PackageVersionDetail,
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
import { isModerator } from "../../lib/roles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
type PluginDetailRateLimitState = {
|
||||
@@ -185,14 +184,8 @@ function PluginDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
const { detail, version, readme, rateLimited } = Route.useLoaderData() as PluginDetailLoaderData;
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const setPackageBatch = useMutation(api.packages.setBatch);
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
const requestPluginRescan = useMutation(api.packages.requestRescan);
|
||||
const staffPackage = useQuery(
|
||||
api.packages.getByNameForStaff,
|
||||
staff && detail.package ? { name: detail.package.name } : "skip",
|
||||
) as { package: { _id: Id<"packages"> }; highlighted: { at: number } | null } | null | undefined;
|
||||
const rescanState = useQuery(
|
||||
api.packages.getOwnerRescanStateByName,
|
||||
isAuthenticated && detail.package ? { name: detail.package.name } : "skip",
|
||||
@@ -249,21 +242,6 @@ function PluginDetailRoute() {
|
||||
: pkg.family === "bundle-plugin"
|
||||
? `openclaw bundles install clawhub:${pkg.name}`
|
||||
: `openclaw skills install ${pkg.name}`;
|
||||
const isHighlighted = Boolean(staffPackage?.highlighted);
|
||||
|
||||
const toggleHighlighted = async () => {
|
||||
const packageId = staffPackage?.package._id;
|
||||
if (!packageId) return;
|
||||
try {
|
||||
await setPackageBatch({
|
||||
packageId,
|
||||
batch: isHighlighted ? undefined : "highlighted",
|
||||
});
|
||||
toast.success(isHighlighted ? "Plugin unhighlighted." : "Plugin highlighted.");
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "Could not update plugin highlight."));
|
||||
}
|
||||
};
|
||||
|
||||
const capabilities = latestRelease?.capabilities ?? pkg.capabilities;
|
||||
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
|
||||
@@ -326,13 +304,6 @@ function PluginDetailRoute() {
|
||||
<Badge variant="destructive">Download blocked</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
{staffPackage ? (
|
||||
<div className="skill-title-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => void toggleHighlighted()}>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="section-subtitle">{pkg.summary ?? "No summary provided."}</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user