diff --git a/ControlPlane.Api/Program.cs b/ControlPlane.Api/Program.cs
index 47f51bc..64f2572 100644
--- a/ControlPlane.Api/Program.cs
+++ b/ControlPlane.Api/Program.cs
@@ -126,6 +126,40 @@ await using (var cmd = ds.CreateCommand("""
CREATE INDEX IF NOT EXISTS ix_opc_artifact_opc_id ON opc_artifact(opc_id);
CREATE INDEX IF NOT EXISTS ix_opc_artifact_type ON opc_artifact(opc_id, artifact_type);
CREATE INDEX IF NOT EXISTS ix_opc_pinned_commit_opc_id ON opc_pinned_commit(opc_id);
+
+ -- ── Build + Release history ────────────────────────────────────────────
+ CREATE TABLE IF NOT EXISTS build_record (
+ id VARCHAR(8) PRIMARY KEY,
+ kind VARCHAR(20) NOT NULL,
+ target VARCHAR(500) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'Running',
+ started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ finished_at TIMESTAMPTZ,
+ duration_ms INTEGER,
+ image_digest VARCHAR(200),
+ commit_sha VARCHAR(40),
+ log TEXT NOT NULL DEFAULT ''
+ );
+ CREATE TABLE IF NOT EXISTS release_record (
+ id VARCHAR(8) PRIMARY KEY,
+ environment VARCHAR(50) NOT NULL,
+ image_name VARCHAR(200) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'Running',
+ started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ finished_at TIMESTAMPTZ
+ );
+ CREATE TABLE IF NOT EXISTS release_tenant_result (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ release_id VARCHAR(8) NOT NULL REFERENCES release_record(id) ON DELETE CASCADE,
+ subdomain VARCHAR(200) NOT NULL,
+ container_name VARCHAR(200) NOT NULL,
+ success BOOLEAN NOT NULL DEFAULT FALSE,
+ error TEXT
+ );
+ CREATE INDEX IF NOT EXISTS ix_build_record_started_at ON build_record(started_at DESC);
+ CREATE INDEX IF NOT EXISTS ix_build_record_kind ON build_record(kind);
+ CREATE INDEX IF NOT EXISTS ix_release_record_started_at ON release_record(started_at DESC);
+ CREATE INDEX IF NOT EXISTS ix_release_tenant_release_id ON release_tenant_result(release_id);
"""))
await cmd.ExecuteNonQueryAsync();
diff --git a/ControlPlane.Core/ControlPlane.Core.csproj b/ControlPlane.Core/ControlPlane.Core.csproj
index 9fc81d7..2c0823f 100644
--- a/ControlPlane.Core/ControlPlane.Core.csproj
+++ b/ControlPlane.Core/ControlPlane.Core.csproj
@@ -5,6 +5,7 @@
+
diff --git a/ControlPlane.Core/Services/BuildHistoryService.cs b/ControlPlane.Core/Services/BuildHistoryService.cs
index 95dcbe1..a7dfb78 100644
--- a/ControlPlane.Core/Services/BuildHistoryService.cs
+++ b/ControlPlane.Core/Services/BuildHistoryService.cs
@@ -1,46 +1,36 @@
using System.Text.Json;
using ControlPlane.Core.Models;
-using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
+using Npgsql;
namespace ControlPlane.Core.Services;
///
-/// Persists build and release history to JSON files in the ClientAssets folder.
-/// Thread-safe — all writes go through a single lock per file.
+/// Persists build and release history to opcdb (Postgres).
+/// Replaces the previous JSON-file implementation.
+/// NpgsqlDataSource is singleton and manages the connection pool; this service is safe to register as singleton.
///
-public class BuildHistoryService
+public class BuildHistoryService(NpgsqlDataSource db, ILogger logger)
{
- private readonly string _buildsPath;
- private readonly string _releasesPath;
- private readonly ILogger _logger;
-
- private static readonly SemaphoreSlim _buildLock = new(1, 1);
- private static readonly SemaphoreSlim _releaseLock = new(1, 1);
-
- private static readonly JsonSerializerOptions JsonOpts = new()
- {
- WriteIndented = true,
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
- };
-
- public BuildHistoryService(IConfiguration config, ILogger logger)
- {
- var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"]
- ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
- Directory.CreateDirectory(folder);
- _buildsPath = Path.Combine(folder, "builds.json");
- _releasesPath = Path.Combine(folder, "releases.json");
- _logger = logger;
- }
-
// ── Builds ──────────────────────────────────────────────────────────────
public async Task CreateBuildAsync(BuildKind kind, string target)
{
var record = new BuildRecord { Kind = kind, Target = target };
- await SaveBuildAsync(record);
+
+ await using var cmd = db.CreateCommand("""
+ INSERT INTO build_record (id, kind, target, status, started_at, commit_sha, log)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ """);
+ cmd.Parameters.AddWithValue(record.Id);
+ cmd.Parameters.AddWithValue(record.Kind.ToString());
+ cmd.Parameters.AddWithValue(record.Target);
+ cmd.Parameters.AddWithValue(record.Status.ToString());
+ cmd.Parameters.AddWithValue(record.StartedAt);
+ cmd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value);
+ cmd.Parameters.AddWithValue(string.Empty);
+ await cmd.ExecuteNonQueryAsync();
+
return record;
}
@@ -50,39 +40,68 @@ public class BuildHistoryService
record.FinishedAt = DateTimeOffset.UtcNow;
record.DurationMs = (int)(record.FinishedAt.Value - record.StartedAt).TotalMilliseconds;
record.ImageDigest = digest;
- await SaveBuildAsync(record);
+
+ await using var cmd = db.CreateCommand("""
+ UPDATE build_record
+ SET status = $2, finished_at = $3, duration_ms = $4, image_digest = $5, commit_sha = $6, log = $7
+ WHERE id = $1
+ """);
+ cmd.Parameters.AddWithValue(record.Id);
+ cmd.Parameters.AddWithValue(record.Status.ToString());
+ cmd.Parameters.AddWithValue(record.FinishedAt!.Value);
+ cmd.Parameters.AddWithValue((object?)record.DurationMs ?? DBNull.Value);
+ cmd.Parameters.AddWithValue((object?)record.ImageDigest ?? DBNull.Value);
+ cmd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value);
+ cmd.Parameters.AddWithValue(string.Join('\n', record.Log));
+ await cmd.ExecuteNonQueryAsync();
}
public async Task AppendBuildLogAsync(BuildRecord record, string line)
{
record.Log.Add(line);
- // Flush to disk every 20 lines to avoid excessive I/O but keep reasonable freshness
+ // Flush to Postgres every 20 lines — keeps the live log queryable without hammering the DB
if (record.Log.Count % 20 == 0)
- await SaveBuildAsync(record);
+ await FlushLogAsync(record);
+ }
+
+ private async Task FlushLogAsync(BuildRecord record)
+ {
+ await using var cmd = db.CreateCommand("UPDATE build_record SET log = $2 WHERE id = $1");
+ cmd.Parameters.AddWithValue(record.Id);
+ cmd.Parameters.AddWithValue(string.Join('\n', record.Log));
+ await cmd.ExecuteNonQueryAsync();
}
public async Task> GetBuildsAsync()
{
- await _buildLock.WaitAsync();
- try { return LoadJson(_buildsPath); }
- finally { _buildLock.Release(); }
- }
+ var result = new List();
- private async Task SaveBuildAsync(BuildRecord record)
- {
- await _buildLock.WaitAsync();
- try
+ await using var cmd = db.CreateCommand("""
+ SELECT id, kind, target, status, started_at, finished_at, duration_ms, image_digest, commit_sha, log
+ FROM build_record
+ ORDER BY started_at DESC
+ LIMIT 100
+ """);
+ await using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
{
- var all = LoadJson(_buildsPath);
- var idx = all.FindIndex(b => b.Id == record.Id);
- if (idx >= 0) all[idx] = record;
- else all.Insert(0, record);
-
- // Keep last 100 builds
- if (all.Count > 100) all = all[..100];
- await File.WriteAllTextAsync(_buildsPath, JsonSerializer.Serialize(all, JsonOpts));
+ var logText = reader.IsDBNull(9) ? "" : reader.GetString(9);
+ result.Add(new BuildRecord
+ {
+ Id = reader.GetString(0),
+ Kind = Enum.Parse(reader.GetString(1)),
+ Target = reader.GetString(2),
+ Status = Enum.Parse(reader.GetString(3)),
+ StartedAt = reader.GetFieldValue(4),
+ FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5),
+ DurationMs = reader.IsDBNull(6) ? null : reader.GetInt32(6),
+ ImageDigest = reader.IsDBNull(7) ? null : reader.GetString(7),
+ CommitSha = reader.IsDBNull(8) ? null : reader.GetString(8),
+ Log = logText.Length == 0 ? [] : [.. logText.Split('\n')],
+ });
}
- finally { _buildLock.Release(); }
+
+ return result;
}
// ── Releases ────────────────────────────────────────────────────────────
@@ -90,49 +109,111 @@ public class BuildHistoryService
public async Task CreateReleaseAsync(string environment, string imageName)
{
var record = new ReleaseRecord { Environment = environment, ImageName = imageName };
- await SaveReleaseAsync(record);
+
+ await using var cmd = db.CreateCommand("""
+ INSERT INTO release_record (id, environment, image_name, status, started_at)
+ VALUES ($1, $2, $3, $4, $5)
+ """);
+ cmd.Parameters.AddWithValue(record.Id);
+ cmd.Parameters.AddWithValue(record.Environment);
+ cmd.Parameters.AddWithValue(record.ImageName);
+ cmd.Parameters.AddWithValue(record.Status.ToString());
+ cmd.Parameters.AddWithValue(record.StartedAt);
+ await cmd.ExecuteNonQueryAsync();
+
return record;
}
public async Task UpdateReleaseAsync(ReleaseRecord record)
{
record.FinishedAt = DateTimeOffset.UtcNow;
- await SaveReleaseAsync(record);
+
+ await using var conn = await db.OpenConnectionAsync();
+ await using var tx = await conn.BeginTransactionAsync();
+
+ await using var upd = new NpgsqlCommand("""
+ UPDATE release_record SET status = $2, finished_at = $3 WHERE id = $1
+ """, conn, tx);
+ upd.Parameters.AddWithValue(record.Id);
+ upd.Parameters.AddWithValue(record.Status.ToString());
+ upd.Parameters.AddWithValue(record.FinishedAt!.Value);
+ await upd.ExecuteNonQueryAsync();
+
+ // Replace tenant results wholesale on each update
+ await using var del = new NpgsqlCommand(
+ "DELETE FROM release_tenant_result WHERE release_id = $1", conn, tx);
+ del.Parameters.AddWithValue(record.Id);
+ await del.ExecuteNonQueryAsync();
+
+ foreach (var t in record.Tenants)
+ {
+ await using var ins = new NpgsqlCommand("""
+ INSERT INTO release_tenant_result (release_id, subdomain, container_name, success, error)
+ VALUES ($1, $2, $3, $4, $5)
+ """, conn, tx);
+ ins.Parameters.AddWithValue(record.Id);
+ ins.Parameters.AddWithValue(t.Subdomain);
+ ins.Parameters.AddWithValue(t.ContainerName);
+ ins.Parameters.AddWithValue(t.Success);
+ ins.Parameters.AddWithValue((object?)t.Error ?? DBNull.Value);
+ await ins.ExecuteNonQueryAsync();
+ }
+
+ await tx.CommitAsync();
}
public async Task> GetReleasesAsync()
{
- await _releaseLock.WaitAsync();
- try { return LoadJson(_releasesPath); }
- finally { _releaseLock.Release(); }
- }
+ var ordered = new List();
+ var lookup = new Dictionary();
- private async Task SaveReleaseAsync(ReleaseRecord record)
- {
- await _releaseLock.WaitAsync();
- try
+ await using var cmd = db.CreateCommand("""
+ SELECT id, environment, image_name, status, started_at, finished_at
+ FROM release_record
+ ORDER BY started_at DESC
+ LIMIT 50
+ """);
+ await using (var reader = await cmd.ExecuteReaderAsync())
{
- var all = LoadJson(_releasesPath);
- var idx = all.FindIndex(r => r.Id == record.Id);
- if (idx >= 0) all[idx] = record;
- else all.Insert(0, record);
-
- if (all.Count > 50) all = all[..50];
- await File.WriteAllTextAsync(_releasesPath, JsonSerializer.Serialize(all, JsonOpts));
+ while (await reader.ReadAsync())
+ {
+ var r = new ReleaseRecord
+ {
+ Id = reader.GetString(0),
+ Environment = reader.GetString(1),
+ ImageName = reader.GetString(2),
+ Status = Enum.Parse(reader.GetString(3)),
+ StartedAt = reader.GetFieldValue(4),
+ FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5),
+ };
+ ordered.Add(r);
+ lookup[r.Id] = r;
+ }
}
- finally { _releaseLock.Release(); }
- }
- // ── Helpers ─────────────────────────────────────────────────────────────
+ if (lookup.Count == 0) return [];
- private static List LoadJson(string path)
- {
- if (!File.Exists(path)) return [];
- try
+ // Load all tenant results for the fetched release IDs in one query
+ await using var cmd2 = db.CreateCommand("""
+ SELECT release_id, subdomain, container_name, success, error
+ FROM release_tenant_result
+ WHERE release_id = ANY($1)
+ """);
+ cmd2.Parameters.Add(new NpgsqlParameter { TypedValue = [.. lookup.Keys] });
+ await using var reader2 = await cmd2.ExecuteReaderAsync();
+ while (await reader2.ReadAsync())
{
- var json = File.ReadAllText(path);
- return JsonSerializer.Deserialize>(json, JsonOpts) ?? [];
+ if (lookup.TryGetValue(reader2.GetString(0), out var r))
+ r.Tenants.Add(new TenantReleaseResult
+ {
+ Subdomain = reader2.GetString(1),
+ ContainerName = reader2.GetString(2),
+ Success = reader2.GetBoolean(3),
+ Error = reader2.IsDBNull(4) ? null : reader2.GetString(4),
+ });
}
- catch { return []; }
+
+ return ordered;
}
}
+
diff --git a/clarity.controlplane/src/api/provisioningApi.ts b/clarity.controlplane/src/api/provisioningApi.ts
index 0d2e20f..d620363 100644
--- a/clarity.controlplane/src/api/provisioningApi.ts
+++ b/clarity.controlplane/src/api/provisioningApi.ts
@@ -134,18 +134,19 @@ export function triggerRelease(
export interface ProjectDefinition {
name: string;
- kind: 'DotnetProject' | 'NpmProject';
+ kind: 'DotnetProject' | 'NpmProject' | 'SolutionBuild';
relativePath: string;
}
export interface BuildRecord {
id: string;
- kind: 'DockerImage' | 'DotnetProject' | 'NpmProject';
+ kind: 'DockerImage' | 'DotnetProject' | 'NpmProject' | 'SolutionBuild';
target: string;
status: 'Running' | 'Succeeded' | 'Failed';
startedAt: string;
finishedAt?: string;
durationMs?: number;
+ commitSha?: string;
log: string[];
}
diff --git a/clarity.controlplane/src/pages/BuildMonitorPage.tsx b/clarity.controlplane/src/pages/BuildMonitorPage.tsx
index 9ebc48d..a745cf1 100644
--- a/clarity.controlplane/src/pages/BuildMonitorPage.tsx
+++ b/clarity.controlplane/src/pages/BuildMonitorPage.tsx
@@ -11,9 +11,10 @@ import {
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
const KIND_INTENT: Record = {
- DotnetProject: Intent.PRIMARY,
- NpmProject: Intent.WARNING,
- DockerImage: Intent.NONE,
+ DotnetProject: Intent.PRIMARY,
+ NpmProject: Intent.WARNING,
+ DockerImage: Intent.NONE,
+ SolutionBuild: Intent.SUCCESS,
};
const STATUS_INTENT: Record = {