mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
Dedupe abuse signal alerts and add bulk review (#3214)
* fix: dedupe abuse alerts and add bulk review * fix: use white bulk selection checks
This commit is contained in:
@@ -367,6 +367,18 @@ const dismissPublisherAbuseSignalHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const reviewPublisherAbuseSignalsBatchHandler = (
|
||||
publisherAbuse.reviewPublisherAbuseSignalsBatch as unknown as Wrapped<
|
||||
{
|
||||
signalIds: string[];
|
||||
status: "snoozed" | "dismissed";
|
||||
note?: string;
|
||||
days?: number;
|
||||
},
|
||||
{ ok: true; status: "snoozed" | "dismissed"; updated: number }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const reopenPublisherAbuseSignalHandler = (
|
||||
publisherAbuse.reopenPublisherAbuseSignal as unknown as Wrapped<
|
||||
{ signalId: string; note?: string },
|
||||
@@ -1076,6 +1088,123 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.any(Symbol), {});
|
||||
});
|
||||
|
||||
it("lets moderators snooze a batch of signals with one audited transition per signal", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const signals = new Map(
|
||||
["first", "second"].map((suffix, index) => [
|
||||
`publisherAbuseSignals:${suffix}`,
|
||||
{
|
||||
_id: `publisherAbuseSignals:${suffix}`,
|
||||
signalType: "sustained_downloads_flat_installs",
|
||||
ownerKey: `publisher:publishers:${suffix}`,
|
||||
ownerPublisherId: `publishers:${suffix}`,
|
||||
ownerUserId: `users:${suffix}`,
|
||||
handleSnapshot: suffix,
|
||||
skillId: `skills:${suffix}`,
|
||||
skillSlug: suffix,
|
||||
skillDisplayName: suffix,
|
||||
firstSeenAt: 10,
|
||||
lastSeenAt: 20,
|
||||
seenCount: index + 1,
|
||||
recent7Downloads: 1_000,
|
||||
recent7Installs: 0,
|
||||
recent7InstallDownloadRatio: 0,
|
||||
recent30Downloads: 5_000,
|
||||
recent30Installs: 0,
|
||||
recent30InstallDownloadRatio: 0,
|
||||
allTimeDownloads: 10_000 + index,
|
||||
allTimeInstalls: 0,
|
||||
allTimeInstallDownloadRatio: 0,
|
||||
reviewStatus: "open",
|
||||
lastChangedAt: 20,
|
||||
needsNotification: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const patch = vi.fn(async () => null);
|
||||
const insert = vi.fn(async () => "publisherAbuseSignalReviewEvents:event");
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
|
||||
await expect(
|
||||
reviewPublisherAbuseSignalsBatchHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => signals.get(id) ?? null),
|
||||
patch,
|
||||
insert,
|
||||
},
|
||||
},
|
||||
{
|
||||
signalIds: ["publisherAbuseSignals:first", "publisherAbuseSignals:second"],
|
||||
status: "snoozed",
|
||||
note: "Likely crawler traffic",
|
||||
days: 30,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ ok: true, status: "snoozed", updated: 2 });
|
||||
|
||||
expect(patch).toHaveBeenCalledTimes(2);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseSignals:first",
|
||||
expect.objectContaining({
|
||||
reviewStatus: "snoozed",
|
||||
reviewNote: "Likely crawler traffic",
|
||||
snoozedUntil: now + 30 * 24 * 60 * 60 * 1_000,
|
||||
evidenceBaselineDownloads: 10_000,
|
||||
needsNotification: false,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledTimes(2);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherAbuseSignalReviewEvents",
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:moderator",
|
||||
eventType: "snoozed",
|
||||
previousStatus: "open",
|
||||
nextStatus: "snoozed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a bulk review atomically when a selected signal is no longer open", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const signals = new Map([
|
||||
["publisherAbuseSignals:open", { _id: "publisherAbuseSignals:open", reviewStatus: "open" }],
|
||||
[
|
||||
"publisherAbuseSignals:reviewed",
|
||||
{ _id: "publisherAbuseSignals:reviewed", reviewStatus: "snoozed" },
|
||||
],
|
||||
]);
|
||||
const patch = vi.fn(async () => null);
|
||||
const insert = vi.fn(async () => "publisherAbuseSignalReviewEvents:event");
|
||||
|
||||
await expect(
|
||||
reviewPublisherAbuseSignalsBatchHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => signals.get(id) ?? null),
|
||||
patch,
|
||||
insert,
|
||||
},
|
||||
},
|
||||
{
|
||||
signalIds: ["publisherAbuseSignals:open", "publisherAbuseSignals:reviewed"],
|
||||
status: "dismissed",
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("One or more selected signals are no longer open; refresh and try again");
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reopen or notify already-open publisher abuse signals", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
@@ -10010,6 +10139,8 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
allTimeDownloads: 10_000,
|
||||
allTimeInstalls: 1_000,
|
||||
allTimeInstallDownloadRatio: 0.1,
|
||||
lastChangedAt: 20,
|
||||
needsNotification: true,
|
||||
};
|
||||
const signalLookups: Array<Record<string, unknown>> = [];
|
||||
const insertedSignals: unknown[] = [];
|
||||
@@ -10115,7 +10246,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
).resolves.toEqual({
|
||||
archivedCandidates: 3,
|
||||
archivedSignals: 2,
|
||||
changedSignals: 2,
|
||||
changedSignals: 1,
|
||||
});
|
||||
|
||||
expect(signalLookups).toEqual([
|
||||
@@ -10139,7 +10270,9 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
recent7InstallDownloadRatio: 0.12,
|
||||
lastSeenAt: 1_234,
|
||||
seenCount: 6,
|
||||
lastChangedAt: 1_234,
|
||||
notificationBaselineDownloads: 10_000,
|
||||
notificationBaselineInstalls: 1_000,
|
||||
lastChangedAt: 20,
|
||||
needsNotification: true,
|
||||
}),
|
||||
);
|
||||
@@ -10153,10 +10286,37 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
lastSeenAt: 1_234,
|
||||
seenCount: 1,
|
||||
reviewStatus: "open",
|
||||
notificationBaselineDownloads: 10_000,
|
||||
notificationBaselineInstalls: 0,
|
||||
lastChangedAt: 1_234,
|
||||
needsNotification: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
patch.mockClear();
|
||||
highRatio.totalDownloads = 10_500;
|
||||
highRatio.totalInstalls = 1_050;
|
||||
|
||||
await expect(
|
||||
archiveTemporalPublisherAbuseSignalsPageHandler(ctx, {
|
||||
runId: "publisherAbuseScoreRuns:temporal",
|
||||
candidates: [highRatio],
|
||||
now: 2_345,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
archivedCandidates: 1,
|
||||
archivedSignals: 1,
|
||||
changedSignals: 1,
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseSignals:existing-ratio",
|
||||
expect.objectContaining({
|
||||
notificationBaselineDownloads: 10_500,
|
||||
notificationBaselineInstalls: 1_050,
|
||||
lastChangedAt: 2_345,
|
||||
needsNotification: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps acknowledged evidence quiet and reopens only for fresh post-snooze activity", async () => {
|
||||
|
||||
@@ -76,6 +76,7 @@ const RECURRING_SUSTAINED_SIGNAL_MAX_INSTALLS = 5;
|
||||
const RECURRING_RATIO_SIGNAL_MIN_DOWNLOADS = 500;
|
||||
const RECURRING_RATIO_SIGNAL_MIN_INSTALLS = 50;
|
||||
const RECURRING_RATIO_SIGNAL_MIN_RATIO = 0.1;
|
||||
const MAX_PUBLISHER_ABUSE_SIGNAL_BATCH_SIZE = 50;
|
||||
const MAX_PUBLISHER_ABUSE_SIGNAL_REVIEW_NOTE_LENGTH = 1000;
|
||||
const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_BATCH_SIZE = 10;
|
||||
const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_MAX_BATCH_SIZE = 25;
|
||||
@@ -656,6 +657,60 @@ export const dismissPublisherAbuseSignal = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const reviewPublisherAbuseSignalsBatch = mutation({
|
||||
args: {
|
||||
signalIds: v.array(v.id("publisherAbuseSignals")),
|
||||
status: v.union(v.literal("snoozed"), v.literal("dismissed")),
|
||||
note: v.optional(v.string()),
|
||||
days: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
const signalIds = [...new Set(args.signalIds)];
|
||||
if (signalIds.length === 0) throw new Error("Select at least one publisher abuse signal");
|
||||
if (signalIds.length > MAX_PUBLISHER_ABUSE_SIGNAL_BATCH_SIZE) {
|
||||
throw new Error(
|
||||
`Review at most ${MAX_PUBLISHER_ABUSE_SIGNAL_BATCH_SIZE} publisher abuse signals at once`,
|
||||
);
|
||||
}
|
||||
|
||||
const signals: PublisherAbuseSignalDoc[] = [];
|
||||
for (const signalId of signalIds) {
|
||||
const signal = await ctx.db.get(signalId);
|
||||
if (!signal) throw new Error("Publisher abuse signal not found");
|
||||
signals.push(signal);
|
||||
}
|
||||
if (signals.some((signal) => publisherAbuseSignalReviewStatus(signal) !== "open")) {
|
||||
throw new Error("One or more selected signals are no longer open; refresh and try again");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const note = normalizePublisherAbuseSignalReviewNote(args.note);
|
||||
const snoozedUntil =
|
||||
args.status === "snoozed"
|
||||
? now +
|
||||
clampInt(args.days ?? DEFAULT_PUBLISHER_ABUSE_SIGNAL_SNOOZE_DAYS, 1, 90) *
|
||||
24 *
|
||||
60 *
|
||||
60 *
|
||||
1000
|
||||
: undefined;
|
||||
for (const signal of signals) {
|
||||
await setPublisherAbuseSignalReviewStatusWithActor(ctx, {
|
||||
signal,
|
||||
status: args.status,
|
||||
actorUserId: user._id,
|
||||
note,
|
||||
snoozedUntil,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true, status: args.status, updated: signals.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const reopenPublisherAbuseSignal = mutation({
|
||||
args: {
|
||||
signalId: v.id("publisherAbuseSignals"),
|
||||
@@ -844,6 +899,12 @@ async function setPublisherAbuseSignalReviewStatusWithActor(
|
||||
freshInstallsSinceSnooze: args.status === "snoozed" ? 0 : undefined,
|
||||
snoozeCount:
|
||||
args.status === "snoozed" ? (args.signal.snoozeCount ?? 0) + 1 : args.signal.snoozeCount,
|
||||
notificationBaselineDownloads: args.notify
|
||||
? args.signal.allTimeDownloads
|
||||
: args.signal.notificationBaselineDownloads,
|
||||
notificationBaselineInstalls: args.notify
|
||||
? args.signal.allTimeInstalls
|
||||
: args.signal.notificationBaselineInstalls,
|
||||
needsNotification: args.notify === true,
|
||||
lastChangedAt: args.notify ? args.now : args.signal.lastChangedAt,
|
||||
notificationClaimedAt: undefined,
|
||||
@@ -3527,8 +3588,18 @@ async function upsertPublisherAbuseSignal(
|
||||
downloads: freshDownloadsSinceSnooze,
|
||||
installs: freshInstallsSinceSnooze,
|
||||
});
|
||||
const notificationBaselineDownloads =
|
||||
signal.notificationBaselineDownloads ?? signal.allTimeDownloads;
|
||||
const notificationBaselineInstalls =
|
||||
signal.notificationBaselineInstalls ?? signal.allTimeInstalls;
|
||||
const materiallyStrongerEvidence =
|
||||
previousStatus === "open" &&
|
||||
freshEvidenceCrossesRepeatThreshold(args.signalType, {
|
||||
downloads: Math.max(0, snapshot.allTimeDownloads - notificationBaselineDownloads),
|
||||
installs: Math.max(0, snapshot.allTimeInstalls - notificationBaselineInstalls),
|
||||
});
|
||||
const nextStatus = recurringAfterSnooze ? "open" : previousStatus;
|
||||
const shouldNotify = nextStatus === "open";
|
||||
const shouldNotify = recurringAfterSnooze || materiallyStrongerEvidence;
|
||||
await ctx.db.patch(signal._id, {
|
||||
...snapshot,
|
||||
reviewStatus: nextStatus,
|
||||
@@ -3548,6 +3619,12 @@ async function upsertPublisherAbuseSignal(
|
||||
recurrenceCount: recurringAfterSnooze
|
||||
? (signal.recurrenceCount ?? 0) + 1
|
||||
: signal.recurrenceCount,
|
||||
notificationBaselineDownloads: shouldNotify
|
||||
? snapshot.allTimeDownloads
|
||||
: notificationBaselineDownloads,
|
||||
notificationBaselineInstalls: shouldNotify
|
||||
? snapshot.allTimeInstalls
|
||||
: notificationBaselineInstalls,
|
||||
lastSeenAt: args.now,
|
||||
seenCount: signal.seenCount + 1,
|
||||
lastChangedAt: shouldNotify ? args.now : signal.lastChangedAt,
|
||||
@@ -3564,6 +3641,8 @@ async function upsertPublisherAbuseSignal(
|
||||
lastSeenAt: args.now,
|
||||
seenCount: 1,
|
||||
reviewStatus: "open",
|
||||
notificationBaselineDownloads: snapshot.allTimeDownloads,
|
||||
notificationBaselineInstalls: snapshot.allTimeInstalls,
|
||||
lastChangedAt: args.now,
|
||||
needsNotification: true,
|
||||
});
|
||||
|
||||
@@ -3105,6 +3105,8 @@ const publisherAbuseSignals = defineTable({
|
||||
freshInstallsSinceSnooze: v.optional(v.number()),
|
||||
snoozeCount: v.optional(v.number()),
|
||||
recurrenceCount: v.optional(v.number()),
|
||||
notificationBaselineDownloads: v.optional(v.number()),
|
||||
notificationBaselineInstalls: v.optional(v.number()),
|
||||
reviewedByUserId: v.optional(v.id("users")),
|
||||
reviewedAt: v.optional(v.number()),
|
||||
reviewNote: v.optional(v.string()),
|
||||
|
||||
@@ -120,11 +120,18 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
the lower repeat threshold: at least 1,500 downloads with at most 5 installs
|
||||
for flat-install volume, or at least 500 downloads and 50 installs at a 10%
|
||||
install/download ratio. Reopened repeat signals are elevated to high severity.
|
||||
Staff may snooze or dismiss up to 50 selected open signals in one atomic
|
||||
action. Bulk review must apply the same evidence checkpoint and write the same
|
||||
per-signal review event as the corresponding single-signal action.
|
||||
- Hermit owns Discord notification delivery for publisher abuse Signals.
|
||||
ClawHub queues Hermit digests only for changed open signals: newly archived
|
||||
signals, repeated open signals with a higher seen count, manual reopens, and
|
||||
expired snoozes with qualifying fresh evidence. Active snoozed or dismissed
|
||||
signals must update their metric snapshot without notifying Hermit.
|
||||
signals, manual reopens, expired snoozes with qualifying fresh evidence, and
|
||||
open signals whose evidence has materially increased since the previous
|
||||
notification. A higher seen count alone is not a change. Material increases
|
||||
use the same lower repeat thresholds as post-snooze recurrence, and the
|
||||
notification checkpoint advances only when a notification is queued so
|
||||
smaller changes accumulate across scans. Active snoozed or dismissed signals
|
||||
must update their metric snapshot without notifying Hermit.
|
||||
- Aggregate publisher spam-abuse labels start at the 200-skill pivot. Below
|
||||
that pivot, publishers can contribute to the population baseline, but they
|
||||
cannot receive aggregate spam reason codes or be nominated by this score path.
|
||||
|
||||
@@ -715,6 +715,129 @@ describe("Management", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bulk snoozes and dismisses selected open publisher abuse signals", async () => {
|
||||
searchState = { view: "abuse", tab: "signals" };
|
||||
const reviewSignalsBatch = vi.fn(async () => ({ ok: true, status: "snoozed", updated: 2 }));
|
||||
const firstSignal = makePublisherAbuseSignal();
|
||||
const secondSignal = makePublisherAbuseSignal({
|
||||
_id: "publisherAbuseSignals:sustained",
|
||||
signalType: "sustained_downloads_flat_installs",
|
||||
skillId: "skills:sustained",
|
||||
skillSlug: "sustained-skill",
|
||||
skillDisplayName: "Sustained Skill",
|
||||
});
|
||||
useMutationMock.mockImplementation((mutation) => {
|
||||
if (getFunctionName(mutation) === "publisherAbuse:reviewPublisherAbuseSignalsBatch") {
|
||||
return reviewSignalsBatch;
|
||||
}
|
||||
return vi.fn(async () => ({ ok: true }));
|
||||
});
|
||||
useQueryMock.mockImplementation((query, args) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = getFunctionName(query);
|
||||
if (name === "skills:listRecentVersions") return [];
|
||||
if (name === "skills:listReportedSkills") return [];
|
||||
if (name === "skills:listDuplicateCandidates") return [];
|
||||
if (name === "publisherAbuse:listReviewDashboard") {
|
||||
return {
|
||||
latestRun: null,
|
||||
pendingItems: [],
|
||||
pendingPotentialBanCandidateItems: [],
|
||||
pendingReviewItems: [],
|
||||
recentResolvedItems: [],
|
||||
signalCount: 2,
|
||||
signalCountHasMore: false,
|
||||
};
|
||||
}
|
||||
if (name === "users:list") return { items: [], total: 0 };
|
||||
return undefined;
|
||||
});
|
||||
usePaginatedQueryMock.mockImplementation((query, args) => ({
|
||||
results:
|
||||
getFunctionName(query) === "publisherAbuse:listSignalsPage" && args !== "skip"
|
||||
? [firstSignal, secondSignal]
|
||||
: [],
|
||||
status: args === "skip" ? "LoadingFirstPage" : "Exhausted",
|
||||
loadMore: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<Management />);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Select Ratio Skill" }));
|
||||
expect(screen.getByText("1 selected")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Snooze 1 signal" }));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Snooze 1 signal" }).at(-1)!);
|
||||
await waitFor(() => {
|
||||
expect(reviewSignalsBatch).toHaveBeenCalledWith({
|
||||
signalIds: ["publisherAbuseSignals:ratio"],
|
||||
status: "snoozed",
|
||||
note: undefined,
|
||||
days: 14,
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Select Sustained Skill" }));
|
||||
expect(screen.getByText("2 selected")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss 2 signals" }));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Dismiss 2 signals" }).at(-1)!);
|
||||
await waitFor(() => {
|
||||
expect(reviewSignalsBatch).toHaveBeenCalledWith({
|
||||
signalIds: ["publisherAbuseSignals:ratio", "publisherAbuseSignals:sustained"],
|
||||
status: "dismissed",
|
||||
note: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("caps bulk signal selection at the backend batch limit", () => {
|
||||
searchState = { view: "abuse", tab: "signals" };
|
||||
const signalResults = Array.from({ length: 51 }, (_, index) =>
|
||||
makePublisherAbuseSignal({
|
||||
_id: `publisherAbuseSignals:bulk-${index}`,
|
||||
skillId: `skills:bulk-${index}`,
|
||||
skillSlug: `bulk-${index}`,
|
||||
skillDisplayName: `Bulk Skill ${index}`,
|
||||
}),
|
||||
);
|
||||
useQueryMock.mockImplementation((query, args) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = getFunctionName(query);
|
||||
if (name === "skills:listRecentVersions") return [];
|
||||
if (name === "skills:listReportedSkills") return [];
|
||||
if (name === "skills:listDuplicateCandidates") return [];
|
||||
if (name === "publisherAbuse:listReviewDashboard") {
|
||||
return {
|
||||
latestRun: null,
|
||||
pendingItems: [],
|
||||
pendingPotentialBanCandidateItems: [],
|
||||
pendingReviewItems: [],
|
||||
recentResolvedItems: [],
|
||||
signalCount: signalResults.length,
|
||||
signalCountHasMore: false,
|
||||
};
|
||||
}
|
||||
if (name === "users:list") return { items: [], total: 0 };
|
||||
return undefined;
|
||||
});
|
||||
usePaginatedQueryMock.mockImplementation((query, args) => ({
|
||||
results:
|
||||
getFunctionName(query) === "publisherAbuse:listSignalsPage" && args !== "skip"
|
||||
? signalResults
|
||||
: [],
|
||||
status: args === "skip" ? "LoadingFirstPage" : "Exhausted",
|
||||
loadMore: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<Management />);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Select all loaded signals" }));
|
||||
expect(screen.getByText("50 selected · 50 maximum")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Dismiss 50 signals" })).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("checkbox", { name: "Select Bulk Skill 50" }) as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("updates publisher abuse tab badges when live counts decrease", () => {
|
||||
const firstItem = makePublisherAbuseItem({ id: "1", handle: "first-pub" });
|
||||
const secondItem = makePublisherAbuseItem({ id: "2", handle: "second-pub" });
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
USER_BAN_REASON_MAX_LENGTH,
|
||||
} from "./managementShared";
|
||||
|
||||
const MAX_BULK_SIGNAL_SELECTION = 50;
|
||||
|
||||
export function AbusePage({
|
||||
admin,
|
||||
autobanSetting,
|
||||
@@ -66,12 +68,14 @@ export function AbusePage({
|
||||
onChangeTab,
|
||||
onClose,
|
||||
onDismissSignal,
|
||||
onDismissSignals,
|
||||
onMarkReviewed,
|
||||
onLoadMore,
|
||||
onRefresh,
|
||||
onReopenSignal,
|
||||
onSelect,
|
||||
onSnoozeSignal,
|
||||
onSnoozeSignals,
|
||||
onToggleAutoban,
|
||||
}: {
|
||||
admin: boolean;
|
||||
@@ -103,17 +107,22 @@ export function AbusePage({
|
||||
onChangeTab: (value: PublisherAbuseTab) => void;
|
||||
onClose: () => void;
|
||||
onDismissSignal: (item: PublisherAbuseSignalEntry) => void;
|
||||
onDismissSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void;
|
||||
onMarkReviewed: (item: PublisherAbuseReviewItem) => void;
|
||||
onLoadMore: () => void;
|
||||
onRefresh: () => void;
|
||||
onReopenSignal: (item: PublisherAbuseSignalEntry) => void;
|
||||
onSelect: (value: Id<"publisherAbuseReviewNominations">) => void;
|
||||
onSnoozeSignal: (item: PublisherAbuseSignalEntry) => void;
|
||||
onSnoozeSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void;
|
||||
onToggleAutoban: () => void;
|
||||
}) {
|
||||
const [selectedSignalItem, setSelectedSignalItem] = useState<PublisherAbuseSignalEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedSignalIds, setSelectedSignalIds] = useState<Set<Id<"publisherAbuseSignals">>>(
|
||||
new Set(),
|
||||
);
|
||||
const selectedSignalId = selectedSignalItem?.signal._id ?? null;
|
||||
useEffect(() => {
|
||||
if (!selectedSignalId) return;
|
||||
@@ -130,6 +139,16 @@ export function AbusePage({
|
||||
setSelectedSignalItem(null);
|
||||
}
|
||||
}, [selectedSignalId, signalItems, signalPageStatus, tab]);
|
||||
useEffect(() => {
|
||||
setSelectedSignalIds(new Set());
|
||||
}, [signalStatus, tab]);
|
||||
useEffect(() => {
|
||||
const visibleSignalIds = new Set(signalItems.map((item) => item.signal._id));
|
||||
setSelectedSignalIds((current) => {
|
||||
const next = new Set([...current].filter((signalId) => visibleSignalIds.has(signalId)));
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
}, [signalItems]);
|
||||
const latestRun = dashboard?.latestRun ?? null;
|
||||
const latestSignalRun = dashboard?.latestSignalRun ?? null;
|
||||
const displayedRun = tab === "signals" ? latestSignalRun : latestRun;
|
||||
@@ -455,9 +474,32 @@ export function AbusePage({
|
||||
items={signalItems}
|
||||
loaded={signalsLoaded}
|
||||
selectedSignalId={selectedSignalItem?.signal._id ?? null}
|
||||
selectedSignalIds={selectedSignalIds}
|
||||
status={signalStatus}
|
||||
searchActive={search.trim().length > 0}
|
||||
onClearSignalSelection={() => setSelectedSignalIds(new Set())}
|
||||
onDismissSignals={onDismissSignals}
|
||||
onSelectSignal={setSelectedSignalItem}
|
||||
onSnoozeSignals={onSnoozeSignals}
|
||||
onToggleAllSignals={(checked) => {
|
||||
setSelectedSignalIds(
|
||||
checked
|
||||
? new Set(
|
||||
signalItems
|
||||
.slice(0, MAX_BULK_SIGNAL_SELECTION)
|
||||
.map((item) => item.signal._id),
|
||||
)
|
||||
: new Set(),
|
||||
);
|
||||
}}
|
||||
onToggleSignal={(signalId, checked) => {
|
||||
setSelectedSignalIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked && next.size < MAX_BULK_SIGNAL_SELECTION) next.add(signalId);
|
||||
else next.delete(signalId);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="pa-table-wrap">
|
||||
@@ -881,112 +923,210 @@ function PublisherAbuseSignalsTable({
|
||||
items,
|
||||
loaded,
|
||||
selectedSignalId,
|
||||
selectedSignalIds,
|
||||
status,
|
||||
searchActive,
|
||||
onClearSignalSelection,
|
||||
onDismissSignals,
|
||||
onSelectSignal,
|
||||
onSnoozeSignals,
|
||||
onToggleAllSignals,
|
||||
onToggleSignal,
|
||||
}: {
|
||||
canLoadMore: boolean;
|
||||
items: PublisherAbuseSignalEntry[];
|
||||
loaded: boolean;
|
||||
selectedSignalId: Id<"publisherAbuseSignals"> | null;
|
||||
selectedSignalIds: Set<Id<"publisherAbuseSignals">>;
|
||||
status: PublisherAbuseSignalStatus;
|
||||
searchActive: boolean;
|
||||
onClearSignalSelection: () => void;
|
||||
onDismissSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void;
|
||||
onSelectSignal: (item: PublisherAbuseSignalEntry) => void;
|
||||
onSnoozeSignals: (signalIds: Id<"publisherAbuseSignals">[]) => void;
|
||||
onToggleAllSignals: (checked: boolean) => void;
|
||||
onToggleSignal: (signalId: Id<"publisherAbuseSignals">, checked: boolean) => void;
|
||||
}) {
|
||||
const emptyState = publisherAbuseSignalEmptyState(searchActive, canLoadMore, status);
|
||||
const bulkSelectionEnabled = status === "open" && loaded && items.length > 0;
|
||||
const selectedIds = [...selectedSignalIds];
|
||||
const selectedCount = selectedIds.length;
|
||||
const selectionAtLimit = selectedCount >= MAX_BULK_SIGNAL_SELECTION;
|
||||
const selectableItems = items.slice(0, MAX_BULK_SIGNAL_SELECTION);
|
||||
const allLoadedSelected =
|
||||
bulkSelectionEnabled &&
|
||||
selectableItems.length > 0 &&
|
||||
selectableItems.every((item) => selectedSignalIds.has(item.signal._id));
|
||||
return (
|
||||
<div className="pa-table-wrap">
|
||||
<table className="pa-table pa-signals-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Severity</th>
|
||||
<th>Signal</th>
|
||||
<th>Subject</th>
|
||||
<th className="pa-num">Evidence</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loaded ? (
|
||||
<PublisherAbuseTableSkeletonRows columns={5} label="Loading publisher abuse signals" />
|
||||
) : items.length === 0 ? (
|
||||
<tr className="pa-empty-row">
|
||||
<td colSpan={5}>
|
||||
<strong>{emptyState.title}</strong>
|
||||
{emptyState.body}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const selected = item.signal._id === selectedSignalId;
|
||||
const recurrenceCount = item.signal.recurrenceCount ?? 0;
|
||||
return (
|
||||
<tr
|
||||
key={item.signal._id}
|
||||
className={selected ? "is-selected" : undefined}
|
||||
onClick={() => onSelectSignal(item)}
|
||||
>
|
||||
<td>
|
||||
<Badge
|
||||
variant={publisherAbuseSignalSeverityVariant(
|
||||
item.signal.signalType,
|
||||
recurrenceCount,
|
||||
)}
|
||||
size="sm"
|
||||
>
|
||||
{formatPublisherAbuseSignalSeverity(item.signal.signalType, recurrenceCount)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="pa-signal-summary pa-row-button"
|
||||
aria-label={`Open details for ${item.signal.skillDisplayName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectSignal(item);
|
||||
}}
|
||||
>
|
||||
<strong className="pa-signal-name">
|
||||
{formatPublisherAbuseSignalType(item.signal.signalType)}
|
||||
</strong>
|
||||
<span>
|
||||
{formatPublisherAbuseSignalStatus(signalReviewStatus(item))}
|
||||
{" · "}Seen {formatWholeNumber(item.signal.seenCount)}x
|
||||
</span>
|
||||
</button>
|
||||
{recurrenceCount > 0 ? (
|
||||
<div className="pa-signal-repeat is-recurring">Repeat after snooze</div>
|
||||
) : item.signal.snoozedUntil ? (
|
||||
<div className="pa-signal-repeat">
|
||||
{formatPublisherAbuseSnoozeState(item.signal.snoozedUntil)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<div className="pa-signal-subject">
|
||||
<strong>{item.signal.skillDisplayName}</strong>
|
||||
<span>
|
||||
@{item.signal.handleSnapshot} / {item.signal.skillSlug}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<PublisherAbuseSignalRatioCell
|
||||
downloads={item.signal.recent30Downloads}
|
||||
installs={item.signal.recent30Installs}
|
||||
ratio={item.signal.recent30InstallDownloadRatio}
|
||||
<>
|
||||
{bulkSelectionEnabled ? (
|
||||
<div className="pa-signal-bulk-bar" aria-label="Bulk signal actions">
|
||||
<span className="pa-signal-bulk-count" aria-live="polite">
|
||||
{formatWholeNumber(selectedCount)} selected
|
||||
{selectionAtLimit ? ` · ${MAX_BULK_SIGNAL_SELECTION} maximum` : null}
|
||||
</span>
|
||||
<div className="pa-signal-bulk-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={() => onSnoozeSignals(selectedIds)}
|
||||
>
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
{bulkSignalActionLabel("Snooze", selectedCount)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={() => onDismissSignals(selectedIds)}
|
||||
>
|
||||
<XCircle size={14} aria-hidden="true" />
|
||||
{bulkSignalActionLabel("Dismiss", selectedCount)}
|
||||
</Button>
|
||||
{selectedCount > 0 ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={onClearSignalSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pa-table-wrap">
|
||||
<table className="pa-table pa-signals-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{bulkSelectionEnabled ? (
|
||||
<th className="pa-signal-select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select all loaded signals"
|
||||
checked={allLoadedSelected}
|
||||
onChange={(event) => onToggleAllSignals(event.target.checked)}
|
||||
/>
|
||||
<td className="pa-muted">{formatShortTimestamp(item.signal.lastSeenAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</th>
|
||||
) : null}
|
||||
<th>Severity</th>
|
||||
<th>Signal</th>
|
||||
<th>Subject</th>
|
||||
<th className="pa-num">Evidence</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loaded ? (
|
||||
<PublisherAbuseTableSkeletonRows
|
||||
columns={5}
|
||||
label="Loading publisher abuse signals"
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<tr className="pa-empty-row">
|
||||
<td colSpan={bulkSelectionEnabled ? 6 : 5}>
|
||||
<strong>{emptyState.title}</strong>
|
||||
{emptyState.body}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const selected = item.signal._id === selectedSignalId;
|
||||
const bulkSelected = selectedSignalIds.has(item.signal._id);
|
||||
const recurrenceCount = item.signal.recurrenceCount ?? 0;
|
||||
return (
|
||||
<tr
|
||||
key={item.signal._id}
|
||||
className={
|
||||
[selected ? "is-selected" : "", bulkSelected ? "is-bulk-selected" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ") || undefined
|
||||
}
|
||||
onClick={() => onSelectSignal(item)}
|
||||
>
|
||||
{bulkSelectionEnabled ? (
|
||||
<td className="pa-signal-select-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${item.signal.skillDisplayName}`}
|
||||
checked={bulkSelected}
|
||||
disabled={selectionAtLimit && !bulkSelected}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) =>
|
||||
onToggleSignal(item.signal._id, event.target.checked)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
<td>
|
||||
<Badge
|
||||
variant={publisherAbuseSignalSeverityVariant(
|
||||
item.signal.signalType,
|
||||
recurrenceCount,
|
||||
)}
|
||||
size="sm"
|
||||
>
|
||||
{formatPublisherAbuseSignalSeverity(
|
||||
item.signal.signalType,
|
||||
recurrenceCount,
|
||||
)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="pa-signal-summary pa-row-button"
|
||||
aria-label={`Open details for ${item.signal.skillDisplayName}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectSignal(item);
|
||||
}}
|
||||
>
|
||||
<strong className="pa-signal-name">
|
||||
{formatPublisherAbuseSignalType(item.signal.signalType)}
|
||||
</strong>
|
||||
<span>
|
||||
{formatPublisherAbuseSignalStatus(signalReviewStatus(item))}
|
||||
{" · "}Seen {formatWholeNumber(item.signal.seenCount)}x
|
||||
</span>
|
||||
</button>
|
||||
{recurrenceCount > 0 ? (
|
||||
<div className="pa-signal-repeat is-recurring">Repeat after snooze</div>
|
||||
) : item.signal.snoozedUntil ? (
|
||||
<div className="pa-signal-repeat">
|
||||
{formatPublisherAbuseSnoozeState(item.signal.snoozedUntil)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<div className="pa-signal-subject">
|
||||
<strong>{item.signal.skillDisplayName}</strong>
|
||||
<span>
|
||||
@{item.signal.handleSnapshot} / {item.signal.skillSlug}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<PublisherAbuseSignalRatioCell
|
||||
downloads={item.signal.recent30Downloads}
|
||||
installs={item.signal.recent30Installs}
|
||||
ratio={item.signal.recent30InstallDownloadRatio}
|
||||
/>
|
||||
<td className="pa-muted">{formatShortTimestamp(item.signal.lastSeenAt)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function bulkSignalActionLabel(action: "Snooze" | "Dismiss", count: number) {
|
||||
if (count <= 0) return `${action} selected`;
|
||||
return `${action} ${formatWholeNumber(count)} ${count === 1 ? "signal" : "signals"}`;
|
||||
}
|
||||
|
||||
function PublisherAbuseSignalInspector({
|
||||
item,
|
||||
onDismissSignal,
|
||||
|
||||
@@ -285,6 +285,9 @@ export function Management() {
|
||||
);
|
||||
const snoozePublisherAbuseSignal = useMutation(api.publisherAbuse.snoozePublisherAbuseSignal);
|
||||
const dismissPublisherAbuseSignal = useMutation(api.publisherAbuse.dismissPublisherAbuseSignal);
|
||||
const reviewPublisherAbuseSignalsBatch = useMutation(
|
||||
api.publisherAbuse.reviewPublisherAbuseSignalsBatch,
|
||||
);
|
||||
const reopenPublisherAbuseSignal = useMutation(api.publisherAbuse.reopenPublisherAbuseSignal);
|
||||
const startPublisherAbuseScoreRun = useAction(api.publisherAbuse.startPublisherAbuseScoreRun);
|
||||
const startPublisherAbuseSignalScan = useAction(
|
||||
@@ -724,6 +727,52 @@ export function Management() {
|
||||
});
|
||||
};
|
||||
|
||||
const requestSnoozePublisherAbuseSignals = (signalIds: Id<"publisherAbuseSignals">[]) => {
|
||||
const count = signalIds.length;
|
||||
if (count === 0) return;
|
||||
const label = `${count} ${count === 1 ? "signal" : "signals"}`;
|
||||
setConfirmRequest({
|
||||
title: `Snooze ${label}?`,
|
||||
body: "Hides the selected signals for 14 days and acknowledges the evidence shown now. Each signal reopens only if fresh activity crosses the repeat threshold.",
|
||||
confirmLabel: `Snooze ${label}`,
|
||||
reason: {
|
||||
label: "Note (optional)",
|
||||
placeholder: "Why are you snoozing these signals?",
|
||||
},
|
||||
onConfirm: (note) => {
|
||||
void reviewPublisherAbuseSignalsBatch({
|
||||
signalIds,
|
||||
status: "snoozed",
|
||||
note,
|
||||
days: 14,
|
||||
})
|
||||
.then((result) => toast.success(`${result.updated} signals snoozed.`))
|
||||
.catch((error) => toast.error(formatMutationError(error)));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const requestDismissPublisherAbuseSignals = (signalIds: Id<"publisherAbuseSignals">[]) => {
|
||||
const count = signalIds.length;
|
||||
if (count === 0) return;
|
||||
const label = `${count} ${count === 1 ? "signal" : "signals"}`;
|
||||
setConfirmRequest({
|
||||
title: `Dismiss ${label}?`,
|
||||
body: "Archives the selected signals and removes them from the Open queue. They will not notify Hermit unless a moderator reopens them.",
|
||||
confirmLabel: `Dismiss ${label}`,
|
||||
destructive: true,
|
||||
reason: {
|
||||
label: "Note (optional)",
|
||||
placeholder: "Why are you dismissing these signals?",
|
||||
},
|
||||
onConfirm: (note) => {
|
||||
void reviewPublisherAbuseSignalsBatch({ signalIds, status: "dismissed", note })
|
||||
.then((result) => toast.success(`${result.updated} signals dismissed.`))
|
||||
.catch((error) => toast.error(formatMutationError(error)));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const requestReopenPublisherAbuseSignal = (item: PublisherAbuseSignalEntry) => {
|
||||
setConfirmRequest({
|
||||
title: `Reopen ${item.signal.skillDisplayName}?`,
|
||||
@@ -863,6 +912,7 @@ export function Management() {
|
||||
}}
|
||||
onToggleAutoban={requestTogglePublisherAbuseAutoban}
|
||||
onDismissSignal={requestDismissPublisherAbuseSignal}
|
||||
onDismissSignals={requestDismissPublisherAbuseSignals}
|
||||
onMarkReviewed={requestMarkPublisherAbuseNominationReviewed}
|
||||
onLoadMore={() => {
|
||||
if (publisherAbuseTab === "signals") {
|
||||
@@ -912,6 +962,7 @@ export function Management() {
|
||||
setSelectedPublisherAbuseNominationId(nominationId);
|
||||
}}
|
||||
onSnoozeSignal={requestSnoozePublisherAbuseSignal}
|
||||
onSnoozeSignals={requestSnoozePublisherAbuseSignals}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -14584,6 +14584,34 @@ code {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pa-signal-bulk-bar {
|
||||
display: flex;
|
||||
min-height: 42px;
|
||||
margin: 0 calc(-1 * var(--space-4));
|
||||
padding: 6px var(--space-4);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
border-top: 1px solid var(--oc-border-subtle);
|
||||
border-bottom: 1px solid var(--oc-border-subtle);
|
||||
background: color-mix(in srgb, var(--oc-bg-surface) 78%, var(--oc-bg-page));
|
||||
}
|
||||
|
||||
.pa-signal-bulk-count {
|
||||
color: var(--oc-text-secondary);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pa-signal-bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pa-table-wrap {
|
||||
min-width: 0;
|
||||
margin: 0 calc(-1 * var(--space-4));
|
||||
@@ -14671,6 +14699,46 @@ code {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.pa-signals-table tbody tr.is-bulk-selected {
|
||||
background: color-mix(in srgb, var(--oc-accent-primary) 9%, transparent);
|
||||
}
|
||||
|
||||
.pa-signals-table .pa-signal-select-cell {
|
||||
width: 42px;
|
||||
padding-right: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pa-signal-select-cell input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--oc-border-subtle);
|
||||
border-radius: var(--oc-radius-inset);
|
||||
background-color: var(--oc-bg-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pa-signal-select-cell input[type="checkbox"]:checked {
|
||||
border-color: var(--oc-accent-primary);
|
||||
background-color: var(--oc-accent-primary);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m3.5 8 3 3 6-6' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 14px;
|
||||
}
|
||||
|
||||
.pa-signal-select-cell input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.pa-signal-select-cell input[type="checkbox"]:focus-visible {
|
||||
outline: 2px solid var(--oc-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.pa-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -15199,6 +15267,17 @@ code {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pa-signal-bulk-bar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pa-signal-bulk-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pa-score > div {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
Reference in New Issue
Block a user