feat: pre-aggregation workspace stats
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
-- Pre-aggregated admin workspace stats tables
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_admin_stats" (
|
||||||
|
"workspace_id" VARCHAR NOT NULL,
|
||||||
|
"snapshot_count" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"snapshot_size" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"blob_count" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"blob_size" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"member_count" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"public_page_count" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"features" TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "workspace_admin_stats_pkey" PRIMARY KEY ("workspace_id"),
|
||||||
|
CONSTRAINT "workspace_admin_stats_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_admin_stats_dirty" (
|
||||||
|
"workspace_id" VARCHAR NOT NULL,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "workspace_admin_stats_dirty_pkey" PRIMARY KEY ("workspace_id"),
|
||||||
|
CONSTRAINT "workspace_admin_stats_dirty_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sorting indexes for admin queries
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_snapshot_size_idx" ON "workspace_admin_stats" ("snapshot_size" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_blob_count_idx" ON "workspace_admin_stats" ("blob_count" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_blob_size_idx" ON "workspace_admin_stats" ("blob_size" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_snapshot_count_idx" ON "workspace_admin_stats" ("snapshot_count" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_member_count_idx" ON "workspace_admin_stats" ("member_count" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_public_page_count_idx" ON "workspace_admin_stats" ("public_page_count" DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_dirty_updated_at_idx" ON "workspace_admin_stats_dirty" ("updated_at");
|
||||||
|
|
||||||
|
-- Feature filtering index
|
||||||
|
CREATE INDEX IF NOT EXISTS "workspace_features_workspace_id_name_activated_idx" ON "workspace_features" ("workspace_id", "name", "activated");
|
||||||
|
|
||||||
|
-- Dirty marker trigger
|
||||||
|
CREATE OR REPLACE FUNCTION workspace_admin_stats_mark_dirty() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
wid VARCHAR;
|
||||||
|
BEGIN
|
||||||
|
wid := COALESCE(NEW."workspace_id", OLD."workspace_id");
|
||||||
|
IF wid IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO "workspace_admin_stats_dirty" ("workspace_id", "updated_at")
|
||||||
|
VALUES (wid, NOW())
|
||||||
|
ON CONFLICT ("workspace_id")
|
||||||
|
DO UPDATE SET "updated_at" = EXCLUDED."updated_at";
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS "workspace_admin_stats_dirty_snapshots" ON "snapshots";
|
||||||
|
CREATE TRIGGER "workspace_admin_stats_dirty_snapshots"
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "snapshots"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION workspace_admin_stats_mark_dirty();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS "workspace_admin_stats_dirty_blobs" ON "blobs";
|
||||||
|
CREATE TRIGGER "workspace_admin_stats_dirty_blobs"
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "blobs"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION workspace_admin_stats_mark_dirty();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS "workspace_admin_stats_dirty_wup" ON "workspace_user_permissions";
|
||||||
|
CREATE TRIGGER "workspace_admin_stats_dirty_wup"
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "workspace_user_permissions"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION workspace_admin_stats_mark_dirty();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS "workspace_admin_stats_dirty_pages" ON "workspace_pages";
|
||||||
|
CREATE TRIGGER "workspace_admin_stats_dirty_pages"
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "workspace_pages"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION workspace_admin_stats_mark_dirty();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS "workspace_admin_stats_dirty_features" ON "workspace_features";
|
||||||
|
CREATE TRIGGER "workspace_admin_stats_dirty_features"
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON "workspace_features"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION workspace_admin_stats_mark_dirty();
|
||||||
|
|
||||||
|
-- Mark existing workspaces dirty for initial backfill
|
||||||
|
INSERT INTO "workspace_admin_stats_dirty" ("workspace_id", "updated_at")
|
||||||
|
SELECT id, NOW() FROM "workspaces"
|
||||||
|
ON CONFLICT ("workspace_id") DO NOTHING;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "idx_wur_workspace";
|
||||||
@@ -124,15 +124,17 @@ model Workspace {
|
|||||||
indexed Boolean @default(false)
|
indexed Boolean @default(false)
|
||||||
lastCheckEmbeddings DateTime @default("1970-01-01T00:00:00-00:00") @map("last_check_embeddings") @db.Timestamptz(3)
|
lastCheckEmbeddings DateTime @default("1970-01-01T00:00:00-00:00") @map("last_check_embeddings") @db.Timestamptz(3)
|
||||||
|
|
||||||
features WorkspaceFeature[]
|
features WorkspaceFeature[]
|
||||||
docs WorkspaceDoc[]
|
docs WorkspaceDoc[]
|
||||||
permissions WorkspaceUserRole[]
|
permissions WorkspaceUserRole[]
|
||||||
docPermissions WorkspaceDocUserRole[]
|
docPermissions WorkspaceDocUserRole[]
|
||||||
blobs Blob[]
|
blobs Blob[]
|
||||||
ignoredDocs AiWorkspaceIgnoredDocs[]
|
ignoredDocs AiWorkspaceIgnoredDocs[]
|
||||||
embedFiles AiWorkspaceFiles[]
|
embedFiles AiWorkspaceFiles[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
commentAttachments CommentAttachment[]
|
commentAttachments CommentAttachment[]
|
||||||
|
workspaceAdminStats WorkspaceAdminStats[]
|
||||||
|
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
|
||||||
|
|
||||||
@@index([lastCheckEmbeddings])
|
@@index([lastCheckEmbeddings])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -241,7 +243,7 @@ model UserFeature {
|
|||||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
|
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
|
||||||
activated Boolean @default(false)
|
activated Boolean @default(false)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@ -267,9 +269,43 @@ model WorkspaceFeature {
|
|||||||
|
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
// Partial index on (workspace_id, name) where activated = true is created via SQL migration
|
||||||
|
@@index([workspaceId, name, activated])
|
||||||
@@map("workspace_features")
|
@@map("workspace_features")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WorkspaceAdminStats {
|
||||||
|
workspaceId String @id @map("workspace_id") @db.VarChar
|
||||||
|
snapshotCount BigInt @default(0) @map("snapshot_count") @db.BigInt
|
||||||
|
snapshotSize BigInt @default(0) @map("snapshot_size") @db.BigInt
|
||||||
|
blobCount BigInt @default(0) @map("blob_count") @db.BigInt
|
||||||
|
blobSize BigInt @default(0) @map("blob_size") @db.BigInt
|
||||||
|
memberCount BigInt @default(0) @map("member_count") @db.BigInt
|
||||||
|
publicPageCount BigInt @default(0) @map("public_page_count") @db.BigInt
|
||||||
|
features String[] @default([]) @db.Text
|
||||||
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([snapshotSize(sort: Desc)])
|
||||||
|
@@index([blobCount(sort: Desc)])
|
||||||
|
@@index([blobSize(sort: Desc)])
|
||||||
|
@@index([snapshotCount(sort: Desc)])
|
||||||
|
@@index([memberCount(sort: Desc)])
|
||||||
|
@@index([publicPageCount(sort: Desc)])
|
||||||
|
@@map("workspace_admin_stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkspaceAdminStatsDirty {
|
||||||
|
workspaceId String @id @map("workspace_id") @db.VarChar
|
||||||
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([updatedAt])
|
||||||
|
@@map("workspace_admin_stats_dirty")
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export type KnownMetricScopes =
|
|||||||
| 'event'
|
| 'event'
|
||||||
| 'queue'
|
| 'queue'
|
||||||
| 'storage'
|
| 'storage'
|
||||||
| 'process';
|
| 'process'
|
||||||
|
| 'workspace';
|
||||||
|
|
||||||
const metricCreators: MetricCreators = {
|
const metricCreators: MetricCreators = {
|
||||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from './resolvers';
|
} from './resolvers';
|
||||||
import { AdminWorkspaceResolver } from './resolvers/admin';
|
import { AdminWorkspaceResolver } from './resolvers/admin';
|
||||||
import { WorkspaceService } from './service';
|
import { WorkspaceService } from './service';
|
||||||
|
import { WorkspaceStatsJob } from './stats.job';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -45,6 +46,7 @@ import { WorkspaceService } from './service';
|
|||||||
WorkspaceService,
|
WorkspaceService,
|
||||||
WorkspaceEvents,
|
WorkspaceEvents,
|
||||||
AdminWorkspaceResolver,
|
AdminWorkspaceResolver,
|
||||||
|
WorkspaceStatsJob,
|
||||||
],
|
],
|
||||||
exports: [WorkspaceService],
|
exports: [WorkspaceService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ enum AdminWorkspaceSort {
|
|||||||
SnapshotSize = 'SnapshotSize',
|
SnapshotSize = 'SnapshotSize',
|
||||||
BlobCount = 'BlobCount',
|
BlobCount = 'BlobCount',
|
||||||
BlobSize = 'BlobSize',
|
BlobSize = 'BlobSize',
|
||||||
|
SnapshotCount = 'SnapshotCount',
|
||||||
|
MemberCount = 'MemberCount',
|
||||||
|
PublicPageCount = 'PublicPageCount',
|
||||||
}
|
}
|
||||||
|
|
||||||
registerEnumType(AdminWorkspaceSort, {
|
registerEnumType(AdminWorkspaceSort, {
|
||||||
@@ -298,6 +301,12 @@ export class AdminWorkspaceResolver {
|
|||||||
return 'blobCount';
|
return 'blobCount';
|
||||||
case AdminWorkspaceSort.BlobSize:
|
case AdminWorkspaceSort.BlobSize:
|
||||||
return 'blobSize';
|
return 'blobSize';
|
||||||
|
case AdminWorkspaceSort.SnapshotCount:
|
||||||
|
return 'snapshotCount';
|
||||||
|
case AdminWorkspaceSort.MemberCount:
|
||||||
|
return 'memberCount';
|
||||||
|
case AdminWorkspaceSort.PublicPageCount:
|
||||||
|
return 'publicPageCount';
|
||||||
case AdminWorkspaceSort.CreatedAt:
|
case AdminWorkspaceSort.CreatedAt:
|
||||||
default:
|
default:
|
||||||
return 'createdAt';
|
return 'createdAt';
|
||||||
|
|||||||
305
packages/backend/server/src/core/workspaces/stats.job.ts
Normal file
305
packages/backend/server/src/core/workspaces/stats.job.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { metrics } from '../../base';
|
||||||
|
|
||||||
|
const LOCK_NAMESPACE = 97_301;
|
||||||
|
const LOCK_KEY = 1;
|
||||||
|
const DIRTY_BATCH_SIZE = 500;
|
||||||
|
const FULL_REFRESH_BATCH_SIZE = 2000;
|
||||||
|
const TRANSACTION_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspaceStatsJob {
|
||||||
|
private readonly logger = new Logger(WorkspaceStatsJob.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async refreshDirty() {
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.withAdvisoryLock(async tx => {
|
||||||
|
const backlog = await this.countDirty(tx);
|
||||||
|
metrics.workspace
|
||||||
|
.gauge('admin_stats_dirty_backlog')
|
||||||
|
.record(Number(backlog));
|
||||||
|
|
||||||
|
const dirty = await this.loadDirty(tx, DIRTY_BATCH_SIZE);
|
||||||
|
if (!dirty.length) {
|
||||||
|
return { processed: 0, backlog };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertStats(tx, dirty);
|
||||||
|
await this.clearDirty(tx, dirty);
|
||||||
|
return { processed: dirty.length, backlog };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
this.logger.debug('skip admin stats refresh, lock not acquired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.workspace
|
||||||
|
.histogram('admin_stats_refresh_duration_ms')
|
||||||
|
.record(Date.now() - started, { mode: 'incremental' });
|
||||||
|
|
||||||
|
if (result.processed > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`Refreshed admin stats for ${result.processed} workspace(s); backlog ${result.backlog}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
metrics.workspace.counter('admin_stats_refresh_failed').add(1, {
|
||||||
|
mode: 'incremental',
|
||||||
|
});
|
||||||
|
this.logger.error('Failed to refresh admin stats', error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_1AM)
|
||||||
|
async recalibrate() {
|
||||||
|
let lastSid = 0;
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await this.withAdvisoryLock(async tx => {
|
||||||
|
const workspaces = await this.fetchWorkspaceBatch(
|
||||||
|
tx,
|
||||||
|
lastSid,
|
||||||
|
FULL_REFRESH_BATCH_SIZE
|
||||||
|
);
|
||||||
|
if (!workspaces.length) {
|
||||||
|
return { processed: 0, lastSid };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = workspaces.map(({ id }) => id);
|
||||||
|
await this.upsertStats(tx, ids);
|
||||||
|
|
||||||
|
return {
|
||||||
|
processed: ids.length,
|
||||||
|
lastSid: workspaces[workspaces.length - 1].sid,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
this.logger.debug(
|
||||||
|
'skip admin stats recalibration, lock not acquired'
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.processed === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
processed += result.processed;
|
||||||
|
lastSid = result.lastSid;
|
||||||
|
|
||||||
|
metrics.workspace
|
||||||
|
.histogram('admin_stats_refresh_duration_ms')
|
||||||
|
.record(Date.now() - started, { mode: 'full' });
|
||||||
|
|
||||||
|
if (result.processed < FULL_REFRESH_BATCH_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
metrics.workspace.counter('admin_stats_refresh_failed').add(1, {
|
||||||
|
mode: 'full',
|
||||||
|
});
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to recalibrate admin stats after sid ${lastSid}`,
|
||||||
|
error as Error
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processed > 0) {
|
||||||
|
this.logger.verbose(
|
||||||
|
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withAdvisoryLock<T>(
|
||||||
|
callback: (tx: Prisma.TransactionClient) => Promise<T>
|
||||||
|
): Promise<T | null> {
|
||||||
|
return await this.prisma.$transaction(
|
||||||
|
async tx => {
|
||||||
|
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>`
|
||||||
|
SELECT pg_try_advisory_lock(${LOCK_NAMESPACE}, ${LOCK_KEY}) AS locked
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!lock?.locked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await callback(tx);
|
||||||
|
} finally {
|
||||||
|
await tx.$executeRaw`SELECT pg_advisory_unlock(${LOCK_NAMESPACE}, ${LOCK_KEY})`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxWait: 5_000,
|
||||||
|
timeout: TRANSACTION_TIMEOUT_MS,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDirty(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
limit: number
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await tx.$queryRaw<{ workspace_id: string }[]>`
|
||||||
|
SELECT workspace_id
|
||||||
|
FROM workspace_admin_stats_dirty
|
||||||
|
ORDER BY updated_at ASC
|
||||||
|
LIMIT ${limit}
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
`;
|
||||||
|
|
||||||
|
return rows.map(row => row.workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async countDirty(tx: Prisma.TransactionClient) {
|
||||||
|
const [row] = await tx.$queryRaw<{ total: bigint | number }[]>`
|
||||||
|
SELECT COUNT(*) AS total FROM workspace_admin_stats_dirty
|
||||||
|
`;
|
||||||
|
return row?.total ? Number(row.total) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async clearDirty(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
workspaceIds: string[]
|
||||||
|
) {
|
||||||
|
if (!workspaceIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.$executeRaw`
|
||||||
|
DELETE FROM workspace_admin_stats_dirty
|
||||||
|
WHERE workspace_id IN (${Prisma.join(
|
||||||
|
workspaceIds.map(id => Prisma.sql`${id}`)
|
||||||
|
)})
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertStats(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
workspaceIds: string[]
|
||||||
|
) {
|
||||||
|
if (!workspaceIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIds = Prisma.join(workspaceIds.map(id => Prisma.sql`${id}`));
|
||||||
|
|
||||||
|
await tx.$executeRaw`
|
||||||
|
WITH targets AS (
|
||||||
|
SELECT UNNEST(ARRAY[${targetIds}]::varchar[]) AS workspace_id
|
||||||
|
),
|
||||||
|
snapshot_stats AS (
|
||||||
|
SELECT workspace_id,
|
||||||
|
COUNT(*) AS snapshot_count,
|
||||||
|
COALESCE(SUM(COALESCE(size, octet_length(blob))), 0) AS snapshot_size
|
||||||
|
FROM snapshots
|
||||||
|
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||||
|
GROUP BY workspace_id
|
||||||
|
),
|
||||||
|
blob_stats AS (
|
||||||
|
SELECT workspace_id,
|
||||||
|
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count,
|
||||||
|
COALESCE(SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed'), 0) AS blob_size
|
||||||
|
FROM blobs
|
||||||
|
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||||
|
GROUP BY workspace_id
|
||||||
|
),
|
||||||
|
member_stats AS (
|
||||||
|
SELECT workspace_id, COUNT(*) AS member_count
|
||||||
|
FROM workspace_user_permissions
|
||||||
|
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||||
|
GROUP BY workspace_id
|
||||||
|
),
|
||||||
|
public_page_stats AS (
|
||||||
|
SELECT workspace_id, COUNT(*) AS public_page_count
|
||||||
|
FROM workspace_pages
|
||||||
|
WHERE public = TRUE AND workspace_id IN (SELECT workspace_id FROM targets)
|
||||||
|
GROUP BY workspace_id
|
||||||
|
),
|
||||||
|
feature_stats AS (
|
||||||
|
SELECT workspace_id,
|
||||||
|
ARRAY_AGG(DISTINCT name ORDER BY name) FILTER (WHERE activated) AS features
|
||||||
|
FROM workspace_features
|
||||||
|
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||||
|
GROUP BY workspace_id
|
||||||
|
),
|
||||||
|
aggregated AS (
|
||||||
|
SELECT t.workspace_id,
|
||||||
|
COALESCE(ss.snapshot_count, 0) AS snapshot_count,
|
||||||
|
COALESCE(ss.snapshot_size, 0) AS snapshot_size,
|
||||||
|
COALESCE(bs.blob_count, 0) AS blob_count,
|
||||||
|
COALESCE(bs.blob_size, 0) AS blob_size,
|
||||||
|
COALESCE(ms.member_count, 0) AS member_count,
|
||||||
|
COALESCE(pp.public_page_count, 0) AS public_page_count,
|
||||||
|
COALESCE(fs.features, ARRAY[]::text[]) AS features
|
||||||
|
FROM targets t
|
||||||
|
LEFT JOIN snapshot_stats ss ON ss.workspace_id = t.workspace_id
|
||||||
|
LEFT JOIN blob_stats bs ON bs.workspace_id = t.workspace_id
|
||||||
|
LEFT JOIN member_stats ms ON ms.workspace_id = t.workspace_id
|
||||||
|
LEFT JOIN public_page_stats pp ON pp.workspace_id = t.workspace_id
|
||||||
|
LEFT JOIN feature_stats fs ON fs.workspace_id = t.workspace_id
|
||||||
|
)
|
||||||
|
INSERT INTO workspace_admin_stats (
|
||||||
|
workspace_id,
|
||||||
|
snapshot_count,
|
||||||
|
snapshot_size,
|
||||||
|
blob_count,
|
||||||
|
blob_size,
|
||||||
|
member_count,
|
||||||
|
public_page_count,
|
||||||
|
features,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
workspace_id,
|
||||||
|
snapshot_count,
|
||||||
|
snapshot_size,
|
||||||
|
blob_count,
|
||||||
|
blob_size,
|
||||||
|
member_count,
|
||||||
|
public_page_count,
|
||||||
|
features,
|
||||||
|
NOW()
|
||||||
|
FROM aggregated
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWorkspaceBatch(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
lastSid: number,
|
||||||
|
limit: number
|
||||||
|
) {
|
||||||
|
return tx.$queryRaw<{ id: string; sid: number }[]>`
|
||||||
|
SELECT id, sid
|
||||||
|
FROM workspaces
|
||||||
|
WHERE sid > ${lastSid}
|
||||||
|
ORDER BY sid
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,7 +185,14 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
first: number;
|
first: number;
|
||||||
keyword?: string | null;
|
keyword?: string | null;
|
||||||
features?: WorkspaceFeatureName[] | null;
|
features?: WorkspaceFeatureName[] | null;
|
||||||
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize';
|
order?:
|
||||||
|
| 'createdAt'
|
||||||
|
| 'snapshotSize'
|
||||||
|
| 'blobCount'
|
||||||
|
| 'blobSize'
|
||||||
|
| 'snapshotCount'
|
||||||
|
| 'memberCount'
|
||||||
|
| 'publicPageCount';
|
||||||
includeTotal?: boolean;
|
includeTotal?: boolean;
|
||||||
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
|
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
|
||||||
const keyword = options.keyword?.trim();
|
const keyword = options.keyword?.trim();
|
||||||
@@ -198,189 +205,100 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
return { rows: [], total: 0 };
|
return { rows: [], total: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows =
|
const featuresHaving =
|
||||||
options.order === 'createdAt' || !options.order
|
features.length > 0
|
||||||
? await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
? Prisma.sql`
|
||||||
WITH feature_set AS (
|
HAVING COUNT(
|
||||||
SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
|
DISTINCT CASE
|
||||||
FROM workspace_features
|
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
|
||||||
GROUP BY workspace_id
|
END
|
||||||
),
|
) = ${features.length}
|
||||||
owner AS (
|
|
||||||
SELECT wur.workspace_id,
|
|
||||||
u.id AS owner_id,
|
|
||||||
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.type = ${WorkspaceRole.Owner}
|
|
||||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
|
||||||
),
|
|
||||||
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_url_preview AS "enableUrlPreview",
|
|
||||||
w.enable_doc_embedding AS "enableDocEmbedding",
|
|
||||||
COALESCE(fs.features, ARRAY[]::text[]) AS features,
|
|
||||||
o.owner_id AS "ownerId",
|
|
||||||
o.owner_name AS "ownerName",
|
|
||||||
o.owner_email AS "ownerEmail",
|
|
||||||
o.owner_avatar_url AS "ownerAvatarUrl"
|
|
||||||
FROM workspaces w
|
|
||||||
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
|
|
||||||
LEFT JOIN owner o ON o.workspace_id = w.id
|
|
||||||
WHERE ${
|
|
||||||
keyword
|
|
||||||
? Prisma.sql`
|
|
||||||
(
|
|
||||||
w.id ILIKE ${'%' + keyword + '%'}
|
|
||||||
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
|
||||||
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
|
||||||
)
|
|
||||||
`
|
|
||||||
: Prisma.sql`TRUE`
|
|
||||||
}
|
|
||||||
AND ${
|
|
||||||
features.length
|
|
||||||
? Prisma.sql`CAST(COALESCE(fs.features, ARRAY[]::text[]) AS text[]) @> ${Prisma.sql`${features}::text[]`}`
|
|
||||||
: Prisma.sql`TRUE`
|
|
||||||
}
|
|
||||||
ORDER BY w.created_at DESC
|
|
||||||
LIMIT ${options.first}
|
|
||||||
OFFSET ${options.skip}
|
|
||||||
)
|
|
||||||
SELECT f.*,
|
|
||||||
COALESCE(ms.member_count, 0) AS "memberCount",
|
|
||||||
COALESCE(pp.public_page_count, 0) AS "publicPageCount",
|
|
||||||
COALESCE(ss.snapshot_count, 0) AS "snapshotCount",
|
|
||||||
COALESCE(ss.snapshot_size, 0) AS "snapshotSize",
|
|
||||||
COALESCE(bs.blob_count, 0) AS "blobCount",
|
|
||||||
COALESCE(bs.blob_size, 0) AS "blobSize"
|
|
||||||
FROM filtered f
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COUNT(*) AS member_count
|
|
||||||
FROM workspace_user_permissions
|
|
||||||
WHERE workspace_id = f.id
|
|
||||||
) ms ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COUNT(*) AS public_page_count
|
|
||||||
FROM workspace_pages
|
|
||||||
WHERE workspace_id = f.id AND public = true
|
|
||||||
) pp ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COUNT(*) AS snapshot_count,
|
|
||||||
SUM(size) AS snapshot_size
|
|
||||||
FROM snapshots
|
|
||||||
WHERE workspace_id = f.id
|
|
||||||
) ss ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count,
|
|
||||||
SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size
|
|
||||||
FROM blobs
|
|
||||||
WHERE workspace_id = f.id
|
|
||||||
) bs ON TRUE
|
|
||||||
ORDER BY f."createdAt" DESC
|
|
||||||
`
|
`
|
||||||
: await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
: Prisma.empty;
|
||||||
WITH feature_set AS (
|
|
||||||
SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
|
const featureJoin =
|
||||||
FROM workspace_features
|
features.length > 0
|
||||||
GROUP BY workspace_id
|
? Prisma.sql`
|
||||||
),
|
LEFT JOIN workspace_features wf
|
||||||
owner AS (
|
ON wf.workspace_id = w.id AND wf.activated = TRUE
|
||||||
SELECT wur.workspace_id,
|
`
|
||||||
u.id AS owner_id,
|
: Prisma.empty;
|
||||||
u.name AS owner_name,
|
|
||||||
u.email AS owner_email,
|
const groupAndHaving =
|
||||||
u.avatar_url AS owner_avatar_url
|
features.length > 0
|
||||||
FROM workspace_user_permissions AS wur
|
? Prisma.sql`
|
||||||
JOIN users u ON wur.user_id = u.id
|
GROUP BY w.id,
|
||||||
WHERE wur.type = ${WorkspaceRole.Owner}
|
|
||||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
|
||||||
),
|
|
||||||
filtered AS (
|
|
||||||
SELECT w.id,
|
|
||||||
w.public,
|
w.public,
|
||||||
w.created_at AS "createdAt",
|
w.created_at,
|
||||||
w.name,
|
w.name,
|
||||||
w.avatar_key AS "avatarKey",
|
w.avatar_key,
|
||||||
w.enable_ai AS "enableAi",
|
w.enable_ai,
|
||||||
w.enable_url_preview AS "enableUrlPreview",
|
w.enable_url_preview,
|
||||||
w.enable_doc_embedding AS "enableDocEmbedding",
|
w.enable_doc_embedding,
|
||||||
COALESCE(fs.features, ARRAY[]::text[]) AS features,
|
o.owner_id,
|
||||||
o.owner_id AS "ownerId",
|
o.owner_name,
|
||||||
o.owner_name AS "ownerName",
|
o.owner_email,
|
||||||
o.owner_email AS "ownerEmail",
|
o.owner_avatar_url
|
||||||
o.owner_avatar_url AS "ownerAvatarUrl"
|
${featuresHaving}
|
||||||
FROM workspaces w
|
`
|
||||||
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
|
: Prisma.empty;
|
||||||
LEFT JOIN owner o ON o.workspace_id = w.id
|
|
||||||
WHERE ${
|
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||||
keyword
|
WITH filtered AS (
|
||||||
? Prisma.sql`
|
SELECT w.id,
|
||||||
(
|
w.public,
|
||||||
w.id ILIKE ${'%' + keyword + '%'}
|
w.created_at AS "createdAt",
|
||||||
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
w.name,
|
||||||
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
w.avatar_key AS "avatarKey",
|
||||||
)
|
w.enable_ai AS "enableAi",
|
||||||
`
|
w.enable_url_preview AS "enableUrlPreview",
|
||||||
: Prisma.sql`TRUE`
|
w.enable_doc_embedding AS "enableDocEmbedding",
|
||||||
}
|
o.owner_id AS "ownerId",
|
||||||
AND ${
|
o.owner_name AS "ownerName",
|
||||||
features.length
|
o.owner_email AS "ownerEmail",
|
||||||
? Prisma.sql`CAST(COALESCE(fs.features, ARRAY[]::text[]) AS text[]) @> ${Prisma.sql`${features}::text[]`}`
|
o.owner_avatar_url AS "ownerAvatarUrl"
|
||||||
: Prisma.sql`TRUE`
|
FROM workspaces w
|
||||||
}
|
LEFT JOIN LATERAL (
|
||||||
),
|
SELECT u.id AS owner_id,
|
||||||
snapshot_stats AS (
|
u.name AS owner_name,
|
||||||
SELECT workspace_id,
|
u.email AS owner_email,
|
||||||
SUM(size) AS snapshot_size,
|
u.avatar_url AS owner_avatar_url
|
||||||
COUNT(*) AS snapshot_count
|
FROM workspace_user_permissions AS wur
|
||||||
FROM snapshots
|
JOIN users u ON wur.user_id = u.id
|
||||||
WHERE workspace_id IN (SELECT id FROM filtered)
|
WHERE wur.workspace_id = w.id
|
||||||
GROUP BY workspace_id
|
AND wur.type = ${WorkspaceRole.Owner}
|
||||||
),
|
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
||||||
blob_stats AS (
|
ORDER BY u.created_at ASC
|
||||||
SELECT workspace_id,
|
LIMIT 1
|
||||||
SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size,
|
) o ON TRUE
|
||||||
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count
|
${featureJoin}
|
||||||
FROM blobs
|
WHERE ${
|
||||||
WHERE workspace_id IN (SELECT id FROM filtered)
|
keyword
|
||||||
GROUP BY workspace_id
|
? Prisma.sql`
|
||||||
),
|
(
|
||||||
member_stats AS (
|
w.id ILIKE ${'%' + keyword + '%'}
|
||||||
SELECT workspace_id, COUNT(*) AS member_count
|
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
||||||
FROM workspace_user_permissions
|
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
||||||
WHERE workspace_id IN (SELECT id FROM filtered)
|
)
|
||||||
GROUP BY workspace_id
|
`
|
||||||
),
|
: Prisma.sql`TRUE`
|
||||||
public_pages AS (
|
}
|
||||||
SELECT workspace_id, COUNT(*) AS public_page_count
|
${groupAndHaving}
|
||||||
FROM workspace_pages
|
)
|
||||||
WHERE public = true AND workspace_id IN (SELECT id FROM filtered)
|
SELECT f.*,
|
||||||
GROUP BY workspace_id
|
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
|
||||||
)
|
COALESCE(s.snapshot_size, 0) AS "snapshotSize",
|
||||||
SELECT f.*,
|
COALESCE(s.blob_count, 0) AS "blobCount",
|
||||||
COALESCE(ms.member_count, 0) AS "memberCount",
|
COALESCE(s.blob_size, 0) AS "blobSize",
|
||||||
COALESCE(pp.public_page_count, 0) AS "publicPageCount",
|
COALESCE(s.member_count, 0) AS "memberCount",
|
||||||
COALESCE(ss.snapshot_count, 0) AS "snapshotCount",
|
COALESCE(s.public_page_count, 0) AS "publicPageCount",
|
||||||
COALESCE(ss.snapshot_size, 0) AS "snapshotSize",
|
COALESCE(s.features, ARRAY[]::text[]) AS features
|
||||||
COALESCE(bs.blob_count, 0) AS "blobCount",
|
FROM filtered f
|
||||||
COALESCE(bs.blob_size, 0) AS "blobSize"
|
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
|
||||||
FROM filtered f
|
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
||||||
LEFT JOIN snapshot_stats ss ON ss.workspace_id = f.id
|
LIMIT ${options.first}
|
||||||
LEFT JOIN blob_stats bs ON bs.workspace_id = f.id
|
OFFSET ${options.skip}
|
||||||
LEFT JOIN member_stats ms ON ms.workspace_id = f.id
|
`;
|
||||||
LEFT JOIN public_pages pp ON pp.workspace_id = f.id
|
|
||||||
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
|
||||||
LIMIT ${options.first}
|
|
||||||
OFFSET ${options.skip}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const mapped = rows.map(row => ({
|
const mapped = rows.map(row => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -418,56 +336,94 @@ export class WorkspaceModel extends BaseModel {
|
|||||||
const keyword = options.keyword?.trim();
|
const keyword = options.keyword?.trim();
|
||||||
const features = options.features ?? [];
|
const features = options.features ?? [];
|
||||||
|
|
||||||
|
const featuresHaving =
|
||||||
|
features.length > 0
|
||||||
|
? Prisma.sql`
|
||||||
|
HAVING COUNT(
|
||||||
|
DISTINCT CASE
|
||||||
|
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
|
||||||
|
END
|
||||||
|
) = ${features.length}
|
||||||
|
`
|
||||||
|
: 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;
|
||||||
|
|
||||||
|
const groupAndHaving =
|
||||||
|
features.length > 0
|
||||||
|
? Prisma.sql`
|
||||||
|
GROUP BY w.id, o.owner_id, o.owner_email
|
||||||
|
${featuresHaving}
|
||||||
|
`
|
||||||
|
: Prisma.empty;
|
||||||
|
|
||||||
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
||||||
WITH feature_set AS (
|
WITH filtered AS (
|
||||||
SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
|
SELECT w.id,
|
||||||
FROM workspace_features
|
o.owner_id AS "ownerId",
|
||||||
GROUP BY workspace_id
|
o.owner_email AS "ownerEmail"
|
||||||
),
|
FROM workspaces w
|
||||||
owner AS (
|
LEFT JOIN LATERAL (
|
||||||
SELECT wur.workspace_id,
|
SELECT wur.workspace_id,
|
||||||
u.id AS owner_id,
|
u.id AS owner_id,
|
||||||
u.email AS owner_email
|
u.email AS owner_email
|
||||||
FROM workspace_user_permissions AS wur
|
FROM workspace_user_permissions AS wur
|
||||||
JOIN users u ON wur.user_id = u.id
|
JOIN users u ON wur.user_id = u.id
|
||||||
WHERE wur.type = ${WorkspaceRole.Owner}
|
WHERE wur.workspace_id = w.id
|
||||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
AND wur.type = ${WorkspaceRole.Owner}
|
||||||
|
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
||||||
|
ORDER BY u.created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
) o ON TRUE
|
||||||
|
${featureJoin}
|
||||||
|
WHERE ${
|
||||||
|
keyword
|
||||||
|
? Prisma.sql`
|
||||||
|
(
|
||||||
|
w.id ILIKE ${'%' + keyword + '%'}
|
||||||
|
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
||||||
|
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
: Prisma.sql`TRUE`
|
||||||
|
}
|
||||||
|
${groupAndHaving}
|
||||||
)
|
)
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total FROM filtered
|
||||||
FROM workspaces w
|
|
||||||
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
|
|
||||||
LEFT JOIN owner o ON o.workspace_id = w.id
|
|
||||||
WHERE ${
|
|
||||||
keyword
|
|
||||||
? Prisma.sql`
|
|
||||||
(
|
|
||||||
w.id ILIKE ${'%' + keyword + '%'}
|
|
||||||
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
|
||||||
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
|
||||||
)
|
|
||||||
`
|
|
||||||
: Prisma.sql`TRUE`
|
|
||||||
}
|
|
||||||
AND ${
|
|
||||||
features.length
|
|
||||||
? Prisma.sql`CAST(COALESCE(fs.features, ARRAY[]::text[]) AS text[]) @> ${Prisma.sql`${features}::text[]`}`
|
|
||||||
: Prisma.sql`TRUE`
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return row?.total ? Number(row.total) : 0;
|
return row?.total ? Number(row.total) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildAdminOrder(
|
private buildAdminOrder(
|
||||||
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize'
|
order?:
|
||||||
|
| 'createdAt'
|
||||||
|
| 'snapshotSize'
|
||||||
|
| 'blobCount'
|
||||||
|
| 'blobSize'
|
||||||
|
| 'snapshotCount'
|
||||||
|
| 'memberCount'
|
||||||
|
| 'publicPageCount'
|
||||||
) {
|
) {
|
||||||
switch (order) {
|
switch (order) {
|
||||||
case 'snapshotSize':
|
case 'snapshotSize':
|
||||||
return `"snapshotSize" DESC NULLS LAST`;
|
return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC`;
|
||||||
case 'blobCount':
|
case 'blobCount':
|
||||||
return `"blobCount" DESC NULLS LAST`;
|
return `"blobCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||||
case 'blobSize':
|
case 'blobSize':
|
||||||
return `"blobSize" DESC NULLS LAST`;
|
return `"blobSize" DESC NULLS LAST, "createdAt" DESC`;
|
||||||
|
case 'snapshotCount':
|
||||||
|
return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||||
|
case 'memberCount':
|
||||||
|
return `"memberCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||||
|
case 'publicPageCount':
|
||||||
|
return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||||
case 'createdAt':
|
case 'createdAt':
|
||||||
default:
|
default:
|
||||||
return `"createdAt" DESC`;
|
return `"createdAt" DESC`;
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ enum AdminWorkspaceSort {
|
|||||||
BlobCount
|
BlobCount
|
||||||
BlobSize
|
BlobSize
|
||||||
CreatedAt
|
CreatedAt
|
||||||
|
MemberCount
|
||||||
|
PublicPageCount
|
||||||
|
SnapshotCount
|
||||||
SnapshotSize
|
SnapshotSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export enum AdminWorkspaceSort {
|
|||||||
BlobCount = 'BlobCount',
|
BlobCount = 'BlobCount',
|
||||||
BlobSize = 'BlobSize',
|
BlobSize = 'BlobSize',
|
||||||
CreatedAt = 'CreatedAt',
|
CreatedAt = 'CreatedAt',
|
||||||
|
MemberCount = 'MemberCount',
|
||||||
|
PublicPageCount = 'PublicPageCount',
|
||||||
|
SnapshotCount = 'SnapshotCount',
|
||||||
SnapshotSize = 'SnapshotSize',
|
SnapshotSize = 'SnapshotSize',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import { useCallback, useTransition } from 'react';
|
|||||||
|
|
||||||
interface DataTablePaginationProps<TData> {
|
interface DataTablePaginationProps<TData> {
|
||||||
table: Table<TData>;
|
table: Table<TData>;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTablePagination<TData>({
|
export function DataTablePagination<TData>({
|
||||||
table,
|
table,
|
||||||
|
disabled = false,
|
||||||
}: DataTablePaginationProps<TData>) {
|
}: DataTablePaginationProps<TData>) {
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ export function DataTablePagination<TData>({
|
|||||||
<Select
|
<Select
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
onValueChange={onPageSizeChange}
|
onValueChange={onPageSizeChange}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
@@ -86,7 +89,7 @@ export function DataTablePagination<TData>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="hidden h-8 w-8 p-0 lg:flex"
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
onClick={handleFirstPage}
|
onClick={handleFirstPage}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={disabled || !table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to first page</span>
|
<span className="sr-only">Go to first page</span>
|
||||||
<ChevronsLeftIcon className="h-4 w-4" />
|
<ChevronsLeftIcon className="h-4 w-4" />
|
||||||
@@ -95,7 +98,7 @@ export function DataTablePagination<TData>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={handlePreviousPage}
|
onClick={handlePreviousPage}
|
||||||
disabled={!table.getCanPreviousPage()}
|
disabled={disabled || !table.getCanPreviousPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to previous page</span>
|
<span className="sr-only">Go to previous page</span>
|
||||||
<ChevronLeftIcon className="h-4 w-4" />
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
@@ -104,7 +107,7 @@ export function DataTablePagination<TData>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={handleNextPage}
|
onClick={handleNextPage}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={disabled || !table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to next page</span>
|
<span className="sr-only">Go to next page</span>
|
||||||
<ChevronRightIcon className="h-4 w-4" />
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
@@ -113,7 +116,7 @@ export function DataTablePagination<TData>({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={handleLastPage}
|
onClick={handleLastPage}
|
||||||
disabled={!table.getCanNextPage()}
|
disabled={disabled || !table.getCanNextPage()}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Go to last page</span>
|
<span className="sr-only">Go to last page</span>
|
||||||
<ChevronsRightIcon className="h-4 w-4" />
|
<ChevronsRightIcon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface DataTableProps<TData, TValue> {
|
|||||||
totalCount: number;
|
totalCount: number;
|
||||||
pagination: PaginationState;
|
pagination: PaginationState;
|
||||||
onPaginationChange: OnChangeFn<PaginationState>;
|
onPaginationChange: OnChangeFn<PaginationState>;
|
||||||
|
loading?: boolean;
|
||||||
|
disablePagination?: boolean;
|
||||||
|
|
||||||
// Row Selection
|
// Row Selection
|
||||||
rowSelection?: RowSelectionState;
|
rowSelection?: RowSelectionState;
|
||||||
@@ -51,6 +53,8 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
|
|||||||
totalCount,
|
totalCount,
|
||||||
pagination,
|
pagination,
|
||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
|
loading = false,
|
||||||
|
disablePagination = false,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
onRowSelectionChange,
|
onRowSelectionChange,
|
||||||
renderToolbar,
|
renderToolbar,
|
||||||
@@ -83,9 +87,34 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto">
|
<div className="flex flex-col gap-4 py-5 px-6 h-full overflow-auto relative">
|
||||||
{renderToolbar?.(table)}
|
{renderToolbar?.(table)}
|
||||||
<div className="rounded-md border h-full flex flex-col overflow-auto">
|
<div className="rounded-md border h-full flex flex-col overflow-auto relative">
|
||||||
|
{loading ? (
|
||||||
|
<div className="absolute inset-0 z-10 bg-gray-50/70 backdrop-blur-[1px] flex flex-col items-center justify-center gap-2 text-sm text-gray-600">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 animate-spin text-gray-500"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map(headerGroup => (
|
{table.getHeaderGroups().map(headerGroup => (
|
||||||
@@ -160,7 +189,10 @@ export function SharedDataTable<TData extends { id: string }, TValue>({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DataTablePagination table={table} />
|
<DataTablePagination
|
||||||
|
table={table}
|
||||||
|
disabled={disablePagination || loading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type FeatureFilterPopoverProps = {
|
|||||||
onChange: (features: FeatureType[]) => void;
|
onChange: (features: FeatureType[]) => void;
|
||||||
align?: 'start' | 'center' | 'end';
|
align?: 'start' | 'center' | 'end';
|
||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureFilterPopover = ({
|
export const FeatureFilterPopover = ({
|
||||||
@@ -22,29 +23,37 @@ export const FeatureFilterPopover = ({
|
|||||||
onChange,
|
onChange,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
buttonLabel = 'Features',
|
buttonLabel = 'Features',
|
||||||
|
disabled = false,
|
||||||
}: FeatureFilterPopoverProps) => {
|
}: FeatureFilterPopoverProps) => {
|
||||||
const handleFeatureToggle = useCallback(
|
const handleFeatureToggle = useCallback(
|
||||||
(feature: FeatureType, checked: boolean) => {
|
(feature: FeatureType, checked: boolean) => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (checked) {
|
if (checked) {
|
||||||
onChange([...new Set([...selectedFeatures, feature])]);
|
onChange([...new Set([...selectedFeatures, feature])]);
|
||||||
} else {
|
} else {
|
||||||
onChange(selectedFeatures.filter(enabled => enabled !== feature));
|
onChange(selectedFeatures.filter(enabled => enabled !== feature));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange, selectedFeatures]
|
[disabled, onChange, selectedFeatures]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClearFeatures = useCallback(() => {
|
const handleClearFeatures = useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange([]);
|
onChange([]);
|
||||||
}, [onChange]);
|
}, [disabled, onChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover open={disabled ? false : undefined}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 px-2 lg:px-3 space-x-1"
|
className="h-8 px-2 lg:px-3 space-x-1"
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span>{buttonLabel}</span>
|
<span>{buttonLabel}</span>
|
||||||
{selectedFeatures.length > 0 ? (
|
{selectedFeatures.length > 0 ? (
|
||||||
@@ -70,6 +79,7 @@ export const FeatureFilterPopover = ({
|
|||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
handleFeatureToggle(feature, !!checked)
|
handleFeatureToggle(feature, !!checked)
|
||||||
}
|
}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm truncate">{feature}</span>
|
<span className="text-sm truncate">{feature}</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -80,7 +90,7 @@ export const FeatureFilterPopover = ({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleClearFeatures}
|
onClick={handleClearFeatures}
|
||||||
disabled={selectedFeatures.length === 0}
|
disabled={disabled || selectedFeatures.length === 0}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -27,12 +27,16 @@ interface DataTableToolbarProps<TData> {
|
|||||||
onFeaturesChange: (features: FeatureType[]) => void;
|
onFeaturesChange: (features: FeatureType[]) => void;
|
||||||
sort: AdminWorkspaceSort | undefined;
|
sort: AdminWorkspaceSort | undefined;
|
||||||
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
|
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
|
const sortOptions: { value: AdminWorkspaceSort; label: string }[] = [
|
||||||
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' },
|
{ value: AdminWorkspaceSort.SnapshotSize, label: 'Snapshot size' },
|
||||||
{ value: AdminWorkspaceSort.BlobCount, label: 'Blob count' },
|
{ value: AdminWorkspaceSort.BlobCount, label: 'Blob count' },
|
||||||
{ value: AdminWorkspaceSort.BlobSize, label: 'Blob size' },
|
{ value: AdminWorkspaceSort.BlobSize, label: 'Blob size' },
|
||||||
|
{ value: AdminWorkspaceSort.SnapshotCount, label: 'Snapshot count' },
|
||||||
|
{ value: AdminWorkspaceSort.MemberCount, label: 'Member count' },
|
||||||
|
{ value: AdminWorkspaceSort.PublicPageCount, label: 'Public pages' },
|
||||||
{ value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
|
{ value: AdminWorkspaceSort.CreatedAt, label: 'Created time' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ export function DataTableToolbar<TData>({
|
|||||||
onFeaturesChange,
|
onFeaturesChange,
|
||||||
sort,
|
sort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
|
disabled = false,
|
||||||
}: DataTableToolbarProps<TData>) {
|
}: DataTableToolbarProps<TData>) {
|
||||||
const [value, setValue] = useState(keyword);
|
const [value, setValue] = useState(keyword);
|
||||||
const debouncedValue = useDebouncedValue(value, 400);
|
const debouncedValue = useDebouncedValue(value, 400);
|
||||||
@@ -82,12 +87,18 @@ export function DataTableToolbar<TData>({
|
|||||||
availableFeatures={availableFeatures}
|
availableFeatures={availableFeatures}
|
||||||
onChange={onFeaturesChange}
|
onChange={onFeaturesChange}
|
||||||
align="start"
|
align="start"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
<div className="flex items-center gap-y-2 flex-wrap justify-end gap-2">
|
||||||
<Popover>
|
<Popover open={disabled ? false : undefined}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="h-8 px-2 lg:px-3">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 lg:px-3"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
Sort: {selectedSortLabel}
|
Sort: {selectedSortLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -99,6 +110,7 @@ export function DataTableToolbar<TData>({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
onClick={() => handleSortChange(option.value)}
|
onClick={() => handleSortChange(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -113,6 +125,7 @@ export function DataTableToolbar<TData>({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={onValueChange}
|
onChange={onValueChange}
|
||||||
className="h-8 w-[150px] lg:w-[250px]"
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface DataTableProps<TData, TValue> {
|
|||||||
onFeaturesChange: (features: FeatureType[]) => void;
|
onFeaturesChange: (features: FeatureType[]) => void;
|
||||||
sort: AdminWorkspaceSort | undefined;
|
sort: AdminWorkspaceSort | undefined;
|
||||||
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
|
onSortChange: (sort: AdminWorkspaceSort | undefined) => void;
|
||||||
|
loading?: boolean;
|
||||||
onPaginationChange: Dispatch<
|
onPaginationChange: Dispatch<
|
||||||
SetStateAction<{
|
SetStateAction<{
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
@@ -36,6 +37,7 @@ export function DataTable<TData extends { id: string }, TValue>({
|
|||||||
sort,
|
sort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onPaginationChange,
|
onPaginationChange,
|
||||||
|
loading = false,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
return (
|
return (
|
||||||
<SharedDataTable
|
<SharedDataTable
|
||||||
@@ -54,8 +56,11 @@ export function DataTable<TData extends { id: string }, TValue>({
|
|||||||
onFeaturesChange={onFeaturesChange}
|
onFeaturesChange={onFeaturesChange}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={onSortChange}
|
onSortChange={onSortChange}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
loading={loading}
|
||||||
|
disablePagination={loading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function WorkspacePage() {
|
|||||||
AdminWorkspaceSort.CreatedAt
|
AdminWorkspaceSort.CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
const { workspaces, pagination, setPagination, workspacesCount } =
|
const { workspaces, pagination, setPagination, workspacesCount, loading } =
|
||||||
useWorkspaceList({
|
useWorkspaceList({
|
||||||
keyword,
|
keyword,
|
||||||
features: featureFilters,
|
features: featureFilters,
|
||||||
@@ -38,6 +38,7 @@ export function WorkspacePage() {
|
|||||||
onFeaturesChange={setFeatureFilters}
|
onFeaturesChange={setFeatureFilters}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,30 +51,43 @@ export const useWorkspaceList = (filter?: {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: listData } = useQuery(
|
const { data: listData, isValidating: isListValidating } = useQuery(
|
||||||
{
|
{
|
||||||
query: adminWorkspacesQuery,
|
query: adminWorkspacesQuery,
|
||||||
variables,
|
variables,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: true,
|
||||||
|
revalidateOnReconnect: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: countData } = useQuery(
|
const { data: countData, isValidating: isCountValidating } = useQuery(
|
||||||
{
|
{
|
||||||
query: adminWorkspacesCountQuery,
|
query: adminWorkspacesCountQuery,
|
||||||
variables,
|
variables,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: true,
|
||||||
|
revalidateOnReconnect: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
isListValidating ||
|
||||||
|
isCountValidating ||
|
||||||
|
listData === undefined ||
|
||||||
|
countData === undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaces: listData?.adminWorkspaces ?? [],
|
workspaces: listData?.adminWorkspaces ?? [],
|
||||||
workspacesCount: countData?.adminWorkspacesCount ?? 0,
|
workspacesCount: countData?.adminWorkspacesCount ?? 0,
|
||||||
pagination,
|
pagination,
|
||||||
setPagination,
|
setPagination,
|
||||||
|
loading,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user