mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add 30-day activity trends to abuse signal drawer (#3216)
Adds 30-day download and install trend charts to the abuse signal drawer, places them near the top for immediate context, and improves development fixtures for realistic manual validation.
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACTIVITY_TREND_DAYS, buildDailyMetricTrends } from "./downloadTrend";
|
||||
import {
|
||||
ACTIVITY_TREND_DAYS,
|
||||
buildDailyActivityTrends,
|
||||
buildDailyMetricTrends,
|
||||
} from "./downloadTrend";
|
||||
|
||||
describe("download trend helpers", () => {
|
||||
it("fills missing days and totals the daily activity points", () => {
|
||||
const trend = buildDailyMetricTrends(
|
||||
const trend = buildDailyActivityTrends(
|
||||
[
|
||||
{ day: 20, downloads: 3, installs: 1 },
|
||||
{ day: 22, downloads: 8, installs: 4 },
|
||||
@@ -26,15 +30,39 @@ describe("download trend helpers", () => {
|
||||
day: 22,
|
||||
value: 8,
|
||||
});
|
||||
expect(trend.installs.range).toBe("daily");
|
||||
expect(trend.installs.days).toBe(ACTIVITY_TREND_DAYS);
|
||||
expect(trend.installs.total).toBe(5);
|
||||
expect(trend.installs.points).toHaveLength(ACTIVITY_TREND_DAYS);
|
||||
expect(trend.installs.points[0]).toEqual({ day: -4, value: 0 });
|
||||
expect(trend.installs.points.at(-1)).toEqual({ day: 25, value: 0 });
|
||||
expect(trend.installs.points.find((point) => point.day === 20)).toEqual({
|
||||
day: 20,
|
||||
value: 1,
|
||||
});
|
||||
expect(trend.installs.points.find((point) => point.day === 22)).toEqual({
|
||||
day: 22,
|
||||
value: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows zero 30-day activity when no daily rows exist", () => {
|
||||
const trend = buildDailyMetricTrends([], 25);
|
||||
const trend = buildDailyActivityTrends([], 25);
|
||||
|
||||
expect(trend.downloads.total).toBe(0);
|
||||
expect(trend.downloads.points).toHaveLength(ACTIVITY_TREND_DAYS);
|
||||
expect(trend.downloads.points[0]?.day).toBe(-4);
|
||||
expect(trend.downloads.points.at(-1)?.day).toBe(25);
|
||||
expect(trend.downloads.points.every((point) => point.value === 0)).toBe(true);
|
||||
expect(trend.installs.total).toBe(0);
|
||||
expect(trend.installs.points).toHaveLength(ACTIVITY_TREND_DAYS);
|
||||
expect(trend.installs.points.every((point) => point.value === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the public metric trend contract downloads-only", () => {
|
||||
const trend = buildDailyMetricTrends([{ day: 25, downloads: 2, installs: 1 }], 25);
|
||||
|
||||
expect(trend.downloads.total).toBe(2);
|
||||
expect("installs" in trend).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ export type DailyMetricTrends = {
|
||||
downloads: MetricTrend;
|
||||
};
|
||||
|
||||
export type DailyActivityTrends = DailyMetricTrends & {
|
||||
installs: MetricTrend;
|
||||
};
|
||||
|
||||
export function getActivityTrendRange(now: number) {
|
||||
return getActivityTrendRangeForEndDay(toDayKey(now));
|
||||
}
|
||||
@@ -36,11 +40,15 @@ export function clampActivityTrendEndDay(endDayValue: number, now: number) {
|
||||
return Math.min(Math.trunc(endDayValue), toDayKey(now));
|
||||
}
|
||||
|
||||
function buildDownloadTrend(rows: DailyMetricRow[], endDay: number): MetricTrend {
|
||||
function buildMetricTrend(
|
||||
rows: DailyMetricRow[],
|
||||
endDay: number,
|
||||
metric: "downloads" | "installs",
|
||||
): MetricTrend {
|
||||
const { startDay } = getActivityTrendRangeForEndDay(endDay);
|
||||
const valuesByDay = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
valuesByDay.set(row.day, Math.max(0, row.downloads));
|
||||
valuesByDay.set(row.day, Math.max(0, row[metric]));
|
||||
}
|
||||
|
||||
const points = Array.from({ length: ACTIVITY_TREND_DAYS }, (_, index) => {
|
||||
@@ -57,6 +65,16 @@ function buildDownloadTrend(rows: DailyMetricRow[], endDay: number): MetricTrend
|
||||
|
||||
export function buildDailyMetricTrends(rows: DailyMetricRow[], endDay: number): DailyMetricTrends {
|
||||
return {
|
||||
downloads: buildDownloadTrend(rows, endDay),
|
||||
downloads: buildMetricTrend(rows, endDay, "downloads"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDailyActivityTrends(
|
||||
rows: DailyMetricRow[],
|
||||
endDay: number,
|
||||
): DailyActivityTrends {
|
||||
return {
|
||||
...buildDailyMetricTrends(rows, endDay),
|
||||
installs: buildMetricTrend(rows, endDay, "installs"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,6 +291,16 @@ const listSignalsPageHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getSignalActivityTrendHandler = (
|
||||
publisherAbuse.getSignalActivityTrend as unknown as Wrapped<
|
||||
{ signalId: string; endDay: number },
|
||||
{
|
||||
downloads: { total: number; points: Array<{ day: number; value: number }> };
|
||||
installs: { total: number; points: Array<{ day: number; value: number }> };
|
||||
} | null
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const archiveTemporalPublisherAbuseSignalsPageHandler = (
|
||||
publisherAbuse.archiveTemporalPublisherAbuseSignalsPageInternal as unknown as Wrapped<
|
||||
{
|
||||
@@ -959,6 +969,54 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
expect(db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns bounded 30-day download and install trends for a signal", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const rows = [
|
||||
{ day: 98, downloads: 120, installs: 9 },
|
||||
{ day: 100, downloads: 180, installs: 12 },
|
||||
];
|
||||
const take = vi.fn(async () => rows);
|
||||
const indexBuilder = {
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
gte: vi.fn().mockReturnThis(),
|
||||
lte: vi.fn().mockReturnThis(),
|
||||
};
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) =>
|
||||
id === "publisherAbuseSignals:ratio" ? { _id: id, skillId: "skills:ratio" } : null,
|
||||
),
|
||||
query: vi.fn((table: string) => {
|
||||
expect(table).toBe("skillDailyStats");
|
||||
return {
|
||||
withIndex: (indexName: string, callback: (q: typeof indexBuilder) => unknown) => {
|
||||
expect(indexName).toBe("by_skill_day");
|
||||
callback(indexBuilder);
|
||||
return { take };
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await getSignalActivityTrendHandler(
|
||||
{ db },
|
||||
{ signalId: "publisherAbuseSignals:ratio", endDay: 100 },
|
||||
);
|
||||
|
||||
expect(result?.downloads.total).toBe(300);
|
||||
expect(result?.installs.total).toBe(21);
|
||||
expect(result?.downloads.points).toHaveLength(30);
|
||||
expect(result?.installs.points).toHaveLength(30);
|
||||
expect(result?.downloads.points.at(-1)).toEqual({ day: 100, value: 180 });
|
||||
expect(result?.installs.points.at(-1)).toEqual({ day: 100, value: 12 });
|
||||
expect(take).toHaveBeenCalledWith(30);
|
||||
expect(indexBuilder.eq).toHaveBeenCalledWith("skillId", "skills:ratio");
|
||||
expect(indexBuilder.gte).toHaveBeenCalledWith("day", 71);
|
||||
expect(indexBuilder.lte).toHaveBeenCalledWith("day", 100);
|
||||
});
|
||||
|
||||
it("lets moderators snooze, dismiss, and reopen archived signals with audit rows", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
query,
|
||||
} from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access";
|
||||
import {
|
||||
ACTIVITY_TREND_DAYS,
|
||||
buildDailyActivityTrends,
|
||||
clampActivityTrendEndDay,
|
||||
getActivityTrendRangeForEndDay,
|
||||
} from "./lib/downloadTrend";
|
||||
import { toDayKey } from "./lib/leaderboards";
|
||||
import { hasOfficialPublisherRow } from "./lib/officialPublishers";
|
||||
import {
|
||||
@@ -483,6 +489,31 @@ export const listSignalsPage = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getSignalActivityTrend = query({
|
||||
args: {
|
||||
signalId: v.id("publisherAbuseSignals"),
|
||||
endDay: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const auth = await requirePublisherAbuseDashboardUser(ctx);
|
||||
if (!auth) return null;
|
||||
|
||||
const signal = await ctx.db.get(args.signalId);
|
||||
if (!signal) return null;
|
||||
|
||||
const endDay = clampActivityTrendEndDay(args.endDay, Date.now());
|
||||
const { startDay } = getActivityTrendRangeForEndDay(endDay);
|
||||
const rows = await ctx.db
|
||||
.query("skillDailyStats")
|
||||
.withIndex("by_skill_day", (q) =>
|
||||
q.eq("skillId", signal.skillId).gte("day", startDay).lte("day", endDay),
|
||||
)
|
||||
.take(ACTIVITY_TREND_DAYS);
|
||||
|
||||
return buildDailyActivityTrends(rows, endDay);
|
||||
},
|
||||
});
|
||||
|
||||
export const getReviewNominationDetail = query({
|
||||
args: {
|
||||
nominationId: v.id("publisherAbuseReviewNominations"),
|
||||
|
||||
@@ -273,6 +273,22 @@ describe("publisherAbuseDevSeed.seed", () => {
|
||||
expect(tables.users ?? []).toHaveLength(16);
|
||||
expect(tables.skills?.some((doc) => doc.slug === "demo-temporal-download-burst")).toBe(true);
|
||||
expect(tables.skills?.some((doc) => doc.slug === "demo-temporal-install-ratio")).toBe(true);
|
||||
const burstSkill = tables.skills?.find((doc) => doc.slug === "demo-temporal-download-burst");
|
||||
const ratioSkill = tables.skills?.find((doc) => doc.slug === "demo-temporal-install-ratio");
|
||||
const burstRecentStats = (tables.skillDailyStats ?? [])
|
||||
.filter((doc) => doc.skillId === burstSkill?._id)
|
||||
.sort((left, right) => Number(left.day) - Number(right.day))
|
||||
.slice(-30);
|
||||
const ratioRecentStats = (tables.skillDailyStats ?? [])
|
||||
.filter((doc) => doc.skillId === ratioSkill?._id)
|
||||
.sort((left, right) => Number(left.day) - Number(right.day));
|
||||
|
||||
expect(burstRecentStats.map((doc) => doc.downloads)).toHaveLength(30);
|
||||
expect(new Set(burstRecentStats.map((doc) => doc.downloads)).size).toBeGreaterThan(10);
|
||||
expect(burstRecentStats.reduce((sum, doc) => sum + Number(doc.downloads), 0)).toBe(16_200);
|
||||
expect(burstRecentStats.reduce((sum, doc) => sum + Number(doc.installs), 0)).toBe(8);
|
||||
expect(ratioRecentStats.reduce((sum, doc) => sum + Number(doc.downloads), 0)).toBe(2_400);
|
||||
expect(ratioRecentStats.reduce((sum, doc) => sum + Number(doc.installs), 0)).toBe(288);
|
||||
expect(tables.publisherAbuseSignals).toEqual([
|
||||
expect.objectContaining({
|
||||
signalType: "sustained_downloads_flat_installs",
|
||||
|
||||
@@ -28,6 +28,24 @@ const TEMPORAL_DEMO_SKILL_SLUG = "demo-temporal-download-burst";
|
||||
const TEMPORAL_DEMO_RATIO_SKILL_SLUG = "demo-temporal-install-ratio";
|
||||
const CLEAR_SEED_BATCH_SIZE = 100;
|
||||
|
||||
// A realistic quiet-baseline → sharp-burst → uneven-tail shape keeps the
|
||||
// activity charts useful during manual review without copying production rows.
|
||||
const TEMPORAL_DEMO_ACTIVITY_SHAPE = [
|
||||
13, 5, 4, 2, 10, 8, 9, 11, 10, 11, 13, 5, 7, 11, 10, 6, 18, 6, 59, 96, 205, 98, 76, 79, 62, 99,
|
||||
81, 53, 54, 11,
|
||||
] as const;
|
||||
const TEMPORAL_DEMO_SPARSE_INSTALLS = [
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 2, 1, 0, 1, 0, 1, 0, 0, 0, 1,
|
||||
] as const;
|
||||
|
||||
function scaleDailySeries(shape: readonly number[], targetTotal: number) {
|
||||
const sourceTotal = shape.reduce((sum, value) => sum + value, 0);
|
||||
const scaled = shape.map((value) => Math.round((value * targetTotal) / sourceTotal));
|
||||
const roundingDifference = targetTotal - scaled.reduce((sum, value) => sum + value, 0);
|
||||
scaled[scaled.length - 1] = (scaled.at(-1) ?? 0) + roundingDifference;
|
||||
return scaled;
|
||||
}
|
||||
|
||||
type TriageStatus =
|
||||
| "pending"
|
||||
| "reviewed_no_action"
|
||||
@@ -397,6 +415,16 @@ export const clearSeed = internalMutation({
|
||||
async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number }) {
|
||||
const now = args.now;
|
||||
const todayDay = Math.floor(now / DAY_MS);
|
||||
const temporalDownloads = scaleDailySeries(TEMPORAL_DEMO_ACTIVITY_SHAPE, 16_200);
|
||||
const temporalInstalls: number[] = [...TEMPORAL_DEMO_SPARSE_INSTALLS];
|
||||
const temporalDownloads30d = temporalDownloads.reduce((sum, value) => sum + value, 0);
|
||||
const temporalInstalls30d = temporalInstalls.reduce((sum, value) => sum + value, 0);
|
||||
const temporalDownloads7d = temporalDownloads.slice(-7).reduce((sum, value) => sum + value, 0);
|
||||
const temporalInstalls7d = temporalInstalls.slice(-7).reduce((sum, value) => sum + value, 0);
|
||||
const ratioDownloads = scaleDailySeries(TEMPORAL_DEMO_ACTIVITY_SHAPE, 2_400);
|
||||
const ratioInstalls = scaleDailySeries(TEMPORAL_DEMO_ACTIVITY_SHAPE, 288);
|
||||
const ratioDownloads7d = ratioDownloads.slice(-7).reduce((sum, value) => sum + value, 0);
|
||||
const ratioInstalls7d = ratioInstalls.slice(-7).reduce((sum, value) => sum + value, 0);
|
||||
const temporalBenchmark = {
|
||||
scope: "all_active_skills" as const,
|
||||
sampleSize: 1000,
|
||||
@@ -421,11 +449,11 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number
|
||||
linkedUserId: temporalUserId,
|
||||
publishedSkills: 1,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 0,
|
||||
totalDownloads: 16_200,
|
||||
totalInstalls: temporalInstalls30d,
|
||||
totalDownloads: temporalDownloads30d,
|
||||
totalStars: 0,
|
||||
skillTotalInstalls: 0,
|
||||
skillTotalDownloads: 16_200,
|
||||
skillTotalInstalls: temporalInstalls30d,
|
||||
skillTotalDownloads: temporalDownloads30d,
|
||||
skillTotalStars: 0,
|
||||
createdAt: now - DAY_MS,
|
||||
updatedAt: now - HOUR_MS,
|
||||
@@ -433,20 +461,20 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number
|
||||
const temporalSkillId = await ctx.db.insert("skills", {
|
||||
slug: TEMPORAL_DEMO_SKILL_SLUG,
|
||||
displayName: "Demo Temporal Download Burst",
|
||||
summary: "Synthetic fixture: high 30-day downloads with zero installs.",
|
||||
summary: "Synthetic fixture: high 30-day downloads with near-flat installs.",
|
||||
ownerUserId: temporalUserId,
|
||||
ownerPublisherId: temporalPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
moderationStatus: "active",
|
||||
statsDownloads: 16_200,
|
||||
statsDownloads: temporalDownloads30d,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
statsInstallsAllTime: temporalInstalls30d,
|
||||
stats: {
|
||||
downloads: 16_200,
|
||||
downloads: temporalDownloads30d,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
installsAllTime: temporalInstalls30d,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
@@ -488,11 +516,19 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number
|
||||
});
|
||||
}
|
||||
for (let offset = 29; offset >= 0; offset -= 1) {
|
||||
const index = 29 - offset;
|
||||
await ctx.db.insert("skillDailyStats", {
|
||||
skillId: temporalSkillId,
|
||||
day: todayDay - offset,
|
||||
downloads: 540,
|
||||
installs: 0,
|
||||
downloads: temporalDownloads[index] ?? 0,
|
||||
installs: temporalInstalls[index] ?? 0,
|
||||
updatedAt: now - HOUR_MS,
|
||||
});
|
||||
await ctx.db.insert("skillDailyStats", {
|
||||
skillId: ratioSkillId,
|
||||
day: todayDay - offset,
|
||||
downloads: ratioDownloads[index] ?? 0,
|
||||
installs: ratioInstalls[index] ?? 0,
|
||||
updatedAt: now - HOUR_MS,
|
||||
});
|
||||
}
|
||||
@@ -534,12 +570,12 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number
|
||||
logPressure: Math.log10(18),
|
||||
zScore: 2.14,
|
||||
publishedSkills: 1,
|
||||
totalInstalls: 0,
|
||||
totalInstalls: temporalInstalls30d,
|
||||
totalStars: 0,
|
||||
totalDownloads: 16_200,
|
||||
installsPerSkill: 0,
|
||||
totalDownloads: temporalDownloads30d,
|
||||
installsPerSkill: temporalInstalls30d,
|
||||
starsPerSkill: 0,
|
||||
downloadsPerSkill: 16_200,
|
||||
downloadsPerSkill: temporalDownloads30d,
|
||||
reasonCodes: ["temporal_sustained_downloads_flat_installs"],
|
||||
temporalHighSkillCount: 1,
|
||||
temporalSpikeSkillCount: 0,
|
||||
@@ -554,14 +590,14 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number
|
||||
spike: false,
|
||||
sustained: true,
|
||||
pressure: 18,
|
||||
recent7Downloads: 3_780,
|
||||
recent7Installs: 0,
|
||||
recent7Downloads: temporalDownloads7d,
|
||||
recent7Installs: temporalInstalls7d,
|
||||
previous30Downloads: 120,
|
||||
baseline7Downloads: 100,
|
||||
spikeMultiplier: 8,
|
||||
recent30Downloads: 16_200,
|
||||
recent30Installs: 0,
|
||||
downloadInstallRatio30: 16_200,
|
||||
recent30Downloads: temporalDownloads30d,
|
||||
recent30Installs: temporalInstalls30d,
|
||||
downloadInstallRatio30: temporalDownloads30d / Math.max(1, temporalInstalls30d),
|
||||
downloads30dCohortBand: "p99",
|
||||
spikeMultiplierCohortBand: "p95",
|
||||
downloads30dVsPeerP95: 18,
|
||||
@@ -601,15 +637,15 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number
|
||||
firstSeenAt: temporalCompletedAt,
|
||||
lastSeenAt: temporalCompletedAt,
|
||||
seenCount: 1,
|
||||
recent7Downloads: 3_780,
|
||||
recent7Installs: 0,
|
||||
recent7InstallDownloadRatio: 0,
|
||||
recent30Downloads: 16_200,
|
||||
recent30Installs: 0,
|
||||
recent30InstallDownloadRatio: 0,
|
||||
allTimeDownloads: 16_200,
|
||||
allTimeInstalls: 0,
|
||||
allTimeInstallDownloadRatio: 0,
|
||||
recent7Downloads: temporalDownloads7d,
|
||||
recent7Installs: temporalInstalls7d,
|
||||
recent7InstallDownloadRatio: temporalInstalls7d / temporalDownloads7d,
|
||||
recent30Downloads: temporalDownloads30d,
|
||||
recent30Installs: temporalInstalls30d,
|
||||
recent30InstallDownloadRatio: temporalInstalls30d / temporalDownloads30d,
|
||||
allTimeDownloads: temporalDownloads30d,
|
||||
allTimeInstalls: temporalInstalls30d,
|
||||
allTimeInstallDownloadRatio: temporalInstalls30d / temporalDownloads30d,
|
||||
reviewStatus: "open",
|
||||
});
|
||||
await ctx.db.insert("publisherAbuseSignals", {
|
||||
@@ -626,9 +662,9 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number
|
||||
firstSeenAt: temporalCompletedAt - 7 * 60_000,
|
||||
lastSeenAt: temporalCompletedAt,
|
||||
seenCount: 2,
|
||||
recent7Downloads: 800,
|
||||
recent7Installs: 96,
|
||||
recent7InstallDownloadRatio: 0.12,
|
||||
recent7Downloads: ratioDownloads7d,
|
||||
recent7Installs: ratioInstalls7d,
|
||||
recent7InstallDownloadRatio: ratioInstalls7d / ratioDownloads7d,
|
||||
recent30Downloads: 2_400,
|
||||
recent30Installs: 288,
|
||||
recent30InstallDownloadRatio: 0.12,
|
||||
|
||||
@@ -122,7 +122,10 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
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.
|
||||
per-signal review event as the corresponding single-signal action. The signal
|
||||
inspector must show the selected skill's daily downloads and installs across
|
||||
the same trailing 30-day window, loaded on demand from the bounded daily-stat
|
||||
index so staff can judge whether the evidence is sustained or spiky.
|
||||
- Hermit owns Discord notification delivery for publisher abuse Signals.
|
||||
ClawHub queues Hermit digests only for changed open signals: newly archived
|
||||
signals, manual reopens, expired snoozes with qualifying fresh evidence, and
|
||||
|
||||
@@ -143,6 +143,22 @@ function makePublisherAbuseSignal(signalOverrides: Record<string, unknown> = {})
|
||||
};
|
||||
}
|
||||
|
||||
function makeSignalActivityTrend() {
|
||||
const points = Array.from({ length: 30 }, (_, index) => ({
|
||||
day: 20_500 + index,
|
||||
value: index + 1,
|
||||
}));
|
||||
return {
|
||||
downloads: { range: "daily", days: 30, total: 465, points },
|
||||
installs: {
|
||||
range: "daily",
|
||||
days: 30,
|
||||
total: 30,
|
||||
points: points.map((point) => ({ ...point, value: point.value % 3 })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeManagementUser(
|
||||
id: string,
|
||||
handle: string,
|
||||
@@ -458,6 +474,9 @@ describe("Management", () => {
|
||||
signalCountHasMore: false,
|
||||
};
|
||||
}
|
||||
if (name === "publisherAbuse:getSignalActivityTrend") {
|
||||
return makeSignalActivityTrend();
|
||||
}
|
||||
if (name === "users:list") return { items: [], total: 0 };
|
||||
return undefined;
|
||||
});
|
||||
@@ -550,6 +569,14 @@ describe("Management", () => {
|
||||
expect(screen.getByText("96 installs / 800 downloads")).toBeTruthy();
|
||||
expect(screen.getByText("288 installs / 2,400 downloads")).toBeTruthy();
|
||||
expect(screen.getByText("1,200 installs / 10,000 downloads")).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Daily downloads over the last 30 days" })).toBeTruthy();
|
||||
expect(screen.getByRole("img", { name: "Daily installs over the last 30 days" })).toBeTruthy();
|
||||
expect(screen.getByText("30-day activity")).toBeTruthy();
|
||||
expect(screen.getByText("Downloads")).toBeTruthy();
|
||||
expect(screen.getByText("Installs")).toBeTruthy();
|
||||
const drawerZones = Array.from(document.querySelectorAll(".pa-sheet-body > .pa-zone"));
|
||||
expect(drawerZones[0]?.textContent).toContain("30-day activity");
|
||||
expect(drawerZones[1]?.textContent).toContain("Signal");
|
||||
expect(
|
||||
screen.getByText(/Platform 30d downloads across all 1,000 active skills: P95 900, P99 3,000/),
|
||||
).toBeTruthy();
|
||||
@@ -569,6 +596,18 @@ describe("Management", () => {
|
||||
JSON.stringify(args) === JSON.stringify({ reviewStatus: "open" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
useQueryMock.mock.calls.some(
|
||||
([query, args]) =>
|
||||
getFunctionName(query) === "publisherAbuse:getSignalActivityTrend" &&
|
||||
typeof args === "object" &&
|
||||
args !== null &&
|
||||
"signalId" in args &&
|
||||
args.signalId === "publisherAbuseSignals:ratio" &&
|
||||
"endDay" in args &&
|
||||
typeof args.endDay === "number",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("opens the signals tab from the management search param", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import {
|
||||
Ban,
|
||||
Clock3,
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { MetricTrendCard, MetricTrendCardSkeleton } from "../../components/MetricTrendCard";
|
||||
import { Badge, type BadgeProps } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "../../components/ui/sheet";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { getActivityTrendEndDay } from "../../lib/activityTrend";
|
||||
import { buildPublisherProfileHref, buildSkillDetailHref } from "../../lib/ownerRoute";
|
||||
import {
|
||||
formatPercent,
|
||||
@@ -1141,6 +1145,10 @@ function PublisherAbuseSignalInspector({
|
||||
const status = signalReviewStatus(item);
|
||||
const publisherHandle = signalPublisherHandle(item);
|
||||
const recurrenceCount = item.signal.recurrenceCount ?? 0;
|
||||
const activityTrend = useQuery(api.publisherAbuse.getSignalActivityTrend, {
|
||||
signalId: item.signal._id,
|
||||
endDay: getActivityTrendEndDay(item.signal.lastSeenAt),
|
||||
});
|
||||
const hasFreshEvidence =
|
||||
typeof item.signal.freshDownloadsSinceSnooze === "number" &&
|
||||
typeof item.signal.freshInstallsSinceSnooze === "number";
|
||||
@@ -1189,6 +1197,44 @@ function PublisherAbuseSignalInspector({
|
||||
</SheetHeader>
|
||||
|
||||
<div className="pa-sheet-body">
|
||||
<section className="pa-zone pa-signal-trends-zone">
|
||||
<div className="pa-section-label">30-day activity</div>
|
||||
<div className="pa-signal-trends" aria-label="30-day activity trends">
|
||||
<div className="pa-signal-trend">
|
||||
<div className="pa-signal-trend-label">Downloads</div>
|
||||
{activityTrend ? (
|
||||
<MetricTrendCard
|
||||
trend={activityTrend.downloads}
|
||||
ariaLabel="Daily downloads over the last 30 days"
|
||||
periodLabel="30 days"
|
||||
unitLabel="download"
|
||||
hideIdlePeriodLabel
|
||||
/>
|
||||
) : activityTrend === undefined ? (
|
||||
<MetricTrendCardSkeleton />
|
||||
) : (
|
||||
<span className="pa-hint">Trend unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pa-signal-trend pa-signal-trend-installs">
|
||||
<div className="pa-signal-trend-label">Installs</div>
|
||||
{activityTrend ? (
|
||||
<MetricTrendCard
|
||||
trend={activityTrend.installs}
|
||||
ariaLabel="Daily installs over the last 30 days"
|
||||
periodLabel="30 days"
|
||||
unitLabel="install"
|
||||
hideIdlePeriodLabel
|
||||
/>
|
||||
) : activityTrend === undefined ? (
|
||||
<MetricTrendCardSkeleton />
|
||||
) : (
|
||||
<span className="pa-hint">Trend unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pa-zone">
|
||||
<div className="pa-section-label">Signal</div>
|
||||
<div className="pa-reason-list">
|
||||
|
||||
@@ -15057,6 +15057,34 @@ code {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pa-signal-trends {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.pa-signal-trend {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.pa-signal-trend + .pa-signal-trend {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pa-signal-trend-label {
|
||||
color: var(--ink-soft);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pa-signal-trend-installs {
|
||||
--metric-trend-line: color-mix(in srgb, var(--status-success-fg) 76%, var(--ink));
|
||||
--metric-trend-area: color-mix(in srgb, var(--status-success-fg) 18%, transparent);
|
||||
--metric-trend-marker: color-mix(in srgb, var(--status-success-fg) 36%, transparent);
|
||||
}
|
||||
|
||||
.pa-section-label {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
|
||||
Reference in New Issue
Block a user