From 65a6b4afafbf7ef3dd213eb9831433f4974c3963 Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sat, 25 Apr 2026 19:35:46 -0400 Subject: [PATCH] OPC # 0001: Gitea services Co-authored-by: Copilot --- ControlPlane.Api/Endpoints/GitEndpoints.cs | 75 ++++---- ControlPlane.Api/Endpoints/GiteaEndpoints.cs | 46 +++-- ControlPlane.Api/Services/GiteaService.cs | 92 ++++++--- ControlPlane.Api/appsettings.json | 7 +- ControlPlane.Core/Models/GiteaModels.cs | 2 +- clarity.controlplane/src/App.tsx | 7 +- clarity.controlplane/src/api/opcApi.ts | 22 ++- .../wizard/DeploymentConfigStep.tsx | 20 +- .../src/components/wizard/ReviewStep.tsx | 2 +- clarity.controlplane/src/index.css | 97 +++++++++- clarity.controlplane/src/opc/OpcPage.tsx | 4 +- .../src/pages/ChangesetsPage.tsx | 174 ++++++++++++++++++ .../src/types/provisioning.ts | 2 +- 13 files changed, 457 insertions(+), 93 deletions(-) create mode 100644 clarity.controlplane/src/pages/ChangesetsPage.tsx diff --git a/ControlPlane.Api/Endpoints/GitEndpoints.cs b/ControlPlane.Api/Endpoints/GitEndpoints.cs index e6a3495..3e7dca4 100644 --- a/ControlPlane.Api/Endpoints/GitEndpoints.cs +++ b/ControlPlane.Api/Endpoints/GitEndpoints.cs @@ -17,10 +17,13 @@ public static class GitEndpoints // GET /api/git/log?grep=OPC+%23+0001&limit=50&repo=all // repo can be "all" (default) or a named key from Git:Repos (e.g. "Clarity", "OPC", "Gateway"). // All responses include a repoKey field so the caller knows which repo each commit came from. + // GET /api/git/log?grep=...&limit=25&page=1&repo=all + // page is 1-based. Each repo contributes up to limit*page commits before merge-sort+skip/take. private static IResult GetLog( IConfiguration config, string? grep = null, - int limit = 50, + int limit = 25, + int page = 1, string repo = "all") { var repos = repo == "all" @@ -56,12 +59,13 @@ public static class GitEndpoints if (!string.IsNullOrWhiteSpace(grep)) query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase)); - foreach (var c in query.Take(limit)) + foreach (var c in query.Take(limit * page)) bucket.Add((c.Author.When, repoKey, ToGitCommit(r, c))); } var commits = bucket .OrderByDescending(x => x.When) + .Skip((page - 1) * limit) .Take(limit) .Select(x => new { @@ -79,41 +83,48 @@ public static class GitEndpoints } // GET /api/git/commits/{hash} + // Searches all registered repos — hash may belong to any of the three repos. private static IResult GetCommit(string hash, IConfiguration config) { - var repoPath = ResolveRepo(config); - if (repoPath is null) - return Results.Problem("Could not locate a git repository."); + var repos = ResolveAllRepos(config); + if (repos.Count == 0) + return Results.Problem("Could not locate any git repositories."); - using var repo = new Repository(repoPath); - var commit = repo.Lookup(hash); - if (commit is null) return Results.NotFound(); - - var parentTree = commit.Parents.FirstOrDefault()?.Tree; - var changes = repo.Diff.Compare(parentTree, commit.Tree); - var patch = repo.Diff.Compare(parentTree, commit.Tree); - - var files = changes.Select(c => new + foreach (var (_, repoPath) in repos) { - path = c.Path, - oldPath = c.OldPath, - status = c.Status.ToString(), - additions = patch[c.Path]?.LinesAdded ?? 0, - deletions = patch[c.Path]?.LinesDeleted ?? 0, - patch = patch[c.Path]?.Patch ?? string.Empty, - }).ToList(); + if (!Directory.Exists(repoPath)) continue; + using var repo = new Repository(repoPath); + var commit = repo.Lookup(hash); + if (commit is null) continue; - return Results.Ok(new - { - hash = commit.Sha, - shortHash = commit.Sha[..7], - author = commit.Author.Name, - email = commit.Author.Email, - date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), - subject = commit.MessageShort, - body = commit.Message, - files, - }); + var parentTree = commit.Parents.FirstOrDefault()?.Tree; + var changes = repo.Diff.Compare(parentTree, commit.Tree); + var patch = repo.Diff.Compare(parentTree, commit.Tree); + + var files = changes.Select(c => new + { + path = c.Path, + oldPath = c.OldPath, + status = c.Status.ToString(), + additions = patch[c.Path]?.LinesAdded ?? 0, + deletions = patch[c.Path]?.LinesDeleted ?? 0, + patch = patch[c.Path]?.Patch ?? string.Empty, + }).ToList(); + + return Results.Ok(new + { + hash = commit.Sha, + shortHash = commit.Sha[..7], + author = commit.Author.Name, + email = commit.Author.Email, + date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"), + subject = commit.MessageShort, + body = commit.Message, + files, + }); + } + + return Results.NotFound(); } // GET /api/git/branches diff --git a/ControlPlane.Api/Endpoints/GiteaEndpoints.cs b/ControlPlane.Api/Endpoints/GiteaEndpoints.cs index 718f2a7..d6b863b 100644 --- a/ControlPlane.Api/Endpoints/GiteaEndpoints.cs +++ b/ControlPlane.Api/Endpoints/GiteaEndpoints.cs @@ -23,14 +23,22 @@ public static class GiteaEndpoints return app; } - private static async Task GetRepo(GiteaService svc, CancellationToken ct) + private static async Task GetRepo(GiteaService svc, string? repo, CancellationToken ct) { - var repo = await svc.GetRepoAsync(ct); - return repo is null ? Results.StatusCode(503) : Results.Ok(repo); + var result = await svc.GetRepoAsync(repo, ct); + return result is null ? Results.StatusCode(503) : Results.Ok(result); } - private static async Task ListBranches(GiteaService svc, CancellationToken ct) => - Results.Ok(await svc.ListBranchesAsync(ct)); + private static async Task ListBranches(GiteaService svc, string? repo, CancellationToken ct) + { + // repo=all returns branches from all registered repos, each tagged with repoKey + if (repo == "all") + { + var all = await svc.ListAllBranchesAsync(ct); + return Results.Ok(all.Select(x => new { repoKey = x.RepoKey, x.Branch.Name, x.Branch.CommitSha, x.Branch.Protected })); + } + return Results.Ok(await svc.ListBranchesAsync(repo, ct)); + } private static async Task CreateBranch( CreateBranchRequest req, GiteaService svc, CancellationToken ct) @@ -40,40 +48,40 @@ public static class GiteaEndpoints } private static async Task ListPulls( - GiteaService svc, string state = "open", CancellationToken ct = default) => - Results.Ok(await svc.ListPullRequestsAsync(state, ct)); + GiteaService svc, string state = "open", string? repo = null, CancellationToken ct = default) => + Results.Ok(await svc.ListPullRequestsAsync(state, repo, ct)); private static async Task GetPull( - long number, GiteaService svc, CancellationToken ct) + long number, GiteaService svc, string? repo = null, CancellationToken ct = default) { - var pr = await svc.GetPullRequestAsync(number, ct); + var pr = await svc.GetPullRequestAsync(number, repo, ct); return pr is null ? Results.NotFound() : Results.Ok(pr); } private static async Task CreatePull( - CreatePullRequestRequest req, GiteaService svc, CancellationToken ct) + CreatePullRequestRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default) { - var pr = await svc.CreatePullRequestAsync(req, ct); + var pr = await svc.CreatePullRequestAsync(req, repo, ct); return pr is null ? Results.BadRequest("Failed to create PR in Gitea.") : Results.Ok(pr); } - private static async Task ListTags(GiteaService svc, CancellationToken ct) => - Results.Ok(await svc.ListTagsAsync(ct)); + private static async Task ListTags(GiteaService svc, string? repo = null, CancellationToken ct = default) => + Results.Ok(await svc.ListTagsAsync(repo, ct)); private static async Task CreateTag( - CreateTagRequest req, GiteaService svc, CancellationToken ct) + CreateTagRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default) { - var tag = await svc.CreateTagAsync(req, ct); + var tag = await svc.CreateTagAsync(req, repo, ct); return tag is null ? Results.BadRequest("Failed to create tag in Gitea.") : Results.Ok(tag); } - private static async Task ListWebhooks(GiteaService svc, CancellationToken ct) => - Results.Ok(await svc.ListWebhooksAsync(ct)); + private static async Task ListWebhooks(GiteaService svc, string? repo = null, CancellationToken ct = default) => + Results.Ok(await svc.ListWebhooksAsync(repo, ct)); private static async Task RegisterWebhook( - CreateWebhookRequest req, GiteaService svc, CancellationToken ct) + CreateWebhookRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default) { - var hook = await svc.RegisterWebhookAsync(req, ct); + var hook = await svc.RegisterWebhookAsync(req, repo, ct); return hook is null ? Results.BadRequest("Failed to register webhook in Gitea.") : Results.Ok(hook); } } diff --git a/ControlPlane.Api/Services/GiteaService.cs b/ControlPlane.Api/Services/GiteaService.cs index b9282c2..be70f3a 100644 --- a/ControlPlane.Api/Services/GiteaService.cs +++ b/ControlPlane.Api/Services/GiteaService.cs @@ -14,17 +14,19 @@ namespace ControlPlane.Api.Services; public class GiteaService { private readonly HttpClient _http; - private readonly string _owner; - private readonly string _repo; + private readonly string _defaultOwner; + private readonly string _defaultRepo; + private readonly IConfiguration _cfg; private readonly ILogger _log; private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger log) { - _log = log; - _owner = cfg["Gitea:Owner"] ?? "Clarity"; - _repo = cfg["Gitea:Repo"] ?? "Clarity"; + _log = log; + _cfg = cfg; + _defaultOwner = cfg["Gitea:Owner"] ?? "ClarityStack"; + _defaultRepo = cfg["Gitea:Repo"] ?? "OPC"; var baseUrl = cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test"; var token = cfg["Gitea:Token"] ?? string.Empty; @@ -37,31 +39,66 @@ public class GiteaService _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); } + // ── Repo resolution ─────────────────────────────────────────────────────── + + /// Returns (owner, repo) for the given key, falling back to the defaults. + private (string Owner, string Repo) ResolveOwnerRepo(string? repoKey = null) + { + if (!string.IsNullOrWhiteSpace(repoKey)) + { + var owner = _cfg[$"Gitea:Repos:{repoKey}:Owner"]; + var repo = _cfg[$"Gitea:Repos:{repoKey}:Repo"]; + if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo)) + return (owner, repo); + } + return (_defaultOwner, _defaultRepo); + } + // ── Repos ───────────────────────────────────────────────────────────────── - public async Task GetRepoAsync(CancellationToken ct = default) + public async Task GetRepoAsync(string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); try { - return await _http.GetFromJsonAsync($"repos/{_owner}/{_repo}", JsonOpts, ct); + return await _http.GetFromJsonAsync($"repos/{owner}/{repo}", JsonOpts, ct); } catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; } } // ── Branches ────────────────────────────────────────────────────────────── - public async Task> ListBranchesAsync(CancellationToken ct = default) + public async Task> ListBranchesAsync(string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( - $"repos/{_owner}/{_repo}/branches?limit=50", JsonOpts, ct) ?? []; + $"repos/{owner}/{repo}/branches?limit=50", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; } } + /// Returns branches from all registered repos, tagged with repoKey. + public async Task> ListAllBranchesAsync(CancellationToken ct = default) + { + var result = new List<(string, GiteaBranch)>(); + var section = _cfg.GetSection("Gitea:Repos"); + var keys = section.GetChildren().Select(c => c.Key).ToList(); + if (keys.Count == 0) keys = ["default"]; + + await Task.WhenAll(keys.Select(async key => + { + var branches = await ListBranchesAsync(key == "default" ? null : key, ct); + lock (result) result.AddRange(branches.Select(b => (key, b))); + })); + + return result; + } + public async Task CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(req.RepoKey); // Slugify: "OPC # 0032" + title → "feature/OPC-0032-git-workflow-integration" var slug = SlugifyTitle(req.OpcTitle); var num = req.OpcNumber.Replace("OPC # ", "OPC-").Replace(" ", ""); @@ -74,7 +111,7 @@ public class GiteaService }, JsonOpts); var res = await _http.PostAsync( - $"repos/{_owner}/{_repo}/branches", + $"repos/{owner}/{repo}/branches", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) @@ -90,29 +127,32 @@ public class GiteaService // ── Pull Requests ───────────────────────────────────────────────────────── public async Task> ListPullRequestsAsync( - string state = "open", CancellationToken ct = default) + string state = "open", string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( - $"repos/{_owner}/{_repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? []; + $"repos/{owner}/{repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; } } - public async Task GetPullRequestAsync(long number, CancellationToken ct = default) + public async Task GetPullRequestAsync(long number, string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync( - $"repos/{_owner}/{_repo}/pulls/{number}", JsonOpts, ct); + $"repos/{owner}/{repo}/pulls/{number}", JsonOpts, ct); } catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; } } public async Task CreatePullRequestAsync( - CreatePullRequestRequest req, CancellationToken ct = default) + CreatePullRequestRequest req, string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); var body = JsonSerializer.Serialize(new { title = req.Title, @@ -122,7 +162,7 @@ public class GiteaService }, JsonOpts); var res = await _http.PostAsync( - $"repos/{_owner}/{_repo}/pulls", + $"repos/{owner}/{repo}/pulls", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) @@ -137,18 +177,20 @@ public class GiteaService // ── Tags ────────────────────────────────────────────────────────────────── - public async Task> ListTagsAsync(CancellationToken ct = default) + public async Task> ListTagsAsync(string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( - $"repos/{_owner}/{_repo}/tags?limit=20", JsonOpts, ct) ?? []; + $"repos/{owner}/{repo}/tags?limit=20", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; } } - public async Task CreateTagAsync(CreateTagRequest req, CancellationToken ct = default) + public async Task CreateTagAsync(CreateTagRequest req, string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); var body = JsonSerializer.Serialize(new { tag_name = req.TagName, @@ -157,7 +199,7 @@ public class GiteaService }, JsonOpts); var res = await _http.PostAsync( - $"repos/{_owner}/{_repo}/tags", + $"repos/{owner}/{repo}/tags", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) @@ -172,19 +214,21 @@ public class GiteaService // ── Webhooks ────────────────────────────────────────────────────────────── - public async Task> ListWebhooksAsync(CancellationToken ct = default) + public async Task> ListWebhooksAsync(string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( - $"repos/{_owner}/{_repo}/hooks", JsonOpts, ct) ?? []; + $"repos/{owner}/{repo}/hooks", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; } } public async Task RegisterWebhookAsync( - CreateWebhookRequest req, CancellationToken ct = default) + CreateWebhookRequest req, string? repoKey = null, CancellationToken ct = default) { + var (owner, repo) = ResolveOwnerRepo(repoKey); var body = JsonSerializer.Serialize(new { type = "gitea", @@ -194,7 +238,7 @@ public class GiteaService }, JsonOpts); var res = await _http.PostAsync( - $"repos/{_owner}/{_repo}/hooks", + $"repos/{owner}/{repo}/hooks", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) diff --git a/ControlPlane.Api/appsettings.json b/ControlPlane.Api/appsettings.json index a043c74..b346775 100644 --- a/ControlPlane.Api/appsettings.json +++ b/ControlPlane.Api/appsettings.json @@ -21,6 +21,11 @@ "BaseUrl": "https://opc.clarity.test", "Owner": "ClarityStack", "Repo": "OPC", - "Token": "fcf9f66415754fb639a8343e3904e06b1d78c646" + "Token": "fcf9f66415754fb639a8343e3904e06b1d78c646", + "Repos": { + "Clarity": { "Owner": "ClarityStack", "Repo": "Clarity" }, + "OPC": { "Owner": "ClarityStack", "Repo": "OPC" }, + "Gateway": { "Owner": "ClarityStack", "Repo": "Gateway" } + } } } \ No newline at end of file diff --git a/ControlPlane.Core/Models/GiteaModels.cs b/ControlPlane.Core/Models/GiteaModels.cs index 3b899b0..4c17892 100644 --- a/ControlPlane.Core/Models/GiteaModels.cs +++ b/ControlPlane.Core/Models/GiteaModels.cs @@ -49,7 +49,7 @@ public record GiteaWebhook(long Id, string Url, bool Active, string[] Events); // ── Request shapes ──────────────────────────────────────────────────────────── -public record CreateBranchRequest(string OpcNumber, string OpcTitle, string From = "master"); +public record CreateBranchRequest(string OpcNumber, string OpcTitle, string RepoKey = "Clarity", string From = "main"); public record CreatePullRequestRequest( string Title, diff --git a/clarity.controlplane/src/App.tsx b/clarity.controlplane/src/App.tsx index 1f2e905..90717f8 100644 --- a/clarity.controlplane/src/App.tsx +++ b/clarity.controlplane/src/App.tsx @@ -9,6 +9,7 @@ import ImageBuildPage from './pages/ImageBuildPage'; import BranchPage from './pages/BranchPage'; import OpcPage from './opc/OpcPage'; import InfraPage from './pages/InfraPage'; +import ChangesetsPage from './pages/ChangesetsPage'; function App() { const [activeNav, setActiveNav] = useState('opc'); @@ -30,8 +31,9 @@ function App() { setActiveNav('image-build')} /> setActiveNav('build-monitor')} /> - setActiveNav('infra')} /> - setActiveNav('opc')} /> + setActiveNav('infra')} /> + setActiveNav('opc')} /> + setActiveNav('changesets')} /> setActiveNav('clients')} /> setActiveNav('settings')} /> @@ -57,6 +59,7 @@ function App() { {activeNav === 'build-monitor' && } {activeNav === 'infra' && } {activeNav === 'opc' && } + {activeNav === 'changesets' && } {activeNav === 'clients' && } {activeNav === 'settings' && } diff --git a/clarity.controlplane/src/api/opcApi.ts b/clarity.controlplane/src/api/opcApi.ts index c4197ab..546b6e8 100644 --- a/clarity.controlplane/src/api/opcApi.ts +++ b/clarity.controlplane/src/api/opcApi.ts @@ -204,6 +204,22 @@ export async function getBranchCoverageForRepo(repoKey: string, hashes: string[] return res.json(); } +// ── Changesets (paginated cross-repo commit log) ─────────────────────────────── +// Reuses LinkedCommit shape — repoKey identifies which repo each commit came from. + +export async function getChangesets( + page = 1, + limit = 25, + repo = 'all', + grep?: string, +): Promise { + const params = new URLSearchParams({ page: String(page), limit: String(limit), repo }); + if (grep) params.set('grep', grep); + const res = await fetch(`${BASE_URL}/api/git/log?${params}`); + if (!res.ok) throw new Error(`Failed to load changesets: ${res.statusText}`); + return res.json(); +} + // ── Commit detail (full diff) ───────────────────────────────────────────────── export interface CommitFile { @@ -296,13 +312,15 @@ function mapArtifact(r: any): OpcArtifact { // ── Gitea branch integration ─────────────────────────────────────────────────── export interface GiteaBranch { + repoKey?: string; // present when querying ?repo=all name: string; commitSha: string; protected: boolean; } -export async function listGiteaBranches(): Promise { - const res = await fetch(`${BASE_URL}/api/gitea/branches`); +export async function listGiteaBranches(repoKey?: string): Promise { + const params = repoKey ? `?repo=${encodeURIComponent(repoKey)}` : ''; + const res = await fetch(`${BASE_URL}/api/gitea/branches${params}`); if (!res.ok) throw new Error(`Failed to load Gitea branches: ${res.statusText}`); return res.json(); } diff --git a/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx b/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx index a268cc2..e925d6a 100644 --- a/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx +++ b/clarity.controlplane/src/components/wizard/DeploymentConfigStep.tsx @@ -14,23 +14,29 @@ const ENVIRONMENTS: { value: TenantEnvironment; label: string; description: stri ]; const TIERS: { value: TenantTier; label: string; description: string; badge: string }[] = [ + { + value: 'Trial', + label: 'Trial', + badge: 'Sandbox', + description: 'Ephemeral all-in-one sandbox. Bundled Postgres, shared Keycloak and Vault. No persistent data guarantee.', + }, { value: 'Shared', label: 'Shared', badge: 'Standard', description: 'Shared Keycloak, Vault, Postgres and MinIO. Isolated by realm, namespace and bucket.', }, - { - value: 'Isolated', - label: 'Isolated', - badge: 'Professional', - description: 'Shared Keycloak and Vault, but a dedicated Postgres container and MinIO bucket per tenant.', - }, { value: 'Dedicated', label: 'Dedicated', + badge: 'Professional', + description: 'Own sidecar containers per component (Postgres, Keycloak, Vault, MinIO) on the shared host.', + }, + { + value: 'Enterprise', + label: 'Enterprise', badge: 'Enterprise', - description: 'Fully dedicated Keycloak, Vault, Postgres and MinIO containers for complete hard isolation.', + description: 'Full VM isolation per component. VpsDocker or VpsBareMetal, provisioned via Pulumi.', }, ]; diff --git a/clarity.controlplane/src/components/wizard/ReviewStep.tsx b/clarity.controlplane/src/components/wizard/ReviewStep.tsx index f445236..3442af3 100644 --- a/clarity.controlplane/src/components/wizard/ReviewStep.tsx +++ b/clarity.controlplane/src/components/wizard/ReviewStep.tsx @@ -24,7 +24,7 @@ export default function ReviewStep({ data }: Props) { Container Name{containerName} Client URL{clientUrl} Admin Email{data.adminEmail} - Tier{data.tier} + Tier{data.tier} diff --git a/clarity.controlplane/src/index.css b/clarity.controlplane/src/index.css index 60f35b3..310f67a 100644 --- a/clarity.controlplane/src/index.css +++ b/clarity.controlplane/src/index.css @@ -385,8 +385,10 @@ body { } .tier-badge-shared { background: #e5e8eb; color: #5f6b7c; } +.tier-badge-trial { background: #dce9ff; color: #184a90; } .tier-badge-isolated { background: #fef3c7; color: #92400e; } -.tier-badge-dedicated { background: #fee2e2; color: #991b1b; } +.tier-badge-dedicated { background: #fde8d8; color: #9e3a06; } +.tier-badge-enterprise { background: #fee2e2; color: #991b1b; } .tier-card-description { font-size: 0.8rem; @@ -963,3 +965,96 @@ body { color: #82071e; } +/* ── Changesets Page ───────────────────────────────────────────────────── */ + +.cs-timeline { + display: flex; + flex-direction: column; + border: 1px solid #dce0e6; + border-radius: 8px; + overflow: hidden; + margin-top: 0.5rem; +} + +.cs-row { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.55rem 1rem; + border-bottom: 1px solid #e5e8eb; + cursor: pointer; + transition: background 0.1s; +} + +.cs-row:last-child { + border-bottom: none; +} + +.cs-row:hover { + background: #f6f7f9; +} + +.cs-row-left { + flex-shrink: 0; + width: 68px; + display: flex; + justify-content: flex-end; + padding-top: 2px; +} + +.cs-repo-badge { + font-size: 0.67rem; + min-width: 52px; + text-align: center; + letter-spacing: 0.01em; +} + +.cs-row-body { + flex: 1; + min-width: 0; +} + +.cs-row-top { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: nowrap; + min-width: 0; +} + +.cs-subject { + font-size: 0.84rem; + font-weight: 500; + color: #1c2127; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cs-row-meta { + font-size: 0.72rem; + color: #738091; + margin-top: 2px; +} + +.cs-file-count { + color: #8f99a8; +} + +.cs-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + padding: 1rem 0; + border-top: 1px solid #e5e8eb; + margin-top: 0.25rem; +} + +.cs-page-label { + font-size: 0.85rem; + color: #5c7080; + font-weight: 500; + min-width: 60px; + text-align: center; +} diff --git a/clarity.controlplane/src/opc/OpcPage.tsx b/clarity.controlplane/src/opc/OpcPage.tsx index f943582..67d2366 100644 --- a/clarity.controlplane/src/opc/OpcPage.tsx +++ b/clarity.controlplane/src/opc/OpcPage.tsx @@ -370,7 +370,7 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) { // Load Gitea branch independently — don't block commit rendering const opcTag = opc.number.replace('OPC # ', 'OPC-'); - listGiteaBranches() + listGiteaBranches('all') .then(branches => { const found = branches.find(b => b.name.includes(opcTag)); setLinkedBranch(found ?? null); @@ -458,7 +458,7 @@ function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) { {linkedBranch.name} Open in Gitea ↗ diff --git a/clarity.controlplane/src/pages/ChangesetsPage.tsx b/clarity.controlplane/src/pages/ChangesetsPage.tsx new file mode 100644 index 0000000..ff854fb --- /dev/null +++ b/clarity.controlplane/src/pages/ChangesetsPage.tsx @@ -0,0 +1,174 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Button, HTMLSelect, InputGroup, Intent, NonIdealState, Spinner, Tag, Tooltip } from '@blueprintjs/core'; +import { GitCommitDrawer } from '../components/GitCommitDrawer'; +import { getChangesets } from '../api/opcApi'; +import type { LinkedCommit } from '../api/opcApi'; + +const PAGE_SIZE = 25; + +const REPO_OPTIONS = [ + { value: 'all', label: 'All Repos' }, + { value: 'Clarity', label: 'Clarity' }, + { value: 'OPC', label: 'OPC' }, + { value: 'Gateway', label: 'Gateway' }, +]; + +const REPO_INTENT: Record = { + Clarity: Intent.PRIMARY, + OPC: Intent.WARNING, + Gateway: Intent.SUCCESS, +}; + +export default function ChangesetsPage() { + const [commits, setCommits] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [repoFilter, setRepoFilter] = useState('all'); + const [grepFilter, setGrepFilter] = useState(''); + const [grepInput, setGrepInput] = useState(''); + const [viewingHash, setViewingHash] = useState(null); + + const load = useCallback(async (p: number, repo: string, grep: string) => { + setLoading(true); + setError(null); + try { + // Fetch one extra to detect whether there's a next page + const rows = await getChangesets(p, PAGE_SIZE + 1, repo, grep || undefined); + setHasMore(rows.length > PAGE_SIZE); + setCommits(rows.slice(0, PAGE_SIZE)); + } catch (e) { + setError(String(e)); + setCommits([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page, repoFilter, grepFilter); + }, [load, page, repoFilter, grepFilter]); + + const applySearch = () => { + setPage(1); + setGrepFilter(grepInput); + }; + + const clearSearch = () => { + setGrepInput(''); + setGrepFilter(''); + setPage(1); + }; + + const handleRepoChange = (repo: string) => { + setPage(1); + setRepoFilter(repo); + }; + + return ( +
+
+
+

Changesets

+

Chronological commit timeline across all three repos.

+
+
+ + {/* Filter bar */} +
+ handleRepoChange(e.target.value)} + options={REPO_OPTIONS} + /> + setGrepInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') applySearch(); }} + style={{ width: 320 }} + rightElement={ +
+ + {/* Content */} + {loading ? ( + } title="Loading changesets…" /> + ) : error ? ( + + ) : commits.length === 0 ? ( + + ) : ( +
+ {commits.map(c => ( +
setViewingHash(c.hash)} + > +
+ + {c.repoKey || '?'} + +
+ +
+
+ + {c.shortHash} + + {c.subject} +
+
+ {c.author} · {c.date} + {c.files.length > 0 && ( + · {c.files.length} file{c.files.length !== 1 ? 's' : ''} + )} +
+
+
+ ))} +
+ )} + + {/* Pagination */} + {!loading && !error && (commits.length > 0 || page > 1) && ( +
+
+ )} + + setViewingHash(null)} /> +
+ ); +} diff --git a/clarity.controlplane/src/types/provisioning.ts b/clarity.controlplane/src/types/provisioning.ts index 24051b9..21afc55 100644 --- a/clarity.controlplane/src/types/provisioning.ts +++ b/clarity.controlplane/src/types/provisioning.ts @@ -1,4 +1,4 @@ -export type TenantTier = 'Shared' | 'Isolated' | 'Dedicated'; +export type TenantTier = 'Trial' | 'Shared' | 'Dedicated' | 'Enterprise'; export type TenantEnvironment = 'fdev' | 'uat' | 'prod'; export interface ProvisioningRequest {