Close first-time user operator journey remediation
Promotions: replace empty-state stub with operator landing surface showing pipeline stages, prerequisites, and onboarding guidance. Operations: unify naming across sidebar, breadcrumb, title, and H1 from "Platform Ops" to "Operations". Playwright: add promotions and operations landing journey checks to the retained first-time-user remediation and aggregate audit suites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -277,6 +277,43 @@ async function main() {
|
||||
};
|
||||
}));
|
||||
|
||||
// ── P0 surface checks: promotions and operations ───────────────────
|
||||
results.push(await runCheck(page, 'promotions-landing-surface', '/releases/promotions', async () => {
|
||||
const heading = await headingText(page);
|
||||
const text = await bodyText(page);
|
||||
const links = await hrefs(page);
|
||||
const hasCreateAction = links.some((href) => href.includes('create'));
|
||||
const hasMeaningfulContent =
|
||||
text.includes('Bundle-version anchored')
|
||||
|| text.includes('No promotions yet')
|
||||
|| text.includes('No promotions match')
|
||||
|| text.includes('Promotion')
|
||||
&& text.includes('Env Path');
|
||||
return {
|
||||
ok: heading === 'Promotions' && hasCreateAction && hasMeaningfulContent,
|
||||
snapshot: await capture(page, 'promotions-landing-surface', {
|
||||
hasCreateAction,
|
||||
hasMeaningfulContent,
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
results.push(await runCheck(page, 'operations-overview-surface', '/ops/operations', async () => {
|
||||
const heading = await headingText(page);
|
||||
const text = await bodyText(page);
|
||||
const hasBlocking = text.includes('Blocking');
|
||||
const hasPendingActions = text.includes('Pending Operator Actions');
|
||||
const hasTestId = await page.locator('[data-testid="operations-overview"]').count().then((c) => c > 0).catch(() => false);
|
||||
return {
|
||||
ok: heading === 'Operations' && hasBlocking && hasPendingActions && hasTestId,
|
||||
snapshot: await capture(page, 'operations-overview-surface', {
|
||||
hasBlocking,
|
||||
hasPendingActions,
|
||||
hasTestId,
|
||||
}),
|
||||
};
|
||||
}));
|
||||
|
||||
const failedChecks = results.filter((result) => !result.ok);
|
||||
const runtimeIssueCount =
|
||||
runtime.consoleErrors.length
|
||||
|
||||
@@ -67,6 +67,11 @@ const suites = [
|
||||
script: 'live-first-time-user-reporting-truthfulness-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'promotions-operations-landing-check',
|
||||
script: 'live-promotions-operations-landing-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-promotions-operations-landing-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'changed-surfaces',
|
||||
script: 'live-frontdoor-changed-surfaces.mjs',
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Playwright journey: Promotions landing and Operations overview
|
||||
* Sprint: SPRINT_20260315_006 / FTU-OPS-002 + FTU-OPS-006
|
||||
*
|
||||
* Verifies that:
|
||||
* 1. /releases/promotions renders a real landing surface (not blank)
|
||||
* 2. The promotions empty state provides operator guidance
|
||||
* 3. The "Create Promotion" action is reachable
|
||||
* 4. /ops/operations renders the Operations overview with child workflow links
|
||||
* 5. Operations overview has blocking cards, pending actions, and setup boundary
|
||||
* 6. Naming consistency: H1, title, and breadcrumb all say "Operations"
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const outputPath = path.join(outputDir, 'live-promotions-operations-landing-check.json');
|
||||
const authStatePath = path.join(outputDir, 'live-promotions-operations-landing-check.state.json');
|
||||
const authReportPath = path.join(outputDir, 'live-promotions-operations-landing-check.auth.json');
|
||||
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
||||
const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d';
|
||||
|
||||
function buildUrl(route) {
|
||||
const url = new URL(route, baseUrl);
|
||||
const scopedParams = new URLSearchParams(scopeQuery);
|
||||
for (const [key, value] of scopedParams.entries()) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
||||
}
|
||||
|
||||
function isStaticAsset(url) {
|
||||
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
|
||||
}
|
||||
|
||||
function isNavigationAbort(errorText = '') {
|
||||
return /aborted|net::err_abort/i.test(errorText);
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
consoleErrors: [],
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
responseErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function attachRuntime(page, runtime) {
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
|
||||
return;
|
||||
}
|
||||
runtime.pageErrors.push({
|
||||
page: page.url(),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const errorText = request.failure()?.errorText ?? 'unknown';
|
||||
if (isStaticAsset(request.url()) || isNavigationAbort(errorText)) {
|
||||
return;
|
||||
}
|
||||
runtime.requestFailures.push({
|
||||
page: page.url(),
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
error: errorText,
|
||||
});
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (isStaticAsset(response.url())) {
|
||||
return;
|
||||
}
|
||||
if (response.status() >= 400) {
|
||||
runtime.responseErrors.push({
|
||||
page: page.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
url: response.url(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function settle(page, ms = 1250) {
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async function headingText(page) {
|
||||
const headings = page.locator('h1, main h1');
|
||||
const count = await headings.count().catch(() => 0);
|
||||
for (let index = 0; index < Math.min(count, 6); index += 1) {
|
||||
const text = cleanText(await headings.nth(index).innerText().catch(() => ''));
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function bodyText(page) {
|
||||
return cleanText(await page.locator('body').innerText().catch(() => ''));
|
||||
}
|
||||
|
||||
async function hrefs(page, selector = 'a') {
|
||||
return page.locator(selector).evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => node.getAttribute('href'))
|
||||
.filter((value) => typeof value === 'string'),
|
||||
).catch(() => []);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const authReport = await authenticateFrontdoor({
|
||||
statePath: authStatePath,
|
||||
reportPath: authReportPath,
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
|
||||
});
|
||||
|
||||
const context = await createAuthenticatedContext(browser, authReport, {
|
||||
statePath: authStatePath,
|
||||
});
|
||||
const runtime = createRuntime();
|
||||
context.on('page', (pg) => attachRuntime(pg, runtime));
|
||||
|
||||
const page = await context.newPage();
|
||||
attachRuntime(page, runtime);
|
||||
|
||||
const results = [];
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
// ── Check 1: Promotions landing renders real content ──────────────
|
||||
await page.goto(buildUrl('/releases/promotions'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
const promotionsHeading = await headingText(page);
|
||||
const promotionsBody = await bodyText(page);
|
||||
const promotionsLinks = await hrefs(page);
|
||||
const promotionsTitle = await page.title().catch(() => '');
|
||||
|
||||
const hasPromotionsH1 = promotionsHeading === 'Promotions';
|
||||
const hasCreateAction = promotionsLinks.some(
|
||||
(href) => href.includes('create') || href.includes('/releases/promotions/create'),
|
||||
);
|
||||
// Either the list table is visible OR the empty state guidance is present
|
||||
const hasPromotionsTable = await page.locator('table[aria-label="Promotions"]').count().then((c) => c > 0).catch(() => false);
|
||||
const hasEmptyGuidance =
|
||||
promotionsBody.includes('No promotions yet') ||
|
||||
promotionsBody.includes('No promotions match') ||
|
||||
promotionsBody.includes('Bundle-version anchored');
|
||||
const hasMeaningfulContent = hasPromotionsTable || hasEmptyGuidance;
|
||||
|
||||
results.push({
|
||||
key: 'promotions-landing',
|
||||
route: '/releases/promotions',
|
||||
ok: hasPromotionsH1 && hasCreateAction && hasMeaningfulContent,
|
||||
snapshot: {
|
||||
heading: promotionsHeading,
|
||||
title: promotionsTitle,
|
||||
hasCreateAction,
|
||||
hasPromotionsTable,
|
||||
hasEmptyGuidance,
|
||||
hasMeaningfulContent,
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPromotionsH1) {
|
||||
failures.push('Promotions landing did not render H1 "Promotions".');
|
||||
}
|
||||
if (!hasCreateAction) {
|
||||
failures.push('Promotions landing has no reachable "Create Promotion" action.');
|
||||
}
|
||||
if (!hasMeaningfulContent) {
|
||||
failures.push('Promotions landing rendered blank -- no table and no empty state guidance.');
|
||||
}
|
||||
|
||||
// ── Check 2: Promotions empty state has operator guidance ─────────
|
||||
// If empty state is present, verify it has pipeline stages and prerequisite links
|
||||
if (!hasPromotionsTable && promotionsBody.includes('No promotions yet')) {
|
||||
const hasPrerequisites = promotionsBody.includes('Prerequisites') || promotionsBody.includes('prerequisite');
|
||||
const hasPipelineStages =
|
||||
promotionsBody.includes('Select Bundle Version') &&
|
||||
promotionsBody.includes('Gate Evaluation') &&
|
||||
promotionsBody.includes('Approval');
|
||||
const hasVersionsLink = promotionsLinks.some((href) => href.includes('/releases/versions'));
|
||||
|
||||
results.push({
|
||||
key: 'promotions-empty-state-guidance',
|
||||
route: '/releases/promotions',
|
||||
ok: hasPrerequisites && hasPipelineStages && hasVersionsLink,
|
||||
snapshot: {
|
||||
hasPrerequisites,
|
||||
hasPipelineStages,
|
||||
hasVersionsLink,
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPipelineStages) {
|
||||
failures.push('Promotions empty state is missing pipeline stage guidance.');
|
||||
}
|
||||
if (!hasVersionsLink) {
|
||||
failures.push('Promotions empty state is missing link to Release Versions.');
|
||||
}
|
||||
} else {
|
||||
results.push({
|
||||
key: 'promotions-empty-state-guidance',
|
||||
route: '/releases/promotions',
|
||||
ok: true,
|
||||
snapshot: { skipped: 'Promotions list has data, empty state not rendered.' },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Check 3: Operations overview renders real content ─────────────
|
||||
await page.goto(buildUrl('/ops/operations'), {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
await settle(page);
|
||||
|
||||
const opsHeading = await headingText(page);
|
||||
const opsBody = await bodyText(page);
|
||||
const opsLinks = await hrefs(page);
|
||||
const opsTitle = await page.title().catch(() => '');
|
||||
|
||||
const hasOpsH1 = opsHeading === 'Operations';
|
||||
const hasBlockingSection = opsBody.includes('Blocking');
|
||||
const hasPendingActions = opsBody.includes('Pending Operator Actions');
|
||||
const hasSetupBoundary = opsBody.includes('Setup Boundary');
|
||||
const hasChildWorkflowLinks =
|
||||
opsLinks.some((href) => href.includes('/ops/operations/doctor')) &&
|
||||
opsLinks.some((href) => href.includes('/evidence/audit-log'));
|
||||
const hasOverviewTestId = await page.locator('[data-testid="operations-overview"]').count().then((c) => c > 0).catch(() => false);
|
||||
|
||||
results.push({
|
||||
key: 'operations-overview',
|
||||
route: '/ops/operations',
|
||||
ok: hasOpsH1 && hasBlockingSection && hasPendingActions && hasSetupBoundary && hasOverviewTestId,
|
||||
snapshot: {
|
||||
heading: opsHeading,
|
||||
title: opsTitle,
|
||||
hasBlockingSection,
|
||||
hasPendingActions,
|
||||
hasSetupBoundary,
|
||||
hasChildWorkflowLinks,
|
||||
hasOverviewTestId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasOpsH1) {
|
||||
failures.push(`Operations overview H1 is "${opsHeading}" instead of "Operations".`);
|
||||
}
|
||||
if (!hasBlockingSection) {
|
||||
failures.push('Operations overview missing Blocking section.');
|
||||
}
|
||||
if (!hasPendingActions) {
|
||||
failures.push('Operations overview missing Pending Operator Actions.');
|
||||
}
|
||||
if (!hasSetupBoundary) {
|
||||
failures.push('Operations overview missing Setup Boundary section.');
|
||||
}
|
||||
|
||||
// ── Check 4: Operations title/breadcrumb naming consistency ──────
|
||||
const titleMatchesOps = opsTitle.includes('Operations');
|
||||
|
||||
results.push({
|
||||
key: 'operations-naming-consistency',
|
||||
route: '/ops/operations',
|
||||
ok: titleMatchesOps && hasOpsH1,
|
||||
snapshot: {
|
||||
title: opsTitle,
|
||||
heading: opsHeading,
|
||||
titleMatchesOps,
|
||||
},
|
||||
});
|
||||
|
||||
if (!titleMatchesOps) {
|
||||
failures.push(`Operations document title is "${opsTitle}", expected it to include "Operations".`);
|
||||
}
|
||||
|
||||
// ── Check 5: Operations quick nav has key child routes ───────────
|
||||
const quickNavLinks = await page
|
||||
.locator('[data-testid^="operations-nav-"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
testId: node.getAttribute('data-testid'),
|
||||
text: (node.textContent || '').trim(),
|
||||
})),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
const hasDataIntegrityNav = quickNavLinks.some((item) => item.text.includes('Data Integrity'));
|
||||
const hasJobsQueuesNav = quickNavLinks.some((item) => item.text.includes('Jobs'));
|
||||
const hasHealthSloNav = quickNavLinks.some((item) => item.text.includes('Health'));
|
||||
const hasFeedsNav = quickNavLinks.some((item) => item.text.includes('Feeds'));
|
||||
|
||||
results.push({
|
||||
key: 'operations-quick-nav',
|
||||
route: '/ops/operations',
|
||||
ok: hasDataIntegrityNav && hasJobsQueuesNav && hasHealthSloNav,
|
||||
snapshot: {
|
||||
quickNavLinks,
|
||||
hasDataIntegrityNav,
|
||||
hasJobsQueuesNav,
|
||||
hasHealthSloNav,
|
||||
hasFeedsNav,
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasDataIntegrityNav || !hasJobsQueuesNav || !hasHealthSloNav) {
|
||||
failures.push('Operations quick nav is missing key child routes (Data Integrity, Jobs, Health).');
|
||||
}
|
||||
|
||||
// ── Check 6: Operations blocking cards link to real pages ────────
|
||||
const blockingCards = await page
|
||||
.locator('[data-testid^="operations-blocking-"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
testId: node.getAttribute('data-testid'),
|
||||
text: (node.textContent || '').trim(),
|
||||
href: node.getAttribute('href') || node.closest('a')?.getAttribute('href') || '',
|
||||
})),
|
||||
)
|
||||
.catch(() => []);
|
||||
|
||||
results.push({
|
||||
key: 'operations-blocking-cards',
|
||||
route: '/ops/operations',
|
||||
ok: blockingCards.length > 0,
|
||||
snapshot: {
|
||||
blockingCardCount: blockingCards.length,
|
||||
blockingCards,
|
||||
},
|
||||
});
|
||||
|
||||
if (blockingCards.length === 0) {
|
||||
failures.push('Operations overview has no blocking cards rendered.');
|
||||
}
|
||||
} finally {
|
||||
await page.close().catch(() => {});
|
||||
await context.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
|
||||
const runtimeIssueCount =
|
||||
runtime.consoleErrors.length +
|
||||
runtime.pageErrors.length +
|
||||
runtime.requestFailures.length +
|
||||
runtime.responseErrors.length;
|
||||
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
failedCheckCount: failures.length,
|
||||
runtimeIssueCount,
|
||||
results,
|
||||
failures,
|
||||
runtime,
|
||||
};
|
||||
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (error) => {
|
||||
const summary = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
fatalError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="ops-overview__header">
|
||||
<div class="ops-overview__title-group">
|
||||
<p class="ops-overview__eyebrow">Ops / Operations</p>
|
||||
<h1>Platform Ops</h1>
|
||||
<h1>Operations</h1>
|
||||
<p class="ops-overview__subtitle">
|
||||
Consolidated operator shell for blocking platform issues, execution control, diagnostics,
|
||||
and airgap workflows. Topology and agent ownership remain under Setup.
|
||||
|
||||
@@ -111,8 +111,51 @@ interface PromotionRow {
|
||||
}
|
||||
|
||||
@if (!loading() && !error()) {
|
||||
@if (filteredPromotions().length === 0) {
|
||||
<div class="state-block">No promotions match the current filters.</div>
|
||||
@if (promotions().length === 0) {
|
||||
<section class="promotions-list__empty" aria-label="No promotions yet">
|
||||
<div class="empty-hero">
|
||||
<h2>No promotions yet</h2>
|
||||
<p>
|
||||
Promotions move a release version from one environment to another through
|
||||
a policy-gated approval pipeline. Each promotion captures bundle identity,
|
||||
gate evaluation, security snapshot, and decision evidence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-pipeline" aria-label="Promotion pipeline stages">
|
||||
<div class="pipeline-stage">
|
||||
<strong>1. Select Bundle Version</strong>
|
||||
<span>Choose the release version and target environment for promotion.</span>
|
||||
</div>
|
||||
<div class="pipeline-arrow" aria-hidden="true">-></div>
|
||||
<div class="pipeline-stage">
|
||||
<strong>2. Gate Evaluation</strong>
|
||||
<span>Policy gates run automatically to verify security, compliance, and data health.</span>
|
||||
</div>
|
||||
<div class="pipeline-arrow" aria-hidden="true">-></div>
|
||||
<div class="pipeline-stage">
|
||||
<strong>3. Approval & Launch</strong>
|
||||
<span>Approved promotions execute with full evidence capture and audit trail.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-actions">
|
||||
<a class="btn-primary" routerLink="create">Create First Promotion</a>
|
||||
<a class="link-sm" routerLink="/releases/versions">View Release Versions</a>
|
||||
<a class="link-sm" routerLink="/releases/approvals">View Approval Queue</a>
|
||||
</div>
|
||||
|
||||
<div class="empty-context">
|
||||
<h3>Prerequisites</h3>
|
||||
<ul>
|
||||
<li>At least one <a routerLink="/releases/versions">release version</a> must exist.</li>
|
||||
<li><a routerLink="/releases/environments">Environments</a> and promotion paths must be configured in topology.</li>
|
||||
<li><a routerLink="/ops/policy">Policy gates</a> control which promotions pass automatically and which require manual approval.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
} @else if (filteredPromotions().length === 0) {
|
||||
<div class="state-block">No promotions match the current filters. Try adjusting the status or environment filter above.</div>
|
||||
} @else {
|
||||
<table class="promotions-list__table" aria-label="Promotions">
|
||||
<thead>
|
||||
@@ -399,10 +442,114 @@ interface PromotionRow {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.promotions-list__empty {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-hero {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
background: var(--color-surface-alt, #f9fafb);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.empty-hero h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-hero p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
line-height: 1.5;
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
.empty-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pipeline-stage {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pipeline-stage strong {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pipeline-stage span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pipeline-arrow {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary, #999);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty-context {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.empty-context h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
|
||||
.empty-context ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.empty-context li {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
|
||||
.empty-context a {
|
||||
color: var(--color-brand-primary, #4f46e5);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.promotions-list__filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.empty-pipeline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pipeline-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
|
||||
@@ -5,8 +5,8 @@ export const OPERATIONS_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
title: 'Platform Ops',
|
||||
data: { breadcrumb: 'Ops' },
|
||||
title: 'Operations',
|
||||
data: { breadcrumb: 'Operations' },
|
||||
loadComponent: () =>
|
||||
import('../features/platform/ops/platform-ops-overview-page.component').then(
|
||||
(m) => m.PlatformOpsOverviewPageComponent,
|
||||
|
||||
Reference in New Issue
Block a user