What I Needed to Solve
Azure DevOps gave me pipeline and release details, but it never gave me one clear screen showing what build is deployed to each environment right now for each pipeline. This is the gap I kept running into across delivery reviews, support calls, and release conversations.
Important: this exact matrix-style deployment dashboard is not built in as a single native view in Azure DevOps, so we build it using Azure DevOps APIs.
What I Kept Seeing
I was regularly asked the same question across projects: what exactly is deployed to each environment right now? Azure DevOps exposed the raw release data, but it did not give me the one-screen answer I needed for support calls, release reviews, and incident conversations.
- Octopus had a built-in view for this style of release tracking.
- Azure DevOps required piecing together definitions, releases, and deployments through APIs.
- The manual path meant opening multiple pages to answer one simple question.
I built the dashboard so the answer became obvious in seconds instead of minutes.
What Changed for the Team
- No unified view of current deployed versions across DEV/QA/UAT/PROD.
- Release troubleshooting is slow because teams open multiple pipeline/release pages.
- Managers and support teams cannot quickly answer: “What is in PROD now?”
- Environment drift is hard to spot (for example, QA ahead of UAT, PROD behind).
- Audit and incident calls take longer without one trusted deployment matrix.
What the Dashboard Needed to Show
- Rows: release definitions or services.
- Columns: environments (DEV, QA, UAT, PROD).
- Cell value: latest deployed build/version + status + deployment time.
- Rules: show only environments that have data, highlight stale deployments.
How I Built It
- Backend service calls Azure DevOps APIs using PAT.
- Fetch release definitions, releases, and deployments.
- Flatten response into dashboard model:
pipeline -> environment -> latest deployment. - Cache data for 30-120 seconds to avoid API throttling.
- UI renders matrix with status colors and build links.
Code I Used for the First Cut
The first version stayed small: one endpoint to fetch the deployment snapshot, one service to call Azure DevOps, and one model that the UI could render directly.
| Piece | Purpose |
|---|---|
DashboardOptions | Stores org, project, PAT, and cache settings. |
AzureDevOpsClient | Calls release APIs and returns flattened deployment data. |
DashboardController | Exposes the refresh endpoint used by the UI refresh button. |
public sealed class DashboardOptions
{
public string Organization { get; set; } = "";
public string Project { get; set; } = "";
public string PersonalAccessToken { get; set; } = "";
public int CacheSeconds { get; set; } = 60;
}
public sealed record DeploymentCell(string Environment, string Build, string Status, DateTimeOffset DeployedOnUtc);
public sealed record DashboardRow(string Pipeline, IReadOnlyList<DeploymentCell> Environments);
app.MapGet("/api/dashboard/deployments", async (AzureDevOpsClient client) =>
{
var rows = await client.GetDashboardRowsAsync();
return Results.Ok(new { generatedAtUtc = DateTimeOffset.UtcNow, rows });
});
Refresh Strategy
I deliberately did not make this a real-time dashboard. The page shows a last-refreshed indicator and a refresh button so the team can pull the latest state when they need it.
- Less API traffic because the page does not auto-poll.
- No real-time streaming overhead when the use case does not need it.
- Users control when they want a fresh snapshot.
- The UI stays simple and predictable during daily use.
This mattered because the goal was visibility, not live telemetry.
Azure DevOps APIs I Used
| Purpose | Endpoint | Notes |
|---|---|---|
| List release definitions | GET https://vsrm.dev.azure.com/{org}/{project}/_apis/release/definitions?api-version=7.1 | Get dashboard rows |
| List releases | GET https://vsrm.dev.azure.com/{org}/{project}/_apis/release/releases?$expand=environments&$top=100&api-version=7.1 | Get environment-level release info |
| List deployments | GET https://vsrm.dev.azure.com/{org}/{project}/_apis/release/deployments?latestAttemptsOnly=true&$top=100&api-version=7.1 | Get latest deployment attempts/status |
Use continuation tokens for pagination when volume is high.
How I Authenticated
Create a PAT with minimum scope for reading release data (for example: release read). Send Basic auth with empty username and PAT as password.
Core Data Shape
{
"pipelineName": "OrderService-Release",
"environments": {
"DEV": { "build": "2026.06.01.4", "status": "succeeded", "deployedOn": "2026-06-01T08:12:00Z" },
"QA": { "build": "2026.05.31.9", "status": "succeeded", "deployedOn": "2026-05-31T16:40:00Z" },
"PROD": { "build": "2026.05.28.2", "status": "partiallySucceeded", "deployedOn": "2026-05-28T22:10:00Z" }
}
}
Build this flattened structure once, and your UI becomes very simple.
Build Steps
- Fetch release definitions.
- Fetch releases with environments expanded.
- Fetch deployments for latest attempts only.
- Group by definition + environment and keep most recent deployment.
- Attach build/version info from artifacts.
- Render matrix and stale indicators (e.g., older than 7 days).
Reference Implementation: API Call Sequence
- Call release definitions API to get the list of services/pipelines.
- Call releases API with
$expand=environmentsto get environment-level status. - Call deployments API with
latestAttemptsOnly=truefor latest execution state. - If response has continuation token, keep calling until all pages are collected.
- Merge data and keep the most recent deployment per environment.
Reference Implementation: Backend Endpoint Contract
Create one backend endpoint such as GET /api/dashboard/deployments so UI does not call Azure DevOps directly.
{
"generatedAtUtc": "2026-06-01T09:10:00Z",
"source": "azure-devops",
"rows": [
{
"pipeline": "OrderService-Release",
"environments": [
{ "name": "DEV", "build": "2026.06.01.4", "status": "succeeded", "deployedOnUtc": "2026-06-01T08:12:00Z", "releaseId": 1021 },
{ "name": "QA", "build": "2026.05.31.9", "status": "succeeded", "deployedOnUtc": "2026-05-31T16:40:00Z", "releaseId": 1014 },
{ "name": "PROD", "build": "2026.05.28.2", "status": "partiallySucceeded", "deployedOnUtc": "2026-05-28T22:10:00Z", "releaseId": 995 }
]
}
]
}
Reference Implementation: Environment Name Mapping
Environment names differ across teams (Dev, Development, QA-Internal, etc.). Normalize names before rendering the matrix.
| Incoming Name | Normalized Name |
|---|---|
| dev, development, d1 | DEV |
| qa, test, quality | QA |
| uat, preprod, staging | UAT |
| prod, production, live | PROD |
Reference Implementation: Manual Refresh and Caching
- Cache final dashboard response for 30-120 seconds.
- Expose a manual refresh action that clears or refreshes the snapshot.
- Use timeout per API call (for example, 10 seconds).
- Use retry with exponential backoff for transient failures (429/5xx).
- Return last known successful snapshot if Azure DevOps is temporarily unavailable.
public sealed class DashboardCache
{
private DashboardRow[]? _rows;
private DateTimeOffset _expiresAtUtc;
public bool TryGet(out DashboardRow[]? rows)
{
if (_rows is null || DateTimeOffset.UtcNow > _expiresAtUtc)
{
rows = null;
return false;
}
rows = _rows;
return true;
}
public void Set(DashboardRow[] rows, TimeSpan ttl)
{
_rows = rows;
_expiresAtUtc = DateTimeOffset.UtcNow.Add(ttl);
}
}
Where It Commonly Breaks
- Missing records: implement pagination via continuation token.
- Wrong environment mapping: normalize environment names (trim/case).
- Slow page: use server-side cache and manual refresh instead of auto-polling.
- Rate limits: avoid per-row API calls; fetch in batches.
Delivery Hardening
- Use least-privilege PAT and rotate it regularly.
- Store secrets in Key Vault or equivalent.
- Add retry and timeout policy for REST calls.
- Log API failures with correlation ID and route.
- Add health endpoint and data freshness timestamp on UI.
Real Scenario: Latest Build Looks Stale Until Refresh
The dashboard shows QA updated correctly, but PROD still displays an older build. The release exists in Azure DevOps, yet the UI looks one cycle behind until someone clicks refresh.
| Symptom | What It Usually Means | What I Check First |
|---|---|---|
| One environment stale | Mapping or refresh issue | Environment normalization and cache expiry |
| Some pipelines missing | Pagination not completed | Continuation tokens and API page size |
| UI not updating after deployment | Snapshot not refreshed yet | Cache TTL, refresh trigger, timestamp shown on page |
- Verify the backend fetched every page from Azure DevOps.
- Verify environment names are normalized before grouping.
- Verify the front-end is showing the latest generated snapshot time.
- Verify the user has clicked refresh when they need the latest state.
- Verify the release record actually has a deployment to PROD.
The usual fix is not a UI redesign. It is a data correctness issue in pagination, mapping, cache freshness, or simply waiting for the next manual refresh.