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 = {