feat: doc status & share status (#14426)
#### PR Dependency Tree * **PR #14426** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Admin dashboard: view workspace analytics (storage, sync activity, top shared links) with charts and configurable windows. * Document analytics tab: see total/unique/guest views and trends over selectable time windows. * Last-accessed members: view who last accessed a document, with pagination. * Shared links analytics: browse and paginate all shared links with view/unique/guest metrics and share URLs. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "workspace_admin_stats_daily" (
|
||||||
|
"workspace_id" VARCHAR NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"snapshot_size" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"blob_size" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"member_count" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "workspace_admin_stats_daily_pkey" PRIMARY KEY ("workspace_id", "date"),
|
||||||
|
CONSTRAINT "workspace_admin_stats_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_daily_date_idx" ON "workspace_admin_stats_daily" ("date");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "sync_active_users_minutely" (
|
||||||
|
"minute_ts" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"active_users" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "sync_active_users_minutely_pkey" PRIMARY KEY ("minute_ts")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_doc_view_daily" (
|
||||||
|
"workspace_id" VARCHAR NOT NULL,
|
||||||
|
"doc_id" VARCHAR NOT NULL,
|
||||||
|
"date" DATE NOT NULL,
|
||||||
|
"total_views" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"unique_views" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"guest_views" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"last_accessed_at" TIMESTAMPTZ(3),
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "workspace_doc_view_daily_pkey" PRIMARY KEY ("workspace_id", "doc_id", "date"),
|
||||||
|
CONSTRAINT "workspace_doc_view_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_doc_view_daily_workspace_id_date_idx" ON "workspace_doc_view_daily" ("workspace_id", "date");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_member_last_access" (
|
||||||
|
"workspace_id" VARCHAR NOT NULL,
|
||||||
|
"user_id" VARCHAR NOT NULL,
|
||||||
|
"last_accessed_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"last_doc_id" VARCHAR,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "workspace_member_last_access_pkey" PRIMARY KEY ("workspace_id", "user_id"),
|
||||||
|
CONSTRAINT "workspace_member_last_access_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "workspace_member_last_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_accessed_at_idx" ON "workspace_member_last_access" ("workspace_id", "last_accessed_at" DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_doc_id_idx" ON "workspace_member_last_access" ("workspace_id", "last_doc_id");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_pages_public_published_at_idx" ON "workspace_pages" ("public", "published_at");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "ai_sessions_messages_created_at_role_idx" ON "ai_sessions_messages" ("created_at", "role");
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS user_features_set_feature_id ON "user_features";
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS workspace_features_set_feature_id ON "workspace_features";
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS set_user_feature_id_from_name();
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS set_workspace_feature_id_from_name();
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS ensure_feature_exists(TEXT);
|
||||||
|
|
||||||
|
ALTER TABLE
|
||||||
|
"user_features" DROP CONSTRAINT "user_features_feature_id_fkey";
|
||||||
|
|
||||||
|
ALTER TABLE
|
||||||
|
"workspace_features" DROP CONSTRAINT "workspace_features_feature_id_fkey";
|
||||||
|
|
||||||
|
DROP INDEX "user_features_feature_id_idx";
|
||||||
|
|
||||||
|
DROP INDEX "workspace_features_feature_id_idx";
|
||||||
|
|
||||||
|
ALTER TABLE
|
||||||
|
"user_features" DROP COLUMN "feature_id";
|
||||||
|
|
||||||
|
ALTER TABLE
|
||||||
|
"workspace_features" DROP COLUMN "feature_id";
|
||||||
|
|
||||||
|
DROP TABLE "features";
|
||||||
@@ -25,31 +25,32 @@ model User {
|
|||||||
registered Boolean @default(true)
|
registered Boolean @default(true)
|
||||||
disabled Boolean @default(false)
|
disabled Boolean @default(false)
|
||||||
|
|
||||||
features UserFeature[]
|
features UserFeature[]
|
||||||
userStripeCustomer UserStripeCustomer?
|
userStripeCustomer UserStripeCustomer?
|
||||||
workspaces WorkspaceUserRole[]
|
workspaces WorkspaceUserRole[]
|
||||||
// Invite others to join the workspace
|
// Invite others to join the workspace
|
||||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||||
docPermissions WorkspaceDocUserRole[]
|
docPermissions WorkspaceDocUserRole[]
|
||||||
connectedAccounts ConnectedAccount[]
|
connectedAccounts ConnectedAccount[]
|
||||||
calendarAccounts CalendarAccount[]
|
calendarAccounts CalendarAccount[]
|
||||||
sessions UserSession[]
|
sessions UserSession[]
|
||||||
aiSessions AiSession[]
|
aiSessions AiSession[]
|
||||||
appConfigs AppConfig[]
|
appConfigs AppConfig[]
|
||||||
userSnapshots UserSnapshot[]
|
userSnapshots UserSnapshot[]
|
||||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||||
createdUpdate Update[] @relation("createdUpdate")
|
createdUpdate Update[] @relation("createdUpdate")
|
||||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||||
// receive notifications
|
// receive notifications
|
||||||
notifications Notification[] @relation("user_notifications")
|
notifications Notification[] @relation("user_notifications")
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
replies Reply[]
|
replies Reply[]
|
||||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||||
AccessToken AccessToken[]
|
AccessToken AccessToken[]
|
||||||
workspaceCalendars WorkspaceCalendar[]
|
workspaceCalendars WorkspaceCalendar[]
|
||||||
|
workspaceMemberLastAccesses WorkspaceMemberLastAccess[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -151,6 +152,9 @@ model Workspace {
|
|||||||
workspaceCalendars WorkspaceCalendar[]
|
workspaceCalendars WorkspaceCalendar[]
|
||||||
workspaceAdminStats WorkspaceAdminStats[]
|
workspaceAdminStats WorkspaceAdminStats[]
|
||||||
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
|
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
|
||||||
|
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
|
||||||
|
workspaceDocViewDaily WorkspaceDocViewDaily[]
|
||||||
|
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
|
||||||
|
|
||||||
@@index([lastCheckEmbeddings])
|
@@index([lastCheckEmbeddings])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -180,6 +184,7 @@ model WorkspaceDoc {
|
|||||||
|
|
||||||
@@id([workspaceId, docId])
|
@@id([workspaceId, docId])
|
||||||
@@index([workspaceId, public])
|
@@index([workspaceId, public])
|
||||||
|
@@index([public, publishedAt])
|
||||||
@@map("workspace_pages")
|
@@map("workspace_pages")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +325,62 @@ model WorkspaceAdminStatsDirty {
|
|||||||
@@map("workspace_admin_stats_dirty")
|
@@map("workspace_admin_stats_dirty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WorkspaceAdminStatsDaily {
|
||||||
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
|
date DateTime @db.Date
|
||||||
|
snapshotSize BigInt @default(0) @map("snapshot_size") @db.BigInt
|
||||||
|
blobSize BigInt @default(0) @map("blob_size") @db.BigInt
|
||||||
|
memberCount BigInt @default(0) @map("member_count") @db.BigInt
|
||||||
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([workspaceId, date])
|
||||||
|
@@index([date])
|
||||||
|
@@map("workspace_admin_stats_daily")
|
||||||
|
}
|
||||||
|
|
||||||
|
model SyncActiveUsersMinutely {
|
||||||
|
minuteTs DateTime @id @map("minute_ts") @db.Timestamptz(3)
|
||||||
|
activeUsers Int @default(0) @map("active_users") @db.Integer
|
||||||
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@map("sync_active_users_minutely")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkspaceDocViewDaily {
|
||||||
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
|
docId String @map("doc_id") @db.VarChar
|
||||||
|
date DateTime @db.Date
|
||||||
|
totalViews BigInt @default(0) @map("total_views") @db.BigInt
|
||||||
|
uniqueViews BigInt @default(0) @map("unique_views") @db.BigInt
|
||||||
|
guestViews BigInt @default(0) @map("guest_views") @db.BigInt
|
||||||
|
lastAccessedAt DateTime? @map("last_accessed_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([workspaceId, docId, date])
|
||||||
|
@@index([workspaceId, date])
|
||||||
|
@@map("workspace_doc_view_daily")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkspaceMemberLastAccess {
|
||||||
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
|
userId String @map("user_id") @db.VarChar
|
||||||
|
lastAccessedAt DateTime @map("last_accessed_at") @db.Timestamptz(3)
|
||||||
|
lastDocId String? @map("last_doc_id") @db.VarChar
|
||||||
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([workspaceId, userId])
|
||||||
|
@@index([workspaceId, lastAccessedAt(sort: Desc)])
|
||||||
|
@@index([workspaceId, lastDocId])
|
||||||
|
@@map("workspace_member_last_access")
|
||||||
|
}
|
||||||
|
|
||||||
// the latest snapshot of each doc that we've seen
|
// the latest snapshot of each doc that we've seen
|
||||||
// Snapshot + Updates are the latest state of the doc
|
// Snapshot + Updates are the latest state of the doc
|
||||||
model Snapshot {
|
model Snapshot {
|
||||||
@@ -456,6 +517,7 @@ model AiSessionMessage {
|
|||||||
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
|
@@index([createdAt, role])
|
||||||
@@map("ai_sessions_messages")
|
@@map("ai_sessions_messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { app, e2e, Mockers } from '../test';
|
||||||
|
|
||||||
|
async function gql(query: string, variables?: Record<string, unknown>) {
|
||||||
|
const res = await app.POST('/graphql').send({ query, variables }).expect(200);
|
||||||
|
return res.body as {
|
||||||
|
data?: Record<string, any>;
|
||||||
|
errors?: Array<{ message: string; extensions: Record<string, any> }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAnalyticsTables(db: PrismaClient) {
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_admin_stats_daily (
|
||||||
|
workspace_id VARCHAR NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
snapshot_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
blob_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
member_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (workspace_id, date)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
|
||||||
|
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
|
||||||
|
active_users INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_doc_view_daily (
|
||||||
|
workspace_id VARCHAR NOT NULL,
|
||||||
|
doc_id VARCHAR NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
total_views BIGINT NOT NULL DEFAULT 0,
|
||||||
|
unique_views BIGINT NOT NULL DEFAULT 0,
|
||||||
|
guest_views BIGINT NOT NULL DEFAULT 0,
|
||||||
|
last_accessed_at TIMESTAMPTZ(3),
|
||||||
|
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (workspace_id, doc_id, date)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_member_last_access (
|
||||||
|
workspace_id VARCHAR NOT NULL,
|
||||||
|
user_id VARCHAR NOT NULL,
|
||||||
|
last_accessed_at TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
last_doc_id VARCHAR,
|
||||||
|
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (workspace_id, user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPublicDoc(input: {
|
||||||
|
workspaceId: string;
|
||||||
|
ownerId: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
publishedAt: Date;
|
||||||
|
}) {
|
||||||
|
const snapshot = await app.create(Mockers.DocSnapshot, {
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
user: { id: input.ownerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.create(Mockers.DocMeta, {
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
docId: snapshot.id,
|
||||||
|
title: input.title,
|
||||||
|
public: true,
|
||||||
|
publishedAt: input.publishedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = app.get(PrismaClient);
|
||||||
|
await db.snapshot.update({
|
||||||
|
where: {
|
||||||
|
workspaceId_id: {
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
id: snapshot.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
updatedAt: input.updatedAt,
|
||||||
|
updatedBy: input.ownerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return snapshot.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
e2e(
|
||||||
|
'adminAllSharedLinks should support stable pagination and includeTotal',
|
||||||
|
async t => {
|
||||||
|
const admin = await app.create(Mockers.User, {
|
||||||
|
feature: 'administrator',
|
||||||
|
});
|
||||||
|
await app.login(admin);
|
||||||
|
|
||||||
|
const owner = await app.create(Mockers.User);
|
||||||
|
const workspace = await app.create(Mockers.Workspace, {
|
||||||
|
owner: { id: owner.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const newerDocId = await createPublicDoc({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
ownerId: owner.id,
|
||||||
|
title: 'newer-doc',
|
||||||
|
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||||||
|
publishedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
const olderDocId = await createPublicDoc({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
ownerId: owner.id,
|
||||||
|
title: 'older-doc',
|
||||||
|
updatedAt: new Date('2026-02-10T10:00:00.000Z'),
|
||||||
|
publishedAt: new Date('2026-02-10T10:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = app.get(PrismaClient);
|
||||||
|
await ensureAnalyticsTables(db);
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO workspace_doc_view_daily (
|
||||||
|
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(${workspace.id}, ${newerDocId}, CURRENT_DATE, 10, 8, 2, NOW(), NOW()),
|
||||||
|
(${workspace.id}, ${olderDocId}, CURRENT_DATE, 5, 4, 1, NOW(), NOW())
|
||||||
|
ON CONFLICT (workspace_id, doc_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_views = EXCLUDED.total_views,
|
||||||
|
unique_views = EXCLUDED.unique_views,
|
||||||
|
guest_views = EXCLUDED.guest_views,
|
||||||
|
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query AdminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
|
||||||
|
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||||
|
totalCount
|
||||||
|
analyticsWindow {
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
cursor
|
||||||
|
node {
|
||||||
|
workspaceId
|
||||||
|
docId
|
||||||
|
title
|
||||||
|
shareUrl
|
||||||
|
views
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const firstPage = await gql(query, {
|
||||||
|
pagination: { first: 1, offset: 0 },
|
||||||
|
filter: {
|
||||||
|
includeTotal: false,
|
||||||
|
orderBy: 'UpdatedAtDesc',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.falsy(firstPage.errors);
|
||||||
|
const first = firstPage.data!.adminAllSharedLinks;
|
||||||
|
t.is(first.totalCount, null);
|
||||||
|
t.true(first.pageInfo.hasNextPage);
|
||||||
|
t.is(first.edges.length, 1);
|
||||||
|
t.true([newerDocId, olderDocId].includes(first.edges[0].node.docId));
|
||||||
|
t.true(
|
||||||
|
first.edges[0].node.shareUrl.includes(`/workspace/${workspace.id}/`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondPage = await gql(query, {
|
||||||
|
pagination: { first: 1, offset: 0, after: first.pageInfo.endCursor },
|
||||||
|
filter: {
|
||||||
|
includeTotal: true,
|
||||||
|
orderBy: 'UpdatedAtDesc',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.falsy(secondPage.errors);
|
||||||
|
const second = secondPage.data!.adminAllSharedLinks;
|
||||||
|
t.is(second.totalCount, 2);
|
||||||
|
t.is(second.edges.length, 1);
|
||||||
|
t.not(second.edges[0].node.docId, first.edges[0].node.docId);
|
||||||
|
|
||||||
|
const conflict = await gql(query, {
|
||||||
|
pagination: {
|
||||||
|
first: 1,
|
||||||
|
offset: 1,
|
||||||
|
after: first.pageInfo.endCursor,
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
includeTotal: false,
|
||||||
|
orderBy: 'UpdatedAtDesc',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.truthy(conflict.errors?.length);
|
||||||
|
t.is(conflict.errors![0].extensions.name, 'BAD_REQUEST');
|
||||||
|
|
||||||
|
const malformedDateCursor = await gql(query, {
|
||||||
|
pagination: {
|
||||||
|
first: 1,
|
||||||
|
offset: 0,
|
||||||
|
after: JSON.stringify({
|
||||||
|
orderBy: 'UpdatedAtDesc',
|
||||||
|
sortValue: 'not-a-date',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId: newerDocId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
includeTotal: false,
|
||||||
|
orderBy: 'UpdatedAtDesc',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.truthy(malformedDateCursor.errors?.length);
|
||||||
|
t.is(malformedDateCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||||
|
|
||||||
|
const malformedViewsCursor = await gql(query, {
|
||||||
|
pagination: {
|
||||||
|
first: 1,
|
||||||
|
offset: 0,
|
||||||
|
after: JSON.stringify({
|
||||||
|
orderBy: 'ViewsDesc',
|
||||||
|
sortValue: 'NaN',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId: newerDocId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
includeTotal: false,
|
||||||
|
orderBy: 'ViewsDesc',
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.truthy(malformedViewsCursor.errors?.length);
|
||||||
|
t.is(malformedViewsCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
e2e(
|
||||||
|
'adminDashboard should clamp window inputs and return expected buckets',
|
||||||
|
async t => {
|
||||||
|
const admin = await app.create(Mockers.User, {
|
||||||
|
feature: 'administrator',
|
||||||
|
});
|
||||||
|
await app.login(admin);
|
||||||
|
|
||||||
|
const owner = await app.create(Mockers.User);
|
||||||
|
const workspace = await app.create(Mockers.Workspace, {
|
||||||
|
owner: { id: owner.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const docId = await createPublicDoc({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
ownerId: owner.id,
|
||||||
|
title: 'dashboard-doc',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
publishedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = app.get(PrismaClient);
|
||||||
|
await ensureAnalyticsTables(db);
|
||||||
|
const minute = new Date();
|
||||||
|
minute.setSeconds(0, 0);
|
||||||
|
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
|
||||||
|
VALUES (${minute}, 7, NOW())
|
||||||
|
ON CONFLICT (minute_ts)
|
||||||
|
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO workspace_admin_stats (
|
||||||
|
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
|
||||||
|
)
|
||||||
|
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW())
|
||||||
|
ON CONFLICT (workspace_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
snapshot_count = EXCLUDED.snapshot_count,
|
||||||
|
snapshot_size = EXCLUDED.snapshot_size,
|
||||||
|
blob_count = EXCLUDED.blob_count,
|
||||||
|
blob_size = EXCLUDED.blob_size,
|
||||||
|
member_count = EXCLUDED.member_count,
|
||||||
|
public_page_count = EXCLUDED.public_page_count,
|
||||||
|
features = EXCLUDED.features,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO workspace_admin_stats_daily (
|
||||||
|
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
|
||||||
|
)
|
||||||
|
VALUES (${workspace.id}, CURRENT_DATE, 100, 50, 1, NOW())
|
||||||
|
ON CONFLICT (workspace_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
snapshot_size = EXCLUDED.snapshot_size,
|
||||||
|
blob_size = EXCLUDED.blob_size,
|
||||||
|
member_count = EXCLUDED.member_count,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO workspace_doc_view_daily (
|
||||||
|
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 3, 2, 1, NOW(), NOW())
|
||||||
|
ON CONFLICT (workspace_id, doc_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_views = EXCLUDED.total_views,
|
||||||
|
unique_views = EXCLUDED.unique_views,
|
||||||
|
guest_views = EXCLUDED.guest_views,
|
||||||
|
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dashboardQuery = `
|
||||||
|
query AdminDashboard($input: AdminDashboardInput) {
|
||||||
|
adminDashboard(input: $input) {
|
||||||
|
syncWindow {
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
storageWindow {
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
topSharedLinksWindow {
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
syncActiveUsersTimeline {
|
||||||
|
minute
|
||||||
|
activeUsers
|
||||||
|
}
|
||||||
|
workspaceStorageHistory {
|
||||||
|
date
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await gql(dashboardQuery, {
|
||||||
|
input: {
|
||||||
|
storageHistoryDays: -10,
|
||||||
|
syncHistoryHours: -10,
|
||||||
|
sharedLinkWindowDays: -10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.falsy(result.errors);
|
||||||
|
const dashboard = result.data!.adminDashboard;
|
||||||
|
t.is(dashboard.syncWindow.bucket, 'Minute');
|
||||||
|
t.is(dashboard.syncWindow.effectiveSize, 1);
|
||||||
|
t.is(dashboard.storageWindow.bucket, 'Day');
|
||||||
|
t.is(dashboard.storageWindow.effectiveSize, 1);
|
||||||
|
t.is(dashboard.topSharedLinksWindow.effectiveSize, 1);
|
||||||
|
t.is(dashboard.syncActiveUsersTimeline.length, 1);
|
||||||
|
t.is(dashboard.workspaceStorageHistory.length, 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
e2e(
|
||||||
|
'Doc analytics and lastAccessedMembers should enforce permissions and privacy',
|
||||||
|
async t => {
|
||||||
|
const owner = await app.signup();
|
||||||
|
const member = await app.create(Mockers.User);
|
||||||
|
const staleMember = await app.create(Mockers.User);
|
||||||
|
|
||||||
|
const workspace = await app.create(Mockers.Workspace, {
|
||||||
|
owner: { id: owner.id },
|
||||||
|
});
|
||||||
|
await app.create(Mockers.WorkspaceUser, {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: member.id,
|
||||||
|
});
|
||||||
|
await app.create(Mockers.WorkspaceUser, {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId: staleMember.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docId = await createPublicDoc({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
ownerId: owner.id,
|
||||||
|
title: 'page-analytics-doc',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
publishedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = app.get(PrismaClient);
|
||||||
|
await ensureAnalyticsTables(db);
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO workspace_doc_view_daily (
|
||||||
|
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 9, 5, 2, NOW(), NOW())
|
||||||
|
ON CONFLICT (workspace_id, doc_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_views = EXCLUDED.total_views,
|
||||||
|
unique_views = EXCLUDED.unique_views,
|
||||||
|
guest_views = EXCLUDED.guest_views,
|
||||||
|
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.$executeRaw`
|
||||||
|
INSERT INTO workspace_member_last_access (
|
||||||
|
workspace_id, user_id, last_accessed_at, last_doc_id, updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(${workspace.id}, ${owner.id}, NOW(), ${docId}, NOW()),
|
||||||
|
(${workspace.id}, ${member.id}, NOW() - interval '1 minute', ${docId}, NOW()),
|
||||||
|
(${workspace.id}, ${staleMember.id}, NOW() - interval '8 day', ${docId}, NOW())
|
||||||
|
ON CONFLICT (workspace_id, user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||||
|
last_doc_id = EXCLUDED.last_doc_id,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const analyticsQuery = `
|
||||||
|
query DocAnalytics($workspaceId: String!, $docId: String!) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
analytics(input: { windowDays: 999 }) {
|
||||||
|
window {
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
series {
|
||||||
|
date
|
||||||
|
totalViews
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
totalViews
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastAccessedMembers(
|
||||||
|
pagination: { first: 100, offset: 0 }
|
||||||
|
includeTotal: true
|
||||||
|
) {
|
||||||
|
totalCount
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
lastAccessedAt
|
||||||
|
lastDocId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await app.login(owner);
|
||||||
|
const ownerResult = await gql(analyticsQuery, {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
});
|
||||||
|
|
||||||
|
t.falsy(ownerResult.errors);
|
||||||
|
t.is(ownerResult.data!.workspace.doc.analytics.window.effectiveSize, 7);
|
||||||
|
t.true(ownerResult.data!.workspace.doc.analytics.series.length > 0);
|
||||||
|
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.totalCount, 2);
|
||||||
|
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.edges.length, 2);
|
||||||
|
t.false(
|
||||||
|
ownerResult.data!.workspace.doc.lastAccessedMembers.edges.some(
|
||||||
|
(edge: { node: { user: { id: string } } }) =>
|
||||||
|
edge.node.user.id === staleMember.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const malformedMembersCursor = await gql(
|
||||||
|
`
|
||||||
|
query DocMembersCursor($workspaceId: String!, $docId: String!, $after: String) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
lastAccessedMembers(
|
||||||
|
pagination: { first: 10, offset: 0, after: $after }
|
||||||
|
) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
after: JSON.stringify({
|
||||||
|
lastAccessedAt: 'not-a-date',
|
||||||
|
userId: owner.id,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
t.truthy(malformedMembersCursor.errors?.length);
|
||||||
|
t.is(malformedMembersCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||||
|
|
||||||
|
const privacyQuery = `
|
||||||
|
query DocMembersPrivacy($workspaceId: String!, $docId: String!) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const privacyRes = await app
|
||||||
|
.POST('/graphql')
|
||||||
|
.send({
|
||||||
|
query: privacyQuery,
|
||||||
|
variables: {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
const privacyResult = privacyRes.body as {
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
t.truthy(privacyResult.errors?.length);
|
||||||
|
t.true(
|
||||||
|
privacyResult.errors![0].message.includes(
|
||||||
|
'Cannot query field "email" on type "PublicUserType"'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.login(member);
|
||||||
|
const memberDeniedRes = await app
|
||||||
|
.POST('/graphql')
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
query DocMembersDenied($workspaceId: String!, $docId: String!) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: { workspaceId: workspace.id, docId },
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const memberDenied = memberDeniedRes.body as {
|
||||||
|
errors?: Array<{ extensions: Record<string, unknown> }>;
|
||||||
|
};
|
||||||
|
t.truthy(memberDenied.errors?.length);
|
||||||
|
t.is(memberDenied.errors![0].extensions.name, 'SPACE_ACCESS_DENIED');
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
import test, { type ExecutionContext } from 'ava';
|
import test, { type ExecutionContext } from 'ava';
|
||||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||||
import { Doc, encodeStateAsUpdate } from 'yjs';
|
import { Doc, encodeStateAsUpdate } from 'yjs';
|
||||||
@@ -146,6 +147,44 @@ function createYjsUpdateBase64() {
|
|||||||
return Buffer.from(update).toString('base64');
|
return Buffer.from(update).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureSyncActiveUsersTable(db: PrismaClient) {
|
||||||
|
await db.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
|
||||||
|
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
|
||||||
|
active_users INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestActiveUsers(db: PrismaClient) {
|
||||||
|
const rows = await db.$queryRaw<{ activeUsers: number }[]>`
|
||||||
|
SELECT active_users::integer AS "activeUsers"
|
||||||
|
FROM sync_active_users_minutely
|
||||||
|
ORDER BY minute_ts DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!rows[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(rows[0].activeUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForActiveUsers(db: PrismaClient, expected: number) {
|
||||||
|
const deadline = Date.now() + WS_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const current = await latestActiveUsers(db);
|
||||||
|
if (current === expected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timed out waiting active users=${expected}`);
|
||||||
|
}
|
||||||
|
|
||||||
let app: TestingApp;
|
let app: TestingApp;
|
||||||
let url: string;
|
let url: string;
|
||||||
|
|
||||||
@@ -461,3 +500,22 @@ test('space:join-awareness should reject clientVersion<0.25.0', async t => {
|
|||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('active users metric should dedupe multiple sockets for one user', async t => {
|
||||||
|
const db = app.get(PrismaClient);
|
||||||
|
await ensureSyncActiveUsersTable(db);
|
||||||
|
|
||||||
|
const { cookieHeader } = await login(app);
|
||||||
|
const first = createClient(url, cookieHeader);
|
||||||
|
const second = createClient(url, cookieHeader);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([waitForConnect(first), waitForConnect(second)]);
|
||||||
|
await waitForActiveUsers(db, 1);
|
||||||
|
t.pass();
|
||||||
|
} finally {
|
||||||
|
first.disconnect();
|
||||||
|
second.disconnect();
|
||||||
|
await Promise.all([waitForDisconnect(first), waitForDisconnect(second)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -217,6 +217,35 @@ test('should be able to get doc', async t => {
|
|||||||
t.deepEqual(res.body, Buffer.from([0, 0]));
|
t.deepEqual(res.body, Buffer.from([0, 0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should record doc view when reading doc', async t => {
|
||||||
|
const { app, workspace: doc, models } = t.context;
|
||||||
|
|
||||||
|
doc.getDoc.resolves({
|
||||||
|
spaceId: '',
|
||||||
|
docId: '',
|
||||||
|
bin: Buffer.from([0, 0]),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const record = Sinon.stub(
|
||||||
|
models.workspaceAnalytics,
|
||||||
|
'recordDocView'
|
||||||
|
).resolves();
|
||||||
|
await app.login(t.context.u1);
|
||||||
|
|
||||||
|
const res = await app.GET('/api/workspaces/private/docs/public');
|
||||||
|
t.is(res.status, HttpStatus.OK);
|
||||||
|
t.true(record.calledOnce);
|
||||||
|
t.like(record.firstCall.args[0], {
|
||||||
|
workspaceId: 'private',
|
||||||
|
docId: 'public',
|
||||||
|
userId: t.context.u1.id,
|
||||||
|
isGuest: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
record.restore();
|
||||||
|
});
|
||||||
|
|
||||||
test('should be able to change page publish mode', async t => {
|
test('should be able to change page publish mode', async t => {
|
||||||
const { app, workspace: doc, models } = t.context;
|
const { app, workspace: doc, models } = t.context;
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ test('should decode pagination input', async t => {
|
|||||||
await app.gql(query, {
|
await app.gql(query, {
|
||||||
input: {
|
input: {
|
||||||
first: 5,
|
first: 5,
|
||||||
offset: 1,
|
offset: 0,
|
||||||
after: Buffer.from('4').toString('base64'),
|
after: Buffer.from('4').toString('base64'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -90,12 +90,34 @@ test('should decode pagination input', async t => {
|
|||||||
t.true(
|
t.true(
|
||||||
paginationStub.calledOnceWithExactly({
|
paginationStub.calledOnceWithExactly({
|
||||||
first: 5,
|
first: 5,
|
||||||
offset: 1,
|
offset: 0,
|
||||||
after: '4',
|
after: '4',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should reject mixed pagination cursor and offset', async t => {
|
||||||
|
const res = await app.POST('/graphql').send({
|
||||||
|
query,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
first: 5,
|
||||||
|
offset: 1,
|
||||||
|
after: Buffer.from('4').toString('base64'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.is(res.status, 200);
|
||||||
|
t.truthy(res.body.errors?.length);
|
||||||
|
t.is(
|
||||||
|
res.body.errors[0].message,
|
||||||
|
'pagination.after and pagination.offset cannot be used together'
|
||||||
|
);
|
||||||
|
t.is(res.body.errors[0].extensions.status, 400);
|
||||||
|
t.is(res.body.errors[0].extensions.name, 'BAD_REQUEST');
|
||||||
|
});
|
||||||
|
|
||||||
test('should return encode pageInfo', async t => {
|
test('should return encode pageInfo', async t => {
|
||||||
const result = paginate(
|
const result = paginate(
|
||||||
ITEMS.slice(10, 20),
|
ITEMS.slice(10, 20),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { PipeTransform, Type } from '@nestjs/common';
|
import { PipeTransform, Type } from '@nestjs/common';
|
||||||
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
|
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { BadRequest } from '../error';
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class PaginationInput {
|
export class PaginationInput {
|
||||||
/**
|
/**
|
||||||
@@ -13,11 +15,15 @@ export class PaginationInput {
|
|||||||
*/
|
*/
|
||||||
static decode: PipeTransform<PaginationInput, PaginationInput> = {
|
static decode: PipeTransform<PaginationInput, PaginationInput> = {
|
||||||
transform: value => {
|
transform: value => {
|
||||||
return {
|
const input = {
|
||||||
...value,
|
...value,
|
||||||
|
first: Math.min(Math.max(value?.first ?? 10, 1), 100),
|
||||||
|
offset: Math.max(value?.offset ?? 0, 0),
|
||||||
after: decode(value?.after),
|
after: decode(value?.after),
|
||||||
// before: decode(value.before),
|
// before: decode(value.before),
|
||||||
};
|
};
|
||||||
|
assertPaginationInput(input);
|
||||||
|
return input;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +57,18 @@ export class PaginationInput {
|
|||||||
// before?: string | null;
|
// before?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertPaginationInput(paginationInput?: PaginationInput) {
|
||||||
|
if (!paginationInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paginationInput.after && paginationInput.offset > 0) {
|
||||||
|
throw new BadRequest(
|
||||||
|
'pagination.after and pagination.offset cannot be used together'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const encode = (input: unknown) => {
|
const encode = (input: unknown) => {
|
||||||
let inputStr: string;
|
let inputStr: string;
|
||||||
if (input instanceof Date) {
|
if (input instanceof Date) {
|
||||||
@@ -65,7 +83,7 @@ const encode = (input: unknown) => {
|
|||||||
const decode = (base64String?: string | null) =>
|
const decode = (base64String?: string | null) =>
|
||||||
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
|
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
|
||||||
|
|
||||||
function encodeWithJson(input: unknown) {
|
export function encodeWithJson(input: unknown) {
|
||||||
return encode(JSON.stringify(input ?? null));
|
return encode(JSON.stringify(input ?? null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import { randomUUID } from 'node:crypto';
|
|||||||
|
|
||||||
import { User, Workspace } from '@prisma/client';
|
import { User, Workspace } from '@prisma/client';
|
||||||
import ava, { TestFn } from 'ava';
|
import ava, { TestFn } from 'ava';
|
||||||
|
import Sinon from 'sinon';
|
||||||
import { Doc as YDoc } from 'yjs';
|
import { Doc as YDoc } from 'yjs';
|
||||||
|
|
||||||
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
|
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
|
||||||
import { ConfigFactory } from '../../../base';
|
import { ConfigFactory } from '../../../base';
|
||||||
import { Flavor } from '../../../env';
|
import { Flavor } from '../../../env';
|
||||||
import { Models } from '../../../models';
|
import { Models } from '../../../models';
|
||||||
import { PgWorkspaceDocStorageAdapter } from '../../doc';
|
import { DocReader, PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||||
|
|
||||||
const test = ava as TestFn<{
|
const test = ava as TestFn<{
|
||||||
models: Models;
|
models: Models;
|
||||||
app: TestingApp;
|
app: TestingApp;
|
||||||
adapter: PgWorkspaceDocStorageAdapter;
|
adapter: PgWorkspaceDocStorageAdapter;
|
||||||
|
docReader: DocReader;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
test.before(async t => {
|
test.before(async t => {
|
||||||
@@ -23,6 +25,7 @@ test.before(async t => {
|
|||||||
|
|
||||||
t.context.models = app.get(Models);
|
t.context.models = app.get(Models);
|
||||||
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
|
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
|
||||||
|
t.context.docReader = app.get(DocReader);
|
||||||
t.context.app = app;
|
t.context.app = app;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,3 +71,41 @@ test('should render page success', async t => {
|
|||||||
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
||||||
t.pass();
|
t.pass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should record page view when rendering shared page', async t => {
|
||||||
|
const docId = randomUUID();
|
||||||
|
const { app, adapter, models, docReader } = t.context;
|
||||||
|
|
||||||
|
const doc = new YDoc();
|
||||||
|
const text = doc.getText('content');
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
|
||||||
|
doc.on('update', update => {
|
||||||
|
updates.push(Buffer.from(update));
|
||||||
|
});
|
||||||
|
|
||||||
|
text.insert(0, 'analytics');
|
||||||
|
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
|
||||||
|
await models.doc.publish(workspace.id, docId);
|
||||||
|
|
||||||
|
const docContent = Sinon.stub(docReader, 'getDocContent').resolves({
|
||||||
|
title: 'analytics-doc',
|
||||||
|
summary: 'summary',
|
||||||
|
});
|
||||||
|
const record = Sinon.stub(
|
||||||
|
models.workspaceAnalytics,
|
||||||
|
'recordDocView'
|
||||||
|
).resolves();
|
||||||
|
|
||||||
|
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
||||||
|
|
||||||
|
t.true(record.calledOnce);
|
||||||
|
t.like(record.firstCall.args[0], {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
docId,
|
||||||
|
isGuest: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
docContent.restore();
|
||||||
|
record.restore();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
@@ -5,7 +6,7 @@ import { Controller, Get, Logger, Req, Res } from '@nestjs/common';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import isMobile from 'is-mobile';
|
import isMobile from 'is-mobile';
|
||||||
|
|
||||||
import { Config, metrics } from '../../base';
|
import { Config, getRequestTrackerId, metrics } from '../../base';
|
||||||
import { Models } from '../../models';
|
import { Models } from '../../models';
|
||||||
import { htmlSanitize } from '../../native';
|
import { htmlSanitize } from '../../native';
|
||||||
import { Public } from '../auth';
|
import { Public } from '../auth';
|
||||||
@@ -60,6 +61,13 @@ export class DocRendererController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
|
||||||
|
const tracker = getRequestTrackerId(req);
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(`${workspaceId}:${docId}:${tracker}`)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('/*path')
|
@Get('/*path')
|
||||||
async render(@Req() req: Request, @Res() res: Response) {
|
async render(@Req() req: Request, @Res() res: Response) {
|
||||||
@@ -83,6 +91,22 @@ export class DocRendererController {
|
|||||||
? await this.getWorkspaceContent(workspaceId)
|
? await this.getWorkspaceContent(workspaceId)
|
||||||
: await this.getPageContent(workspaceId, subPath);
|
: await this.getPageContent(workspaceId, subPath);
|
||||||
metrics.doc.counter('render').add(1);
|
metrics.doc.counter('render').add(1);
|
||||||
|
|
||||||
|
if (opts && workspaceId !== subPath) {
|
||||||
|
void this.models.workspaceAnalytics
|
||||||
|
.recordDocView({
|
||||||
|
workspaceId,
|
||||||
|
docId: subPath,
|
||||||
|
visitorId: this.buildVisitorId(req, workspaceId, subPath),
|
||||||
|
isGuest: true,
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to record shared page view: ${workspaceId}/${subPath}`,
|
||||||
|
error as Error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error('failed to render page', e);
|
this.logger.error('failed to render page', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { applyDecorators, Logger, UseInterceptors } from '@nestjs/common';
|
import {
|
||||||
|
applyDecorators,
|
||||||
|
Logger,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ConnectedSocket,
|
ConnectedSocket,
|
||||||
MessageBody,
|
MessageBody,
|
||||||
@@ -8,6 +14,7 @@ import {
|
|||||||
WebSocketGateway,
|
WebSocketGateway,
|
||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { ClsInterceptor } from 'nestjs-cls';
|
import { ClsInterceptor } from 'nestjs-cls';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
import { type Server, Socket } from 'socket.io';
|
import { type Server, Socket } from 'socket.io';
|
||||||
@@ -71,6 +78,7 @@ const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
|
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
|
||||||
|
const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId';
|
||||||
|
|
||||||
function normalizeWsClientVersion(clientVersion: string): string | null {
|
function normalizeWsClientVersion(clientVersion: string): string | null {
|
||||||
if (env.namespaces.canary) {
|
if (env.namespaces.canary) {
|
||||||
@@ -190,7 +198,11 @@ interface UpdateAwarenessMessage {
|
|||||||
@WebSocketGateway()
|
@WebSocketGateway()
|
||||||
@UseInterceptors(ClsInterceptor)
|
@UseInterceptors(ClsInterceptor)
|
||||||
export class SpaceSyncGateway
|
export class SpaceSyncGateway
|
||||||
implements OnGatewayConnection, OnGatewayDisconnect
|
implements
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
OnModuleInit,
|
||||||
|
OnModuleDestroy
|
||||||
{
|
{
|
||||||
protected logger = new Logger(SpaceSyncGateway.name);
|
protected logger = new Logger(SpaceSyncGateway.name);
|
||||||
|
|
||||||
@@ -198,6 +210,7 @@ export class SpaceSyncGateway
|
|||||||
private readonly server!: Server;
|
private readonly server!: Server;
|
||||||
|
|
||||||
private connectionCount = 0;
|
private connectionCount = 0;
|
||||||
|
private flushTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ac: AccessController,
|
private readonly ac: AccessController,
|
||||||
@@ -208,6 +221,22 @@ export class SpaceSyncGateway
|
|||||||
private readonly models: Models
|
private readonly models: Models
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.flushTimer = setInterval(() => {
|
||||||
|
this.flushActiveUsersMinute().catch(error => {
|
||||||
|
this.logger.warn('Failed to flush active users minute', error as Error);
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
this.flushTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.flushTimer) {
|
||||||
|
clearInterval(this.flushTimer);
|
||||||
|
this.flushTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private encodeUpdates(updates: Uint8Array[]) {
|
private encodeUpdates(updates: Uint8Array[]) {
|
||||||
return updates.map(update => Buffer.from(update).toString('base64'));
|
return updates.map(update => Buffer.from(update).toString('base64'));
|
||||||
}
|
}
|
||||||
@@ -269,18 +298,95 @@ export class SpaceSyncGateway
|
|||||||
setImmediate(() => client.disconnect());
|
setImmediate(() => client.disconnect());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection() {
|
handleConnection(client: Socket) {
|
||||||
this.connectionCount++;
|
this.connectionCount++;
|
||||||
this.logger.debug(`New connection, total: ${this.connectionCount}`);
|
this.logger.debug(`New connection, total: ${this.connectionCount}`);
|
||||||
metrics.socketio.gauge('connections').record(this.connectionCount);
|
metrics.socketio.gauge('connections').record(this.connectionCount);
|
||||||
|
this.attachPresenceUserId(client);
|
||||||
|
this.flushActiveUsersMinute().catch(error => {
|
||||||
|
this.logger.warn('Failed to flush active users minute', error as Error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect() {
|
handleDisconnect(_client: Socket) {
|
||||||
this.connectionCount--;
|
this.connectionCount = Math.max(0, this.connectionCount - 1);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Connection disconnected, total: ${this.connectionCount}`
|
`Connection disconnected, total: ${this.connectionCount}`
|
||||||
);
|
);
|
||||||
metrics.socketio.gauge('connections').record(this.connectionCount);
|
metrics.socketio.gauge('connections').record(this.connectionCount);
|
||||||
|
void this.flushActiveUsersMinute({
|
||||||
|
aggregateAcrossCluster: false,
|
||||||
|
}).catch(error => {
|
||||||
|
this.logger.warn('Failed to flush active users minute', error as Error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachPresenceUserId(client: Socket) {
|
||||||
|
const request = client.request as Request;
|
||||||
|
const userId = request.session?.user.id ?? request.token?.user.id;
|
||||||
|
if (typeof userId !== 'string' || !userId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to resolve authenticated user id for socket ${client.id}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.data[SOCKET_PRESENCE_USER_ID_KEY] = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePresenceUserId(socket: { data?: unknown }) {
|
||||||
|
if (!socket.data || typeof socket.data !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (socket.data as Record<string, unknown>)[
|
||||||
|
SOCKET_PRESENCE_USER_ID_KEY
|
||||||
|
];
|
||||||
|
return typeof userId === 'string' && userId ? userId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushActiveUsersMinute(options?: {
|
||||||
|
aggregateAcrossCluster?: boolean;
|
||||||
|
}) {
|
||||||
|
const minute = new Date();
|
||||||
|
minute.setSeconds(0, 0);
|
||||||
|
|
||||||
|
const aggregateAcrossCluster = options?.aggregateAcrossCluster ?? true;
|
||||||
|
let activeUsers = Math.max(0, this.connectionCount);
|
||||||
|
if (aggregateAcrossCluster) {
|
||||||
|
try {
|
||||||
|
const sockets = await this.server.fetchSockets();
|
||||||
|
const uniqueUsers = new Set<string>();
|
||||||
|
let missingUserCount = 0;
|
||||||
|
for (const socket of sockets) {
|
||||||
|
const userId = this.resolvePresenceUserId(socket);
|
||||||
|
if (userId) {
|
||||||
|
uniqueUsers.add(userId);
|
||||||
|
} else {
|
||||||
|
missingUserCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingUserCount > 0) {
|
||||||
|
activeUsers = sockets.length;
|
||||||
|
this.logger.warn(
|
||||||
|
`Unable to resolve user id for ${missingUserCount} active sockets, fallback to connection count`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
activeUsers = uniqueUsers.size;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Failed to aggregate active users from sockets, using local value',
|
||||||
|
error as Error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.models.workspaceAnalytics.upsertSyncActiveUsersMinute(
|
||||||
|
minute,
|
||||||
|
activeUsers
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent('doc.updates.pushed')
|
@OnEvent('doc.updates.pushed')
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
|
import { createHash } from 'node:crypto';
|
||||||
import type { Response } from 'express';
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Logger,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyAttachHeaders,
|
applyAttachHeaders,
|
||||||
@@ -8,6 +18,7 @@ import {
|
|||||||
CommentAttachmentNotFound,
|
CommentAttachmentNotFound,
|
||||||
DocHistoryNotFound,
|
DocHistoryNotFound,
|
||||||
DocNotFound,
|
DocNotFound,
|
||||||
|
getRequestTrackerId,
|
||||||
InvalidHistoryTimestamp,
|
InvalidHistoryTimestamp,
|
||||||
} from '../../base';
|
} from '../../base';
|
||||||
import { DocMode, Models, PublicDocMode } from '../../models';
|
import { DocMode, Models, PublicDocMode } from '../../models';
|
||||||
@@ -30,6 +41,13 @@ export class WorkspacesController {
|
|||||||
private readonly models: Models
|
private readonly models: Models
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
|
||||||
|
const tracker = getRequestTrackerId(req);
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(`${workspaceId}:${docId}:${tracker}`)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
// get workspace blob
|
// get workspace blob
|
||||||
//
|
//
|
||||||
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
||||||
@@ -99,6 +117,7 @@ export class WorkspacesController {
|
|||||||
@CallMetric('controllers', 'workspace_get_doc')
|
@CallMetric('controllers', 'workspace_get_doc')
|
||||||
async doc(
|
async doc(
|
||||||
@CurrentUser() user: CurrentUser | undefined,
|
@CurrentUser() user: CurrentUser | undefined,
|
||||||
|
@Req() req: Request,
|
||||||
@Param('id') ws: string,
|
@Param('id') ws: string,
|
||||||
@Param('guid') guid: string,
|
@Param('guid') guid: string,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
@@ -127,6 +146,23 @@ export class WorkspacesController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!docId.isWorkspace) {
|
||||||
|
void this.models.workspaceAnalytics
|
||||||
|
.recordDocView({
|
||||||
|
workspaceId: docId.workspace,
|
||||||
|
docId: docId.guid,
|
||||||
|
userId: user?.id,
|
||||||
|
visitorId: this.buildVisitorId(req, docId.workspace, docId.guid),
|
||||||
|
isGuest: !user,
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to record doc view: ${docId.workspace}/${docId.guid}`,
|
||||||
|
error as Error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!docId.isWorkspace) {
|
if (!docId.isWorkspace) {
|
||||||
// fetch the publish page mode for publish page
|
// fetch the publish page mode for publish page
|
||||||
const docMeta = await this.models.doc.getMeta(
|
const docMeta = await this.models.doc.getMeta(
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import { SafeIntResolver } from 'graphql-scalars';
|
import { SafeIntResolver } from 'graphql-scalars';
|
||||||
|
|
||||||
|
import { PaginationInput, URLHelper } from '../../../base';
|
||||||
|
import { PageInfo } from '../../../base/graphql/pagination';
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
Models,
|
Models,
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
} from '../../../models';
|
} from '../../../models';
|
||||||
import { Admin } from '../../common';
|
import { Admin } from '../../common';
|
||||||
import { WorkspaceUserType } from '../../user';
|
import { WorkspaceUserType } from '../../user';
|
||||||
|
import { TimeWindow } from './analytics-types';
|
||||||
|
|
||||||
enum AdminWorkspaceSort {
|
enum AdminWorkspaceSort {
|
||||||
CreatedAt = 'CreatedAt',
|
CreatedAt = 'CreatedAt',
|
||||||
@@ -40,6 +43,16 @@ registerEnumType(AdminWorkspaceSort, {
|
|||||||
name: 'AdminWorkspaceSort',
|
name: 'AdminWorkspaceSort',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
enum AdminSharedLinksOrder {
|
||||||
|
UpdatedAtDesc = 'UpdatedAtDesc',
|
||||||
|
PublishedAtDesc = 'PublishedAtDesc',
|
||||||
|
ViewsDesc = 'ViewsDesc',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(AdminSharedLinksOrder, {
|
||||||
|
name: 'AdminSharedLinksOrder',
|
||||||
|
});
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
class ListWorkspaceInput {
|
class ListWorkspaceInput {
|
||||||
@Field(() => Int, { defaultValue: 20 })
|
@Field(() => Int, { defaultValue: 20 })
|
||||||
@@ -106,6 +119,195 @@ class AdminWorkspaceSharedLink {
|
|||||||
publishedAt?: Date | null;
|
publishedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class AdminDashboardInput {
|
||||||
|
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
|
||||||
|
timezone?: string;
|
||||||
|
|
||||||
|
@Field(() => Int, { nullable: true, defaultValue: 30 })
|
||||||
|
storageHistoryDays?: number;
|
||||||
|
|
||||||
|
@Field(() => Int, { nullable: true, defaultValue: 48 })
|
||||||
|
syncHistoryHours?: number;
|
||||||
|
|
||||||
|
@Field(() => Int, { nullable: true, defaultValue: 28 })
|
||||||
|
sharedLinkWindowDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class AdminDashboardMinutePoint {
|
||||||
|
@Field(() => Date)
|
||||||
|
minute!: Date;
|
||||||
|
|
||||||
|
@Field(() => Int)
|
||||||
|
activeUsers!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class AdminDashboardValueDayPoint {
|
||||||
|
@Field(() => Date)
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
value!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class AdminSharedLinkTopItem {
|
||||||
|
@Field(() => String)
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
docId!: string;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
title?: string | null;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
shareUrl!: string;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
publishedAt?: Date | null;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
views!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
uniqueViews!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
guestViews!: number;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
lastAccessedAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class AdminDashboard {
|
||||||
|
@Field(() => Int)
|
||||||
|
syncActiveUsers!: number;
|
||||||
|
|
||||||
|
@Field(() => [AdminDashboardMinutePoint])
|
||||||
|
syncActiveUsersTimeline!: AdminDashboardMinutePoint[];
|
||||||
|
|
||||||
|
@Field(() => TimeWindow)
|
||||||
|
syncWindow!: TimeWindow;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
copilotConversations!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
workspaceStorageBytes!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
blobStorageBytes!: number;
|
||||||
|
|
||||||
|
@Field(() => [AdminDashboardValueDayPoint])
|
||||||
|
workspaceStorageHistory!: AdminDashboardValueDayPoint[];
|
||||||
|
|
||||||
|
@Field(() => [AdminDashboardValueDayPoint])
|
||||||
|
blobStorageHistory!: AdminDashboardValueDayPoint[];
|
||||||
|
|
||||||
|
@Field(() => TimeWindow)
|
||||||
|
storageWindow!: TimeWindow;
|
||||||
|
|
||||||
|
@Field(() => [AdminSharedLinkTopItem])
|
||||||
|
topSharedLinks!: AdminSharedLinkTopItem[];
|
||||||
|
|
||||||
|
@Field(() => TimeWindow)
|
||||||
|
topSharedLinksWindow!: TimeWindow;
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
generatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class AdminAllSharedLinksFilterInput {
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
workspaceId?: string;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
updatedAfter?: Date;
|
||||||
|
|
||||||
|
@Field(() => AdminSharedLinksOrder, {
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: AdminSharedLinksOrder.UpdatedAtDesc,
|
||||||
|
})
|
||||||
|
orderBy?: AdminSharedLinksOrder;
|
||||||
|
|
||||||
|
@Field(() => Int, { nullable: true, defaultValue: 28 })
|
||||||
|
analyticsWindowDays?: number;
|
||||||
|
|
||||||
|
@Field(() => Boolean, { nullable: true, defaultValue: false })
|
||||||
|
includeTotal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class AdminAllSharedLink {
|
||||||
|
@Field(() => String)
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
docId!: string;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
title?: string | null;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
publishedAt?: Date | null;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
docUpdatedAt?: Date | null;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
workspaceOwnerId?: string | null;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
lastUpdaterId?: string | null;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
shareUrl!: string;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver, { nullable: true })
|
||||||
|
views?: number | null;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver, { nullable: true })
|
||||||
|
uniqueViews?: number | null;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver, { nullable: true })
|
||||||
|
guestViews?: number | null;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
lastAccessedAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class AdminAllSharedLinkEdge {
|
||||||
|
@Field(() => String)
|
||||||
|
cursor!: string;
|
||||||
|
|
||||||
|
@Field(() => AdminAllSharedLink)
|
||||||
|
node!: AdminAllSharedLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class PaginatedAdminAllSharedLink {
|
||||||
|
@Field(() => [AdminAllSharedLinkEdge])
|
||||||
|
edges!: AdminAllSharedLinkEdge[];
|
||||||
|
|
||||||
|
@Field(() => PageInfo)
|
||||||
|
pageInfo!: PageInfo;
|
||||||
|
|
||||||
|
@Field(() => Int, { nullable: true })
|
||||||
|
totalCount?: number;
|
||||||
|
|
||||||
|
@Field(() => TimeWindow)
|
||||||
|
analyticsWindow!: TimeWindow;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class AdminWorkspace {
|
export class AdminWorkspace {
|
||||||
@Field()
|
@Field()
|
||||||
@@ -187,7 +389,10 @@ class AdminUpdateWorkspaceInput extends PartialType(
|
|||||||
@Admin()
|
@Admin()
|
||||||
@Resolver(() => AdminWorkspace)
|
@Resolver(() => AdminWorkspace)
|
||||||
export class AdminWorkspaceResolver {
|
export class AdminWorkspaceResolver {
|
||||||
constructor(private readonly models: Models) {}
|
constructor(
|
||||||
|
private readonly models: Models,
|
||||||
|
private readonly url: URLHelper
|
||||||
|
) {}
|
||||||
|
|
||||||
private assertCloudOnly() {
|
private assertCloudOnly() {
|
||||||
if (env.selfhosted) {
|
if (env.selfhosted) {
|
||||||
@@ -261,6 +466,72 @@ export class AdminWorkspaceResolver {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Query(() => AdminDashboard, {
|
||||||
|
description: 'Get aggregated dashboard metrics for admin panel',
|
||||||
|
})
|
||||||
|
async adminDashboard(
|
||||||
|
@Args('input', { nullable: true, type: () => AdminDashboardInput })
|
||||||
|
input?: AdminDashboardInput
|
||||||
|
) {
|
||||||
|
this.assertCloudOnly();
|
||||||
|
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
|
||||||
|
timezone: input?.timezone,
|
||||||
|
storageHistoryDays: input?.storageHistoryDays,
|
||||||
|
syncHistoryHours: input?.syncHistoryHours,
|
||||||
|
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dashboard,
|
||||||
|
topSharedLinks: dashboard.topSharedLinks.map(link => ({
|
||||||
|
...link,
|
||||||
|
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => PaginatedAdminAllSharedLink, {
|
||||||
|
description: 'List all shared links across workspaces for admin panel',
|
||||||
|
})
|
||||||
|
async adminAllSharedLinks(
|
||||||
|
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
|
||||||
|
@Args('filter', {
|
||||||
|
nullable: true,
|
||||||
|
type: () => AdminAllSharedLinksFilterInput,
|
||||||
|
})
|
||||||
|
filter?: AdminAllSharedLinksFilterInput
|
||||||
|
) {
|
||||||
|
this.assertCloudOnly();
|
||||||
|
const result =
|
||||||
|
await this.models.workspaceAnalytics.adminPaginateAllSharedLinks({
|
||||||
|
keyword: filter?.keyword,
|
||||||
|
workspaceId: filter?.workspaceId,
|
||||||
|
updatedAfter: filter?.updatedAfter,
|
||||||
|
orderBy:
|
||||||
|
filter?.orderBy === AdminSharedLinksOrder.PublishedAtDesc
|
||||||
|
? 'PublishedAtDesc'
|
||||||
|
: filter?.orderBy === AdminSharedLinksOrder.ViewsDesc
|
||||||
|
? 'ViewsDesc'
|
||||||
|
: 'UpdatedAtDesc',
|
||||||
|
analyticsWindowDays: filter?.analyticsWindowDays,
|
||||||
|
includeTotal: filter?.includeTotal,
|
||||||
|
pagination,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
edges: result.edges.map(edge => ({
|
||||||
|
...edge,
|
||||||
|
node: {
|
||||||
|
...edge.node,
|
||||||
|
shareUrl: this.url.link(
|
||||||
|
`/workspace/${edge.node.workspaceId}/${edge.node.docId}`
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ResolveField(() => [AdminWorkspaceMember], {
|
@ResolveField(() => [AdminWorkspaceMember], {
|
||||||
description: 'Members of workspace',
|
description: 'Members of workspace',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
export enum TimeBucket {
|
||||||
|
Minute = 'Minute',
|
||||||
|
Day = 'Day',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(TimeBucket, {
|
||||||
|
name: 'TimeBucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class TimeWindow {
|
||||||
|
@Field(() => Date)
|
||||||
|
from!: Date;
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
to!: Date;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
timezone!: string;
|
||||||
|
|
||||||
|
@Field(() => TimeBucket)
|
||||||
|
bucket!: TimeBucket;
|
||||||
|
|
||||||
|
@Field(() => Int)
|
||||||
|
requestedSize!: number;
|
||||||
|
|
||||||
|
@Field(() => Int)
|
||||||
|
effectiveSize!: number;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
InputType,
|
InputType,
|
||||||
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
Parent,
|
Parent,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { SafeIntResolver } from 'graphql-scalars';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Cache,
|
Cache,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
PaginationInput,
|
PaginationInput,
|
||||||
registerObjectType,
|
registerObjectType,
|
||||||
} from '../../../base';
|
} from '../../../base';
|
||||||
|
import { PageInfo } from '../../../base/graphql/pagination';
|
||||||
import { Models, PublicDocMode } from '../../../models';
|
import { Models, PublicDocMode } from '../../../models';
|
||||||
import { CurrentUser } from '../../auth';
|
import { CurrentUser } from '../../auth';
|
||||||
import { Editor } from '../../doc';
|
import { Editor } from '../../doc';
|
||||||
@@ -38,6 +41,7 @@ import {
|
|||||||
} from '../../permission';
|
} from '../../permission';
|
||||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||||
import { WorkspaceType } from '../types';
|
import { WorkspaceType } from '../types';
|
||||||
|
import { TimeBucket, TimeWindow } from './analytics-types';
|
||||||
import {
|
import {
|
||||||
DotToUnderline,
|
DotToUnderline,
|
||||||
mapPermissionsToGraphqlPermissions,
|
mapPermissionsToGraphqlPermissions,
|
||||||
@@ -194,6 +198,93 @@ class WorkspaceDocMeta {
|
|||||||
updatedBy!: EditorType | null;
|
updatedBy!: EditorType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class DocPageAnalyticsInput {
|
||||||
|
@Field(() => Int, { nullable: true, defaultValue: 28 })
|
||||||
|
windowDays?: number;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class DocPageAnalyticsPoint {
|
||||||
|
@Field(() => Date)
|
||||||
|
date!: Date;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
totalViews!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
uniqueViews!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
guestViews!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class DocPageAnalyticsSummary {
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
totalViews!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
uniqueViews!: number;
|
||||||
|
|
||||||
|
@Field(() => SafeIntResolver)
|
||||||
|
guestViews!: number;
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
lastAccessedAt!: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class DocPageAnalytics {
|
||||||
|
@Field(() => TimeWindow)
|
||||||
|
window!: TimeWindow;
|
||||||
|
|
||||||
|
@Field(() => [DocPageAnalyticsPoint])
|
||||||
|
series!: DocPageAnalyticsPoint[];
|
||||||
|
|
||||||
|
@Field(() => DocPageAnalyticsSummary)
|
||||||
|
summary!: DocPageAnalyticsSummary;
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
generatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class DocMemberLastAccess {
|
||||||
|
@Field(() => PublicUserType)
|
||||||
|
user!: PublicUserType;
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
lastAccessedAt!: Date;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
lastDocId!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class DocMemberLastAccessEdge {
|
||||||
|
@Field(() => String)
|
||||||
|
cursor!: string;
|
||||||
|
|
||||||
|
@Field(() => DocMemberLastAccess)
|
||||||
|
node!: DocMemberLastAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
class PaginatedDocMemberLastAccess {
|
||||||
|
@Field(() => [DocMemberLastAccessEdge])
|
||||||
|
edges!: DocMemberLastAccessEdge[];
|
||||||
|
|
||||||
|
@Field(() => PageInfo)
|
||||||
|
pageInfo!: PageInfo;
|
||||||
|
|
||||||
|
@Field(() => Int, { nullable: true })
|
||||||
|
totalCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Resolver(() => WorkspaceType)
|
@Resolver(() => WorkspaceType)
|
||||||
export class WorkspaceDocResolver {
|
export class WorkspaceDocResolver {
|
||||||
private readonly logger = new Logger(WorkspaceDocResolver.name);
|
private readonly logger = new Logger(WorkspaceDocResolver.name);
|
||||||
@@ -464,6 +555,64 @@ export class DocResolver {
|
|||||||
updatedBy: metadata.updatedByUser || null,
|
updatedBy: metadata.updatedByUser || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => DocPageAnalytics, {
|
||||||
|
description: 'Doc page analytics in a time window',
|
||||||
|
complexity: 5,
|
||||||
|
})
|
||||||
|
async analytics(
|
||||||
|
@CurrentUser() me: CurrentUser,
|
||||||
|
@Parent() doc: DocType,
|
||||||
|
@Args('input', { nullable: true, type: () => DocPageAnalyticsInput })
|
||||||
|
input?: DocPageAnalyticsInput
|
||||||
|
): Promise<DocPageAnalytics> {
|
||||||
|
await this.ac.user(me.id).doc(doc).assert('Doc.Read');
|
||||||
|
|
||||||
|
const analytics = await this.models.workspaceAnalytics.getDocPageAnalytics({
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
docId: doc.docId,
|
||||||
|
windowDays: input?.windowDays,
|
||||||
|
timezone: input?.timezone,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...analytics,
|
||||||
|
window: {
|
||||||
|
...analytics.window,
|
||||||
|
bucket:
|
||||||
|
analytics.window.bucket === 'Minute'
|
||||||
|
? TimeBucket.Minute
|
||||||
|
: TimeBucket.Day,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => PaginatedDocMemberLastAccess, {
|
||||||
|
description: 'Paginated last accessed members of the current doc',
|
||||||
|
complexity: 5,
|
||||||
|
})
|
||||||
|
async lastAccessedMembers(
|
||||||
|
@CurrentUser() me: CurrentUser,
|
||||||
|
@Parent() doc: DocType,
|
||||||
|
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
|
||||||
|
@Args('query', { nullable: true }) query?: string,
|
||||||
|
@Args('includeTotal', { nullable: true, defaultValue: false })
|
||||||
|
includeTotal?: boolean
|
||||||
|
): Promise<PaginatedDocMemberLastAccess> {
|
||||||
|
await this.ac
|
||||||
|
.user(me.id)
|
||||||
|
.workspace(doc.workspaceId)
|
||||||
|
.assert('Workspace.Users.Manage');
|
||||||
|
|
||||||
|
return this.models.workspaceAnalytics.paginateDocLastAccessedMembers({
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
docId: doc.docId,
|
||||||
|
pagination,
|
||||||
|
query,
|
||||||
|
includeTotal: includeTotal ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@ResolveField(() => DocPermissions)
|
@ResolveField(() => DocPermissions)
|
||||||
async permissions(
|
async permissions(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
|
|||||||
@@ -124,6 +124,21 @@ export class WorkspaceStatsJob {
|
|||||||
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
|
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshotted = await this.withAdvisoryLock(async tx => {
|
||||||
|
await this.writeDailySnapshot(tx);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (snapshotted) {
|
||||||
|
this.logger.debug('Wrote daily workspace admin stats snapshot');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
'Failed to write daily workspace admin stats snapshot',
|
||||||
|
error as Error
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async withAdvisoryLock<T>(
|
private async withAdvisoryLock<T>(
|
||||||
@@ -304,4 +319,31 @@ export class WorkspaceStatsJob {
|
|||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async writeDailySnapshot(tx: Prisma.TransactionClient) {
|
||||||
|
await tx.$executeRaw`
|
||||||
|
INSERT INTO workspace_admin_stats_daily (
|
||||||
|
workspace_id,
|
||||||
|
date,
|
||||||
|
snapshot_size,
|
||||||
|
blob_size,
|
||||||
|
member_count,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
workspace_id,
|
||||||
|
CURRENT_DATE,
|
||||||
|
snapshot_size,
|
||||||
|
blob_size,
|
||||||
|
member_count,
|
||||||
|
NOW()
|
||||||
|
FROM workspace_admin_stats
|
||||||
|
ON CONFLICT (workspace_id, date)
|
||||||
|
DO UPDATE SET
|
||||||
|
snapshot_size = EXCLUDED.snapshot_size,
|
||||||
|
blob_size = EXCLUDED.blob_size,
|
||||||
|
member_count = EXCLUDED.member_count,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { UserFeatureModel } from './user-feature';
|
|||||||
import { UserSettingsModel } from './user-settings';
|
import { UserSettingsModel } from './user-settings';
|
||||||
import { VerificationTokenModel } from './verification-token';
|
import { VerificationTokenModel } from './verification-token';
|
||||||
import { WorkspaceModel } from './workspace';
|
import { WorkspaceModel } from './workspace';
|
||||||
|
import { WorkspaceAnalyticsModel } from './workspace-analytics';
|
||||||
import { WorkspaceCalendarModel } from './workspace-calendar';
|
import { WorkspaceCalendarModel } from './workspace-calendar';
|
||||||
import { WorkspaceFeatureModel } from './workspace-feature';
|
import { WorkspaceFeatureModel } from './workspace-feature';
|
||||||
import { WorkspaceUserModel } from './workspace-user';
|
import { WorkspaceUserModel } from './workspace-user';
|
||||||
@@ -68,6 +69,7 @@ const MODELS = {
|
|||||||
calendarEvent: CalendarEventModel,
|
calendarEvent: CalendarEventModel,
|
||||||
calendarEventInstance: CalendarEventInstanceModel,
|
calendarEventInstance: CalendarEventInstanceModel,
|
||||||
workspaceCalendar: WorkspaceCalendarModel,
|
workspaceCalendar: WorkspaceCalendarModel,
|
||||||
|
workspaceAnalytics: WorkspaceAnalyticsModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModelsType = {
|
type ModelsType = {
|
||||||
@@ -144,6 +146,7 @@ export * from './user-feature';
|
|||||||
export * from './user-settings';
|
export * from './user-settings';
|
||||||
export * from './verification-token';
|
export * from './verification-token';
|
||||||
export * from './workspace';
|
export * from './workspace';
|
||||||
|
export * from './workspace-analytics';
|
||||||
export * from './workspace-calendar';
|
export * from './workspace-calendar';
|
||||||
export * from './workspace-feature';
|
export * from './workspace-feature';
|
||||||
export * from './workspace-user';
|
export * from './workspace-user';
|
||||||
|
|||||||
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,11 +59,13 @@ export const CheckoutParams = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export abstract class SubscriptionManager {
|
export abstract class SubscriptionManager {
|
||||||
protected readonly scheduleManager = new ScheduleManager(this.stripeProvider);
|
protected readonly scheduleManager: ScheduleManager;
|
||||||
constructor(
|
constructor(
|
||||||
protected readonly stripeProvider: StripeFactory,
|
protected readonly stripeProvider: StripeFactory,
|
||||||
protected readonly db: PrismaClient
|
protected readonly db: PrismaClient
|
||||||
) {}
|
) {
|
||||||
|
this.scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
get stripe() {
|
get stripe() {
|
||||||
return this.stripeProvider.stripe;
|
return this.stripeProvider.stripe;
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export { CheckoutParams };
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionService {
|
export class SubscriptionService {
|
||||||
private readonly logger = new Logger(SubscriptionService.name);
|
private readonly logger = new Logger(SubscriptionService.name);
|
||||||
private readonly scheduleManager = new ScheduleManager(this.stripeProvider);
|
private readonly scheduleManager: ScheduleManager;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeProvider: StripeFactory,
|
private readonly stripeProvider: StripeFactory,
|
||||||
@@ -85,7 +85,9 @@ export class SubscriptionService {
|
|||||||
private readonly userManager: UserSubscriptionManager,
|
private readonly userManager: UserSubscriptionManager,
|
||||||
private readonly workspaceManager: WorkspaceSubscriptionManager,
|
private readonly workspaceManager: WorkspaceSubscriptionManager,
|
||||||
private readonly selfhostManager: SelfhostTeamSubscriptionManager
|
private readonly selfhostManager: SelfhostTeamSubscriptionManager
|
||||||
) {}
|
) {
|
||||||
|
this.scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
get stripe() {
|
get stripe() {
|
||||||
return this.stripeProvider.stripe;
|
return this.stripeProvider.stripe;
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { fixUrl, OriginRules } from './utils';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkerService {
|
export class WorkerService {
|
||||||
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
|
allowedOrigins: OriginRules;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly url: URLHelper
|
private readonly url: URLHelper
|
||||||
) {}
|
) {
|
||||||
|
this.allowedOrigins = [...this.url.allowedOrigins];
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent('config.init')
|
@OnEvent('config.init')
|
||||||
onConfigInit() {
|
onConfigInit() {
|
||||||
|
|||||||
@@ -30,6 +30,85 @@ input AddContextFileInput {
|
|||||||
contextId: String!
|
contextId: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdminAllSharedLink {
|
||||||
|
docId: String!
|
||||||
|
docUpdatedAt: DateTime
|
||||||
|
guestViews: SafeInt
|
||||||
|
lastAccessedAt: DateTime
|
||||||
|
lastUpdaterId: String
|
||||||
|
publishedAt: DateTime
|
||||||
|
shareUrl: String!
|
||||||
|
title: String
|
||||||
|
uniqueViews: SafeInt
|
||||||
|
views: SafeInt
|
||||||
|
workspaceId: String!
|
||||||
|
workspaceOwnerId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminAllSharedLinkEdge {
|
||||||
|
cursor: String!
|
||||||
|
node: AdminAllSharedLink!
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminAllSharedLinksFilterInput {
|
||||||
|
analyticsWindowDays: Int = 28
|
||||||
|
includeTotal: Boolean = false
|
||||||
|
keyword: String
|
||||||
|
orderBy: AdminSharedLinksOrder = UpdatedAtDesc
|
||||||
|
updatedAfter: DateTime
|
||||||
|
workspaceId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminDashboard {
|
||||||
|
blobStorageBytes: SafeInt!
|
||||||
|
blobStorageHistory: [AdminDashboardValueDayPoint!]!
|
||||||
|
copilotConversations: SafeInt!
|
||||||
|
generatedAt: DateTime!
|
||||||
|
storageWindow: TimeWindow!
|
||||||
|
syncActiveUsers: Int!
|
||||||
|
syncActiveUsersTimeline: [AdminDashboardMinutePoint!]!
|
||||||
|
syncWindow: TimeWindow!
|
||||||
|
topSharedLinks: [AdminSharedLinkTopItem!]!
|
||||||
|
topSharedLinksWindow: TimeWindow!
|
||||||
|
workspaceStorageBytes: SafeInt!
|
||||||
|
workspaceStorageHistory: [AdminDashboardValueDayPoint!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input AdminDashboardInput {
|
||||||
|
sharedLinkWindowDays: Int = 28
|
||||||
|
storageHistoryDays: Int = 30
|
||||||
|
syncHistoryHours: Int = 48
|
||||||
|
timezone: String = "UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminDashboardMinutePoint {
|
||||||
|
activeUsers: Int!
|
||||||
|
minute: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminDashboardValueDayPoint {
|
||||||
|
date: DateTime!
|
||||||
|
value: SafeInt!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSharedLinkTopItem {
|
||||||
|
docId: String!
|
||||||
|
guestViews: SafeInt!
|
||||||
|
lastAccessedAt: DateTime
|
||||||
|
publishedAt: DateTime
|
||||||
|
shareUrl: String!
|
||||||
|
title: String
|
||||||
|
uniqueViews: SafeInt!
|
||||||
|
views: SafeInt!
|
||||||
|
workspaceId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AdminSharedLinksOrder {
|
||||||
|
PublishedAtDesc
|
||||||
|
UpdatedAtDesc
|
||||||
|
ViewsDesc
|
||||||
|
}
|
||||||
|
|
||||||
input AdminUpdateWorkspaceInput {
|
input AdminUpdateWorkspaceInput {
|
||||||
avatarKey: String
|
avatarKey: String
|
||||||
enableAi: Boolean
|
enableAi: Boolean
|
||||||
@@ -720,6 +799,17 @@ type DocHistoryType {
|
|||||||
workspaceId: String!
|
workspaceId: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocMemberLastAccess {
|
||||||
|
lastAccessedAt: DateTime!
|
||||||
|
lastDocId: String
|
||||||
|
user: PublicUserType!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocMemberLastAccessEdge {
|
||||||
|
cursor: String!
|
||||||
|
node: DocMemberLastAccess!
|
||||||
|
}
|
||||||
|
|
||||||
"""Doc mode"""
|
"""Doc mode"""
|
||||||
enum DocMode {
|
enum DocMode {
|
||||||
edgeless
|
edgeless
|
||||||
@@ -731,6 +821,32 @@ type DocNotFoundDataType {
|
|||||||
spaceId: String!
|
spaceId: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DocPageAnalytics {
|
||||||
|
generatedAt: DateTime!
|
||||||
|
series: [DocPageAnalyticsPoint!]!
|
||||||
|
summary: DocPageAnalyticsSummary!
|
||||||
|
window: TimeWindow!
|
||||||
|
}
|
||||||
|
|
||||||
|
input DocPageAnalyticsInput {
|
||||||
|
timezone: String = "UTC"
|
||||||
|
windowDays: Int = 28
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocPageAnalyticsPoint {
|
||||||
|
date: DateTime!
|
||||||
|
guestViews: SafeInt!
|
||||||
|
totalViews: SafeInt!
|
||||||
|
uniqueViews: SafeInt!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocPageAnalyticsSummary {
|
||||||
|
guestViews: SafeInt!
|
||||||
|
lastAccessedAt: DateTime
|
||||||
|
totalViews: SafeInt!
|
||||||
|
uniqueViews: SafeInt!
|
||||||
|
}
|
||||||
|
|
||||||
type DocPermissions {
|
type DocPermissions {
|
||||||
Doc_Comments_Create: Boolean!
|
Doc_Comments_Create: Boolean!
|
||||||
Doc_Comments_Delete: Boolean!
|
Doc_Comments_Delete: Boolean!
|
||||||
@@ -763,6 +879,8 @@ enum DocRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DocType {
|
type DocType {
|
||||||
|
"""Doc page analytics in a time window"""
|
||||||
|
analytics(input: DocPageAnalyticsInput): DocPageAnalytics!
|
||||||
createdAt: DateTime
|
createdAt: DateTime
|
||||||
|
|
||||||
"""Doc create user"""
|
"""Doc create user"""
|
||||||
@@ -774,6 +892,9 @@ type DocType {
|
|||||||
grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType!
|
grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType!
|
||||||
id: String!
|
id: String!
|
||||||
|
|
||||||
|
"""Paginated last accessed members of the current doc"""
|
||||||
|
lastAccessedMembers(includeTotal: Boolean = false, pagination: PaginationInput!, query: String): PaginatedDocMemberLastAccess!
|
||||||
|
|
||||||
"""Doc last updated user"""
|
"""Doc last updated user"""
|
||||||
lastUpdatedBy: PublicUserType
|
lastUpdatedBy: PublicUserType
|
||||||
lastUpdaterId: String
|
lastUpdaterId: String
|
||||||
@@ -1677,6 +1798,13 @@ type PageInfo {
|
|||||||
startCursor: String
|
startCursor: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginatedAdminAllSharedLink {
|
||||||
|
analyticsWindow: TimeWindow!
|
||||||
|
edges: [AdminAllSharedLinkEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
totalCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
type PaginatedCommentChangeObjectType {
|
type PaginatedCommentChangeObjectType {
|
||||||
edges: [CommentChangeObjectTypeEdge!]!
|
edges: [CommentChangeObjectTypeEdge!]!
|
||||||
pageInfo: PageInfo!
|
pageInfo: PageInfo!
|
||||||
@@ -1701,6 +1829,12 @@ type PaginatedCopilotWorkspaceFileType {
|
|||||||
totalCount: Int!
|
totalCount: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaginatedDocMemberLastAccess {
|
||||||
|
edges: [DocMemberLastAccessEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
totalCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
type PaginatedDocType {
|
type PaginatedDocType {
|
||||||
edges: [DocTypeEdge!]!
|
edges: [DocTypeEdge!]!
|
||||||
pageInfo: PageInfo!
|
pageInfo: PageInfo!
|
||||||
@@ -1762,6 +1896,12 @@ type PublicUserType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
|
"""List all shared links across workspaces for admin panel"""
|
||||||
|
adminAllSharedLinks(filter: AdminAllSharedLinksFilterInput, pagination: PaginationInput!): PaginatedAdminAllSharedLink!
|
||||||
|
|
||||||
|
"""Get aggregated dashboard metrics for admin panel"""
|
||||||
|
adminDashboard(input: AdminDashboardInput): AdminDashboard!
|
||||||
|
|
||||||
"""Get workspace detail for admin"""
|
"""Get workspace detail for admin"""
|
||||||
adminWorkspace(id: String!): AdminWorkspace
|
adminWorkspace(id: String!): AdminWorkspace
|
||||||
|
|
||||||
@@ -2207,6 +2347,20 @@ enum SubscriptionVariant {
|
|||||||
Onetime
|
Onetime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TimeBucket {
|
||||||
|
Day
|
||||||
|
Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeWindow {
|
||||||
|
bucket: TimeBucket!
|
||||||
|
effectiveSize: Int!
|
||||||
|
from: DateTime!
|
||||||
|
requestedSize: Int!
|
||||||
|
timezone: String!
|
||||||
|
to: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
type TranscriptionItemType {
|
type TranscriptionItemType {
|
||||||
end: String!
|
end: String!
|
||||||
speaker: String!
|
speaker: String!
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
query adminAllSharedLinks(
|
||||||
|
$pagination: PaginationInput!
|
||||||
|
$filter: AdminAllSharedLinksFilterInput
|
||||||
|
) {
|
||||||
|
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||||
|
totalCount
|
||||||
|
analyticsWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
hasPreviousPage
|
||||||
|
startCursor
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
cursor
|
||||||
|
node {
|
||||||
|
workspaceId
|
||||||
|
docId
|
||||||
|
title
|
||||||
|
publishedAt
|
||||||
|
docUpdatedAt
|
||||||
|
workspaceOwnerId
|
||||||
|
lastUpdaterId
|
||||||
|
shareUrl
|
||||||
|
views
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
lastAccessedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
query adminDashboard($input: AdminDashboardInput) {
|
||||||
|
adminDashboard(input: $input) {
|
||||||
|
syncActiveUsers
|
||||||
|
syncActiveUsersTimeline {
|
||||||
|
minute
|
||||||
|
activeUsers
|
||||||
|
}
|
||||||
|
syncWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
copilotConversations
|
||||||
|
workspaceStorageBytes
|
||||||
|
blobStorageBytes
|
||||||
|
workspaceStorageHistory {
|
||||||
|
date
|
||||||
|
value
|
||||||
|
}
|
||||||
|
blobStorageHistory {
|
||||||
|
date
|
||||||
|
value
|
||||||
|
}
|
||||||
|
storageWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
topSharedLinks {
|
||||||
|
workspaceId
|
||||||
|
docId
|
||||||
|
title
|
||||||
|
shareUrl
|
||||||
|
publishedAt
|
||||||
|
views
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
lastAccessedAt
|
||||||
|
}
|
||||||
|
topSharedLinksWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
generatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
query getDocLastAccessedMembers(
|
||||||
|
$workspaceId: String!
|
||||||
|
$docId: String!
|
||||||
|
$pagination: PaginationInput!
|
||||||
|
$query: String
|
||||||
|
$includeTotal: Boolean
|
||||||
|
) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
lastAccessedMembers(
|
||||||
|
pagination: $pagination
|
||||||
|
query: $query
|
||||||
|
includeTotal: $includeTotal
|
||||||
|
) {
|
||||||
|
totalCount
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
hasPreviousPage
|
||||||
|
startCursor
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
cursor
|
||||||
|
node {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
lastAccessedAt
|
||||||
|
lastDocId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
query getDocPageAnalytics(
|
||||||
|
$workspaceId: String!
|
||||||
|
$docId: String!
|
||||||
|
$input: DocPageAnalyticsInput
|
||||||
|
) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
analytics(input: $input) {
|
||||||
|
window {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
series {
|
||||||
|
date
|
||||||
|
totalViews
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
totalViews
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
lastAccessedAt
|
||||||
|
}
|
||||||
|
generatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,108 @@ export const revokeUserAccessTokenMutation = {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const adminAllSharedLinksQuery = {
|
||||||
|
id: 'adminAllSharedLinksQuery' as const,
|
||||||
|
op: 'adminAllSharedLinks',
|
||||||
|
query: `query adminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
|
||||||
|
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||||
|
totalCount
|
||||||
|
analyticsWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
hasPreviousPage
|
||||||
|
startCursor
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
cursor
|
||||||
|
node {
|
||||||
|
workspaceId
|
||||||
|
docId
|
||||||
|
title
|
||||||
|
publishedAt
|
||||||
|
docUpdatedAt
|
||||||
|
workspaceOwnerId
|
||||||
|
lastUpdaterId
|
||||||
|
shareUrl
|
||||||
|
views
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
lastAccessedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminDashboardQuery = {
|
||||||
|
id: 'adminDashboardQuery' as const,
|
||||||
|
op: 'adminDashboard',
|
||||||
|
query: `query adminDashboard($input: AdminDashboardInput) {
|
||||||
|
adminDashboard(input: $input) {
|
||||||
|
syncActiveUsers
|
||||||
|
syncActiveUsersTimeline {
|
||||||
|
minute
|
||||||
|
activeUsers
|
||||||
|
}
|
||||||
|
syncWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
copilotConversations
|
||||||
|
workspaceStorageBytes
|
||||||
|
blobStorageBytes
|
||||||
|
workspaceStorageHistory {
|
||||||
|
date
|
||||||
|
value
|
||||||
|
}
|
||||||
|
blobStorageHistory {
|
||||||
|
date
|
||||||
|
value
|
||||||
|
}
|
||||||
|
storageWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
topSharedLinks {
|
||||||
|
workspaceId
|
||||||
|
docId
|
||||||
|
title
|
||||||
|
shareUrl
|
||||||
|
publishedAt
|
||||||
|
views
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
lastAccessedAt
|
||||||
|
}
|
||||||
|
topSharedLinksWindow {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
generatedAt
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const adminServerConfigQuery = {
|
export const adminServerConfigQuery = {
|
||||||
id: 'adminServerConfigQuery' as const,
|
id: 'adminServerConfigQuery' as const,
|
||||||
op: 'adminServerConfig',
|
op: 'adminServerConfig',
|
||||||
@@ -1877,6 +1979,76 @@ export const getDocDefaultRoleQuery = {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDocLastAccessedMembersQuery = {
|
||||||
|
id: 'getDocLastAccessedMembersQuery' as const,
|
||||||
|
op: 'getDocLastAccessedMembers',
|
||||||
|
query: `query getDocLastAccessedMembers($workspaceId: String!, $docId: String!, $pagination: PaginationInput!, $query: String, $includeTotal: Boolean) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
lastAccessedMembers(
|
||||||
|
pagination: $pagination
|
||||||
|
query: $query
|
||||||
|
includeTotal: $includeTotal
|
||||||
|
) {
|
||||||
|
totalCount
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
hasPreviousPage
|
||||||
|
startCursor
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
cursor
|
||||||
|
node {
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
lastAccessedAt
|
||||||
|
lastDocId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDocPageAnalyticsQuery = {
|
||||||
|
id: 'getDocPageAnalyticsQuery' as const,
|
||||||
|
op: 'getDocPageAnalytics',
|
||||||
|
query: `query getDocPageAnalytics($workspaceId: String!, $docId: String!, $input: DocPageAnalyticsInput) {
|
||||||
|
workspace(id: $workspaceId) {
|
||||||
|
doc(docId: $docId) {
|
||||||
|
analytics(input: $input) {
|
||||||
|
window {
|
||||||
|
from
|
||||||
|
to
|
||||||
|
timezone
|
||||||
|
bucket
|
||||||
|
requestedSize
|
||||||
|
effectiveSize
|
||||||
|
}
|
||||||
|
series {
|
||||||
|
date
|
||||||
|
totalViews
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
totalViews
|
||||||
|
uniqueViews
|
||||||
|
guestViews
|
||||||
|
lastAccessedAt
|
||||||
|
}
|
||||||
|
generatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const getDocSummaryQuery = {
|
export const getDocSummaryQuery = {
|
||||||
id: 'getDocSummaryQuery' as const,
|
id: 'getDocSummaryQuery' as const,
|
||||||
op: 'getDocSummary',
|
op: 'getDocSummary',
|
||||||
|
|||||||
@@ -66,6 +66,91 @@ export interface AddContextFileInput {
|
|||||||
contextId: Scalars['String']['input'];
|
contextId: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminAllSharedLink {
|
||||||
|
__typename?: 'AdminAllSharedLink';
|
||||||
|
docId: Scalars['String']['output'];
|
||||||
|
docUpdatedAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
|
guestViews: Maybe<Scalars['SafeInt']['output']>;
|
||||||
|
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
|
lastUpdaterId: Maybe<Scalars['String']['output']>;
|
||||||
|
publishedAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
|
shareUrl: Scalars['String']['output'];
|
||||||
|
title: Maybe<Scalars['String']['output']>;
|
||||||
|
uniqueViews: Maybe<Scalars['SafeInt']['output']>;
|
||||||
|
views: Maybe<Scalars['SafeInt']['output']>;
|
||||||
|
workspaceId: Scalars['String']['output'];
|
||||||
|
workspaceOwnerId: Maybe<Scalars['String']['output']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAllSharedLinkEdge {
|
||||||
|
__typename?: 'AdminAllSharedLinkEdge';
|
||||||
|
cursor: Scalars['String']['output'];
|
||||||
|
node: AdminAllSharedLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAllSharedLinksFilterInput {
|
||||||
|
analyticsWindowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
|
keyword?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
orderBy?: InputMaybe<AdminSharedLinksOrder>;
|
||||||
|
updatedAfter?: InputMaybe<Scalars['DateTime']['input']>;
|
||||||
|
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboard {
|
||||||
|
__typename?: 'AdminDashboard';
|
||||||
|
blobStorageBytes: Scalars['SafeInt']['output'];
|
||||||
|
blobStorageHistory: Array<AdminDashboardValueDayPoint>;
|
||||||
|
copilotConversations: Scalars['SafeInt']['output'];
|
||||||
|
generatedAt: Scalars['DateTime']['output'];
|
||||||
|
storageWindow: TimeWindow;
|
||||||
|
syncActiveUsers: Scalars['Int']['output'];
|
||||||
|
syncActiveUsersTimeline: Array<AdminDashboardMinutePoint>;
|
||||||
|
syncWindow: TimeWindow;
|
||||||
|
topSharedLinks: Array<AdminSharedLinkTopItem>;
|
||||||
|
topSharedLinksWindow: TimeWindow;
|
||||||
|
workspaceStorageBytes: Scalars['SafeInt']['output'];
|
||||||
|
workspaceStorageHistory: Array<AdminDashboardValueDayPoint>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardInput {
|
||||||
|
sharedLinkWindowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
storageHistoryDays?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
syncHistoryHours?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
timezone?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardMinutePoint {
|
||||||
|
__typename?: 'AdminDashboardMinutePoint';
|
||||||
|
activeUsers: Scalars['Int']['output'];
|
||||||
|
minute: Scalars['DateTime']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardValueDayPoint {
|
||||||
|
__typename?: 'AdminDashboardValueDayPoint';
|
||||||
|
date: Scalars['DateTime']['output'];
|
||||||
|
value: Scalars['SafeInt']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSharedLinkTopItem {
|
||||||
|
__typename?: 'AdminSharedLinkTopItem';
|
||||||
|
docId: Scalars['String']['output'];
|
||||||
|
guestViews: Scalars['SafeInt']['output'];
|
||||||
|
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
|
publishedAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
|
shareUrl: Scalars['String']['output'];
|
||||||
|
title: Maybe<Scalars['String']['output']>;
|
||||||
|
uniqueViews: Scalars['SafeInt']['output'];
|
||||||
|
views: Scalars['SafeInt']['output'];
|
||||||
|
workspaceId: Scalars['String']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AdminSharedLinksOrder {
|
||||||
|
PublishedAtDesc = 'PublishedAtDesc',
|
||||||
|
UpdatedAtDesc = 'UpdatedAtDesc',
|
||||||
|
ViewsDesc = 'ViewsDesc',
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminUpdateWorkspaceInput {
|
export interface AdminUpdateWorkspaceInput {
|
||||||
avatarKey?: InputMaybe<Scalars['String']['input']>;
|
avatarKey?: InputMaybe<Scalars['String']['input']>;
|
||||||
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
@@ -851,6 +936,19 @@ export interface DocHistoryType {
|
|||||||
workspaceId: Scalars['String']['output'];
|
workspaceId: Scalars['String']['output'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocMemberLastAccess {
|
||||||
|
__typename?: 'DocMemberLastAccess';
|
||||||
|
lastAccessedAt: Scalars['DateTime']['output'];
|
||||||
|
lastDocId: Maybe<Scalars['String']['output']>;
|
||||||
|
user: PublicUserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocMemberLastAccessEdge {
|
||||||
|
__typename?: 'DocMemberLastAccessEdge';
|
||||||
|
cursor: Scalars['String']['output'];
|
||||||
|
node: DocMemberLastAccess;
|
||||||
|
}
|
||||||
|
|
||||||
/** Doc mode */
|
/** Doc mode */
|
||||||
export enum DocMode {
|
export enum DocMode {
|
||||||
edgeless = 'edgeless',
|
edgeless = 'edgeless',
|
||||||
@@ -863,6 +961,35 @@ export interface DocNotFoundDataType {
|
|||||||
spaceId: Scalars['String']['output'];
|
spaceId: Scalars['String']['output'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocPageAnalytics {
|
||||||
|
__typename?: 'DocPageAnalytics';
|
||||||
|
generatedAt: Scalars['DateTime']['output'];
|
||||||
|
series: Array<DocPageAnalyticsPoint>;
|
||||||
|
summary: DocPageAnalyticsSummary;
|
||||||
|
window: TimeWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocPageAnalyticsInput {
|
||||||
|
timezone?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
windowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocPageAnalyticsPoint {
|
||||||
|
__typename?: 'DocPageAnalyticsPoint';
|
||||||
|
date: Scalars['DateTime']['output'];
|
||||||
|
guestViews: Scalars['SafeInt']['output'];
|
||||||
|
totalViews: Scalars['SafeInt']['output'];
|
||||||
|
uniqueViews: Scalars['SafeInt']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocPageAnalyticsSummary {
|
||||||
|
__typename?: 'DocPageAnalyticsSummary';
|
||||||
|
guestViews: Scalars['SafeInt']['output'];
|
||||||
|
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
|
totalViews: Scalars['SafeInt']['output'];
|
||||||
|
uniqueViews: Scalars['SafeInt']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocPermissions {
|
export interface DocPermissions {
|
||||||
__typename?: 'DocPermissions';
|
__typename?: 'DocPermissions';
|
||||||
Doc_Comments_Create: Scalars['Boolean']['output'];
|
Doc_Comments_Create: Scalars['Boolean']['output'];
|
||||||
@@ -897,6 +1024,8 @@ export enum DocRole {
|
|||||||
|
|
||||||
export interface DocType {
|
export interface DocType {
|
||||||
__typename?: 'DocType';
|
__typename?: 'DocType';
|
||||||
|
/** Doc page analytics in a time window */
|
||||||
|
analytics: DocPageAnalytics;
|
||||||
createdAt: Maybe<Scalars['DateTime']['output']>;
|
createdAt: Maybe<Scalars['DateTime']['output']>;
|
||||||
/** Doc create user */
|
/** Doc create user */
|
||||||
createdBy: Maybe<PublicUserType>;
|
createdBy: Maybe<PublicUserType>;
|
||||||
@@ -905,6 +1034,8 @@ export interface DocType {
|
|||||||
/** paginated doc granted users list */
|
/** paginated doc granted users list */
|
||||||
grantedUsersList: PaginatedGrantedDocUserType;
|
grantedUsersList: PaginatedGrantedDocUserType;
|
||||||
id: Scalars['String']['output'];
|
id: Scalars['String']['output'];
|
||||||
|
/** Paginated last accessed members of the current doc */
|
||||||
|
lastAccessedMembers: PaginatedDocMemberLastAccess;
|
||||||
/** Doc last updated user */
|
/** Doc last updated user */
|
||||||
lastUpdatedBy: Maybe<PublicUserType>;
|
lastUpdatedBy: Maybe<PublicUserType>;
|
||||||
lastUpdaterId: Maybe<Scalars['String']['output']>;
|
lastUpdaterId: Maybe<Scalars['String']['output']>;
|
||||||
@@ -919,10 +1050,20 @@ export interface DocType {
|
|||||||
workspaceId: Scalars['String']['output'];
|
workspaceId: Scalars['String']['output'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocTypeAnalyticsArgs {
|
||||||
|
input?: InputMaybe<DocPageAnalyticsInput>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocTypeGrantedUsersListArgs {
|
export interface DocTypeGrantedUsersListArgs {
|
||||||
pagination: PaginationInput;
|
pagination: PaginationInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocTypeLastAccessedMembersArgs {
|
||||||
|
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
|
pagination: PaginationInput;
|
||||||
|
query?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocTypeEdge {
|
export interface DocTypeEdge {
|
||||||
__typename?: 'DocTypeEdge';
|
__typename?: 'DocTypeEdge';
|
||||||
cursor: Scalars['String']['output'];
|
cursor: Scalars['String']['output'];
|
||||||
@@ -2282,6 +2423,14 @@ export interface PageInfo {
|
|||||||
startCursor: Maybe<Scalars['String']['output']>;
|
startCursor: Maybe<Scalars['String']['output']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaginatedAdminAllSharedLink {
|
||||||
|
__typename?: 'PaginatedAdminAllSharedLink';
|
||||||
|
analyticsWindow: TimeWindow;
|
||||||
|
edges: Array<AdminAllSharedLinkEdge>;
|
||||||
|
pageInfo: PageInfo;
|
||||||
|
totalCount: Maybe<Scalars['Int']['output']>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedCommentChangeObjectType {
|
export interface PaginatedCommentChangeObjectType {
|
||||||
__typename?: 'PaginatedCommentChangeObjectType';
|
__typename?: 'PaginatedCommentChangeObjectType';
|
||||||
edges: Array<CommentChangeObjectTypeEdge>;
|
edges: Array<CommentChangeObjectTypeEdge>;
|
||||||
@@ -2310,6 +2459,13 @@ export interface PaginatedCopilotWorkspaceFileType {
|
|||||||
totalCount: Scalars['Int']['output'];
|
totalCount: Scalars['Int']['output'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaginatedDocMemberLastAccess {
|
||||||
|
__typename?: 'PaginatedDocMemberLastAccess';
|
||||||
|
edges: Array<DocMemberLastAccessEdge>;
|
||||||
|
pageInfo: PageInfo;
|
||||||
|
totalCount: Maybe<Scalars['Int']['output']>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedDocType {
|
export interface PaginatedDocType {
|
||||||
__typename?: 'PaginatedDocType';
|
__typename?: 'PaginatedDocType';
|
||||||
edges: Array<DocTypeEdge>;
|
edges: Array<DocTypeEdge>;
|
||||||
@@ -2376,6 +2532,10 @@ export interface PublicUserType {
|
|||||||
|
|
||||||
export interface Query {
|
export interface Query {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
|
/** List all shared links across workspaces for admin panel */
|
||||||
|
adminAllSharedLinks: PaginatedAdminAllSharedLink;
|
||||||
|
/** Get aggregated dashboard metrics for admin panel */
|
||||||
|
adminDashboard: AdminDashboard;
|
||||||
/** Get workspace detail for admin */
|
/** Get workspace detail for admin */
|
||||||
adminWorkspace: Maybe<AdminWorkspace>;
|
adminWorkspace: Maybe<AdminWorkspace>;
|
||||||
/** List workspaces for admin */
|
/** List workspaces for admin */
|
||||||
@@ -2428,6 +2588,15 @@ export interface Query {
|
|||||||
workspaces: Array<WorkspaceType>;
|
workspaces: Array<WorkspaceType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QueryAdminAllSharedLinksArgs {
|
||||||
|
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
|
||||||
|
pagination: PaginationInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryAdminDashboardArgs {
|
||||||
|
input?: InputMaybe<AdminDashboardInput>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueryAdminWorkspaceArgs {
|
export interface QueryAdminWorkspaceArgs {
|
||||||
id: Scalars['String']['input'];
|
id: Scalars['String']['input'];
|
||||||
}
|
}
|
||||||
@@ -2871,6 +3040,21 @@ export enum SubscriptionVariant {
|
|||||||
Onetime = 'Onetime',
|
Onetime = 'Onetime',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TimeBucket {
|
||||||
|
Day = 'Day',
|
||||||
|
Minute = 'Minute',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeWindow {
|
||||||
|
__typename?: 'TimeWindow';
|
||||||
|
bucket: TimeBucket;
|
||||||
|
effectiveSize: Scalars['Int']['output'];
|
||||||
|
from: Scalars['DateTime']['output'];
|
||||||
|
requestedSize: Scalars['Int']['output'];
|
||||||
|
timezone: Scalars['String']['output'];
|
||||||
|
to: Scalars['DateTime']['output'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TranscriptionItemType {
|
export interface TranscriptionItemType {
|
||||||
__typename?: 'TranscriptionItemType';
|
__typename?: 'TranscriptionItemType';
|
||||||
end: Scalars['String']['output'];
|
end: Scalars['String']['output'];
|
||||||
@@ -3409,6 +3593,124 @@ export type RevokeUserAccessTokenMutation = {
|
|||||||
revokeUserAccessToken: boolean;
|
revokeUserAccessToken: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminAllSharedLinksQueryVariables = Exact<{
|
||||||
|
pagination: PaginationInput;
|
||||||
|
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AdminAllSharedLinksQuery = {
|
||||||
|
__typename?: 'Query';
|
||||||
|
adminAllSharedLinks: {
|
||||||
|
__typename?: 'PaginatedAdminAllSharedLink';
|
||||||
|
totalCount: number | null;
|
||||||
|
analyticsWindow: {
|
||||||
|
__typename?: 'TimeWindow';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timezone: string;
|
||||||
|
bucket: TimeBucket;
|
||||||
|
requestedSize: number;
|
||||||
|
effectiveSize: number;
|
||||||
|
};
|
||||||
|
pageInfo: {
|
||||||
|
__typename?: 'PageInfo';
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
startCursor: string | null;
|
||||||
|
endCursor: string | null;
|
||||||
|
};
|
||||||
|
edges: Array<{
|
||||||
|
__typename?: 'AdminAllSharedLinkEdge';
|
||||||
|
cursor: string;
|
||||||
|
node: {
|
||||||
|
__typename?: 'AdminAllSharedLink';
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
title: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
docUpdatedAt: string | null;
|
||||||
|
workspaceOwnerId: string | null;
|
||||||
|
lastUpdaterId: string | null;
|
||||||
|
shareUrl: string;
|
||||||
|
views: number | null;
|
||||||
|
uniqueViews: number | null;
|
||||||
|
guestViews: number | null;
|
||||||
|
lastAccessedAt: string | null;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardQueryVariables = Exact<{
|
||||||
|
input?: InputMaybe<AdminDashboardInput>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AdminDashboardQuery = {
|
||||||
|
__typename?: 'Query';
|
||||||
|
adminDashboard: {
|
||||||
|
__typename?: 'AdminDashboard';
|
||||||
|
syncActiveUsers: number;
|
||||||
|
copilotConversations: number;
|
||||||
|
workspaceStorageBytes: number;
|
||||||
|
blobStorageBytes: number;
|
||||||
|
generatedAt: string;
|
||||||
|
syncActiveUsersTimeline: Array<{
|
||||||
|
__typename?: 'AdminDashboardMinutePoint';
|
||||||
|
minute: string;
|
||||||
|
activeUsers: number;
|
||||||
|
}>;
|
||||||
|
syncWindow: {
|
||||||
|
__typename?: 'TimeWindow';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timezone: string;
|
||||||
|
bucket: TimeBucket;
|
||||||
|
requestedSize: number;
|
||||||
|
effectiveSize: number;
|
||||||
|
};
|
||||||
|
workspaceStorageHistory: Array<{
|
||||||
|
__typename?: 'AdminDashboardValueDayPoint';
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
blobStorageHistory: Array<{
|
||||||
|
__typename?: 'AdminDashboardValueDayPoint';
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
storageWindow: {
|
||||||
|
__typename?: 'TimeWindow';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timezone: string;
|
||||||
|
bucket: TimeBucket;
|
||||||
|
requestedSize: number;
|
||||||
|
effectiveSize: number;
|
||||||
|
};
|
||||||
|
topSharedLinks: Array<{
|
||||||
|
__typename?: 'AdminSharedLinkTopItem';
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
title: string | null;
|
||||||
|
shareUrl: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
views: number;
|
||||||
|
uniqueViews: number;
|
||||||
|
guestViews: number;
|
||||||
|
lastAccessedAt: string | null;
|
||||||
|
}>;
|
||||||
|
topSharedLinksWindow: {
|
||||||
|
__typename?: 'TimeWindow';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timezone: string;
|
||||||
|
bucket: TimeBucket;
|
||||||
|
requestedSize: number;
|
||||||
|
effectiveSize: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>;
|
export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type AdminServerConfigQuery = {
|
export type AdminServerConfigQuery = {
|
||||||
@@ -5916,6 +6218,93 @@ export type GetDocDefaultRoleQuery = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetDocLastAccessedMembersQueryVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
docId: Scalars['String']['input'];
|
||||||
|
pagination: PaginationInput;
|
||||||
|
query?: InputMaybe<Scalars['String']['input']>;
|
||||||
|
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type GetDocLastAccessedMembersQuery = {
|
||||||
|
__typename?: 'Query';
|
||||||
|
workspace: {
|
||||||
|
__typename?: 'WorkspaceType';
|
||||||
|
doc: {
|
||||||
|
__typename?: 'DocType';
|
||||||
|
lastAccessedMembers: {
|
||||||
|
__typename?: 'PaginatedDocMemberLastAccess';
|
||||||
|
totalCount: number | null;
|
||||||
|
pageInfo: {
|
||||||
|
__typename?: 'PageInfo';
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
startCursor: string | null;
|
||||||
|
endCursor: string | null;
|
||||||
|
};
|
||||||
|
edges: Array<{
|
||||||
|
__typename?: 'DocMemberLastAccessEdge';
|
||||||
|
cursor: string;
|
||||||
|
node: {
|
||||||
|
__typename?: 'DocMemberLastAccess';
|
||||||
|
lastAccessedAt: string;
|
||||||
|
lastDocId: string | null;
|
||||||
|
user: {
|
||||||
|
__typename?: 'PublicUserType';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetDocPageAnalyticsQueryVariables = Exact<{
|
||||||
|
workspaceId: Scalars['String']['input'];
|
||||||
|
docId: Scalars['String']['input'];
|
||||||
|
input?: InputMaybe<DocPageAnalyticsInput>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type GetDocPageAnalyticsQuery = {
|
||||||
|
__typename?: 'Query';
|
||||||
|
workspace: {
|
||||||
|
__typename?: 'WorkspaceType';
|
||||||
|
doc: {
|
||||||
|
__typename?: 'DocType';
|
||||||
|
analytics: {
|
||||||
|
__typename?: 'DocPageAnalytics';
|
||||||
|
generatedAt: string;
|
||||||
|
window: {
|
||||||
|
__typename?: 'TimeWindow';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timezone: string;
|
||||||
|
bucket: TimeBucket;
|
||||||
|
requestedSize: number;
|
||||||
|
effectiveSize: number;
|
||||||
|
};
|
||||||
|
series: Array<{
|
||||||
|
__typename?: 'DocPageAnalyticsPoint';
|
||||||
|
date: string;
|
||||||
|
totalViews: number;
|
||||||
|
uniqueViews: number;
|
||||||
|
guestViews: number;
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
__typename?: 'DocPageAnalyticsSummary';
|
||||||
|
totalViews: number;
|
||||||
|
uniqueViews: number;
|
||||||
|
guestViews: number;
|
||||||
|
lastAccessedAt: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type GetDocSummaryQueryVariables = Exact<{
|
export type GetDocSummaryQueryVariables = Exact<{
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
docId: Scalars['String']['input'];
|
docId: Scalars['String']['input'];
|
||||||
@@ -7199,6 +7588,16 @@ export type Queries =
|
|||||||
variables: ListUserAccessTokensQueryVariables;
|
variables: ListUserAccessTokensQueryVariables;
|
||||||
response: ListUserAccessTokensQuery;
|
response: ListUserAccessTokensQuery;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'adminAllSharedLinksQuery';
|
||||||
|
variables: AdminAllSharedLinksQueryVariables;
|
||||||
|
response: AdminAllSharedLinksQuery;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'adminDashboardQuery';
|
||||||
|
variables: AdminDashboardQueryVariables;
|
||||||
|
response: AdminDashboardQuery;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'adminServerConfigQuery';
|
name: 'adminServerConfigQuery';
|
||||||
variables: AdminServerConfigQueryVariables;
|
variables: AdminServerConfigQueryVariables;
|
||||||
@@ -7419,6 +7818,16 @@ export type Queries =
|
|||||||
variables: GetDocDefaultRoleQueryVariables;
|
variables: GetDocDefaultRoleQueryVariables;
|
||||||
response: GetDocDefaultRoleQuery;
|
response: GetDocDefaultRoleQuery;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'getDocLastAccessedMembersQuery';
|
||||||
|
variables: GetDocLastAccessedMembersQueryVariables;
|
||||||
|
response: GetDocLastAccessedMembersQuery;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'getDocPageAnalyticsQuery';
|
||||||
|
variables: GetDocPageAnalyticsQueryVariables;
|
||||||
|
response: GetDocPageAnalyticsQuery;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'getDocSummaryQuery';
|
name: 'getDocSummaryQuery';
|
||||||
variables: GetDocSummaryQueryVariables;
|
variables: GetDocSummaryQueryVariables;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"react-hook-form": "^7.54.1",
|
"react-hook-form": "^7.54.1",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swr": "^2.3.7",
|
"swr": "^2.3.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export const Setup = lazy(
|
|||||||
export const Accounts = lazy(
|
export const Accounts = lazy(
|
||||||
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
|
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
|
||||||
);
|
);
|
||||||
|
export const Dashboard = lazy(
|
||||||
|
() => import(/* webpackChunkName: "dashboard" */ './modules/dashboard')
|
||||||
|
);
|
||||||
export const Workspaces = lazy(
|
export const Workspaces = lazy(
|
||||||
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
|
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
|
||||||
);
|
);
|
||||||
@@ -75,7 +78,15 @@ function RootRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (/^\/admin\/?$/.test(location.pathname)) {
|
if (/^\/admin\/?$/.test(location.pathname)) {
|
||||||
return <Navigate to="/admin/accounts" />;
|
return (
|
||||||
|
<Navigate
|
||||||
|
to={
|
||||||
|
environment.isSelfHosted
|
||||||
|
? ROUTES.admin.accounts
|
||||||
|
: ROUTES.admin.dashboard
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
@@ -96,6 +107,16 @@ export const App = () => {
|
|||||||
<Route path={ROUTES.admin.auth} element={<Auth />} />
|
<Route path={ROUTES.admin.auth} element={<Auth />} />
|
||||||
<Route path={ROUTES.admin.setup} element={<Setup />} />
|
<Route path={ROUTES.admin.setup} element={<Setup />} />
|
||||||
<Route element={<AuthenticatedRoutes />}>
|
<Route element={<AuthenticatedRoutes />}>
|
||||||
|
<Route
|
||||||
|
path={ROUTES.admin.dashboard}
|
||||||
|
element={
|
||||||
|
environment.isSelfHosted ? (
|
||||||
|
<Navigate to={ROUTES.admin.accounts} replace />
|
||||||
|
) : (
|
||||||
|
<Dashboard />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
|
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
|
||||||
<Route
|
<Route
|
||||||
path={ROUTES.admin.workspaces}
|
path={ROUTES.admin.workspaces}
|
||||||
|
|||||||
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal file
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { cn } from '@affine/admin/utils';
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { TooltipProps } from 'recharts';
|
||||||
|
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||||
|
|
||||||
|
const THEMES = { light: '', dark: '.dark' } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
color?: string;
|
||||||
|
theme?: Partial<Record<keyof typeof THEMES, string>>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ChartContextValue = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextValue | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const value = React.useContext(ChartContext);
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('useChart must be used within <ChartContainer />');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartStyle({
|
||||||
|
chartId,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
chartId: string;
|
||||||
|
config: ChartConfig;
|
||||||
|
}) {
|
||||||
|
const colorEntries = Object.entries(config).filter(
|
||||||
|
([, item]) => item.color || item.theme
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorEntries.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const css = Object.entries(THEMES)
|
||||||
|
.map(([themeKey, prefix]) => {
|
||||||
|
const declarations = colorEntries
|
||||||
|
.map(([key, item]) => {
|
||||||
|
const color =
|
||||||
|
item.theme?.[themeKey as keyof typeof THEMES] ?? item.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (!declarations) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix} [data-chart="${chartId}"] {\n${declarations}\n}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
if (!css) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContainerProps = React.ComponentProps<'div'> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
|
||||||
|
({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-0 w-full items-center justify-center text-xs',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle chartId={chartId} config={config} />
|
||||||
|
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ChartContainer.displayName = 'ChartContainer';
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsTooltip;
|
||||||
|
|
||||||
|
type TooltipContentProps = {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipProps<number, string>['payload'];
|
||||||
|
label?: string | number;
|
||||||
|
labelFormatter?: (
|
||||||
|
label: string | number,
|
||||||
|
payload: TooltipProps<number, string>['payload']
|
||||||
|
) => React.ReactNode;
|
||||||
|
valueFormatter?: (value: number, key: string) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
TooltipContentProps
|
||||||
|
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<div className="mb-2 font-medium text-foreground/90">{title}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const dataKey = String(item.dataKey ?? item.name ?? index);
|
||||||
|
const itemConfig = config[dataKey];
|
||||||
|
const labelText = itemConfig?.label ?? item.name ?? dataKey;
|
||||||
|
const numericValue =
|
||||||
|
typeof item.value === 'number'
|
||||||
|
? item.value
|
||||||
|
: Number(item.value ?? 0);
|
||||||
|
const valueText = valueFormatter
|
||||||
|
? valueFormatter(numericValue, dataKey)
|
||||||
|
: numericValue;
|
||||||
|
const color = item.color ?? `var(--color-${dataKey})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${dataKey}-${index}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{labelText}</span>
|
||||||
|
<span className="ml-auto font-medium tabular-nums">
|
||||||
|
{valueText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ChartTooltipContent.displayName = 'ChartTooltipContent';
|
||||||
|
|
||||||
|
export { ChartContainer, ChartTooltip, ChartTooltipContent };
|
||||||
645
packages/frontend/admin/src/modules/dashboard/index.tsx
Normal file
645
packages/frontend/admin/src/modules/dashboard/index.tsx
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
import { Button } from '@affine/admin/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@affine/admin/components/ui/card';
|
||||||
|
import {
|
||||||
|
type ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from '@affine/admin/components/ui/chart';
|
||||||
|
import { Label } from '@affine/admin/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@affine/admin/components/ui/select';
|
||||||
|
import { Separator } from '@affine/admin/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@affine/admin/components/ui/table';
|
||||||
|
import { useQuery } from '@affine/admin/use-query';
|
||||||
|
import { adminDashboardQuery } from '@affine/graphql';
|
||||||
|
import { ROUTES } from '@affine/routes';
|
||||||
|
import {
|
||||||
|
DatabaseIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { type ReactNode, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { Header } from '../header';
|
||||||
|
import { formatBytes } from '../workspaces/utils';
|
||||||
|
|
||||||
|
const intFormatter = new Intl.NumberFormat('en-US');
|
||||||
|
const compactFormatter = new Intl.NumberFormat('en-US', {
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
const utcDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'UTC',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
|
||||||
|
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] as const;
|
||||||
|
const SHARED_DAY_OPTIONS = [7, 14, 28, 60, 90] as const;
|
||||||
|
|
||||||
|
type DualNumberPoint = {
|
||||||
|
label: string;
|
||||||
|
primary: number;
|
||||||
|
secondary: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TrendPoint = {
|
||||||
|
x: number;
|
||||||
|
label: string;
|
||||||
|
primary: number;
|
||||||
|
secondary?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
return utcDateTimeFormatter.format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return utcDateFormatter.format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function downsample<T>(items: T[], maxPoints: number) {
|
||||||
|
if (items.length <= maxPoints) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = Math.ceil(items.length / maxPoints);
|
||||||
|
return items.filter(
|
||||||
|
(_, index) => index % step === 0 || index === items.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIndexedTrendPoints<T extends Omit<TrendPoint, 'x'>>(points: T[]) {
|
||||||
|
return points.map((point, index) => ({
|
||||||
|
...point,
|
||||||
|
x: index,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendChart({
|
||||||
|
ariaLabel,
|
||||||
|
points,
|
||||||
|
primaryLabel,
|
||||||
|
primaryFormatter,
|
||||||
|
secondaryLabel,
|
||||||
|
secondaryFormatter,
|
||||||
|
}: {
|
||||||
|
ariaLabel: string;
|
||||||
|
points: TrendPoint[];
|
||||||
|
primaryLabel: string;
|
||||||
|
primaryFormatter: (value: number) => string;
|
||||||
|
secondaryLabel?: string;
|
||||||
|
secondaryFormatter?: (value: number) => string;
|
||||||
|
}) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return <div className="text-sm text-muted-foreground">No data</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartPoints =
|
||||||
|
points.length === 1
|
||||||
|
? [points[0], { ...points[0], x: points[0].x + 1 }]
|
||||||
|
: points;
|
||||||
|
|
||||||
|
const hasSecondary =
|
||||||
|
Boolean(secondaryLabel) &&
|
||||||
|
chartPoints.some(point => typeof point.secondary === 'number');
|
||||||
|
const config: ChartConfig = {
|
||||||
|
primary: {
|
||||||
|
label: primaryLabel,
|
||||||
|
color: 'hsl(var(--primary))',
|
||||||
|
},
|
||||||
|
...(hasSecondary
|
||||||
|
? {
|
||||||
|
secondary: {
|
||||||
|
label: secondaryLabel,
|
||||||
|
color: 'hsl(var(--foreground) / 0.6)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ChartContainer
|
||||||
|
config={config}
|
||||||
|
className="h-44 w-full"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<LineChart
|
||||||
|
data={chartPoints}
|
||||||
|
margin={{ top: 8, right: 0, bottom: 0, left: 0 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
vertical={false}
|
||||||
|
stroke="hsl(var(--border) / 0.6)"
|
||||||
|
strokeDasharray="3 4"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="x"
|
||||||
|
type="number"
|
||||||
|
hide
|
||||||
|
allowDecimals={false}
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
hide
|
||||||
|
domain={[
|
||||||
|
0,
|
||||||
|
(max: number) => {
|
||||||
|
if (max <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.ceil(max * 1.1);
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={{
|
||||||
|
stroke: 'hsl(var(--border))',
|
||||||
|
strokeDasharray: '4 4',
|
||||||
|
strokeWidth: 1,
|
||||||
|
}}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
const item = payload?.[0];
|
||||||
|
return item?.payload?.label ?? '';
|
||||||
|
}}
|
||||||
|
valueFormatter={(value, key) => {
|
||||||
|
if (key === 'secondary') {
|
||||||
|
return secondaryFormatter
|
||||||
|
? secondaryFormatter(value)
|
||||||
|
: intFormatter.format(value);
|
||||||
|
}
|
||||||
|
return primaryFormatter(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="primary"
|
||||||
|
type="monotone"
|
||||||
|
fill="var(--color-primary)"
|
||||||
|
fillOpacity={0.16}
|
||||||
|
stroke="none"
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="primary"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--color-primary)"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
{hasSecondary ? (
|
||||||
|
<Line
|
||||||
|
dataKey="secondary"
|
||||||
|
type="monotone"
|
||||||
|
stroke="var(--color-secondary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 3 }}
|
||||||
|
strokeDasharray="6 4"
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
<span>{points[0]?.label}</span>
|
||||||
|
<span>{points[points.length - 1]?.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrimaryMetricCard({
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="lg:col-span-5 border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="flex items-center gap-2 text-foreground/75">
|
||||||
|
<UsersIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Current Sync Active Users
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1">
|
||||||
|
<div className="text-4xl font-bold tracking-tight tabular-nums">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryMetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="flex items-center gap-2">
|
||||||
|
<span aria-hidden="true">{icon}</span>
|
||||||
|
{title}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WindowSelect({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
unit,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
options: readonly number[];
|
||||||
|
unit: string;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 min-w-40">
|
||||||
|
<Label
|
||||||
|
htmlFor={id}
|
||||||
|
className="text-xs uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={String(value)}
|
||||||
|
onValueChange={next => onChange(Number(next))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={id}>
|
||||||
|
<SelectValue placeholder={`Select ${label.toLowerCase()}…`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map(option => (
|
||||||
|
<SelectItem key={option} value={String(option)}>
|
||||||
|
{option} {unit}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
|
||||||
|
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
||||||
|
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
||||||
|
|
||||||
|
const variables = useMemo(
|
||||||
|
() => ({
|
||||||
|
input: {
|
||||||
|
storageHistoryDays,
|
||||||
|
syncHistoryHours,
|
||||||
|
sharedLinkWindowDays,
|
||||||
|
timezone: 'UTC',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isValidating, mutate } = useQuery(
|
||||||
|
{
|
||||||
|
query: adminDashboardQuery,
|
||||||
|
variables,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: true,
|
||||||
|
revalidateOnReconnect: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const dashboard = data.adminDashboard;
|
||||||
|
|
||||||
|
const syncPoints = useMemo(
|
||||||
|
() =>
|
||||||
|
toIndexedTrendPoints(
|
||||||
|
downsample(
|
||||||
|
dashboard.syncActiveUsersTimeline.map(point => ({
|
||||||
|
label: formatDateTime(point.minute),
|
||||||
|
primary: point.activeUsers,
|
||||||
|
})),
|
||||||
|
96
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[dashboard.syncActiveUsersTimeline]
|
||||||
|
);
|
||||||
|
|
||||||
|
const storagePoints = useMemo(() => {
|
||||||
|
const merged: DualNumberPoint[] = dashboard.workspaceStorageHistory.map(
|
||||||
|
(point, index) => ({
|
||||||
|
label: formatDate(point.date),
|
||||||
|
primary: point.value,
|
||||||
|
secondary: dashboard.blobStorageHistory[index]?.value ?? 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return toIndexedTrendPoints(downsample(merged, 60));
|
||||||
|
}, [dashboard.blobStorageHistory, dashboard.workspaceStorageHistory]);
|
||||||
|
|
||||||
|
const totalStorageBytes =
|
||||||
|
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex-1 flex-col flex overflow-hidden">
|
||||||
|
<Header
|
||||||
|
title="Dashboard"
|
||||||
|
endFix={
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
Updated at {formatDateTime(dashboard.generatedAt)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
mutate().catch(() => {});
|
||||||
|
}}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||||
|
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 via-card to-card shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Window Controls</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tune dashboard windows. Data is sampled in UTC and refreshes
|
||||||
|
automatically.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 grid-cols-1 md:grid-cols-3 items-end">
|
||||||
|
<WindowSelect
|
||||||
|
id="storage-history-window"
|
||||||
|
label="Storage History"
|
||||||
|
value={storageHistoryDays}
|
||||||
|
options={STORAGE_DAY_OPTIONS}
|
||||||
|
unit="days"
|
||||||
|
onChange={setStorageHistoryDays}
|
||||||
|
/>
|
||||||
|
<WindowSelect
|
||||||
|
id="sync-history-window"
|
||||||
|
label="Sync History"
|
||||||
|
value={syncHistoryHours}
|
||||||
|
options={SYNC_HOUR_OPTIONS}
|
||||||
|
unit="hours"
|
||||||
|
onChange={setSyncHistoryHours}
|
||||||
|
/>
|
||||||
|
<WindowSelect
|
||||||
|
id="shared-link-window"
|
||||||
|
label="Shared Link Window"
|
||||||
|
value={sharedLinkWindowDays}
|
||||||
|
options={SHARED_DAY_OPTIONS}
|
||||||
|
unit="days"
|
||||||
|
onChange={setSharedLinkWindowDays}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
|
||||||
|
<PrimaryMetricCard
|
||||||
|
value={intFormatter.format(dashboard.syncActiveUsers)}
|
||||||
|
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
||||||
|
/>
|
||||||
|
<SecondaryMetricCard
|
||||||
|
title="Copilot Conversations"
|
||||||
|
value={intFormatter.format(dashboard.copilotConversations)}
|
||||||
|
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
|
||||||
|
icon={
|
||||||
|
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription className="flex items-center gap-2">
|
||||||
|
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Managed Storage
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||||
|
{formatBytes(totalStorageBytes)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Workspace {formatBytes(dashboard.workspaceStorageBytes)} • Blob{' '}
|
||||||
|
{formatBytes(dashboard.blobStorageBytes)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
|
||||||
|
<Card className="xl:col-span-1 border-border/70 bg-card/95 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Sync Active Users Trend
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{dashboard.syncWindow.effectiveSize}h at minute bucket
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<TrendChart
|
||||||
|
ariaLabel="Sync active users trend"
|
||||||
|
points={syncPoints}
|
||||||
|
primaryLabel="Sync Active Users"
|
||||||
|
primaryFormatter={value => intFormatter.format(value)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="xl:col-span-2 border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Storage Trend (Workspace + Blob)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{dashboard.storageWindow.effectiveSize}d at day bucket
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<TrendChart
|
||||||
|
ariaLabel="Workspace and blob storage trend"
|
||||||
|
points={storagePoints}
|
||||||
|
primaryLabel="Workspace Storage"
|
||||||
|
primaryFormatter={value => formatBytes(value)}
|
||||||
|
secondaryLabel="Blob Storage"
|
||||||
|
secondaryFormatter={value => formatBytes(value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-primary" />
|
||||||
|
Workspace: {formatBytes(dashboard.workspaceStorageBytes)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-foreground/50" />
|
||||||
|
Blob: {formatBytes(dashboard.blobStorageBytes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Top {dashboard.topSharedLinks.length} links in the last{' '}
|
||||||
|
{dashboard.topSharedLinksWindow.effectiveSize} days
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{dashboard.topSharedLinks.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
No shared links in this window
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">
|
||||||
|
Publish pages and collect traffic, then this table will rank
|
||||||
|
links by views.
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||||
|
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Document</TableHead>
|
||||||
|
<TableHead>Workspace</TableHead>
|
||||||
|
<TableHead className="text-right">Views</TableHead>
|
||||||
|
<TableHead className="text-right">Unique</TableHead>
|
||||||
|
<TableHead className="text-right">Guest</TableHead>
|
||||||
|
<TableHead>Last Accessed</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dashboard.topSharedLinks.map(link => (
|
||||||
|
<TableRow
|
||||||
|
key={`${link.workspaceId}-${link.docId}`}
|
||||||
|
className="hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<TableCell className="max-w-80 min-w-0">
|
||||||
|
<a
|
||||||
|
href={link.shareUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium underline-offset-4 hover:underline truncate block"
|
||||||
|
>
|
||||||
|
{link.title || link.docId}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs tabular-nums">
|
||||||
|
{link.workspaceId}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{compactFormatter.format(link.views)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{compactFormatter.format(link.uniqueViews)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{compactFormatter.format(link.guestViews)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="tabular-nums">
|
||||||
|
{link.lastAccessedAt
|
||||||
|
? formatDateTime(link.lastAccessedAt)
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||||
|
<span>{formatDate(dashboard.topSharedLinksWindow.from)}</span>
|
||||||
|
<span>{formatDate(dashboard.topSharedLinksWindow.to)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DashboardPage as Component };
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { buttonVariants } from '@affine/admin/components/ui/button';
|
import { buttonVariants } from '@affine/admin/components/ui/button';
|
||||||
import { cn } from '@affine/admin/utils';
|
import { cn } from '@affine/admin/utils';
|
||||||
|
import { ROUTES } from '@affine/routes';
|
||||||
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
|
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { LayoutDashboardIcon, ListChecksIcon } from 'lucide-react';
|
import {
|
||||||
|
BarChart3Icon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ListChecksIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { ServerVersion } from './server-version';
|
import { ServerVersion } from './server-version';
|
||||||
@@ -85,22 +90,30 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
|||||||
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
|
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{environment.isSelfHosted ? null : (
|
||||||
|
<NavItem
|
||||||
|
to={ROUTES.admin.dashboard}
|
||||||
|
icon={<BarChart3Icon size={18} />}
|
||||||
|
label="Dashboard"
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/admin/accounts"
|
to={ROUTES.admin.accounts}
|
||||||
icon={<AccountIcon fontSize={20} />}
|
icon={<AccountIcon fontSize={20} />}
|
||||||
label="Accounts"
|
label="Accounts"
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
{environment.isSelfHosted ? null : (
|
{environment.isSelfHosted ? null : (
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/admin/workspaces"
|
to={ROUTES.admin.workspaces}
|
||||||
icon={<LayoutDashboardIcon size={18} />}
|
icon={<LayoutDashboardIcon size={18} />}
|
||||||
label="Workspaces"
|
label="Workspaces"
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/admin/queue"
|
to={ROUTES.admin.queue}
|
||||||
icon={<ListChecksIcon size={18} />}
|
icon={<ListChecksIcon size={18} />}
|
||||||
label="Queue"
|
label="Queue"
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@@ -113,7 +126,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
|
|||||||
/> */}
|
/> */}
|
||||||
<SettingsItem isCollapsed={isCollapsed} />
|
<SettingsItem isCollapsed={isCollapsed} />
|
||||||
<NavItem
|
<NavItem
|
||||||
to="/admin/about"
|
to={ROUTES.admin.about}
|
||||||
icon={<SelfhostIcon fontSize={20} />}
|
icon={<SelfhostIcon fontSize={20} />}
|
||||||
label="About"
|
label="About"
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
"react-transition-state": "^2.2.0",
|
"react-transition-state": "^2.2.0",
|
||||||
"react-virtuoso": "^4.12.3",
|
"react-virtuoso": "^4.12.3",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"ses": "^1.14.0",
|
"ses": "^1.14.0",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
|
|||||||
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
|
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
|
||||||
import {
|
import {
|
||||||
AiIcon,
|
AiIcon,
|
||||||
|
ChartPanelIcon,
|
||||||
CommentIcon,
|
CommentIcon,
|
||||||
ExportIcon,
|
ExportIcon,
|
||||||
FrameIcon,
|
FrameIcon,
|
||||||
@@ -67,6 +68,7 @@ import * as styles from './detail-page.css';
|
|||||||
import { DetailPageHeader } from './detail-page-header';
|
import { DetailPageHeader } from './detail-page-header';
|
||||||
import { DetailPageWrapper } from './detail-page-wrapper';
|
import { DetailPageWrapper } from './detail-page-wrapper';
|
||||||
import { EditorAdapterPanel } from './tabs/adapter';
|
import { EditorAdapterPanel } from './tabs/adapter';
|
||||||
|
import { EditorAnalyticsPanel } from './tabs/analytics';
|
||||||
import { EditorChatPanel } from './tabs/chat';
|
import { EditorChatPanel } from './tabs/chat';
|
||||||
import { EditorFramePanel } from './tabs/frame';
|
import { EditorFramePanel } from './tabs/frame';
|
||||||
import { EditorJournalPanel } from './tabs/journal';
|
import { EditorJournalPanel } from './tabs/journal';
|
||||||
@@ -433,6 +435,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
</ViewSidebarTab>
|
</ViewSidebarTab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{workspace.flavour === 'affine-cloud' && (
|
||||||
|
<ViewSidebarTab tabId="analytics" icon={<ChartPanelIcon />}>
|
||||||
|
<Scrollable.Root className={styles.sidebarScrollArea}>
|
||||||
|
<Scrollable.Viewport>
|
||||||
|
<EditorAnalyticsPanel workspaceId={workspace.id} docId={doc.id} />
|
||||||
|
</Scrollable.Viewport>
|
||||||
|
<Scrollable.Scrollbar />
|
||||||
|
</Scrollable.Root>
|
||||||
|
</ViewSidebarTab>
|
||||||
|
)}
|
||||||
|
|
||||||
<GlobalPageHistoryModal />
|
<GlobalPageHistoryModal />
|
||||||
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
|
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
|
||||||
</FrameworkScope>
|
</FrameworkScope>
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: '16px',
|
||||||
|
gap: '20px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const section = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sectionHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sectionTitle = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: '8px',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
fontWeight: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sectionSubtitle = style({
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const windowButton = style({
|
||||||
|
minWidth: '124px',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const lockButton = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metrics = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
gap: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metricCard = style({
|
||||||
|
minWidth: '0',
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||||
|
padding: '8px 10px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '4px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metricLabel = style({
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
lineHeight: 1.2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metricValue = style({
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chartContainer = style({
|
||||||
|
height: '228px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||||
|
padding: '10px 10px 8px 10px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const axisLabels = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
marginTop: '4px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chartLegend = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '12px',
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const legendItem = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const legendDot = style({
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tooltip = style({
|
||||||
|
minWidth: '160px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
backgroundColor: cssVar('backgroundPrimaryColor'),
|
||||||
|
boxShadow: cssVar('shadow2'),
|
||||||
|
padding: '8px 10px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tooltipTitle = style({
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: '6px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tooltipRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
lineHeight: 1.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tooltipValue = style({
|
||||||
|
marginLeft: 'auto',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontWeight: 600,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emptyState = style({
|
||||||
|
borderRadius: '10px',
|
||||||
|
border: `1px dashed ${cssVar('borderColor')}`,
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
minHeight: '96px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '0 16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const viewersList = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const viewerRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '6px 8px',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: cssVar('hoverColor'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const viewerUser = style({
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const viewerName = style({
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const viewerTime = style({
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loadMoreButton = style({
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loading = style({
|
||||||
|
minHeight: '120px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Loading,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
toast,
|
||||||
|
} from '@affine/component';
|
||||||
|
import { useQuery } from '@affine/core/components/hooks/use-query';
|
||||||
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
|
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||||
|
import {
|
||||||
|
getDocLastAccessedMembersQuery,
|
||||||
|
getDocPageAnalyticsQuery,
|
||||||
|
} from '@affine/graphql';
|
||||||
|
import { i18nTime, useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
ArrowDownSmallIcon,
|
||||||
|
CalendarPanelIcon,
|
||||||
|
LockIcon,
|
||||||
|
} from '@blocksuite/icons/rc';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
type TooltipProps,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import * as styles from './analytics.css';
|
||||||
|
import {
|
||||||
|
type AnalyticsChartPoint,
|
||||||
|
buildAnalyticsChartPoints,
|
||||||
|
clampAnalyticsWindowDays,
|
||||||
|
DEFAULT_ANALYTICS_WINDOW_DAYS,
|
||||||
|
ensureMinimumChartPoints,
|
||||||
|
getAvailableAnalyticsWindowOptions,
|
||||||
|
INITIAL_MEMBERS_PAGE_SIZE,
|
||||||
|
isLockedAnalyticsWindowOption,
|
||||||
|
MAX_MEMBERS_PAGE_SIZE,
|
||||||
|
} from './analytics.utils';
|
||||||
|
|
||||||
|
const intFormatter = new Intl.NumberFormat('en-US');
|
||||||
|
const totalViewsColor = cssVar('primaryColor');
|
||||||
|
const uniqueViewsColor = cssVar('processingColor');
|
||||||
|
|
||||||
|
function formatChartDate(value: string) {
|
||||||
|
return i18nTime(value, { absolute: { accuracy: 'day' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalyticsChartTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: TooltipProps<number, string>) {
|
||||||
|
const t = useI18n();
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = payload[0]?.payload as AnalyticsChartPoint | undefined;
|
||||||
|
if (!point) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueByKey = payload.reduce<Record<string, number>>((acc, item) => {
|
||||||
|
if (!item.dataKey) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc[String(item.dataKey)] =
|
||||||
|
typeof item.value === 'number' ? item.value : Number(item.value ?? 0);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tooltip}>
|
||||||
|
<div className={styles.tooltipTitle}>{formatChartDate(point.date)}</div>
|
||||||
|
<div className={styles.tooltipRow}>
|
||||||
|
<span
|
||||||
|
className={styles.legendDot}
|
||||||
|
style={{ backgroundColor: totalViewsColor }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t['com.affine.doc.analytics.chart.total-views']()}
|
||||||
|
<span className={styles.tooltipValue}>
|
||||||
|
{intFormatter.format(valueByKey.totalViews ?? point.totalViews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tooltipRow}>
|
||||||
|
<span
|
||||||
|
className={styles.legendDot}
|
||||||
|
style={{ backgroundColor: uniqueViewsColor }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t['com.affine.doc.analytics.chart.unique-views']()}
|
||||||
|
<span className={styles.tooltipValue}>
|
||||||
|
{intFormatter.format(valueByKey.uniqueViews ?? point.uniqueViews)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorAnalyticsPanel = ({
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
}: {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const permission = useService(WorkspacePermissionService).permission;
|
||||||
|
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||||
|
const isTeam = useLiveData(permission.isTeam$);
|
||||||
|
const isTeamWorkspace = isTeam ?? false;
|
||||||
|
const [windowDays, setWindowDays] = useState(DEFAULT_ANALYTICS_WINDOW_DAYS);
|
||||||
|
const [membersPageSize, setMembersPageSize] = useState(
|
||||||
|
INITIAL_MEMBERS_PAGE_SIZE
|
||||||
|
);
|
||||||
|
const allowedWindowOptions = useMemo(
|
||||||
|
() => getAvailableAnalyticsWindowOptions(),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const effectiveWindowDays = useMemo(
|
||||||
|
() => clampAnalyticsWindowDays(windowDays, isTeamWorkspace),
|
||||||
|
[isTeamWorkspace, windowDays]
|
||||||
|
);
|
||||||
|
const timezone = useMemo(
|
||||||
|
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMembersPageSize(INITIAL_MEMBERS_PAGE_SIZE);
|
||||||
|
}, [docId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
permission.revalidate();
|
||||||
|
}, [permission, workspaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (windowDays !== effectiveWindowDays) {
|
||||||
|
setWindowDays(effectiveWindowDays);
|
||||||
|
}
|
||||||
|
}, [effectiveWindowDays, windowDays]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: analyticsData,
|
||||||
|
isLoading: analyticsLoading,
|
||||||
|
error: analyticsError,
|
||||||
|
} = useQuery(
|
||||||
|
{
|
||||||
|
query: getDocPageAnalyticsQuery,
|
||||||
|
variables: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
input: {
|
||||||
|
windowDays: effectiveWindowDays,
|
||||||
|
timezone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
suspense: false,
|
||||||
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: membersData,
|
||||||
|
isLoading: membersLoading,
|
||||||
|
error: membersError,
|
||||||
|
} = useQuery(
|
||||||
|
{
|
||||||
|
query: getDocLastAccessedMembersQuery,
|
||||||
|
variables: {
|
||||||
|
workspaceId,
|
||||||
|
docId,
|
||||||
|
pagination: {
|
||||||
|
first: membersPageSize,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
includeTotal: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
suspense: false,
|
||||||
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const analytics = analyticsData?.workspace.doc.analytics;
|
||||||
|
const summary = analytics?.summary;
|
||||||
|
const chartPoints = useMemo(
|
||||||
|
() =>
|
||||||
|
ensureMinimumChartPoints(
|
||||||
|
buildAnalyticsChartPoints(analytics?.series ?? [])
|
||||||
|
),
|
||||||
|
[analytics?.series]
|
||||||
|
);
|
||||||
|
|
||||||
|
const membersConnection = membersData?.workspace.doc.lastAccessedMembers;
|
||||||
|
const members = useMemo(
|
||||||
|
() => membersConnection?.edges.map(edge => edge.node) ?? [],
|
||||||
|
[membersConnection?.edges]
|
||||||
|
);
|
||||||
|
const totalMembers = membersConnection?.totalCount ?? members.length;
|
||||||
|
const hasMoreMembers =
|
||||||
|
Boolean(membersConnection?.pageInfo.hasNextPage) &&
|
||||||
|
membersPageSize < MAX_MEMBERS_PAGE_SIZE;
|
||||||
|
const openTeamPricing = useCallback(() => {
|
||||||
|
workspaceDialogService.open('setting', {
|
||||||
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
|
});
|
||||||
|
}, [workspaceDialogService]);
|
||||||
|
const showTeamPlanToast = useCallback(() => {
|
||||||
|
toast(t['com.affine.doc.analytics.paywall.toast']());
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<section className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
<span>{t['com.affine.doc.analytics.title']()}</span>
|
||||||
|
<span className={styles.sectionSubtitle}>
|
||||||
|
{summary
|
||||||
|
? t.t('com.affine.doc.analytics.summary.total', {
|
||||||
|
count: intFormatter.format(summary.totalViews),
|
||||||
|
})
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
contentOptions={{ align: 'end' }}
|
||||||
|
items={
|
||||||
|
<>
|
||||||
|
{allowedWindowOptions.map(option => {
|
||||||
|
const isLocked = isLockedAnalyticsWindowOption(
|
||||||
|
option,
|
||||||
|
isTeamWorkspace
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={option}
|
||||||
|
selected={effectiveWindowDays === option}
|
||||||
|
suffixIcon={
|
||||||
|
isLocked ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.lockButton}
|
||||||
|
aria-label={t[
|
||||||
|
'com.affine.doc.analytics.paywall.open-pricing'
|
||||||
|
]()}
|
||||||
|
onClick={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openTeamPricing();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LockIcon />
|
||||||
|
</button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onSelect={() => {
|
||||||
|
if (isLocked) {
|
||||||
|
showTeamPlanToast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWindowDays(option);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.t('com.affine.doc.analytics.window.last-days', {
|
||||||
|
days: option,
|
||||||
|
})}
|
||||||
|
{isLocked
|
||||||
|
? ` (${t['com.affine.payment.cloud.team-workspace.name']()})`
|
||||||
|
: ''}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="default"
|
||||||
|
className={styles.windowButton}
|
||||||
|
prefix={<CalendarPanelIcon />}
|
||||||
|
suffix={<ArrowDownSmallIcon />}
|
||||||
|
>
|
||||||
|
{t.t('com.affine.doc.analytics.window.last-days', {
|
||||||
|
days: effectiveWindowDays,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
{t['com.affine.doc.analytics.metric.total']()}
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>
|
||||||
|
{intFormatter.format(summary?.totalViews ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
{t['com.affine.doc.analytics.metric.unique']()}
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>
|
||||||
|
{intFormatter.format(summary?.uniqueViews ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
{t['com.affine.doc.analytics.metric.guest']()}
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>
|
||||||
|
{intFormatter.format(summary?.guestViews ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analyticsLoading && !analytics ? (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Loading size={20} />
|
||||||
|
</div>
|
||||||
|
) : analyticsError && !analytics ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
{t['com.affine.doc.analytics.error.load-analytics']()}
|
||||||
|
</div>
|
||||||
|
) : chartPoints.length ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart
|
||||||
|
data={chartPoints}
|
||||||
|
margin={{ top: 10, right: 6, bottom: 6, left: 6 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
vertical={false}
|
||||||
|
stroke={cssVar('borderColor')}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="x"
|
||||||
|
type="number"
|
||||||
|
hide
|
||||||
|
allowDecimals={false}
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
hide
|
||||||
|
domain={[
|
||||||
|
0,
|
||||||
|
(max: number) => {
|
||||||
|
if (max <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.ceil(max * 1.1);
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
cursor={{
|
||||||
|
stroke: cssVar('borderColor'),
|
||||||
|
strokeDasharray: '4 4',
|
||||||
|
}}
|
||||||
|
content={<AnalyticsChartTooltip />}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="totalViews"
|
||||||
|
type="monotone"
|
||||||
|
stroke={totalViewsColor}
|
||||||
|
fill={totalViewsColor}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="uniqueViews"
|
||||||
|
type="monotone"
|
||||||
|
stroke={uniqueViewsColor}
|
||||||
|
fill={uniqueViewsColor}
|
||||||
|
fillOpacity={0.1}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="totalViews"
|
||||||
|
type="monotone"
|
||||||
|
stroke={totalViewsColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="uniqueViews"
|
||||||
|
type="monotone"
|
||||||
|
stroke={uniqueViewsColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 3 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className={styles.axisLabels}>
|
||||||
|
<span>{formatChartDate(chartPoints[0].date)}</span>
|
||||||
|
<span>
|
||||||
|
{formatChartDate(chartPoints[chartPoints.length - 1].date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartLegend}>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.legendDot}
|
||||||
|
style={{ backgroundColor: totalViewsColor }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t['com.affine.doc.analytics.chart.total-views']()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.legendDot}
|
||||||
|
style={{ backgroundColor: uniqueViewsColor }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t['com.affine.doc.analytics.chart.unique-views']()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
{t['com.affine.doc.analytics.empty.no-page-views']()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
<span>{t['com.affine.doc.analytics.viewers.title']()}</span>
|
||||||
|
<span className={styles.sectionSubtitle}>
|
||||||
|
({intFormatter.format(totalMembers)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{membersLoading && !membersConnection ? (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<Loading size={20} />
|
||||||
|
</div>
|
||||||
|
) : membersError && !membersConnection ? (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
{t['com.affine.doc.analytics.error.load-viewers']()}
|
||||||
|
</div>
|
||||||
|
) : members.length ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.viewersList}>
|
||||||
|
{members.map(member => (
|
||||||
|
<div className={styles.viewerRow} key={member.user.id}>
|
||||||
|
<div className={styles.viewerUser}>
|
||||||
|
<Avatar
|
||||||
|
size={24}
|
||||||
|
url={member.user.avatarUrl || ''}
|
||||||
|
name={member.user.name}
|
||||||
|
/>
|
||||||
|
<span className={styles.viewerName}>
|
||||||
|
{member.user.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.viewerTime}>
|
||||||
|
{i18nTime(member.lastAccessedAt, { relative: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMoreMembers ? (
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
className={styles.loadMoreButton}
|
||||||
|
onClick={() => setMembersPageSize(MAX_MEMBERS_PAGE_SIZE)}
|
||||||
|
>
|
||||||
|
{t['com.affine.doc.analytics.viewers.show-all']()}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
{t['com.affine.doc.analytics.empty.no-viewers']()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ANALYTICS_WINDOW_OPTIONS,
|
||||||
|
buildAnalyticsChartPoints,
|
||||||
|
clampAnalyticsWindowDays,
|
||||||
|
DEFAULT_ANALYTICS_WINDOW_DAYS,
|
||||||
|
ensureMinimumChartPoints,
|
||||||
|
getAvailableAnalyticsWindowOptions,
|
||||||
|
isLockedAnalyticsWindowOption,
|
||||||
|
NON_TEAM_ANALYTICS_WINDOW_DAYS,
|
||||||
|
} from './analytics.utils';
|
||||||
|
|
||||||
|
describe('analytics.utils', () => {
|
||||||
|
test('clampAnalyticsWindowDays returns default for unsupported values', () => {
|
||||||
|
expect(clampAnalyticsWindowDays(28, true)).toBe(28);
|
||||||
|
expect(clampAnalyticsWindowDays(15, true)).toBe(
|
||||||
|
DEFAULT_ANALYTICS_WINDOW_DAYS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampAnalyticsWindowDays returns only 7 days for non-team workspaces', () => {
|
||||||
|
expect(clampAnalyticsWindowDays(7, false)).toBe(
|
||||||
|
NON_TEAM_ANALYTICS_WINDOW_DAYS
|
||||||
|
);
|
||||||
|
expect(clampAnalyticsWindowDays(28, false)).toBe(
|
||||||
|
NON_TEAM_ANALYTICS_WINDOW_DAYS
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAvailableAnalyticsWindowOptions keeps all options visible', () => {
|
||||||
|
expect(getAvailableAnalyticsWindowOptions()).toEqual([
|
||||||
|
...ANALYTICS_WINDOW_OPTIONS,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isLockedAnalyticsWindowOption locks windows over 7 days for non-team workspaces', () => {
|
||||||
|
expect(
|
||||||
|
isLockedAnalyticsWindowOption(NON_TEAM_ANALYTICS_WINDOW_DAYS, false)
|
||||||
|
).toBe(false);
|
||||||
|
expect(isLockedAnalyticsWindowOption(14, false)).toBe(true);
|
||||||
|
expect(isLockedAnalyticsWindowOption(14, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAnalyticsChartPoints sorts series by date and maps values', () => {
|
||||||
|
const points = buildAnalyticsChartPoints([
|
||||||
|
{
|
||||||
|
date: '2026-02-12',
|
||||||
|
totalViews: 4,
|
||||||
|
uniqueViews: 2,
|
||||||
|
guestViews: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2026-02-10',
|
||||||
|
totalViews: 9,
|
||||||
|
uniqueViews: 3,
|
||||||
|
guestViews: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(points).toEqual([
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
date: '2026-02-10',
|
||||||
|
totalViews: 9,
|
||||||
|
uniqueViews: 3,
|
||||||
|
guestViews: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 1,
|
||||||
|
date: '2026-02-12',
|
||||||
|
totalViews: 4,
|
||||||
|
uniqueViews: 2,
|
||||||
|
guestViews: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureMinimumChartPoints duplicates the only data point', () => {
|
||||||
|
const points = ensureMinimumChartPoints([
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
date: '2026-02-12',
|
||||||
|
totalViews: 4,
|
||||||
|
uniqueViews: 2,
|
||||||
|
guestViews: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(points).toEqual([
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
date: '2026-02-12',
|
||||||
|
totalViews: 4,
|
||||||
|
uniqueViews: 2,
|
||||||
|
guestViews: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 1,
|
||||||
|
date: '2026-02-12',
|
||||||
|
totalViews: 4,
|
||||||
|
uniqueViews: 2,
|
||||||
|
guestViews: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { GetDocPageAnalyticsQuery } from '@affine/graphql';
|
||||||
|
|
||||||
|
export const ANALYTICS_WINDOW_OPTIONS = [7, 14, 28, 60, 90] as const;
|
||||||
|
export const DEFAULT_ANALYTICS_WINDOW_DAYS = 28;
|
||||||
|
export const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7;
|
||||||
|
export const INITIAL_MEMBERS_PAGE_SIZE = 5;
|
||||||
|
export const MAX_MEMBERS_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export type AnalyticsSeriesPoint =
|
||||||
|
GetDocPageAnalyticsQuery['workspace']['doc']['analytics']['series'][number];
|
||||||
|
|
||||||
|
export type AnalyticsChartPoint = {
|
||||||
|
x: number;
|
||||||
|
date: string;
|
||||||
|
totalViews: number;
|
||||||
|
uniqueViews: number;
|
||||||
|
guestViews: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAvailableAnalyticsWindowOptions() {
|
||||||
|
return [...ANALYTICS_WINDOW_OPTIONS];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLockedAnalyticsWindowOption(
|
||||||
|
value: number,
|
||||||
|
isTeamWorkspace: boolean
|
||||||
|
) {
|
||||||
|
return !isTeamWorkspace && value > NON_TEAM_ANALYTICS_WINDOW_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampAnalyticsWindowDays(
|
||||||
|
value: number,
|
||||||
|
isTeamWorkspace: boolean
|
||||||
|
) {
|
||||||
|
if (!isTeamWorkspace) {
|
||||||
|
return NON_TEAM_ANALYTICS_WINDOW_DAYS;
|
||||||
|
}
|
||||||
|
return ANALYTICS_WINDOW_OPTIONS.includes(
|
||||||
|
value as (typeof ANALYTICS_WINDOW_OPTIONS)[number]
|
||||||
|
)
|
||||||
|
? value
|
||||||
|
: DEFAULT_ANALYTICS_WINDOW_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAnalyticsChartPoints(series: AnalyticsSeriesPoint[]) {
|
||||||
|
const sorted = [...series].sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(left.date).getTime() - new Date(right.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return sorted.map((point, index) => ({
|
||||||
|
x: index,
|
||||||
|
date: point.date,
|
||||||
|
totalViews: point.totalViews,
|
||||||
|
uniqueViews: point.uniqueViews,
|
||||||
|
guestViews: point.guestViews,
|
||||||
|
})) satisfies AnalyticsChartPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureMinimumChartPoints(points: AnalyticsChartPoint[]) {
|
||||||
|
if (points.length !== 1) {
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
points[0],
|
||||||
|
{
|
||||||
|
...points[0],
|
||||||
|
x: points[0].x + 1,
|
||||||
|
},
|
||||||
|
] satisfies AnalyticsChartPoint[];
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
{
|
{
|
||||||
"ar": 97,
|
"ar": 96,
|
||||||
"ca": 98,
|
"ca": 98,
|
||||||
"da": 4,
|
"da": 4,
|
||||||
"de": 98,
|
"de": 97,
|
||||||
"el-GR": 97,
|
"el-GR": 96,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 97,
|
"es-AR": 96,
|
||||||
"es-CL": 98,
|
"es-CL": 98,
|
||||||
"es": 97,
|
"es": 96,
|
||||||
"fa": 97,
|
"fa": 96,
|
||||||
"fr": 98,
|
"fr": 98,
|
||||||
"hi": 2,
|
"hi": 1,
|
||||||
"it-IT": 98,
|
"it-IT": 98,
|
||||||
"it": 1,
|
"it": 1,
|
||||||
"ja": 97,
|
"ja": 96,
|
||||||
"ko": 98,
|
"ko": 97,
|
||||||
"nb-NO": 48,
|
"nb-NO": 47,
|
||||||
"pl": 98,
|
"pl": 98,
|
||||||
"pt-BR": 97,
|
"pt-BR": 96,
|
||||||
"ru": 98,
|
"ru": 98,
|
||||||
"sv-SE": 97,
|
"sv-SE": 97,
|
||||||
"uk": 97,
|
"uk": 96,
|
||||||
"ur": 2,
|
"ur": 2,
|
||||||
"zh-Hans": 99,
|
"zh-Hans": 98,
|
||||||
"zh-Hant": 97
|
"zh-Hant": 97
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4574,6 +4574,74 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Copied key to clipboard`
|
* `Copied key to clipboard`
|
||||||
*/
|
*/
|
||||||
["com.affine.payment.license-success.copy"](): string;
|
["com.affine.payment.license-success.copy"](): string;
|
||||||
|
/**
|
||||||
|
* `View analytics`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.title"](): string;
|
||||||
|
/**
|
||||||
|
* `({{count}} total)`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.summary.total"](options: {
|
||||||
|
readonly count: string;
|
||||||
|
}): string;
|
||||||
|
/**
|
||||||
|
* `Last {{days}} days`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.window.last-days"](options: {
|
||||||
|
readonly days: string;
|
||||||
|
}): string;
|
||||||
|
/**
|
||||||
|
* `Total`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.metric.total"](): string;
|
||||||
|
/**
|
||||||
|
* `Unique`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.metric.unique"](): string;
|
||||||
|
/**
|
||||||
|
* `Guest`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.metric.guest"](): string;
|
||||||
|
/**
|
||||||
|
* `Total views`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.chart.total-views"](): string;
|
||||||
|
/**
|
||||||
|
* `Unique views`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.chart.unique-views"](): string;
|
||||||
|
/**
|
||||||
|
* `Unable to load analytics.`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.error.load-analytics"](): string;
|
||||||
|
/**
|
||||||
|
* `Unable to load viewers.`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.error.load-viewers"](): string;
|
||||||
|
/**
|
||||||
|
* `No page views in this window.`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.empty.no-page-views"](): string;
|
||||||
|
/**
|
||||||
|
* `No viewers in this window.`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.empty.no-viewers"](): string;
|
||||||
|
/**
|
||||||
|
* `Viewers`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.viewers.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Show all viewers`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.viewers.show-all"](): string;
|
||||||
|
/**
|
||||||
|
* `Open pricing plans`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.paywall.open-pricing"](): string;
|
||||||
|
/**
|
||||||
|
* `Doc analytics over 7 days require an AFFiNE Team subscription.`
|
||||||
|
*/
|
||||||
|
["com.affine.doc.analytics.paywall.toast"](): string;
|
||||||
/**
|
/**
|
||||||
* `Close`
|
* `Close`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1134,6 +1134,22 @@
|
|||||||
"com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > License > Use purchased key",
|
"com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > License > Use purchased key",
|
||||||
"com.affine.payment.license-success.open-affine": "Open AFFiNE",
|
"com.affine.payment.license-success.open-affine": "Open AFFiNE",
|
||||||
"com.affine.payment.license-success.copy": "Copied key to clipboard",
|
"com.affine.payment.license-success.copy": "Copied key to clipboard",
|
||||||
|
"com.affine.doc.analytics.title": "View analytics",
|
||||||
|
"com.affine.doc.analytics.summary.total": "({{count}} total)",
|
||||||
|
"com.affine.doc.analytics.window.last-days": "Last {{days}} days",
|
||||||
|
"com.affine.doc.analytics.metric.total": "Total",
|
||||||
|
"com.affine.doc.analytics.metric.unique": "Unique",
|
||||||
|
"com.affine.doc.analytics.metric.guest": "Guest",
|
||||||
|
"com.affine.doc.analytics.chart.total-views": "Total views",
|
||||||
|
"com.affine.doc.analytics.chart.unique-views": "Unique views",
|
||||||
|
"com.affine.doc.analytics.error.load-analytics": "Unable to load analytics.",
|
||||||
|
"com.affine.doc.analytics.error.load-viewers": "Unable to load viewers.",
|
||||||
|
"com.affine.doc.analytics.empty.no-page-views": "No page views in this window.",
|
||||||
|
"com.affine.doc.analytics.empty.no-viewers": "No viewers in this window.",
|
||||||
|
"com.affine.doc.analytics.viewers.title": "Viewers",
|
||||||
|
"com.affine.doc.analytics.viewers.show-all": "Show all viewers",
|
||||||
|
"com.affine.doc.analytics.paywall.open-pricing": "Open pricing plans",
|
||||||
|
"com.affine.doc.analytics.paywall.toast": "Doc analytics over 7 days require an AFFiNE Team subscription.",
|
||||||
"com.affine.peek-view-controls.close": "Close",
|
"com.affine.peek-view-controls.close": "Close",
|
||||||
"com.affine.peek-view-controls.open-doc": "Open this doc",
|
"com.affine.peek-view-controls.open-doc": "Open this doc",
|
||||||
"com.affine.peek-view-controls.open-doc-in-edgeless": "Open in edgeless",
|
"com.affine.peek-view-controls.open-doc-in-edgeless": "Open in edgeless",
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
"children": {
|
"children": {
|
||||||
"auth": "auth",
|
"auth": "auth",
|
||||||
"setup": "setup",
|
"setup": "setup",
|
||||||
|
"dashboard": "dashboard",
|
||||||
"accounts": "accounts",
|
"accounts": "accounts",
|
||||||
|
"workspaces": "workspaces",
|
||||||
|
"queue": "queue",
|
||||||
"ai": "ai",
|
"ai": "ai",
|
||||||
"settings": {
|
"settings": {
|
||||||
"route": "settings",
|
"route": "settings",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const ROUTES = {
|
|||||||
index: '/admin',
|
index: '/admin',
|
||||||
auth: '/admin/auth',
|
auth: '/admin/auth',
|
||||||
setup: '/admin/setup',
|
setup: '/admin/setup',
|
||||||
|
dashboard: '/admin/dashboard',
|
||||||
accounts: '/admin/accounts',
|
accounts: '/admin/accounts',
|
||||||
workspaces: '/admin/workspaces',
|
workspaces: '/admin/workspaces',
|
||||||
queue: '/admin/queue',
|
queue: '/admin/queue',
|
||||||
@@ -29,6 +30,7 @@ export const RELATIVE_ROUTES = {
|
|||||||
index: 'admin',
|
index: 'admin',
|
||||||
auth: 'auth',
|
auth: 'auth',
|
||||||
setup: 'setup',
|
setup: 'setup',
|
||||||
|
dashboard: 'dashboard',
|
||||||
accounts: 'accounts',
|
accounts: 'accounts',
|
||||||
workspaces: 'workspaces',
|
workspaces: 'workspaces',
|
||||||
queue: 'queue',
|
queue: 'queue',
|
||||||
@@ -45,6 +47,7 @@ const home = () => '/';
|
|||||||
const admin = () => '/admin';
|
const admin = () => '/admin';
|
||||||
admin.auth = () => '/admin/auth';
|
admin.auth = () => '/admin/auth';
|
||||||
admin.setup = () => '/admin/setup';
|
admin.setup = () => '/admin/setup';
|
||||||
|
admin.dashboard = () => '/admin/dashboard';
|
||||||
admin.accounts = () => '/admin/accounts';
|
admin.accounts = () => '/admin/accounts';
|
||||||
admin.workspaces = () => '/admin/workspaces';
|
admin.workspaces = () => '/admin/workspaces';
|
||||||
admin.queue = () => '/admin/queue';
|
admin.queue = () => '/admin/queue';
|
||||||
|
|||||||
160
yarn.lock
160
yarn.lock
@@ -231,6 +231,7 @@ __metadata:
|
|||||||
react-hook-form: "npm:^7.54.1"
|
react-hook-form: "npm:^7.54.1"
|
||||||
react-resizable-panels: "npm:^3.0.6"
|
react-resizable-panels: "npm:^3.0.6"
|
||||||
react-router-dom: "npm:^7.12.0"
|
react-router-dom: "npm:^7.12.0"
|
||||||
|
recharts: "npm:^2.15.4"
|
||||||
shadcn-ui: "npm:^0.9.5"
|
shadcn-ui: "npm:^0.9.5"
|
||||||
sonner: "npm:^2.0.7"
|
sonner: "npm:^2.0.7"
|
||||||
swr: "npm:^2.3.7"
|
swr: "npm:^2.3.7"
|
||||||
@@ -481,6 +482,7 @@ __metadata:
|
|||||||
react-router-dom: "npm:^6.30.3"
|
react-router-dom: "npm:^6.30.3"
|
||||||
react-transition-state: "npm:^2.2.0"
|
react-transition-state: "npm:^2.2.0"
|
||||||
react-virtuoso: "npm:^4.12.3"
|
react-virtuoso: "npm:^4.12.3"
|
||||||
|
recharts: "npm:^2.15.4"
|
||||||
rxjs: "npm:^7.8.2"
|
rxjs: "npm:^7.8.2"
|
||||||
semver: "npm:^7.7.3"
|
semver: "npm:^7.7.3"
|
||||||
ses: "npm:^1.14.0"
|
ses: "npm:^1.14.0"
|
||||||
@@ -1776,10 +1778,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.27.1, @babel/runtime@npm:^7.7.6":
|
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.27.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.7":
|
||||||
version: 7.27.1
|
version: 7.28.6
|
||||||
resolution: "@babel/runtime@npm:7.27.1"
|
resolution: "@babel/runtime@npm:7.28.6"
|
||||||
checksum: 10/34cefcbf781ea5a4f1b93f8563327b9ac82694bebdae91e8bd9d7f58d084cbe5b9a6e7f94d77076e15b0bcdaa0040a36cb30737584028df6c4673b4c67b2a31d
|
checksum: 10/fbcd439cb74d4a681958eb064c509829e3f46d8a4bfaaf441baa81bb6733d1e680bccc676c813883d7741bcaada1d0d04b15aa320ef280b5734e2192b50decf9
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -16704,7 +16706,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/d3-array@npm:*":
|
"@types/d3-array@npm:*, @types/d3-array@npm:^3.0.3":
|
||||||
version: 3.2.2
|
version: 3.2.2
|
||||||
resolution: "@types/d3-array@npm:3.2.2"
|
resolution: "@types/d3-array@npm:3.2.2"
|
||||||
checksum: 10/1afebd05b688cafaaea295f765b409789f088b274b8a7ca40a4bc2b79760044a898e06a915f40bbc59cf39eabdd2b5d32e960b136fc025fd05c9a9d4435614c6
|
checksum: 10/1afebd05b688cafaaea295f765b409789f088b274b8a7ca40a4bc2b79760044a898e06a915f40bbc59cf39eabdd2b5d32e960b136fc025fd05c9a9d4435614c6
|
||||||
@@ -16783,7 +16785,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/d3-ease@npm:*":
|
"@types/d3-ease@npm:*, @types/d3-ease@npm:^3.0.0":
|
||||||
version: 3.0.2
|
version: 3.0.2
|
||||||
resolution: "@types/d3-ease@npm:3.0.2"
|
resolution: "@types/d3-ease@npm:3.0.2"
|
||||||
checksum: 10/d8f92a8a7a008da71f847a16227fdcb53a8938200ecdf8d831ab6b49aba91e8921769761d3bfa7e7191b28f62783bfd8b0937e66bae39d4dd7fb0b63b50d4a94
|
checksum: 10/d8f92a8a7a008da71f847a16227fdcb53a8938200ecdf8d831ab6b49aba91e8921769761d3bfa7e7191b28f62783bfd8b0937e66bae39d4dd7fb0b63b50d4a94
|
||||||
@@ -16829,7 +16831,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/d3-interpolate@npm:*":
|
"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.1":
|
||||||
version: 3.0.4
|
version: 3.0.4
|
||||||
resolution: "@types/d3-interpolate@npm:3.0.4"
|
resolution: "@types/d3-interpolate@npm:3.0.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16873,7 +16875,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/d3-scale@npm:*":
|
"@types/d3-scale@npm:*, @types/d3-scale@npm:^4.0.2":
|
||||||
version: 4.0.9
|
version: 4.0.9
|
||||||
resolution: "@types/d3-scale@npm:4.0.9"
|
resolution: "@types/d3-scale@npm:4.0.9"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16889,12 +16891,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/d3-shape@npm:*":
|
"@types/d3-shape@npm:*, @types/d3-shape@npm:^3.1.0":
|
||||||
version: 3.1.7
|
version: 3.1.8
|
||||||
resolution: "@types/d3-shape@npm:3.1.7"
|
resolution: "@types/d3-shape@npm:3.1.8"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/d3-path": "npm:*"
|
"@types/d3-path": "npm:*"
|
||||||
checksum: 10/b7ddda2a9c916ba438308bfa6e53fa2bb11c2ce13537ba2a7816c16f9432287b57901921c7231d2924f2d7d360535c3795f017865ab05abe5057c6ca06ca81df
|
checksum: 10/ebc161d49101d84409829fea516ba7ec71ad51a1e97438ca0fafc1c30b56b3feae802d220375323632723a338dda7237c652e831e0b53527a6222ab0d1bb7809
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -16905,14 +16907,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/d3-time@npm:*":
|
"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0":
|
||||||
version: 3.0.4
|
version: 3.0.4
|
||||||
resolution: "@types/d3-time@npm:3.0.4"
|
resolution: "@types/d3-time@npm:3.0.4"
|
||||||
checksum: 10/b1eb4255066da56023ad243fd4ae5a20462d73bd087a0297c7d49ece42b2304a4a04297568c604a38541019885b2bc35a9e0fd704fad218e9bc9c5f07dc685ce
|
checksum: 10/b1eb4255066da56023ad243fd4ae5a20462d73bd087a0297c7d49ece42b2304a4a04297568c604a38541019885b2bc35a9e0fd704fad218e9bc9c5f07dc685ce
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/d3-timer@npm:*":
|
"@types/d3-timer@npm:*, @types/d3-timer@npm:^3.0.0":
|
||||||
version: 3.0.2
|
version: 3.0.2
|
||||||
resolution: "@types/d3-timer@npm:3.0.2"
|
resolution: "@types/d3-timer@npm:3.0.2"
|
||||||
checksum: 10/1643eebfa5f4ae3eb00b556bbc509444d88078208ec2589ddd8e4a24f230dd4cf2301e9365947e70b1bee33f63aaefab84cd907822aae812b9bc4871b98ab0e1
|
checksum: 10/1643eebfa5f4ae3eb00b556bbc509444d88078208ec2589ddd8e4a24f230dd4cf2301e9365947e70b1bee33f63aaefab84cd907822aae812b9bc4871b98ab0e1
|
||||||
@@ -21763,7 +21765,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.2.0":
|
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:2.5.0 - 3, d3-array@npm:3, d3-array@npm:^3.1.6, d3-array@npm:^3.2.0":
|
||||||
version: 3.2.4
|
version: 3.2.4
|
||||||
resolution: "d3-array@npm:3.2.4"
|
resolution: "d3-array@npm:3.2.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -21864,7 +21866,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"d3-ease@npm:1 - 3, d3-ease@npm:3":
|
"d3-ease@npm:1 - 3, d3-ease@npm:3, d3-ease@npm:^3.0.1":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "d3-ease@npm:3.0.1"
|
resolution: "d3-ease@npm:3.0.1"
|
||||||
checksum: 10/985d46e868494e9e6806fedd20bad712a50dcf98f357bf604a843a9f6bc17714a657c83dd762f183173dcde983a3570fa679b2bc40017d40b24163cdc4167796
|
checksum: 10/985d46e868494e9e6806fedd20bad712a50dcf98f357bf604a843a9f6bc17714a657c83dd762f183173dcde983a3570fa679b2bc40017d40b24163cdc4167796
|
||||||
@@ -21914,7 +21916,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3":
|
"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3, d3-interpolate@npm:^3.0.1":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "d3-interpolate@npm:3.0.1"
|
resolution: "d3-interpolate@npm:3.0.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -21978,7 +21980,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"d3-scale@npm:4":
|
"d3-scale@npm:4, d3-scale@npm:^4.0.2":
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
resolution: "d3-scale@npm:4.0.2"
|
resolution: "d3-scale@npm:4.0.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -21998,7 +22000,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"d3-shape@npm:3":
|
"d3-shape@npm:3, d3-shape@npm:^3.1.0":
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
resolution: "d3-shape@npm:3.2.0"
|
resolution: "d3-shape@npm:3.2.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -22025,7 +22027,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3":
|
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3, d3-time@npm:^3.0.0":
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
resolution: "d3-time@npm:3.1.0"
|
resolution: "d3-time@npm:3.1.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -22034,7 +22036,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"d3-timer@npm:1 - 3, d3-timer@npm:3":
|
"d3-timer@npm:1 - 3, d3-timer@npm:3, d3-timer@npm:^3.0.1":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "d3-timer@npm:3.0.1"
|
resolution: "d3-timer@npm:3.0.1"
|
||||||
checksum: 10/004128602bb187948d72c7dc153f0f063f38ac7a584171de0b45e3a841ad2e17f1e40ad396a4af9cce5551b6ab4a838d5246d23492553843d9da4a4050a911e2
|
checksum: 10/004128602bb187948d72c7dc153f0f063f38ac7a584171de0b45e3a841ad2e17f1e40ad396a4af9cce5551b6ab4a838d5246d23492553843d9da4a4050a911e2
|
||||||
@@ -22245,6 +22247,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"decimal.js-light@npm:^2.4.1":
|
||||||
|
version: 2.5.1
|
||||||
|
resolution: "decimal.js-light@npm:2.5.1"
|
||||||
|
checksum: 10/6360911e31221a9b8b90e23020aa969d104e182c5c6518589cdfedc3ced31bf1f19cf931e265bd451ae6ee3a35ee15e81f5456a86813606fda96f8374616688f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"decimal.js@npm:^10.2.0, decimal.js@npm:^10.4.3":
|
"decimal.js@npm:^10.2.0, decimal.js@npm:^10.4.3":
|
||||||
version: 10.6.0
|
version: 10.6.0
|
||||||
resolution: "decimal.js@npm:10.6.0"
|
resolution: "decimal.js@npm:10.6.0"
|
||||||
@@ -22622,6 +22631,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"dom-helpers@npm:^5.0.1":
|
||||||
|
version: 5.2.1
|
||||||
|
resolution: "dom-helpers@npm:5.2.1"
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime": "npm:^7.8.7"
|
||||||
|
csstype: "npm:^3.0.2"
|
||||||
|
checksum: 10/bed2341adf8864bf932b3289c24f35fdd99930af77df46688abf2d753ff291df49a15850c874d686d9be6ec4e1c6835673906e64dbd8b2839d227f117a11fd41
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"dom-serializer@npm:^1.0.1":
|
"dom-serializer@npm:^1.0.1":
|
||||||
version: 1.4.1
|
version: 1.4.1
|
||||||
resolution: "dom-serializer@npm:1.4.1"
|
resolution: "dom-serializer@npm:1.4.1"
|
||||||
@@ -23881,7 +23900,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.4":
|
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.4":
|
||||||
version: 4.0.7
|
version: 4.0.7
|
||||||
resolution: "eventemitter3@npm:4.0.7"
|
resolution: "eventemitter3@npm:4.0.7"
|
||||||
checksum: 10/8030029382404942c01d0037079f1b1bc8fed524b5849c237b80549b01e2fc49709e1d0c557fa65ca4498fc9e24cff1475ef7b855121fcc15f9d61f93e282346
|
checksum: 10/8030029382404942c01d0037079f1b1bc8fed524b5849c237b80549b01e2fc49709e1d0c557fa65ca4498fc9e24cff1475ef7b855121fcc15f9d61f93e282346
|
||||||
@@ -24208,6 +24227,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"fast-equals@npm:^5.0.1":
|
||||||
|
version: 5.4.0
|
||||||
|
resolution: "fast-equals@npm:5.4.0"
|
||||||
|
checksum: 10/bea068ceb7825d486d88a17ccc3fe889d1833efefa8dc64c83806e797f66b3ea953ac4aebd96af022d828de315ec87476e76418a5da774217d0ab66de53d68f5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"fast-glob@npm:3.3.3, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2, fast-glob@npm:^3.3.3":
|
"fast-glob@npm:3.3.3, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2, fast-glob@npm:^3.3.3":
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
resolution: "fast-glob@npm:3.3.3"
|
resolution: "fast-glob@npm:3.3.3"
|
||||||
@@ -32311,7 +32337,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"prop-types@npm:^15, prop-types@npm:^15.8.1":
|
"prop-types@npm:^15, prop-types@npm:^15.6.2, prop-types@npm:^15.8.1":
|
||||||
version: 15.8.1
|
version: 15.8.1
|
||||||
resolution: "prop-types@npm:15.8.1"
|
resolution: "prop-types@npm:15.8.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -32843,6 +32869,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-is@npm:^18.3.1":
|
||||||
|
version: 18.3.1
|
||||||
|
resolution: "react-is@npm:18.3.1"
|
||||||
|
checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react-json-tree@npm:^0.20.0":
|
"react-json-tree@npm:^0.20.0":
|
||||||
version: 0.20.0
|
version: 0.20.0
|
||||||
resolution: "react-json-tree@npm:0.20.0"
|
resolution: "react-json-tree@npm:0.20.0"
|
||||||
@@ -33009,6 +33042,20 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-smooth@npm:^4.0.4":
|
||||||
|
version: 4.0.4
|
||||||
|
resolution: "react-smooth@npm:4.0.4"
|
||||||
|
dependencies:
|
||||||
|
fast-equals: "npm:^5.0.1"
|
||||||
|
prop-types: "npm:^15.8.1"
|
||||||
|
react-transition-group: "npm:^4.4.5"
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
checksum: 10/cc5593356d154253f61a2c0b7b2fa8a979527495e2fe47c4252628d86e93c72c75df988c5438867d373de4e5a47d871ab9262474c02e66c411f94f047ecb5b0f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react-stately@npm:^3.43.0":
|
"react-stately@npm:^3.43.0":
|
||||||
version: 3.43.0
|
version: 3.43.0
|
||||||
resolution: "react-stately@npm:3.43.0"
|
resolution: "react-stately@npm:3.43.0"
|
||||||
@@ -33061,6 +33108,21 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-transition-group@npm:^4.4.5":
|
||||||
|
version: 4.4.5
|
||||||
|
resolution: "react-transition-group@npm:4.4.5"
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime": "npm:^7.5.5"
|
||||||
|
dom-helpers: "npm:^5.0.1"
|
||||||
|
loose-envify: "npm:^1.4.0"
|
||||||
|
prop-types: "npm:^15.6.2"
|
||||||
|
peerDependencies:
|
||||||
|
react: ">=16.6.0"
|
||||||
|
react-dom: ">=16.6.0"
|
||||||
|
checksum: 10/ca32d3fd2168c976c5d90a317f25d5f5cd723608b415fb3b9006f9d793c8965c619562d0884503a3e44e4b06efbca4fdd1520f30e58ca3e00a0890e637d55419
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react-transition-state@npm:^2.2.0":
|
"react-transition-state@npm:^2.2.0":
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
resolution: "react-transition-state@npm:2.3.1"
|
resolution: "react-transition-state@npm:2.3.1"
|
||||||
@@ -33189,6 +33251,34 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"recharts-scale@npm:^0.4.4":
|
||||||
|
version: 0.4.5
|
||||||
|
resolution: "recharts-scale@npm:0.4.5"
|
||||||
|
dependencies:
|
||||||
|
decimal.js-light: "npm:^2.4.1"
|
||||||
|
checksum: 10/6e1118635018bd0622b5e978e56a8764ced5741140709e025c5989a0cb40c4b0bebb7c4e231c11ab8d6127a85fef8c68d92662c6f3c22af9551737a767cea014
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"recharts@npm:^2.15.4":
|
||||||
|
version: 2.15.4
|
||||||
|
resolution: "recharts@npm:2.15.4"
|
||||||
|
dependencies:
|
||||||
|
clsx: "npm:^2.0.0"
|
||||||
|
eventemitter3: "npm:^4.0.1"
|
||||||
|
lodash: "npm:^4.17.21"
|
||||||
|
react-is: "npm:^18.3.1"
|
||||||
|
react-smooth: "npm:^4.0.4"
|
||||||
|
recharts-scale: "npm:^0.4.4"
|
||||||
|
tiny-invariant: "npm:^1.3.1"
|
||||||
|
victory-vendor: "npm:^36.6.8"
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
checksum: 10/d29656d465ccdfcf95de7bc35e53c1f30fbd45e3d7b53c70bc2cd1892707058606eab8b0a648c1e1d0b3f21cc3b6774abf3f304da4d4534f550500909623492f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"rechoir@npm:^0.8.0":
|
"rechoir@npm:^0.8.0":
|
||||||
version: 0.8.0
|
version: 0.8.0
|
||||||
resolution: "rechoir@npm:0.8.0"
|
resolution: "rechoir@npm:0.8.0"
|
||||||
@@ -35862,7 +35952,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"tiny-invariant@npm:^1.3.3":
|
"tiny-invariant@npm:^1.3.1, tiny-invariant@npm:^1.3.3":
|
||||||
version: 1.3.3
|
version: 1.3.3
|
||||||
resolution: "tiny-invariant@npm:1.3.3"
|
resolution: "tiny-invariant@npm:1.3.3"
|
||||||
checksum: 10/5e185c8cc2266967984ce3b352a4e57cb89dad5a8abb0dea21468a6ecaa67cd5bb47a3b7a85d08041008644af4f667fb8b6575ba38ba5fb00b3b5068306e59fe
|
checksum: 10/5e185c8cc2266967984ce3b352a4e57cb89dad5a8abb0dea21468a6ecaa67cd5bb47a3b7a85d08041008644af4f667fb8b6575ba38ba5fb00b3b5068306e59fe
|
||||||
@@ -37105,6 +37195,28 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"victory-vendor@npm:^36.6.8":
|
||||||
|
version: 36.9.2
|
||||||
|
resolution: "victory-vendor@npm:36.9.2"
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-array": "npm:^3.0.3"
|
||||||
|
"@types/d3-ease": "npm:^3.0.0"
|
||||||
|
"@types/d3-interpolate": "npm:^3.0.1"
|
||||||
|
"@types/d3-scale": "npm:^4.0.2"
|
||||||
|
"@types/d3-shape": "npm:^3.1.0"
|
||||||
|
"@types/d3-time": "npm:^3.0.0"
|
||||||
|
"@types/d3-timer": "npm:^3.0.0"
|
||||||
|
d3-array: "npm:^3.1.6"
|
||||||
|
d3-ease: "npm:^3.0.1"
|
||||||
|
d3-interpolate: "npm:^3.0.1"
|
||||||
|
d3-scale: "npm:^4.0.2"
|
||||||
|
d3-shape: "npm:^3.1.0"
|
||||||
|
d3-time: "npm:^3.0.0"
|
||||||
|
d3-timer: "npm:^3.0.1"
|
||||||
|
checksum: 10/db67b3d9b8070d4eae4122edc72be7067b4e32363340cdd4d5b628e7dd65bea0c7c5b4116016658d223adaa575bcc6b7b3a71507aa4f34b2609ed61dbfbba1ea
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"vite-node@npm:3.2.4, vite-node@npm:^3.2.2":
|
"vite-node@npm:3.2.4, vite-node@npm:^3.2.2":
|
||||||
version: 3.2.4
|
version: 3.2.4
|
||||||
resolution: "vite-node@npm:3.2.4"
|
resolution: "vite-node@npm:3.2.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user