Skip to content
This repository was archived by the owner on Mar 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/code-freeze.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
# In EST (UTC-5), Midnight entering Saturday, Feb 14 is 05:00:00 UTC.

# Target: Feb 24, 2026 at 05:00:00 UTC
DEADLINE=$(date -d "2026-02-24 05:00:00 UTC" +%s)
DEADLINE=$(date -d "2026-03-20 05:00:00 UTC" +%s)
CURRENT=$(date +%s)

if [ $CURRENT -gt $DEADLINE ]; then
Expand Down
299 changes: 299 additions & 0 deletions frontend/e2e/pages/dashboard/metrics-dashboard.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import { expect } from "@playwright/test";
import { BasePage } from "../base.page";
import { expectPath } from "../../utils/wait";

/**
* Page Object for the MetricsDashboard component rendered at /dashboard/systems.
* Wraps all assertions used by the UC-50 Cluster Health E2E suite.
*/
export class MetricsDashboardPage extends BasePage {
// ── Navigation ────────────────────────────────────────────────────────────

async open() {
await this.goto("/dashboard/systems");
}

// ── Load assertions ───────────────────────────────────────────────────────

/** Confirms the page container is present (data-testid="systems-page"). */
async expectLoaded() {
await expectPath(this.page, /\/dashboard\/systems$/, 30_000);
await expect(this.page.getByTestId("systems-page")).toBeVisible({ timeout: 30_000 });
}

/** Confirms the happy-path "ready" state: no error banner visible. */
async expectReady() {
await expect(this.page.getByTestId("systems-error")).not.toBeVisible({ timeout: 15_000 });
Comment on lines +24 to +26
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expectReady() only asserts that the error banner is not visible, but systems-error is also absent during the initial loading state (before the first metrics fetch resolves). That means callers can treat the page as "ready" even though it’s still rendering skeletons. Consider renaming this to reflect what it checks (e.g., expectNoErrorBanner) and adding a separate readiness assertion that waits for the metrics request to complete and/or for at least one known piece of loaded content to appear.

Suggested change
/** Confirms the happy-path "ready" state: no error banner visible. */
async expectReady() {
await expect(this.page.getByTestId("systems-error")).not.toBeVisible({ timeout: 15_000 });
/** Confirms no error banner is visible. */
async expectNoErrorBanner() {
await expect(this.errorBanner()).not.toBeVisible({ timeout: 15_000 });
}
/**
* Confirms the happy-path "ready" state:
* - no error banner visible
* - expected metric cards rendered (metrics fetch completed)
*/
async expectReady() {
await this.expectNoErrorBanner();
await this.expectMetricCardsPresent();

Copilot uses AI. Check for mistakes.
}

// ── Error state ───────────────────────────────────────────────────────────

/** Returns the error banner element. */
errorBanner() {
return this.page.getByTestId("systems-error");
}

/** Confirms the inline red error alert is visible with the exact FDUC text. */
async expectErrorAlert(
text = "Unable to retrieve cluster metrics. Please verify the observability service is running and accessible.",
) {
await expect(this.errorBanner()).toBeVisible({ timeout: 15_000 });
await expect(this.errorBanner()).toContainText(text);
}

// ── MetricCard helpers ────────────────────────────────────────────────────

/**
* Returns all metric-card elements.
* MetricCard has no individual data-testid so we locate them by their
* shared CSS classes rendered inside data-testid="systems-page".
*/
metricCards() {
return this.page
.getByTestId("systems-page")
.locator(".rounded-xl.border.border-white\\/6.bg-white\\/3");
Comment on lines +48 to +54
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metricCards() locates cards via Tailwind class strings. This is brittle (style refactors break tests) and it matches both skeleton and loaded MetricCard markup, so expectMetricCardsPresent() can pass before data has rendered. Prefer a more stable selector (e.g., add a data-testid on MetricCard/summary grid or assert on stable card titles from the mock response).

Suggested change
* MetricCard has no individual data-testid so we locate them by their
* shared CSS classes rendered inside data-testid="systems-page".
*/
metricCards() {
return this.page
.getByTestId("systems-page")
.locator(".rounded-xl.border.border-white\\/6.bg-white\\/3");
* MetricCard elements are located via a dedicated data-testid to avoid
* brittle Tailwind class selectors and to distinguish loaded cards from
* any skeleton placeholders.
*
* NOTE: Each rendered MetricCard in the app should expose
* `data-testid="systems-metric-card"`.
*/
metricCards() {
return this.page.getByTestId("systems-metric-card");

Copilot uses AI. Check for mistakes.
}

/** Asserts exactly `count` metric-card elements exist. */
async expectMetricCardsPresent(count = 4) {
await expect(this.metricCards()).toHaveCount(count, { timeout: 15_000 });
}

// ── Time-range selector ───────────────────────────────────────────────────

timeRangeSelect() {
return this.page.locator("select");
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeRangeSelect() targets the first matching select on the page. This page already contains other selects when dropdown menus are opened (e.g., Export Metrics), so this locator is prone to strict-mode failures if UI structure changes. Scope the locator under data-testid="systems-page" and/or select by accessible name/label to keep it stable.

Suggested change
return this.page.locator("select");
return this.page.getByTestId("systems-page").locator("select");

Copilot uses AI. Check for mistakes.
}

async selectTimeRange(value: string) {
await this.timeRangeSelect().selectOption(value);
}

// ── NodesList ─────────────────────────────────────────────────────────────

nodesSection() {
return this.page.getByText("Node Metrics").first();
}

nodeCards() {
return this.page.locator(
".nodes-scroll-container .rounded-lg.border.border-neutral-700.bg-neutral-800",
);
}

async expectNodesListPresent() {
await expect(this.nodesSection()).toBeVisible({ timeout: 15_000 });
}

async expectAtLeastOneNodeRow() {
await expect(this.nodeCards().first()).toBeVisible({ timeout: 15_000 });
}

// ── Refresh button ────────────────────────────────────────────────────────

refreshButton() {
return this.page.getByRole("button", { name: /refresh/i });
}

async clickRefresh() {
await this.refreshButton().click();
}

// ── API route mocks ───────────────────────────────────────────────────────

/** Stubs GET /api/v1/systems/metrics with a full happy-path response. */
async mockSuccessResponse(overrides: Record<string, unknown> = {}) {
await this.page.route("**/api/v1/systems/metrics**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
overview: {
totalNodes: 3,
runningNodes: 3,
totalPods: 12,
totalNamespaces: 4,
cpuUsagePercent: 42.5,
memoryUsagePercent: 67.1,
},
requestsMetric: {
title: "Requests/s",
value: "1.2k",
rawValue: 1200,
changePercent: "+5.0%",
changeLabel: "vs last window",
status: "good",
sparkline: [],
},
podsMetric: {
title: "Running Pods",
value: "12",
rawValue: 12,
changePercent: "+0.0%",
changeLabel: "stable",
status: "excellent",
sparkline: [],
},
nodesMetric: {
title: "Nodes",
value: "3/3",
rawValue: 3,
changePercent: "+0.0%",
changeLabel: "all healthy",
status: "excellent",
sparkline: [],
},
tenantsMetric: {
title: "Tenants",
value: "4",
rawValue: 4,
changePercent: "+0.0%",
changeLabel: "namespaces",
status: "good",
sparkline: [],
},
cpuUtilization: { currentValue: 42.5, changePercent: 2.1, trend: "up", history: [] },
memoryUtilization: {
currentValue: 67.1,
changePercent: -0.5,
trend: "stable",
history: [],
},
nodes: [
{
name: "node-1",
cpuUsagePercent: 35.0,
memoryUsagePercent: 60.0,
Comment on lines +162 to +166
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The happy-path mock response reports overview.totalNodes: 3 / runningNodes: 3 and nodesMetric.value: "3/3", but the nodes array only contains 2 entries. This inconsistency can make UI behavior or future assertions confusing if counts are cross-checked. Adjust the mock so the overview/node-card counts match the nodes list.

Copilot uses AI. Check for mistakes.
podCount: 6,
status: "Ready",
},
{
name: "node-2",
cpuUsagePercent: 50.0,
memoryUsagePercent: 74.2,
podCount: 6,
status: "Ready",
},
],
namespaces: [
{ name: "default", podCount: 4, cpuUsage: 20.0, memoryUsage: 30.0 },
{ name: "kube-system", podCount: 8, cpuUsage: 22.5, memoryUsage: 37.1 },
],
databaseIOMetrics: {
diskReadBytesPerSec: 0,
diskWriteBytesPerSec: 0,
diskReadOpsPerSec: 0,
diskWriteOpsPerSec: 0,
networkReceiveBytesPerSec: 0,
networkTransmitBytesPerSec: 0,
networkReceiveOpsPerSec: 0,
networkTransmitOpsPerSec: 0,
diskReadHistory: [],
diskWriteHistory: [],
networkReceiveHistory: [],
networkTransmitHistory: [],
source: "mock",
},
uptimeMetrics: { services: [] },
systemUptime: 86400,
systemUptimeFormatted: "1d 0h 0m",
...overrides,
}),
});
});
}

/** Stubs GET /api/v1/systems/metrics with a 500 response (Exception path). */
async mockServerError() {
await this.page.route("**/api/v1/systems/metrics**", async (route) => {
await route.fulfill({ status: 500, body: "Internal Server Error" });
});
}

/** Stubs GET /api/v1/systems/metrics with a network-level abort (Exception path). */
async mockNetworkError() {
await this.page.route("**/api/v1/systems/metrics**", async (route) => {
await route.abort("failed");
});
}

/**
* Stubs GET /api/v1/systems/metrics with a partial/null-field response.
* Simulates Extension 6a (Prometheus timeout) and Extension 10a (partial data).
*/
async mockPartialDataResponse() {
await this.page.route("**/api/v1/systems/metrics**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
overview: {
totalNodes: 2,
runningNodes: 1,
totalPods: 4,
totalNamespaces: 2,
cpuUsagePercent: null, // null field – Extension 10a
memoryUsagePercent: null, // null field – Extension 10a
},
requestsMetric: null, // null card – Extension 6a (query timed out)
podsMetric: {
title: "Running Pods",
value: "4",
rawValue: 4,
changePercent: "+0.0%",
changeLabel: "available",
status: "good",
sparkline: [],
},
nodesMetric: {
title: "Nodes",
value: "1/2",
rawValue: 1,
changePercent: "-50.0%",
changeLabel: "one notready",
status: "warning",
sparkline: [],
},
tenantsMetric: null, // null card – Extension 10a
cpuUtilization: null,
memoryUtilization: null,
nodes: [
{
name: "node-1",
cpuUsagePercent: 0,
memoryUsagePercent: 0,
podCount: 4,
status: "Ready",
},
{
name: "node-2",
cpuUsagePercent: 0,
memoryUsagePercent: 0,
podCount: 0,
status: "NotReady",
},
],
namespaces: [],
databaseIOMetrics: {
diskReadBytesPerSec: 0,
diskWriteBytesPerSec: 0,
diskReadOpsPerSec: 0,
diskWriteOpsPerSec: 0,
networkReceiveBytesPerSec: 0,
networkTransmitBytesPerSec: 0,
networkReceiveOpsPerSec: 0,
networkTransmitOpsPerSec: 0,
diskReadHistory: [],
diskWriteHistory: [],
networkReceiveHistory: [],
networkTransmitHistory: [],
source: "mock-partial",
},
uptimeMetrics: { services: [] },
systemUptime: 0,
systemUptimeFormatted: "0d 0h 0m",
}),
});
});
}
}
Loading
Loading