feat: consolidate database management work
Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow. Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page, type Route } from "@playwright/test";
|
||||
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
@@ -12,6 +12,13 @@ const database = {
|
||||
workspaceId: "psd-clinical",
|
||||
workspaceName: "Policlinico San Donato",
|
||||
workspaceAvailable: true,
|
||||
workspaceRevision: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
||||
workspaceEvidence: { sourceType: "filesystem", state: "materialized_current_revision" },
|
||||
runtimeBinding: {
|
||||
transport: "postgres_direct",
|
||||
configurationState: "ready",
|
||||
sessionTransportSupported: true,
|
||||
},
|
||||
configured: true,
|
||||
engine: "postgres",
|
||||
databaseName: "warehouse",
|
||||
@@ -37,6 +44,22 @@ const database = {
|
||||
},
|
||||
};
|
||||
|
||||
const unconfiguredDatabase = {
|
||||
...database,
|
||||
id: undefined,
|
||||
workspaceId: "research-lab",
|
||||
workspaceName: "Research laboratory",
|
||||
configured: false,
|
||||
databaseName: "research",
|
||||
schema: "analytics",
|
||||
version: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
binding: { transport: "postgres_direct", port: 5432 },
|
||||
connectionStatus: "untested",
|
||||
testedVersion: undefined,
|
||||
};
|
||||
|
||||
const table = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
databaseId,
|
||||
@@ -118,7 +141,112 @@ async function expectContextPanelGeometry(page: Page, accessibleName: string) {
|
||||
expect(panelBox.y + panelBox.height).toBeLessThanOrEqual(managerBox.y + managerBox.height + 1);
|
||||
}
|
||||
|
||||
async function expectContextPanelHeaderMatchesNewSession(
|
||||
async function expectCurrentNavigation(page: Page, accessibleName: string) {
|
||||
const navigation = page.getByRole("complementary", { name: "Session navigation" });
|
||||
const button = navigation.getByRole("button", { name: accessibleName, exact: true });
|
||||
const available = navigation.locator('[data-navigation-state="available"]').first();
|
||||
await expect(navigation.locator('[data-navigation-state="current"]')).toHaveCount(1);
|
||||
await expect(button).toHaveAttribute("data-navigation-state", "current");
|
||||
await expect(button).toHaveAttribute("aria-current", "page");
|
||||
expect(await button.getAttribute("class")).toContain("bg-[oklch(var(--nav-active))]");
|
||||
|
||||
const [style, availableStyle] = await Promise.all([
|
||||
button.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
borderColor: getComputedStyle(element).borderTopColor,
|
||||
})),
|
||||
available.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
borderColor: getComputedStyle(element).borderTopColor,
|
||||
})),
|
||||
]);
|
||||
expect(style.backgroundColor).not.toBe(availableStyle.backgroundColor);
|
||||
expect(style.borderColor).not.toBe(availableStyle.borderColor);
|
||||
|
||||
const tokens = await page.evaluate(() => {
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const read = (name: string) => root.getPropertyValue(name).trim().split(/\s+/).map(Number);
|
||||
return { primary: read("--primary"), active: read("--nav-active") };
|
||||
});
|
||||
expect(tokens.active[2]).toBeCloseTo(tokens.primary[2], 1);
|
||||
expect(tokens.active[1]).toBeLessThan(tokens.primary[1]);
|
||||
expect(tokens.active[0]).toBeGreaterThan(tokens.primary[0]);
|
||||
}
|
||||
|
||||
async function expectSelectedSessionScopeTab(page: Page, accessibleName: string) {
|
||||
const tablist = page.getByRole("tablist", { name: "Session scope" });
|
||||
const selected = tablist.getByRole("tab", { name: accessibleName, exact: true });
|
||||
const inactive = tablist.locator('[role="tab"][aria-selected="false"]');
|
||||
await expect(tablist.locator('[role="tab"][aria-selected="true"]')).toHaveCount(1);
|
||||
await expect(selected).toHaveAttribute("data-tab-state", "active");
|
||||
expect(await selected.getAttribute("class")).toContain("bg-[oklch(var(--nav-active))]");
|
||||
|
||||
const [selectedStyle, inactiveStyle] = await Promise.all([
|
||||
selected.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
borderColor: getComputedStyle(element).borderBottomColor,
|
||||
})),
|
||||
inactive.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
borderColor: getComputedStyle(element).borderBottomColor,
|
||||
borderWidth: getComputedStyle(element).borderTopWidth,
|
||||
})),
|
||||
]);
|
||||
expect(selectedStyle.backgroundColor).not.toBe(inactiveStyle.backgroundColor);
|
||||
expect(selectedStyle.borderColor).not.toBe(inactiveStyle.borderColor);
|
||||
expect(inactiveStyle.borderWidth).toBe("1px");
|
||||
expect(inactiveStyle.backgroundColor).not.toMatch(/^(?:transparent|rgba\(0, 0, 0, 0\))$/);
|
||||
expect(inactiveStyle.borderColor).not.toMatch(/^(?:transparent|rgba\(0, 0, 0, 0\))$/);
|
||||
}
|
||||
|
||||
function cssLightness(color: string): number {
|
||||
const oklab = color.match(/^okl(?:ab|ch)\(([\d.]+)(%)?/);
|
||||
if (oklab) {
|
||||
const value = Number(oklab[1]);
|
||||
return oklab[2] ? value / 100 : value;
|
||||
}
|
||||
const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [];
|
||||
if (channels.length !== 3) return Number.NaN;
|
||||
return color.startsWith("color(srgb") ? Math.max(...channels) : Math.max(...channels) / 255;
|
||||
}
|
||||
|
||||
async function expectFleetRowTooltipBelow(trigger: Locator, expectedContent: string) {
|
||||
await trigger.hover();
|
||||
await expect.poll(() => trigger.evaluate((element) => {
|
||||
const owner = element.closest<HTMLElement>("[data-tooltip]");
|
||||
return owner ? getComputedStyle(owner, "::after").visibility : "missing";
|
||||
})).toBe("visible");
|
||||
await expect.poll(() => trigger.evaluate((element) => {
|
||||
const owner = element.closest<HTMLElement>("[data-tooltip]");
|
||||
return owner ? getComputedStyle(owner, "::after").opacity : "missing";
|
||||
})).toBe("1");
|
||||
|
||||
const state = await trigger.evaluate((element) => {
|
||||
const owner = element.closest<HTMLElement>("[data-tooltip]");
|
||||
if (!owner) throw new Error("Row action tooltip owner is missing");
|
||||
const style = getComputedStyle(owner, "::after");
|
||||
return {
|
||||
content: style.content.replace(/^['\"]|['\"]$/g, ""),
|
||||
ownerHeight: owner.getBoundingClientRect().height,
|
||||
top: Number.parseFloat(style.top),
|
||||
right: Number.parseFloat(style.right),
|
||||
opacity: style.opacity,
|
||||
pointerEvents: style.pointerEvents,
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
};
|
||||
});
|
||||
|
||||
expect(state.content).toBe(expectedContent);
|
||||
expect(state.top - state.ownerHeight).toBeCloseTo(3, 1);
|
||||
expect(state.right).toBe(0);
|
||||
expect(state.opacity).toBe("1");
|
||||
expect(state.pointerEvents).toBe("none");
|
||||
expect(cssLightness(state.backgroundColor)).toBeLessThan(0.35);
|
||||
expect(cssLightness(state.color)).toBeGreaterThan(0.9);
|
||||
}
|
||||
|
||||
async function expectContextPanelHeaderUsesPrimary(
|
||||
page: Page,
|
||||
accessibleName: string,
|
||||
) {
|
||||
@@ -127,26 +255,22 @@ async function expectContextPanelHeaderMatchesNewSession(
|
||||
.getByRole("main", { name: "Database management" });
|
||||
const panel = manager.getByRole("dialog", { name: accessibleName });
|
||||
const header = panel.locator(':scope > [data-catalog-panel-region="header"]');
|
||||
const newSession = page
|
||||
.getByRole("complementary", { name: "Session navigation" })
|
||||
.getByRole("button", { name: "New session", exact: true });
|
||||
|
||||
await expect(header).toHaveCount(1);
|
||||
await expect(newSession).toBeVisible();
|
||||
|
||||
const [headerStyle, actionStyle] = await Promise.all([
|
||||
const currentNavigation = page.locator('[data-navigation-state="current"]');
|
||||
const [headerStyle, navigationStyle] = await Promise.all([
|
||||
header.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||
})),
|
||||
newSession.evaluate((element) => ({
|
||||
currentNavigation.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
})),
|
||||
]);
|
||||
|
||||
expect(headerStyle.backgroundImage).toBe("none");
|
||||
expect(headerStyle.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(headerStyle.backgroundColor).toBe(actionStyle.backgroundColor);
|
||||
expect(headerStyle.backgroundColor).not.toBe(navigationStyle.backgroundColor);
|
||||
}
|
||||
|
||||
async function expectWorkAreaPanelGeometry(page: Page, accessibleName: string) {
|
||||
@@ -181,7 +305,7 @@ async function expectWorkAreaPanelGeometry(page: Page, accessibleName: string) {
|
||||
expect(panelBox.y + panelBox.height).toBeLessThanOrEqual(workAreaBox.y + workAreaBox.height + 1);
|
||||
}
|
||||
|
||||
async function expectWorkAreaPanelHeaderMatchesNewSession(
|
||||
async function expectWorkAreaPanelHeaderUsesPrimary(
|
||||
page: Page,
|
||||
accessibleName: string,
|
||||
) {
|
||||
@@ -189,21 +313,19 @@ async function expectWorkAreaPanelHeaderMatchesNewSession(
|
||||
.getByTestId("conversation-column")
|
||||
.getByRole("dialog", { name: accessibleName });
|
||||
const header = panel.locator(':scope > [data-work-area-panel-region="header"]');
|
||||
const newSession = page
|
||||
.getByRole("complementary", { name: "Session navigation" })
|
||||
.getByRole("button", { name: "New session", exact: true });
|
||||
const [headerStyle, actionStyle] = await Promise.all([
|
||||
const currentNavigation = page.locator('[data-navigation-state="current"]');
|
||||
const [headerStyle, navigationStyle] = await Promise.all([
|
||||
header.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
backgroundImage: getComputedStyle(element).backgroundImage,
|
||||
})),
|
||||
newSession.evaluate((element) => ({
|
||||
currentNavigation.evaluate((element) => ({
|
||||
backgroundColor: getComputedStyle(element).backgroundColor,
|
||||
})),
|
||||
]);
|
||||
|
||||
expect(headerStyle.backgroundImage).toBe("none");
|
||||
expect(headerStyle.backgroundColor).toBe(actionStyle.backgroundColor);
|
||||
expect(headerStyle.backgroundColor).not.toBe(navigationStyle.backgroundColor);
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
@@ -218,10 +340,14 @@ test.afterAll(async () => {
|
||||
test("context panels stay inside the manager and the Tables grid sits in a sidebar-colored frame", async ({ page }) => {
|
||||
const unexpectedCatalogRequests: string[] = [];
|
||||
const responses: Record<string, unknown> = {
|
||||
"GET /api/catalog/databases": [database],
|
||||
"GET /api/catalog/databases": [database, unconfiguredDatabase],
|
||||
"GET /api/catalog/metadata-generation/models": {
|
||||
models: [],
|
||||
default: null,
|
||||
models: [
|
||||
{ id: "deepseek-v4-pro", label: "DeepSeek V4 Pro" },
|
||||
{ id: "glm-53", label: "GLM 5.3" },
|
||||
{ id: "qwen-36", label: "AritmoLab Qwen 3.6 35B A3B" },
|
||||
],
|
||||
default: "glm-53",
|
||||
},
|
||||
"GET /api/catalog/description-generation-runs?limit=50": [],
|
||||
"GET /api/catalog/sensitive-data-suggestion-runs?limit=50": [],
|
||||
@@ -252,10 +378,40 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
||||
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInAsAdmin(page);
|
||||
await expectCurrentNavigation(page, "New session");
|
||||
await expectSelectedSessionScopeTab(page, "My sessions");
|
||||
await page.getByRole("tab", { name: "All sessions", exact: true }).click();
|
||||
await expectSelectedSessionScopeTab(page, "All sessions");
|
||||
await page.getByRole("tab", { name: "My sessions", exact: true }).click();
|
||||
await expectSelectedSessionScopeTab(page, "My sessions");
|
||||
|
||||
await page
|
||||
const adminNavigationRail = page.getByRole("complementary", { name: "Session navigation" });
|
||||
const administration = adminNavigationRail.getByRole("button", { name: "Administration", exact: true });
|
||||
await expect(administration).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(adminNavigationRail.getByRole("region", { name: "Administration" })).toHaveCount(0);
|
||||
await administration.click();
|
||||
await expect(administration).toHaveAttribute("aria-expanded", "true");
|
||||
const administrationPanel = adminNavigationRail.getByRole("region", { name: "Administration" });
|
||||
await expect(administrationPanel).toBeVisible();
|
||||
expect(await administrationPanel.locator(":scope > *").evaluateAll((elements) => elements.map((element) => (
|
||||
element.getAttribute("role") === "separator" ? "separator" : element.textContent?.trim()
|
||||
)))).toEqual([
|
||||
"Database management",
|
||||
"separator",
|
||||
"Workspace management",
|
||||
"Pi management",
|
||||
]);
|
||||
|
||||
await administration.click();
|
||||
await expect(administration).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(adminNavigationRail.getByRole("button", { name: "Database management", exact: true })).toHaveCount(0);
|
||||
await administration.click();
|
||||
await expect(administration).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await adminNavigationRail
|
||||
.getByRole("button", { name: "Database management", exact: true })
|
||||
.click();
|
||||
await expectCurrentNavigation(page, "Database management");
|
||||
|
||||
await expect(
|
||||
page.getByRole("complementary", { name: "Session navigation" }),
|
||||
@@ -268,27 +424,41 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Back to workspace", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Add database/i })).toHaveCount(0);
|
||||
await expect(page.getByRole("columnheader", { name: /Revision \/ Evidence/ })).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: /NL→SQL runtime/ })).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: /Metadata Catalog/ })).toBeVisible();
|
||||
const metadataModelSelector = page.getByRole("combobox", {
|
||||
name: "Metadata-generation LLM model",
|
||||
});
|
||||
await expect(metadataModelSelector).toHaveCount(1);
|
||||
await expect(metadataModelSelector).toHaveValue("glm-53");
|
||||
await expect(metadataModelSelector.locator("option")).toHaveText([
|
||||
"DeepSeek V4 Pro",
|
||||
"GLM 5.3",
|
||||
"AritmoLab Qwen 3.6 35B A3B",
|
||||
]);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Configure catalog for Research laboratory", exact: true })
|
||||
.click();
|
||||
const configurePanel = page.getByRole("dialog", { name: "Configure catalog" });
|
||||
await expectContextPanelGeometry(page, "Configure catalog");
|
||||
const configuredWorkspace = configurePanel.getByLabel("Workspace", { exact: true });
|
||||
await expect(configuredWorkspace).toHaveValue("Research laboratory");
|
||||
await expect(configuredWorkspace).toHaveAttribute("readonly", "");
|
||||
await configurePanel.getByRole("button", { name: "Close database panel" }).click();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Edit Policlinico San Donato", exact: true })
|
||||
.click();
|
||||
await expectContextPanelGeometry(page, "Edit database");
|
||||
await expectContextPanelHeaderMatchesNewSession(page, "Edit database");
|
||||
await expectContextPanelHeaderUsesPrimary(page, "Edit database");
|
||||
await page
|
||||
.getByRole("dialog", { name: "Edit database" })
|
||||
.getByRole("button", { name: "Close database panel" })
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Description history", exact: true })
|
||||
.click();
|
||||
await expectContextPanelGeometry(page, "Description generation");
|
||||
await expectContextPanelHeaderMatchesNewSession(page, "Description generation");
|
||||
await page
|
||||
.getByRole("dialog", { name: "Description generation" })
|
||||
.getByRole("button", { name: /close/i })
|
||||
.click();
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page
|
||||
.getByRole("button", { name: "Edit Policlinico San Donato", exact: true })
|
||||
@@ -300,12 +470,12 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
||||
.click();
|
||||
await page.setViewportSize({ width: 1910, height: 911 });
|
||||
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: "View tables for Policlinico San Donato",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const databaseTablesAction = page.getByRole("button", {
|
||||
name: "View tables for Policlinico San Donato",
|
||||
exact: true,
|
||||
});
|
||||
await expectFleetRowTooltipBelow(databaseTablesAction, "Tables");
|
||||
await databaseTablesAction.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("region", {
|
||||
@@ -313,6 +483,16 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Description history", exact: true })
|
||||
.click();
|
||||
await expectContextPanelGeometry(page, "Description generation");
|
||||
await expectContextPanelHeaderUsesPrimary(page, "Description generation");
|
||||
await page
|
||||
.getByRole("dialog", { name: "Description generation" })
|
||||
.getByRole("button", { name: /close/i })
|
||||
.click();
|
||||
|
||||
const applicationBar = page.locator(
|
||||
'main[aria-label="Database management"] .thot-fleet-ledger__header',
|
||||
);
|
||||
@@ -326,6 +506,10 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
||||
await expect(
|
||||
applicationBar.getByRole("group", { name: "Database management actions" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
applicationBar.getByRole("combobox", { name: "Metadata-generation LLM model" }),
|
||||
).toHaveValue("glm-53");
|
||||
await expect(metadataModelSelector).toHaveCount(1);
|
||||
await expect(applicationBar).not.toContainText("Thoth catalog · Fleet ledger");
|
||||
await expect(applicationBar).not.toContainText("Inspect physical metadata");
|
||||
|
||||
@@ -355,11 +539,14 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
||||
|
||||
await expect(page.getByRole("row", { name: /patients/ })).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Edit description for patients", exact: true })
|
||||
.click();
|
||||
const editPatientsAction = page.getByRole("button", {
|
||||
name: "Edit description for patients",
|
||||
exact: true,
|
||||
});
|
||||
await expectFleetRowTooltipBelow(editPatientsAction, "Edit metadata");
|
||||
await editPatientsAction.click();
|
||||
await expectContextPanelGeometry(page, "Review table description");
|
||||
await expectContextPanelHeaderMatchesNewSession(page, "Review table description");
|
||||
await expectContextPanelHeaderUsesPrimary(page, "Review table description");
|
||||
const tableEditor = page.getByRole("dialog", { name: "Review table description" });
|
||||
const tableEditorFooter = tableEditor.locator(
|
||||
':scope > [data-catalog-panel-region="footer"]',
|
||||
@@ -500,6 +687,10 @@ test("Workspace and Pi management share the centered work-area panel without cov
|
||||
|
||||
await page.goto(stack.publicUrl);
|
||||
await signInAsAdmin(page);
|
||||
const administration = page.getByRole("button", { name: "Administration", exact: true });
|
||||
await expect(administration).toHaveAttribute("aria-expanded", "false");
|
||||
await administration.click();
|
||||
await expect(administration).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1910, height: 911 },
|
||||
@@ -511,25 +702,29 @@ test("Workspace and Pi management share the centered work-area panel without cov
|
||||
exact: true,
|
||||
});
|
||||
await workspaceTrigger.click();
|
||||
await expectCurrentNavigation(page, "Workspace management");
|
||||
await expectWorkAreaPanelGeometry(page, "Workspace management");
|
||||
await expectWorkAreaPanelHeaderMatchesNewSession(page, "Workspace management");
|
||||
await expectWorkAreaPanelHeaderUsesPrimary(page, "Workspace management");
|
||||
|
||||
const piTrigger = page.getByRole("button", { name: "Pi management", exact: true });
|
||||
await piTrigger.click();
|
||||
await expectCurrentNavigation(page, "Pi management");
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Workspace management" }),
|
||||
).toHaveCount(0);
|
||||
await expectWorkAreaPanelGeometry(page, "Pi management");
|
||||
await expectWorkAreaPanelHeaderMatchesNewSession(page, "Pi management");
|
||||
await expectWorkAreaPanelHeaderUsesPrimary(page, "Pi management");
|
||||
await page
|
||||
.getByRole("dialog", { name: "Pi management" })
|
||||
.getByRole("button", { name: "Close Pi management" })
|
||||
.click();
|
||||
await expect(piTrigger).toBeFocused();
|
||||
await expectCurrentNavigation(page, "New session");
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 720, height: 800 });
|
||||
await page.getByRole("button", { name: "Workspace management", exact: true }).click();
|
||||
await expectCurrentNavigation(page, "Workspace management");
|
||||
const compactWorkArea = page.getByTestId("conversation-column");
|
||||
const compactPanel = compactWorkArea.getByRole("dialog", { name: "Workspace management" });
|
||||
const [compactWorkAreaBox, compactPanelBox] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user