diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index f7bb6f4a2..60ca62522 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -8,20 +8,8 @@ permissions: jobs: build: - name: ${{ matrix.apphost.name }} Build + name: AppHost Build runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - apphost: - - name: AppHost - project_name: Aspire.Dev.AppHost - project_path: src/apphost/Aspire.Dev.AppHost - artifact_name: apphost-release - - name: Preview AppHost - project_name: Aspire.Dev.Preview.AppHost - project_path: src/apphost/Aspire.Dev.Preview.AppHost - artifact_name: preview-apphost-release steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -30,17 +18,17 @@ jobs: global-json-file: global.json - name: Restore - run: cd ${{ matrix.apphost.project_path }} && dotnet restore + run: cd src/apphost/Aspire.Dev.AppHost && dotnet restore - name: Build - run: cd ${{ matrix.apphost.project_path }} && dotnet build --no-restore --configuration Release + run: cd src/apphost/Aspire.Dev.AppHost && dotnet build --no-restore --configuration Release - name: Verify output run: | - APPHOST_DLL=$(ls -1 ${{ matrix.apphost.project_path }}/bin/Release/*/${{ matrix.apphost.project_name }}.dll 2>/dev/null | head -n 1) + APPHOST_DLL=$(ls -1 src/apphost/Aspire.Dev.AppHost/bin/Release/*/Aspire.Dev.AppHost.dll 2>/dev/null | head -n 1) if [ -z "$APPHOST_DLL" ]; then echo "AppHost build failed - output assembly not found" - ls -R ${{ matrix.apphost.project_path }}/bin/Release || true + ls -R src/apphost/Aspire.Dev.AppHost/bin/Release || true exit 1 fi echo "Found $APPHOST_DLL" @@ -49,7 +37,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: ${{ matrix.apphost.artifact_name }} - path: ${{ matrix.apphost.project_path }}/bin/Release/*/ + name: apphost-release + path: src/apphost/Aspire.Dev.AppHost/bin/Release/*/ if-no-files-found: warn retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9368b36a7..d5e1870ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,6 @@ jobs: uses: ./.github/workflows/frontend-build.yml with: node_version: '24.x' - artifact_name: ${{ github.event_name == 'pull_request' && format('frontend-dist-pr-{0}', github.event.pull_request.number) || 'frontend-dist' }} - artifact_retention_days: ${{ github.event_name == 'pull_request' && 30 || 7 }} - site_base_path: ${{ github.event_name == 'pull_request' && format('/prs/{0}', github.event.pull_request.number) || '/' }} apphost-build: needs: changes diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index 6827bbfc2..580c2ed6c 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -8,21 +8,6 @@ on: required: true default: "24.x" type: string - artifact_name: - description: Artifact name to publish - required: false - default: "frontend-dist" - type: string - artifact_retention_days: - description: Number of days to keep the uploaded artifact - required: false - default: 7 - type: number - site_base_path: - description: Path base to use for the Astro build - required: false - default: "/" - type: string permissions: contents: read @@ -56,7 +41,6 @@ jobs: env: MODE: production ASTRO_TELEMETRY_DISABLED: 1 - ASTRO_BASE_PATH: ${{ inputs.site_base_path }} run: pnpm build:production - name: Check dist @@ -77,7 +61,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: ${{ inputs.artifact_name }} + name: frontend-dist path: src/frontend/dist if-no-files-found: warn - retention-days: ${{ inputs.artifact_retention_days }} + retention-days: 7 diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml deleted file mode 100644 index eb8109d3a..000000000 --- a/.github/workflows/pr-preview-cleanup.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: PR Preview Cleanup - -on: - pull_request: - types: [ closed ] - -permissions: - pull-requests: write - -jobs: - cleanup-preview: - if: ${{ vars.PR_PREVIEW_BASE_URL != '' }} - runs-on: ubuntu-latest - steps: - - name: Check preview configuration - id: config - env: - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - run: | - if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then - echo "configured=false" >> "$GITHUB_OUTPUT" - else - echo "configured=true" >> "$GITHUB_OUTPUT" - fi - - - name: Remove preview registration - if: ${{ steps.config.outputs.configured == 'true' }} - env: - PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - curl --fail-with-body \ - --request DELETE \ - --url "${PREVIEW_BASE_URL%/}/api/previews/${PR_NUMBER}" \ - --header "Authorization: Bearer $PREVIEW_REGISTRATION_TOKEN" - - - name: Remove preview block from PR description - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const markerStart = ''; - const markerEnd = ''; - const pullRequest = context.payload.pull_request; - const existingBody = pullRequest.body ?? ''; - const blockExpression = new RegExp(`\\n?${markerStart}[\\s\\S]*?${markerEnd}\\n?`, 'm'); - const updatedBody = existingBody.replace(blockExpression, '\n').trimEnd(); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequest.number, - body: updatedBody, - }); diff --git a/.github/workflows/pr-preview-register.yml b/.github/workflows/pr-preview-register.yml deleted file mode 100644 index 39eec65c2..000000000 --- a/.github/workflows/pr-preview-register.yml +++ /dev/null @@ -1,149 +0,0 @@ -name: PR Preview Register - -on: - workflow_run: - workflows: [ CI ] - types: [ completed ] - -permissions: - actions: read - contents: read - pull-requests: write - -jobs: - register-preview: - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' && vars.PR_PREVIEW_BASE_URL != '' }} - runs-on: ubuntu-latest - steps: - - name: Check preview configuration - id: config - env: - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - run: | - if [ -z "$PREVIEW_REGISTRATION_TOKEN" ]; then - echo "configured=false" >> "$GITHUB_OUTPUT" - else - echo "configured=true" >> "$GITHUB_OUTPUT" - fi - - - name: Resolve preview metadata - if: ${{ steps.config.outputs.configured == 'true' }} - id: metadata - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const run = context.payload.workflow_run; - - if (!run.pull_requests || run.pull_requests.length === 0) { - core.setOutput('should_register', 'false'); - return; - } - - const pullRequest = run.pull_requests[0]; - const artifactName = `frontend-dist-pr-${pullRequest.number}`; - - const artifacts = await github.paginate( - github.rest.actions.listWorkflowRunArtifacts, - { - owner: context.repo.owner, - repo: context.repo.repo, - run_id: run.id, - per_page: 100, - }); - - const previewArtifact = artifacts.find(artifact => artifact.name === artifactName && !artifact.expired); - if (!previewArtifact) { - core.info(`No preview artifact named ${artifactName} was found on run ${run.id}.`); - core.setOutput('should_register', 'false'); - return; - } - - core.setOutput('should_register', 'true'); - core.setOutput('pr_number', String(pullRequest.number)); - core.setOutput('artifact_name', artifactName); - core.setOutput('head_sha', run.head_sha); - core.setOutput('run_id', String(run.id)); - core.setOutput('run_attempt', String(run.run_attempt)); - core.setOutput('completed_at', run.updated_at ?? run.created_at); - - - name: Register preview build - if: ${{ steps.config.outputs.configured == 'true' && steps.metadata.outputs.should_register == 'true' }} - env: - PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} - PREVIEW_REGISTRATION_TOKEN: ${{ secrets.PR_PREVIEW_REGISTRATION_TOKEN }} - PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} - ARTIFACT_NAME: ${{ steps.metadata.outputs.artifact_name }} - HEAD_SHA: ${{ steps.metadata.outputs.head_sha }} - RUN_ID: ${{ steps.metadata.outputs.run_id }} - RUN_ATTEMPT: ${{ steps.metadata.outputs.run_attempt }} - COMPLETED_AT: ${{ steps.metadata.outputs.completed_at }} - REPOSITORY_OWNER: ${{ github.repository_owner }} - REPOSITORY_NAME: ${{ github.event.repository.name }} - run: | - payload=$(jq -n \ - --arg repositoryOwner "$REPOSITORY_OWNER" \ - --arg repositoryName "$REPOSITORY_NAME" \ - --arg headSha "$HEAD_SHA" \ - --arg artifactName "$ARTIFACT_NAME" \ - --arg completedAtUtc "$COMPLETED_AT" \ - --argjson pullRequestNumber "$PR_NUMBER" \ - --argjson runId "$RUN_ID" \ - --argjson runAttempt "$RUN_ATTEMPT" \ - '{ - repositoryOwner: $repositoryOwner, - repositoryName: $repositoryName, - pullRequestNumber: $pullRequestNumber, - headSha: $headSha, - runId: $runId, - runAttempt: $runAttempt, - artifactName: $artifactName, - completedAtUtc: $completedAtUtc - }') - - curl --fail-with-body \ - --request POST \ - --url "${PREVIEW_BASE_URL%/}/api/previews/registrations" \ - --header "Authorization: Bearer $PREVIEW_REGISTRATION_TOKEN" \ - --header "Content-Type: application/json" \ - --data "$payload" - - - name: Update PR description preview block - if: ${{ steps.config.outputs.configured == 'true' && steps.metadata.outputs.should_register == 'true' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - PREVIEW_BASE_URL: ${{ vars.PR_PREVIEW_BASE_URL }} - PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} - with: - script: | - const markerStart = ''; - const markerEnd = ''; - const prNumber = Number(process.env.PR_NUMBER); - const previewUrl = new URL(`/prs/${prNumber}/`, process.env.PREVIEW_BASE_URL).toString(); - const badgeUrl = `https://img.shields.io/badge/aspire.dev-${encodeURIComponent(`PR #${prNumber}`)}-512BD4?style=for-the-badge&labelColor=512BD4&color=555555`; - - const pullRequest = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); - - const existingBody = pullRequest.data.body ?? ''; - const previewBlock = [ - markerStart, - '## 🌐 Preview', - '', - `[![](${badgeUrl})](${previewUrl})`, - markerEnd, - ].join('\n'); - - const blockExpression = new RegExp(`${markerStart}[\\s\\S]*?${markerEnd}`, 'm'); - const updatedBody = blockExpression.test(existingBody) - ? existingBody.replace(blockExpression, previewBlock) - : `${existingBody.trimEnd()}${existingBody.trim() ? '\n\n' : ''}${previewBlock}`; - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - body: updatedBody, - }); diff --git a/Aspire.Dev.slnx b/Aspire.Dev.slnx index 1369bd7d0..de7801a77 100644 --- a/Aspire.Dev.slnx +++ b/Aspire.Dev.slnx @@ -1,11 +1,9 @@ - - diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs deleted file mode 100644 index 6d5872b2d..000000000 --- a/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs +++ /dev/null @@ -1,35 +0,0 @@ -var builder = DistributedApplication.CreateBuilder(args); - -var registrationToken = builder.AddParameter("registration-token", secret: true); -var extractionMode = builder.AddParameter("extraction-mode", value: "command-line"); - -var previewHost = builder.AddProject("previewhost") - .PublishAsDockerFile() - .WithExternalHttpEndpoints() - .WithEnvironment("PreviewHost__RegistrationToken", registrationToken) - .WithEnvironment("PreviewHost__ExtractionMode", extractionMode); - -if (!string.IsNullOrWhiteSpace(builder.Configuration["PreviewHost:GitHubToken"])) -{ - var githubToken = builder.AddParameterFromConfiguration("github-token", "PreviewHost:GitHubToken", secret: true); - previewHost.WithEnvironment("PreviewHost__GitHubToken", githubToken); -} -else -{ - var githubAppId = builder.AddParameter("github-app-id"); - var githubAppInstallationId = builder.AddParameter("github-app-installation-id"); - var githubAppPrivateKey = builder.AddParameter("github-app-private-key", secret: true); - - previewHost - .WithEnvironment("PreviewHost__GitHubAppId", githubAppId) - .WithEnvironment("PreviewHost__GitHubAppInstallationId", githubAppInstallationId) - .WithEnvironment("PreviewHost__GitHubAppPrivateKey", githubAppPrivateKey); -} - -if (!builder.ExecutionContext.IsRunMode) -{ - previewHost.WithEnvironment("PreviewHost__ContentRoot", "/tmp/pr-preview-data"); - builder.AddAzureAppServiceEnvironment("preview"); -} - -builder.Build().Run(); diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj b/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj deleted file mode 100644 index 18b2edc0b..000000000 --- a/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Exe - net10.0 - enable - enable - cde7765d-59fe-4ea8-a20a-c1eb1ced5cbf - - - - - - - - - - - diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json b/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json deleted file mode 100644 index 43294c9d1..000000000 --- a/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17156;http://localhost:15082", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21055", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22207" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:15082", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19290", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20046" - } - } - } -} diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json deleted file mode 100644 index 0c208ae91..000000000 --- a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json deleted file mode 100644 index 31c092aa4..000000000 --- a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning" - } - } -} diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index b5f462eb4..98cf1de7c 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -21,16 +21,9 @@ import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightPageActions from 'starlight-page-actions'; import jopSoftwarecookieconsent from '@jop-software/astro-cookieconsent'; -const configuredBasePath = process.env.ASTRO_BASE_PATH ?? '/'; -const normalizedBasePath = - configuredBasePath === '/' - ? '/' - : `/${configuredBasePath.replace(/^\/+|\/+$/g, '')}`; - // https://astro.build/config export default defineConfig({ prefetch: true, - base: normalizedBasePath, site: 'https://aspire.dev', trailingSlash: 'always', redirects: redirects, diff --git a/src/statichost/PreviewHost/.dockerignore b/src/statichost/PreviewHost/.dockerignore deleted file mode 100644 index cd42ee34e..000000000 --- a/src/statichost/PreviewHost/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -bin/ -obj/ diff --git a/src/statichost/PreviewHost/Dockerfile b/src/statichost/PreviewHost/Dockerfile deleted file mode 100644 index e1aaecb3c..000000000 --- a/src/statichost/PreviewHost/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build -WORKDIR /src - -COPY . ./ - -RUN dotnet publish ./PreviewHost.csproj -c Release -o /app/publish /p:UseAppHost=false - -FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS unzip-tools -RUN apt-get update \ - && apt-get install -y --no-install-recommends unzip \ - && rm -rf /var/lib/apt/lists/* - -FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final -WORKDIR /app - -COPY --from=unzip-tools /usr/bin/unzip /usr/bin/unzip -COPY --from=unzip-tools /lib/x86_64-linux-gnu/libbz2.so.1.0* /lib/x86_64-linux-gnu/ -COPY --from=build /app/publish/ ./ - -ENV ASPNETCORE_URLS=http://+:8080 -EXPOSE 8080 - -ENTRYPOINT ["dotnet", "PreviewHost.dll"] diff --git a/src/statichost/PreviewHost/PreviewHost.csproj b/src/statichost/PreviewHost/PreviewHost.csproj deleted file mode 100644 index 95e47648c..000000000 --- a/src/statichost/PreviewHost/PreviewHost.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - net10.0 - enable - enable - false - false - true - noble-chiseled - - - - - - diff --git a/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs b/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs deleted file mode 100644 index bb0f96298..000000000 --- a/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs +++ /dev/null @@ -1,77 +0,0 @@ -namespace PreviewHost.Previews; - -internal static class CommandLineExtractionSupport -{ - public static bool IsConfigurationSupported(PreviewHostOptions options) => - !options.UseCommandLineExtraction || TryResolveConfiguredTool(options, out _); - - public static string GetConfigurationValidationMessage() => - OperatingSystem.IsWindows() - ? $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting is 'command-line', but 'tar.exe' is not available on PATH. Switch back to 'managed' or deploy PreviewHost on a Windows image that includes tar.exe." - : $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting is 'command-line', but 'unzip' is not available on PATH. Chiseled .NET runtime images do not include unzip by default; switch back to 'managed' or deploy PreviewHost from a custom image with unzip installed."; - - public static bool TryResolveConfiguredTool(PreviewHostOptions options, out string? resolvedPath) - { - resolvedPath = null; - - if (!options.UseCommandLineExtraction) - { - return true; - } - - return TryResolveCommand(options.CommandLineExtractionCommandName!, out resolvedPath); - } - - private static bool TryResolveCommand(string commandName, out string? resolvedPath) - { - resolvedPath = null; - - if (Path.IsPathRooted(commandName) && File.Exists(commandName)) - { - resolvedPath = commandName; - return true; - } - - var pathValue = Environment.GetEnvironmentVariable("PATH"); - if (string.IsNullOrWhiteSpace(pathValue)) - { - return false; - } - - var searchNames = GetSearchNames(commandName); - foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - foreach (var searchName in searchNames) - { - var candidate = Path.Combine(directory, searchName); - if (File.Exists(candidate)) - { - resolvedPath = candidate; - return true; - } - } - } - - return false; - } - - private static IEnumerable GetSearchNames(string commandName) - { - yield return commandName; - - if (!OperatingSystem.IsWindows() || Path.HasExtension(commandName)) - { - yield break; - } - - var pathExt = Environment.GetEnvironmentVariable("PATHEXT"); - var extensions = string.IsNullOrWhiteSpace(pathExt) - ? [".exe", ".cmd", ".bat"] - : pathExt.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var extension in extensions) - { - yield return $"{commandName}{extension}"; - } - } -} diff --git a/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs b/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs deleted file mode 100644 index 42e712895..000000000 --- a/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs +++ /dev/null @@ -1,800 +0,0 @@ -using System.Buffers; -using System.Collections.Concurrent; -using System.Diagnostics; -using GitHubJwt; -using Microsoft.Extensions.Options; -using Octokit; - -namespace PreviewHost.Previews; - -internal sealed class GitHubArtifactClient(IOptions options, ILogger logger) -{ - private static readonly ApiOptions SinglePageApiOptions = new() - { - StartPage = 1, - PageCount = 1, - PageSize = 100 - }; - - private static readonly TimeSpan DownloadProgressPublishInterval = TimeSpan.FromSeconds(1); - private static readonly TimeSpan OpenPullRequestCatalogCacheDuration = TimeSpan.FromMinutes(2); - private const string CiWorkflowPath = ".github/workflows/ci.yml"; - private const string DefaultPreviewArtifactName = "frontend-dist"; - private readonly SemaphoreSlim _installationTokenGate = new(1, 1); - private readonly SemaphoreSlim _pullRequestCatalogGate = new(1, 1); - private readonly PreviewHostOptions _options = options.Value; - private readonly ILogger _logger = logger; - private AccessToken? _cachedInstallationToken; - private long? _cachedInstallationId; - private IReadOnlyList? _cachedOpenPullRequests; - private DateTimeOffset _cachedOpenPullRequestsExpiresAtUtc; - private Task>? _pullRequestCatalogRefreshTask; - - public async Task TryResolveLatestPreviewRegistrationAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - EnsureCredentialsConfigured(); - EnsureRepositoryConfigured(); - - var repositoryClient = await CreateRepositoryClientAsync( - _options.RepositoryOwner, - _options.RepositoryName, - cancellationToken); - - PullRequest? pullRequest; - try - { - pullRequest = await repositoryClient.PullRequest.Get( - _options.RepositoryOwner, - _options.RepositoryName, - pullRequestNumber); - } - catch (NotFoundException) - { - return null; - } - - if (pullRequest is null || string.IsNullOrWhiteSpace(pullRequest.Head?.Sha)) - { - return null; - } - - var workflowRun = await GetLatestSuccessfulPreviewRunAsync( - repositoryClient, - _options.RepositoryOwner, - _options.RepositoryName, - pullRequest.Head.Sha, - cancellationToken); - - if (workflowRun is null) - { - return null; - } - - var artifacts = await GetArtifactsAsync( - repositoryClient, - _options.RepositoryOwner, - _options.RepositoryName, - workflowRun.Id, - cancellationToken); - - var previewArtifact = ResolvePreviewArtifact(artifacts.Artifacts, pullRequestNumber); - if (previewArtifact is null) - { - return null; - } - - return new PreviewRegistrationRequest - { - RepositoryOwner = _options.RepositoryOwner, - RepositoryName = _options.RepositoryName, - PullRequestNumber = pullRequestNumber, - HeadSha = pullRequest.Head.Sha, - RunId = workflowRun.Id, - RunAttempt = checked((int)workflowRun.RunAttempt), - ArtifactName = previewArtifact.Name, - CompletedAtUtc = workflowRun.UpdatedAt - }; - } - - public async Task> ListOpenPullRequestsAsync(CancellationToken cancellationToken) - { - EnsureCredentialsConfigured(); - EnsureRepositoryConfigured(); - - IReadOnlyList? cachedOpenPullRequests = null; - Task>? refreshTask = null; - - await _pullRequestCatalogGate.WaitAsync(cancellationToken); - try - { - if (_cachedOpenPullRequests is not null - && _cachedOpenPullRequestsExpiresAtUtc > DateTimeOffset.UtcNow) - { - return _cachedOpenPullRequests; - } - - cachedOpenPullRequests = _cachedOpenPullRequests; - - if (_pullRequestCatalogRefreshTask is null || _pullRequestCatalogRefreshTask.IsCompleted) - { - _pullRequestCatalogRefreshTask = StartPullRequestCatalogRefreshTask(); - } - - refreshTask = _pullRequestCatalogRefreshTask; - } - finally - { - _pullRequestCatalogGate.Release(); - } - - if (cachedOpenPullRequests is not null) - { - _logger.LogDebug("Serving stale open PR catalog while GitHub previewability data refreshes in the background."); - return cachedOpenPullRequests; - } - - return await refreshTask!.WaitAsync(cancellationToken); - } - - private Task> StartPullRequestCatalogRefreshTask() - { - Task>? refreshTask = null; - - refreshTask = Task.Run( - async () => - { - try - { - return await RefreshOpenPullRequestsAsync(CancellationToken.None); - } - finally - { - await _pullRequestCatalogGate.WaitAsync(CancellationToken.None); - try - { - if (ReferenceEquals(_pullRequestCatalogRefreshTask, refreshTask)) - { - _pullRequestCatalogRefreshTask = null; - } - } - finally - { - _pullRequestCatalogGate.Release(); - } - } - }, - CancellationToken.None); - - _ = refreshTask.ContinueWith( - task => - { - if (task.Exception is null) - { - return; - } - - _logger.LogWarning( - task.Exception.GetBaseException(), - "Failed to refresh the open PR catalog. Continuing to serve the last cached catalog until the next refresh succeeds."); - }, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted, - TaskScheduler.Default); - - return refreshTask; - } - - private async Task> RefreshOpenPullRequestsAsync(CancellationToken cancellationToken) - { - var refreshedPullRequests = await QueryOpenPullRequestsAsync(cancellationToken); - - await _pullRequestCatalogGate.WaitAsync(CancellationToken.None); - try - { - _cachedOpenPullRequests = refreshedPullRequests; - _cachedOpenPullRequestsExpiresAtUtc = DateTimeOffset.UtcNow.Add(OpenPullRequestCatalogCacheDuration); - return _cachedOpenPullRequests; - } - finally - { - _pullRequestCatalogGate.Release(); - } - } - - private async Task> QueryOpenPullRequestsAsync(CancellationToken cancellationToken) - { - var stopwatch = Stopwatch.StartNew(); - - var repositoryClient = await CreateRepositoryClientAsync( - _options.RepositoryOwner, - _options.RepositoryName, - cancellationToken); - - var request = new PullRequestRequest - { - State = ItemStateFilter.Open, - SortProperty = PullRequestSort.Created, - SortDirection = SortDirection.Descending - }; - - var pullRequests = new List(); - - for (var page = 1; ; page++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var pageItems = await repositoryClient.PullRequest.GetAllForRepository( - _options.RepositoryOwner, - _options.RepositoryName, - request, - new ApiOptions - { - StartPage = page, - PageCount = 1, - PageSize = 100 - }); - - if (pageItems.Count == 0) - { - break; - } - - pullRequests.AddRange(pageItems); - - if (pageItems.Count < 100) - { - break; - } - } - - var previewablePullRequests = await GetPullRequestNumbersWithSuccessfulPreviewBuildsAsync( - repositoryClient, - pullRequests, - cancellationToken); - - IReadOnlyList summaries = [.. pullRequests - .OrderByDescending(static pullRequest => pullRequest.CreatedAt) - .ThenByDescending(static pullRequest => pullRequest.Number) - .Select(pullRequest => new GitHubPullRequestSummary( - pullRequest.Number, - pullRequest.Title, - pullRequest.HtmlUrl, - pullRequest.Head?.Sha ?? string.Empty, - pullRequest.User?.Login, - pullRequest.Draft, - pullRequest.CreatedAt, - pullRequest.UpdatedAt, - previewablePullRequests.Contains(pullRequest.Number)))]; - - stopwatch.Stop(); - _logger.LogInformation( - "Refreshed the open PR catalog with {PullRequestCount} pull requests in {ElapsedMilliseconds} ms", - summaries.Count, - stopwatch.ElapsedMilliseconds); - - return summaries; - } - - public async Task GetArtifactDescriptorAsync(PreviewWorkItem workItem, CancellationToken cancellationToken) - { - EnsureCredentialsConfigured(); - - var repositoryClient = await CreateRepositoryClientAsync( - workItem.RepositoryOwner, - workItem.RepositoryName, - cancellationToken); - - var payload = await GetArtifactsAsync( - repositoryClient, - workItem.RepositoryOwner, - workItem.RepositoryName, - workItem.RunId, - cancellationToken); - - var artifact = payload.Artifacts.FirstOrDefault(candidate => - !candidate.Expired - && string.Equals(candidate.Name, workItem.ArtifactName, StringComparison.Ordinal)); - - if (artifact is null) - { - throw new InvalidOperationException( - $"Could not find a non-expired GitHub Actions artifact named '{workItem.ArtifactName}' on run {workItem.RunId}."); - } - - return new GitHubArtifactDescriptor( - workItem.RepositoryOwner, - workItem.RepositoryName, - artifact.Id, - artifact.Name, - artifact.ExpiresAt == default ? DateTimeOffset.UtcNow : new DateTimeOffset(artifact.ExpiresAt, TimeSpan.Zero), - artifact.SizeInBytes > 0 ? artifact.SizeInBytes : null); - } - - public async Task DownloadArtifactAsync( - GitHubArtifactDescriptor artifact, - string destinationZipPath, - Func progressCallback, - CancellationToken cancellationToken) - { - EnsureCredentialsConfigured(); - Directory.CreateDirectory(Path.GetDirectoryName(destinationZipPath)!); - var bufferSettings = PreviewBufferSettings.Resolve(); - - var repositoryClient = await CreateRepositoryClientAsync( - artifact.RepositoryOwner, - artifact.RepositoryName, - cancellationToken); - - await using var sourceStream = await repositoryClient.Actions.Artifacts.DownloadArtifact( - artifact.RepositoryOwner, - artifact.RepositoryName, - artifact.ArtifactId, - "zip"); - - var totalBytes = artifact.SizeInBytes; - if (totalBytes is null && sourceStream.CanSeek) - { - totalBytes = sourceStream.Length; - } - - await using var destinationStream = new FileStream( - destinationZipPath, - System.IO.FileMode.Create, - System.IO.FileAccess.Write, - System.IO.FileShare.None, - bufferSize: bufferSettings.DownloadFileBufferSize, - options: System.IO.FileOptions.Asynchronous); - - _logger.LogInformation( - "Downloading preview artifact {ArtifactName} with adaptive buffers: copy {CopyBufferSizeMiB} MiB, file {FileBufferSizeMiB} MiB (~{AvailableMemoryMiB} MiB headroom)", - artifact.ArtifactName, - bufferSettings.DownloadCopyBufferMiB, - bufferSettings.DownloadFileBufferMiB, - bufferSettings.AvailableMemoryMiB); - - await using var progressPublisher = new DownloadProgressPublisher( - totalBytes, - progressCallback, - cancellationToken); - - var buffer = ArrayPool.Shared.Rent(bufferSettings.DownloadCopyBufferSize); - long downloadedBytes = 0; - - try - { - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); - if (read == 0) - { - break; - } - - await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - downloadedBytes += read; - progressPublisher.Report(downloadedBytes); - } - - await destinationStream.FlushAsync(cancellationToken); - await progressPublisher.CompleteAsync(downloadedBytes); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private static async Task GetArtifactsAsync( - GitHubClient repositoryClient, - string repositoryOwner, - string repositoryName, - long runId, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - return await repositoryClient.Actions.Artifacts.ListWorkflowArtifacts( - repositoryOwner, - repositoryName, - runId, - new ListArtifactsRequest - { - Page = 1, - PerPage = 100 - }); - } - - private static async Task GetLatestSuccessfulPreviewRunAsync( - GitHubClient repositoryClient, - string repositoryOwner, - string repositoryName, - string headSha, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var request = new WorkflowRunsRequest - { - Event = "pull_request", - HeadSha = headSha, - Status = CheckRunStatusFilter.Completed - }; - - var payload = await repositoryClient.Actions.Workflows.Runs.List( - repositoryOwner, - repositoryName, - request, - SinglePageApiOptions); - - return payload.WorkflowRuns - .Where(IsSuccessfulPreviewRun) - .OrderByDescending(static run => run.RunAttempt) - .ThenByDescending(static run => run.UpdatedAt) - .ThenByDescending(static run => run.Id) - .FirstOrDefault(); - } - - private async Task> GetPullRequestNumbersWithSuccessfulPreviewBuildsAsync( - GitHubClient repositoryClient, - IReadOnlyList pullRequests, - CancellationToken cancellationToken) - { - var headShas = pullRequests - .Select(static pullRequest => pullRequest.Head?.Sha) - .OfType() - .Distinct(StringComparer.Ordinal) - .ToArray(); - - if (headShas.Length == 0) - { - return []; - } - - var latestRunsByHeadSha = await GetLatestSuccessfulPreviewRunsByHeadShaAsync( - repositoryClient, - _options.RepositoryOwner, - _options.RepositoryName, - headShas, - cancellationToken); - - if (latestRunsByHeadSha.Count == 0) - { - return []; - } - - var previewablePullRequests = new ConcurrentDictionary(); - - await Parallel.ForEachAsync( - pullRequests, - new ParallelOptions - { - CancellationToken = cancellationToken, - MaxDegreeOfParallelism = 4 - }, - async (pullRequest, ct) => - { - var headSha = pullRequest.Head?.Sha; - if (string.IsNullOrWhiteSpace(headSha) - || !latestRunsByHeadSha.TryGetValue(headSha, out var workflowRun)) - { - return; - } - - var artifacts = await GetArtifactsAsync( - repositoryClient, - _options.RepositoryOwner, - _options.RepositoryName, - workflowRun.Id, - ct); - - if (ResolvePreviewArtifact(artifacts.Artifacts, pullRequest.Number) is not null) - { - previewablePullRequests.TryAdd(pullRequest.Number, 0); - } - }); - - return [.. previewablePullRequests.Keys]; - } - - private static async Task> GetLatestSuccessfulPreviewRunsByHeadShaAsync( - GitHubClient repositoryClient, - string repositoryOwner, - string repositoryName, - IReadOnlyCollection headShas, - CancellationToken cancellationToken) - { - var remainingHeadShas = headShas - .Where(static headSha => !string.IsNullOrWhiteSpace(headSha)) - .ToHashSet(StringComparer.Ordinal); - var workflowRunsByHeadSha = new Dictionary(StringComparer.Ordinal); - - if (remainingHeadShas.Count == 0) - { - return workflowRunsByHeadSha; - } - - for (var page = 1; remainingHeadShas.Count > 0; page++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var payload = await repositoryClient.Actions.Workflows.Runs.List( - repositoryOwner, - repositoryName, - new WorkflowRunsRequest - { - Event = "pull_request", - Status = CheckRunStatusFilter.Completed - }, - new ApiOptions - { - StartPage = page, - PageCount = 1, - PageSize = 100 - }); - - if (payload.WorkflowRuns.Count == 0) - { - break; - } - - foreach (var workflowRun in payload.WorkflowRuns.Where(IsSuccessfulPreviewRun)) - { - if (string.IsNullOrWhiteSpace(workflowRun.HeadSha) - || !remainingHeadShas.Remove(workflowRun.HeadSha)) - { - continue; - } - - workflowRunsByHeadSha[workflowRun.HeadSha] = workflowRun; - } - - if (payload.WorkflowRuns.Count < 100) - { - break; - } - } - - return workflowRunsByHeadSha; - } - - private async Task CreateRepositoryClientAsync( - string repositoryOwner, - string repositoryName, - CancellationToken cancellationToken) - { - if (_options.HasGitHubToken) - { - return CreateClient(new Credentials(_options.GitHubToken)); - } - - var installationToken = await GetInstallationTokenAsync(repositoryOwner, repositoryName, cancellationToken); - return CreateClient(new Credentials(installationToken.Token)); - } - - private async Task GetInstallationTokenAsync( - string repositoryOwner, - string repositoryName, - CancellationToken cancellationToken) - { - EnsureGitHubAppConfigured(); - - await _installationTokenGate.WaitAsync(cancellationToken); - try - { - if (_cachedInstallationToken is not null - && _cachedInstallationToken.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) - { - return _cachedInstallationToken; - } - - var appClient = CreateGitHubAppClient(); - var installationId = _options.GitHubAppInstallationId > 0 - ? _options.GitHubAppInstallationId - : _cachedInstallationId.GetValueOrDefault(); - - if (installationId <= 0) - { - cancellationToken.ThrowIfCancellationRequested(); - installationId = (await appClient.GitHubApps.GetRepositoryInstallationForCurrent( - repositoryOwner, - repositoryName)).Id; - } - - _cachedInstallationId = installationId; - cancellationToken.ThrowIfCancellationRequested(); - _cachedInstallationToken = await appClient.GitHubApps.CreateInstallationToken(installationId); - return _cachedInstallationToken; - } - finally - { - _installationTokenGate.Release(); - } - } - - private GitHubClient CreateGitHubAppClient() - { - EnsureGitHubAppConfigured(); - - var jwtFactory = new GitHubJwtFactory( - new StringPrivateKeySource(NormalizePrivateKey(_options.GitHubAppPrivateKey)), - new GitHubJwtFactoryOptions - { - AppIntegrationId = _options.GitHubAppId, - ExpirationSeconds = 540 - }); - - var jwt = jwtFactory.CreateEncodedJwtToken(TimeSpan.FromMinutes(9)); - return CreateClient(new Credentials(jwt, AuthenticationType.Bearer)); - } - - private GitHubClient CreateClient(Credentials credentials) - { - var client = new GitHubClient( - new ProductHeaderValue("aspire-dev-preview-host"), - new Uri(_options.GitHubApiBaseUrl)) - { - Credentials = credentials - }; - - return client; - } - - private void EnsureCredentialsConfigured() - { - if (_options.HasGitHubToken) - { - return; - } - - EnsureGitHubAppConfigured(); - } - - private void EnsureGitHubAppConfigured() - { - if (!_options.HasGitHubAppConfiguration) - { - throw new InvalidOperationException( - $"Either '{PreviewHostOptions.SectionName}:GitHubToken' or both '{PreviewHostOptions.SectionName}:GitHubAppId' and '{PreviewHostOptions.SectionName}:GitHubAppPrivateKey' must be configured before GitHub authentication can be used."); - } - } - - private void EnsureRepositoryConfigured() - { - if (string.IsNullOrWhiteSpace(_options.RepositoryOwner) || string.IsNullOrWhiteSpace(_options.RepositoryName)) - { - throw new InvalidOperationException( - $"The '{PreviewHostOptions.SectionName}:RepositoryOwner' and '{PreviewHostOptions.SectionName}:RepositoryName' settings must be configured before previews can be discovered."); - } - } - - private static Artifact? ResolvePreviewArtifact(IEnumerable artifacts, int pullRequestNumber) - { - var preferredNames = new[] - { - $"frontend-dist-pr-{pullRequestNumber}", - DefaultPreviewArtifactName - }; - - foreach (var artifactName in preferredNames) - { - var artifact = artifacts.FirstOrDefault(candidate => - !candidate.Expired - && string.Equals(candidate.Name, artifactName, StringComparison.Ordinal)); - - if (artifact is not null) - { - return artifact; - } - } - - return null; - } - - private static bool IsSuccessfulPreviewRun(WorkflowRun run) => - string.Equals(run.Conclusion?.StringValue, "success", StringComparison.OrdinalIgnoreCase) - && (string.Equals(run.Path, CiWorkflowPath, StringComparison.Ordinal) - || string.Equals(run.Name, "CI", StringComparison.Ordinal)); - - private sealed class DownloadProgressPublisher : IAsyncDisposable - { - private readonly Func _progressCallback; - private readonly CancellationTokenSource _publisherCancellationSource; - private readonly SemaphoreSlim _publishGate = new(1, 1); - private readonly Task _publisherTask; - private readonly long? _totalBytes; - private long _latestBytes; - private long _publishedBytes = -1; - private int _completed; - - public DownloadProgressPublisher( - long? totalBytes, - Func progressCallback, - CancellationToken cancellationToken) - { - _totalBytes = totalBytes; - _progressCallback = progressCallback; - _publisherCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _publisherTask = Task.Run(() => PublishLoopAsync(_publisherCancellationSource.Token), CancellationToken.None); - } - - public void Report(long downloadedBytes) => Interlocked.Exchange(ref _latestBytes, downloadedBytes); - - public async Task CompleteAsync(long downloadedBytes) - { - Report(downloadedBytes); - - if (Interlocked.Exchange(ref _completed, 1) != 0) - { - return; - } - - await PublishLatestAsync(CancellationToken.None); - await StopAsync(); - } - - public async ValueTask DisposeAsync() - { - await StopAsync(); - _publishGate.Dispose(); - _publisherCancellationSource.Dispose(); - } - - private async Task PublishLoopAsync(CancellationToken cancellationToken) - { - using var timer = new PeriodicTimer(DownloadProgressPublishInterval); - - try - { - while (await timer.WaitForNextTickAsync(cancellationToken)) - { - await PublishLatestAsync(cancellationToken); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - } - } - - private async Task PublishLatestAsync(CancellationToken cancellationToken) - { - await _publishGate.WaitAsync(cancellationToken); - try - { - var latestBytes = Interlocked.Read(ref _latestBytes); - if (latestBytes == _publishedBytes) - { - return; - } - - await _progressCallback(new PreviewDownloadProgress(latestBytes, _totalBytes), cancellationToken); - _publishedBytes = latestBytes; - } - finally - { - _publishGate.Release(); - } - } - - private async Task StopAsync() - { - _publisherCancellationSource.Cancel(); - - try - { - await _publisherTask; - } - catch (OperationCanceledException) when (_publisherCancellationSource.IsCancellationRequested) - { - } - } - } - - private static string NormalizePrivateKey(string privateKey) - { - var normalized = privateKey.Trim(); - normalized = normalized.Replace("\\r\\n", "\n", StringComparison.Ordinal); - normalized = normalized.Replace("\\n", "\n", StringComparison.Ordinal); - return normalized.Replace("\r\n", "\n", StringComparison.Ordinal); - } -} diff --git a/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs b/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs deleted file mode 100644 index b049fc85b..000000000 --- a/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs +++ /dev/null @@ -1,92 +0,0 @@ -namespace PreviewHost.Previews; - -internal readonly record struct PreviewBufferSettings( - int DownloadCopyBufferSize, - int DownloadFileBufferSize, - int ManagedZipReadBufferSize, - long AvailableMemoryBytes) -{ - private const int OneMiB = 1024 * 1024; - private const long DefaultAvailableMemoryBytes = 2L * 1024 * OneMiB; - - public static PreviewBufferSettings Resolve() - { - var availableMemoryBytes = GetAvailableMemoryBytes(); - - return availableMemoryBytes switch - { - <= 768L * OneMiB => new( - DownloadCopyBufferSize: 4 * OneMiB, - DownloadFileBufferSize: 1 * OneMiB, - ManagedZipReadBufferSize: 4 * OneMiB, - AvailableMemoryBytes: availableMemoryBytes), - - <= 1536L * OneMiB => new( - DownloadCopyBufferSize: 8 * OneMiB, - DownloadFileBufferSize: 2 * OneMiB, - ManagedZipReadBufferSize: 8 * OneMiB, - AvailableMemoryBytes: availableMemoryBytes), - - <= 3072L * OneMiB => new( - DownloadCopyBufferSize: 16 * OneMiB, - DownloadFileBufferSize: 4 * OneMiB, - ManagedZipReadBufferSize: 16 * OneMiB, - AvailableMemoryBytes: availableMemoryBytes), - - <= 6144L * OneMiB => new( - DownloadCopyBufferSize: 32 * OneMiB, - DownloadFileBufferSize: 8 * OneMiB, - ManagedZipReadBufferSize: 24 * OneMiB, - AvailableMemoryBytes: availableMemoryBytes), - - _ => new( - DownloadCopyBufferSize: 64 * OneMiB, - DownloadFileBufferSize: 16 * OneMiB, - ManagedZipReadBufferSize: 32 * OneMiB, - AvailableMemoryBytes: availableMemoryBytes) - }; - } - - public long AvailableMemoryMiB => AvailableMemoryBytes / OneMiB; - - public int DownloadCopyBufferMiB => DownloadCopyBufferSize / OneMiB; - - public int DownloadFileBufferMiB => DownloadFileBufferSize / OneMiB; - - public int ManagedZipReadBufferMiB => ManagedZipReadBufferSize / OneMiB; - - private static long GetAvailableMemoryBytes() - { - var gcInfo = GC.GetGCMemoryInfo(); - var gcBudgetBytes = NormalizePositive(gcInfo.TotalAvailableMemoryBytes); - var managedHeadroomBytes = gcBudgetBytes > 0 - ? Math.Max(gcBudgetBytes - GC.GetTotalMemory(forceFullCollection: false), 0) - : 0; - var loadHeadroomBytes = gcInfo.HighMemoryLoadThresholdBytes > 0 && gcInfo.MemoryLoadBytes > 0 - ? Math.Max(gcInfo.HighMemoryLoadThresholdBytes - gcInfo.MemoryLoadBytes, 0) - : 0; - - long availableMemoryBytes = 0; - - foreach (var candidate in new[] { managedHeadroomBytes, loadHeadroomBytes, gcBudgetBytes }) - { - if (candidate <= 0) - { - continue; - } - - availableMemoryBytes = availableMemoryBytes == 0 - ? candidate - : Math.Min(availableMemoryBytes, candidate); - } - - return availableMemoryBytes > 0 - ? availableMemoryBytes - : DefaultAvailableMemoryBytes; - } - - private static long NormalizePositive(long value) => - value > 0 && value < long.MaxValue - ? value - : 0; -} diff --git a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs deleted file mode 100644 index 29e5b6584..000000000 --- a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs +++ /dev/null @@ -1,997 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.IO.Compression; -using System.Net; -using Microsoft.Extensions.Options; - -namespace PreviewHost.Previews; - -internal sealed class PreviewCoordinator( - PreviewStateStore stateStore, - GitHubArtifactClient artifactClient, - IOptions options, - ILogger logger) -{ - private static readonly TimeSpan ProgressUpdateInterval = TimeSpan.FromSeconds(1); - private static readonly TimeSpan ExtractionProgressReconciliationInterval = TimeSpan.FromSeconds(15); - private const long DownloadProgressByteStride = 32L * 1024 * 1024; - private const int ExtractionProgressUnitStride = 100; - private const int PreparingWeight = 6; - private const int DownloadingWeight = 52; - private const int ExtractingWeight = 38; - private const int ValidatingWeight = 2; - private const int ActivatingWeight = 2; - private const int PreparingStart = 0; - private const int DownloadingStart = PreparingStart + PreparingWeight; - private const int ExtractingStart = DownloadingStart + DownloadingWeight; - private const int ValidatingStart = ExtractingStart + ExtractingWeight; - private const int ActivatingStart = ValidatingStart + ValidatingWeight; - private readonly ConcurrentDictionary> _activeDiscovery = []; - private readonly ConcurrentDictionary _activeLoadCancellations = []; - private readonly ConcurrentDictionary _activeLoads = []; - private readonly PreviewStateStore _stateStore = stateStore; - private readonly GitHubArtifactClient _artifactClient = artifactClient; - private readonly ILogger _logger = logger; - private readonly PreviewHostOptions _options = options.Value; - - public void EnsureLoading(int pullRequestNumber) - { - _activeLoads.GetOrAdd( - pullRequestNumber, - static (prNumber, state) => - { - var cancellationSource = new CancellationTokenSource(); - state._activeLoadCancellations[prNumber] = cancellationSource; - return Task.Run(() => state.LoadAsync(prNumber, cancellationSource.Token), CancellationToken.None); - }, - this); - } - - public async Task BootstrapAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (snapshot is null) - { - var discovery = await EnsureRegisteredAsync(pullRequestNumber, cancellationToken); - snapshot = discovery.Snapshot; - if (snapshot is null) - { - return discovery; - } - } - - if (snapshot.State is PreviewLoadState.Cancelled or PreviewLoadState.Failed or PreviewLoadState.Evicted) - { - snapshot = await _stateStore.RequeueAsync( - pullRequestNumber, - "Retrying preview preparation.", - cancellationToken) ?? snapshot; - } - - if (!snapshot.IsReady) - { - EnsureLoading(pullRequestNumber); - snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; - } - - return new PreviewDiscoveryResult(snapshot); - } - - public async Task RetryAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (snapshot is null) - { - return await BootstrapAsync(pullRequestNumber, cancellationToken); - } - - if (!snapshot.IsReady) - { - snapshot = await _stateStore.RequeueAsync( - pullRequestNumber, - "Retrying preview preparation.", - cancellationToken) ?? snapshot; - } - - EnsureLoading(pullRequestNumber); - snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; - return new PreviewDiscoveryResult(snapshot); - } - - public async Task CancelAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (snapshot is null) - { - return null; - } - - if (_activeLoadCancellations.TryGetValue(pullRequestNumber, out var cancellationSource)) - { - cancellationSource.Cancel(); - } - - if (!snapshot.IsReady) - { - await _stateStore.MarkCancelledAsync(pullRequestNumber, "Preview preparation was cancelled.", CancellationToken.None); - return await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - } - - return snapshot; - } - - public async Task ResetAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (snapshot is null) - { - return false; - } - - if (_activeLoadCancellations.TryGetValue(pullRequestNumber, out var cancellationSource)) - { - cancellationSource.Cancel(); - } - - if (_activeLoads.TryGetValue(pullRequestNumber, out var activeLoad)) - { - try - { - await activeLoad.WaitAsync(cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - _logger.LogDebug(exception, "Preview load task ended while resetting PR #{PullRequestNumber}", pullRequestNumber); - } - } - - await _stateStore.RemoveAsync(pullRequestNumber, cancellationToken); - _logger.LogInformation("Reset preview for PR #{PullRequestNumber}", pullRequestNumber); - return true; - } - - public async Task EnsureRegisteredAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - var existingSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (existingSnapshot is not null) - { - return new PreviewDiscoveryResult(existingSnapshot); - } - - var discoveryTask = _activeDiscovery.GetOrAdd( - pullRequestNumber, - static (prNumber, state) => Task.Run(() => state.DiscoverAsync(prNumber), CancellationToken.None), - this); - - return await discoveryTask.WaitAsync(cancellationToken); - } - - private async Task DiscoverAsync(int pullRequestNumber) - { - try - { - var existingSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, CancellationToken.None); - if (existingSnapshot is not null) - { - return new PreviewDiscoveryResult(existingSnapshot); - } - - var registrationRequest = await _artifactClient.TryResolveLatestPreviewRegistrationAsync(pullRequestNumber, CancellationToken.None); - if (registrationRequest is null) - { - return new PreviewDiscoveryResult( - Snapshot: null, - FailureMessage: "The preview host could not find a successful frontend artifact for this pull request yet."); - } - - var registrationResult = await _stateStore.RegisterAsync(registrationRequest, CancellationToken.None); - return new PreviewDiscoveryResult(registrationResult.Snapshot); - } - catch (Exception exception) - { - _logger.LogError(exception, "Failed to discover preview metadata for PR #{PullRequestNumber}", pullRequestNumber); - return new PreviewDiscoveryResult( - Snapshot: null, - FailureMessage: "The preview host could not look up the latest successful build for this pull request."); - } - finally - { - _activeDiscovery.TryRemove(pullRequestNumber, out _); - } - } - - private async Task LoadAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - string? temporaryZipPath = null; - string? stagingDirectoryPath = null; - var downloadProgress = new ProgressThrottle(); - - try - { - var workItem = await _stateStore.GetWorkItemAsync(pullRequestNumber, cancellationToken); - if (workItem is null) - { - return; - } - - var currentSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (currentSnapshot?.State == PreviewLoadState.Ready) - { - return; - } - - await _stateStore.MarkLoadingAsync( - pullRequestNumber, - stage: "Preparing", - message: "Preparing the preview workspace.", - percent: CalculateWeightedPercent(PreparingStart, PreparingWeight, 15), - stagePercent: 15, - cancellationToken: cancellationToken); - - await EnsureCapacityAsync(pullRequestNumber, cancellationToken); - - temporaryZipPath = _stateStore.GetTemporaryZipPath(workItem); - stagingDirectoryPath = _stateStore.GetStagingDirectoryPath(workItem); - - if (File.Exists(temporaryZipPath)) - { - File.Delete(temporaryZipPath); - } - - if (Directory.Exists(stagingDirectoryPath)) - { - Directory.Delete(stagingDirectoryPath, recursive: true); - } - - Directory.CreateDirectory(Path.GetDirectoryName(temporaryZipPath)!); - Directory.CreateDirectory(stagingDirectoryPath); - - var artifact = await _artifactClient.GetArtifactDescriptorAsync(workItem, cancellationToken); - await _stateStore.UpdateProgressAsync( - pullRequestNumber, - stage: "Downloading", - message: $"Downloading {artifact.ArtifactName} from GitHub Actions.", - percent: CalculateWeightedPercent(DownloadingStart, DownloadingWeight, 0), - stagePercent: 0, - bytesDownloaded: 0, - bytesTotal: null, - itemsCompleted: null, - itemsTotal: null, - itemsLabel: null, - cancellationToken: cancellationToken); - - await _artifactClient.DownloadArtifactAsync( - artifact, - temporaryZipPath, - async (progress, progressCancellationToken) => - { - var stagePercent = CalculateDownloadStagePercent(progress); - var percent = CalculateWeightedPercent(DownloadingStart, DownloadingWeight, stagePercent); - var isComplete = progress.BytesTotal is > 0 && progress.BytesDownloaded >= progress.BytesTotal.Value; - if (!downloadProgress.ShouldPublish( - stage: "Downloading", - percent: percent, - bytesDownloaded: progress.BytesDownloaded, - isTerminal: isComplete)) - { - return; - } - - await _stateStore.UpdateProgressAsync( - pullRequestNumber, - stage: "Downloading", - message: "Downloading the latest preview artifact.", - percent: percent, - stagePercent: stagePercent, - bytesDownloaded: progress.BytesDownloaded, - bytesTotal: progress.BytesTotal, - itemsCompleted: null, - itemsTotal: null, - itemsLabel: null, - progressCancellationToken); - }, - cancellationToken); - - if (!await _stateStore.MatchesBuildAsync(pullRequestNumber, workItem, cancellationToken)) - { - return; - } - - var extractedFileCount = await ExtractArchiveAsync(workItem, temporaryZipPath, stagingDirectoryPath, cancellationToken); - - var activationSourceDirectory = ResolveActivationSourceDirectory(stagingDirectoryPath); - var entryIndexPath = Path.Combine(activationSourceDirectory, "index.html"); - if (!File.Exists(entryIndexPath)) - { - throw new InvalidOperationException("The downloaded preview artifact did not contain an index.html entry."); - } - - if (!await _stateStore.MatchesBuildAsync(pullRequestNumber, workItem, cancellationToken)) - { - return; - } - - await _stateStore.UpdateProgressAsync( - pullRequestNumber, - stage: "Activating", - message: "Activating the preview and switching traffic to the new build.", - percent: CalculateWeightedPercent(ActivatingStart, ActivatingWeight, 50), - stagePercent: 100, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: extractedFileCount, - itemsTotal: extractedFileCount, - itemsLabel: "files", - cancellationToken); - - var activeDirectoryPath = _stateStore.GetActiveDirectoryPath(pullRequestNumber); - if (Directory.Exists(activeDirectoryPath)) - { - Directory.Delete(activeDirectoryPath, recursive: true); - } - - Directory.CreateDirectory(Path.GetDirectoryName(activeDirectoryPath)!); - Directory.Move(activationSourceDirectory, activeDirectoryPath); - stagingDirectoryPath = null; - - await _stateStore.MarkReadyAsync(pullRequestNumber, activeDirectoryPath, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - _logger.LogInformation("Cancelled preview preparation for PR #{PullRequestNumber}", pullRequestNumber); - await _stateStore.MarkCancelledAsync(pullRequestNumber, "Preview preparation was cancelled.", CancellationToken.None); - } - catch (Exception exception) - { - _logger.LogError(exception, "Failed to prepare preview for PR #{PullRequestNumber}", pullRequestNumber); - await _stateStore.MarkFailedAsync(pullRequestNumber, BuildFriendlyErrorMessage(exception), CancellationToken.None); - } - finally - { - if (_activeLoadCancellations.TryRemove(pullRequestNumber, out var cancellationSource)) - { - cancellationSource.Dispose(); - } - - _activeLoads.TryRemove(pullRequestNumber, out _); - - DeleteFileIfPresent(temporaryZipPath); - DeleteDirectoryIfPresent(stagingDirectoryPath); - } - } - - private async Task EnsureCapacityAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - if (_options.MaxActivePreviews <= 0) - { - return; - } - - var readyCandidates = await _stateStore.ListReadyCandidatesAsync(pullRequestNumber, cancellationToken); - var excessCount = readyCandidates.Count - (_options.MaxActivePreviews - 1); - - if (excessCount <= 0) - { - return; - } - - foreach (var candidate in readyCandidates.Take(excessCount)) - { - await _stateStore.EvictAsync( - candidate.PullRequestNumber, - reason: "Evicted to make room for a more recently requested preview.", - cancellationToken: cancellationToken); - } - } - - private async Task ExtractArchiveAsync( - PreviewWorkItem workItem, - string zipPath, - string destinationDirectory, - CancellationToken cancellationToken) - { - var extractionToolDescription = _options.ExtractionToolDescription; - var extractionStopwatch = Stopwatch.StartNew(); - var extractionMessage = $"Extracting preview files with {extractionToolDescription}."; - var totalFileCount = CountArchiveFileEntries(zipPath); - var bufferSettings = PreviewBufferSettings.Resolve(); - using var extractionProgressState = new ExtractionProgressState(totalFileCount); - - await _stateStore.UpdateProgressAsync( - workItem.PullRequestNumber, - stage: "Extracting", - message: extractionMessage, - percent: CalculateWeightedPercent(ExtractingStart, ExtractingWeight, 0), - stagePercent: 0, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: 0, - itemsTotal: totalFileCount, - itemsLabel: "files", - cancellationToken); - - Directory.CreateDirectory(destinationDirectory); - - using var extractionWatcher = CreateExtractionFileWatcher(destinationDirectory, extractionProgressState); - using var extractionReportingCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var extractionReportingTask = ReportExtractionProgressAsync( - workItem, - extractionMessage, - destinationDirectory, - extractionProgressState, - extractionReportingCancellation.Token); - - try - { - if (_options.UseCommandLineExtraction) - { - await ExtractArchiveWithCommandLineAsync(zipPath, destinationDirectory, cancellationToken); - } - else - { - _logger.LogInformation( - "Extracting preview artifact with adaptive managed buffer: {ManagedZipReadBufferMiB} MiB (~{AvailableMemoryMiB} MiB headroom)", - bufferSettings.ManagedZipReadBufferMiB, - bufferSettings.AvailableMemoryMiB); - - using var zipStream = new FileStream( - zipPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: bufferSettings.ManagedZipReadBufferSize, - options: FileOptions.Asynchronous | FileOptions.SequentialScan); - using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false); - await archive.ExtractToDirectoryAsync(destinationDirectory, overwriteFiles: true, cancellationToken); - } - } - finally - { - extractionProgressState.ReconcileWithFilesystem(destinationDirectory, force: true); - extractionReportingCancellation.Cancel(); - await AwaitBackgroundWorkAsync(extractionReportingTask); - } - - extractionStopwatch.Stop(); - _logger.LogInformation( - "Extracted preview artifact for PR #{PullRequestNumber} using {ExtractionTool} in {ElapsedMilliseconds} ms", - workItem.PullRequestNumber, - extractionToolDescription, - extractionStopwatch.ElapsedMilliseconds); - - await _stateStore.UpdateProgressAsync( - workItem.PullRequestNumber, - stage: "Extracting", - message: "Preview artifact extracted.", - percent: CalculateWeightedPercent(ExtractingStart, ExtractingWeight, 100), - stagePercent: 100, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: totalFileCount, - itemsTotal: totalFileCount, - itemsLabel: "files", - cancellationToken); - - await _stateStore.UpdateProgressAsync( - workItem.PullRequestNumber, - stage: "Validating", - message: "Validating the extracted preview output.", - percent: CalculateWeightedPercent(ValidatingStart, ValidatingWeight, 100), - stagePercent: 100, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: totalFileCount, - itemsTotal: totalFileCount, - itemsLabel: "files", - cancellationToken); - - return totalFileCount; - } - - private async Task ExtractArchiveWithCommandLineAsync( - string zipPath, - string destinationDirectory, - CancellationToken cancellationToken) - { - var toolDescription = _options.ExtractionToolDescription; - _logger.LogInformation("Extracting preview artifact via {ExtractionTool}", toolDescription); - - if (OperatingSystem.IsWindows()) - { - await RunExtractionProcessAsync( - "tar.exe", - toolDescription, - cancellationToken, - "-xf", - zipPath, - "-C", - destinationDirectory); - return; - } - - await RunExtractionProcessAsync( - "unzip", - toolDescription, - cancellationToken, - "-o", - "-qq", - zipPath, - "-d", - destinationDirectory); - } - - private async Task RunExtractionProcessAsync( - string fileName, - string toolDescription, - CancellationToken cancellationToken, - params string[] arguments) - { - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = fileName, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - } - }; - - foreach (var argument in arguments) - { - process.StartInfo.ArgumentList.Add(argument); - } - - try - { - if (!process.Start()) - { - throw new InvalidOperationException($"The preview host could not start {toolDescription} for command-line extraction."); - } - } - catch (Exception exception) when (exception is FileNotFoundException or System.ComponentModel.Win32Exception) - { - throw new InvalidOperationException( - $"The preview host is configured for command-line extraction, but {toolDescription} is not available on this machine.", - exception); - } - - var commandDisplay = BuildCommandDisplayString(fileName, arguments); - var outputTail = new ProcessOutputTail(maxLines: 32); - _logger.LogInformation("Starting extraction command for {ExtractionTool}: {Command}", toolDescription, commandDisplay); - - var standardOutputTask = PumpProcessStreamAsync( - process.StandardOutput, - toolDescription, - "stdout", - LogLevel.Information, - outputTail, - emitLiveLogs: false); - var standardErrorTask = PumpProcessStreamAsync( - process.StandardError, - toolDescription, - "stderr", - LogLevel.Warning, - outputTail, - emitLiveLogs: true); - using var cancellationRegistration = cancellationToken.Register(static state => TryKillProcess((Process)state!), process); - - try - { - await process.WaitForExitAsync(cancellationToken); - } - catch (OperationCanceledException) - { - TryKillProcess(process); - await process.WaitForExitAsync(CancellationToken.None); - throw; - } - - await Task.WhenAll(standardOutputTask, standardErrorTask); - - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"The preview host could not extract the artifact using {toolDescription}. Exit code {process.ExitCode}. {BuildProcessFailureOutput(outputTail.BuildSummary())}"); - } - - _logger.LogInformation("Extraction command completed successfully via {ExtractionTool}", toolDescription); - } - - private async Task PumpProcessStreamAsync( - StreamReader reader, - string toolDescription, - string streamName, - LogLevel logLevel, - ProcessOutputTail outputTail, - bool emitLiveLogs) - { - while (await reader.ReadLineAsync() is { } line) - { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - outputTail.Add(streamName, line); - if (emitLiveLogs) - { - _logger.Log(logLevel, "{ExtractionTool} {StreamName}: {Line}", toolDescription, streamName, line); - } - } - } - - private static string BuildProcessFailureOutput(string detail) - { - var normalizedDetail = string.IsNullOrWhiteSpace(detail) - ? "The extraction tool did not emit any additional output." - : detail.Trim(); - - const int maxLength = 600; - return normalizedDetail.Length <= maxLength - ? normalizedDetail - : $"{normalizedDetail[..maxLength]}..."; - } - - private async Task ReportExtractionProgressAsync( - PreviewWorkItem workItem, - string extractionMessage, - string destinationDirectory, - ExtractionProgressState extractionProgressState, - CancellationToken cancellationToken) - { - using var timer = new PeriodicTimer(ProgressUpdateInterval); - var progressThrottle = new ProgressThrottle(); - - try - { - while (await timer.WaitForNextTickAsync(cancellationToken)) - { - extractionProgressState.ReconcileWithFilesystem(destinationDirectory, force: false); - var completedFiles = extractionProgressState.GetCompletedFiles(); - var stagePercent = CalculateExtractionStagePercent(completedFiles, extractionProgressState.TotalFiles); - var percent = CalculateWeightedPercent(ExtractingStart, ExtractingWeight, stagePercent); - var isComplete = completedFiles >= extractionProgressState.TotalFiles; - - if (!progressThrottle.ShouldPublish( - stage: "Extracting", - percent: percent, - isTerminal: isComplete, - unitsCompleted: completedFiles, - unitStride: ExtractionProgressUnitStride)) - { - continue; - } - - await _stateStore.UpdateProgressAsync( - workItem.PullRequestNumber, - stage: "Extracting", - message: extractionMessage, - percent: percent, - stagePercent: stagePercent, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: completedFiles, - itemsTotal: extractionProgressState.TotalFiles, - itemsLabel: "files", - cancellationToken); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - } - } - - private static int CountArchiveFileEntries(string zipPath) - { - using var archive = ZipFile.OpenRead(zipPath); - return archive.Entries.Count(static entry => !string.IsNullOrEmpty(entry.Name)); - } - - private static FileSystemWatcher CreateExtractionFileWatcher(string destinationDirectory, ExtractionProgressState extractionProgressState) - { - var watcher = new FileSystemWatcher(destinationDirectory) - { - IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.FileName, - InternalBufferSize = 64 * 1024 - }; - - watcher.Created += (_, eventArgs) => extractionProgressState.TryTrackFile(eventArgs.FullPath); - watcher.Renamed += (_, eventArgs) => extractionProgressState.TryTrackFile(eventArgs.FullPath); - watcher.Error += (_, _) => extractionProgressState.MarkNeedsReconciliation(); - watcher.EnableRaisingEvents = true; - return watcher; - } - - private static string BuildCommandDisplayString(string fileName, IEnumerable arguments) => - string.Join( - ' ', - new[] { QuoteCommandSegment(fileName) }.Concat(arguments.Select(QuoteCommandSegment))); - - private static string QuoteCommandSegment(string value) => - string.IsNullOrWhiteSpace(value) || value.IndexOfAny([' ', '\t', '"']) >= 0 - ? $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" - : value; - - private static int CalculateExtractionStagePercent(int completedFiles, int totalFiles) - { - if (totalFiles <= 0) - { - return 0; - } - - var rawPercent = (double)completedFiles / totalFiles; - return Math.Clamp((int)Math.Round(rawPercent * 100d), 0, 100); - } - - private static int CalculateDownloadStagePercent(PreviewDownloadProgress progress) - { - if (progress.BytesTotal is > 0) - { - var rawPercent = (double)progress.BytesDownloaded / progress.BytesTotal.Value; - return Math.Clamp((int)Math.Round(rawPercent * 100d), 0, 100); - } - - return 0; - } - - private static int CalculateWeightedPercent(int stageStart, int stageWeight, int stagePercent) - { - var clampedStagePercent = Math.Clamp(stagePercent, 0, 100); - var weightedProgress = (int)Math.Round(stageWeight * (clampedStagePercent / 100d)); - return Math.Clamp(stageStart + weightedProgress, 0, 99); - } - - private static string BuildFriendlyErrorMessage(Exception exception) - { - if (exception is InvalidOperationException invalidOperationException) - { - if (invalidOperationException.Message.Contains("GitHubToken", StringComparison.Ordinal) - || invalidOperationException.Message.Contains("GitHubAppId", StringComparison.Ordinal) - || invalidOperationException.Message.Contains("GitHubAppPrivateKey", StringComparison.Ordinal)) - { - return "The preview host is missing its GitHub artifact-read credential."; - } - - if (invalidOperationException.Message.Contains("RepositoryOwner", StringComparison.Ordinal) - || invalidOperationException.Message.Contains("RepositoryName", StringComparison.Ordinal)) - { - return "The preview host is missing its GitHub repository configuration."; - } - - if (invalidOperationException.Message.Contains("artifact", StringComparison.OrdinalIgnoreCase)) - { - return "The requested preview artifact could not be found or is no longer available."; - } - - if (invalidOperationException.Message.Contains("tar.exe", StringComparison.OrdinalIgnoreCase) - || invalidOperationException.Message.Contains("unzip", StringComparison.OrdinalIgnoreCase) - || invalidOperationException.Message.Contains("command-line extraction", StringComparison.OrdinalIgnoreCase)) - { - return "The preview host could not extract the artifact using its configured extraction tool."; - } - } - - if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode == HttpStatusCode.Unauthorized) - { - return "The preview host could not authenticate with GitHub to download this artifact."; - } - - if (exception is HttpRequestException { StatusCode: HttpStatusCode.NotFound or HttpStatusCode.Gone }) - { - return "The requested preview artifact could not be found or has already expired."; - } - - return "The preview could not be prepared. Check the preview host logs for more details."; - } - - private static string ResolveActivationSourceDirectory(string extractedRoot) - { - var topLevelIndexPath = Path.Combine(extractedRoot, "index.html"); - if (File.Exists(topLevelIndexPath)) - { - return extractedRoot; - } - - var nestedDirectory = Directory.EnumerateDirectories(extractedRoot) - .Select(static directory => new - { - Directory = directory, - IndexPath = Path.Combine(directory, "index.html") - }) - .FirstOrDefault(static candidate => File.Exists(candidate.IndexPath)); - - return nestedDirectory?.Directory ?? extractedRoot; - } - - private static void DeleteFileIfPresent(string? path) - { - if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) - { - File.Delete(path); - } - } - - private static void DeleteDirectoryIfPresent(string? path) - { - if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - - private static void TryKillProcess(Process process) - { - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - } - catch (InvalidOperationException) - { - } - } - - private static async Task AwaitBackgroundWorkAsync(Task task) - { - try - { - await task; - } - catch (OperationCanceledException) - { - } - } - - private sealed class ProcessOutputTail(int maxLines) - { - private readonly object _gate = new(); - private readonly Queue _lines = new(); - private readonly int _maxLines = maxLines; - - public void Add(string streamName, string line) - { - lock (_gate) - { - _lines.Enqueue($"{streamName}: {line}"); - while (_lines.Count > _maxLines) - { - _lines.Dequeue(); - } - } - } - - public string BuildSummary() - { - lock (_gate) - { - return string.Join(Environment.NewLine, _lines); - } - } - } - - private sealed class ExtractionProgressState(int totalFiles) : IDisposable - { - private readonly object _gate = new(); - private readonly HashSet _seenFiles = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); - private int _needsReconciliation; - private DateTimeOffset _lastReconciledAtUtc = DateTimeOffset.MinValue; - - public int TotalFiles { get; } = totalFiles; - - public int GetCompletedFiles() - { - lock (_gate) - { - return _seenFiles.Count; - } - } - - public void TryTrackFile(string path) - { - if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) - { - return; - } - - lock (_gate) - { - _seenFiles.Add(path); - } - } - - public void MarkNeedsReconciliation() => Interlocked.Exchange(ref _needsReconciliation, 1); - - public void ReconcileWithFilesystem(string destinationDirectory, bool force) - { - var needsReconciliation = Interlocked.Exchange(ref _needsReconciliation, 0) != 0; - var now = DateTimeOffset.UtcNow; - - if (!force - && !needsReconciliation - && now - _lastReconciledAtUtc < ExtractionProgressReconciliationInterval) - { - return; - } - - if (!force && GetCompletedFiles() >= TotalFiles) - { - return; - } - - var files = Directory.Exists(destinationDirectory) - ? Directory.EnumerateFiles(destinationDirectory, "*", SearchOption.AllDirectories) - : []; - - lock (_gate) - { - _seenFiles.Clear(); - foreach (var file in files) - { - _seenFiles.Add(file); - } - } - - _lastReconciledAtUtc = now; - } - - public void Dispose() - { - } - } - - private sealed class ProgressThrottle - { - private string? _lastStage; - private int _lastPercent = -1; - private long _lastBytesDownloaded = -1; - private int _lastUnitsCompleted; - private DateTimeOffset _lastPublishedAtUtc = DateTimeOffset.MinValue; - - public bool ShouldPublish( - string stage, - int percent, - bool isTerminal, - long? bytesDownloaded = null, - int unitsCompleted = 0, - int unitStride = 0) - { - var now = DateTimeOffset.UtcNow; - if (_lastPublishedAtUtc == DateTimeOffset.MinValue) - { - Publish(stage, percent, bytesDownloaded, unitsCompleted, now); - return true; - } - - if (!string.Equals(stage, _lastStage, StringComparison.Ordinal) - || isTerminal - || percent > _lastPercent - || now - _lastPublishedAtUtc >= ProgressUpdateInterval - || bytesDownloaded is >= 0 && bytesDownloaded.Value - _lastBytesDownloaded >= DownloadProgressByteStride - || unitStride > 0 && unitsCompleted - _lastUnitsCompleted >= unitStride) - { - Publish(stage, percent, bytesDownloaded, unitsCompleted, now); - return true; - } - - return false; - } - - private void Publish(string stage, int percent, long? bytesDownloaded, int unitsCompleted, DateTimeOffset publishedAtUtc) - { - _lastStage = stage; - _lastPercent = percent; - _lastBytesDownloaded = bytesDownloaded ?? _lastBytesDownloaded; - _lastUnitsCompleted = unitsCompleted; - _lastPublishedAtUtc = publishedAtUtc; - } - } -} diff --git a/src/statichost/PreviewHost/Previews/PreviewModels.cs b/src/statichost/PreviewHost/Previews/PreviewModels.cs deleted file mode 100644 index 0336c9364..000000000 --- a/src/statichost/PreviewHost/Previews/PreviewModels.cs +++ /dev/null @@ -1,284 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace PreviewHost.Previews; - -internal sealed class PreviewHostOptions -{ - public const string SectionName = "PreviewHost"; - - public string? DataRoot { get; set; } - - public string? ContentRoot { get; set; } - - public string RepositoryOwner { get; set; } = "microsoft"; - - public string RepositoryName { get; set; } = "aspire.dev"; - - public int MaxActivePreviews { get; set; } = 10; - - public string RegistrationToken { get; set; } = string.Empty; - - public string GitHubToken { get; set; } = string.Empty; - - public int GitHubAppId { get; set; } - - public long GitHubAppInstallationId { get; set; } - - public string GitHubAppPrivateKey { get; set; } = string.Empty; - - public string GitHubApiBaseUrl { get; set; } = "https://api.github.com/"; - - public string ExtractionMode { get; set; } = "managed"; - - [JsonIgnore] - public bool HasGitHubToken => !string.IsNullOrWhiteSpace(GitHubToken); - - [JsonIgnore] - public bool HasGitHubAppConfiguration => - GitHubAppId > 0 - && !string.IsNullOrWhiteSpace(GitHubAppPrivateKey); - - [JsonIgnore] - public bool HasValidExtractionMode => - string.Equals(ExtractionMode, "managed", StringComparison.OrdinalIgnoreCase) - || string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); - - [JsonIgnore] - public bool UseCommandLineExtraction => - string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); - - [JsonIgnore] - public string ExtractionToolDescription => - UseCommandLineExtraction - ? OperatingSystem.IsWindows() - ? "tar.exe" - : "unzip" - : "ZipArchive.ExtractToDirectoryAsync"; - - [JsonIgnore] - public string? CommandLineExtractionCommandName => - UseCommandLineExtraction - ? OperatingSystem.IsWindows() - ? "tar.exe" - : "unzip" - : null; - - public string GetGitHubAuthenticationMode() => - HasGitHubToken - ? "personal-access-token" - : HasGitHubAppConfiguration - ? "github-app" - : "unconfigured"; - - public string GetExtractionModeDescription() => - UseCommandLineExtraction - ? $"command-line ({ExtractionToolDescription})" - : $"managed ({ExtractionToolDescription})"; -} - -internal sealed class PreviewRegistrationRequest -{ - [Required] - public string RepositoryOwner { get; set; } = string.Empty; - - [Required] - public string RepositoryName { get; set; } = string.Empty; - - [Range(1, int.MaxValue)] - public int PullRequestNumber { get; set; } - - [Required] - public string HeadSha { get; set; } = string.Empty; - - [Range(1, long.MaxValue)] - public long RunId { get; set; } - - [Range(1, int.MaxValue)] - public int RunAttempt { get; set; } - - [Required] - public string ArtifactName { get; set; } = string.Empty; - - public DateTimeOffset CompletedAtUtc { get; set; } -} - -internal enum PreviewLoadState -{ - Registered, - Loading, - Ready, - Failed, - Evicted, - Cancelled -} - -internal sealed class PreviewProgress -{ - public long Version { get; set; } - - public string Stage { get; set; } = "Registered"; - - public string Message { get; set; } = "A preview has been registered and will be prepared on demand."; - - public int Percent { get; set; } - - public int StagePercent { get; set; } - - public long? BytesDownloaded { get; set; } - - public long? BytesTotal { get; set; } - - public int? ItemsCompleted { get; set; } - - public int? ItemsTotal { get; set; } - - public string? ItemsLabel { get; set; } - - public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; -} - -internal sealed class PreviewRecord -{ - public string RepositoryOwner { get; set; } = string.Empty; - - public string RepositoryName { get; set; } = string.Empty; - - public int PullRequestNumber { get; set; } - - public string HeadSha { get; set; } = string.Empty; - - public long RunId { get; set; } - - public int RunAttempt { get; set; } - - public string ArtifactName { get; set; } = string.Empty; - - public DateTimeOffset CompletedAtUtc { get; set; } - - public PreviewLoadState State { get; set; } = PreviewLoadState.Registered; - - public DateTimeOffset RegisteredAtUtc { get; set; } = DateTimeOffset.UtcNow; - - public DateTimeOffset LastUpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; - - public DateTimeOffset? LastAccessedAtUtc { get; set; } - - public DateTimeOffset? ReadyAtUtc { get; set; } - - public string? ActiveDirectoryPath { get; set; } - - public string? LastError { get; set; } - - public PreviewProgress Progress { get; set; } = new(); - - [JsonIgnore] - public string PreviewPath => PreviewRoute.BuildPath(PullRequestNumber); - - public PreviewStatusSnapshot ToSnapshot() => - new() - { - RepositoryOwner = RepositoryOwner, - RepositoryName = RepositoryName, - PullRequestNumber = PullRequestNumber, - HeadSha = HeadSha, - State = State, - Stage = Progress.Stage, - Message = Progress.Message, - Percent = Progress.Percent, - StagePercent = Progress.StagePercent, - Version = Progress.Version, - BytesDownloaded = Progress.BytesDownloaded, - BytesTotal = Progress.BytesTotal, - ItemsCompleted = Progress.ItemsCompleted, - ItemsTotal = Progress.ItemsTotal, - ItemsLabel = Progress.ItemsLabel, - Error = LastError, - UpdatedAtUtc = Progress.UpdatedAtUtc, - PreviewPath = PreviewPath, - ActiveDirectoryPath = ActiveDirectoryPath - }; -} - -internal sealed class PreviewStatusSnapshot -{ - public string RepositoryOwner { get; init; } = string.Empty; - - public string RepositoryName { get; init; } = string.Empty; - - public int PullRequestNumber { get; init; } - - public string HeadSha { get; init; } = string.Empty; - - public PreviewLoadState State { get; init; } - - public string Stage { get; init; } = string.Empty; - - public string Message { get; init; } = string.Empty; - - public int Percent { get; init; } - - public int StagePercent { get; init; } - - public long Version { get; init; } - - public long? BytesDownloaded { get; init; } - - public long? BytesTotal { get; init; } - - public int? ItemsCompleted { get; init; } - - public int? ItemsTotal { get; init; } - - public string? ItemsLabel { get; init; } - - public string? Error { get; init; } - - public DateTimeOffset UpdatedAtUtc { get; init; } - - public string PreviewPath { get; init; } = string.Empty; - - public string? ActiveDirectoryPath { get; init; } - - public bool IsReady => State == PreviewLoadState.Ready; -} - -internal sealed record PreviewRegistrationResult(bool Accepted, PreviewStatusSnapshot Snapshot); - -internal sealed record PreviewWorkItem( - string RepositoryOwner, - string RepositoryName, - int PullRequestNumber, - string HeadSha, - long RunId, - int RunAttempt, - string ArtifactName, - DateTimeOffset CompletedAtUtc); - -internal sealed record ReadyPreviewCandidate( - int PullRequestNumber, - string ActiveDirectoryPath, - DateTimeOffset SortKey); - -internal sealed record PreviewDownloadProgress(long BytesDownloaded, long? BytesTotal); - -internal sealed record GitHubArtifactDescriptor( - string RepositoryOwner, - string RepositoryName, - long ArtifactId, - string ArtifactName, - DateTimeOffset ExpiresAtUtc, - long? SizeInBytes = null); - -internal sealed record PreviewDiscoveryResult(PreviewStatusSnapshot? Snapshot, string? FailureMessage = null); - -internal sealed record GitHubPullRequestSummary( - int PullRequestNumber, - string Title, - string HtmlUrl, - string HeadSha, - string? AuthorLogin, - bool IsDraft, - DateTimeOffset CreatedAtUtc, - DateTimeOffset UpdatedAtUtc, - bool HasSuccessfulPreviewBuild); diff --git a/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs b/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs deleted file mode 100644 index 744b121ea..000000000 --- a/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs +++ /dev/null @@ -1,1244 +0,0 @@ -using System.Net; -using System.Globalization; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; - -namespace PreviewHost.Previews; - -internal sealed class PreviewRequestDispatcher( - PreviewStateStore stateStore, - IWebHostEnvironment environment, - IOptions options) -{ - private static readonly JsonSerializerOptions WebJsonOptions = CreateJsonOptions(); - private static readonly string AspireFaviconDataUri = $"data:image/svg+xml,{Uri.EscapeDataString(AspireLogoSvg)}"; - private readonly PreviewHostOptions _options = options.Value; - private readonly string _previewShellRoot = Path.Combine( - string.IsNullOrWhiteSpace(environment.WebRootPath) - ? Path.Combine(AppContext.BaseDirectory, "wwwroot") - : environment.WebRootPath, - "_preview"); - - public async Task DispatchIndexAsync(HttpContext context, CancellationToken cancellationToken) - { - await WritePreviewShellAsync(context, "index.html", cancellationToken); - } - - public async Task DispatchAsync(HttpContext context, int pullRequestNumber, string relativePath, CancellationToken cancellationToken) - { - var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - - if (snapshot is null || !snapshot.IsReady || string.IsNullOrWhiteSpace(snapshot.ActiveDirectoryPath)) - { - if (!string.IsNullOrEmpty(relativePath)) - { - context.Response.Redirect(PreviewRoute.BuildPath(pullRequestNumber)); - return; - } - - await WritePreviewShellAsync(context, "status.html", cancellationToken); - return; - } - - await stateStore.TouchAsync(pullRequestNumber, cancellationToken); - - var resolvedFile = ResolvePreviewFile(snapshot.ActiveDirectoryPath, relativePath); - if (resolvedFile is null) - { - var fallback404Path = Path.Combine(snapshot.ActiveDirectoryPath, "404.html"); - if (File.Exists(fallback404Path)) - { - context.Response.StatusCode = StatusCodes.Status404NotFound; - await ServeFileAsync(context, fallback404Path, "text/html; charset=utf-8", "no-cache, no-store, must-revalidate", cancellationToken); - return; - } - - context.Response.StatusCode = StatusCodes.Status404NotFound; - await WriteHtmlAsync(context, BuildInfoPage( - title: $"PR #{pullRequestNumber} file not found", - body: "The requested page was not found in the prepared preview artifact."), cancellationToken); - return; - } - - await ServeResolvedFileAsync(context, snapshot, resolvedFile, cancellationToken); - } - - private static async Task ServeFileAsync( - HttpContext context, - string filePath, - string contentType, - string cacheControl, - CancellationToken cancellationToken) - { - context.Response.Headers.CacheControl = cacheControl; - context.Response.ContentType = contentType; - await context.Response.SendFileAsync(filePath, cancellationToken); - } - - private async Task ServeResolvedFileAsync( - HttpContext context, - PreviewStatusSnapshot snapshot, - ResolvedPreviewFile resolvedFile, - CancellationToken cancellationToken) - { - if (!ShouldRewritePreviewContent(resolvedFile.ContentType, snapshot.PreviewPath)) - { - await ServeFileAsync(context, resolvedFile.FilePath, resolvedFile.ContentType, resolvedFile.CacheControl, cancellationToken); - return; - } - - var originalContent = await File.ReadAllTextAsync(resolvedFile.FilePath, cancellationToken); - var rewrittenContent = RewritePreviewContent(originalContent, resolvedFile.ContentType, snapshot.PreviewPath); - - context.Response.Headers.CacheControl = resolvedFile.CacheControl; - context.Response.ContentType = resolvedFile.ContentType; - await context.Response.WriteAsync(rewrittenContent, cancellationToken); - } - - private static ResolvedPreviewFile? ResolvePreviewFile(string previewRoot, string relativePath) - { - var normalizedPath = string.IsNullOrWhiteSpace(relativePath) - ? "index.html" - : relativePath.Replace('\\', '/').TrimStart('/'); - - if (string.IsNullOrWhiteSpace(normalizedPath)) - { - normalizedPath = "index.html"; - } - - if (normalizedPath.EndsWith("/", StringComparison.Ordinal)) - { - normalizedPath = $"{normalizedPath}index.html"; - } - - if (normalizedPath.Contains("..", StringComparison.Ordinal)) - { - return null; - } - - var candidatePath = Path.GetFullPath(Path.Combine(previewRoot, normalizedPath.Replace('/', Path.DirectorySeparatorChar))); - var fullPreviewRoot = Path.GetFullPath(previewRoot + Path.DirectorySeparatorChar); - - if (!candidatePath.StartsWith(fullPreviewRoot, StringComparison.Ordinal)) - { - return null; - } - - if (!File.Exists(candidatePath) && !Path.HasExtension(candidatePath)) - { - var directoryIndexPath = Path.Combine(candidatePath, "index.html"); - if (File.Exists(directoryIndexPath)) - { - candidatePath = directoryIndexPath; - } - } - - if (!File.Exists(candidatePath)) - { - return null; - } - - var contentTypeProvider = new FileExtensionContentTypeProvider(); - if (!contentTypeProvider.TryGetContentType(candidatePath, out var contentType)) - { - contentType = "application/octet-stream"; - } - - var relativeFilePath = Path.GetRelativePath(previewRoot, candidatePath).Replace('\\', '/'); - var cacheControl = candidatePath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) - ? "no-cache, no-store, must-revalidate" - : relativeFilePath.StartsWith("_astro/", StringComparison.Ordinal) - ? "max-age=31536000, public, immutable" - : "public, max-age=300"; - - return new ResolvedPreviewFile(candidatePath, contentType, cacheControl); - } - - private static async Task WriteHtmlAsync(HttpContext context, string html, CancellationToken cancellationToken) - { - context.Response.ContentType = "text/html; charset=utf-8"; - context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; - await context.Response.WriteAsync(html, cancellationToken); - } - - private async Task WritePreviewShellAsync(HttpContext context, string fileName, CancellationToken cancellationToken) - { - var filePath = Path.Combine(_previewShellRoot, fileName); - if (!File.Exists(filePath)) - { - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await WriteHtmlAsync( - context, - BuildInfoPage( - title: "Preview shell is unavailable", - body: $"The preview host could not find the '{fileName}' shell asset under '/_preview/'."), - cancellationToken); - return; - } - - await ServeFileAsync( - context, - filePath, - contentType: "text/html; charset=utf-8", - cacheControl: "no-cache, no-store, must-revalidate", - cancellationToken); - } - - private static string BuildLoaderPage(PreviewStatusSnapshot snapshot) - { - var serializedSnapshot = JsonSerializer.Serialize(snapshot, WebJsonOptions); - var escapedMessage = HtmlEncoder.Default.Encode(snapshot.Message); - var escapedStage = HtmlEncoder.Default.Encode(snapshot.Stage); - var escapedPreviewPath = HtmlEncoder.Default.Encode(snapshot.PreviewPath); - var escapedHint = HtmlEncoder.Default.Encode(BuildHintText(snapshot)); - var escapedPullRequestUrl = HtmlEncoder.Default.Encode(BuildPullRequestUrl(snapshot)); - var cancelButtonHidden = snapshot.State is PreviewLoadState.Ready or PreviewLoadState.Failed or PreviewLoadState.Cancelled ? "hidden" : string.Empty; - - return $$""" - - - - - - - Aspire PR #{{snapshot.PullRequestNumber}} preview - - - -
-
-
- -
-
Aspire PR Preview
-

Preparing PR #{{snapshot.PullRequestNumber}}

-
-
- -
-

{{escapedMessage}}

-
-
-
- Overall progress - {{snapshot.Percent}}% -
- -
-
-
- {{escapedStage}} progress - {{snapshot.StagePercent}}% -
- -
-
-
-
-
Stage
-
{{escapedStage}}
-
-
-
Overall
-
{{snapshot.Percent}}%
-
-
-
Preview path
-
{{escapedPreviewPath}}
-
-
-
Updated
-
{{snapshot.UpdatedAtUtc:O}}
-
-
-

{{escapedHint}}

-
- - - - """; - } - - private static string BuildIndexPage(IReadOnlyList snapshots, int maxActivePreviews) - { - var previewCards = snapshots.Count == 0 - ? """ -
-

No recent PR previews yet

-

Open a route like /prs/{number}/ to resolve a PR, load its latest frontend artifact, and add it to this window.

-
- """ - : string.Join(Environment.NewLine, snapshots.Select(BuildPreviewCard)); - - return $$""" - - - - - - - Aspire PR previews - - - -
-
-
-
- -
-
Aspire PR Preview
-

Recent preview window

-
-
-

These are the most recent preview routes the host is currently tracking. Open any route like /prs/{number}/ to start or resume that PR's preview flow.

-
-

- Up to {{maxActivePreviews.ToString("N0", CultureInfo.InvariantCulture)}} PRs - Current entries: {{snapshots.Count.ToString("N0", CultureInfo.InvariantCulture)}} -

-
-
- {{previewCards}} -
-
- - - - """; - } - - private static string BuildPreviewCard(PreviewStatusSnapshot snapshot) - { - var escapedPreviewPath = HtmlEncoder.Default.Encode(snapshot.PreviewPath); - var escapedState = HtmlEncoder.Default.Encode(snapshot.State.ToString()); - var escapedStage = HtmlEncoder.Default.Encode(snapshot.Stage); - var escapedSummary = HtmlEncoder.Default.Encode(BuildPreviewSummary(snapshot)); - - return $$""" -
-
-
-
PR #{{snapshot.PullRequestNumber}}
- {{escapedPreviewPath}} -
- {{escapedState}} -
-

{{escapedSummary}}

-
-
-
Stage
-
{{escapedStage}}
-
-
-
Overall
-
{{snapshot.Percent}}%
-
-
-
Updated
-
-
-
-
Status
-
{{BuildPreviewStatusText(snapshot)}}
-
-
-
- """; - } - - private static string BuildPreviewSummary(PreviewStatusSnapshot snapshot) => - snapshot.State switch - { - PreviewLoadState.Ready => "The preview is ready to serve.", - PreviewLoadState.Loading => $"{snapshot.Stage} is in progress for this preview.", - PreviewLoadState.Registered => "A fresh preview was registered and will start loading on demand.", - PreviewLoadState.Cancelled => "The last preparation run was cancelled. Opening the route starts it again.", - PreviewLoadState.Failed => snapshot.Error ?? snapshot.Message, - PreviewLoadState.Evicted => "This preview was evicted from the active window and will reload on the next visit.", - _ => snapshot.Message - }; - - private static string BuildPreviewStatusText(PreviewStatusSnapshot snapshot) => - snapshot.State switch - { - PreviewLoadState.Ready => "Ready", - PreviewLoadState.Loading => $"{snapshot.Percent}% overall", - PreviewLoadState.Registered => "Queued", - PreviewLoadState.Cancelled => "Cancelled", - PreviewLoadState.Failed => "Attention needed", - PreviewLoadState.Evicted => "Will reload", - _ => snapshot.State.ToString() - }; - - private static string BuildStateClass(PreviewLoadState state) => - state switch - { - PreviewLoadState.Ready => "state-ready", - PreviewLoadState.Loading => "state-loading", - PreviewLoadState.Registered => "state-registered", - PreviewLoadState.Failed => "state-failed", - PreviewLoadState.Cancelled => "state-cancelled", - PreviewLoadState.Evicted => "state-evicted", - _ => string.Empty - }; - - private static string BuildInfoPage(string title, string body) - { - var safeTitle = WebUtility.HtmlEncode(title); - var safeBody = WebUtility.HtmlEncode(body); - - return $$""" - - - - - - - {{safeTitle}} - - - -
-

{{safeTitle}}

-

{{safeBody}}

-
- - - """; - } - - private static JsonSerializerOptions CreateJsonOptions() - { - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); - options.Converters.Add(new JsonStringEnumConverter()); - return options; - } - - private static string BuildPullRequestUrl(PreviewStatusSnapshot snapshot) - { - if (string.IsNullOrWhiteSpace(snapshot.RepositoryOwner) || string.IsNullOrWhiteSpace(snapshot.RepositoryName)) - { - return "#"; - } - - return $"https://github.com/{snapshot.RepositoryOwner}/{snapshot.RepositoryName}/pull/{snapshot.PullRequestNumber}"; - } - - private static bool ShouldRewritePreviewContent(string contentType, string previewPath) - { - if (string.IsNullOrWhiteSpace(previewPath) || string.Equals(previewPath, "/", StringComparison.Ordinal)) - { - return false; - } - - return contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) - || contentType.StartsWith("text/css", StringComparison.OrdinalIgnoreCase); - } - - private static string RewritePreviewContent(string content, string contentType, string previewPath) - { - var previewPrefix = NormalizePreviewPrefix(previewPath); - if (string.IsNullOrEmpty(previewPrefix)) - { - return content; - } - - var previewPrefixPattern = Regex.Escape(previewPrefix.TrimStart('/')); - var cssRootPathPattern = $@"(?\burl\(([""']?))/(?!(?:/|{previewPrefixPattern})(?:/|$))"; - content = Regex.Replace( - content, - cssRootPathPattern, - "${prefix}" + previewPrefix + "/", - RegexOptions.IgnoreCase); - - var cssImportPattern = $@"(?@import\s+([""']))/(?!(?:/|{previewPrefixPattern})(?:/|$))"; - content = Regex.Replace( - content, - cssImportPattern, - "${prefix}" + previewPrefix + "/", - RegexOptions.IgnoreCase); - - if (!contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) - { - return content; - } - - var htmlRootPathPattern = $@"(?\b(?:href|src|action|poster|content|data|imagesrcset)=[""'])/(?!(?:/|{previewPrefixPattern})(?:/|$))"; - content = Regex.Replace( - content, - htmlRootPathPattern, - "${attr}" + previewPrefix + "/", - RegexOptions.IgnoreCase); - - return InjectPreviewCompatibilityScript(content, previewPrefix); - } - - private static string InjectPreviewCompatibilityScript(string html, string previewPrefix) - { - const string Marker = "data-aspire-preview-bootstrap"; - if (html.Contains(Marker, StringComparison.Ordinal)) - { - return html; - } - - var bootstrapScript = $$""" - - """; - - return html.Replace("", $"{bootstrapScript}", StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizePreviewPrefix(string previewPath) - { - if (string.IsNullOrWhiteSpace(previewPath) || string.Equals(previewPath, "/", StringComparison.Ordinal)) - { - return string.Empty; - } - - return previewPath.EndsWith("/", StringComparison.Ordinal) - ? previewPath[..^1] - : previewPath; - } - - private static string BuildHintText(PreviewStatusSnapshot snapshot) => - snapshot.State == PreviewLoadState.Failed - ? "The preview host could not finish preparing this build. Fix the backing configuration or register a newer successful build, then refresh." - : snapshot.State == PreviewLoadState.Cancelled - ? "Preview preparation was cancelled. Refresh this page to start again." - : "The preview will automatically open as soon as the artifact download and extraction finish."; - - private const string AspireLogoSvg = """ - - - - - - - - - """; -} - -internal static class PreviewRoute -{ - public const string CollectionPath = "/prs/"; - private const string Prefix = "/prs/"; - private const string LegacyPrefix = "/pr/"; - - public static string BuildPath(int pullRequestNumber, string? relativePath = null) - { - var path = $"{Prefix}{pullRequestNumber}/"; - if (string.IsNullOrWhiteSpace(relativePath)) - { - return path; - } - - return $"{path}{relativePath.TrimStart('/')}"; - } - - public static bool IsCollectionPath(PathString path) => - string.Equals(path.Value, CollectionPath, StringComparison.OrdinalIgnoreCase) - || string.Equals(path.Value, CollectionPath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); - - public static bool TryParse(PathString path, out int pullRequestNumber, out string relativePath) - => TryParse(path.Value, Prefix, out pullRequestNumber, out relativePath); - - public static bool TryParse(string? path, out int pullRequestNumber, out string relativePath) - => TryParse(path, Prefix, out pullRequestNumber, out relativePath); - - public static bool TryParseLegacy(PathString path, out int pullRequestNumber, out string relativePath) - => TryParse(path.Value, LegacyPrefix, out pullRequestNumber, out relativePath); - - public static bool TryParseLegacy(string? path, out int pullRequestNumber, out string relativePath) - => TryParse(path, LegacyPrefix, out pullRequestNumber, out relativePath); - - private static bool TryParse(string? pathValue, string prefix, out int pullRequestNumber, out string relativePath) - { - pullRequestNumber = default; - relativePath = string.Empty; - - if (string.IsNullOrWhiteSpace(pathValue) || !pathValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var remaining = pathValue[prefix.Length..]; - var nextSlashIndex = remaining.IndexOf('/'); - var pullRequestSegment = nextSlashIndex >= 0 - ? remaining[..nextSlashIndex] - : remaining; - - if (!int.TryParse(pullRequestSegment, out pullRequestNumber)) - { - return false; - } - - if (nextSlashIndex < 0) - { - return true; - } - - relativePath = remaining[(nextSlashIndex + 1)..]; - if (pathValue.EndsWith('/') && !string.IsNullOrEmpty(relativePath) && !relativePath.EndsWith('/')) - { - relativePath = $"{relativePath}/"; - } - - return true; - } -} - -internal sealed record ResolvedPreviewFile(string FilePath, string ContentType, string CacheControl); diff --git a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs deleted file mode 100644 index beb7e3960..000000000 --- a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs +++ /dev/null @@ -1,653 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Extensions.Options; - -namespace PreviewHost.Previews; - -internal sealed class PreviewStateStore -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - Converters = { new JsonStringEnumConverter() } - }; - - private readonly SemaphoreSlim _gate = new(1, 1); - private readonly Dictionary _records = []; - private readonly ILogger _logger; - private readonly string _registryPath; - - public PreviewStateStore(IOptions options, ILogger logger) - { - _logger = logger; - - var configuredRoot = options.Value.DataRoot; - StateRoot = string.IsNullOrWhiteSpace(configuredRoot) - ? ResolveDefaultStateRoot() - : configuredRoot; - var configuredContentRoot = options.Value.ContentRoot; - ContentRoot = string.IsNullOrWhiteSpace(configuredContentRoot) - ? ResolveDefaultContentRoot(StateRoot) - : configuredContentRoot; - - _registryPath = Path.Combine(StateRoot, "registry.json"); - } - - public string StateRoot { get; } - - public string ContentRoot { get; } - - public async Task InitializeAsync(CancellationToken cancellationToken) - { - Directory.CreateDirectory(StateRoot); - Directory.CreateDirectory(ContentRoot); - - if (!File.Exists(_registryPath)) - { - return; - } - - await _gate.WaitAsync(cancellationToken); - try - { - await using var stream = File.OpenRead(_registryPath); - var records = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken); - _records.Clear(); - - if (records is null) - { - return; - } - - var requiresSave = false; - foreach (var pair in records) - { - requiresSave |= NormalizeLoadedRecord(pair.Value); - _records[pair.Key] = pair.Value; - } - - if (requiresSave) - { - await SaveLockedAsync(cancellationToken); - } - } - finally - { - _gate.Release(); - } - } - - public async Task RegisterAsync(PreviewRegistrationRequest request, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(request); - - var completedAtUtc = request.CompletedAtUtc == default - ? DateTimeOffset.UtcNow - : request.CompletedAtUtc; - - string? directoryToDelete = null; - PreviewStatusSnapshot snapshot; - var accepted = false; - - await _gate.WaitAsync(cancellationToken); - try - { - if (_records.TryGetValue(request.PullRequestNumber, out var existing) && !IsNewer(existing, request, completedAtUtc)) - { - return new PreviewRegistrationResult(false, existing.ToSnapshot()); - } - - if (existing?.ActiveDirectoryPath is { Length: > 0 } activeDirectoryPath) - { - directoryToDelete = activeDirectoryPath; - } - - var record = existing ?? new PreviewRecord - { - PullRequestNumber = request.PullRequestNumber, - RegisteredAtUtc = DateTimeOffset.UtcNow - }; - - record.RepositoryOwner = request.RepositoryOwner; - record.RepositoryName = request.RepositoryName; - record.HeadSha = request.HeadSha; - record.RunId = request.RunId; - record.RunAttempt = request.RunAttempt; - record.ArtifactName = request.ArtifactName; - record.CompletedAtUtc = completedAtUtc; - record.State = PreviewLoadState.Registered; - record.ActiveDirectoryPath = null; - record.LastError = null; - record.ReadyAtUtc = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress( - record, - stage: "Registered", - message: "A fresh preview build has been registered. Loading will start on the next request.", - percent: 0, - stagePercent: 0, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: null, - itemsTotal: null, - itemsLabel: null); - - _records[request.PullRequestNumber] = record; - await SaveLockedAsync(cancellationToken); - snapshot = record.ToSnapshot(); - accepted = true; - } - finally - { - _gate.Release(); - } - - if (!string.IsNullOrWhiteSpace(directoryToDelete)) - { - DeleteDirectoryIfPresent(directoryToDelete); - } - - return new PreviewRegistrationResult(accepted, snapshot); - } - - public async Task GetSnapshotAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken); - try - { - return _records.TryGetValue(pullRequestNumber, out var record) - ? record.ToSnapshot() - : null; - } - finally - { - _gate.Release(); - } - } - - public async Task> ListRecentSnapshotsAsync(int limit, CancellationToken cancellationToken) - { - if (limit <= 0) - { - return []; - } - - await _gate.WaitAsync(cancellationToken); - try - { - return [.. _records.Values - .OrderByDescending(static record => record.LastAccessedAtUtc ?? record.ReadyAtUtc ?? record.LastUpdatedAtUtc) - .ThenByDescending(static record => record.PullRequestNumber) - .Take(limit) - .Select(static record => record.ToSnapshot())]; - } - finally - { - _gate.Release(); - } - } - - public async Task> ListSnapshotsAsync(CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken); - try - { - return _records.Values - .Select(static record => record.ToSnapshot()) - .ToDictionary(static snapshot => snapshot.PullRequestNumber); - } - finally - { - _gate.Release(); - } - } - - public async Task GetWorkItemAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken); - try - { - return _records.TryGetValue(pullRequestNumber, out var record) - ? new PreviewWorkItem( - record.RepositoryOwner, - record.RepositoryName, - record.PullRequestNumber, - record.HeadSha, - record.RunId, - record.RunAttempt, - record.ArtifactName, - record.CompletedAtUtc) - : null; - } - finally - { - _gate.Release(); - } - } - - public async Task MatchesBuildAsync(int pullRequestNumber, PreviewWorkItem workItem, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken); - try - { - if (!_records.TryGetValue(pullRequestNumber, out var record)) - { - return false; - } - - return record.RunId == workItem.RunId - && record.RunAttempt == workItem.RunAttempt - && string.Equals(record.HeadSha, workItem.HeadSha, StringComparison.Ordinal); - } - finally - { - _gate.Release(); - } - } - - public async Task MarkLoadingAsync(int pullRequestNumber, string stage, string message, int percent, int stagePercent, CancellationToken cancellationToken) - { - await UpdateRecordAsync( - pullRequestNumber, - record => - { - record.State = PreviewLoadState.Loading; - record.LastError = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress(record, stage, message, percent, stagePercent, null, null, null, null, null); - }, - cancellationToken, - persist: false); - } - - public async Task UpdateProgressAsync( - int pullRequestNumber, - string stage, - string message, - int percent, - int stagePercent, - long? bytesDownloaded, - long? bytesTotal, - int? itemsCompleted, - int? itemsTotal, - string? itemsLabel, - CancellationToken cancellationToken) - { - await UpdateRecordAsync( - pullRequestNumber, - record => - { - record.State = PreviewLoadState.Loading; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress(record, stage, message, percent, stagePercent, bytesDownloaded, bytesTotal, itemsCompleted, itemsTotal, itemsLabel); - }, - cancellationToken, - persist: false); - } - - public async Task MarkReadyAsync(int pullRequestNumber, string activeDirectoryPath, CancellationToken cancellationToken) - { - await UpdateRecordAsync( - pullRequestNumber, - record => - { - record.State = PreviewLoadState.Ready; - record.ActiveDirectoryPath = activeDirectoryPath; - record.LastError = null; - record.ReadyAtUtc = DateTimeOffset.UtcNow; - record.LastAccessedAtUtc = record.ReadyAtUtc; - record.LastUpdatedAtUtc = record.ReadyAtUtc.Value; - record.Progress = NextProgress( - record, - stage: "Ready", - message: "The preview is ready to serve.", - percent: 100, - stagePercent: 100, - bytesDownloaded: record.Progress.BytesDownloaded, - bytesTotal: record.Progress.BytesTotal, - itemsCompleted: record.Progress.ItemsCompleted, - itemsTotal: record.Progress.ItemsTotal, - itemsLabel: record.Progress.ItemsLabel); - }, - cancellationToken); - } - - public async Task MarkFailedAsync(int pullRequestNumber, string error, CancellationToken cancellationToken) - { - await UpdateRecordAsync( - pullRequestNumber, - record => - { - record.State = PreviewLoadState.Failed; - record.LastError = error; - record.ActiveDirectoryPath = null; - record.ReadyAtUtc = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress(record, "Failed", error, record.Progress.Percent, record.Progress.StagePercent, record.Progress.BytesDownloaded, record.Progress.BytesTotal, record.Progress.ItemsCompleted, record.Progress.ItemsTotal, record.Progress.ItemsLabel); - }, - cancellationToken); - } - - public async Task MarkCancelledAsync(int pullRequestNumber, string message, CancellationToken cancellationToken) - { - await UpdateRecordAsync( - pullRequestNumber, - record => - { - record.State = PreviewLoadState.Cancelled; - record.LastError = null; - record.ActiveDirectoryPath = null; - record.ReadyAtUtc = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress( - record, - stage: "Cancelled", - message: message, - percent: record.Progress.Percent, - stagePercent: record.Progress.StagePercent, - bytesDownloaded: record.Progress.BytesDownloaded, - bytesTotal: record.Progress.BytesTotal, - itemsCompleted: record.Progress.ItemsCompleted, - itemsTotal: record.Progress.ItemsTotal, - itemsLabel: record.Progress.ItemsLabel); - }, - cancellationToken); - } - - public async Task RequeueAsync(int pullRequestNumber, string message, CancellationToken cancellationToken) - { - PreviewStatusSnapshot? snapshot = null; - - await UpdateRecordAsync( - pullRequestNumber, - record => - { - record.State = PreviewLoadState.Registered; - record.LastError = null; - record.ActiveDirectoryPath = null; - record.ReadyAtUtc = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress( - record, - stage: "Registered", - message: message, - percent: 0, - stagePercent: 0, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: null, - itemsTotal: null, - itemsLabel: null); - snapshot = record.ToSnapshot(); - }, - cancellationToken); - - return snapshot; - } - - public async Task TouchAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - await UpdateRecordAsync( - pullRequestNumber, - record => - { - record.LastAccessedAtUtc = DateTimeOffset.UtcNow; - record.LastUpdatedAtUtc = record.LastAccessedAtUtc.Value; - }, - cancellationToken, - persist: false); - } - - public async Task> ListReadyCandidatesAsync(int excludedPullRequestNumber, CancellationToken cancellationToken) - { - await _gate.WaitAsync(cancellationToken); - try - { - return [.. _records.Values - .Where(static record => record.State == PreviewLoadState.Ready && !string.IsNullOrWhiteSpace(record.ActiveDirectoryPath)) - .Where(record => record.PullRequestNumber != excludedPullRequestNumber) - .Select(record => new ReadyPreviewCandidate( - record.PullRequestNumber, - record.ActiveDirectoryPath!, - record.LastAccessedAtUtc ?? record.ReadyAtUtc ?? record.RegisteredAtUtc)) - .OrderBy(static candidate => candidate.SortKey)]; - } - finally - { - _gate.Release(); - } - } - - public async Task EvictAsync(int pullRequestNumber, string reason, CancellationToken cancellationToken) - { - string? directoryToDelete = null; - - await _gate.WaitAsync(cancellationToken); - try - { - if (!_records.TryGetValue(pullRequestNumber, out var record)) - { - return; - } - - directoryToDelete = record.ActiveDirectoryPath; - record.State = PreviewLoadState.Evicted; - record.ActiveDirectoryPath = null; - record.ReadyAtUtc = null; - record.LastError = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress(record, "Evicted", reason, 0, 0, null, null, null, null, null); - await SaveLockedAsync(cancellationToken); - } - finally - { - _gate.Release(); - } - - if (!string.IsNullOrWhiteSpace(directoryToDelete)) - { - DeleteDirectoryIfPresent(directoryToDelete); - } - } - - public async Task RemoveAsync(int pullRequestNumber, CancellationToken cancellationToken) - { - string? directoryToDelete = null; - - await _gate.WaitAsync(cancellationToken); - try - { - if (_records.Remove(pullRequestNumber, out var record)) - { - directoryToDelete = record.ActiveDirectoryPath; - await SaveLockedAsync(cancellationToken); - } - } - finally - { - _gate.Release(); - } - - if (!string.IsNullOrWhiteSpace(directoryToDelete)) - { - DeleteDirectoryIfPresent(directoryToDelete); - } - } - - public string GetActiveDirectoryPath(int pullRequestNumber) => Path.Combine(ContentRoot, "active", $"pr-{pullRequestNumber}"); - - public string GetTemporaryZipPath(PreviewWorkItem workItem) => - Path.Combine(ContentRoot, "downloads", $"pr-{workItem.PullRequestNumber}-{workItem.RunId}-{workItem.RunAttempt}.zip"); - - public string GetStagingDirectoryPath(PreviewWorkItem workItem) => - Path.Combine(ContentRoot, "staging", $"pr-{workItem.PullRequestNumber}-{workItem.RunId}-{workItem.RunAttempt}"); - - private async Task UpdateRecordAsync(int pullRequestNumber, Action update, CancellationToken cancellationToken, bool persist = true) - { - await _gate.WaitAsync(cancellationToken); - try - { - if (!_records.TryGetValue(pullRequestNumber, out var record)) - { - return; - } - - update(record); - if (persist) - { - await SaveLockedAsync(cancellationToken); - } - } - finally - { - _gate.Release(); - } - } - - private async Task SaveLockedAsync(CancellationToken cancellationToken) - { - Directory.CreateDirectory(StateRoot); - var json = JsonSerializer.Serialize(_records, JsonOptions); - await File.WriteAllTextAsync(_registryPath, json, cancellationToken); - } - - private bool NormalizeLoadedRecord(PreviewRecord record) - { - if (record.State == PreviewLoadState.Ready - && !string.IsNullOrWhiteSpace(record.ActiveDirectoryPath) - && !Directory.Exists(record.ActiveDirectoryPath)) - { - record.State = PreviewLoadState.Registered; - record.ActiveDirectoryPath = null; - record.ReadyAtUtc = null; - record.LastError = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress( - record, - stage: "Registered", - message: "The local preview cache was cleared and will reload on the next request.", - percent: 0, - stagePercent: 0, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: null, - itemsTotal: null, - itemsLabel: null); - return true; - } - - if (record.State == PreviewLoadState.Loading) - { - record.State = PreviewLoadState.Registered; - record.ActiveDirectoryPath = null; - record.ReadyAtUtc = null; - record.LastError = null; - record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; - record.Progress = NextProgress( - record, - stage: "Registered", - message: "Preview preparation was interrupted and will restart on the next request.", - percent: 0, - stagePercent: 0, - bytesDownloaded: null, - bytesTotal: null, - itemsCompleted: null, - itemsTotal: null, - itemsLabel: null); - return true; - } - - return false; - } - - private static PreviewProgress NextProgress( - PreviewRecord record, - string stage, - string message, - int percent, - int stagePercent, - long? bytesDownloaded, - long? bytesTotal, - int? itemsCompleted, - int? itemsTotal, - string? itemsLabel) => - new() - { - Version = record.Progress.Version + 1, - Stage = stage, - Message = message, - Percent = Math.Clamp(percent, 0, 100), - StagePercent = Math.Clamp(stagePercent, 0, 100), - BytesDownloaded = bytesDownloaded, - BytesTotal = bytesTotal, - ItemsCompleted = itemsCompleted, - ItemsTotal = itemsTotal, - ItemsLabel = itemsLabel, - UpdatedAtUtc = DateTimeOffset.UtcNow - }; - - private static bool IsNewer(PreviewRecord existing, PreviewRegistrationRequest request, DateTimeOffset completedAtUtc) - { - if (completedAtUtc > existing.CompletedAtUtc) - { - return true; - } - - if (completedAtUtc < existing.CompletedAtUtc) - { - return false; - } - - if (request.RunId > existing.RunId) - { - return true; - } - - if (request.RunId < existing.RunId) - { - return false; - } - - return request.RunAttempt > existing.RunAttempt; - } - - private static string ResolveDefaultStateRoot() - { - var home = Environment.GetEnvironmentVariable("HOME"); - if (!string.IsNullOrWhiteSpace(home)) - { - return Path.Combine(home, "pr-preview-data"); - } - - return Path.Combine(AppContext.BaseDirectory, "pr-preview-data"); - } - - private static string ResolveDefaultContentRoot(string stateRoot) - { - if (string.Equals(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true", StringComparison.OrdinalIgnoreCase)) - { - return Path.Combine(Path.GetTempPath(), "pr-preview-data"); - } - - return stateRoot; - } - - private void DeleteDirectoryIfPresent(string path) - { - try - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - catch (IOException exception) - { - _logger.LogWarning(exception, "Failed to delete preview directory {Directory}", path); - } - catch (UnauthorizedAccessException exception) - { - _logger.LogWarning(exception, "Failed to delete preview directory {Directory}", path); - } - } -} diff --git a/src/statichost/PreviewHost/Program.cs b/src/statichost/PreviewHost/Program.cs deleted file mode 100644 index f1817b10e..000000000 --- a/src/statichost/PreviewHost/Program.cs +++ /dev/null @@ -1,441 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.Options; -using PreviewHost.Previews; - -var webJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); -webJsonOptions.Converters.Add(new JsonStringEnumConverter()); -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddProblemDetails(); -builder.Services.AddHealthChecks(); -builder.Services.ConfigureHttpJsonOptions(static options => -{ - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); -}); -builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(PreviewHostOptions.SectionName)) - .Validate( - static options => options.HasGitHubToken || options.HasGitHubAppConfiguration, - $"Either '{PreviewHostOptions.SectionName}:GitHubToken' or both '{PreviewHostOptions.SectionName}:GitHubAppId' and '{PreviewHostOptions.SectionName}:GitHubAppPrivateKey' must be configured.") - .Validate( - static options => options.HasValidExtractionMode, - $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting must be either 'managed' or 'command-line'.") - .Validate( - static options => CommandLineExtractionSupport.IsConfigurationSupported(options), - CommandLineExtractionSupport.GetConfigurationValidationMessage()) - .ValidateOnStart(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -var app = builder.Build(); -var previewHostOptions = app.Services.GetRequiredService>().Value; - -var previewStateStore = app.Services.GetRequiredService(); -await previewStateStore.InitializeAsync(CancellationToken.None); -app.Logger.LogInformation( - "PreviewHost GitHub authentication mode: {GitHubAuthenticationMode}", - previewHostOptions.GetGitHubAuthenticationMode()); -app.Logger.LogInformation( - "PreviewHost artifact extraction mode: {ExtractionMode}", - previewHostOptions.GetExtractionModeDescription()); -app.Logger.LogInformation( - "PreviewHost storage roots: state {StateRoot}, content {ContentRoot}", - previewStateStore.StateRoot, - previewStateStore.ContentRoot); -if (previewHostOptions.UseCommandLineExtraction - && CommandLineExtractionSupport.TryResolveConfiguredTool(previewHostOptions, out var extractionToolPath)) -{ - app.Logger.LogInformation( - "PreviewHost command-line extractor resolved to: {ExtractionToolPath}", - extractionToolPath); -} - -if (!app.Environment.IsDevelopment()) -{ - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseExceptionHandler(); -app.UseStaticFiles(new StaticFileOptions -{ - OnPrepareResponse = static context => - { - if (context.Context.Request.Path.StartsWithSegments("/_preview", StringComparison.OrdinalIgnoreCase)) - { - context.Context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; - } - } -}); - -app.Use(async (context, next) => -{ - if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) - && PreviewRoute.TryParseLegacy(context.Request.Path, out var legacyPullRequestNumber, out var legacyRelativePath)) - { - context.Response.Redirect($"{PreviewRoute.BuildPath(legacyPullRequestNumber, legacyRelativePath)}{context.Request.QueryString}", permanent: false); - return; - } - - if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) - && PreviewRoute.IsCollectionPath(context.Request.Path)) - { - var indexDispatcher = context.RequestServices.GetRequiredService(); - await indexDispatcher.DispatchIndexAsync(context, context.RequestAborted); - return; - } - - if (!TryResolvePreviewRequest(context, out var pullRequestNumber, out var relativePath)) - { - await next(); - return; - } - - var dispatcher = context.RequestServices.GetRequiredService(); - await dispatcher.DispatchAsync(context, pullRequestNumber, relativePath, context.RequestAborted); -}); - -app.MapGet("/", () => Results.Redirect(PreviewRoute.CollectionPath)); - -app.MapHealthChecks("/healthz", new HealthCheckOptions -{ - AllowCachingResponses = false -}); - -app.MapGet( - "/api/previews/recent", - async (PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - var snapshots = await stateStore.ListRecentSnapshotsAsync(options.Value.MaxActivePreviews, cancellationToken); - return Results.Json(new - { - updatedAtUtc = DateTimeOffset.UtcNow, - maxActivePreviews = options.Value.MaxActivePreviews, - snapshots - }); - }); - -app.MapGet( - "/api/previews/catalog", - async (GitHubArtifactClient gitHubArtifactClient, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - var openPullRequests = await gitHubArtifactClient.ListOpenPullRequestsAsync(cancellationToken); - var trackedSnapshots = await stateStore.ListSnapshotsAsync(cancellationToken); - var activePreviewCount = trackedSnapshots.Values.Count(static snapshot => snapshot.State is PreviewLoadState.Loading or PreviewLoadState.Ready); - - var entries = openPullRequests - .Select(pullRequest => new - { - pullRequestNumber = pullRequest.PullRequestNumber, - title = pullRequest.Title, - pullRequestUrl = pullRequest.HtmlUrl, - previewPath = PreviewRoute.BuildPath(pullRequest.PullRequestNumber), - authorLogin = pullRequest.AuthorLogin, - isDraft = pullRequest.IsDraft, - headSha = pullRequest.HeadSha, - hasSuccessfulPreviewBuild = pullRequest.HasSuccessfulPreviewBuild, - createdAtUtc = pullRequest.CreatedAtUtc, - updatedAtUtc = pullRequest.UpdatedAtUtc, - preview = trackedSnapshots.TryGetValue(pullRequest.PullRequestNumber, out var snapshot) ? snapshot : null - }) - .ToArray(); - - return Results.Json(new - { - updatedAtUtc = DateTimeOffset.UtcNow, - openPullRequestCount = entries.Length, - previewablePullRequestCount = entries.Count(static entry => entry.hasSuccessfulPreviewBuild), - maxActivePreviews = options.Value.MaxActivePreviews, - activePreviewCount, - entries - }); - }); - -app.MapGet( - "/api/previews/{pullRequestNumber:int}", - async (int pullRequestNumber, PreviewStateStore stateStore, CancellationToken cancellationToken) => - { - var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - return snapshot is null - ? Results.NotFound() - : Results.Json(snapshot); - }); - -app.MapGet( - "/api/previews/{pullRequestNumber:int}/bootstrap", - async (int pullRequestNumber, PreviewCoordinator coordinator, IOptions options, CancellationToken cancellationToken) => - { - var result = await coordinator.BootstrapAsync(pullRequestNumber, cancellationToken); - return result.Snapshot is null - ? Results.Json(CreateUnavailablePreviewPayload( - pullRequestNumber, - options.Value, - result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) - : Results.Json(result.Snapshot); - }); - -app.MapGet( - "/api/previews/{pullRequestNumber:int}/events", - async (HttpContext context, int pullRequestNumber, PreviewStateStore stateStore, PreviewCoordinator coordinator, CancellationToken cancellationToken) => - { - context.Response.ContentType = "text/event-stream"; - context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; - coordinator.EnsureLoading(pullRequestNumber); - - long lastVersion = -1; - - while (!cancellationToken.IsCancellationRequested) - { - var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); - if (snapshot is null) - { - var missingSnapshot = JsonSerializer.Serialize( - new PreviewStatusSnapshot - { - RepositoryOwner = string.Empty, - RepositoryName = string.Empty, - PullRequestNumber = pullRequestNumber, - State = PreviewLoadState.Failed, - Stage = "Missing", - Message = "No preview registration is available for this pull request.", - Percent = 0, - StagePercent = 0, - Version = lastVersion + 1, - Error = "No preview registration is available for this pull request.", - UpdatedAtUtc = DateTimeOffset.UtcNow, - PreviewPath = PreviewRoute.BuildPath(pullRequestNumber) - }, - webJsonOptions); - - await context.Response.WriteAsync($"data: {missingSnapshot}\n\n", cancellationToken); - break; - } - - if (snapshot.Version != lastVersion) - { - var payload = JsonSerializer.Serialize(snapshot, webJsonOptions); - await context.Response.WriteAsync($"data: {payload}\n\n", cancellationToken); - await context.Response.Body.FlushAsync(cancellationToken); - lastVersion = snapshot.Version; - - if (snapshot.IsReady || snapshot.State is PreviewLoadState.Failed or PreviewLoadState.Cancelled) - { - break; - } - } - - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - }); - -app.MapPost( - "/api/previews/{pullRequestNumber:int}/cancel", - async (int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => - { - var snapshot = await coordinator.CancelAsync(pullRequestNumber, cancellationToken); - return snapshot is null - ? Results.NotFound() - : Results.Json(snapshot); - }); - -app.MapPost( - "/api/previews/{pullRequestNumber:int}/retry", - async (int pullRequestNumber, PreviewCoordinator coordinator, IOptions options, CancellationToken cancellationToken) => - { - var result = await coordinator.RetryAsync(pullRequestNumber, cancellationToken); - return result.Snapshot is null - ? Results.Json(CreateUnavailablePreviewPayload( - pullRequestNumber, - options.Value, - result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) - : Results.Json(result.Snapshot); - }); - -app.MapPost( - "/api/previews/{pullRequestNumber:int}/reset", - async (int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => - { - var removed = await coordinator.ResetAsync(pullRequestNumber, cancellationToken); - return removed - ? Results.NoContent() - : Results.NotFound(); - }); - -app.MapPost( - "/api/previews/registrations", - async (HttpContext context, PreviewRegistrationRequest request, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - if (!HasValidBearerToken(context.Request, options.Value.RegistrationToken)) - { - return Results.Unauthorized(); - } - - if (!TryValidate(request, out var validationErrors)) - { - return Results.ValidationProblem(validationErrors); - } - - var result = await stateStore.RegisterAsync(request, cancellationToken); - return Results.Json(new - { - accepted = result.Accepted, - previewPath = PreviewRoute.BuildPath(request.PullRequestNumber), - snapshot = result.Snapshot - }); - }); - -app.MapDelete( - "/api/previews/{pullRequestNumber:int}", - async (HttpContext context, int pullRequestNumber, PreviewStateStore stateStore, IOptions options, CancellationToken cancellationToken) => - { - if (!HasValidBearerToken(context.Request, options.Value.RegistrationToken)) - { - return Results.Unauthorized(); - } - - await stateStore.RemoveAsync(pullRequestNumber, cancellationToken); - return Results.NoContent(); - }); - -await app.RunAsync(); - -static bool TryValidate(PreviewRegistrationRequest request, out Dictionary validationErrors) -{ - var validationResults = new List(); - var validationContext = new ValidationContext(request); - - if (Validator.TryValidateObject(request, validationContext, validationResults, validateAllProperties: true)) - { - validationErrors = []; - return true; - } - - validationErrors = validationResults - .SelectMany( - result => result.MemberNames.DefaultIfEmpty(string.Empty), - static (result, memberName) => new { MemberName = memberName, result.ErrorMessage }) - .GroupBy(static item => item.MemberName, StringComparer.Ordinal) - .ToDictionary( - static group => group.Key, - static group => group.Select(static item => item.ErrorMessage ?? "Validation failed.").ToArray(), - StringComparer.Ordinal); - - return false; -} - -static bool HasValidBearerToken(HttpRequest request, string expectedToken) -{ - if (string.IsNullOrWhiteSpace(expectedToken)) - { - return false; - } - - var authorizationHeader = request.Headers.Authorization.ToString(); - if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var providedToken = authorizationHeader["Bearer ".Length..].Trim(); - var expectedBytes = Encoding.UTF8.GetBytes(expectedToken); - var providedBytes = Encoding.UTF8.GetBytes(providedToken); - - return expectedBytes.Length == providedBytes.Length - && CryptographicOperations.FixedTimeEquals(expectedBytes, providedBytes); -} - -static object CreateUnavailablePreviewPayload(int pullRequestNumber, PreviewHostOptions options, string failureMessage) => - new - { - pullRequestNumber, - state = "Missing", - stage = "Missing", - failureMessage, - previewPath = PreviewRoute.BuildPath(pullRequestNumber), - updatedAtUtc = DateTimeOffset.UtcNow, - repositoryOwner = options.RepositoryOwner, - repositoryName = options.RepositoryName - }; - -static bool TryResolvePreviewRequest(HttpContext context, out int pullRequestNumber, out string relativePath) -{ - if (PreviewRoute.TryParse(context.Request.Path, out pullRequestNumber, out relativePath)) - { - return true; - } - - if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) - { - pullRequestNumber = default; - relativePath = string.Empty; - return false; - } - - if (context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) - || context.Request.Path.StartsWithSegments("/healthz", StringComparison.OrdinalIgnoreCase)) - { - pullRequestNumber = default; - relativePath = string.Empty; - return false; - } - - if (!TryGetPullRequestNumberFromReferer(context.Request, out pullRequestNumber)) - { - relativePath = string.Empty; - return false; - } - - relativePath = GetRelativePreviewPath(context.Request.Path); - return true; -} - -static bool TryGetPullRequestNumberFromReferer(HttpRequest request, out int pullRequestNumber) -{ - pullRequestNumber = default; - - var refererHeader = request.Headers.Referer.ToString(); - if (string.IsNullOrWhiteSpace(refererHeader) - || !Uri.TryCreate(refererHeader, UriKind.Absolute, out var refererUri)) - { - return false; - } - - var requestHost = request.Host.Host; - if (!string.Equals(refererUri.Host, requestHost, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (request.Host.Port is { } requestPort && !refererUri.IsDefaultPort && refererUri.Port != requestPort) - { - return false; - } - - return PreviewRoute.TryParse(refererUri.AbsolutePath, out pullRequestNumber, out _) - || PreviewRoute.TryParseLegacy(refererUri.AbsolutePath, out pullRequestNumber, out _); -} - -static string GetRelativePreviewPath(PathString requestPath) -{ - var pathValue = requestPath.Value; - if (string.IsNullOrEmpty(pathValue) || pathValue == "/") - { - return string.Empty; - } - - var relativePath = pathValue.TrimStart('/'); - if (pathValue.EndsWith('/') && !relativePath.EndsWith('/')) - { - relativePath = $"{relativePath}/"; - } - - return relativePath; -} diff --git a/src/statichost/PreviewHost/Properties/launchSettings.json b/src/statichost/PreviewHost/Properties/launchSettings.json deleted file mode 100644 index 18eff7506..000000000 --- a/src/statichost/PreviewHost/Properties/launchSettings.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:17155;http://localhost:15081", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:15081", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/statichost/PreviewHost/appsettings.Development.json b/src/statichost/PreviewHost/appsettings.Development.json deleted file mode 100644 index 0c208ae91..000000000 --- a/src/statichost/PreviewHost/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/statichost/PreviewHost/appsettings.json b/src/statichost/PreviewHost/appsettings.json deleted file mode 100644 index 9558123fd..000000000 --- a/src/statichost/PreviewHost/appsettings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "PreviewHost": { - "MaxActivePreviews": 10 - } -} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg b/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg deleted file mode 100644 index 89fe2a3fc..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg b/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg deleted file mode 100644 index 2c00a294f..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.html b/src/statichost/PreviewHost/wwwroot/_preview/index.html deleted file mode 100644 index 0954c9dc3..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/index.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - Aspire PR Previews - - - - -
-
-
- - Aspire logo - - Aspire - PR Preview - - -

Aspire PR Preview

-

Open pull requests

-

Pick any open PR and the preview host will warm the latest frontend build on demand while keeping only a small window hot.

-
-
- - - Repository - - Loading warm window... - Loading open pull requests... -
-
- -
-
- Authors -
- -
-
-
- Preview status -
- -
-
-
- -
- - - -
-
- - - - diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.js b/src/statichost/PreviewHost/wwwroot/_preview/index.js deleted file mode 100644 index 1cb5c7ed4..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/index.js +++ /dev/null @@ -1,710 +0,0 @@ -const previewGrid = document.getElementById("preview-grid"); -const windowCapacity = document.getElementById("window-capacity"); -const windowCount = document.getElementById("window-count"); -const availabilityFilterBar = document.getElementById("availability-filter-bar"); -const authorFilterBar = document.getElementById("author-filter-bar"); - -const numberFormatter = new Intl.NumberFormat(); -const dateFormatter = new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", -}); - -const initialFilters = readFiltersFromQuery(); - -let catalogEntries = []; -let authorOptions = []; -let showPreviewableOnly = initialFilters.previewable; -let selectedAuthors = new Set(initialFilters.authors); -let activePreviewCount = 0; -let maxActivePreviews = 0; -let openPullRequestCount = 0; -let previewablePullRequestCount = 0; -let openDropdown = ""; -const resettingPreviews = new Set(); - -availabilityFilterBar.addEventListener("click", (event) => { - if (!(event.target instanceof Element)) { - return; - } - - const button = event.target.closest("[data-filter-trigger='availability']"); - if (!(button instanceof HTMLButtonElement)) { - return; - } - - toggleDropdown("availability"); -}); - -availabilityFilterBar.addEventListener("change", (event) => { - if (!(event.target instanceof HTMLInputElement)) { - return; - } - - if (event.target.dataset.previewableCheckbox !== "true") { - return; - } - - showPreviewableOnly = event.target.checked; - syncQueryString(); - syncAvailabilityFilter(); - renderCatalog(); -}); - -authorFilterBar.addEventListener("click", (event) => { - if (!(event.target instanceof Element)) { - return; - } - - const button = event.target.closest("[data-filter-trigger='author']"); - if (!(button instanceof HTMLButtonElement)) { - const clearButton = event.target.closest("[data-clear-authors]"); - if (!(clearButton instanceof HTMLButtonElement)) { - return; - } - - selectedAuthors = new Set(); - syncQueryString(); - syncAuthorFilter(catalogEntries); - renderCatalog(); - return; - } - - toggleDropdown("author"); -}); - -authorFilterBar.addEventListener("change", (event) => { - if (!(event.target instanceof HTMLInputElement)) { - return; - } - - if (event.target.dataset.authorCheckbox !== "true") { - return; - } - - const value = normalizeAuthorValue(event.target.value); - if (!value) { - return; - } - - if (event.target.checked) { - selectedAuthors.add(value); - } else { - selectedAuthors.delete(value); - } - - syncQueryString(); - syncAuthorFilter(catalogEntries); - renderCatalog(); -}); - -document.addEventListener("click", (event) => { - if (!(event.target instanceof Element)) { - return; - } - - const path = typeof event.composedPath === "function" - ? event.composedPath() - : []; - - if (path.includes(availabilityFilterBar) || path.includes(authorFilterBar)) { - return; - } - - closeDropdowns(); -}); - -document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - closeDropdowns(); - } -}); - -previewGrid.addEventListener("click", (event) => { - if (!(event.target instanceof Element)) { - return; - } - - const button = event.target.closest("[data-reset-preview]"); - if (!(button instanceof HTMLButtonElement)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const pullRequestNumber = Number(button.dataset.resetPreview); - if (!Number.isInteger(pullRequestNumber) || pullRequestNumber <= 0) { - return; - } - - void resetPreview(pullRequestNumber); -}); - -loadCatalog().catch((error) => { - previewGrid.setAttribute("aria-busy", "false"); - availabilityFilterBar.setAttribute("aria-busy", "false"); - authorFilterBar.setAttribute("aria-busy", "false"); - syncAvailabilityFilter(); - syncAuthorFilter([]); - renderEmptyState(error instanceof Error ? error.message : "The preview host could not load open pull requests."); -}); - -setInterval(() => { - loadCatalog().catch((error) => { - console.error(error); - }); -}, 15000); - -async function loadCatalog() { - const response = await fetch("/api/previews/catalog", { - cache: "no-store", - }); - - if (!response.ok) { - throw new Error(`Open pull requests request failed with status ${response.status}.`); - } - - const payload = await response.json(); - catalogEntries = Array.isArray(payload.entries) ? payload.entries : []; - maxActivePreviews = payload.maxActivePreviews ?? 0; - activePreviewCount = payload.activePreviewCount ?? 0; - openPullRequestCount = payload.openPullRequestCount ?? catalogEntries.length; - previewablePullRequestCount = payload.previewablePullRequestCount - ?? catalogEntries.filter(isPreviewable).length; - - authorOptions = buildAuthorOptions(catalogEntries, selectedAuthors); - syncAvailabilityFilter(); - syncAuthorFilter(catalogEntries); - renderCatalog(); -} - -function renderCatalog() { - const filteredEntries = applyFilters(catalogEntries); - - previewGrid.setAttribute("aria-busy", "false"); - availabilityFilterBar.setAttribute("aria-busy", "false"); - authorFilterBar.setAttribute("aria-busy", "false"); - - windowCapacity.textContent = `Warm window: ${numberFormatter.format(activePreviewCount)} / ${numberFormatter.format(maxActivePreviews)}`; - windowCount.textContent = buildWindowCountText(filteredEntries); - - if (filteredEntries.length === 0) { - renderEmptyState(buildEmptyStateMessage()); - return; - } - - previewGrid.innerHTML = filteredEntries.map(renderPreviewCard).join("\n"); -} - -function renderPreviewCard(entry) { - const preview = entry.preview ?? null; - const previewPath = escapeHtml(entry.previewPath ?? "/prs/"); - const title = escapeHtml(entry.title ?? `PR #${entry.pullRequestNumber}`); - const subtitle = escapeHtml(`${getAuthorLabel(entry.authorLogin)} · Created ${formatDateOnly(entry.createdAtUtc)}`); - const stateText = escapeHtml(buildChipLabel(preview, entry)); - const stateClass = escapeHtml(getStateClass(preview, entry)); - const statusDetail = escapeHtml(buildStatusDetail(preview, entry)); - const openPullRequestAction = entry.pullRequestUrl - ? ` - - - ` - : ""; - const draftBadge = entry.isDraft - ? 'Draft' - : ""; - const resetAction = renderResetPreviewAction(entry, preview); - - return ` - `; -} - -function renderEmptyState(message) { - previewGrid.setAttribute("aria-busy", "false"); - previewGrid.innerHTML = ` -
-

${escapeHtml(message)}

-

Open a route like /prs/{number}/ to resolve a PR, prepare its latest frontend artifact, and add it to the warm preview window.

-
`; -} - -function syncAuthorFilter(entries) { - authorOptions = buildAuthorOptions(entries, selectedAuthors); - authorFilterBar.dataset.open = openDropdown === "author" ? "true" : "false"; - authorFilterBar.innerHTML = renderDropdown({ - kind: "author", - active: selectedAuthors.size > 0, - open: openDropdown === "author", - label: buildAuthorTriggerLabel(), - meta: buildAuthorTriggerMeta(), - menuHtml: renderAuthorFilterMenu(), - }); -} - -function syncAvailabilityFilter() { - availabilityFilterBar.dataset.open = openDropdown === "availability" ? "true" : "false"; - availabilityFilterBar.innerHTML = renderDropdown({ - kind: "availability", - active: showPreviewableOnly, - open: openDropdown === "availability", - label: showPreviewableOnly ? "Previewable only" : "All previews", - meta: showPreviewableOnly - ? `${numberFormatter.format(previewablePullRequestCount)} matching open PRs` - : `${numberFormatter.format(openPullRequestCount)} open PRs`, - menuHtml: renderAvailabilityFilterMenu(), - }); -} - -function renderDropdown({ kind, active, open, label, meta, menuHtml }) { - const activeClass = active ? " is-active" : ""; - return ` - -
- ${menuHtml} -
`; -} - -function renderAvailabilityFilterMenu() { - const checked = showPreviewableOnly ? " checked" : ""; - return ` -
- -
`; -} - -function renderAuthorFilterMenu() { - if (authorOptions.length === 0) { - return '
No open pull request authors are available right now.
'; - } - - const footer = selectedAuthors.size > 0 - ? ` - ` - : ""; - - return ` -
- ${authorOptions.map(renderAuthorOption).join("\n")} -
- ${footer}`; -} - -function renderAuthorOption(option) { - const checked = selectedAuthors.has(option.value) ? " checked" : ""; - const emptyClass = option.count === 0 ? " is-empty" : ""; - return ` - `; -} - -function renderResetPreviewAction(entry, preview) { - if (!preview) { - return ""; - } - - const pullRequestNumber = Number(entry.pullRequestNumber); - const isResetting = resettingPreviews.has(pullRequestNumber); - const label = isResetting ? "Resetting..." : "Reset preview"; - const disabled = isResetting ? " disabled" : ""; - - return ` - `; -} - -function buildAuthorOptions(entries, activeAuthors) { - const counts = new Map(); - const options = []; - - for (const entry of entries) { - const value = getAuthorValue(entry.authorLogin); - counts.set(value, (counts.get(value) ?? 0) + 1); - } - - for (const [value, count] of counts) { - options.push({ - value, - label: getAuthorLabelFromValue(value), - count, - }); - } - - for (const value of activeAuthors) { - if (counts.has(value)) { - continue; - } - - options.push({ - value, - label: getAuthorLabelFromValue(value), - count: 0, - }); - } - - return options.sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: "base" })); -} - -function applyFilters(entries) { - return entries.filter((entry) => { - if (showPreviewableOnly && !isPreviewable(entry)) { - return false; - } - - if (selectedAuthors.size > 0 && !selectedAuthors.has(getAuthorValue(entry.authorLogin))) { - return false; - } - - return true; - }); -} - -function getAuthorValue(authorLogin) { - return normalizeAuthorValue(authorLogin) || "unknown"; -} - -function getAuthorLabel(authorLogin) { - return getAuthorLabelFromValue(getAuthorValue(authorLogin)); -} - -function getAuthorLabelFromValue(value) { - return value === "unknown" - ? "Unknown" - : `@${value}`; -} - -function buildStatusDetail(preview, entry) { - if (!preview) { - return isPreviewable(entry) - ? "Loads on first visit." - : "No successful frontend build artifact is available for the current head yet."; - } - - if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { - return isPreviewable(entry) - ? "New commits are ready to warm from the latest frontend build." - : "New commits are waiting for a successful frontend build."; - } - - switch (preview.state) { - case "Ready": - return "Served from the warm window."; - case "Loading": - return preview.stage ? `${preview.stage} · ${preview.percent ?? 0}%` : `${preview.percent ?? 0}%`; - case "Registered": - return "Latest build is queued to warm."; - case "Cancelled": - return "Preparation was cancelled."; - case "Failed": - return preview.error ?? preview.message ?? "The preview host could not finish preparing this build."; - case "Evicted": - return "Loads again on the next visit."; - default: - return preview.message ?? "Waiting for preview activity."; - } -} - -function buildChipLabel(preview, entry) { - if (!preview) { - return isPreviewable(entry) ? "On demand" : "Waiting on CI"; - } - - if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { - return isPreviewable(entry) ? "Outdated" : "Waiting on CI"; - } - - switch (preview.state) { - case "Ready": - return "Ready"; - case "Loading": - return "Preparing"; - case "Registered": - return "Queued"; - case "Cancelled": - return "Cancelled"; - case "Failed": - return "Failed"; - case "Evicted": - return "Evicted"; - default: - return preview.state ?? "Unknown"; - } -} - -function getStateClass(preview, entry) { - if (!preview) { - return isPreviewable(entry) ? "missing" : "registered"; - } - - if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { - return isPreviewable(entry) ? "outdated" : "registered"; - } - - return String(preview.state ?? "missing").toLowerCase(); -} - -function isPreviewable(entry) { - const preview = entry.preview ?? null; - if (preview && preview.headSha && entry.headSha && preview.headSha === entry.headSha) { - return true; - } - - return entry.hasSuccessfulPreviewBuild === true; -} - -function buildWindowCountText(filteredEntries) { - if (showPreviewableOnly && selectedAuthors.size > 0) { - return `Showing ${numberFormatter.format(filteredEntries.length)} previewable PRs for ${buildSelectedAuthorSummary()}`; - } - - if (showPreviewableOnly) { - return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} open PRs with a successful frontend build`; - } - - if (selectedAuthors.size > 0) { - return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} open PRs for ${buildSelectedAuthorSummary()}`; - } - - return `Open PRs: ${numberFormatter.format(openPullRequestCount)}`; -} - -function buildEmptyStateMessage() { - if (showPreviewableOnly && selectedAuthors.size > 0) { - return `No previewable open pull requests match ${buildSelectedAuthorSummary()}.`; - } - - if (showPreviewableOnly) { - return "No open pull requests have a successful frontend build artifact right now."; - } - - if (selectedAuthors.size > 0) { - return `No open pull requests match ${buildSelectedAuthorSummary()}.`; - } - - return "No open pull requests need previews right now."; -} - -function buildAuthorTriggerLabel() { - if (selectedAuthors.size === 0) { - return "All authors"; - } - - if (selectedAuthors.size === 1) { - return getAuthorLabelFromValue([...selectedAuthors][0]); - } - - return `${numberFormatter.format(selectedAuthors.size)} authors selected`; -} - -function buildAuthorTriggerMeta() { - if (selectedAuthors.size === 0) { - if (authorOptions.length === 0) { - return "No authors available"; - } - - return authorOptions.length === 1 - ? "1 author available" - : `${numberFormatter.format(authorOptions.length)} authors available`; - } - - return selectedAuthors.size === 1 - ? "1 author selected" - : `${numberFormatter.format(selectedAuthors.size)} authors selected`; -} - -function buildAuthorOptionMeta(count) { - if (count === 0) { - return "No open pull requests right now"; - } - - return count === 1 - ? "1 open pull request" - : `${numberFormatter.format(count)} open pull requests`; -} - -function buildSelectedAuthorSummary() { - const labels = [...selectedAuthors] - .map(getAuthorLabelFromValue) - .sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" })); - - if (labels.length === 0) { - return "the selected authors"; - } - - if (labels.length === 1) { - return labels[0]; - } - - if (labels.length === 2) { - return `${labels[0]} and ${labels[1]}`; - } - - return `${numberFormatter.format(labels.length)} authors`; -} - -function toggleDropdown(name) { - openDropdown = openDropdown === name ? "" : name; - syncAvailabilityFilter(); - syncAuthorFilter(catalogEntries); -} - -function closeDropdowns() { - if (!openDropdown) { - return; - } - - openDropdown = ""; - syncAvailabilityFilter(); - syncAuthorFilter(catalogEntries); -} - -function readFiltersFromQuery() { - const searchParams = new URLSearchParams(window.location.search); - const previewable = searchParams.has("previewable") - ? normalizeBooleanQuery(searchParams.get("previewable")) - : true; - const authors = new Set(); - const rawAuthors = searchParams.get("author"); - - if (rawAuthors) { - for (const value of rawAuthors.split(",")) { - const normalized = normalizeAuthorValue(value); - if (normalized) { - authors.add(normalized); - } - } - } - - return { - previewable, - authors, - }; -} - -function normalizeBooleanQuery(value) { - if (!value) { - return false; - } - - return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); -} - -function normalizeAuthorValue(value) { - const normalized = String(value ?? "") - .trim() - .toLowerCase() - .replace(/^author:/, "") - .replace(/^@/, ""); - - return normalized; -} - -function syncQueryString() { - const url = new URL(window.location.href); - - url.searchParams.set("previewable", showPreviewableOnly ? "true" : "false"); - - if (selectedAuthors.size > 0) { - url.searchParams.set("author", [...selectedAuthors].sort().join(",")); - } else { - url.searchParams.delete("author"); - } - - window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); -} - -async function resetPreview(pullRequestNumber) { - if (resettingPreviews.has(pullRequestNumber)) { - return; - } - - if (!window.confirm(`Reset preview for PR #${pullRequestNumber}? This clears the cached preview so the next visit has to prepare it again.`)) { - return; - } - - resettingPreviews.add(pullRequestNumber); - renderCatalog(); - - try { - const response = await fetch(`/api/previews/${pullRequestNumber}/reset`, { - method: "POST", - headers: { - "Accept": "application/json", - }, - }); - - if (!response.ok) { - throw new Error(`Preview reset request failed with status ${response.status}.`); - } - - await loadCatalog(); - } catch (error) { - console.error(error); - window.alert(error instanceof Error - ? error.message - : "The preview host could not reset this preview."); - } finally { - resettingPreviews.delete(pullRequestNumber); - renderCatalog(); - } -} - -function formatDateOnly(value) { - const date = new Date(value); - return Number.isNaN(date.getTime()) ? "Waiting" : dateFormatter.format(date); -} - -function escapeHtml(value) { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg b/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg deleted file mode 100644 index 56f14e989..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/statichost/PreviewHost/wwwroot/_preview/preview.css b/src/statichost/PreviewHost/wwwroot/_preview/preview.css deleted file mode 100644 index 0a57310ae..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/preview.css +++ /dev/null @@ -1,1014 +0,0 @@ -:root { - color-scheme: dark; - --bg: #1f1e33; - --bg-deep: #181825; - --bg-accent: #231d41; - --panel: rgba(24, 24, 37, 0.94); - --panel-strong: rgba(35, 29, 65, 0.76); - --panel-border: rgba(160, 164, 171, 0.22); - --text: #ffffff; - --muted: #c6c8cc; - --muted-strong: #eceef1; - --line: rgba(160, 164, 171, 0.16); - --accent: #7455dd; - --accent-strong: #512bd4; - --accent-soft: #c6c2f2; - --danger: #f65163; - --warning: #e9b44c; - --shadow: rgba(10, 11, 20, 0.46); -} - -* { - box-sizing: border-box; -} - -[hidden] { - display: none !important; -} - -body { - margin: 0; - min-height: 100vh; - background: - radial-gradient(circle at top, rgba(116, 85, 221, 0.14), transparent 24%), - linear-gradient(180deg, var(--bg-deep) 0%, var(--bg) 18%, var(--bg) 100%); - color: var(--text); - font-family: "Poppins", "Segoe UI", system-ui, sans-serif; -} - -body[data-view="status"] { - display: grid; - align-content: start; - justify-items: center; - padding: 2rem 1rem 3rem; -} - -body[data-view="index"] { - padding: 2rem 1rem 3rem; -} - -.page-chrome { - width: min(42rem, 100%); - margin-bottom: 1rem; -} - -.page-shell { - width: min(42rem, 100%); -} - -.page-shell-wide { - width: min(72rem, 100%); - margin: 0 auto; -} - -.header-copy { - display: grid; - gap: 0.85rem; -} - -.brand-lockup { - display: inline-flex; - align-items: center; - gap: 0.8rem; - width: fit-content; - color: var(--text); - text-decoration: none; -} - -.brand-mark { - width: 2.5rem; - height: 2.5rem; - flex: none; - filter: drop-shadow(0 0.4rem 1.1rem rgba(81, 43, 212, 0.42)); -} - -.brand-text { - display: grid; - gap: 0.05rem; -} - -.brand-title { - font-size: 1.05rem; - line-height: 1; -} - -.brand-subtitle { - color: var(--muted); - font-size: 0.82rem; - letter-spacing: 0.16em; - text-transform: uppercase; -} - -.nav-button, -.action-link, -.action-button, -.preview-link { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border-radius: 0.75rem; - border: 1px solid var(--panel-border); - text-decoration: none; - transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; -} - -.nav-button, -.action-link, -.action-button { - min-height: 2.75rem; - padding: 0.7rem 1rem; - color: var(--text); - background: rgba(24, 24, 37, 0.84); - font: inherit; - font-weight: 600; -} - -.action-link-github { - background: var(--panel-strong); - cursor: pointer; -} - -.action-button.primary { - border-color: rgba(198, 194, 242, 0.35); - background: linear-gradient(180deg, var(--accent), var(--accent-strong)); - box-shadow: 0 1rem 2rem rgba(81, 43, 212, 0.28); -} - -.action-icon { - width: 1rem; - height: 1rem; - flex: none; -} - -.action-icon-github { - background-color: currentColor; - -webkit-mask-image: url("/_preview/github-mark.svg"); - mask-image: url("/_preview/github-mark.svg"); - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: center; - mask-position: center; - -webkit-mask-size: contain; - mask-size: contain; -} - -.action-icon-left-arrow, -.action-icon-retry { - background-color: currentColor; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: center; - mask-position: center; - -webkit-mask-size: contain; - mask-size: contain; -} - -.action-icon-left-arrow { - -webkit-mask-image: url("/_preview/left-arrow.svg"); - mask-image: url("/_preview/left-arrow.svg"); -} - -.action-icon-retry { - -webkit-mask-image: url("/_preview/retry-arrow.svg"); - mask-image: url("/_preview/retry-arrow.svg"); -} - -.nav-button:hover, -.action-link:hover, -.action-button:hover:not(:disabled), -.preview-link:hover { - transform: translateY(-1px); - border-color: rgba(198, 194, 242, 0.55); -} - -.action-button { - cursor: pointer; -} - -.action-button.secondary { - background: var(--panel-strong); -} - -.action-button.danger { - border-color: rgba(246, 81, 99, 0.28); - background: rgba(246, 81, 99, 0.12); -} - -.action-button:disabled { - cursor: wait; - opacity: 0.7; -} - -.status-card, -.preview-card, -.empty-card { - border-radius: 0.9rem; - border: 1px solid var(--panel-border); - background: - linear-gradient(180deg, rgba(198, 194, 242, 0.05), transparent 20%), - var(--panel); - box-shadow: 0 1.5rem 3rem var(--shadow); - backdrop-filter: blur(12px); -} - -.status-card { - position: relative; - padding: 1.75rem; -} - -.card-busy-indicator { - position: absolute; - top: 1.5rem; - right: 1.5rem; - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - border-radius: 999px; - border: 1px solid var(--line); - background: rgba(24, 24, 37, 0.88); - box-shadow: 0 0.75rem 1.5rem rgba(10, 11, 20, 0.24); - pointer-events: none; -} - -.card-spinner { - width: 1rem; - height: 1rem; - border-radius: 999px; - border: 2px solid rgba(198, 194, 242, 0.2); - border-top-color: var(--accent); - border-right-color: var(--accent-soft); - animation: status-spinner 0.9s linear infinite; -} - -.card-header, -.collection-header { - display: flex; - justify-content: space-between; - gap: 1rem; - align-items: flex-start; -} - -.card-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 0.75rem; -} - -.eyebrow { - margin: 0; - color: var(--accent-soft); - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.18em; - text-transform: uppercase; -} - -.preview-title { - margin-top: 0.35rem; - font-size: 1.35rem; -} - -h1, -h2 { - margin: 0; - line-height: 1.08; -} - -h1 { - font-size: clamp(2rem, 4vw, 3rem); -} - -h2 { - font-size: 1.2rem; -} - -p { - line-height: 1.6; -} - -.status-message { - margin: 1rem 0 0; - color: var(--muted); - font-size: 1rem; -} - -.status-message.error { - color: var(--danger); -} - -.progress-section { - display: grid; - gap: 1rem; - margin-top: 1.5rem; -} - -.progress-card, -.terminal-section { - border-radius: 1rem; - border: 1px solid var(--line); - background: rgba(24, 24, 37, 0.82); - padding: 1rem; -} - -.progress-header { - display: flex; - justify-content: space-between; - gap: 1rem; - margin-bottom: 0.75rem; - color: var(--muted); -} - -.progress-header strong { - color: var(--text); -} - -.progress-track { - overflow: hidden; - border-radius: 999px; - background: rgba(53, 56, 62, 0.56); - padding: 0.3rem; -} - -.progress-bar { - height: 0.8rem; - width: 0%; - border-radius: 999px; - background: linear-gradient(90deg, var(--accent-strong), var(--accent)); - transition: width 0.2s ease; -} - -.progress-track.stage .progress-bar { - background: linear-gradient(90deg, rgba(233, 180, 76, 0.96), rgba(198, 194, 242, 0.92)); -} - -.terminal-section { - display: grid; - gap: 0.75rem; - justify-items: start; - margin-top: 1.5rem; -} - -.terminal-summary, -.hint-text, -.collection-summary { - margin: 0; - color: var(--muted); -} - -.terminal-summary.error { - color: var(--danger); -} - -.detail-grid { - margin: 1.5rem 0 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; -} - -.detail-grid dt, -.preview-meta dt { - color: var(--muted); - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.detail-grid dd, -.preview-meta dd { - margin: 0.35rem 0 0; -} - -.hint-text { - margin-top: 1.25rem; -} - -.status-card-actions { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 1rem; - margin-top: 1.5rem; - padding-top: 1.25rem; - border-top: 1px solid var(--line); -} - -.status-card-secondary-actions, -.status-card-primary-actions { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; -} - -.status-card-primary-actions { - margin-left: auto; - justify-content: flex-end; -} - -.status-chip { - display: inline-flex; - align-items: center; - justify-content: center; - width: fit-content; - min-height: 2rem; - padding: 0.35rem 0.7rem; - border-radius: 999px; - border: 1px solid var(--line); - background: rgba(24, 24, 37, 0.86); - color: var(--text); - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; -} - -.status-chip.ready { - border-color: rgba(198, 194, 242, 0.3); - background: rgba(116, 85, 221, 0.2); -} - -.status-chip.loading, -.status-chip.registered { - border-color: rgba(116, 85, 221, 0.28); - background: rgba(81, 43, 212, 0.22); -} - -.status-chip.failed, -.status-chip.cancelled, -.status-chip.evicted { - border-color: rgba(246, 81, 99, 0.24); - background: rgba(246, 81, 99, 0.14); -} - -.status-chip.missing { - border-color: rgba(233, 180, 76, 0.22); - background: rgba(233, 180, 76, 0.14); -} - -.status-chip.outdated, -.status-chip.draft { - border-color: rgba(233, 180, 76, 0.22); - background: rgba(233, 180, 76, 0.14); -} - -.collection-meta { - display: grid; - justify-items: end; - gap: 0.35rem; - text-align: right; -} - -.collection-controls { - display: flex; - justify-content: flex-start; - flex-wrap: wrap; - align-items: flex-start; - gap: 1rem; - margin-top: 1rem; -} - -.filter-stack { - display: grid; - gap: 0.55rem; - min-width: min(100%, 16rem); -} - -.filter-label { - color: var(--muted); - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.filter-dropdown { - position: relative; - width: min(100%, 18rem); -} - -.filter-trigger { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.9rem; - width: 100%; - min-height: 2.85rem; - padding: 0.65rem 0.85rem; - border-radius: 0.95rem; - border: 1px solid var(--panel-border); - background: rgba(24, 24, 37, 0.88); - color: var(--text); - font: inherit; - text-align: left; - cursor: pointer; - transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; -} - -.filter-trigger:hover:not(:disabled) { - transform: translateY(-1px); - border-color: rgba(198, 194, 242, 0.42); - background: rgba(35, 29, 65, 0.84); - box-shadow: 0 0.9rem 1.8rem rgba(10, 11, 20, 0.22); -} - -.filter-trigger:focus-visible { - outline: 2px solid rgba(198, 194, 242, 0.45); - outline-offset: 2px; -} - -.filter-trigger.is-active, -.filter-dropdown[data-open="true"] .filter-trigger { - border-color: rgba(198, 194, 242, 0.35); - background: linear-gradient(180deg, rgba(53, 44, 103, 0.92), rgba(35, 29, 65, 0.92)); -} - -.filter-trigger:disabled { - cursor: wait; -} - -.filter-trigger-copy { - display: grid; - gap: 0.2rem; - min-width: 0; -} - -.filter-trigger-value, -.filter-trigger-meta { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.filter-trigger-value { - font-weight: 600; -} - -.filter-trigger-meta { - color: var(--muted); - font-size: 0.84rem; -} - -.filter-trigger-icon { - width: 0.7rem; - height: 0.7rem; - margin-right: 0.15rem; - border-right: 2px solid currentColor; - border-bottom: 2px solid currentColor; - transform: rotate(45deg) translateY(-1px); - opacity: 0.78; - transition: transform 0.18s ease; - flex: none; -} - -.filter-dropdown[data-open="true"] .filter-trigger-icon { - transform: rotate(225deg) translateY(-1px); -} - -.filter-trigger-loading { - position: relative; - overflow: hidden; - opacity: 0.9; -} - -.filter-menu { - position: absolute; - top: calc(100% + 0.55rem); - left: 0; - z-index: 20; - display: grid; - gap: 0.35rem; - width: min(22rem, calc(100vw - 1.8rem)); - padding: 0.55rem; - border-radius: 1rem; - border: 1px solid rgba(198, 194, 242, 0.18); - background: linear-gradient(180deg, rgba(31, 30, 51, 0.98), rgba(24, 24, 37, 0.98)); - box-shadow: 0 1.35rem 2.7rem rgba(10, 11, 20, 0.38); -} - -.filter-menu[hidden] { - display: none; -} - -.filter-menu-section { - display: grid; - gap: 0.15rem; -} - -.filter-option { - position: relative; - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: start; - gap: 0.75rem; - padding: 0.75rem; - border-radius: 0.85rem; - cursor: pointer; - transition: background 0.18s ease; -} - -.filter-option:hover { - background: rgba(53, 44, 103, 0.34); -} - -.filter-option-input { - position: absolute; - width: 1px; - height: 1px; - margin: 0; - padding: 0; - border: 0; - opacity: 0; - pointer-events: none; -} - -.filter-option-box { - width: 1.05rem; - height: 1.05rem; - margin-top: 0.08rem; - border-radius: 0.32rem; - border: 1px solid rgba(198, 194, 242, 0.28); - background: rgba(24, 24, 37, 0.9); - box-shadow: inset 0 0 0 1px rgba(10, 11, 20, 0.16); - transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; -} - -.filter-option-input:focus-visible + .filter-option-box { - outline: 2px solid rgba(198, 194, 242, 0.45); - outline-offset: 2px; -} - -.filter-option-input:checked + .filter-option-box { - position: relative; - border-color: rgba(198, 194, 242, 0.35); - background: linear-gradient(180deg, var(--accent), var(--accent-strong)); - box-shadow: 0 0 0 1px rgba(116, 85, 221, 0.14); -} - -.filter-option-input:checked + .filter-option-box::after { - content: ""; - position: absolute; - top: 0.15rem; - left: 0.34rem; - width: 0.22rem; - height: 0.45rem; - border: solid rgba(255, 255, 255, 0.94); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.filter-option-copy { - display: grid; - gap: 0.18rem; - min-width: 0; -} - -.filter-option-title { - color: var(--text); - font-weight: 600; -} - -.filter-option-meta, -.filter-option-count, -.filter-menu-empty, -.filter-menu-hint { - color: var(--muted); - font-size: 0.84rem; -} - -.filter-option.is-empty .filter-option-count { - opacity: 0.7; -} - -.filter-option-count { - align-self: center; -} - -.filter-menu-empty { - padding: 0.75rem; -} - -.filter-menu-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.75rem; - padding: 0.35rem 0.75rem 0.15rem; -} - -.filter-menu-action { - border: none; - background: none; - padding: 0; - color: var(--accent); - font: inherit; - font-weight: 600; - cursor: pointer; -} - -.filter-menu-action:hover { - color: var(--accent-strong); -} - -.preview-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); - gap: 1rem; - margin-top: 1.5rem; -} - -.preview-card, -.empty-card { - padding: 1.15rem; -} - -.preview-card { - transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; -} - -.preview-card:hover { - transform: translateY(-1px); - border-color: rgba(198, 194, 242, 0.36); - box-shadow: 0 1rem 2rem rgba(10, 11, 20, 0.34); -} - -.preview-card-skeleton { - pointer-events: none; -} - -.preview-card-skeleton:hover { - transform: none; - border-color: var(--panel-border); - box-shadow: 0 1.5rem 3rem var(--shadow); -} - -.preview-card-topline { - display: flex; - justify-content: space-between; - gap: 0.75rem; - align-items: flex-start; -} - -.preview-card-tools { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.25rem; - height: 2.25rem; - border-radius: 0.65rem; - border: 1px solid var(--panel-border); - background: rgba(24, 24, 37, 0.84); - color: var(--text); - text-decoration: none; - transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; -} - -.icon-button:hover { - transform: translateY(-1px); - border-color: rgba(198, 194, 242, 0.42); - background: rgba(35, 29, 65, 0.82); -} - -.preview-card-link { - display: grid; - gap: 0.55rem; - margin-top: 0.35rem; - color: inherit; - text-decoration: none; -} - -.preview-card-link:hover .preview-title { - color: var(--accent); -} - -.skeleton-line, -.skeleton-chip { - position: relative; - overflow: hidden; - border-radius: 999px; - background: rgba(198, 194, 242, 0.09); -} - -.skeleton-line::after, -.skeleton-chip::after, -.filter-trigger-loading::after { - content: ""; - position: absolute; - inset: 0; - transform: translateX(-100%); - background: linear-gradient( - 90deg, - transparent 0%, - rgba(198, 194, 242, 0.08) 30%, - rgba(198, 194, 242, 0.22) 50%, - rgba(198, 194, 242, 0.08) 70%, - transparent 100% - ); - animation: skeleton-shimmer 1.35s ease-in-out infinite; -} - -.skeleton-line-eyebrow { - width: 5.5rem; - height: 0.9rem; -} - -.skeleton-line-title { - width: 82%; - height: 1.35rem; - margin-top: 0.35rem; -} - -.skeleton-line-subtitle { - width: 70%; - height: 0.95rem; - margin-top: 0.85rem; -} - -.skeleton-line-status { - width: 52%; - height: 0.95rem; -} - -.skeleton-chip { - width: 4.75rem; - height: 2rem; - flex: none; -} - -.preview-title-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.6rem; - margin-top: 0.15rem; -} - -.preview-link { - color: var(--text); - background: transparent; - border: none; - padding: 0; - min-height: 0; - font-size: 1.15rem; - font-weight: 700; - justify-content: flex-start; -} - -.preview-path { - margin: 0.45rem 0 0; - color: var(--muted); - font-family: "Fira Code", "Cascadia Code", Consolas, monospace; - font-size: 0.92rem; -} - -.preview-card-subtitle { - margin: 0; - color: var(--muted); - font-size: 0.9rem; -} - -.preview-card-status-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.65rem; -} - -.preview-status-detail { - color: var(--muted); - font-size: 0.95rem; -} - -.preview-card-footer { - display: grid; - gap: 0.9rem; - margin-top: 1rem; -} - -.preview-card-meta { - display: flex; - flex-wrap: wrap; - gap: 0.55rem; -} - -.preview-meta-pill { - display: inline-flex; - align-items: center; - min-height: 2rem; - padding: 0.35rem 0.7rem; - border-radius: 0.6rem; - border: 1px solid var(--line); - background: rgba(24, 24, 37, 0.82); - color: var(--muted); - font-size: 0.88rem; -} - -.preview-card-actions { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-bottom: 0; -} - -.preview-card-reset { - width: 100%; - min-height: 2.4rem; - padding: 0.6rem 0.85rem; - justify-content: center; - font-size: 0.92rem; -} - -.preview-meta { - margin: 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.75rem 1rem; -} - -code { - border-radius: 0.4rem; - background: rgba(35, 29, 65, 0.72); - padding: 0.15rem 0.35rem; - font-family: Consolas, "Cascadia Code", monospace; -} - -@keyframes skeleton-shimmer { - 100% { - transform: translateX(100%); - } -} - -@keyframes status-spinner { - 100% { - transform: rotate(360deg); - } -} - -@media (max-width: 640px) { - body[data-view="status"], - body[data-view="index"] { - padding: 1rem 0.9rem 2rem; - } - - .status-card { - padding: 1.25rem; - } - - .card-busy-indicator { - top: 1rem; - right: 1rem; - } - - .card-header, - .collection-header { - flex-direction: column; - } - - .card-actions, - .collection-controls, - .collection-meta { - justify-content: flex-start; - justify-items: start; - text-align: left; - } - - .status-card-actions { - flex-direction: column; - align-items: stretch; - } - - .status-card-secondary-actions, - .status-card-primary-actions { - width: 100%; - } - - .status-card-primary-actions { - margin-left: 0; - justify-content: flex-start; - } - - .status-card-actions .action-link, - .status-card-actions .action-button { - width: 100%; - } - - .detail-grid, - .preview-meta { - grid-template-columns: 1fr; - } - - .preview-card-topline { - flex-direction: column; - align-items: flex-start; - } -} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg b/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg deleted file mode 100644 index b1f17ad24..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.html b/src/statichost/PreviewHost/wwwroot/_preview/status.html deleted file mode 100644 index b953e280f..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/status.html +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - Aspire PR Preview - - - - - - -
-
- - -
-
- - Aspire logo - - Aspire - PR Preview - - -

Preparing preview

-
-
- -

- - - - - -
-
-
Status
-
Preparing
-
-
-
Overall
-
0%
-
-
-
Preview path
-
/prs/
-
-
-
Updated
-
Waiting
-
- - -
- -

- - -
-
- - - - diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.js b/src/statichost/PreviewHost/wwwroot/_preview/status.js deleted file mode 100644 index f98395974..000000000 --- a/src/statichost/PreviewHost/wwwroot/_preview/status.js +++ /dev/null @@ -1,591 +0,0 @@ -const routeMatch = window.location.pathname.match(/^\/prs\/(\d+)(?:\/.*)?$/i); -const pullRequestNumber = routeMatch ? Number(routeMatch[1]) : null; -const refreshTarget = pullRequestNumber ? `/prs/${pullRequestNumber}/` : "/prs/"; - -const pageTitle = document.getElementById("page-title"); -const message = document.getElementById("message"); -const progressSection = document.getElementById("progress-section"); -const terminalSection = document.getElementById("terminal-section"); -const terminalChip = document.getElementById("terminal-chip"); -const terminalSummary = document.getElementById("terminal-summary"); -const statusValue = document.getElementById("status-value"); -const overallSummary = document.getElementById("overall-summary"); -const previewPath = document.getElementById("preview-path"); -const updatedAt = document.getElementById("updated-at"); -const cardBusyIndicator = document.getElementById("card-busy-indicator"); -const downloadDetailGroup = document.getElementById("download-detail-group"); -const downloadDetail = document.getElementById("download-detail"); -const extractDetailGroup = document.getElementById("extract-detail-group"); -const extractDetail = document.getElementById("extract-detail"); -const hint = document.getElementById("hint"); -const overallProgressValue = document.getElementById("overall-progress-value"); -const stageProgressLabel = document.getElementById("stage-progress-label"); -const stageProgressValue = document.getElementById("stage-progress-value"); -const overallProgressBar = document.getElementById("overall-progress-bar"); -const stageProgressBar = document.getElementById("stage-progress-bar"); -const statusCardActions = document.getElementById("status-card-actions"); -const openPrLink = document.getElementById("open-pr-link"); -const cancelButton = document.getElementById("cancel-button"); -const retryButton = document.getElementById("retry-button"); -const retryButtonLabel = retryButton?.querySelector(".button-label"); - -const numberFormatter = new Intl.NumberFormat(); -const dateFormatter = new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", -}); -const clockFormatter = new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - second: "2-digit", -}); - -let currentState = null; -let eventSource = null; -let retryInFlight = false; -let cancelInFlight = false; -let suppressCloseCancellation = false; -let closeCancellationSent = false; -const downloadRateTracker = createRateTracker(); -const extractionRateTracker = createRateTracker(); - -initializeShell(); -window.addEventListener("pagehide", cancelIfClosingDuringPreparation); -window.addEventListener("beforeunload", cancelIfClosingDuringPreparation); -document.addEventListener("click", preservePreparationWhenReturningToCatalog); - -bootstrap().catch((error) => { - applyState(buildFailureState(error instanceof Error ? error.message : "The preview host could not load the preview status.")); -}); - -cancelButton.addEventListener("click", async () => { - if (!pullRequestNumber || cancelInFlight) { - return; - } - - cancelInFlight = true; - updateActionButtons(); - - try { - const response = await fetch(`/api/previews/${pullRequestNumber}/cancel`, { - method: "POST", - cache: "no-store", - }); - - if (!response.ok) { - throw new Error(`Cancel failed with status ${response.status}.`); - } - - closeEventSource(); - applyState(await response.json()); - } catch (error) { - hint.textContent = error instanceof Error - ? error.message - : "The preview host could not cancel the current preparation run."; - } finally { - cancelInFlight = false; - updateActionButtons(); - } -}); - -retryButton.addEventListener("click", async () => { - if (!pullRequestNumber || retryInFlight) { - return; - } - - retryInFlight = true; - updateActionButtons(); - - try { - const response = await fetch(`/api/previews/${pullRequestNumber}/retry`, { - method: "POST", - cache: "no-store", - }); - const payload = await readJsonSafely(response); - - if (!response.ok && response.status !== 202) { - applyState(buildFailureState( - payload?.failureMessage ?? `Retry failed with status ${response.status}.`, - payload ?? {}, - )); - return; - } - - if (!payload) { - await bootstrap(); - return; - } - - closeEventSource(); - applyState(payload); - - if (payload.isReady || payload.state === "Missing" || isTerminalState(payload.state)) { - return; - } - - if (isActiveState(payload.state)) { - connectEventsIfNeeded(payload); - return; - } - - await bootstrap(); - } catch (error) { - hint.textContent = error instanceof Error - ? error.message - : "The preview host could not restart preview preparation."; - } finally { - retryInFlight = false; - updateActionButtons(); - } -}); - -async function bootstrap() { - if (!pullRequestNumber) { - applyState(buildFailureState("This route does not contain a valid pull request number.")); - return; - } - - suppressCloseCancellation = false; - closeEventSource(); - - const response = await fetch(`/api/previews/${pullRequestNumber}/bootstrap`, { - cache: "no-store", - }); - const payload = await readJsonSafely(response); - - if (!response.ok) { - applyState(buildFailureState(payload?.failureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.", payload)); - return; - } - - applyState(payload); - connectEventsIfNeeded(payload); -} - -function connectEventsIfNeeded(snapshot) { - if (!pullRequestNumber || !isActiveState(snapshot.state)) { - return; - } - - eventSource = new EventSource(`/api/previews/${pullRequestNumber}/events`); - eventSource.onmessage = (event) => { - const snapshotUpdate = JSON.parse(event.data); - applyState(snapshotUpdate); - - if (snapshotUpdate.isReady || isTerminalState(snapshotUpdate.state)) { - closeEventSource(); - } - }; - - eventSource.onerror = () => { - if (currentState && isActiveState(currentState.state)) { - hint.textContent = "Connection interrupted. The page will reconnect while the preview is still preparing."; - } - }; -} - -function applyState(snapshot) { - currentState = snapshot; - closeCancellationSent = false; - - if (snapshot.isReady) { - suppressCloseCancellation = true; - closeEventSource(); - window.location.replace(refreshTarget); - return; - } - - const statusLabel = getStatusLabel(snapshot.state); - const titleLabel = getTitle(snapshot); - const overallPercent = snapshot.percent ?? 0; - const stagePercent = snapshot.stagePercent ?? 0; - const activeState = isActiveState(snapshot.state); - const terminalState = isTerminalState(snapshot.state) || snapshot.state === "Missing"; - const text = formatMessage(snapshot.error ?? snapshot.message ?? snapshot.failureMessage ?? "Waiting for preview status."); - const downloadRate = updateRateTracker( - downloadRateTracker, - activeState && snapshot.stage === "Downloading", - snapshot.bytesDownloaded, - ); - const extractionRate = updateRateTracker( - extractionRateTracker, - activeState && snapshot.itemsLabel === "files", - snapshot.itemsCompleted, - ); - const downloadText = getDownloadText(snapshot, downloadRate); - const extractionText = getExtractionText(snapshot, extractionRate); - - document.title = titleLabel; - pageTitle.textContent = titleLabel; - message.textContent = text; - message.hidden = terminalState; - message.classList.toggle("error", terminalState && snapshot.state !== "Cancelled"); - progressSection.hidden = !activeState; - terminalSection.hidden = !terminalState; - cardBusyIndicator.hidden = !activeState; - statusValue.textContent = statusLabel; - overallSummary.textContent = activeState ? `${overallPercent}%` : statusLabel; - previewPath.textContent = snapshot.previewPath ?? refreshTarget; - updatedAt.textContent = snapshot.updatedAtUtc ? formatUpdated(snapshot.updatedAtUtc) : "Waiting"; - downloadDetailGroup.hidden = !downloadText; - downloadDetail.textContent = downloadText ?? "Waiting"; - extractDetailGroup.hidden = !extractionText; - extractDetail.textContent = extractionText ?? "Waiting"; - hint.textContent = getHint(snapshot); - - overallProgressValue.textContent = `${overallPercent}%`; - stageProgressLabel.textContent = getStageProgressLabel(snapshot); - stageProgressValue.textContent = `${stagePercent}%`; - overallProgressBar.style.width = `${overallPercent}%`; - stageProgressBar.style.width = `${stagePercent}%`; - - terminalChip.textContent = statusLabel; - terminalChip.className = `status-chip ${getStateClassName(snapshot.state)}`; - terminalSummary.textContent = terminalState ? text : ""; - terminalSummary.classList.toggle("error", terminalState && snapshot.state !== "Cancelled"); - - updateOpenPrLink(snapshot); - updateActionButtons(); -} - -function initializeShell() { - if (!pullRequestNumber) { - return; - } - - const initialTitle = `Preparing PR #${pullRequestNumber}`; - document.title = initialTitle; - pageTitle.textContent = initialTitle; - message.textContent = "Checking GitHub for the latest successful frontend artifact."; - message.hidden = false; - cardBusyIndicator.hidden = false; - previewPath.textContent = refreshTarget; - hint.textContent = "This page starts loading the preview automatically when a build is available."; -} - -function updateOpenPrLink(snapshot) { - const repositoryOwner = snapshot.repositoryOwner; - const repositoryName = snapshot.repositoryName; - if (!repositoryOwner || !repositoryName || !pullRequestNumber) { - openPrLink.hidden = true; - return; - } - - openPrLink.href = `https://github.com/${repositoryOwner}/${repositoryName}/pull/${pullRequestNumber}`; - openPrLink.hidden = false; -} - -function updateActionButtons() { - const canCancel = currentState && isActiveState(currentState.state); - const canRetry = currentState && (isTerminalState(currentState.state) || currentState.state === "Missing"); - - cancelButton.hidden = !canCancel && !cancelInFlight; - cancelButton.disabled = !canCancel || cancelInFlight; - cancelButton.textContent = cancelInFlight ? "Cancelling..." : "Cancel prep"; - - retryButton.hidden = !canRetry && !retryInFlight; - retryButton.disabled = !canRetry || retryInFlight; - if (retryButtonLabel) { - retryButtonLabel.textContent = retryInFlight ? "Restarting..." : "Retry prep"; - } - - if (statusCardActions) { - statusCardActions.hidden = openPrLink.hidden && cancelButton.hidden && retryButton.hidden; - } -} - -function buildFailureState(messageText, payload = {}) { - return { - pullRequestNumber: payload.pullRequestNumber ?? pullRequestNumber ?? 0, - state: payload.state ?? "Missing", - stage: payload.stage ?? "Failed", - message: payload.message ?? messageText, - error: payload.error ?? messageText, - percent: payload.percent ?? 0, - stagePercent: payload.stagePercent ?? 100, - bytesDownloaded: payload.bytesDownloaded ?? null, - bytesTotal: payload.bytesTotal ?? null, - itemsCompleted: payload.itemsCompleted ?? null, - itemsTotal: payload.itemsTotal ?? null, - itemsLabel: payload.itemsLabel ?? null, - updatedAtUtc: payload.updatedAtUtc ?? new Date().toISOString(), - previewPath: payload.previewPath ?? refreshTarget, - repositoryOwner: payload.repositoryOwner ?? null, - repositoryName: payload.repositoryName ?? null, - isReady: false, - }; -} - -async function readJsonSafely(response) { - try { - return await response.json(); - } catch { - return null; - } -} - -function closeEventSource() { - if (eventSource) { - eventSource.close(); - eventSource = null; - } -} - -function preservePreparationWhenReturningToCatalog(event) { - if (!(event.target instanceof Element) || event.defaultPrevented) { - return; - } - - const link = event.target.closest("a[data-preserve-preparation='true']"); - if (!(link instanceof HTMLAnchorElement)) { - return; - } - - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - - if (link.target && link.target !== "_self") { - return; - } - - suppressCloseCancellation = true; -} - -function cancelIfClosingDuringPreparation() { - if (!pullRequestNumber - || suppressCloseCancellation - || closeCancellationSent - || retryInFlight - || cancelInFlight - || !currentState - || !isActiveState(currentState.state)) { - return; - } - - closeCancellationSent = true; - const cancelUrl = `/api/previews/${pullRequestNumber}/cancel`; - - if (typeof navigator.sendBeacon === "function") { - navigator.sendBeacon(cancelUrl, new Blob([], { type: "application/octet-stream" })); - return; - } - - fetch(cancelUrl, { - method: "POST", - cache: "no-store", - keepalive: true, - }).catch(() => { - }); -} - -function isActiveState(state) { - return state === "Registered" || state === "Loading"; -} - -function isTerminalState(state) { - return state === "Failed" || state === "Cancelled" || state === "Evicted"; -} - -function getStatusLabel(state) { - switch (state) { - case "Registered": - return "Queued"; - case "Loading": - return "Preparing"; - case "Ready": - return "Ready"; - case "Failed": - return "Failed"; - case "Cancelled": - return "Cancelled"; - case "Evicted": - return "Evicted"; - case "Missing": - return "Unavailable"; - default: - return state ?? "Preparing"; - } -} - -function getTitle(snapshot) { - const number = snapshot.pullRequestNumber ?? pullRequestNumber ?? "?"; - if (snapshot.state === "Failed" || snapshot.state === "Missing") { - return `PR #${number} preview unavailable`; - } - - if (snapshot.state === "Cancelled") { - return `PR #${number} prep cancelled`; - } - - return `Preparing PR #${number}`; -} - -function getHint(snapshot) { - if (snapshot.state === "Missing") { - return "Retry after CI publishes a new artifact."; - } - - if (snapshot.state === "Failed") { - return "Fix the backing configuration or publish a new build, then retry."; - } - - if (snapshot.state === "Cancelled") { - return "Retry when you are ready to start again."; - } - - if (snapshot.state === "Evicted") { - return "Retry to prepare it again."; - } - - return "This page will open the preview automatically as soon as preparation finishes."; -} - -function getStateClassName(state) { - return String(state ?? "missing").toLowerCase(); -} - -function getStageProgressLabel(snapshot) { - if (snapshot.stage === "Downloading") { - return "Downloading preview artifact"; - } - - if (snapshot.stage === "Extracting" && snapshot.itemsLabel === "files") { - return "Extracting preview files"; - } - - return snapshot.stage ? `${snapshot.stage} progress` : "Stage progress"; -} - -function formatMessage(text) { - return String(text).replace(/\d{4,}/g, (value) => numberFormatter.format(Number(value))); -} - -function formatUpdated(value) { - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? value - : `${dateFormatter.format(date)} · ${clockFormatter.format(date)}`; -} - -function getDownloadText(snapshot, bytesPerSecond) { - if (!Number.isFinite(snapshot.bytesDownloaded) && !Number.isFinite(snapshot.bytesTotal)) { - return null; - } - - const parts = []; - - if (Number.isFinite(snapshot.bytesDownloaded) && Number.isFinite(snapshot.bytesTotal) && snapshot.bytesTotal > 0) { - parts.push(`${formatBytes(snapshot.bytesDownloaded)} / ${formatBytes(snapshot.bytesTotal)}`); - } else if (Number.isFinite(snapshot.bytesDownloaded)) { - parts.push(`${formatBytes(snapshot.bytesDownloaded)} downloaded`); - } - - if (snapshot.stage === "Downloading" && Number.isFinite(bytesPerSecond) && bytesPerSecond > 0) { - parts.push(`${formatBytes(bytesPerSecond)}/s`); - } - - return parts.join(" · "); -} - -function getExtractionText(snapshot, itemsPerSecond) { - if (snapshot.itemsLabel !== "files" || !Number.isFinite(snapshot.itemsCompleted) && !Number.isFinite(snapshot.itemsTotal)) { - return null; - } - - const completed = Number.isFinite(snapshot.itemsCompleted) - ? numberFormatter.format(snapshot.itemsCompleted) - : "0"; - const total = Number.isFinite(snapshot.itemsTotal) && snapshot.itemsTotal > 0 - ? ` / ${numberFormatter.format(snapshot.itemsTotal)}` - : ""; - - const parts = [`${completed}${total} files`]; - if (Number.isFinite(itemsPerSecond) && itemsPerSecond > 0) { - parts.push(`${formatUnitRate(itemsPerSecond)} files/s`); - } - - return parts.join(" · "); -} - -function formatBytes(value) { - const bytes = Number(value); - if (!Number.isFinite(bytes) || bytes < 0) { - return "0 B"; - } - - const units = ["B", "KB", "MB", "GB", "TB"]; - let size = bytes; - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex += 1; - } - - const digits = size >= 100 || unitIndex === 0 ? 0 : size >= 10 ? 1 : 2; - return `${size.toFixed(digits)} ${units[unitIndex]}`; -} - -function createRateTracker() { - return { - samples: [], - lastRate: null, - lastRateUpdatedAt: 0, - }; -} - -function updateRateTracker(tracker, isActive, value) { - const now = Date.now(); - - if (!isActive || !Number.isFinite(value) || Number(value) < 0) { - tracker.samples = []; - return now - tracker.lastRateUpdatedAt <= 10000 - ? tracker.lastRate - : null; - } - - const numericValue = Number(value); - const lastSample = tracker.samples[tracker.samples.length - 1]; - - if (lastSample && numericValue < lastSample.value) { - tracker.samples = []; - } - - if (!lastSample || numericValue !== lastSample.value || now - lastSample.time >= 500) { - tracker.samples.push({ time: now, value: numericValue }); - } - - while (tracker.samples.length > 0 && now - tracker.samples[0].time > 8000) { - tracker.samples.shift(); - } - - const firstSample = tracker.samples[0]; - const newestSample = tracker.samples[tracker.samples.length - 1]; - if (!firstSample || !newestSample || newestSample.time <= firstSample.time || newestSample.value <= firstSample.value) { - return now - tracker.lastRateUpdatedAt <= 10000 - ? tracker.lastRate - : null; - } - - tracker.lastRate = (newestSample.value - firstSample.value) / ((newestSample.time - firstSample.time) / 1000); - tracker.lastRateUpdatedAt = now; - return tracker.lastRate; -} - -function formatUnitRate(value) { - const numericValue = Number(value); - if (!Number.isFinite(numericValue) || numericValue <= 0) { - return "0"; - } - - if (numericValue >= 100) { - return numberFormatter.format(Math.round(numericValue)); - } - - if (numericValue >= 10) { - return numericValue.toFixed(1); - } - - return numericValue.toFixed(2); -}