1
0
forked from baron/baron-sso

perf(admin): full-stack performance optimization for all list tables

- Implemented server-side search, infinite scrolling, and list virtualization for Tenants, Users, and Audit Logs.
- Backend: Enhanced Repository, Service, and Handler layers to support 'search' and 'cursor' parameters.
- Frontend: Integrated @tanstack/react-virtual and useInfiniteQuery for high-performance rendering.
- Quality: Updated all unit tests and E2E tests to match the new asynchronous server-side search architecture.
- i18n: Synced all translation keys and cleaned up unused resources.
This commit is contained in:
2026-06-04 16:06:30 +09:00
parent 6d3f128282
commit b2f155e35b
26 changed files with 1103 additions and 440 deletions

View File

@@ -53,13 +53,33 @@ test.describe("Audit Logs Management", () => {
const url = route.request().url();
const urlObj = new URL(url);
const cursor = urlObj.searchParams.get("cursor");
const search = urlObj.searchParams.get("search")?.toLowerCase();
const status = urlObj.searchParams.get("status");
const offset = cursor ? 20 : 0;
console.log(`[mock] Audit logs request: ${url} (offset: ${offset})`);
let allMockLogs = generateMockLogs(40, 0);
if (status && status !== "all") {
allMockLogs = allMockLogs.filter((l) => l.status === status);
}
if (search) {
allMockLogs = allMockLogs.filter(
(l) =>
l.user_id.toLowerCase().includes(search) ||
l.details.toLowerCase().includes(search),
);
}
const paginatedItems = allMockLogs.slice(offset, offset + 20);
console.log(
`[mock] Audit logs request: ${url} (offset: ${offset}, search: ${search}, status: ${status}, results: ${paginatedItems.length})`,
);
return route.fulfill({
json: {
items: generateMockLogs(20, offset),
next_cursor: offset === 0 ? "fake-cursor" : null,
total: 40,
items: paginatedItems,
next_cursor: allMockLogs.length > offset + 20 ? "fake-cursor" : null,
total: allMockLogs.length,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
@@ -172,7 +192,7 @@ test.describe("Audit Logs Management", () => {
await userIdInput.fill("user-even");
// Wait for deferred value to apply
await expect(page.locator("tbody tr")).toHaveCount(10, { timeout: 15000 });
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
await expect(page.locator("tbody")).not.toContainText("user-odd");
// Clear User ID
@@ -183,12 +203,13 @@ test.describe("Audit Logs Management", () => {
const actionInput = page.getByTestId("audit-search-action");
await actionInput.fill("ROTATE_SECRET");
// Check that we only see ROTATE_SECRET (20 - 7 = 13)
await expect(page.locator("tbody tr")).toHaveCount(13, { timeout: 15000 });
// Check that we see ROTATE_SECRET across all 40 logs (40 - 14 = 26)
// Wait for the mock to respond and render
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
await expect(page.locator("tbody")).not.toContainText("CREATE_TENANT");
});
test("should filter logs by Status locally", async ({ page }) => {
test("should filter logs by Status", async ({ page }) => {
await page.goto("/audit-logs");
await expect(page.locator(".animate-spin")).not.toBeVisible({
timeout: 10000,
@@ -201,12 +222,13 @@ test.describe("Audit Logs Management", () => {
// Select "Failure" status
await page.getByTestId("audit-filter-status").selectOption("failure");
// ID % 5 === 0 are status "failure" (0, 5, 10, 15)
await expect(page.locator("tbody tr")).toHaveCount(4, { timeout: 15000 });
// Total 8 failures in 40 logs
await expect(page.locator("tbody tr")).toHaveCount(8, { timeout: 15000 });
// Select "Success" status
await page.getByTestId("audit-filter-status").selectOption("success");
await expect(page.locator("tbody tr")).toHaveCount(16, { timeout: 15000 });
// Total 32 successes in 40 logs, but page limit is 20
await expect(page.locator("tbody tr")).toHaveCount(20, { timeout: 15000 });
});
});

View File

@@ -61,28 +61,42 @@ test.describe("Tenants Management", () => {
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
json: {
items: [
{
id: internalTenantId,
name: "Tenant A",
slug: "tenant-a",
status: "active",
type: "COMPANY",
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
} else {
await route.continue();
if (route.request().method() !== "GET") {
return route.continue();
}
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: internalTenantId,
name: "Tenant A",
slug: "tenant-a",
status: "active",
type: "COMPANY",
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({
json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/tenants");
@@ -115,40 +129,55 @@ test.describe("Tenants Management", () => {
return route.continue();
}
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({
json: {
items: [
{
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 3,
items: filtered,
total: filtered.length,
limit: 500,
offset: 0,
},
@@ -162,7 +191,6 @@ test.describe("Tenants Management", () => {
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
.fill("team-1");
await expect(page.locator("table")).toContainText("Platform");
await expect(page.locator("table")).toContainText("Acme");
await page
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
@@ -188,40 +216,55 @@ test.describe("Tenants Management", () => {
return route.continue();
}
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({
json: {
items: [
{
id: "company-1",
name: "Acme",
slug: "acme",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 3,
items: filtered,
total: filtered.length,
limit: 500,
offset: 0,
},
@@ -239,10 +282,14 @@ test.describe("Tenants Management", () => {
);
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
await expect(page.locator("table")).toContainText("Platform");
await page.keyboard.press("Enter");
await expect(page.locator("table")).toContainText("Platform", {
timeout: 10000,
});
await expect(page.locator("table")).not.toContainText("Acme");
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
await page.keyboard.press("Enter");
await page
.locator("tbody tr")
.filter({ hasText: "Acme" })
@@ -266,24 +313,37 @@ test.describe("Tenants Management", () => {
}
const url = new URL(route.request().url());
const cursor = url.searchParams.get("cursor");
const search = url.searchParams.get("search")?.toLowerCase();
_requestCount += 1;
const items = Array.from({ length: 501 }, (_, index) => ({
id: `tenant-${String(index + 1).padStart(3, "0")}`,
name: `Tenant ${String(index + 1).padStart(3, "0")}`,
slug: `tenant-${String(index + 1).padStart(3, "0")}`,
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
}));
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
if (!cursor) {
return route.fulfill({
json: {
items: Array.from({ length: 500 }, (_, index) => ({
id: `tenant-${String(index + 1).padStart(3, "0")}`,
name: `Tenant ${String(index + 1).padStart(3, "0")}`,
slug: `tenant-${String(index + 1).padStart(3, "0")}`,
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
})),
total: 501,
items: filtered.slice(0, 500),
total: filtered.length,
limit: 500,
offset: 0,
nextCursor: "next-page",
nextCursor: filtered.length > 500 ? "next-page" : undefined,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
@@ -291,18 +351,8 @@ test.describe("Tenants Management", () => {
return route.fulfill({
json: {
items: [
{
id: "tenant-501",
name: "Tenant 501",
slug: "tenant-501",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 501,
items: filtered.slice(500),
total: filtered.length,
limit: 500,
offset: 0,
},
@@ -322,9 +372,10 @@ test.describe("Tenants Management", () => {
// Virtualization and infinite scroll are removed in the tree view.
// The query fetches based on pageParam, but without a scroller, it just fetches the first page or relies on other mechanisms.
// In this test, we just check if it renders the first page of 500 items properly.
// With virtualization, only a few items are rendered
await expect
.poll(async () => page.locator("tbody tr").count())
.toEqual(500);
.toBeLessThan(50);
// Skip the scroll to load more check because the infinite scroll handler was removed
// expect(requestCount).toBe(2);
@@ -372,54 +423,68 @@ test.describe("Tenants Management", () => {
return;
}
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
status: "active",
type: "COMPANY",
parentId: "hanmac-family-id",
memberCount: 0,
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
status: "active",
type: "USER_GROUP",
parentId: "hanmac-company-id",
memberCount: 0,
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
},
{
id: "external-team-id",
slug: "external-team",
name: "External Team",
status: "active",
type: "USER_GROUP",
parentId: "external-tenant-id",
memberCount: 0,
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({
json: {
items: [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
status: "active",
type: "COMPANY",
parentId: "hanmac-family-id",
memberCount: 0,
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
status: "active",
type: "USER_GROUP",
parentId: "hanmac-company-id",
memberCount: 0,
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
},
{
id: "external-team-id",
slug: "external-team",
name: "External Team",
status: "active",
type: "USER_GROUP",
parentId: "external-tenant-id",
memberCount: 0,
},
],
total: 5,
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
@@ -493,9 +558,30 @@ test.describe("Tenants Management", () => {
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() !== "GET") {
return route.continue();
}
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const headers = { "Access-Control-Allow-Origin": "*" };
let filtered = tenants;
if (search) {
filtered = tenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers,
});
});
@@ -569,12 +655,23 @@ test.describe("Tenants Management", () => {
await page.route("**/api/v1/admin/tenants**", async (route) => {
const method = route.request().method();
const url = new URL(route.request().url());
const search = url.searchParams.get("search")?.toLowerCase();
const headers = { "Access-Control-Allow-Origin": "*" };
if (method === "GET") {
let filtered = tenants;
if (search) {
filtered = tenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({
json: {
items: tenants,
total: tenants.length,
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
@@ -705,21 +802,33 @@ test.describe("Tenants Management", () => {
}
if (method === "GET") {
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({
json: {
items: [
{
id: "tenant-alpha-id",
name: "Tenant Alpha",
slug: "tenant-alpha",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
@@ -846,21 +955,33 @@ test.describe("Tenants Management", () => {
}
if (method === "GET") {
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
const items = [
{
id: "staging-existing-id",
name: "Existing Parent",
slug: "parent-local",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({
json: {
items: [
{
id: "staging-existing-id",
name: "Existing Parent",
slug: "parent-local",
status: "active",
type: "COMPANY",
domains: [],
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 1,
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
@@ -979,8 +1100,24 @@ test.describe("Tenants Management", () => {
headers: { "Access-Control-Allow-Origin": "*" },
});
} else {
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
let filtered = mockTenants;
if (search) {
filtered = mockTenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
await route.fulfill({
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
@@ -1051,8 +1188,24 @@ test.describe("Tenants Management", () => {
if (url.includes(`/admin/tenants/${parentId}`)) {
return route.fulfill({ json: mockTenants[0], headers });
}
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
let filtered = mockTenants;
if (search) {
filtered = mockTenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers,
});
});
@@ -1093,8 +1246,25 @@ test.describe("Tenants Management", () => {
if (url.includes(`/admin/tenants/${tenantUuid}`)) {
return route.fulfill({ json: tenant, headers });
}
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
const items = [tenant];
let filtered = items;
if (search) {
filtered = items.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({
json: { items: [tenant], total: 1, limit: 1000, offset: 0 },
json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers,
});
});
@@ -1152,8 +1322,24 @@ test.describe("Tenants Management", () => {
if (url.includes("/admin/tenants/team-1")) {
return route.fulfill({ json: tenants[2], headers });
}
const urlObj = new URL(url);
const search = urlObj.searchParams.get("search")?.toLowerCase();
let filtered = tenants;
if (search) {
filtered = tenants.filter(
(i) =>
i.name.toLowerCase().includes(search) ||
i.slug.toLowerCase().includes(search) ||
i.id.toLowerCase().includes(search),
);
}
return route.fulfill({
json: { items: tenants, total: tenants.length, limit: 1000, offset: 0 },
json: {
items: filtered,
total: filtered.length,
limit: 1000,
offset: 0,
},
headers,
});
});