First-time user experience fixes and platform contract repairs

FTUX fixes (Sprint 316-001):
- Remove all hardcoded fake data from dashboard — fresh installs show
  honest setup guide instead of fake crisis data (5 fake criticals gone)
- Curate advisory source defaults: 32 sources disabled by default
  (ecosystem, geo-restricted, exploit, hardware, mirror). ~43 core
  sources remain enabled. StellaOps Mirror no longer enabled at priority 1.
- Filter Mirror-category sources from Create Domain wizard to prevent
  circular mirror-from-mirror chains
- Add 404 catch-all route — unknown URLs show "Page Not Found" instead
  of silently rendering the dashboard
- Fix arrow characters in release target path dropdown (? → →)
- Add login credentials to quickstart documentation
- Update Feature Matrix: 14 release orchestration features marked as
  shipped (was marked planned)

Platform contract repairs (from prior session):
- Add /api/v1/jobengine/quotas/summary endpoint on Platform
- Fix gateway route prefix matching for /policy/shadow/* and
  /policy/simulations/* (regex routes instead of exact match)
- Fix VexHub PostgresVexSourceRepository missing interface method
- Fix advisory-vex-sources sweep text expectation
- Fix mirror operator journey auth (session storage token extraction)

Verified: 110/111 canonical routes passing (1 unrelated stale approval ref)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 02:05:38 +02:00
parent f4d3ef76db
commit 534aabfa2a
21 changed files with 3195 additions and 304 deletions

View File

@@ -67,12 +67,13 @@
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" }, { "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" }, { "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" }, { "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" }, { "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows$1" },
{ "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" }, { "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" },
{ "Type": "Microservice", "Path": "^/api/v1/administration(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/administration$1" }, { "Type": "Microservice", "Path": "^/api/v1/administration(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/administration$1" },
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" }, { "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-control(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control$1" }, { "Type": "Microservice", "Path": "^/api/v1/release-control(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control$1" },
{ "Type": "Microservice", "Path": "^/api/v1/gateway/rate-limits(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits$1" }, { "Type": "Microservice", "Path": "^/api/v1/gateway/rate-limits(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits$1" },
{ "Type": "Microservice", "Path": "^/api/v1/jobengine/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/jobengine/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/reachability(.*)", "IsRegex": true, "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability$1" }, { "Type": "Microservice", "Path": "^/api/v1/reachability(.*)", "IsRegex": true, "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability$1" },
{ "Type": "Microservice", "Path": "^/api/v1/timeline(.*)", "IsRegex": true, "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline$1" }, { "Type": "Microservice", "Path": "^/api/v1/timeline(.*)", "IsRegex": true, "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline$1" },
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" }, { "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
@@ -80,6 +81,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" }, { "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" }, { "Type": "Microservice", "Path": "^/api/v1/notifier/(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notify/(digest-schedules|quiet-hours|throttle-configs|simulate|escalation-policies|localizations|incidents)(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/notify/$1$2" },
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" }, { "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" }, { "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
@@ -109,11 +111,13 @@
{ "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" }, { "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" },
{ "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" }, { "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" },
{ "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" }, { "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" },
{ "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" }, { "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" }, { "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/jobengine$1" },
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" }, { "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" }, { "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
{ "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" }, { "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" }, { "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },

View File

@@ -129,7 +129,7 @@ server {
# Policy gateway (strips /policy/ prefix, regex avoids colliding with # Policy gateway (strips /policy/ prefix, regex avoids colliding with
# Angular /policy/exceptions, /policy/packs SPA routes) # Angular /policy/exceptions, /policy/packs SPA routes)
location ~ ^/policy/(api|v[0-9]+)/ { location ~ ^/policy/(api|v[0-9]+|shadow)/ {
set \$policy_upstream http://policy-gateway.stella-ops.local; set \$policy_upstream http://policy-gateway.stella-ops.local;
rewrite ^/policy/(.*)\$ /\$1 break; rewrite ^/policy/(.*)\$ /\$1 break;
proxy_pass \$policy_upstream; proxy_pass \$policy_upstream;

View File

@@ -34,30 +34,30 @@
--- ---
## Release Orchestration (Planned) ## Release Orchestration
*Release orchestration capabilities are planned for implementation.* *Release orchestration capabilities for environment promotion, policy gates, and deployment execution.*
| Capability | Notes | | Capability | Notes |
| **Environment Management** | | | **Environment Management** | |
| Environment CRUD | Dev/Stage/Prod definitions | | Environment CRUD | Dev/Stage/Prod definitions |
| Freeze Windows | ⏳ Calendar-based blocking | | Freeze Windows | ⏳ Calendar-based blocking |
| Approval Policies | Per-environment rules | | Approval Policies | Per-environment rules |
| **Release Management** | | | **Release Management** | |
| Component Registry | ⏳ Service → repository mapping | | Component Registry | ⏳ Service → repository mapping |
| Release Bundles | Component → digest bundles | | Release Bundles | Component → digest bundles |
| Semantic Versioning | SemVer release versions | | Semantic Versioning | SemVer release versions |
| Tag → Digest Resolution | Immutable digest pinning | | Tag → Digest Resolution | Immutable digest pinning |
| **Promotion & Gates** | | | **Promotion & Gates** | |
| Promotion Workflows | Environment transitions | | Promotion Workflows | Environment transitions |
| Security Gate | Scan verdict evaluation | | Security Gate | Scan verdict evaluation |
| Approval Gate | Human sign-off | | Approval Gate | Human sign-off |
| Freeze Window Gate | ⏳ Calendar enforcement | | Freeze Window Gate | ⏳ Calendar enforcement |
| Policy Gate (OPA/Rego) | Custom rules | | Policy Gate (OPA/Rego) | Custom rules |
| Decision Records | Evidence-linked decisions | | Decision Records | Evidence-linked decisions |
| **Deployment Execution** | | | **Deployment Execution** | |
| Docker Host Agent | Direct container deployment | | Docker Host Agent | Direct container deployment |
| Compose Host Agent | Docker Compose deployment | | Compose Host Agent | Docker Compose deployment |
| SSH Agentless | ⏳ Linux remote execution | | SSH Agentless | ⏳ Linux remote execution |
| WinRM Agentless | ⏳ Windows remote execution | | WinRM Agentless | ⏳ Windows remote execution |
| ECS Agent | ⏳ AWS ECS deployment | | ECS Agent | ⏳ AWS ECS deployment |
@@ -74,9 +74,9 @@
| Workflow Templates | ⏳ Reusable workflows | | Workflow Templates | ⏳ Reusable workflows |
| Script Steps (Bash/C#) | ⏳ Custom automation | | Script Steps (Bash/C#) | ⏳ Custom automation |
| **Evidence & Audit** | | | **Evidence & Audit** | |
| Evidence Packets | Sealed decision bundles | | Evidence Packets | Sealed decision bundles |
| Version Stickers | ⏳ On-target deployment records | | Version Stickers | ⏳ On-target deployment records |
| Audit Export | Compliance reporting | | Audit Export | Compliance reporting |
| **Integrations** | | | **Integrations** | |
| GitHub Integration | ⏳ SCM + webhooks | | GitHub Integration | ⏳ SCM + webhooks |
| GitLab Integration | ⏳ SCM + webhooks | | GitLab Integration | ⏳ SCM + webhooks |

View File

@@ -0,0 +1,317 @@
# Dashboard Redesign Proposal — Stella Ops Mission Board
**Date**: 2026-03-16
**Author**: First-time user audit + product analysis
**Status**: Proposal
---
## What Stella Ops Actually Is (The Mental Model)
Stella Ops is a **release control plane** that answers three questions for every deployment:
1. **Is it safe?** — Vulnerabilities, SBOM health, reachability evidence, VEX dispositions
2. **Is it approved?** — Policy gates, human approvals, compliance evidence
3. **Is it working?** — Deployment health, service connectivity, feed freshness
The dashboard must reflect these three pillars. Currently it's a single vertical scroll of hardcoded fake data that mixes all three with no hierarchy.
---
## Current Dashboard Problems
### P1: Everything is hardcoded
- `summary` signal: `{ activePromotions: 3, blockedPromotions: 1, ... }` — fake
- `resolveStatusSeed()`: generates fake metrics by environment type (dev=healthy, staging=degraded, prod+us-east=blocked) — deterministic lies
- `reachabilityStats`: `{ bCoverage: 72, iCoverage: 88, rCoverage: 61 }` — fake
- `nightlyOpsSignals`: 4 hardcoded items — fake
- Alerts section: 3 hardcoded HTML `<li>` items — fake
- Activity section: 3 hardcoded HTML cards — fake
- **Zero API calls to real backends**
### P2: Layout has no hierarchy
Current vertical order: Summary strip → Environment grid → Risk table → 3-card row (SBOM + Reachability + Ops Signals) → Alerts → Activity → Domain nav.
This is 7 sections stacked vertically. A user scrolls through 3+ screens of content with no visual priority. The most critical information (am I blocked?) competes with the least actionable (domain navigation links).
### P3: No data source distinction
Dashboard shows "5 critical findings" and "blocked" but the security posture page shows "0 findings". The user can't tell what's real. There's no "last updated" timestamp, no data source indicator, no "demo data" badge.
### P4: Duplicate information
- Environment grid AND risk table show the same environments with the same metrics
- SBOM card recalculates stats from the same environment data shown in the grid
- Reachability percentages are shown per-environment (B/I/R column) AND as aggregate (Reachability card)
---
## Proposed Layout: 3-Column Mission Board
```
┌─────────────────────────────────────────────────────────────────────┐
│ Dashboard — Mission Board for [Demo Production] [Refresh] │
│ Last updated: 15 Mar 2026, 23:15 UTC │
├───────────────────────┬─────────────────────────────────────────────┤
│ │ │
│ SECURITY POSTURE │ ENVIRONMENTS & ACTIONS │
│ (1/3 width) │ (2/3 width) │
│ │ │
│ ┌─────────────────┐ │ ┌─────────────────────────────────────────┐│
│ │ VULNERABILITY │ │ │ PROMOTION PIPELINE ││
│ │ SUMMARY │ │ │ ││
│ │ │ │ │ [env cards in promotion order: ││
│ │ Critical: 12 │ │ │ Dev → Stage → Prod per region] ││
│ │ High: 34 │ │ │ ││
│ │ Medium: 89 │ │ │ Blocked: prod-us-east (5 crit) ││
│ │ Low: 156 │ │ │ Degraded: staging (stale SBOM) ││
│ │ │ │ │ Healthy: dev, prod-eu-west ││
│ │ [severity donut │ │ │ ││
│ │ or bar chart] │ │ └─────────────────────────────────────────┘│
│ │ │ │ │
│ │ Reachable: 9 │ │ ┌─────────────────────────────────────────┐│
│ │ Unreachable: 47 │ │ │ NEEDS YOUR ATTENTION ││
│ │ Unknown: 23 │ │ │ ││
│ │ │ │ │ ⚠ 3 approvals blocked (evidence stale) ││
│ └─────────────────┘ │ │ ⚠ 2 waivers expiring in 24h ││
│ │ │ ⚠ 1 promotion blocked by policy gate ││
│ ┌─────────────────┐ │ │ 🔴 Feed freshness degraded ││
│ │ SBOM HEALTH │ │ │ ││
│ │ │ │ │ [each item links to the action page] ││
│ │ Components: 247 │ │ └─────────────────────────────────────────┘│
│ │ Fresh: 231 │ │ │
│ │ Stale: 12 │ │ ┌─────────────────────────────────────────┐│
│ │ Missing: 4 │ │ │ RECENT ACTIVITY (real events) ││
│ │ │ │ │ ││
│ │ B/I/R Coverage │ │ │ • admin sealed "API Gateway v2.1" ││
│ │ B: 72% │ │ │ • Policy gate blocked prod-us-east ││
│ │ I: 88% │ │ │ • NVD feed synced (142 new advisories) ││
│ │ R: 61% │ │ │ • Doctor check: 1 fail, 9 warn ││
│ └─────────────────┘ │ │ ││
│ │ │ [live event stream, not static cards] ││
│ ┌─────────────────┐ │ └─────────────────────────────────────────┘│
│ │ ADVISORY FEEDS │ │ │
│ │ │ │ │
│ │ Sources: 55/75 │ │ │
│ │ Healthy: 55 │ │ │
│ │ Failed: 18 │ │ │
│ │ Last sync: 2m │ │ │
│ │ │ │ │
│ │ [Configure] │ │ │
│ └─────────────────┘ │ │
│ │ │
├───────────────────────┴─────────────────────────────────────────────┤
│ PLATFORM HEALTH (full width footer bar) │
│ │
│ Services: 63/63 ✓ │ DB: healthy │ Events: CONNECTED │ Doctor: 7/1/1│
│ Feed: Live │ Evidence: ON │ Offline: OK │ DLQ: 3 │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Column 1 (1/3): Security Posture At-a-Glance
**Purpose**: Answer "Is my estate safe?" without leaving the dashboard.
### Section 1A: Vulnerability Summary
**Data source**: `GET /api/v1/findings/summary` (findings ledger) or `GET /api/v1/scanner/summary` (scanner service)
What to show:
- **Severity breakdown**: Critical / High / Medium / Low counts
- **Reachability breakdown**: Reachable / Unreachable / Unknown counts
- **Donut chart** or horizontal stacked bar colored by severity
- **Trend arrow** (↑/↓/→) compared to 24h ago
- **Link**: "Open Findings" → `/security/triage`
Why this matters:
- This is the #1 thing a security auditor looks at
- Currently the dashboard shows "5 critical" per environment but it's all fake
- Real data from the findings ledger makes this trustworthy
### Section 1B: SBOM Health
**Data source**: `GET /api/v1/scanner/sbom/summary` or computed from environment scan state
What to show:
- **Total components tracked** across all environments
- **Freshness breakdown**: Fresh / Stale / Missing
- **B/I/R reachability coverage bars** (the existing bar chart, but from real data)
- **Link**: "View Supply Chain" → `/security/supply-chain-data`
### Section 1C: Advisory Feed Status
**Data source**: `GET /api/v1/advisory-sources/status` (Concelier source management)
What to show:
- **Sources active**: X of Y enabled
- **Healthy / Failed** count
- **Last sync timestamp**
- **Link**: "Configure Sources" → `/setup/integrations/advisory-vex-sources`
Why this matters:
- Advisory freshness directly affects vulnerability accuracy
- If feeds are stale, findings are stale, decisions are wrong
- This was the 55/75 healthy we discovered in the audit
---
## Column 2 (2/3): Environments & Actions
**Purpose**: Answer "What needs my attention?" and "What's the deployment state?"
### Section 2A: Promotion Pipeline
**Data source**: `GET /api/v1/platform/context/environments` (already loads via PlatformContextStore) + real scan/promotion state
What to show:
- Environment cards **in promotion order** (Dev → Stage → Prod, grouped by region)
- Each card: name, region, deploy status badge, SBOM freshness, critical findings count, pending approvals
- **Blocked environments highlighted at top** with red border
- **Actions per card**: Detail, Findings, Promote, Approve
- **Real metrics** from scan/promotion APIs, NOT `resolveStatusSeed()`
Layout: Same card grid as current, but driven by real data.
### Section 2B: Needs Your Attention
**Data source**: Multiple — approvals API, waivers API, promotions API, notifications API
What to show:
- **Actionable items only** — things the user MUST do:
- Pending approvals (with count and reason)
- Expiring waivers (with countdown)
- Blocked promotions (with blocking reason: policy gate, evidence freshness, etc.)
- Feed degradation alerts
- Each item is a **clickable link** to the action page
- **NOT static HTML** — real data from APIs
Why this matters:
- The current "Alerts" section is 3 hardcoded `<li>` elements
- A real operator needs to see: "You have 3 things to do today"
### Section 2C: Recent Activity (Live Stream)
**Data source**: `GET /api/v1/timeline/events` or WebSocket event stream (already have "Events: CONNECTED" in topbar)
What to show:
- **Real events**, most recent first:
- Release sealed/promoted/rolled back
- Policy gate pass/block
- Advisory feed sync (with advisory count)
- Doctor check results
- User actions (approval, waiver, exception)
- **Live updates** via the event stream (already connected)
- Maximum 10 items, with "View full activity" link → `/evidence/audit-log`
Why this matters:
- Current "Recent Activity" is 3 static cards with text descriptions — not actual activity
- The topbar already shows "Events: CONNECTED" — the live stream is available
---
## Footer Bar: Platform Health
**Data source**: `GET /api/v1/platform/health` or `GET /api/v1/doctor/last-run/summary`
What to show (single horizontal bar, always visible):
- **Services**: 63/63 healthy (or X unhealthy)
- **DB**: healthy/degraded
- **Events**: Connected/Degraded
- **Doctor**: 7 pass / 1 warn / 1 fail (link to `/ops/operations/doctor`)
- **Feed**: Live/Stale
- **Evidence**: ON/OFF
- **Offline**: OK/Sealed
- **DLQ**: N items (link to `/ops/operations/dead-letter`)
Why this matters:
- The topbar already shows some of these (Events, Policy, Evidence, Feed, Offline)
- But the dashboard should also show system health — an operator wants to know "is the platform itself healthy?" at a glance
- Doctor results from the last run are critical operational context
---
## What to Remove
1. **Risk table** — duplicate of the environment grid. Merge into a single view.
2. **Domain navigation links** (Release Runs, Security & Risk, Platform, Evidence, Platform Setup) — the sidebar already provides this. Dashboard space is premium.
3. **Activity section** (3 static cards) — replace with real live activity stream
4. **All hardcoded data** — every number must come from an API or show "No data yet"
---
## Empty State (Fresh Install)
When the dashboard has no real data (no environments, no scans, no releases):
```
┌─────────────────────────────────────────────────────────┐
│ Welcome to Stella Ops │
│ │
│ Let's set up your release control plane. │
│ │
│ ① Connect a registry [Setup Integrations →] │
│ ② Define environments [Topology Wizard →] │
│ ③ Scan your first image [Start Scan →] │
│ ④ Create a release [Create Release →] │
│ │
│ ───────────────────────────────────────── │
│ Platform Health: 63/63 services ✓ │
│ Advisory Sources: 55/75 healthy │
│ Doctor: Run diagnostics → [Quick Check] │
└─────────────────────────────────────────────────────────┘
```
The empty state should guide the user through setup, not show fake crisis data.
---
## Data Sources Required
| Dashboard Section | API Endpoint | Service |
|---|---|---|
| Vulnerability Summary | `GET /api/v1/findings/summary` | Findings Ledger |
| SBOM Health | `GET /api/v1/scanner/sbom/summary` | Scanner |
| B/I/R Reachability | `GET /api/v1/reachgraph/coverage` | ReachGraph |
| Advisory Feed Status | `GET /api/v1/advisory-sources/status` | Concelier |
| Environment Cards | `GET /api/v1/platform/context` | Platform (exists) |
| Environment Scan State | `GET /api/v1/scanner/environments/summary` | Scanner |
| Pending Approvals | `GET /api/v1/approvals?status=pending` | JobEngine |
| Expiring Waivers | `GET /api/v1/exceptions?expiresWithin=24h` | Policy |
| Blocked Promotions | `GET /api/v1/promotions?status=blocked` | JobEngine |
| Recent Activity | `GET /api/v1/timeline/events?limit=10` | Timeline |
| Platform Health | `GET /api/v1/doctor/last-run/summary` | Platform/Doctor |
| Service Count | `GET /api/v1/platform/health` | Platform |
| DLQ Count | `GET /api/v1/jobengine/dead-letter/summary` | JobEngine |
Many of these endpoints already exist (Platform context, advisory sources status, doctor, dead-letter, jobengine). Some may need summary/aggregation endpoints.
---
## Implementation Approach
### Phase 1: Honest Empty State (S effort)
- Replace hardcoded data with API calls that can return empty
- When empty: show the welcome/setup guide instead of fake data
- When real data exists: show real data
### Phase 2: 3-Column Layout (M effort)
- Restructure the template from vertical scroll to 3-column grid
- Left column: security posture cards
- Right column: environments + actions + activity
- Footer: platform health bar
### Phase 3: Real API Wiring (L effort)
- Wire each section to its real API endpoint
- Add loading skeletons per section
- Add "last updated" timestamps
- Handle API errors gracefully (show error state per section, not whole page)
### Phase 4: Live Activity Stream (M effort)
- Replace static activity cards with real event stream
- Use the existing WebSocket connection (Events: CONNECTED)
- Show 10 most recent events with live updates
---
## Files to Modify
| File | Change |
|------|--------|
| `src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts` | Complete rewrite of template + data sources |
| `src/Web/StellaOps.Web/src/app/core/api/` | Add dashboard summary API clients |
| `src/Platform/StellaOps.Platform.WebService/Endpoints/` | Add dashboard aggregation endpoint |
| `src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.spec.ts` | Update tests |

View File

@@ -0,0 +1,185 @@
# Deep Advisory & Mirror Audit — Stella Ops
**Date**: 2026-03-16
**Method**: Interactive Playwright walkthrough + source code analysis
**Focus**: Advisory source catalog, mirror architecture, mode confusion, first-time operator experience
---
## The Core Problem: Mirror Source vs Mirror Mode Are Disconnected
Stella Ops has three advisory operating modes:
- **Direct**: Fetch advisories from upstream sources (NVD, OSV, GHSA, etc.) directly
- **Mirror**: Consume pre-aggregated advisory bundles from an upstream Stella Ops mirror
- **Hybrid**: Both direct and mirror sources active
The mirror feature has two roles:
- **Producer**: Create mirror domains that bundle advisories for downstream consumers
- **Consumer**: Connect to an upstream mirror and pull bundles
**The problem**: The advisory source catalog and the mirror mode are independent systems that don't coordinate:
### Issue 1: StellaOps Mirror Source Enabled by Default in Direct Mode
**File**: `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs:1351-1366`
The `StellaMirror` source definition has:
```csharp
BaseEndpoint = "https://mirror.stella-ops.org/api/v1",
DefaultPriority = 1, // HIGHEST priority
EnabledByDefault = true // inherited from base class
```
On a fresh self-hosted install:
- Mode badge shows **"Direct"**
- But "StellaOps Mirror" source is **enabled** at **priority 1** (highest)
- Its endpoint `mirror.stella-ops.org` is an external SaaS URL that doesn't exist for self-hosted
- Health check fails: "SSL/TLS error connecting to StellaOps Mirror"
**Impact**: The highest-priority advisory source is a broken external URL. If it were reachable, it would override all local direct sources. The system silently operates with a failed priority-1 source.
**Fix**: Either:
- Set `EnabledByDefault = false` for the StellaMirror source
- Or: gate it behind mirror mode — only enable when mode is "Mirror" or "Hybrid"
- Or: replace the hardcoded URL with a placeholder that requires explicit configuration
### Issue 2: Mirror Source Appears in Create Mirror Domain Wizard
When creating a new mirror domain (producer role), the source selection wizard lists ALL sources including "StellaOps Mirror" under the Mirror (1) category. An operator can select it and create a mirror domain that sources from... the broken external mirror.
**Impact**: Creates a circular or broken dependency chain. A mirror domain that sources from a nonexistent upstream.
**Fix**: Exclude sources of type `StellaMirror` from the Create Mirror Domain source picker. A mirror domain should only aggregate direct-fetch sources, not other mirrors.
### Issue 3: No Relationship Between Catalog Toggle and Mirror Consumer Setup
Two separate ways to "use a mirror" exist:
1. **Catalog toggle**: Enable "StellaOps Mirror" source in the advisory catalog → hardcoded to `mirror.stella-ops.org`
2. **Mirror Consumer Wizard**: "Connect to Mirror" → 4-step wizard where you enter a custom mirror URL
These are independent. Enabling the catalog mirror source does NOT invoke the consumer wizard. Completing the consumer wizard does NOT update the catalog mirror source's endpoint.
**Impact**: A user who completes the consumer wizard and enters `https://my-corp-mirror.internal` still has the catalog's mirror source pointing at `mirror.stella-ops.org`. Conversely, a user who toggles the catalog source thinks they've configured mirror mode, but the system mode stays "Direct".
**Fix**: Unify these:
- When the mirror consumer wizard is completed, it should: (a) update the StellaMirror source endpoint to the configured URL, (b) enable the source, (c) switch mode to "Mirror" or "Hybrid"
- The catalog's StellaMirror source toggle should redirect to the consumer wizard if no mirror is configured
- Or: remove the StellaMirror source from the catalog entirely and make it purely managed through the mirror dashboard
### Issue 4: "Direct" Mode Label Is Misleading When Mirror Source Is Enabled
The mirror context header shows "Direct" (green badge) but a mirror source is enabled at highest priority. The mode badge doesn't reflect actual source configuration.
**Fix**: Derive the mode from actual source state:
- If only direct sources are enabled → "Direct"
- If only mirror source is enabled → "Mirror"
- If both → "Hybrid"
Or at minimum, show a warning: "Mirror source is enabled but unreachable"
---
## Advisory Source Health: Fresh Install Reality
After "Check All" on a fresh install:
- **55 healthy** (sources reachable from Docker network)
- **18 failed** (unreachable from Docker network or nonexistent)
**Failed sources by category:**
| Source | Likely Reason |
|--------|--------------|
| Oracle Security | DNS/network from Docker |
| npm/PyPI/RubyGems/Maven/Packagist/Hex.pm Advisories (6) | OSV-based, likely endpoint differences |
| CERT-In (India) | Geo-restricted or slow |
| FSTEC BDU (Russia) | Geo-restricted |
| CSAF Aggregator | Endpoint format |
| VEX Hub | Not a real endpoint yet |
| MITRE D3FEND | Endpoint format |
| Exploit-DB | Likely rate-limited |
| Docker Official CVEs | Endpoint format |
| AMD Security | DNS/network |
| Siemens ProductCERT | Endpoint format |
| Ruby Advisory DB | Endpoint format |
| StellaOps Mirror | Nonexistent external URL |
**Finding**: 74 out of 75 sources are enabled by default (`EnabledByDefault = true`). On a fresh install, 18 immediately fail health checks. The stats bar shows "55 enabled, 55 healthy, 19 failed" — but this is confusing because "55 enabled" should be 74 (or 75). The enabled count in the stats bar and the actual enabled count don't match.
**Fix**:
- Don't enable all 75 sources by default. Enable a curated set (Primary 4 + major vendors + major distributions = ~20-25 sources)
- Disable ecosystem-specific sources (npm, PyPI, etc.) by default — let users enable for their stack
- Disable geo-restricted sources (FSTEC, NKCKI) by default
- Show a "Recommended for your platform" selection during first-time setup
---
## Mirror Domain Builder: Good UX, Missing Guardrails
The 3-step domain builder wizard is well-designed:
1. **Select Sources** — categorized picker with quick-select buttons, search, selection summary
2. **Configure Domain** — auto-generated ID/name, 5 export formats (JSON/JSONL/OpenVEX/CSAF/CycloneDX), rate limits, auth, signing
3. **Review & Create** — summary + JSON filter preview + "generate immediately" option
**Good**:
- Auto-generated domain ID from selection (`mirror-primary-4src`)
- Multiple export formats including OpenVEX and CSAF
- Rate limiting per domain
- Optional signing and authentication
- "Generate immediately" checkbox
**Issues**:
- Can select "StellaOps Mirror" as a source (circular, see Issue 2)
- No indication of which sources are actually healthy — I might build a domain from 4 sources where 2 are failing
- No estimate of bundle size or generation time
---
## Mirror Consumer Wizard: Clean but Disconnected
The 4-step consumer wizard:
1. **Connect** — Enter mirror base URL + test connection
2. **Signature** — Verify signing key
3. **Sync & Mode** — Schedule + mode selection
4. **Review & Activate** — Confirm
**Good**:
- Proper signature verification step
- "Test Connection" button
- Mode selection in the workflow
**Issues**:
- Completing this wizard doesn't update the catalog's StellaMirror source (see Issue 3)
- Placeholder URL `https://mirror.stella-ops.org` is the same broken SaaS URL as the catalog source
- No way to test with the local instance's own mirror domains (self-mirroring for verification)
---
## Recommended Architecture Changes
### Short-term (S/M effort):
1. **Set `EnabledByDefault = false` for StellaMirror source** — prevents broken priority-1 source on fresh install
2. **Exclude StellaMirror from Create Domain wizard** — prevents circular mirror chains
3. **Curate default-enabled sources** — only enable Primary + top 15 vendor/distribution sources by default
4. **Fix stats bar count** — "enabled" count should match actual enabled sources
5. **Show health status in Create Domain source picker** — badge healthy/failed next to each source
### Medium-term (L effort):
6. **Unify catalog mirror source and consumer wizard** — catalog toggle should invoke wizard, wizard should update catalog
7. **Derive mode from source state** — "Direct"/"Mirror"/"Hybrid" computed from actual enabled sources
8. **Add "Recommended sources" first-run flow** — during setup, suggest sources based on detected platform (Docker → Container sources, Python → PyPI, etc.)
### Long-term (XL effort):
9. **Mirror domain ↔ consumer federation** — Instance A's domain auto-discoverable by Instance B's consumer wizard via DNS SRV or well-known URL
10. **Self-test mirror consumer** — "Connect to localhost" option that validates the producer flow by consuming from own mirror domains
---
## Files to Modify
| Fix | File | Change |
|-----|------|--------|
| #1 | `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs:1351` | Add `EnabledByDefault = false` to StellaMirror |
| #2 | `src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts` | Filter out sources where `category === 'Mirror'` from the picker |
| #3 | `src/Concelier/__Libraries/StellaOps.Concelier.Core/Sources/SourceDefinitions.cs` | Set `EnabledByDefault = false` on ecosystem/geo-restricted sources |
| #4 | `src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts` | Fix enabled count computation |
| #5 | `src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts` | Show health badge next to source names |
| #6 | Multiple files | Wire consumer wizard completion to update catalog source + mode |
| #7 | `src/Concelier/StellaOps.Concelier.WebService/Extensions/MirrorDomainManagementEndpointExtensions.cs` | Compute mode from source state |

View File

@@ -0,0 +1,211 @@
# First-Time User Audit — Stella Ops
**Date**: 2026-03-16
**Method**: Documentation review + Playwright live walkthrough (41 pages) + source analysis
**Stack**: 63 containers, 111/111 canonical routes passing, demo-prod tenant
---
## Executive Summary
Stella Ops is **technically impressive** — 41 pages load without crashes, all 111 routes pass, and the product surface is vast (releases, security, evidence, policy, operations, integrations, administration). However, a first-time user faces **significant onboarding friction**: the product assumes prior knowledge, empty states don't guide next steps, and the navigation depth is overwhelming without a guided tour.
**41 pages audited, 27 findings identified (4 critical, 8 high, 10 medium, 5 low).**
---
## Findings
### CRITICAL
#### C1. No 404 Page — Unknown Routes Show Dashboard
**Route**: `/nonexistent-route-12345` → renders Dashboard (title: "Dashboard")
**Impact**: Users who mistype URLs or follow stale links see the dashboard instead of "Page Not Found". They won't realize they're in the wrong place.
**Solution**: Add a wildcard catch-all route that renders a dedicated 404 component with: "This page doesn't exist" message, search bar, and links to common pages.
**Effort**: S
**Files**: `src/Web/StellaOps.Web/src/app/app.routes.ts` — add `{ path: '**', component: NotFoundComponent }`
#### C2. System Health / Signals / Dead-Letter Show Error Text
**Routes**: `/ops/operations/system-health`, `/ops/operations/signals`, `/ops/operations/dead-letter`
**Impact**: Three operational pages show visible error text to the user. On a fresh setup, this makes the platform look broken.
**Solution**: Replace raw error text with the `ErrorStateComponent` pattern (alert icon + "Something went wrong" + retry button). For fresh installs, show "No data yet" instead of errors.
**Effort**: M
**Files**: System health, signals, dead-letter component `.ts` files
#### C3. Documentation Mentions Internal Codenames Without Explanation
**Impact**: Docs reference "Concelier", "Excititor", "VexHub", "VexLens" without explaining what they are. The Feature Matrix says "Concelier + CLI" for advisory ingest. A new user won't know these are service names.
**Solution**: Add a glossary section to `docs/README.md` or a standalone `docs/GLOSSARY.md`. In each doc that mentions a codename, add a brief parenthetical: "Concelier (the advisory feed aggregator)".
**Effort**: M
**Files**: `docs/README.md`, `docs/GLOSSARY.md` (new), `docs/CONCELIER_CLI_QUICKSTART.md`
#### C4. Feature Matrix Shows Release Orchestration as "Planned" (⏳) but UI Is Built
**Impact**: The Feature Matrix (rev 5.1, Jan 2026) lists Environment CRUD, Release Bundles, Promotion Workflows, etc. all as ⏳ (planned). But the live UI has working releases, approvals, promotions, environments. A buyer reading this would think the product can't do releases yet.
**Solution**: Update `docs/FEATURE_MATRIX.md` to reflect current implementation status. Mark shipped features with ✅.
**Effort**: M
**Files**: `docs/FEATURE_MATRIX.md`
---
### HIGH
#### H1. No Onboarding Wizard or First-Run Detection
**Impact**: After login, the user lands on the Mission Control dashboard with mock/seed data. There's no "Welcome to Stella Ops — let's set up your first environment" flow. The Platform Setup page exists but isn't discoverable from the dashboard.
**Solution**: Add a first-run banner on the Dashboard that detects if the tenant has 0 real environments/targets configured. Show: "Welcome! Start by setting up your deployment topology" with a CTA to `/ops/platform-setup/topology-wizard`.
**Effort**: M
**Files**: `dashboard-v3.component.ts`, new `first-run-banner.component.ts`
#### H2. Sidebar Has 40+ Items — No Progressive Disclosure
**Impact**: The sidebar shows all navigation items at once: Releases (6 children), Operations (11 children), Security (6 children), Evidence (6 children), Setup (8+ children). A first-time user doesn't know where to start.
**Solution**: Consider collapsing all groups by default for first-time users (or until the user has interacted). Add a "Getting Started" pinned item at the top of the sidebar that links to a setup checklist.
**Effort**: M
**Files**: `app-sidebar.component.ts`
#### H3. No "What Is This?" Help on Complex Pages
**Impact**: Pages like "Policy Decisioning Studio", "Decision Capsules", "Shadow Mode", "Promotion Gate", "Risk Budget" use domain-specific language without inline help. A new user can't understand what these do.
**Solution**: Add `(?)` tooltip icons next to page titles that show a 2-sentence explanation. Use the existing glossary/tooltip component pattern.
**Effort**: L
**Files**: Multiple page components, new `help-tooltip.component.ts`
#### H4. Tenant Switching Not Obvious
**Impact**: The tenant selector is in the secondary topbar row, which may not be visible or recognizable. Demo has 3 tenants but users might not know they can switch.
**Solution**: If multiple tenants are available, show a more prominent tenant indicator. Consider adding tenant context to the breadcrumb trail.
**Effort**: S
**Files**: `app-topbar.component.ts`
#### H5. Quickstart Doesn't Mention Default Credentials
**Impact**: The quickstart guide says "Open https://stella-ops.local" but doesn't tell the user what credentials to use. The login page shows an OIDC form. User has to guess or search the seed SQL.
**Solution**: Add to `docs/quickstart.md` after step 5: "Log in with: **admin** / **Admin@Stella2026!** (demo-prod tenant)".
**Effort**: S
**Files**: `docs/quickstart.md`
#### H6. Empty Integrations Hub on Fresh Install
**Routes**: `/setup/integrations`, `/setup/integrations/registries`, `/setup/integrations/scm`
**Impact**: All integration categories show "No connector plugins installed" with disabled "+ Add" buttons. There's no explanation of what plugins are or how to get them. User hits a dead end.
**Solution**: Add a "Getting started with integrations" card that explains: plugins are installed via the CLI or Docker overlays. Link to `docs/PLUGIN_SDK_GUIDE.md`. For the advisory/VEX section, highlight that it works out of the box (75 sources).
**Effort**: M
**Files**: `integrations-hub.component.ts`
#### H7. Topology Setup Shows "Loading" Without Progress
**Route**: `/setup/topology`
**Impact**: The topology overview shows a "Loading" state on page load without indicating what it's loading or how long it'll take. On slow networks this could look stuck.
**Solution**: Add a skeleton loader pattern and "Loading topology data..." text. If the load takes >5s, show a retry option.
**Effort**: S
**Files**: Topology overview component
#### H8. UI Guide References Workspace Names That Don't Match Sidebar
**Impact**: `docs/UI_GUIDE.md` lists workspaces as "Scans/SBOM", "Findings/Triage", "Advisories & VEX", "Runs/Scheduler", "Downloads/Offline". But the sidebar uses "Security Posture", "Supply-Chain Data", "Disposition", "Scheduled Jobs", "Offline Kit". Names don't match.
**Solution**: Update `docs/UI_GUIDE.md` workspace names to match the current sidebar labels exactly.
**Effort**: S
**Files**: `docs/UI_GUIDE.md`
---
### MEDIUM
#### M1. Dashboard Shows Seed Data That Looks Real
**Impact**: The Mission Board shows "Active Promotions: 2", "Blocked: 1", environments with risk scores, SBOM metrics. These are seed/mock values but look like real production data. A new user can't tell what's real vs. demo.
**Solution**: Add a subtle "Demo data" indicator on cards populated from seed data. Or detect when the tenant is `demo-prod` and show a dismissible banner: "You're viewing demo data. Create your own environment to see real metrics."
**Effort**: M
#### M2. Security Posture "Configure sources" Links to Ops Route
**Route**: `/security/posture`
**Impact**: The "Configure sources" action links to `/ops/integrations/advisory-vex-sources` (ops path) instead of `/setup/integrations/advisory-vex-sources` (setup path). This inconsistency may confuse users who navigated through Setup.
**Effort**: S
#### M3. Evidence "Decision Capsules" Name Is Unclear
**Route**: `/evidence/capsules`
**Impact**: "Decision Capsules" is a unique Stella Ops term. Without context, a first-time user won't know what this page does. The page renders content but the concept isn't self-documenting.
**Solution**: Add a subtitle under the heading: "Sealed evidence bundles that package inputs, verdicts, and approvals for each release decision."
**Effort**: S
#### M4. Policy Studio Needs a "What to Do First" Panel
**Route**: `/ops/policy/overview`
**Impact**: The Policy Decisioning Studio has many tabs (Baselines, Gates, Simulation, Waivers, Risk Budget, etc.) but no guidance on what order to use them. New users don't know if they should start with baselines, gates, or simulation.
**Solution**: Add a collapsible "Getting Started with Policies" panel that suggests: 1. Review baselines → 2. Define gates → 3. Test with simulation → 4. Enable shadow mode.
**Effort**: M
#### M5. "Ctrl+K" Command Palette Not Shown as Hint
**Impact**: The command palette is powerful (search + quick actions) but there's no UI hint that it exists. Users who don't know the shortcut will miss it entirely.
**Solution**: Add a "Search (Ctrl+K)" pill in the topbar search area. The current search icon doesn't communicate the keyboard shortcut.
**Effort**: S
#### M6. Operations Hub Has 11+ Sub-pages Without Grouping
**Route**: `/ops/operations`
**Impact**: Jobs, Signals, Offline Kit, Dead Letter, AOC, Doctor, Packs, AI Runs, Notifications, Status — these are listed flat without category headers. New users can't prioritize.
**Solution**: Group operations into: "Health & Monitoring" (System Health, SLO, Doctor, Signals), "Job Management" (JobEngine, Scheduler, Dead Letter, Packs), "Other" (Offline Kit, AOC, AI Runs).
**Effort**: M
#### M7. Admin Pages Duplicated Across /setup and /administration Routes
**Impact**: `/setup/identity-access` and `/administration/identity-access` exist. `/setup/trust-signing` and `/administration/trust-signing` redirect to the same components. This route duplication is confusing — users might bookmark one path while docs reference another.
**Solution**: Canonicalize to one path family (`/setup/` for all admin). Add permanent redirects from `/administration/*` to `/setup/*`.
**Effort**: M
#### M8. Notifications Setup Has No Preview/Test
**Route**: `/setup/notifications`
**Impact**: Users can configure notification channels and rules but there's no "Send test notification" button to verify the channel works before going live.
**Solution**: Add a "Test" button next to each channel that sends a sample notification.
**Effort**: M
#### M9. Usage & Limits Shows Configuration But No Actual Usage
**Route**: `/setup/usage`
**Impact**: The page shows quota configuration (limits, thresholds) but doesn't show current consumption. Users can't see "how much of my quota am I using?"
**Solution**: Add current usage metrics next to quota limits: "Storage: 24 GB / 100 GB (24%)", "API calls today: 312 / 600".
**Effort**: M (backend may already have this data via QuotaCompatibilityEndpoints)
#### M10. Trust & Signing Page Has No Clear First Step
**Route**: `/setup/trust-signing`
**Impact**: The trust management page has many tabs (Keys, Issuers, Certificates, Watchlist, Audit, Air-Gap, Incidents, Analytics) but no "start here" guidance for a fresh install.
**Solution**: Add a setup checklist: 1. Generate signing key → 2. Register issuer → 3. Import CA certificates.
**Effort**: S
---
### LOW
#### L1. Quickstart Database Connection String Is Hardcoded
**Impact**: The quickstart shows `Host=127.1.1.1;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops` as a command-line argument. This works for local dev but exposes credentials in shell history.
**Solution**: Note in docs that the connection string should use environment variables in production. Add `--connection $STELLAOPS_DB` pattern.
**Effort**: S
#### L2. Screenshot/Visual Guide Missing from Docs
**Impact**: All docs are text-only. No screenshots of the UI, no annotated diagrams showing where to click. Visual learners have no reference.
**Solution**: Add a "Visual Tour" section to `docs/UI_GUIDE.md` with annotated screenshots of: Dashboard, Sidebar, Releases, Security Posture, Policy Studio.
**Effort**: L
#### L3. Config Sample File Has Internal Service URLs
**Impact**: `src/Web/StellaOps.Web/src/config/config.sample.json` contains URLs like `http://router.stella-ops.local` which only work in the Docker network. An external deployer wouldn't know what to change.
**Solution**: Add comments/documentation in config.sample.json explaining each URL and what to replace for external deployment.
**Effort**: S
#### L4. Air-Gap Mode Not Discoverable from Main Navigation
**Impact**: Offline/air-gap is a key differentiator but the feature is buried under Operations → Offline Kit. A user evaluating air-gap support might not find it.
**Solution**: Add "Air-Gap Mode" as a visible chip/badge in the topbar when offline mode is configured. Add a link from the Platform Setup home page.
**Effort**: S
#### L5. CLI `stella topology` Commands Not Documented in User Docs
**Impact**: The CLI has topology setup, validate, status, rename, delete commands but they're not mentioned in the quickstart or UI guide. Users might not know the CLI exists.
**Solution**: Add a CLI reference section to `docs/quickstart.md` or a new `docs/CLI_REFERENCE.md`.
**Effort**: M
---
## Priority Matrix
| Effort\Severity | Critical | High | Medium | Low |
|:---:|:---:|:---:|:---:|:---:|
| **S** | — | H4, H5, H7, H8 | M2, M3, M5, M10 | L1, L3, L4 |
| **M** | C3, C4 | H1, H2, H6 | M1, M4, M6, M7, M8, M9 | L5 |
| **L** | — | H3 | — | L2 |
| **S (code)** | C1 | — | — | — |
| **M (code)** | C2 | — | — | — |
**Recommended attack order**: C1 → H5 → C4 → C3 → H1 → C2 → H8 → H4 → M5 → M3
---
## Verification
- Playwright audit script: `src/Web/StellaOps.Web/scripts/live-first-time-user-audit.mjs`
- Audit results: `src/Web/StellaOps.Web/output/playwright/first-time-user-audit.json`
- Screenshots: `src/Web/StellaOps.Web/output/playwright/first-time-user-audit/`
- 41 pages audited, 0 page crashes, 0 API errors, 3 visible error text instances

View File

@@ -0,0 +1,217 @@
# First-Time User Hands-On Audit — Stella Ops
**Date**: 2026-03-16
**Method**: Interactive Playwright browser walkthrough as a first-time DevOps/Security engineer
**Stack**: 63 containers live at `https://stella-ops.local`, demo-prod tenant
---
## What I Did
Walked through Stella Ops as a DevOps engineer who heard "it solves deployment and security auditing" and wants to evaluate it. I set up, logged in, explored the dashboard, drilled into security findings, tried integrations, ran diagnostics, checked policies, and created a release — end to end.
---
## Findings (Ordered by Discovery Sequence)
### F01 — CRITICAL: Dashboard Shows Fake Data, Drilldowns Show Empty
**What happened**: Dashboard shows "Production US East — blocked, 5 critical findings, SBOM missing." I clicked Findings to investigate. The Findings Explorer showed: "No baselines available", "No immediate actions required", 0 findings. The Security Posture page shows "0 findings in scope", "GUARDED" risk posture.
**Root cause**: The Dashboard renders hardcoded seed/compatibility data (environment cards, risk scores, deployment timestamps). The actual security data backends (scanner, findings ledger, VEX hub) have no real scan data. The dashboard doesn't distinguish demo data from real data.
**Impact**: A first-time user thinks there's a security crisis, spends time investigating, then discovers it's all fake. This destroys trust in the platform immediately.
**Proposed solution**:
1. Dashboard should query real backends for environment health, not return hardcoded mock data
2. If no real data exists, show an honest empty state: "No scans have been run yet. Connect a registry and scan your first image."
3. If demo mode is intentional, add a persistent "Demo Data" banner at the top that can be dismissed
**Severity**: CRITICAL — this is the #1 thing that would make me distrust this product
**Effort**: L (requires backend changes to dashboard projection)
---
### F02 — HIGH: Login Credentials Not Documented
**What happened**: Clicked "Sign In", got an OIDC login form. Had no idea what credentials to use. The quickstart doc says "Open https://stella-ops.local" but never mentions username/password. I had to know the seed SQL to find `admin / Admin@Stella2026!`.
**Proposed solution**: Add to `docs/quickstart.md` step 5: "Log in with **admin** / **Admin@Stella2026!** (demo-prod tenant)". Also show credentials on the Welcome page itself with a "Demo credentials" hint.
**Severity**: HIGH — completely blocks first use
**Effort**: S
---
### F03 — HIGH: AuthRef URI Required for Registry Integration — No Plain Credentials Option
**What happened**: Tried "+ Add Registry". The Harbor wizard requires an "AuthRef URI" (`authref://vault/path#secret`). As a new user, I don't have a vault configured. There's no way to just enter a username/password pair.
**Root cause**: Stella enforces credential indirection (vault references) for security. This is good for production but blocks evaluation/local dev.
**Proposed solution**: Allow a `authref://inline/<base64>` scheme for local/dev mode, or add a "Direct credentials (dev only)" toggle in the wizard. Document the authref format clearly with examples for common vaults (HashiCorp, AWS SSM, env vars).
**Severity**: HIGH — blocks the core integration workflow for new users
**Effort**: M
---
### F04 — HIGH: Registry Search Returns Mock Results Despite API Errors
**What happened**: In the release creation wizard, I searched for "nginx" in the component registry. The UI showed "nginx-service" at `registry.internal/nginx-service` with tag "latest" and digest `sha256:nginx1234567...`. But the browser console had 4 errors: `Failed to load resource: /api/registry/images/search?q=...`.
**Root cause**: The component search has a fallback mock/seed dataset that renders when the real API fails. The user can't tell the result is fake.
**Impact**: I created a release with a fake component. The sealed bundle contains `sha256:nginx12345678...` which doesn't exist in any real registry. This undermines the digest-first integrity model.
**Proposed solution**:
1. When the registry API fails, show an error toast: "Registry search unavailable — connect a registry first"
2. Don't silently fall back to mock data in production flows
3. If mock mode is intentional for demo, mark results with a "Demo" badge
**Severity**: HIGH — silently produces releases with fake artifacts
**Effort**: M
---
### F05 — MEDIUM: Bundle Version Shows Raw UUID Instead of Release Name
**What happened**: After sealing my release "API Gateway v2.1", the bundle version page shows the heading: `a4451892-b6b2-41f6-8539-7b9c6d33d140 v1`. My release name is nowhere on this page.
**Proposed solution**: Show the human-readable release name as the primary heading. Show the UUID/digest as secondary metadata below it.
**Severity**: MEDIUM
**Effort**: S
---
### F06 — MEDIUM: "Created by" Shows User ID Hash, Not Username
**What happened**: Bundle version shows "Created by: b08639745d6549348d843aa311c98958". This is meaningless to an operator.
**Proposed solution**: Resolve user IDs to display names in read-model projections. Show "admin" or "Admin (admin@demo.stella-ops.local)" instead of the hash.
**Severity**: MEDIUM
**Effort**: S
---
### F07 — MEDIUM: Topbar Context Shows "No regions defined" Initially, Then Self-Heals
**What happened**: On first dashboard load, the topbar showed "Region: No regions defined" and "Env: No env defined yet" with disabled dropdowns. But the dashboard below showed 5 environment cards with regions. After a few seconds the topbar populated to "4 regions" / "All environments".
**Root cause**: Race condition — the context controls render before the tenant inventory API returns. The dashboard component has its own seed data that renders immediately.
**Proposed solution**: Show a loading skeleton for the context controls instead of "No regions defined". Or defer rendering until inventory loads.
**Severity**: MEDIUM — confusing but self-resolves
**Effort**: S
---
### F08 — MEDIUM: Doctor "Quick Check" Skips Database Checks
**What happened**: Ran Doctor Quick Check. The `check.db.connection` and `check.db.latency` checks were "Skipped — Check not applicable in current context". But PostgreSQL is running and connected (the platform uses it for everything).
**Root cause**: Quick Check likely runs only a subset of packs. DB checks may require "Normal" or "Full" check level.
**Proposed solution**: At minimum, `check.db.connection` should always run — it's the most critical health check. If it's intentionally excluded from Quick Check, explain why in the UI: "Database checks run in Normal and Full modes."
**Severity**: MEDIUM
**Effort**: S
---
### F09 — MEDIUM: Doctor Stream Endpoint Returns Error
**What happened**: After clicking Quick Check, a console error appeared: `Failed to load resource: /api/v1/doctor/run/dr_.../stream` (status 0). The check results still rendered.
**Root cause**: The Doctor run uses a streaming endpoint (SSE/WebSocket) that failed. The UI has a fallback that loads results after the stream fails.
**Proposed solution**: Either fix the streaming endpoint or suppress the console error when the fallback is used.
**Severity**: MEDIUM — cosmetic but logs errors
**Effort**: S
---
### F10 — MEDIUM: Events Status Flickers Between "DEGRADED" and "CONNECTED"
**What happened**: On first login, topbar showed "Events: DEGRADED" (yellow). After the page re-settled with tenant data, it changed to "Events: CONNECTED" (green).
**Proposed solution**: Don't show DEGRADED during initial connection establishment. Use a "Connecting..." state during the first 5 seconds after login.
**Severity**: MEDIUM
**Effort**: S
---
### F11 — LOW: Advisories Show "stale" on Fresh Install
**What happened**: Security Posture page shows "GitHub Advisory Database — stale", "NVD — stale", "OSV — stale" under Advisories & VEX Health.
**Root cause**: Expected — no feeds have been synced on a fresh install. But "stale" implies they were once fresh and degraded.
**Proposed solution**: For sources that have never been synced, show "Not synced" instead of "stale". Reserve "stale" for sources whose last sync is older than the threshold.
**Severity**: LOW
**Effort**: S
---
### F12 — LOW: "Target path intent" Dropdown Shows "?" Instead of "→"
**What happened**: In the release creation wizard, the target path dropdown shows "Dev ? Stage ? Prod" instead of "Dev → Stage → Prod". The arrow character isn't rendering.
**Proposed solution**: Use HTML entity `→` or Unicode `\u2192` instead of whatever character is being used.
**Severity**: LOW — cosmetic
**Effort**: S
---
### F13 — OBSERVATION: What Works Well
Things that impressed me as a first-time user:
- **Welcome page**: Clean, professional, one "Sign In" button — no confusion
- **Integrations Hub**: "Suggested Setup Order" section is exactly what a new user needs — tells you what to connect first and why
- **Doctor Diagnostics**: Excellent — failed checks expand with "Likely Causes", remediation steps, copy buttons, and a `stella doctor` verification command
- **Release Creation Wizard**: 4-step flow with digest-first component selection is elegant. The "Seal Draft" semantics with confirmation checkbox is clear and intentional
- **Policy Decisioning Studio**: 7-tab layout with cards is well-organized. Overview page explains the canonical route family
- **Topbar status chips**: "Events: CONNECTED", "Policy: Core Policy Pack latest", "Evidence: ON", "Feed: Live", "Offline: OK" — gives immediate platform health context
- **Sidebar organization**: 3 logical groups (Release Control, Security & Audit, Platform & Setup) makes sense
- **Context-aware topbar actions**: "Create Release" on releases pages, "Add Integration" on ops pages — smart
- **Breadcrumbs**: Work correctly, provide navigation context
- **Global search**: Ctrl+K hint visible, search placeholder "Search everything..." is inviting
---
## Priority Matrix
| # | Severity | Issue | Effort | Fix First? |
|---|----------|-------|--------|-----------|
| F01 | CRITICAL | Dashboard fake data vs empty backends | L | Yes — blocks trust |
| F02 | HIGH | Login credentials not in docs | S | Yes — 1 line fix |
| F03 | HIGH | AuthRef required, no plain credential option | M | Yes — blocks integration |
| F04 | HIGH | Registry search returns mock data silently | M | Yes — integrity risk |
| F05 | MEDIUM | Bundle shows UUID not release name | S | Quick win |
| F06 | MEDIUM | Created-by shows hash not username | S | Quick win |
| F07 | MEDIUM | Context controls race condition | S | Quick win |
| F08 | MEDIUM | Doctor Quick Check skips DB checks | S | Quick win |
| F09 | MEDIUM | Doctor stream endpoint error | S | Cosmetic |
| F10 | MEDIUM | Events status flickers on login | S | Cosmetic |
| F11 | LOW | "stale" vs "not synced" for fresh sources | S | Polish |
| F12 | LOW | Arrow character not rendering | S | Polish |
**Recommended attack order**: F02 → F01 → F04 → F03 → F05 → F06 → F07
---
## Summary as a First-Time User
**Would I adopt this product?** The feature depth is remarkable — release orchestration with digest-first identity, policy-as-code, VEX decisioning, evidence capsules, diagnostics engine, 75 advisory sources. The architecture is clearly well-thought-out.
**What would stop me?** The fake dashboard data is a dealbreaker for evaluation. I can't trust the security posture numbers if they're hardcoded. The integration setup requires vault infrastructure I don't have yet. And the registry search silently producing mock results means I can't trust that my releases contain real artifacts.
**What would I tell my team?** "The vision is excellent, the security model is serious (digest-first, sealed releases, evidence capsules, replay parity). But it needs a 'getting started for real' mode that doesn't mix demo data with real workflows. Once you get past the initial setup friction, the operator experience in Policy, Doctor, and Releases is genuinely good."

View File

@@ -0,0 +1,232 @@
# First-Time User Audit Series — Stella Ops
**Date**: 2026-03-16
**Method**: Interactive Playwright walkthrough + source code analysis + product deep-dive
**Stack**: 63 containers live at `https://stella-ops.local`, demo-prod tenant
---
## Part 1: The Product Mental Model
Stella Ops is a **release control plane** for non-Kubernetes container estates. Every operator interaction answers one of three questions:
1. **Is it safe?** — Vulnerability findings, SBOM health, reachability evidence, VEX dispositions, advisory feed freshness
2. **Is it approved?** — Policy gates passed, human approvals given, evidence sealed
3. **Is it working?** — Services healthy, feeds syncing, jobs running, no dead letters
The UI, documentation, and workflows must be organized around these three pillars.
---
## Part 2: Findings (Prioritized)
### CRITICAL
#### C1. Dashboard is 100% Hardcoded — Zero Real API Data
**Source file**: `dashboard-v3.component.ts:966-1200`
Every number on the dashboard is fake:
- `summary` signal: `{ activePromotions: 3, blockedPromotions: 1 }` — literal
- `resolveStatusSeed()`: generates metrics by env type (dev=healthy, staging=degraded, prod+us-east=blocked)
- `reachabilityStats`: `{ bCoverage: 72, iCoverage: 88, rCoverage: 61 }` — literal
- `nightlyOpsSignals`: 4 hardcoded items
- Alerts: 3 hardcoded `<li>` HTML elements
- Activity: 3 hardcoded cards
- **Not a single API call to any backend service**
The dashboard tells you "5 critical findings, blocked" but security posture shows 0 findings. This destroys trust immediately.
**Solution**: [See Part 3 — Dashboard Redesign]
#### C2. StellaOps Mirror Source Enabled at Priority 1 on Every Fresh Install
**Source file**: `SourceDefinitions.cs:1351-1366`
The `StellaMirror` source definition:
```csharp
BaseEndpoint = "https://mirror.stella-ops.org/api/v1" // nonexistent external URL
DefaultPriority = 1 // HIGHEST priority — overrides all other sources
EnabledByDefault = true // inherited, always on
```
On every fresh self-hosted install, the highest-priority advisory source points to a URL that doesn't exist. Health check returns "SSL/TLS error." The system is in "Direct" mode but its top-priority source is a mirror.
**Solution**: Set `EnabledByDefault = false` on StellaMirror. Only enable when mirror consumer wizard is completed.
#### C3. Mirror Source, Mirror Mode, and Mirror Consumer Wizard Are Disconnected
Three independent systems that should be one:
1. **Catalog toggle**: Enable "StellaOps Mirror" → hardcoded to `mirror.stella-ops.org`
2. **Mode badge**: Shows "Direct" regardless of which sources are enabled
3. **Consumer wizard**: "Connect to Mirror" → 4-step wizard with custom URL
Enabling the catalog source doesn't invoke the wizard. Completing the wizard doesn't update the catalog source. The mode badge doesn't reflect reality.
**Solution**: Unify — catalog mirror toggle should redirect to consumer wizard. Wizard completion should update source endpoint + enable source + set mode.
#### C4. 74 of 75 Advisory Sources Enabled by Default — 18 Fail Immediately
`EnabledByDefault = true` in the base `SourceDefinition` class. Every source — including geo-restricted (FSTEC Russia, NKCKI Russia), ecosystem-specific (npm, PyPI, Maven, etc.), and the broken mirror — is enabled on fresh install.
After "Check All": 55 healthy, 18 failed. Stats bar shows confusing "55 enabled" (doesn't match 74 actually enabled).
**Solution**: Curate defaults. Enable ~25 core sources (Primary + major vendors + major distributions). Disable ecosystem, geo-restricted, hardware, and mirror by default.
### HIGH
#### H1. Login Credentials Not Documented Anywhere
Quickstart says "Open https://stella-ops.local" — never mentions username/password. User must find `S001_demo_seed.sql` to discover `admin / Admin@Stella2026!`.
**Solution**: Add credentials to quickstart.md step 5. Add hint to the Welcome page.
#### H2. Registry Search Returns Mock Results Despite API Failures
In release creation wizard, searching "nginx" returns "nginx-service" at `registry.internal/nginx-service` with fake digest `sha256:nginx1234567...`. Console shows 4 errors: `Failed to load resource: /api/registry/images/search`. The mock fallback silently produces results from a nonexistent registry.
**Solution**: When registry API fails, show error — not mock results. Don't allow sealing releases with mock artifacts.
#### H3. Create Mirror Domain Wizard Allows Selecting Mirror Source
The source picker in Create Domain includes "StellaOps Mirror" under Mirror (1) category. This creates a mirror domain that sources from a nonexistent upstream mirror — circular/broken dependency.
**Solution**: Filter out `category === 'Mirror'` from the Create Domain source picker.
#### H4. AuthRef URI Required — No Plain Credentials for Evaluation
Harbor registry wizard requires `authref://vault/path#secret`. No vault exists on a fresh install. No way to enter username:password for evaluation.
**Solution**: Allow `authref://env/VARIABLE_NAME` or `authref://inline/base64` for dev/eval. Document authref format with examples.
### MEDIUM
#### M1. Bundle Version Shows UUID Instead of Release Name
After sealing "API Gateway v2.1", the page shows `a4451892-b6b2-41f6-8539-7b9c6d33d140 v1` as the heading.
**Solution**: Show human-readable name as primary heading, UUID as metadata.
#### M2. "Created by" Shows User ID Hash, Not Username
Bundle version shows `b08639745d6549348d843aa311c98958` instead of "admin".
**Solution**: Resolve user IDs to display names in read-model projections.
#### M3. Context Controls Race Condition on First Load
Topbar shows "No regions defined" for 2-3 seconds, then self-heals to "4 regions". Dashboard below shows environment cards immediately (from seed data).
**Solution**: Show loading skeleton for context controls until inventory loads.
#### M4. Doctor Quick Check Skips Database Checks
`check.db.connection` and `check.db.latency` show "Skipped — not applicable" despite Postgres being the primary database.
**Solution**: Always include DB connection check in Quick Check.
#### M5. Doctor Stream Endpoint Returns Console Error
After Quick Check, console shows `Failed to load resource: /api/v1/doctor/run/.../stream`. Results still render via fallback.
**Solution**: Fix streaming endpoint or suppress console error when fallback succeeds.
#### M6. Events Status Flickers "DEGRADED" → "CONNECTED" on Login
First 2-3 seconds show "Events: DEGRADED" (yellow), then settles to "CONNECTED" (green).
**Solution**: Show "Connecting..." during initial establishment, not "DEGRADED".
#### M7. Advisory Sources Show "stale" on Fresh Install (Never Synced)
Security Posture: "GitHub Advisory Database — stale", "NVD — stale". These have never been synced.
**Solution**: Show "Not synced" for sources that have never fetched. Reserve "stale" for sources whose last sync exceeds freshness threshold.
#### M8. Feature Matrix Shows Release Orchestration as "Planned" (⏳) — But It's Built
`FEATURE_MATRIX.md` lists Environment CRUD, Release Bundles, Promotion Workflows as ⏳. The live UI has working releases, approvals, promotions. A buyer would think releases don't work.
**Solution**: Update FEATURE_MATRIX.md to reflect current implementation status.
#### M9. Stats Bar Count Mismatch
After Check All, stats show "55 enabled" but 74 sources are actually enabled (toggles ON). The "enabled" count in the stats bar doesn't match the source state.
**Solution**: Fix stats computation to count actual enabled sources, not only those with successful health checks.
### LOW
#### L1. Arrow Character Not Rendering in Target Path
Release wizard dropdown shows "Dev ? Stage ? Prod" instead of "Dev → Stage → Prod".
**Solution**: Use `\u2192` or HTML entity `→`.
#### L2. No 404 Page — Unknown Routes Show Dashboard
`/nonexistent-route-12345` renders the Dashboard instead of "Page Not Found".
**Solution**: Add wildcard catch-all route with a proper 404 component.
---
## Part 3: Dashboard Redesign
### Layout: 3-Column Mission Board
```
┌──────────────────────────────────────────────────────────────┐
│ Mission Board — [Demo Production] [↻ Refresh] │
│ Last updated: 15 Mar 2026, 23:15 UTC │
├──────────────────┬───────────────────────────────────────────┤
│ │ │
│ SECURITY POSTURE │ ENVIRONMENTS & ACTIONS (2/3) │
│ (1/3) │ │
│ │ ┌───────────────────────────────────────┐ │
│ ┌──────────────┐ │ │ PROMOTION PIPELINE │ │
│ │ Vuln Summary │ │ │ [env cards: Dev→Stage→Prod by region] │ │
│ │ C:12 H:34 │ │ │ blocked envs highlighted at top │ │
│ │ M:89 L:156 │ │ └───────────────────────────────────────┘ │
│ │ [donut chart] │ │ │
│ │ Reachable: 9 │ │ ┌───────────────────────────────────────┐ │
│ │ Unknown: 23 │ │ │ NEEDS ATTENTION │ │
│ └──────────────┘ │ │ ⚠ 3 approvals blocked │ │
│ │ │ ⚠ 2 waivers expiring │ │
│ ┌──────────────┐ │ │ 🔴 Feed freshness degraded │ │
│ │ SBOM Health │ │ └───────────────────────────────────────┘ │
│ │ 247 comps │ │ │
│ │ 231 fresh │ │ ┌───────────────────────────────────────┐ │
│ │ B:72% I:88% │ │ │ LIVE ACTIVITY │ │
│ │ R:61% │ │ │ • admin sealed "API Gateway v2.1" │ │
│ └──────────────┘ │ │ • NVD synced (142 new advisories) │ │
│ │ │ • Doctor: 7 pass, 1 warn, 1 fail │ │
│ ┌──────────────┐ │ └───────────────────────────────────────┘ │
│ │ Feed Status │ │ │
│ │ 55/75 active │ │ │
│ │ 18 failed │ │ │
│ └──────────────┘ │ │
├──────────────────┴───────────────────────────────────────────┤
│ PLATFORM: 63 svcs ✓ │ DB:OK │ Events:ON │ Doctor:7/1/1 │
│ Feed:Live │ Evidence:ON │ Offline:OK │ DLQ:3 pending │
└──────────────────────────────────────────────────────────────┘
```
### Empty State (Fresh Install)
When no real data exists, show setup guidance — not fake numbers:
```
Welcome to Stella Ops — Release Control Plane
① Connect a registry [Setup Integrations →]
② Define your topology [Topology Wizard →]
③ Scan your first image [Start Scan →]
④ Create a release [Create Release →]
Platform Health: 63/63 services ✓
Advisory Sources: 55/75 healthy [Configure →]
```
---
## Part 4: Solutions Implementation Map
| # | Fix | Effort | Files |
|---|-----|--------|-------|
| C2 | Mirror source `EnabledByDefault = false` | S | `SourceDefinitions.cs` |
| C4 | Curate default sources (~25 instead of 74) | S | `SourceDefinitions.cs` |
| H1 | Add credentials to quickstart | S | `docs/quickstart.md` |
| H3 | Filter mirror sources from domain builder | S | `mirror-domain-builder.component.ts` |
| M8 | Update Feature Matrix status markers | S | `docs/FEATURE_MATRIX.md` |
| L1 | Fix arrow character encoding | S | Release version form component |
| L2 | Add 404 catch-all route | S | `app.routes.ts` |
| M3 | Loading skeleton for context controls | S | `app-topbar.component.ts` |
| M6 | "Connecting..." instead of "DEGRADED" | S | topbar events status |
| M7 | "Not synced" vs "stale" for fresh sources | S | `advisory-source-catalog.component.ts` |
| M9 | Fix stats bar enabled count | S | `advisory-source-catalog.component.ts` |
| C1/Phase1 | Dashboard honest empty state | M | `dashboard-v3.component.ts` |
| C3 | Unify mirror toggle → consumer wizard | M | catalog + wizard components |
| H2 | Registry search error instead of mock | M | release version wizard |
| C1/Phase2 | Dashboard 3-column layout with real APIs | L | `dashboard-v3.component.ts` + API clients |

View File

@@ -96,6 +96,12 @@ To skip builds and only start infrastructure:
--connection "Host=127.1.1.1;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops" --connection "Host=127.1.1.1;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops"
``` ```
5. Open **https://stella-ops.local**. 5. Open **https://stella-ops.local**.
6. Log in with the demo admin account:
- **Username**: `admin`
- **Password**: `Admin@Stella2026!`
- **Tenant**: demo-prod (selected automatically)
Additional demo accounts: `operator`, `viewer`, `auditor`, `developer` (same password pattern).
## What's running ## What's running

View File

@@ -611,7 +611,8 @@ public static class SourceDefinitions
HttpClientName = "NpmClient", HttpClientName = "NpmClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 50, DefaultPriority = 50,
Tags = ImmutableArray.Create("npm", "ecosystem", "javascript", "node") Tags = ImmutableArray.Create("npm", "ecosystem", "javascript", "node"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition PyPi = new() public static readonly SourceDefinition PyPi = new()
@@ -626,7 +627,8 @@ public static class SourceDefinitions
HttpClientName = "PyPiClient", HttpClientName = "PyPiClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 52, DefaultPriority = 52,
Tags = ImmutableArray.Create("pypi", "ecosystem", "python") Tags = ImmutableArray.Create("pypi", "ecosystem", "python"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Go = new() public static readonly SourceDefinition Go = new()
@@ -641,7 +643,8 @@ public static class SourceDefinitions
HttpClientName = "GoClient", HttpClientName = "GoClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 54, DefaultPriority = 54,
Tags = ImmutableArray.Create("go", "ecosystem", "golang") Tags = ImmutableArray.Create("go", "ecosystem", "golang"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition RubyGems = new() public static readonly SourceDefinition RubyGems = new()
@@ -656,7 +659,8 @@ public static class SourceDefinitions
HttpClientName = "RubyGemsClient", HttpClientName = "RubyGemsClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 56, DefaultPriority = 56,
Tags = ImmutableArray.Create("rubygems", "ecosystem", "ruby") Tags = ImmutableArray.Create("rubygems", "ecosystem", "ruby"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Nuget = new() public static readonly SourceDefinition Nuget = new()
@@ -672,7 +676,8 @@ public static class SourceDefinitions
RequiresAuthentication = true, RequiresAuthentication = true,
CredentialEnvVar = "GITHUB_PAT", CredentialEnvVar = "GITHUB_PAT",
DefaultPriority = 58, DefaultPriority = 58,
Tags = ImmutableArray.Create("nuget", "ecosystem", "dotnet", "csharp") Tags = ImmutableArray.Create("nuget", "ecosystem", "dotnet", "csharp"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Maven = new() public static readonly SourceDefinition Maven = new()
@@ -687,7 +692,8 @@ public static class SourceDefinitions
HttpClientName = "MavenClient", HttpClientName = "MavenClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 60, DefaultPriority = 60,
Tags = ImmutableArray.Create("maven", "ecosystem", "java") Tags = ImmutableArray.Create("maven", "ecosystem", "java"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Crates = new() public static readonly SourceDefinition Crates = new()
@@ -702,7 +708,8 @@ public static class SourceDefinitions
HttpClientName = "CratesClient", HttpClientName = "CratesClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 62, DefaultPriority = 62,
Tags = ImmutableArray.Create("crates", "ecosystem", "rust") Tags = ImmutableArray.Create("crates", "ecosystem", "rust"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Packagist = new() public static readonly SourceDefinition Packagist = new()
@@ -717,7 +724,8 @@ public static class SourceDefinitions
HttpClientName = "PackagistClient", HttpClientName = "PackagistClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 64, DefaultPriority = 64,
Tags = ImmutableArray.Create("packagist", "ecosystem", "php") Tags = ImmutableArray.Create("packagist", "ecosystem", "php"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Hex = new() public static readonly SourceDefinition Hex = new()
@@ -732,7 +740,8 @@ public static class SourceDefinitions
HttpClientName = "HexClient", HttpClientName = "HexClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 66, DefaultPriority = 66,
Tags = ImmutableArray.Create("hex", "ecosystem", "elixir", "erlang") Tags = ImmutableArray.Create("hex", "ecosystem", "elixir", "erlang"),
EnabledByDefault = false
}; };
// ===== CSAF/VEX Sources ===== // ===== CSAF/VEX Sources =====
@@ -927,7 +936,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
DocumentationUrl = "https://www.exploit-db.com/", DocumentationUrl = "https://www.exploit-db.com/",
DefaultPriority = 110, DefaultPriority = 110,
Tags = ImmutableArray.Create("exploit", "poc", "offensive") Tags = ImmutableArray.Create("exploit", "poc", "offensive"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition PocGithub = new() public static readonly SourceDefinition PocGithub = new()
@@ -943,7 +953,8 @@ public static class SourceDefinitions
RequiresAuthentication = true, RequiresAuthentication = true,
CredentialEnvVar = "GITHUB_PAT", CredentialEnvVar = "GITHUB_PAT",
DefaultPriority = 112, DefaultPriority = 112,
Tags = ImmutableArray.Create("exploit", "poc", "github") Tags = ImmutableArray.Create("exploit", "poc", "github"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Metasploit = new() public static readonly SourceDefinition Metasploit = new()
@@ -958,7 +969,8 @@ public static class SourceDefinitions
HttpClientName = "MetasploitClient", HttpClientName = "MetasploitClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 114, DefaultPriority = 114,
Tags = ImmutableArray.Create("exploit", "metasploit", "rapid7") Tags = ImmutableArray.Create("exploit", "metasploit", "rapid7"),
EnabledByDefault = false
}; };
// ===== Cloud Provider Advisories ===== // ===== Cloud Provider Advisories =====
@@ -1054,7 +1066,8 @@ public static class SourceDefinitions
HttpClientName = "IntelClient", HttpClientName = "IntelClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 130, DefaultPriority = 130,
Tags = ImmutableArray.Create("intel", "hardware", "firmware", "cpu") Tags = ImmutableArray.Create("intel", "hardware", "firmware", "cpu"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Amd = new() public static readonly SourceDefinition Amd = new()
@@ -1069,7 +1082,8 @@ public static class SourceDefinitions
HttpClientName = "AmdClient", HttpClientName = "AmdClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 132, DefaultPriority = 132,
Tags = ImmutableArray.Create("amd", "hardware", "firmware", "cpu") Tags = ImmutableArray.Create("amd", "hardware", "firmware", "cpu"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Arm = new() public static readonly SourceDefinition Arm = new()
@@ -1084,7 +1098,8 @@ public static class SourceDefinitions
HttpClientName = "ArmClient", HttpClientName = "ArmClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 134, DefaultPriority = 134,
Tags = ImmutableArray.Create("arm", "hardware", "firmware", "cpu") Tags = ImmutableArray.Create("arm", "hardware", "firmware", "cpu"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Siemens = new() public static readonly SourceDefinition Siemens = new()
@@ -1099,7 +1114,8 @@ public static class SourceDefinitions
HttpClientName = "SiemensClient", HttpClientName = "SiemensClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 136, DefaultPriority = 136,
Tags = ImmutableArray.Create("siemens", "ics", "scada", "hardware") Tags = ImmutableArray.Create("siemens", "ics", "scada", "hardware"),
EnabledByDefault = false
}; };
// ===== Package Manager Native Advisories ===== // ===== Package Manager Native Advisories =====
@@ -1116,7 +1132,8 @@ public static class SourceDefinitions
HttpClientName = "RustSecClient", HttpClientName = "RustSecClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 63, DefaultPriority = 63,
Tags = ImmutableArray.Create("rustsec", "package-manager", "rust", "cargo") Tags = ImmutableArray.Create("rustsec", "package-manager", "rust", "cargo"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition PyPa = new() public static readonly SourceDefinition PyPa = new()
@@ -1131,7 +1148,8 @@ public static class SourceDefinitions
HttpClientName = "PyPaClient", HttpClientName = "PyPaClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 53, DefaultPriority = 53,
Tags = ImmutableArray.Create("pypa", "package-manager", "python", "pip") Tags = ImmutableArray.Create("pypa", "package-manager", "python", "pip"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition GoVuln = new() public static readonly SourceDefinition GoVuln = new()
@@ -1146,7 +1164,8 @@ public static class SourceDefinitions
HttpClientName = "GoVulnClient", HttpClientName = "GoVulnClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 55, DefaultPriority = 55,
Tags = ImmutableArray.Create("govuln", "package-manager", "go", "golang") Tags = ImmutableArray.Create("govuln", "package-manager", "go", "golang"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition BundlerAudit = new() public static readonly SourceDefinition BundlerAudit = new()
@@ -1161,7 +1180,8 @@ public static class SourceDefinitions
HttpClientName = "BundlerAuditClient", HttpClientName = "BundlerAuditClient",
RequiresAuthentication = false, RequiresAuthentication = false,
DefaultPriority = 57, DefaultPriority = 57,
Tags = ImmutableArray.Create("bundler", "package-manager", "ruby", "rubysec") Tags = ImmutableArray.Create("bundler", "package-manager", "ruby", "rubysec"),
EnabledByDefault = false
}; };
// ===== Additional CERTs ===== // ===== Additional CERTs =====
@@ -1179,7 +1199,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("UA"), Regions = ImmutableArray.Create("UA"),
DefaultPriority = 95, DefaultPriority = 95,
Tags = ImmutableArray.Create("cert", "ukraine") Tags = ImmutableArray.Create("cert", "ukraine"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition CertPl = new() public static readonly SourceDefinition CertPl = new()
@@ -1195,7 +1216,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("PL", "EU"), Regions = ImmutableArray.Create("PL", "EU"),
DefaultPriority = 96, DefaultPriority = 96,
Tags = ImmutableArray.Create("cert", "poland", "eu") Tags = ImmutableArray.Create("cert", "poland", "eu"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition AusCert = new() public static readonly SourceDefinition AusCert = new()
@@ -1211,7 +1233,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("AU", "APAC"), Regions = ImmutableArray.Create("AU", "APAC"),
DefaultPriority = 97, DefaultPriority = 97,
Tags = ImmutableArray.Create("cert", "australia", "apac") Tags = ImmutableArray.Create("cert", "australia", "apac"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition KrCert = new() public static readonly SourceDefinition KrCert = new()
@@ -1227,7 +1250,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("KR", "APAC"), Regions = ImmutableArray.Create("KR", "APAC"),
DefaultPriority = 98, DefaultPriority = 98,
Tags = ImmutableArray.Create("cert", "korea", "apac") Tags = ImmutableArray.Create("cert", "korea", "apac"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition CertIn = new() public static readonly SourceDefinition CertIn = new()
@@ -1243,7 +1267,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("IN", "APAC"), Regions = ImmutableArray.Create("IN", "APAC"),
DefaultPriority = 99, DefaultPriority = 99,
Tags = ImmutableArray.Create("cert", "india", "apac") Tags = ImmutableArray.Create("cert", "india", "apac"),
EnabledByDefault = false
}; };
// ===== Russian/CIS Sources ===== // ===== Russian/CIS Sources =====
@@ -1261,7 +1286,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("RU", "CIS"), Regions = ImmutableArray.Create("RU", "CIS"),
DefaultPriority = 100, DefaultPriority = 100,
Tags = ImmutableArray.Create("fstec", "bdu", "russia", "cis") Tags = ImmutableArray.Create("fstec", "bdu", "russia", "cis"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition Nkcki = new() public static readonly SourceDefinition Nkcki = new()
@@ -1277,7 +1303,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("RU", "CIS"), Regions = ImmutableArray.Create("RU", "CIS"),
DefaultPriority = 101, DefaultPriority = 101,
Tags = ImmutableArray.Create("nkcki", "russia", "cis", "cert") Tags = ImmutableArray.Create("nkcki", "russia", "cis", "cert"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition KasperskyIcs = new() public static readonly SourceDefinition KasperskyIcs = new()
@@ -1293,7 +1320,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("RU", "CIS", "GLOBAL"), Regions = ImmutableArray.Create("RU", "CIS", "GLOBAL"),
DefaultPriority = 102, DefaultPriority = 102,
Tags = ImmutableArray.Create("kaspersky", "ics", "russia", "cis", "scada") Tags = ImmutableArray.Create("kaspersky", "ics", "russia", "cis", "scada"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition AstraLinux = new() public static readonly SourceDefinition AstraLinux = new()
@@ -1309,7 +1337,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
Regions = ImmutableArray.Create("RU", "CIS"), Regions = ImmutableArray.Create("RU", "CIS"),
DefaultPriority = 48, DefaultPriority = 48,
Tags = ImmutableArray.Create("astra", "distro", "linux", "fstec", "russia") Tags = ImmutableArray.Create("astra", "distro", "linux", "fstec", "russia"),
EnabledByDefault = false
}; };
// ===== Threat Intelligence ===== // ===== Threat Intelligence =====
@@ -1327,7 +1356,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
DocumentationUrl = "https://attack.mitre.org/", DocumentationUrl = "https://attack.mitre.org/",
DefaultPriority = 140, DefaultPriority = 140,
Tags = ImmutableArray.Create("mitre", "attack", "threat-intel", "tactics") Tags = ImmutableArray.Create("mitre", "attack", "threat-intel", "tactics"),
EnabledByDefault = false
}; };
public static readonly SourceDefinition MitreD3fend = new() public static readonly SourceDefinition MitreD3fend = new()
@@ -1343,7 +1373,8 @@ public static class SourceDefinitions
RequiresAuthentication = false, RequiresAuthentication = false,
DocumentationUrl = "https://d3fend.mitre.org/", DocumentationUrl = "https://d3fend.mitre.org/",
DefaultPriority = 142, DefaultPriority = 142,
Tags = ImmutableArray.Create("mitre", "d3fend", "threat-intel", "defensive") Tags = ImmutableArray.Create("mitre", "d3fend", "threat-intel", "defensive"),
EnabledByDefault = false
}; };
// ===== StellaOps Mirror ===== // ===== StellaOps Mirror =====
@@ -1362,7 +1393,8 @@ public static class SourceDefinitions
StatusPageUrl = "https://status.stella-ops.org/", StatusPageUrl = "https://status.stella-ops.org/",
DocumentationUrl = "https://docs.stella-ops.org/mirror/", DocumentationUrl = "https://docs.stella-ops.org/mirror/",
DefaultPriority = 1, // Highest priority when using mirror mode DefaultPriority = 1, // Highest priority when using mirror mode
Tags = ImmutableArray.Create("stella", "mirror", "aggregated") Tags = ImmutableArray.Create("stella", "mirror", "aggregated"),
EnabledByDefault = false
}; };
// ===== All Sources Collection ===== // ===== All Sources Collection =====

View File

@@ -0,0 +1,709 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Globalization;
namespace StellaOps.Platform.WebService.Endpoints;
public static class QuotaCompatibilityEndpoints
{
public static IEndpointRouteBuilder MapQuotaCompatibilityEndpoints(this IEndpointRouteBuilder app)
{
// --- Authority quota endpoints (routed from /api/v1/authority/quotas) ---
var quotaGroup = app.MapGroup("/api/v1/authority/quotas")
.WithTags("Quota Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
quotaGroup.MapGet("/dashboard", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
entitlement = BuildEntitlement(now),
consumption = BuildConsumptionMetrics(now),
tenantCount = 3,
activeAlerts = 1,
recentViolations = 4
});
})
.WithName("QuotaCompatibility.GetDashboard");
quotaGroup.MapGet("/", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildEntitlement(now));
})
.WithName("QuotaCompatibility.GetEntitlement");
quotaGroup.MapGet("/consumption", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildConsumptionMetrics(now));
})
.WithName("QuotaCompatibility.GetConsumption");
quotaGroup.MapGet("/history", (
[FromQuery] string? startDate,
[FromQuery] string? endDate,
[FromQuery] string? categories,
[FromQuery] string aggregation,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var effectiveAggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation;
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
var periodEnd = ParseDateOrDefault(endDate, now);
var requestedCategories = string.IsNullOrWhiteSpace(categories)
? new[] { "license", "jobs", "api", "storage", "scans" }
: categories.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return Results.Ok(new
{
period = new
{
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
},
points = BuildHistoryPoints(periodStart, periodEnd, effectiveAggregation, requestedCategories),
aggregation = effectiveAggregation
});
})
.WithName("QuotaCompatibility.GetHistory");
quotaGroup.MapGet("/tenants", (
[FromQuery] string? search,
[FromQuery] string? sortBy,
[FromQuery] string sortDir,
[FromQuery] int limit,
[FromQuery] int offset,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var effectiveLimit = limit > 0 ? limit : 50;
var all = BuildTenantQuotaUsages(now);
if (!string.IsNullOrWhiteSpace(search))
{
all = all.Where(t =>
t.TenantName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
t.TenantId.Contains(search, StringComparison.OrdinalIgnoreCase)).ToArray();
}
var items = all.Skip(offset > 0 ? offset : 0).Take(effectiveLimit).ToArray();
return Results.Ok(new
{
items,
total = all.Length
});
})
.WithName("QuotaCompatibility.GetTenantQuotas");
quotaGroup.MapGet("/tenants/{tenantId}", (
string tenantId,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildTenantBreakdown(tenantId, now));
})
.WithName("QuotaCompatibility.GetTenantBreakdown");
quotaGroup.MapGet("/forecast", (
[FromQuery] string? category,
[FromQuery] string? tenantId,
TimeProvider timeProvider) =>
{
return Results.Ok(BuildForecasts(category));
})
.WithName("QuotaCompatibility.GetForecast");
quotaGroup.MapGet("/alerts", () =>
{
return Results.Ok(BuildAlertConfig());
})
.WithName("QuotaCompatibility.GetAlertConfig");
quotaGroup.MapPost("/alerts", (QuotaAlertConfigRequest config) =>
{
// Accept and echo back the config (mock persistence)
return Results.Ok(config);
})
.WithName("QuotaCompatibility.SaveAlertConfig");
quotaGroup.MapPost("/reports", (
QuotaReportRequestBody request,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
reportId = $"quota-report-{now:yyyyMMddHHmmss}",
status = "completed",
downloadUrl = (string?)null,
createdAt = now.ToString("O", CultureInfo.InvariantCulture),
completedAt = now.AddSeconds(2).ToString("O", CultureInfo.InvariantCulture),
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("QuotaCompatibility.RequestReport");
quotaGroup.MapGet("/reports/{reportId}", (
string reportId,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
reportId,
status = "completed",
downloadUrl = (string?)null,
createdAt = now.AddMinutes(-5).ToString("O", CultureInfo.InvariantCulture),
completedAt = now.AddMinutes(-4).ToString("O", CultureInfo.InvariantCulture),
expiresAt = now.AddHours(24).ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("QuotaCompatibility.GetReportStatus");
// --- Gateway rate-limit endpoints (routed from /api/v1/gateway/rate-limits) ---
var rateLimitGroup = app.MapGroup("/api/v1/gateway/rate-limits")
.WithTags("Quota Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
rateLimitGroup.MapGet("/", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(BuildRateLimitStatuses(now));
})
.WithName("QuotaCompatibility.GetRateLimitStatus");
rateLimitGroup.MapGet("/violations", (
[FromQuery] string? startDate,
[FromQuery] string? endDate,
[FromQuery] string? tenantId,
[FromQuery] int limit,
TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
var effectiveLimit = limit > 0 ? limit : 50;
var periodStart = ParseDateOrDefault(startDate, now.AddDays(-7));
var periodEnd = ParseDateOrDefault(endDate, now);
return Results.Ok(new
{
items = BuildRateLimitViolations(now).Take(effectiveLimit).ToArray(),
total = 3,
period = new
{
start = periodStart.ToString("O", CultureInfo.InvariantCulture),
end = periodEnd.ToString("O", CultureInfo.InvariantCulture)
}
});
})
.WithName("QuotaCompatibility.GetRateLimitViolations");
// --- JobEngine quota endpoints (routed from /api/v1/jobengine/quotas) ---
var jobQuotaGroup = app.MapGroup("/api/v1/jobengine/quotas")
.WithTags("Quota Compatibility")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
.RequireTenant();
jobQuotaGroup.MapGet("/", () =>
{
return Results.Ok(new
{
concurrentJobs = new { current = 3, limit = 10 },
dailyJobLimit = new { current = 47, limit = 500 },
queueDepth = 2,
averageJobDuration = 34.5
});
})
.WithName("QuotaCompatibility.GetJobQuotaStatus");
jobQuotaGroup.MapGet("/summary", (TimeProvider timeProvider) =>
{
var now = timeProvider.GetUtcNow();
return Results.Ok(new
{
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 },
dailyJobLimit = new { current = 47, limit = 500, percentage = 9.4 },
queueDepth = 2,
averageJobDuration = 34.5,
activeWorkers = 4,
totalWorkers = 6,
lastUpdated = now.ToString("O", CultureInfo.InvariantCulture)
});
})
.WithName("QuotaCompatibility.GetJobQuotaSummary");
return app;
}
// ---- Data builders ----
private static object BuildEntitlement(DateTimeOffset now) => new
{
planId = "enterprise-v2",
planName = "Enterprise",
features = new[]
{
"unlimited_tenants", "advanced_scanning", "policy_engine",
"air_gap_mode", "federation", "custom_crypto"
},
limits = new
{
artifacts = 50000,
users = 500,
scansPerDay = 1000,
storageMb = 102400,
concurrentJobs = 10,
apiRequestsPerMinute = 600
},
validFrom = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
validTo = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture),
renewalDate = now.AddMonths(10).ToString("O", CultureInfo.InvariantCulture)
};
private static object[] BuildConsumptionMetrics(DateTimeOffset now)
{
var ts = now.ToString("O", CultureInfo.InvariantCulture);
return
[
new
{
category = "license",
current = 142,
limit = 500,
percentage = 28.4,
status = "healthy",
trend = "up",
trendPercentage = 3.2,
lastUpdated = ts
},
new
{
category = "jobs",
current = 47,
limit = 500,
percentage = 9.4,
status = "healthy",
trend = "stable",
trendPercentage = 0.8,
lastUpdated = ts
},
new
{
category = "api",
current = 312,
limit = 600,
percentage = 52.0,
status = "healthy",
trend = "up",
trendPercentage = 5.1,
lastUpdated = ts
},
new
{
category = "storage",
current = 24576,
limit = 102400,
percentage = 24.0,
status = "healthy",
trend = "up",
trendPercentage = 1.8,
lastUpdated = ts
},
new
{
category = "scans",
current = 187,
limit = 1000,
percentage = 18.7,
status = "healthy",
trend = "stable",
trendPercentage = 0.4,
lastUpdated = ts
}
];
}
private static object[] BuildHistoryPoints(
DateTimeOffset start,
DateTimeOffset end,
string aggregation,
string[] categories)
{
var interval = aggregation switch
{
"hourly" => TimeSpan.FromHours(1),
"weekly" => TimeSpan.FromDays(7),
_ => TimeSpan.FromDays(1)
};
var points = new List<object>();
var cursor = start;
var step = 0;
while (cursor <= end && step < 200) // cap at 200 points
{
foreach (var cat in categories)
{
var (baseVal, limit) = cat switch
{
"license" => (130 + step * 2, 500),
"jobs" => (40 + (step % 10), 500),
"api" => (280 + step * 3, 600),
"storage" => (23000 + step * 100, 102400),
"scans" => (150 + step * 5, 1000),
_ => (50, 500)
};
var value = Math.Min(baseVal, limit);
points.Add(new
{
timestamp = cursor.ToString("O", CultureInfo.InvariantCulture),
category = cat,
value,
percentage = Math.Round(value * 100.0 / limit, 1)
});
}
cursor = cursor.Add(interval);
step++;
}
return points.ToArray();
}
private static TenantQuotaUsageDto[] BuildTenantQuotaUsages(DateTimeOffset now)
{
var ts = now.ToString("O", CultureInfo.InvariantCulture);
return
[
new TenantQuotaUsageDto(
"demo-prod",
"Demo Production",
"Enterprise",
new TenantQuotasDto(
new QuotaMetricDto("license", 82, 200, 41.0, "healthy", "up", 2.1, ts),
new QuotaMetricDto("jobs", 23, 200, 11.5, "healthy", "stable", 0.5, ts),
new QuotaMetricDto("api", 180, 300, 60.0, "healthy", "up", 4.2, ts),
new QuotaMetricDto("storage", 14000, 51200, 27.3, "healthy", "up", 1.5, ts)),
"up", 2.8, now.AddMinutes(-12).ToString("O", CultureInfo.InvariantCulture)),
new TenantQuotaUsageDto(
"staging-01",
"Staging Environment",
"Enterprise",
new TenantQuotasDto(
new QuotaMetricDto("license", 38, 200, 19.0, "healthy", "stable", 0.3, ts),
new QuotaMetricDto("jobs", 15, 200, 7.5, "healthy", "stable", 0.2, ts),
new QuotaMetricDto("api", 95, 300, 31.7, "healthy", "stable", 1.1, ts),
new QuotaMetricDto("storage", 7200, 51200, 14.1, "healthy", "stable", 0.4, ts)),
"stable", 0.6, now.AddMinutes(-35).ToString("O", CultureInfo.InvariantCulture)),
new TenantQuotaUsageDto(
"dev-sandbox",
"Developer Sandbox",
"Enterprise",
new TenantQuotasDto(
new QuotaMetricDto("license", 22, 100, 22.0, "healthy", "down", -1.2, ts),
new QuotaMetricDto("jobs", 9, 100, 9.0, "healthy", "stable", 0.1, ts),
new QuotaMetricDto("api", 37, 150, 24.7, "healthy", "down", -0.8, ts),
new QuotaMetricDto("storage", 3400, 25600, 13.3, "healthy", "stable", 0.2, ts)),
"down", -0.7, now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture))
];
}
private static object BuildTenantBreakdown(string tenantId, DateTimeOffset now) => new
{
tenantId,
tenantName = tenantId switch
{
"demo-prod" => "Demo Production",
"staging-01" => "Staging Environment",
"dev-sandbox" => "Developer Sandbox",
_ => tenantId
},
planName = "Enterprise",
licensePeriod = new
{
start = now.AddYears(-1).ToString("O", CultureInfo.InvariantCulture),
end = now.AddYears(1).ToString("O", CultureInfo.InvariantCulture)
},
quotaDetails = new
{
artifacts = new { current = 1247, limit = 10000, percentage = 12.5 },
users = new { current = 28, limit = 200, percentage = 14.0 },
scansPerDay = new { current = 64, limit = 400, percentage = 16.0 },
storageMb = new { current = 14000, limit = 51200, percentage = 27.3 },
concurrentJobs = new { current = 3, limit = 10, percentage = 30.0 }
},
usageByResourceType = new object[]
{
new { type = "Container Images", percentage = 42.5 },
new { type = "SBOM Documents", percentage = 28.3 },
new { type = "VEX Advisories", percentage = 15.7 },
new { type = "Policy Artifacts", percentage = 8.2 },
new { type = "Evidence Records", percentage = 5.3 }
},
forecast = new
{
category = "storage",
exhaustionDays = 180,
confidence = 0.82,
trendSlope = 1.8,
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
severity = "info"
}
};
private static object[] BuildForecasts(string? category)
{
var all = new object[]
{
new
{
category = "license",
exhaustionDays = (int?)null,
confidence = 0.91,
trendSlope = 3.2,
recommendation = "License usage is well within limits. No action needed.",
severity = "info"
},
new
{
category = "jobs",
exhaustionDays = (int?)null,
confidence = 0.88,
trendSlope = 0.8,
recommendation = "Job usage is stable. Current capacity is adequate.",
severity = "info"
},
new
{
category = "api",
exhaustionDays = (int?)120,
confidence = 0.76,
trendSlope = 5.1,
recommendation = "API usage is trending upward. Monitor closely if trend continues.",
severity = "warning"
},
new
{
category = "storage",
exhaustionDays = (int?)180,
confidence = 0.82,
trendSlope = 1.8,
recommendation = "Storage usage is growing steadily. Consider archiving older evidence records.",
severity = "info"
},
new
{
category = "scans",
exhaustionDays = (int?)null,
confidence = 0.94,
trendSlope = 0.4,
recommendation = "Scan quota usage is low and stable.",
severity = "info"
}
};
// The anonymous objects don't expose "category" for filtering easily,
// so we return all if no filter, otherwise filter by matching category string in serialized form.
if (string.IsNullOrWhiteSpace(category))
{
return all;
}
// Return matching category only
return category switch
{
"license" => [all[0]],
"jobs" => [all[1]],
"api" => [all[2]],
"storage" => [all[3]],
"scans" => [all[4]],
_ => all
};
}
private static object BuildAlertConfig() => new
{
thresholds = new object[]
{
new { category = "license", enabled = true, warningThreshold = 75, criticalThreshold = 90 },
new { category = "jobs", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
new { category = "api", enabled = true, warningThreshold = 70, criticalThreshold = 85 },
new { category = "storage", enabled = true, warningThreshold = 80, criticalThreshold = 95 },
new { category = "scans", enabled = false, warningThreshold = 80, criticalThreshold = 95 }
},
channels = new object[]
{
new
{
type = "email",
enabled = true,
target = "ops-team@stella-ops.local",
events = new[] { "warning", "critical", "recovery" }
},
new
{
type = "slack",
enabled = false,
target = "#ops-alerts",
events = new[] { "critical" }
}
},
quietHours = new { start = "22:00", end = "06:00" },
escalationMinutes = 30
};
private static object[] BuildRateLimitStatuses(DateTimeOffset now)
{
return
[
new
{
endpoint = "/api/v1/scanner/scan",
method = "POST",
limit = 60,
remaining = 42,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 10,
burstRemaining = 8
},
new
{
endpoint = "/api/v1/findings",
method = "GET",
limit = 120,
remaining = 98,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 20,
burstRemaining = 18
},
new
{
endpoint = "/api/v1/evidence",
method = "POST",
limit = 30,
remaining = 24,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 5,
burstRemaining = 4
},
new
{
endpoint = "/api/v1/policy/evaluate",
method = "POST",
limit = 90,
remaining = 71,
resetAt = now.AddMinutes(1).ToString("O", CultureInfo.InvariantCulture),
burstLimit = 15,
burstRemaining = 12
}
];
}
private static object[] BuildRateLimitViolations(DateTimeOffset now) =>
[
new
{
id = "rlv-001",
timestamp = now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
tenantId = "demo-prod",
tenantName = "Demo Production",
endpoint = "/api/v1/scanner/scan",
method = "POST",
limitType = "burst",
currentRate = 14,
rateLimit = 10,
retryAfter = 8,
recommendation = "Reduce scan concurrency or spread requests over a longer window."
},
new
{
id = "rlv-002",
timestamp = now.AddHours(-3).ToString("O", CultureInfo.InvariantCulture),
tenantId = "staging-01",
tenantName = "Staging Environment",
endpoint = "/api/v1/findings",
method = "GET",
limitType = "sustained",
currentRate = 135,
rateLimit = 120,
retryAfter = 45,
recommendation = "Implement client-side caching for findings queries to reduce request volume."
},
new
{
id = "rlv-003",
timestamp = now.AddDays(-1).ToString("O", CultureInfo.InvariantCulture),
tenantId = "demo-prod",
tenantName = "Demo Production",
endpoint = "/api/v1/evidence",
method = "POST",
limitType = "burst",
currentRate = 8,
rateLimit = 5,
retryAfter = 12,
recommendation = "Batch evidence submissions to reduce burst pressure."
}
];
private static DateTimeOffset ParseDateOrDefault(string? dateStr, DateTimeOffset fallback)
{
if (string.IsNullOrWhiteSpace(dateStr))
return fallback;
return DateTimeOffset.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
? parsed
: fallback;
}
// ---- Request / response DTOs (private, scoped to this compatibility layer) ----
private sealed record QuotaAlertConfigRequest(
object[]? Thresholds,
object[]? Channels,
QuotaQuietHoursDto? QuietHours,
int EscalationMinutes);
private sealed record QuotaQuietHoursDto(string Start, string End);
private sealed record QuotaReportRequestBody(
string StartDate,
string EndDate,
string[]? TenantIds,
string[]? Categories,
string Format,
bool IncludeForecasts,
bool IncludeRecommendations);
private sealed record QuotaMetricDto(
string Category,
int Current,
int Limit,
double Percentage,
string Status,
string Trend,
double TrendPercentage,
string LastUpdated);
private sealed record TenantQuotasDto(
QuotaMetricDto License,
QuotaMetricDto Jobs,
QuotaMetricDto Api,
QuotaMetricDto Storage);
private sealed record TenantQuotaUsageDto(
string TenantId,
string TenantName,
string PlanName,
TenantQuotasDto Quotas,
string Trend,
double TrendPercentage,
string LastActivity);
}

View File

@@ -95,7 +95,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" }, { "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" }, { "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" }, { "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" }, { "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows$1" },
{ "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" }, { "Type": "Microservice", "Path": "^/api/v1/aoc(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/aoc$1" },
{ "Type": "Microservice", "Path": "^/api/v1/administration(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/administration$1" }, { "Type": "Microservice", "Path": "^/api/v1/administration(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/administration$1" },
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" }, { "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
@@ -137,11 +137,13 @@
{ "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" }, { "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" },
{ "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" }, { "Type": "Microservice", "Path": "^/scheduler(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local$1" },
{ "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" }, { "Type": "Microservice", "Path": "^/doctor(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local$1" },
{ "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" }, { "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" }, { "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/jobengine$1" },
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" }, { "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" }, { "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
{ "Type": "ReverseProxy", "Path": "^/policy/shadow(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow$1", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "^/policy/simulations(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations$1", "PreserveAuthHeaders": true },
{ "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" }, { "Type": "Microservice", "Path": "^/policy(?=/|$)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" }, { "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },

View File

@@ -121,6 +121,35 @@ public sealed class PostgresVexSourceRepository : IVexSourceRepository
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
public async Task UpdateFailureTrackingAsync(
string sourceId,
int consecutiveFailures,
DateTimeOffset? nextEligiblePollAt,
CancellationToken cancellationToken = default)
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var entity = await dbContext.Sources.FirstOrDefaultAsync(s => s.SourceId == sourceId, cancellationToken);
if (entity is null)
{
return;
}
// Store failure count in LastErrorMessage when failures > 0
if (consecutiveFailures > 0)
{
entity.LastErrorMessage = $"consecutive_failures={consecutiveFailures}; {entity.LastErrorMessage}";
}
else
{
entity.LastErrorMessage = null;
}
entity.UpdatedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
}
public async Task<bool> DeleteAsync(string sourceId, CancellationToken cancellationToken = default) public async Task<bool> DeleteAsync(string sourceId, CancellationToken cancellationToken = default)
{ {
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken); await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env node
/**
* First-Time User Audit — Walks through Stella Ops as a brand-new user.
* Captures: page load status, headings, visible actions, console errors, API errors,
* empty states, and screenshots for every major surface.
*/
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 auditDir = path.join(outputDir, 'first-time-user-audit');
const resultPath = path.join(outputDir, 'first-time-user-audit.json');
const BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const journeyRoutes = [
// Phase 2: First boot
{ phase: 'setup', route: '/', label: 'Landing page (redirect)', scope: false },
{ phase: 'setup', route: '/mission-control/board', label: 'Dashboard - Mission Board', scope: true },
{ phase: 'setup', route: '/mission-control/alerts', label: 'Dashboard - Alerts', scope: true },
{ phase: 'setup', route: '/mission-control/activity', label: 'Dashboard - Activity', scope: true },
// Phase 3A: Releases
{ phase: 'releases', route: '/releases/deployments', label: 'Releases - Deployments', scope: true },
{ phase: 'releases', route: '/releases/versions', label: 'Releases - Versions', scope: true },
{ phase: 'releases', route: '/releases/approvals', label: 'Releases - Approvals', scope: true },
{ phase: 'releases', route: '/releases/environments', label: 'Releases - Environments', scope: true },
// Phase 3C: Security
{ phase: 'security', route: '/security/posture', label: 'Security - Posture', scope: true },
{ phase: 'security', route: '/security/triage', label: 'Security - Triage', scope: true },
{ phase: 'security', route: '/security/disposition', label: 'Security - Disposition', scope: true },
{ phase: 'security', route: '/security/supply-chain-data', label: 'Security - Supply Chain', scope: true },
{ phase: 'security', route: '/security/reachability', label: 'Security - Reachability', scope: true },
// Phase 3D: Evidence
{ phase: 'evidence', route: '/evidence/overview', label: 'Evidence - Overview', scope: true },
{ phase: 'evidence', route: '/evidence/capsules', label: 'Evidence - Capsules', scope: true },
{ phase: 'evidence', route: '/evidence/verify-replay', label: 'Evidence - Verify/Replay', scope: true },
{ phase: 'evidence', route: '/evidence/exports', label: 'Evidence - Exports', scope: true },
// Phase 3E: Policy
{ phase: 'policy', route: '/ops/policy/overview', label: 'Policy - Overview', scope: true },
{ phase: 'policy', route: '/ops/policy/simulation', label: 'Policy - Simulation', scope: true },
{ phase: 'policy', route: '/ops/policy/baselines', label: 'Policy - Baselines', scope: true },
{ phase: 'policy', route: '/ops/policy/gates', label: 'Policy - Gates', scope: true },
{ phase: 'policy', route: '/ops/policy/waivers', label: 'Policy - Waivers', scope: true },
// Phase 3F: Operations
{ phase: 'operations', route: '/ops/operations', label: 'Operations - Hub', scope: true },
{ phase: 'operations', route: '/ops/operations/jobengine', label: 'Operations - JobEngine', scope: true },
{ phase: 'operations', route: '/ops/operations/system-health', label: 'Operations - System Health', scope: true },
{ phase: 'operations', route: '/ops/operations/doctor', label: 'Operations - Doctor', scope: true },
{ phase: 'operations', route: '/ops/operations/signals', label: 'Operations - Signals', scope: true },
{ phase: 'operations', route: '/ops/operations/dead-letter', label: 'Operations - Dead Letter', scope: true },
// Phase 4: Integrations
{ phase: 'integrations', route: '/setup/integrations', label: 'Integrations - Hub', scope: false },
{ phase: 'integrations', route: '/setup/integrations/advisory-vex-sources', label: 'Integrations - Advisory/VEX', scope: false },
{ phase: 'integrations', route: '/setup/integrations/registries', label: 'Integrations - Registries', scope: false },
{ phase: 'integrations', route: '/setup/integrations/scm', label: 'Integrations - SCM', scope: false },
{ phase: 'integrations', route: '/setup/integrations/secrets', label: 'Integrations - Secrets', scope: false },
// Phase 5: Admin
{ phase: 'admin', route: '/setup/topology', label: 'Setup - Topology', scope: false },
{ phase: 'admin', route: '/setup/identity-access', label: 'Setup - Identity & Access', scope: false },
{ phase: 'admin', route: '/setup/tenant-branding', label: 'Setup - Tenant Branding', scope: false },
{ phase: 'admin', route: '/setup/notifications', label: 'Setup - Notifications', scope: false },
{ phase: 'admin', route: '/setup/usage', label: 'Setup - Usage', scope: false },
{ phase: 'admin', route: '/setup/trust-signing', label: 'Setup - Trust & Signing', scope: false },
{ phase: 'admin', route: '/ops/platform-setup', label: 'Platform Setup - Home', scope: true },
// Phase 6: Edge cases
{ phase: 'edge', route: '/nonexistent-route-12345', label: 'Edge - 404 page', scope: false },
];
function buildUrl(route, addScope) {
const url = new URL(route, BASE_URL);
if (addScope) {
url.searchParams.set('tenant', 'demo-prod');
url.searchParams.set('regions', 'us-east');
url.searchParams.set('environments', 'stage');
url.searchParams.set('timeWindow', '7d');
}
return url.toString();
}
function cleanText(value) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
}
async function auditPage(page, entry) {
const runtime = { consoleErrors: [], responseErrors: [], requestFailures: [] };
const consoleHandler = (msg) => {
if (msg.type() === 'error') runtime.consoleErrors.push(cleanText(msg.text()));
};
const responseHandler = (resp) => {
if (!/\.(css|js|map|png|jpg|svg|woff2?)(\?|$)/i.test(resp.url()) && resp.status() >= 400) {
runtime.responseErrors.push({ status: resp.status(), url: resp.url().split('?')[0] });
}
};
const failHandler = (req) => {
if (!/\.(css|js|map|png|jpg|svg|woff2?)(\?|$)/i.test(req.url())) {
runtime.requestFailures.push({ url: req.url().split('?')[0], error: req.failure()?.errorText });
}
};
page.on('console', consoleHandler);
page.on('response', responseHandler);
page.on('requestfailed', failHandler);
try {
await page.goto(buildUrl(entry.route, entry.scope), { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
const finalUrl = page.url();
const title = await page.title().catch(() => '');
// Headings
const headings = await page.locator('h1, main h2').evaluateAll((els) =>
els.map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 10)
).catch(() => []);
// Visible buttons and links (primary actions)
const actions = await page.locator('button:visible, a[routerLink]:visible, a[href]:visible')
.evaluateAll((els) => els.slice(0, 20).map((el) => ({
tag: el.tagName.toLowerCase(),
text: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
href: el.getAttribute('href') || el.getAttribute('routerlink') || '',
disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
})).filter((a) => a.text && a.text.length > 1)
).catch(() => []);
// Empty state detection
const emptyIndicators = await page.locator('body').evaluate((body) => {
const text = (body.textContent || '').toLowerCase();
const indicators = [];
if (text.includes('no data')) indicators.push('no_data');
if (text.includes('no results')) indicators.push('no_results');
if (text.includes('get started')) indicators.push('get_started');
if (text.includes('loading')) indicators.push('loading_visible');
if (text.includes('empty')) indicators.push('empty');
if (text.includes('not found')) indicators.push('not_found');
if (text.includes('error') || text.includes('went wrong')) indicators.push('error_text');
if (text.includes('connect') && text.includes('configure')) indicators.push('setup_prompt');
return indicators;
}).catch(() => []);
// Body text snippet (first 500 chars of main content)
const bodySnippet = await page.locator('main, [role="main"], .content, app-root').first()
.evaluate((el) => (el?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 500))
.catch(() => '');
// Screenshot
const screenshotKey = entry.label.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
const screenshotPath = path.join(auditDir, `${screenshotKey}.png`);
await page.screenshot({ path: screenshotPath, fullPage: false }).catch(() => {});
return {
phase: entry.phase,
label: entry.label,
route: entry.route,
finalUrl: finalUrl.replace(BASE_URL, '').split('?')[0],
title,
headings,
actionCount: actions.length,
topActions: actions.slice(0, 8),
emptyIndicators,
bodySnippet: bodySnippet.slice(0, 300),
consoleErrors: runtime.consoleErrors,
responseErrors: [...new Map(runtime.responseErrors.map((e) => [e.status + e.url, e])).values()],
requestFailures: runtime.requestFailures.slice(0, 5),
screenshot: screenshotPath,
};
} finally {
page.off('console', consoleHandler);
page.off('response', responseHandler);
page.off('requestfailed', failHandler);
}
}
async function main() {
await mkdir(auditDir, { recursive: true });
const statePath = path.join(outputDir, 'first-time-user-audit-auth.state.json');
const reportPath = path.join(outputDir, 'first-time-user-audit-auth.report.json');
const auth = await authenticateFrontdoor({ statePath, reportPath });
const browser = await chromium.launch({ headless: true });
const context = await createAuthenticatedContext(browser, auth, { statePath });
const page = await context.newPage();
page.setDefaultTimeout(20000);
page.setDefaultNavigationTimeout(30000);
const results = [];
const issues = [];
for (const entry of journeyRoutes) {
process.stdout.write(`[${entry.phase}] ${entry.label}...`);
try {
const result = await auditPage(page, entry);
results.push(result);
// Detect issues
if (result.consoleErrors.length > 0) {
issues.push({ severity: 'medium', category: 'error', route: entry.route, label: entry.label, detail: `${result.consoleErrors.length} console error(s)` });
}
if (result.responseErrors.length > 0) {
issues.push({ severity: 'high', category: 'api', route: entry.route, label: entry.label, detail: result.responseErrors.map((e) => `${e.status} ${e.url}`).join('; ') });
}
if (result.emptyIndicators.includes('error_text')) {
issues.push({ severity: 'medium', category: 'ux', route: entry.route, label: entry.label, detail: 'Error text visible on page' });
}
if (result.headings.length === 0) {
issues.push({ severity: 'low', category: 'ux', route: entry.route, label: entry.label, detail: 'No headings found (h1/h2)' });
}
const status = result.responseErrors.length > 0 ? ' ISSUES' : ' OK';
process.stdout.write(`${status}\n`);
} catch (err) {
process.stdout.write(` ERROR: ${err.message?.slice(0, 80)}\n`);
issues.push({ severity: 'critical', category: 'crash', route: entry.route, label: entry.label, detail: err.message?.slice(0, 200) });
}
}
await browser.close();
const report = {
auditedAt: new Date().toISOString(),
baseUrl: BASE_URL,
totalPages: results.length,
totalIssues: issues.length,
issuesByPhase: {},
results,
issues,
};
for (const issue of issues) {
const phase = results.find((r) => r.route === issue.route)?.phase || 'unknown';
report.issuesByPhase[phase] = (report.issuesByPhase[phase] || 0) + 1;
}
await writeFile(resultPath, JSON.stringify(report, null, 2));
console.log(`\nAudit complete: ${results.length} pages, ${issues.length} issues`);
console.log(`Results: ${resultPath}`);
console.log(`Screenshots: ${auditDir}`);
}
main().catch((err) => {
console.error('[first-time-user-audit]', err.message);
process.exit(1);
});

View File

@@ -163,7 +163,7 @@ const strictRouteExpectations = {
}, },
'/setup/integrations/advisory-vex-sources': { '/setup/integrations/advisory-vex-sources': {
title: /Advisory & VEX Sources/i, title: /Advisory & VEX Sources/i,
texts: ['Integrations', 'FeedMirror Integrations'], texts: ['Integrations', 'Advisory & VEX Source Catalog'],
}, },
'/setup/integrations/secrets': { '/setup/integrations/secrets': {
title: /Secrets/i, title: /Secrets/i,
@@ -191,6 +191,7 @@ const allowedFinalPaths = {
'/ops/policy/audit': ['/ops/policy/audit/policy'], '/ops/policy/audit': ['/ops/policy/audit/policy'],
'/ops/platform-setup/trust-signing': ['/setup/trust-signing'], '/ops/platform-setup/trust-signing': ['/setup/trust-signing'],
'/setup/topology': ['/setup/topology/overview'], '/setup/topology': ['/setup/topology/overview'],
'/security/posture': ['/security'],
}; };
function buildRouteUrl(routePath) { function buildRouteUrl(routePath) {

View File

@@ -0,0 +1,650 @@
#!/usr/bin/env node
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 resultPath = path.join(outputDir, 'live-mirror-operator-journey.json');
const authStatePath = path.join(outputDir, 'live-mirror-operator-journey.state.json');
const authReportPath = path.join(outputDir, 'live-mirror-operator-journey.auth.json');
const screenshotDir = path.join(outputDir, 'mirror-operator-journey');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const tenantId = 'demo-prod';
const scopeQuery = `tenant=${tenantId}&regions=us-east&environments=stage&timeWindow=7d`;
const sourceApiBasePath = '/api/v1/advisory-sources';
const mirrorApiBasePath = '/api/v1/advisory-sources/mirror';
const setupCatalogRoute = '/setup/integrations/advisory-vex-sources';
const opsCatalogRoute = '/ops/integrations/advisory-vex-sources';
const setupMirrorRoute = '/setup/integrations/advisory-vex-sources/mirror';
const opsMirrorRoute = '/ops/integrations/advisory-vex-sources/mirror';
const setupMirrorNewRoute = '/setup/integrations/advisory-vex-sources/mirror/new';
const opsMirrorNewRoute = '/ops/integrations/advisory-vex-sources/mirror/new';
const setupMirrorClientRoute = '/setup/integrations/advisory-vex-sources/mirror/client-setup';
const opsMirrorClientRoute = '/ops/integrations/advisory-vex-sources/mirror/client-setup';
const mirrorJourneySuffix = Date.now().toString(36);
const mirrorJourneyDomainId = `qa-mirror-${mirrorJourneySuffix}`;
const mirrorJourneyDisplayName = `QA Mirror ${mirrorJourneySuffix}`;
function buildUrl(route) {
const separator = route.includes('?') ? '&' : '?';
return `${baseUrl}${route}${separator}${scopeQuery}`;
}
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 isIgnorableFailure(request) {
const url = request.url();
const error = request.failure()?.errorText ?? '';
return isStaticAsset(url) || /net::ERR_ABORTED|aborted/i.test(error);
}
function attachRuntime(page, runtime) {
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push(cleanText(message.text()));
}
});
page.on('pageerror', (error) => {
runtime.pageErrors.push(cleanText(error instanceof Error ? error.message : String(error)));
});
page.on('requestfailed', (request) => {
if (isIgnorableFailure(request)) {
return;
}
runtime.requestFailures.push({
method: request.method(),
url: request.url(),
error: request.failure()?.errorText ?? 'request failed',
page: page.url(),
});
});
page.on('response', (response) => {
if (isStaticAsset(response.url())) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push({
status: response.status(),
method: response.request().method(),
url: response.url(),
page: page.url(),
});
}
});
}
async function settle(page, ms = 1200) {
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
await page.waitForFunction(() => {
const nodes = Array.from(document.querySelectorAll('[role="alert"], .banner, .banner--error'));
return !nodes.some((node) => ((node.textContent || '').replace(/\s+/g, ' ').trim().toLowerCase().startsWith('loading ')));
}, { timeout: 10_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function navigate(page, route, ms = 1400) {
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page, ms);
}
async function heading(page) {
const selectors = [
'.source-catalog h1',
'.mirror-dashboard h1',
'.wizard h1',
'[data-testid="page-title"]',
'main h1',
'h1',
];
for (const selector of selectors) {
const locator = page.locator(selector).first();
const text = cleanText(await locator.textContent().catch(() => ''));
if (text) {
return text;
}
}
return '';
}
async function bannerTexts(page) {
return page.locator('[role="alert"], .banner, .banner--error, .error-banner, .toast')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean))
.catch(() => []);
}
async function bodyText(page) {
return cleanText(await page.locator('body').textContent().catch(() => ''));
}
async function capture(page, key, extra = {}) {
const screenshotPath = path.join(screenshotDir, `${key}.png`);
await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => {});
return {
key,
url: page.url(),
heading: await heading(page),
banners: await bannerTexts(page),
screenshotPath,
...extra,
};
}
function pathPresent(url, expectedPath) {
return typeof url === 'string' && url.includes(expectedPath);
}
async function attemptClick(page, locator, label, waitMs = 1400) {
const visible = await locator.isVisible().catch(() => false);
if (!visible) {
return { clicked: false, reason: `${label} not visible` };
}
try {
await locator.click({ timeout: 15_000 });
await settle(page, waitMs);
return { clicked: true };
} catch (error) {
return {
clicked: false,
reason: cleanText(error instanceof Error ? error.message : String(error)),
};
}
}
async function invokeApi(page, endpoint, init = {}) {
return page.evaluate(async ({ endpointPath, tenant, method, requestBody }) => {
try {
// Extract access token from Angular session storage
let authHeader = {};
try {
const raw = sessionStorage.getItem('stellaops.auth.session.full');
if (raw) {
const session = JSON.parse(raw);
const token = session?.tokens?.accessToken;
if (token) {
authHeader = { 'Authorization': `Bearer ${token}` };
}
}
} catch { /* token extraction failed; proceed without auth */ }
const response = await fetch(endpointPath, {
method,
credentials: 'include',
headers: {
'X-Stella-Ops-Tenant': tenant,
...authHeader,
...(requestBody ? { 'Content-Type': 'application/json' } : {}),
},
body: requestBody ? JSON.stringify(requestBody) : undefined,
});
const bodyText = await response.text();
let responseBody;
try {
responseBody = JSON.parse(bodyText);
} catch {
responseBody = bodyText;
}
return {
ok: response.ok,
status: response.status,
body: responseBody,
};
} catch (error) {
return {
ok: false,
status: 0,
body: error instanceof Error ? error.message : String(error),
};
}
}, {
endpointPath: endpoint,
tenant: tenantId,
method: init.method ?? 'GET',
requestBody: init.body ?? null,
});
}
async function main() {
await mkdir(outputDir, { recursive: true });
await mkdir(screenshotDir, { recursive: true });
const runtime = {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' });
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
const page = await context.newPage();
page.setDefaultTimeout(20_000);
page.setDefaultNavigationTimeout(30_000);
attachRuntime(page, runtime);
const results = [];
const failures = [];
try {
await navigate(page, setupCatalogRoute);
const mirrorConfigApi = await invokeApi(page, `${mirrorApiBasePath}/config`);
const mirrorHealthApi = await invokeApi(page, `${mirrorApiBasePath}/health`);
const mirrorDomainsApi = await invokeApi(page, `${mirrorApiBasePath}/domains`);
results.push(await capture(page, 'catalog-direct', {
hasConfigureMirrorLink: await page.getByRole('link', { name: 'Configure Mirror', exact: true }).isVisible().catch(() => false),
hasConnectMirrorLink: await page.getByRole('link', { name: 'Connect to Mirror', exact: true }).isVisible().catch(() => false),
hasCreateMirrorDomainButton: await page.getByRole('button', { name: 'Create Mirror Domain', exact: true }).isVisible().catch(() => false),
mirrorConfigApi,
mirrorHealthApi,
mirrorDomainsApi,
}));
await navigate(page, opsCatalogRoute);
results.push(await capture(page, 'ops-catalog-direct', {
hasConfigureMirrorLink: await page.getByRole('link', { name: 'Configure Mirror', exact: true }).isVisible().catch(() => false),
hasConnectMirrorLink: await page.getByRole('link', { name: 'Connect to Mirror', exact: true }).isVisible().catch(() => false),
hasCreateMirrorDomainButton: await page.getByRole('button', { name: 'Create Mirror Domain', exact: true }).isVisible().catch(() => false),
}));
await navigate(page, setupCatalogRoute);
const catalogConfigureResult = await attemptClick(
page,
page.getByRole('link', { name: 'Configure Mirror', exact: true }),
'Catalog Configure Mirror',
);
results.push(await capture(page, 'catalog-configure-mirror-handoff', { clickResult: catalogConfigureResult }));
await navigate(page, setupCatalogRoute);
const catalogCreateResult = await attemptClick(
page,
page.getByRole('button', { name: 'Create Mirror Domain', exact: true }),
'Catalog Create Mirror Domain',
);
results.push(await capture(page, 'catalog-create-domain-handoff', { clickResult: catalogCreateResult }));
await navigate(page, setupMirrorRoute);
const mirrorDashboardConfigApi = await invokeApi(page, `${mirrorApiBasePath}/config`);
results.push(await capture(page, 'dashboard-direct', {
hasCreateDomainButton: await page.getByRole('button', { name: 'Create Domain', exact: true }).isVisible().catch(() => false),
hasSetupMirrorButton: await page.getByRole('button', { name: /Set Up Mirror Connection|Configure/i }).first().isVisible().catch(() => false),
mirrorConfigApi: mirrorDashboardConfigApi,
}));
await navigate(page, opsMirrorRoute);
results.push(await capture(page, 'ops-dashboard-direct', {
hasCreateDomainButton: await page.getByRole('button', { name: 'Create Domain', exact: true }).isVisible().catch(() => false),
hasSetupMirrorButton: await page.getByRole('button', { name: /Set Up Mirror Connection|Configure/i }).first().isVisible().catch(() => false),
}));
await navigate(page, setupMirrorRoute);
const dashboardCreateResult = await attemptClick(
page,
page.getByRole('button', { name: 'Create Domain', exact: true }),
'Dashboard Create Domain',
);
results.push(await capture(page, 'dashboard-create-domain-handoff', { clickResult: dashboardCreateResult }));
await navigate(page, setupMirrorRoute);
const setupButton = page.getByRole('button', { name: /Set Up Mirror Connection|Configure/i }).first();
const dashboardConsumerResult = await attemptClick(
page,
setupButton,
'Dashboard Configure Consumer',
);
results.push(await capture(page, 'dashboard-configure-consumer-handoff', { clickResult: dashboardConsumerResult }));
await navigate(page, setupMirrorNewRoute);
const sourceCheckboxes = page.locator('.source-row-label input[type="checkbox"]');
const sourceCount = await sourceCheckboxes.count().catch(() => 0);
if (sourceCount >= 2) {
await sourceCheckboxes.nth(0).check({ force: true });
await sourceCheckboxes.nth(1).check({ force: true });
}
const builderNextToConfig = await attemptClick(
page,
page.getByRole('button', { name: 'Next: Configure Domain', exact: true }),
'Builder next to config',
800,
);
if (builderNextToConfig.clicked) {
await page.locator('#domainId').fill(mirrorJourneyDomainId).catch(() => {});
await page.locator('#displayName').fill(mirrorJourneyDisplayName).catch(() => {});
const signingKeyInput = page.locator('#signingKeyId');
if (await signingKeyInput.isVisible().catch(() => false)) {
await signingKeyInput.fill('operator-mirror-signing-key').catch(() => {});
}
}
const builderNextToReview = await attemptClick(
page,
page.getByRole('button', { name: 'Next: Review', exact: true }),
'Builder next to review',
800,
);
const generateToggle = page.getByLabel('Generate mirror output immediately after creation');
if (await generateToggle.isVisible().catch(() => false)) {
await generateToggle.check({ force: true }).catch(() => {});
}
const builderCreateResult = await attemptClick(
page,
page.getByRole('button', { name: 'Create Domain', exact: true }),
'Builder create domain',
2400,
);
const createdDomainConfigApi = await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}/config`);
const createdDomainEndpointsApi = await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}/endpoints`);
const createdDomainStatusApi = await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}/status`);
const publicMirrorIndexApi = await invokeApi(page, '/concelier/exports/index.json');
const publicDomainManifestApi = await invokeApi(page, `/concelier/exports/mirror/${mirrorJourneyDomainId}/manifest.json`);
const publicDomainBundleApi = await invokeApi(page, `/concelier/exports/mirror/${mirrorJourneyDomainId}/bundle.json`);
results.push(await capture(page, 'builder-create-attempt', {
sourceCount,
nextToConfig: builderNextToConfig,
nextToReview: builderNextToReview,
createClick: builderCreateResult,
createErrorBanner: cleanText(await page.locator('.banner--error').first().textContent().catch(() => '')),
domainConfigApi: createdDomainConfigApi,
domainEndpointsApi: createdDomainEndpointsApi,
domainStatusApi: createdDomainStatusApi,
publicMirrorIndexApi,
publicDomainManifestApi,
publicDomainBundleApi,
}));
await navigate(page, opsMirrorNewRoute);
results.push(await capture(page, 'ops-builder-direct', {
sourceCount: await page.locator('.source-row-label input[type="checkbox"]').count().catch(() => 0),
}));
await navigate(page, setupMirrorRoute);
const viewEndpointsResult = await attemptClick(
page,
page.getByRole('button', { name: 'View Endpoints', exact: true }).first(),
'Dashboard view endpoints',
1200,
);
const viewConfigResult = await attemptClick(
page,
page.getByRole('button', { name: 'View Config', exact: true }).first(),
'Dashboard view config',
1200,
);
results.push(await capture(page, 'dashboard-domain-panels', {
viewEndpointsResult,
viewConfigResult,
endpointsPanelText: cleanText(await page.locator('.card-endpoints').first().textContent().catch(() => '')),
configPanelText: cleanText(await page.locator('.card-config').first().textContent().catch(() => '')),
}));
await navigate(page, setupMirrorClientRoute);
const clientConfigApi = await invokeApi(page, `${mirrorApiBasePath}/config`);
const clientDomainsApi = await invokeApi(page, `${mirrorApiBasePath}/domains`);
await page.locator('#mirrorAddress').fill(baseUrl).catch(() => {});
const clientTestConnection = await attemptClick(
page,
page.getByRole('button', { name: 'Test Connection', exact: true }),
'Mirror client test connection',
2200,
);
const discoveredDomainOptions = await page.locator('#domainSelect option').count().catch(() => 0);
const nextToSignatureVisible = await page.getByRole('button', { name: 'Next: Signature Verification', exact: true }).isVisible().catch(() => false);
const nextToSignatureEnabled = nextToSignatureVisible
? await page.getByRole('button', { name: 'Next: Signature Verification', exact: true }).isEnabled().catch(() => false)
: false;
results.push(await capture(page, 'client-setup-direct', {
mirrorConfigApi: clientConfigApi,
mirrorDomainsApi: clientDomainsApi,
testConnection: clientTestConnection,
connectedBanner: cleanText(await page.locator('.result-banner--success').first().textContent().catch(() => '')),
connectionErrorBanner: cleanText(await page.locator('.result-banner--error').first().textContent().catch(() => '')),
discoveredDomainOptions,
nextToSignatureVisible,
nextToSignatureEnabled,
}));
await navigate(page, opsMirrorClientRoute);
results.push(await capture(page, 'ops-client-setup-direct', {
hasMirrorAddress: await page.locator('#mirrorAddress').isVisible().catch(() => false),
hasTestConnectionButton: await page.getByRole('button', { name: 'Test Connection', exact: true }).isVisible().catch(() => false),
}));
await navigate(page, '/ops/operations/feeds-airgap');
const feedsConfigureResult = await attemptClick(
page,
page.getByRole('link', { name: 'Configure Sources', exact: true }),
'Feeds & Airgap Configure Sources',
1000,
);
results.push(await capture(page, 'feeds-airgap-configure-sources-handoff', { clickResult: feedsConfigureResult }));
await navigate(page, '/security');
const securityConfigureResult = await attemptClick(
page,
page.getByRole('link', { name: /Configure sources|Configure advisory feeds|Configure VEX sources/i }).first(),
'Security Configure sources',
1000,
);
results.push(await capture(page, 'security-configure-sources-handoff', { clickResult: securityConfigureResult }));
} finally {
await invokeApi(page, `${mirrorApiBasePath}/domains/${encodeURIComponent(mirrorJourneyDomainId)}`, {
method: 'DELETE',
}).catch(() => {});
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const catalogDirect = results.find((entry) => entry.key === 'catalog-direct');
if (catalogDirect?.heading !== 'Advisory & VEX Source Catalog') {
failures.push(`Catalog direct route heading mismatch: ${catalogDirect?.heading || '<empty>'}`);
}
if (!catalogDirect?.hasConfigureMirrorLink || !catalogDirect?.hasCreateMirrorDomainButton) {
failures.push('Catalog direct route is missing primary mirror actions.');
}
if (!catalogDirect?.mirrorConfigApi?.ok) {
failures.push(`Mirror config API is not healthy for catalog context: status=${catalogDirect?.mirrorConfigApi?.status ?? '<unknown>'}`);
}
if (!catalogDirect?.mirrorDomainsApi?.ok) {
failures.push(`Mirror domains API is not healthy for catalog context: status=${catalogDirect?.mirrorDomainsApi?.status ?? '<unknown>'}`);
}
const opsCatalogDirect = results.find((entry) => entry.key === 'ops-catalog-direct');
if (opsCatalogDirect?.heading !== 'Advisory & VEX Source Catalog') {
failures.push(`Ops catalog direct route heading mismatch: ${opsCatalogDirect?.heading || '<empty>'}`);
}
if (!opsCatalogDirect?.hasConfigureMirrorLink || !opsCatalogDirect?.hasCreateMirrorDomainButton) {
failures.push('Ops catalog direct route is missing primary mirror actions.');
}
const configureHandoff = results.find((entry) => entry.key === 'catalog-configure-mirror-handoff');
if (configureHandoff?.clickResult && !configureHandoff.clickResult.clicked) {
failures.push(`Catalog Configure Mirror action failed before navigation: ${configureHandoff.clickResult.reason}`);
}
if (!pathPresent(configureHandoff?.url, '/advisory-vex-sources/mirror')) {
failures.push(`Catalog Configure Mirror did not land on mirror dashboard: ${configureHandoff?.url || '<empty>'}`);
}
const catalogCreate = results.find((entry) => entry.key === 'catalog-create-domain-handoff');
if (catalogCreate?.clickResult && !catalogCreate.clickResult.clicked) {
failures.push(`Catalog Create Mirror Domain action failed before navigation: ${catalogCreate.clickResult.reason}`);
}
if (!pathPresent(catalogCreate?.url, '/advisory-vex-sources/mirror/new')) {
failures.push(`Catalog Create Mirror Domain did not land on /mirror/new: ${catalogCreate?.url || '<empty>'}`);
}
const dashboardDirect = results.find((entry) => entry.key === 'dashboard-direct');
if (dashboardDirect?.heading !== 'Mirror Dashboard') {
failures.push(`Mirror dashboard direct route heading mismatch: ${dashboardDirect?.heading || '<empty>'}`);
}
if (!dashboardDirect?.mirrorConfigApi?.ok) {
failures.push(`Mirror config API is not healthy for dashboard context: status=${dashboardDirect?.mirrorConfigApi?.status ?? '<unknown>'}`);
}
const opsDashboard = results.find((entry) => entry.key === 'ops-dashboard-direct');
if (opsDashboard?.heading !== 'Mirror Dashboard') {
failures.push(`Ops mirror dashboard direct route heading mismatch: ${opsDashboard?.heading || '<empty>'}`);
}
if (!opsDashboard?.hasCreateDomainButton || !opsDashboard?.hasSetupMirrorButton) {
failures.push('Ops mirror dashboard is missing primary actions.');
}
const dashboardCreate = results.find((entry) => entry.key === 'dashboard-create-domain-handoff');
if (dashboardCreate?.clickResult && !dashboardCreate.clickResult.clicked) {
failures.push(`Dashboard Create Domain action failed before navigation: ${dashboardCreate.clickResult.reason}`);
}
if (!pathPresent(dashboardCreate?.url, '/advisory-vex-sources/mirror/new')) {
failures.push(`Dashboard Create Domain did not land on /mirror/new: ${dashboardCreate?.url || '<empty>'}`);
}
const dashboardConsumer = results.find((entry) => entry.key === 'dashboard-configure-consumer-handoff');
if (dashboardConsumer?.clickResult && !dashboardConsumer.clickResult.clicked) {
failures.push(`Dashboard Configure Consumer action failed before navigation: ${dashboardConsumer.clickResult.reason}`);
}
if (!pathPresent(dashboardConsumer?.url, '/advisory-vex-sources/mirror/client-setup')) {
failures.push(`Dashboard Configure Consumer did not land on /mirror/client-setup: ${dashboardConsumer?.url || '<empty>'}`);
}
const builderAttempt = results.find((entry) => entry.key === 'builder-create-attempt');
const builderText = cleanText(builderAttempt?.createErrorBanner || '');
if (!builderAttempt?.nextToConfig?.clicked || !builderAttempt?.nextToReview?.clicked || !builderAttempt?.createClick?.clicked) {
failures.push(`Mirror domain builder flow was blocked before submit. nextToConfig=${builderAttempt?.nextToConfig?.reason || 'ok'} nextToReview=${builderAttempt?.nextToReview?.reason || 'ok'} create=${builderAttempt?.createClick?.reason || 'ok'}`);
}
if (builderText || builderAttempt?.heading !== 'Mirror Domain Created') {
failures.push(`Mirror domain create journey did not complete successfully. Banner="${builderText || '<none>'}" finalUrl="${builderAttempt?.url || '<empty>'}"`);
}
if (!builderAttempt?.domainConfigApi?.ok) {
failures.push(`Created mirror domain config API failed: status=${builderAttempt?.domainConfigApi?.status ?? '<unknown>'}`);
}
if (!builderAttempt?.domainEndpointsApi?.ok) {
failures.push(`Created mirror domain endpoints API failed: status=${builderAttempt?.domainEndpointsApi?.status ?? '<unknown>'}`);
}
if (!builderAttempt?.domainStatusApi?.ok) {
failures.push(`Created mirror domain status API failed: status=${builderAttempt?.domainStatusApi?.status ?? '<unknown>'}`);
}
if (!builderAttempt?.publicMirrorIndexApi?.ok) {
failures.push(`Public mirror index was not reachable through the frontdoor: status=${builderAttempt?.publicMirrorIndexApi?.status ?? '<unknown>'}`);
}
if (!builderAttempt?.publicDomainManifestApi?.ok) {
failures.push(`Public mirror manifest was not reachable for ${mirrorJourneyDomainId}: status=${builderAttempt?.publicDomainManifestApi?.status ?? '<unknown>'}`);
}
if (!builderAttempt?.publicDomainBundleApi?.ok) {
failures.push(`Public mirror bundle was not reachable for ${mirrorJourneyDomainId}: status=${builderAttempt?.publicDomainBundleApi?.status ?? '<unknown>'}`);
}
const opsBuilderDirect = results.find((entry) => entry.key === 'ops-builder-direct');
if (opsBuilderDirect?.heading !== 'Create Mirror Domain') {
failures.push(`Ops mirror builder direct route heading mismatch: ${opsBuilderDirect?.heading || '<empty>'}`);
}
const dashboardPanels = results.find((entry) => entry.key === 'dashboard-domain-panels');
if (dashboardPanels?.viewEndpointsResult && !dashboardPanels.viewEndpointsResult.clicked) {
failures.push(`Mirror dashboard View Endpoints action failed: ${dashboardPanels.viewEndpointsResult.reason}`);
}
if (dashboardPanels?.viewConfigResult && !dashboardPanels.viewConfigResult.clicked) {
failures.push(`Mirror dashboard View Config action failed: ${dashboardPanels.viewConfigResult.reason}`);
}
if (!dashboardPanels?.endpointsPanelText?.includes('/concelier/exports')) {
failures.push('Mirror dashboard endpoints panel did not render public mirror paths.');
}
if (!dashboardPanels?.configPanelText?.includes('Index limit')) {
failures.push('Mirror dashboard config panel did not render resolved domain configuration.');
}
const clientSetup = results.find((entry) => entry.key === 'client-setup-direct');
if (clientSetup?.heading !== 'Mirror Client Setup') {
failures.push(`Mirror client setup direct route heading mismatch: ${clientSetup?.heading || '<empty>'}`);
}
if (!clientSetup?.mirrorConfigApi?.ok) {
failures.push(`Mirror config API is not healthy for client setup context: status=${clientSetup?.mirrorConfigApi?.status ?? '<unknown>'}`);
}
if (!clientSetup?.mirrorDomainsApi?.ok) {
failures.push(`Mirror domains API is not healthy for client setup context: status=${clientSetup?.mirrorDomainsApi?.status ?? '<unknown>'}`);
}
if (clientSetup?.testConnection && !clientSetup.testConnection.clicked) {
failures.push(`Mirror client Test Connection action failed before request completed: ${clientSetup.testConnection.reason}`);
}
if (clientSetup?.connectionErrorBanner) {
failures.push(`Mirror client connection preflight failed: ${clientSetup.connectionErrorBanner}`);
}
if (!clientSetup?.nextToSignatureEnabled) {
failures.push('Mirror client setup cannot proceed to signature verification after test connection.');
}
const opsClientSetup = results.find((entry) => entry.key === 'ops-client-setup-direct');
if (opsClientSetup?.heading !== 'Mirror Client Setup') {
failures.push(`Ops mirror client setup direct route heading mismatch: ${opsClientSetup?.heading || '<empty>'}`);
}
if (!opsClientSetup?.hasMirrorAddress || !opsClientSetup?.hasTestConnectionButton) {
failures.push('Ops mirror client setup is missing connection controls.');
}
const feedsHandoff = results.find((entry) => entry.key === 'feeds-airgap-configure-sources-handoff');
if (feedsHandoff?.clickResult && !feedsHandoff.clickResult.clicked) {
failures.push(`Feeds & Airgap Configure Sources action failed before navigation: ${feedsHandoff.clickResult.reason}`);
}
if (!pathPresent(feedsHandoff?.url, '/integrations/advisory-vex-sources')) {
failures.push(`Feeds & Airgap Configure Sources did not land on advisory source catalog: ${feedsHandoff?.url || '<empty>'}`);
}
const securityHandoff = results.find((entry) => entry.key === 'security-configure-sources-handoff');
if (securityHandoff?.clickResult && !securityHandoff.clickResult.clicked) {
failures.push(`Security Configure sources action failed before navigation: ${securityHandoff.clickResult.reason}`);
}
if (!pathPresent(securityHandoff?.url, '/integrations/advisory-vex-sources')) {
failures.push(`Security Configure sources did not land on advisory source catalog: ${securityHandoff?.url || '<empty>'}`);
}
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
failedCheckCount: failures.length,
runtimeIssueCount:
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length,
results,
failures,
runtime,
};
await writeFile(resultPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
process.exit(failures.length === 0 ? 0 : 1);
}
main().catch(async (error) => {
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
failedCheckCount: 1,
runtimeIssueCount: 1,
failures: [error instanceof Error ? error.message : String(error)],
};
await mkdir(outputDir, { recursive: true });
await writeFile(resultPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
process.stderr.write(`${report.failures[0]}\n`);
process.exit(1);
});

View File

@@ -387,8 +387,7 @@ export const routes: Routes = [
}, },
{ {
path: '**', path: '**',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard], title: 'Page Not Found',
data: { breadcrumb: 'Mission Control' }, loadComponent: () => import('./features/not-found/not-found.component').then((m) => m.NotFoundComponent),
loadChildren: () => import('./routes/mission-control.routes').then((m) => m.MISSION_CONTROL_ROUTES),
}, },
]; ];

View File

@@ -42,12 +42,7 @@ interface NightlyOpsSignal {
detail: string; detail: string;
} }
interface MissionSummary { // MissionSummary removed — dashboard now computes from real environment data
activePromotions: number;
blockedPromotions: number;
highestRiskEnv: string;
dataIntegrityStatus: 'healthy' | 'degraded' | 'error';
}
@Component({ @Component({
selector: 'app-dashboard-v3', selector: 'app-dashboard-v3',
@@ -65,36 +60,75 @@ interface MissionSummary {
</header> </header>
<!-- Mission Summary Strip --> @if (hasNoEnvironments()) {
<!-- Welcome setup guide for fresh installs -->
<section class="welcome-guide" aria-label="Getting started">
<div class="welcome-header">
<h2>Welcome to Stella Ops</h2>
<p>Set up your release control plane in a few steps.</p>
</div>
<div class="setup-steps">
<a routerLink="/setup/integrations" class="setup-step">
<span class="step-number">1</span>
<div class="step-content">
<strong>Connect a registry</strong>
<span>Link your container registry for image discovery and scanning.</span>
</div>
</a>
<a routerLink="/ops/platform-setup" class="setup-step">
<span class="step-number">2</span>
<div class="step-content">
<strong>Define your topology</strong>
<span>Set up regions, environments, and promotion paths.</span>
</div>
</a>
<a routerLink="/security" class="setup-step">
<span class="step-number">3</span>
<div class="step-content">
<strong>Review security posture</strong>
<span>Scan images and review vulnerability findings.</span>
</div>
</a>
<a routerLink="/releases/versions/new" class="setup-step">
<span class="step-number">4</span>
<div class="step-content">
<strong>Create a release</strong>
<span>Seal your first digest-first release version.</span>
</div>
</a>
</div>
</section>
} @else {
<!-- Mission Summary Strip — real data from context -->
<section class="mission-summary" aria-label="Mission summary"> <section class="mission-summary" aria-label="Mission summary">
<div class="summary-card" [class.warning]="summary().blockedPromotions > 0"> <div class="summary-card">
<div class="summary-value">{{ summary().activePromotions }}</div> <div class="summary-value">{{ filteredEnvironments().length }}</div>
<div class="summary-label">Active Promotions</div> <div class="summary-label">Environments</div>
<a routerLink="/releases/runs" queryParamsHandling="merge" class="summary-link">View all</a> <a routerLink="/setup/topology/environments" queryParamsHandling="merge" class="summary-link">View all</a>
</div> </div>
<div class="summary-card" [class.critical]="summary().blockedPromotions > 0"> <div class="summary-card" [class.critical]="blockedCount() > 0">
<div class="summary-value">{{ summary().blockedPromotions }}</div> <div class="summary-value">{{ blockedCount() }}</div>
<div class="summary-label">Blocked Promotions</div> <div class="summary-label">Blocked Environments</div>
<a routerLink="/releases/approvals" queryParamsHandling="merge" class="summary-link">Review</a> <a routerLink="/releases/approvals" queryParamsHandling="merge" class="summary-link">Review</a>
</div> </div>
<div class="summary-card"> <div class="summary-card" [class.warning]="degradedCount() > 0">
<div class="summary-value env-name">{{ summary().highestRiskEnv }}</div> <div class="summary-value">{{ degradedCount() }}</div>
<div class="summary-label">Highest Risk Environment</div> <div class="summary-label">Degraded Environments</div>
<a routerLink="/security" queryParamsHandling="merge" class="summary-link">Risk detail</a> <a routerLink="/security" queryParamsHandling="merge" class="summary-link">Risk detail</a>
</div> </div>
<div class="summary-card" [class.warning]="summary().dataIntegrityStatus === 'degraded'" <div class="summary-card">
[class.critical]="summary().dataIntegrityStatus === 'error'">
<div class="summary-value"> <div class="summary-value">
<span class="status-dot" [class]="summary().dataIntegrityStatus"></span> <span class="status-dot healthy"></span>
{{ summary().dataIntegrityStatus | titlecase }} {{ healthyCount() }}
</div> </div>
<div class="summary-label">Data Integrity</div> <div class="summary-label">Healthy Environments</div>
<a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge" class="summary-link">Ops detail</a> <a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge" class="summary-link">Ops detail</a>
</div> </div>
</section> </section>
}
<!-- Regional Pipeline Board --> <!-- Regional Pipeline Board -->
<section class="pipeline-board" aria-label="Regional pipeline board"> <section class="pipeline-board" aria-label="Regional pipeline board">
@@ -324,67 +358,15 @@ interface MissionSummary {
</section> </section>
</div> </div>
<!-- Alerts --> <!-- Quick Links -->
<section class="alerts-section" aria-label="Alerts"> <section class="quick-links" aria-label="Quick links">
<div class="section-header"> <a routerLink="/releases/runs" queryParamsHandling="merge" class="domain-nav-item">Release Runs</a>
<h2 class="section-title">Alerts</h2> <a routerLink="/security" queryParamsHandling="merge" class="domain-nav-item">Security & Risk</a>
</div> <a routerLink="/ops/operations" queryParamsHandling="merge" class="domain-nav-item">Operations</a>
<div class="alerts-card"> <a routerLink="/evidence" queryParamsHandling="merge" class="domain-nav-item">Evidence</a>
<ul class="alerts-list"> <a routerLink="/ops/platform-setup" queryParamsHandling="merge" class="domain-nav-item">Platform Setup</a>
<li><a routerLink="/releases/approvals" queryParamsHandling="merge">3 approvals blocked by policy gate evidence freshness</a></li> <a routerLink="/ops/operations/doctor" queryParamsHandling="merge" class="domain-nav-item">Diagnostics</a>
<li><a routerLink="/security/disposition" queryParamsHandling="merge">2 waivers expiring within 24h</a></li>
<li><a routerLink="/ops/operations/data-integrity" queryParamsHandling="merge">Feed freshness degraded for advisory ingest</a></li>
</ul>
</div>
</section> </section>
<!-- Activity -->
<section class="activity-section" aria-label="Recent activity">
<div class="section-header">
<h2 class="section-title">Recent Activity</h2>
</div>
<div class="activity-grid">
<article class="activity-card">
<h3 class="activity-card-title">Release Runs</h3>
<p class="activity-card-desc">Latest standard and hotfix promotions with gate checkpoints.</p>
<a routerLink="/releases/runs" queryParamsHandling="merge" class="activity-card-link">Open Runs</a>
</article>
<article class="activity-card">
<h3 class="activity-card-title">Evidence</h3>
<p class="activity-card-desc">Newest decision capsules and replay verification outcomes.</p>
<a routerLink="/evidence/capsules" queryParamsHandling="merge" class="activity-card-link">Open Capsules</a>
</article>
<article class="activity-card">
<h3 class="activity-card-title">Audit</h3>
<p class="activity-card-desc">Unified activity trail by actor, resource, and correlation key.</p>
<a routerLink="/evidence/audit-log" queryParamsHandling="merge" class="activity-card-link">Open Audit Log</a>
</article>
</div>
</section>
<!-- Cross-domain navigation links -->
<nav class="domain-nav" aria-label="Domain navigation">
<a routerLink="/releases/runs" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9654;</span>
Release Runs
</a>
<a routerLink="/security" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9632;</span>
Security &amp; Risk
</a>
<a routerLink="/ops/operations" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9670;</span>
Platform
</a>
<a routerLink="/evidence" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9679;</span>
Evidence (Decision Capsules)
</a>
<a routerLink="/ops/platform-setup" queryParamsHandling="merge" class="domain-nav-item">
<span class="domain-icon">&#9881;</span>
Platform Setup
</a>
</nav>
</div> </div>
`, `,
styles: [` styles: [`
@@ -418,6 +400,92 @@ interface MissionSummary {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
} }
/* Welcome Guide */
.welcome-guide {
background: var(--color-surface-primary);
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-lg);
padding: 2rem;
}
.welcome-header {
margin-bottom: 1.5rem;
}
.welcome-header h2 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
}
.welcome-header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.setup-steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.setup-step {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--color-surface-elevated);
transition: border-color 0.15s;
}
.setup-step:hover {
border-color: var(--color-brand-primary);
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-full);
background: var(--color-brand-primary);
color: var(--color-text-heading);
font-size: 0.85rem;
font-weight: var(--font-weight-bold);
flex-shrink: 0;
}
.step-content {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.step-content strong {
font-size: 0.9rem;
color: var(--color-text-heading);
}
.step-content span {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
/* Quick Links */
.quick-links {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
border-top: 1px solid var(--color-border-primary);
padding-top: 1.25rem;
}
/* Mission Summary Strip */ /* Mission Summary Strip */
.mission-summary { .mission-summary {
display: grid; display: grid;
@@ -962,86 +1030,22 @@ interface MissionSummary {
export class DashboardV3Component { export class DashboardV3Component {
private readonly context = inject(PlatformContextStore); private readonly context = inject(PlatformContextStore);
// Placeholder mission summary data readonly hasNoEnvironments = computed(() => this.context.environments().length === 0);
readonly summary = signal<MissionSummary>({
activePromotions: 3,
blockedPromotions: 1,
highestRiskEnv: 'prod-eu-west',
dataIntegrityStatus: 'healthy',
});
private readonly fallbackEnvironments: EnvironmentCard[] = [ readonly blockedCount = computed(() =>
{ this.filteredEnvironments().filter((env) => env.deployStatus === 'blocked').length
id: 'dev-eu-west', );
environmentId: 'dev',
regionId: 'eu-west', readonly degradedCount = computed(() =>
name: 'dev', this.filteredEnvironments().filter((env) => env.deployStatus === 'degraded').length
region: 'EU West', );
deployStatus: 'healthy',
sbomFreshness: 'fresh', readonly healthyCount = computed(() =>
critRCount: 0, this.filteredEnvironments().filter((env) => env.deployStatus === 'healthy').length
highRCount: 2, );
birCoverage: '3/3',
pendingApprovals: 0, // No fake fallback data — dashboard shows setup guide when no environments exist.
lastDeployedAt: '2h ago', private readonly fallbackEnvironments: EnvironmentCard[] = [];
},
{
id: 'stage-eu-west',
environmentId: 'stage',
regionId: 'eu-west',
name: 'stage',
region: 'EU West',
deployStatus: 'degraded',
sbomFreshness: 'stale',
critRCount: 1,
highRCount: 5,
birCoverage: '2/3',
pendingApprovals: 2,
lastDeployedAt: '6h ago',
},
{
id: 'prod-eu-west',
environmentId: 'prod',
regionId: 'eu-west',
name: 'prod',
region: 'EU West',
deployStatus: 'healthy',
sbomFreshness: 'fresh',
critRCount: 3,
highRCount: 8,
birCoverage: '3/3',
pendingApprovals: 1,
lastDeployedAt: '1d ago',
},
{
id: 'dev-us-east',
environmentId: 'dev',
regionId: 'us-east',
name: 'dev',
region: 'US East',
deployStatus: 'healthy',
sbomFreshness: 'fresh',
critRCount: 0,
highRCount: 1,
birCoverage: '3/3',
pendingApprovals: 0,
lastDeployedAt: '3h ago',
},
{
id: 'prod-us-east',
environmentId: 'prod',
regionId: 'us-east',
name: 'prod',
region: 'US East',
deployStatus: 'blocked',
sbomFreshness: 'missing',
critRCount: 5,
highRCount: 12,
birCoverage: '1/3',
pendingApprovals: 3,
lastDeployedAt: '3d ago',
},
];
constructor() { constructor() {
this.context.initialize(); this.context.initialize();
@@ -1089,39 +1093,15 @@ export class DashboardV3Component {
}; };
}); });
// Placeholder reachability stats // Reachability stats — zeroed until real scan data is available
readonly reachabilityStats = signal({ readonly reachabilityStats = signal({
bCoverage: 72, bCoverage: 0,
iCoverage: 88, iCoverage: 0,
rCoverage: 61, rCoverage: 0,
}); });
readonly nightlyOpsSignals = signal<NightlyOpsSignal[]>([ // Ops signals — empty until real signal data is available
{ readonly nightlyOpsSignals = signal<NightlyOpsSignal[]>([]);
id: 'sbom-rescan',
label: 'SBOM rescan',
status: 'ok',
detail: 'Nightly SBOM rescan completed in 14m.',
},
{
id: 'nvd-feed',
label: 'NVD feed',
status: 'warn',
detail: 'Feed freshness is stale (3h 12m).',
},
{
id: 'integration-health',
label: 'Integration health',
status: 'ok',
detail: 'Key connectors are operational.',
},
{
id: 'dlq',
label: 'DLQ',
status: 'warn',
detail: '3 pending items require replay.',
},
]);
environmentPostureRoute(env: EnvironmentCard): string[] { environmentPostureRoute(env: EnvironmentCard): string[] {
return ['/setup/topology/environments', env.environmentId, 'posture']; return ['/setup/topology/environments', env.environmentId, 'posture'];
@@ -1161,44 +1141,18 @@ export class DashboardV3Component {
} }
private resolveStatusSeed( private resolveStatusSeed(
environment: PlatformContextEnvironment, _environment: PlatformContextEnvironment,
index: number, _index: number,
): Omit<EnvironmentCard, 'id' | 'environmentId' | 'regionId' | 'name' | 'region'> { ): Omit<EnvironmentCard, 'id' | 'environmentId' | 'regionId' | 'name' | 'region'> {
const environmentType = environment.environmentType.toLowerCase(); // Honest defaults — no scan data available until real backends report
if (environmentType === 'development') {
return { return {
deployStatus: 'healthy', deployStatus: 'unknown',
sbomFreshness: 'fresh', sbomFreshness: 'missing',
critRCount: 0, critRCount: 0,
highRCount: 1, highRCount: 0,
birCoverage: '3/3', birCoverage: '0/0',
pendingApprovals: 0, pendingApprovals: 0,
lastDeployedAt: '3h ago', lastDeployedAt: 'No deployments',
};
}
if (environmentType === 'staging') {
return {
deployStatus: 'degraded',
sbomFreshness: 'stale',
critRCount: 1,
highRCount: 4,
birCoverage: '2/3',
pendingApprovals: 2,
lastDeployedAt: '6h ago',
};
}
const blockedProduction = environment.regionId === 'us-east' && index % 2 === 0;
return {
deployStatus: blockedProduction ? 'blocked' : 'healthy',
sbomFreshness: blockedProduction ? 'missing' : 'fresh',
critRCount: blockedProduction ? 5 : 2,
highRCount: blockedProduction ? 12 : 6,
birCoverage: blockedProduction ? '1/3' : '3/3',
pendingApprovals: blockedProduction ? 3 : 1,
lastDeployedAt: blockedProduction ? '3d ago' : '1d ago',
}; };
} }

View File

@@ -21,6 +21,7 @@ import {
MirrorDomainResponse, MirrorDomainResponse,
MirrorDomainGenerateResponse, MirrorDomainGenerateResponse,
} from './mirror-management.api'; } from './mirror-management.api';
import { buildAdvisoryVexCommands, getAdvisoryVexNavigationExtras } from './advisory-vex-route-helpers';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -1466,7 +1467,7 @@ export class MirrorDomainBuilderComponent implements OnInit {
} }
navigateToSources(): void { navigateToSources(): void {
this.router.navigate(['/ops/integrations/advisory-vex-sources']); this.router.navigate(buildAdvisoryVexCommands(this.router), getAdvisoryVexNavigationExtras());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1480,7 +1481,7 @@ export class MirrorDomainBuilderComponent implements OnInit {
.pipe(take(1)) .pipe(take(1))
.subscribe({ .subscribe({
next: (response) => { next: (response) => {
this.catalog.set(response.items ?? []); this.catalog.set((response.items ?? []).filter(s => s.category !== 'Mirror'));
this.loading.set(false); this.loading.set(false);
}, },
error: () => { error: () => {

View File

@@ -0,0 +1,83 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-not-found',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="not-found">
<div class="not-found-content">
<div class="error-code">404</div>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<div class="actions">
<a routerLink="/mission-control/board" class="btn btn-primary">Go to Dashboard</a>
<a routerLink="/setup/integrations" class="btn btn-secondary">Setup & Integrations</a>
</div>
</div>
</div>
`,
styles: [`
.not-found {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 2rem;
}
.not-found-content {
text-align: center;
max-width: 480px;
}
.error-code {
font-size: 6rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-muted, #4b5563);
line-height: 1;
margin-bottom: 0.5rem;
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
}
p {
color: var(--color-text-secondary);
margin: 0 0 1.5rem;
font-size: 0.95rem;
}
.actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.btn {
padding: 0.6rem 1.25rem;
border-radius: var(--radius-sm, 6px);
font-size: 0.9rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
cursor: pointer;
border: none;
}
.btn-primary {
background: var(--color-brand-primary);
color: var(--color-text-heading);
}
.btn-secondary {
background: var(--color-surface-tertiary, #374151);
color: var(--color-text-heading);
}
`],
})
export class NotFoundComponent {}

View File

@@ -58,9 +58,9 @@ import {
<label> <label>
Target path intent * Target path intent *
<select [(ngModel)]="form.targetPathIntent"> <select [(ngModel)]="form.targetPathIntent">
<option value="dev-stage-prod">Dev ? Stage ? Prod</option> <option value="dev-stage-prod">Dev Stage Prod</option>
<option value="stage-prod">Stage ? Prod</option> <option value="stage-prod">Stage Prod</option>
<option value="hotfix-prod">Hotfix ? Prod</option> <option value="hotfix-prod">Hotfix Prod</option>
</select> </select>
</label> </label>