fix(server): dashboard query (#15195)

#### PR Dependency Tree


* **PR #15195** 👈

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

* **Bug Fixes**
* Admin dashboard storage history now returns a continuous day-by-day
trend, carrying forward the last known workspace and blob storage values
for days without samples.
* Active member counts for workspace analytics were corrected to reflect
active workspace members.
* **New Features**
* Admin workspace member roles are now returned via a dedicated role
enum, serialized as enum name strings (e.g., `Owner`).
* **UI Improvements**
* Renamed key labels to “Active Members” and “Members and Invitations”;
member roles use pill badges and non-accepted statuses are shown.
* **Tests**
* Added E2E coverage for role enum serialization and strengthened
storage history assertions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-05 03:04:05 +08:00
committed by GitHub
parent 7cfedbd49d
commit 296a4c2f81
10 changed files with 451 additions and 149 deletions
@@ -390,6 +390,43 @@ e2e(
}
);
e2e('adminWorkspace should serialize member roles as enum names', 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 result = await gql(
`
query AdminWorkspace($id: String!) {
adminWorkspace(id: $id) {
id
members(skip: 0, take: 20) {
id
role
status
}
}
}
`,
{ id: workspace.id }
);
t.falsy(result.errors);
t.is(result.data!.adminWorkspace.id, workspace.id);
t.true(
result.data!.adminWorkspace.members.some(
(member: { id: string; role: string }) =>
member.id === owner.id && member.role === 'Owner'
)
);
});
e2e(
'adminDashboard should carry forward missing sync and storage samples',
async t => {
@@ -524,6 +561,36 @@ e2e(
dashboard.workspaceStorageBytes
);
t.is(blobHistory[blobHistory.length - 1], dashboard.blobStorageBytes);
const baselineResult = await gql(
`
query AdminDashboard($input: AdminDashboardInput) {
adminDashboard(input: $input) {
workspaceStorageHistory {
value
}
blobStorageHistory {
value
}
}
}
`,
{
input: {
storageHistoryDays: 2,
},
}
);
t.falsy(baselineResult.errors);
t.is(
baselineResult.data!.adminDashboard.workspaceStorageHistory[0].value,
workspaceHistory[1]
);
t.is(
baselineResult.data!.adminDashboard.blobStorageHistory[0].value,
blobHistory[1]
);
} finally {
clock.restore();
}
@@ -30,7 +30,6 @@ import {
Models,
WorkspaceFeatureName,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models';
import { Admin } from '../../common';
import { WorkspaceUserType } from '../../user';
@@ -60,6 +59,16 @@ registerEnumType(AdminSharedLinksOrder, {
name: 'AdminSharedLinksOrder',
});
enum AdminWorkspaceMemberRole {
Owner = 'Owner',
Admin = 'Admin',
Collaborator = 'Collaborator',
}
registerEnumType(AdminWorkspaceMemberRole, {
name: 'AdminWorkspaceMemberRole',
});
function hasSelectedField(
selections: readonly SelectionNode[],
fieldName: string,
@@ -145,8 +154,8 @@ class AdminWorkspaceMember {
@Field(() => String, { nullable: true })
avatarUrl?: string | null;
@Field(() => WorkspaceRole)
role!: WorkspaceRole;
@Field(() => AdminWorkspaceMemberRole)
role!: `${AdminWorkspaceMemberRole}`;
@Field(() => WorkspaceMemberStatus)
status!: WorkspaceMemberStatus;
@@ -494,18 +503,7 @@ export class AdminWorkspaceResolver {
})
async adminWorkspace(@Args('id') id: string) {
this.assertCloudOnly();
const { rows } = await this.models.workspace.adminListWorkspaces({
first: 1,
skip: 0,
keyword: id,
order: 'createdAt',
includeTotal: false,
});
const row = rows.find(r => r.id === id);
if (!row) {
return null;
}
return row;
return await this.models.workspace.adminGetWorkspace(id);
}
@Query(() => AdminDashboard, {
@@ -608,46 +606,11 @@ export class AdminWorkspaceResolver {
after: undefined,
};
if (query) {
const list = await this.models.workspaceUser.search(
workspaceId,
query,
pagination
);
return list.flatMap(({ user, status, type }) =>
user
? [
{
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: type,
status,
},
]
: []
);
}
const [list] = await this.models.workspaceUser.paginate(
workspaceId,
pagination
);
return list.flatMap(({ user, status, type }) =>
user
? [
{
id: user.id,
name: user.name,
email: user.email,
avatarUrl: user.avatarUrl,
role: type,
status,
},
]
: []
);
return await this.models.workspaceUser.adminListMembers(workspaceId, {
first: pagination.first,
offset: pagination.offset,
query,
});
}
@ResolveField(() => [AdminWorkspaceSharedLink], {
@@ -677,18 +640,7 @@ export class AdminWorkspaceResolver {
await this.models.workspace.update(id, updates);
}
const { rows } = await this.models.workspace.adminListWorkspaces({
first: 1,
skip: 0,
keyword: id,
order: 'createdAt',
includeTotal: false,
});
const row = rows.find(r => r.id === id);
if (!row) {
return null;
}
return row;
return await this.models.workspace.adminGetWorkspace(id);
}
private mapSort(orderBy?: AdminWorkspaceSort) {
@@ -276,8 +276,9 @@ export class WorkspaceStatsJob {
),
member_stats AS (
SELECT workspace_id, COUNT(*) AS member_count
FROM workspace_user_permissions
FROM workspace_members
WHERE workspace_id IN (SELECT workspace_id FROM targets)
AND state = 'active'
GROUP BY workspace_id
),
public_page_stats AS (
@@ -9,7 +9,6 @@ import {
} from '../base/graphql/pagination';
import { CacheRedis } from '../base/redis';
import { BaseModel } from './base';
import { WorkspaceRole } from './common';
const DEFAULT_STORAGE_HISTORY_DAYS = 30;
const DEFAULT_SYNC_HISTORY_HOURS = 48;
@@ -323,11 +322,11 @@ export class WorkspaceAnalyticsModel extends BaseModel {
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
FROM workspace_members
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
AND role = 'owner'
AND state = 'active'
ORDER BY created_at ASC, id ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
@@ -391,32 +390,42 @@ export class WorkspaceAnalyticsModel extends BaseModel {
blobStorageBytes: bigint | number;
}[]
>`
WITH days AS (
SELECT generate_series(${storageFrom}::date, ${currentDay}::date, interval '1 day')::date AS day
WITH baseline_day AS (
SELECT max(date) AS date
FROM workspace_admin_stats_daily
WHERE date < ${storageFrom}::date
),
grouped AS (
baseline AS (
SELECT
baseline_day.date,
COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes,
COALESCE(SUM(blob_size), 0) AS blob_storage_bytes
FROM baseline_day
JOIN workspace_admin_stats_daily stats
ON stats.date = baseline_day.date
WHERE baseline_day.date IS NOT NULL
GROUP BY baseline_day.date
),
in_window AS (
SELECT
date,
COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes,
COALESCE(SUM(blob_size), 0) AS blob_storage_bytes
FROM workspace_admin_stats_daily
WHERE date <= ${currentDay}::date
WHERE date BETWEEN ${storageFrom}::date AND ${currentDay}::date
GROUP BY date
)
SELECT
days.day AS date,
COALESCE(snapshot.workspace_storage_bytes, 0) AS "workspaceStorageBytes",
COALESCE(snapshot.blob_storage_bytes, 0) AS "blobStorageBytes"
FROM days
LEFT JOIN LATERAL (
SELECT
workspace_storage_bytes,
blob_storage_bytes
FROM grouped
WHERE date <= days.day
ORDER BY date DESC
LIMIT 1
) snapshot ON TRUE
date,
workspace_storage_bytes AS "workspaceStorageBytes",
blob_storage_bytes AS "blobStorageBytes"
FROM baseline
UNION ALL
SELECT
date,
workspace_storage_bytes AS "workspaceStorageBytes",
blob_storage_bytes AS "blobStorageBytes"
FROM in_window
ORDER BY date ASC
`,
this.db.$queryRaw<{ conversations: bigint | number }[]>`
@@ -435,11 +444,36 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const currentBlobStorageBytes = Number(
storageCurrent[0]?.blobStorageBytes ?? 0
);
const storageHistorySeries = storageHistory.map(row => ({
date: row.date,
workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0),
blobStorageBytes: Number(row.blobStorageBytes ?? 0),
}));
const storageByDate = new Map(
storageHistory.map(row => [
asDateOnlyString(row.date),
{
workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0),
blobStorageBytes: Number(row.blobStorageBytes ?? 0),
},
])
);
const storageHistorySeries: Array<{
date: Date;
workspaceStorageBytes: number;
blobStorageBytes: number;
}> = [];
const baselineStorage =
storageHistory.find(row => row.date < storageFrom) ?? null;
let carriedStorageValue = {
workspaceStorageBytes: Number(
baselineStorage?.workspaceStorageBytes ?? 0
),
blobStorageBytes: Number(baselineStorage?.blobStorageBytes ?? 0),
};
for (let day = storageFrom; day <= currentDay; day = addUtcDays(day, 1)) {
carriedStorageValue =
storageByDate.get(asDateOnlyString(day)) ?? carriedStorageValue;
storageHistorySeries.push({
date: day,
...carriedStorageValue,
});
}
if (storageHistorySeries.length > 0) {
const lastPoint = storageHistorySeries[storageHistorySeries.length - 1];
if (asDateOnlyString(lastPoint.date) === asDateOnlyString(currentDay)) {
@@ -596,11 +630,11 @@ export class WorkspaceAnalyticsModel extends BaseModel {
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
FROM workspace_members
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
AND role = 'owner'
AND state = 'active'
ORDER BY created_at ASC, id ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
@@ -861,10 +895,10 @@ export class WorkspaceAnalyticsModel extends BaseModel {
mla.last_doc_id AS "lastDocId"
FROM workspace_member_last_access mla
INNER JOIN users u ON u.id = mla.user_id
INNER JOIN workspace_user_permissions wur
ON wur.workspace_id = mla.workspace_id
AND wur.user_id = mla.user_id
AND wur.status = 'Accepted'::"WorkspaceMemberStatus"
INNER JOIN workspace_members wm
ON wm.workspace_id = mla.workspace_id
AND wm.user_id = mla.user_id
AND wm.state = 'active'
WHERE mla.workspace_id = ${input.workspaceId}
AND mla.last_doc_id = ${input.docId}
${windowCondition}
@@ -1075,10 +1109,10 @@ export class WorkspaceAnalyticsModel extends BaseModel {
SELECT COUNT(*) AS total
FROM workspace_member_last_access mla
INNER JOIN users u ON u.id = mla.user_id
INNER JOIN workspace_user_permissions wur
ON wur.workspace_id = mla.workspace_id
AND wur.user_id = mla.user_id
AND wur.status = 'Accepted'::"WorkspaceMemberStatus"
INNER JOIN workspace_members wm
ON wm.workspace_id = mla.workspace_id
AND wm.user_id = mla.user_id
AND wm.state = 'active'
WHERE mla.workspace_id = ${workspaceId}
AND mla.last_doc_id = ${docId}
${windowCondition}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import {
Prisma,
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
@@ -23,6 +24,15 @@ import {
export { WorkspaceMemberStatus };
export type AdminWorkspaceMember = {
id: string;
name: string;
email: string;
avatarUrl: string | null;
role: 'Owner' | 'Admin' | 'Collaborator';
status: WorkspaceMemberStatus;
};
declare global {
interface Events {
'workspace.owner.changed': {
@@ -467,6 +477,66 @@ export class WorkspaceUserModel extends BaseModel {
});
}
async adminListMembers(
workspaceId: string,
options: {
first: number;
offset: number;
query?: string | null;
}
): Promise<AdminWorkspaceMember[]> {
const keyword = options.query?.trim();
const keywordCondition = keyword
? Prisma.sql`AND (u.name ILIKE ${`%${keyword}%`} OR u.email ILIKE ${`%${keyword}%`})`
: Prisma.empty;
return await this.db.$queryRaw<AdminWorkspaceMember[]>`
SELECT *
FROM (
SELECT
u.id,
u.name,
u.email,
u.avatar_url AS "avatarUrl",
CASE wm.role
WHEN 'owner' THEN 'Owner'
WHEN 'admin' THEN 'Admin'
ELSE 'Collaborator'
END AS role,
'Accepted'::"WorkspaceMemberStatus" AS status,
wm.created_at AS created_at
FROM workspace_members wm
INNER JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id = ${workspaceId}
AND wm.state = 'active'
${keywordCondition}
UNION ALL
SELECT
u.id,
u.name,
u.email,
u.avatar_url AS "avatarUrl",
CASE wi.requested_role
WHEN 'admin' THEN 'Admin'
ELSE 'Collaborator'
END AS role,
CASE wi.status
WHEN 'waiting_review' THEN 'UnderReview'::"WorkspaceMemberStatus"
WHEN 'waiting_seat' THEN 'NeedMoreSeat'::"WorkspaceMemberStatus"
ELSE 'Pending'::"WorkspaceMemberStatus"
END AS status,
wi.created_at AS created_at
FROM workspace_invitations wi
INNER JOIN users u ON u.id = wi.invitee_user_id
WHERE wi.workspace_id = ${workspaceId}
${keywordCondition}
) members
ORDER BY created_at ASC, id ASC
OFFSET ${options.offset}
LIMIT ${options.first}
`;
}
@Transactional()
async allocateSeats(workspaceId: string, limit: number) {
return await allocateWorkspaceSeats(
+185 -31
View File
@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma, type Workspace, WorkspaceMemberStatus } from '@prisma/client';
import { Prisma, type Workspace } from '@prisma/client';
import { EventBus } from '../base';
import { BaseModel } from './base';
import type { WorkspaceFeatureName } from './common';
import { WorkspaceRole } from './common/role';
type RawWorkspaceSummary = {
id: string;
@@ -292,6 +291,82 @@ export class WorkspaceModel extends BaseModel {
${featuresHaving}
`
: Prisma.empty;
const workspaceOnlyGroupAndHaving =
features.length > 0
? Prisma.sql`
GROUP BY w.id,
w.public,
w.created_at,
w.name,
w.avatar_key,
w.enable_ai,
w.enable_sharing,
w.enable_url_preview,
w.enable_doc_embedding
${featuresHaving}
`
: Prisma.empty;
if (!keyword) {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH filtered AS (
SELECT w.id,
w.public,
w.created_at AS "createdAt",
w.name,
w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi",
w.enable_sharing AS "enableSharing",
w.enable_url_preview AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding"
FROM workspaces w
${featureJoin}
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
: Prisma.sql`TRUE`
}
${workspaceOnlyGroupAndHaving}
),
page AS (
SELECT f.*,
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
COALESCE(s.snapshot_size, 0) AS "snapshotSize",
COALESCE(s.blob_count, 0) AS "blobCount",
COALESCE(s.blob_size, 0) AS "blobSize",
COALESCE(s.member_count, 0) AS "memberCount",
COALESCE(s.public_page_count, 0) AS "publicPageCount",
COALESCE(s.features, ARRAY[]::text[]) AS features
FROM filtered f
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
LIMIT ${options.first}
OFFSET ${options.skip}
)
SELECT p.*,
o.owner_id AS "ownerId",
o.owner_name AS "ownerName",
o.owner_email AS "ownerEmail",
o.owner_avatar_url AS "ownerAvatarUrl"
FROM page p
LEFT JOIN LATERAL (
SELECT u.id AS owner_id,
u.name AS owner_name,
u.email AS owner_email,
u.avatar_url AS owner_avatar_url
FROM workspace_members AS wm
JOIN users u ON wm.user_id = u.id
WHERE wm.workspace_id = p.id
AND wm.role = 'owner'
AND wm.state = 'active'
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
`;
return { rows: this.mapAdminWorkspaceRows(rows), total };
}
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH filtered AS (
@@ -314,12 +389,12 @@ export class WorkspaceModel extends BaseModel {
u.name AS owner_name,
u.email AS owner_email,
u.avatar_url AS owner_avatar_url
FROM workspace_user_permissions AS wur
JOIN users u ON wur.user_id = u.id
WHERE wur.workspace_id = w.id
AND wur.type = ${WorkspaceRole.Owner}
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
ORDER BY u.created_at ASC
FROM workspace_members AS wm
JOIN users u ON wm.user_id = u.id
WHERE wm.workspace_id = w.id
AND wm.role = 'owner'
AND wm.state = 'active'
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
${featureJoin}
@@ -359,7 +434,11 @@ export class WorkspaceModel extends BaseModel {
OFFSET ${options.skip}
`;
const mapped = rows.map(row => ({
return { rows: this.mapAdminWorkspaceRows(rows), total };
}
private mapAdminWorkspaceRows(rows: RawWorkspaceSummary[]) {
return rows.map(row => ({
id: row.id,
public: row.public,
createdAt: row.createdAt,
@@ -385,8 +464,50 @@ export class WorkspaceModel extends BaseModel {
}
: null,
}));
}
return { rows: mapped, total };
async adminGetWorkspace(id: string) {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
SELECT w.id,
w.public,
w.created_at AS "createdAt",
w.name,
w.avatar_key AS "avatarKey",
w.enable_ai AS "enableAi",
w.enable_sharing AS "enableSharing",
w.enable_url_preview AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding",
o.owner_id AS "ownerId",
o.owner_name AS "ownerName",
o.owner_email AS "ownerEmail",
o.owner_avatar_url AS "ownerAvatarUrl",
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
COALESCE(s.snapshot_size, 0) AS "snapshotSize",
COALESCE(s.blob_count, 0) AS "blobCount",
COALESCE(s.blob_size, 0) AS "blobSize",
COALESCE(s.member_count, 0) AS "memberCount",
COALESCE(s.public_page_count, 0) AS "publicPageCount",
COALESCE(s.features, ARRAY[]::text[]) AS features
FROM workspaces w
LEFT JOIN LATERAL (
SELECT u.id AS owner_id,
u.name AS owner_name,
u.email AS owner_email,
u.avatar_url AS owner_avatar_url
FROM workspace_members AS wm
JOIN users u ON wm.user_id = u.id
WHERE wm.workspace_id = w.id
AND wm.role = 'owner'
AND wm.state = 'active'
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
LEFT JOIN workspace_admin_stats s ON s.workspace_id = w.id
WHERE w.id = ${id}
LIMIT 1
`;
return this.mapAdminWorkspaceRows(rows)[0] ?? null;
}
async adminCountWorkspaces(options: {
@@ -404,6 +525,27 @@ export class WorkspaceModel extends BaseModel {
const features = options.features ?? [];
const flags = options.flags ?? {};
if (!keyword && features.length === 0) {
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
SELECT COUNT(*) AS total
FROM workspaces w
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
: Prisma.sql`TRUE`
}
`;
return row?.total ? Number(row.total) : 0;
}
const featureJoin =
features.length > 0
? Prisma.sql`
LEFT JOIN workspace_features wf
ON wf.workspace_id = w.id AND wf.activated = TRUE
`
: Prisma.empty;
const featuresHaving =
features.length > 0
? Prisma.sql`
@@ -415,13 +557,25 @@ export class WorkspaceModel extends BaseModel {
`
: Prisma.empty;
const featureJoin =
features.length > 0
? Prisma.sql`
LEFT JOIN workspace_features wf
ON wf.workspace_id = w.id AND wf.activated = TRUE
`
: Prisma.empty;
if (!keyword) {
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
WITH filtered AS (
SELECT w.id
FROM workspaces w
${featureJoin}
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
: Prisma.sql`TRUE`
}
GROUP BY w.id
${featuresHaving}
)
SELECT COUNT(*) AS total FROM filtered
`;
return row?.total ? Number(row.total) : 0;
}
const groupAndHaving =
features.length > 0
@@ -438,15 +592,15 @@ export class WorkspaceModel extends BaseModel {
o.owner_email AS "ownerEmail"
FROM workspaces w
LEFT JOIN LATERAL (
SELECT wur.workspace_id,
SELECT wm.workspace_id,
u.id AS owner_id,
u.email AS owner_email
FROM workspace_user_permissions AS wur
JOIN users u ON wur.user_id = u.id
WHERE wur.workspace_id = w.id
AND wur.type = ${WorkspaceRole.Owner}
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
ORDER BY u.created_at ASC
FROM workspace_members AS wm
JOIN users u ON wm.user_id = u.id
WHERE wm.workspace_id = w.id
AND wm.role = 'owner'
AND wm.state = 'active'
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
${featureJoin}
@@ -519,20 +673,20 @@ export class WorkspaceModel extends BaseModel {
) {
switch (order) {
case 'snapshotSize':
return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC`;
return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'blobCount':
return `"blobCount" DESC NULLS LAST, "createdAt" DESC`;
return `"blobCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'blobSize':
return `"blobSize" DESC NULLS LAST, "createdAt" DESC`;
return `"blobSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'snapshotCount':
return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC`;
return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'memberCount':
return `"memberCount" DESC NULLS LAST, "createdAt" DESC`;
return `"memberCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'publicPageCount':
return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC`;
return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'createdAt':
default:
return `"createdAt" DESC`;
return `"createdAt" DESC, "id" ASC`;
}
}
// #endregion
+8 -2
View File
@@ -163,10 +163,16 @@ type AdminWorkspaceMember {
email: String!
id: String!
name: String!
role: Permission!
role: AdminWorkspaceMemberRole!
status: WorkspaceMemberStatus!
}
enum AdminWorkspaceMemberRole {
Admin
Collaborator
Owner
}
type AdminWorkspaceSharedLink {
docId: String!
publishedAt: DateTime
@@ -2953,4 +2959,4 @@ type tokenType {
refresh: String!
sessionToken: String
token: String!
}
}
+8 -2
View File
@@ -213,10 +213,16 @@ export interface AdminWorkspaceMember {
email: Scalars['String']['output'];
id: Scalars['String']['output'];
name: Scalars['String']['output'];
role: Permission;
role: AdminWorkspaceMemberRole;
status: WorkspaceMemberStatus;
}
export enum AdminWorkspaceMemberRole {
Admin = 'Admin',
Collaborator = 'Collaborator',
Owner = 'Owner',
}
export interface AdminWorkspaceSharedLink {
__typename?: 'AdminWorkspaceSharedLink';
docId: Scalars['String']['output'];
@@ -4013,7 +4019,7 @@ export type AdminWorkspaceQuery = {
name: string;
email: string;
avatarUrl: string | null;
role: Permission;
role: AdminWorkspaceMemberRole;
status: WorkspaceMemberStatus;
}>;
} | null;
@@ -102,14 +102,14 @@ export const useColumns = () => {
},
{
accessorKey: 'members',
header: () => <div className="text-xs font-medium">Members</div>,
header: () => <div className="text-xs font-medium">Active Members</div>,
cell: ({ row }) => {
const ws = row.original;
return (
<div className="flex flex-col text-xs gap-1">
<div className="flex gap-2">
<span className="font-medium">{ws.memberCount}</span>
<span className="text-muted-foreground">members</span>
<span className="text-muted-foreground">active members</span>
</div>
<div className="flex gap-2">
<span className="font-medium">{ws.publicPageCount}</span>
@@ -235,7 +235,10 @@ function WorkspacePanelContent({
value={formatBytes(workspace.blobSize)}
/>
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
<MetricCard label="Members" value={`${workspace.memberCount}`} />
<MetricCard
label="Active Members"
value={`${workspace.memberCount}`}
/>
<MetricCard
label="Shared Pages"
value={`${workspace.publicPageCount}`}
@@ -243,12 +246,14 @@ function WorkspacePanelContent({
</div>
<div className="rounded-xl border border-border/60 bg-card shadow-sm">
<div className="px-3 py-2 text-sm font-medium">Members</div>
<div className="px-3 py-2 text-sm font-medium">
Members and Invitations
</div>
<Separator />
<div className="flex flex-col divide-y">
{memberList.length === 0 ? (
<div className="px-3 py-3 text-xs text-muted-foreground">
No members.
No members or invitations.
</div>
) : (
memberList.map(member => (
@@ -270,8 +275,15 @@ function WorkspacePanelContent({
{member.email}
</div>
</div>
<div className="ml-auto text-xs px-2 py-1 rounded border">
{member.role}
<div className="ml-auto flex flex-col items-end gap-1 text-xs">
<div className="rounded border px-2 py-1">
{member.role}
</div>
{member.status !== 'Accepted' ? (
<div className="text-muted-foreground">
{member.status}
</div>
) : null}
</div>
</div>
))