diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index e3703c2..fabe46b 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,12 +2,19 @@ "version": 1, "isRoot": true, "tools": { - "markdownutil": { - "version": "4.8.0", + "mdu": { + "version": "6.0.1", "commands": [ "mdu" ], "rollForward": false + }, + "dotnet-ef": { + "version": "10.0.14", + "commands": [ + "dotnet-ef" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ecc17e..c15e399 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: inputs: version: type: string - default: '0.1.0' + default: '0.2.0' permissions: contents: read @@ -62,7 +62,7 @@ jobs: - name: Publish Native AOT executable shell: bash env: - PACKAGE_VERSION: ${{ inputs.version || '0.1.0' }} + PACKAGE_VERSION: ${{ inputs.version || '0.2.0' }} run: dotnet publish src/DotnetCockpit/DotnetCockpit.csproj --configuration Release --runtime ${{ matrix.rid }} --self-contained true -p:PublishAot=true -warnaserror:IL2026,IL2104,IL3050,IL3053 -p:Version="$PACKAGE_VERSION" --output artifacts/native - name: Smoke test published executable run: node scripts/smoke.mjs artifacts/native/${{ matrix.exe }} @@ -73,6 +73,13 @@ jobs: - name: Test UI against native backend if: runner.os == 'Linux' run: npm --prefix ui run test:browser + - name: Install browser for screenshot comparison + if: matrix.os == 'macos-latest' + working-directory: ui + run: npx playwright install chromium + - name: Compare screenshots + if: matrix.os == 'macos-latest' + run: npm --prefix ui run test:visual - name: Archive native executable shell: pwsh env: @@ -116,11 +123,11 @@ jobs: npm --prefix ui run build - name: Pack .NET tool env: - PACKAGE_VERSION: ${{ inputs.version || '0.1.0' }} + PACKAGE_VERSION: ${{ inputs.version || '0.2.0' }} run: dotnet pack src/DotnetCockpit/DotnetCockpit.csproj --configuration Release -p:Version="$PACKAGE_VERSION" --output artifacts/packages - name: Install and smoke test tool package env: - PACKAGE_VERSION: ${{ inputs.version || '0.1.0' }} + PACKAGE_VERSION: ${{ inputs.version || '0.2.0' }} run: | dotnet tool install dotnet-cockpit --tool-path "$PWD/artifacts/tool" --add-source "$PWD/artifacts/packages" --version "$PACKAGE_VERSION" PATH="$PWD/artifacts/tool:$PATH" dotnet cockpit --help diff --git a/AGENTS.md b/AGENTS.md index ca7a84d..be3075e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Repository guide -.NET Cockpit is a local browser application: a Native AOT ASP.NET Core Minimal API host renders HTML in C#. Fluent UI Web Components enhance the server-rendered page; React is not used. +.NET Cockpit is a local browser application: a Native AOT ASP.NET Core Minimal API host renders HTML in C#. A small module script enhances the server-rendered page; no UI framework is used. - Keep native startup independent of an installed .NET runtime or SDK. The optional managed .NET tool is an additional distribution. - Build browser assets before compiling .NET: `npm --prefix ui ci` and `npm --prefix ui run build`. Node is only a build/test dependency. @@ -11,8 +11,12 @@ - Inventory reflects the selected dotnet host on PATH. Distinguish a missing host from a failed query. - `/tools` lists user-global tools and local manifest declarations for Cockpit's startup working directory. Do not scan arbitrary projects or imply that a listed local package has been restored. Keep the two scopes independent when a query fails. - Tool mutations run only through explicit local forms: search results install globally until a local manifest exists, initialized workspaces offer global/local targets, installed rows offer uninstall, and the current workspace can remove its own local manifest. Keep POST actions scoped to the startup working directory and preserve server-rendered behavior without JavaScript; never remove an inherited parent manifest. +- Every mutation asks first through `ConfirmActions`: the shared dialog with JavaScript, the `/confirm` page without it. +- Support dates, end-of-life state and the timeline come only from the release catalog. When it is unavailable, say the status is unknown instead of guessing. +- Preferences live in `SettingsStore`, not browser storage, because the free port changes the origin on every launch. +- Styles use the tokens in `ui/src/styles/tokens.css`: six type sizes, three radii, semantic colors. Add no literal colors, other sizes or `!important`. Palettes in `palettes.css` override color roles only and must pass the contrast test in both themes. - `/setup` may detect, install, update, and remove only the official global `dotnet-core-uninstall` installation for Windows and macOS. Global installs use the platform paths and must surface administrator approval. Release discovery must follow every page of the official GitHub Releases API, filter stable releases to those with a compatible asset, and treat an incomplete/failed response as unavailable rather than presenting a partial list. Do not download arbitrary URLs or claim Linux support. - The CLI's bracketed path is a base directory. Display `InstallationPath` (base path plus version) for an individual SDK or runtime; retain `Path` in the JSON API for compatibility. -- Run `dotnet test DotnetCockpit.sln`, publish the native binary, run `node scripts/smoke.mjs `, and run the browser tests for UI changes. +- Run `dotnet test DotnetCockpit.sln`, publish the native binary, run `node scripts/smoke.mjs `, and run the browser tests for UI changes. For visual changes also run `npm --prefix ui run test:visual`; after an intended change, update the baselines with `npm --prefix ui run test:visual:update` and review them. - Use `gh` for GitHub operations. Commit messages follow Conventional Commits and contain no AI attribution. - Keep changes focused and avoid speculative abstractions or functionality. diff --git a/docs/Features.md b/docs/Features.md index 3540096..0ec2438 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -20,10 +20,12 @@ The **.NET Tools** page keeps the two scopes separate: A local declaration is not proof that its package has been restored. Cockpit never scans unrelated projects. Tool changes are deliberate POST actions: search results install globally until the current workspace has a manifest; initialized workspaces can choose global or local installation; installed rows can uninstall; and Cockpit only removes a manifest owned by its own startup directory. -## Setup +## Settings On macOS and Windows, Cockpit can detect, install, update, and remove the official global `dotnet-core-uninstall` installation. These operations request administrator approval where the platform requires it. Linux is not supported by the official tool. -On macOS, **Setup** also lists official `.pkg` installers for the host architecture. The catalog comes from Microsoft's release metadata and includes SDKs and standalone .NET Runtimes, release channels, support status, release dates, SHA-512 checksums, and release CVEs where Microsoft provides them. Downloading a package is separate from installing it: open the downloaded package in macOS Installer to complete installation. +On macOS, **Versions → Install .NET** lists official `.pkg` installers for the host architecture. The catalog comes from Microsoft's release metadata and includes SDKs and standalone .NET Runtimes, release channels, support status, release dates, SHA-512 checksums, and release CVEs where Microsoft provides them. Downloading a package is separate from installing it: open the downloaded package in macOS Installer to complete installation. The SDK installer includes the matching .NET Runtime and ASP.NET Core Runtime. Use the standalone Runtime package only when you do not need the SDK. + +The colors can be switched under **Settings** between the Cockpit, macOS, GitHub and Codex styles, each in light and dark. On macOS, the macOS style follows the accent color from System Settings. diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 4e408cd..a86d66c 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -44,6 +44,6 @@ The .NET tool is optional. Use the native download on machines without an SDK. 1. Open **Installations** to inspect the SDKs and runtimes reported by the selected `dotnet` host. 2. Open **.NET Tools** to see global tools and local manifest declarations for the directory where Cockpit was launched. -3. Open **Setup** to manage the supported uninstall tool or download an official macOS SDK or runtime package. +3. Open **Settings** to manage the supported uninstall tool, or **Versions → Install .NET** to download an official macOS SDK or runtime package. Restart Cockpit after changing its inherited `PATH` or installing a new `dotnet` host. diff --git a/docs/Privacy-and-Safety.md b/docs/Privacy-and-Safety.md index ef59cab..381ee96 100644 --- a/docs/Privacy-and-Safety.md +++ b/docs/Privacy-and-Safety.md @@ -13,10 +13,14 @@ Cockpit reads the .NET host selected by its own `PATH`, the output of the standa ## Network requests -The application starts without an internet connection. The Setup page requests release metadata only when it needs current installer or uninstall-tool release information. Installer links target official Microsoft-hosted files; uninstall-tool release discovery uses the official GitHub releases API. +The application starts without an internet connection. The Settings page requests release metadata only when it needs current installer or uninstall-tool release information. Installer links target official Microsoft-hosted files; uninstall-tool release discovery uses the official GitHub releases API. ## Explicit changes Cockpit never changes .NET installations or tool manifests during inventory. Mutations require a local form submission and are scoped to the startup directory. An inherited parent tool manifest is displayed but cannot be removed from Cockpit. Before removing an installation, confirm that it is not needed by another application. Cockpit surfaces the command result, but the underlying platform installer and .NET tooling remain authoritative. + +## Local files + +Cockpit stores your appearance and pre-flight choices in `settings.json` in the `dotnet-cockpit` folder of your user configuration directory. Delete the file to reset them. diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index b31c077..032c9e0 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -146,7 +146,7 @@ await runWithoutRepositoryFiles(process.env, async (data, html, get, url) => { const setupPage = await get('setup'); assert.equal(setupPage.status, 200); const setupHtml = await setupPage.text(); - assert.ok(setupHtml.includes('

Setup

')); + assert.ok(setupHtml.includes('

Settings

')); assert.ok(setupHtml.includes('.NET Uninstall Tool')); assert.ok(setupHtml.includes('learn.microsoft.com/en-us/dotnet/core/additional-tools/uninstall-tool-overview')); const invalidAction = await fetch(new URL('tools/install', url), { diff --git a/src/DotnetCockpit/AppJsonContext.cs b/src/DotnetCockpit/AppJsonContext.cs index 48122ea..5ccf406 100644 --- a/src/DotnetCockpit/AppJsonContext.cs +++ b/src/DotnetCockpit/AppJsonContext.cs @@ -8,6 +8,10 @@ namespace DotnetCockpit; [JsonSerializable(typeof(InstallationSnapshot))] [JsonSerializable(typeof(HealthStatus))] [JsonSerializable(typeof(ToolsSnapshot))] +[JsonSerializable(typeof(ToolSearch))] +[JsonSerializable(typeof(AppSettings))] +[JsonSerializable(typeof(SystemAccentStatus))] internal partial class AppJsonContext : JsonSerializerContext; internal sealed record HealthStatus(string Status); +internal sealed record SystemAccentStatus(string? Accent); diff --git a/src/DotnetCockpit/ConfirmActions.cs b/src/DotnetCockpit/ConfirmActions.cs new file mode 100644 index 0000000..7d7aefd --- /dev/null +++ b/src/DotnetCockpit/ConfirmActions.cs @@ -0,0 +1,242 @@ +using System.Runtime.InteropServices; +using DotnetCockpit.SdkDownloads; +using DotnetCockpit.Installation; +using static DotnetCockpit.PageLayout; + +namespace DotnetCockpit; + +/// +/// Describes one change Cockpit can make. The same description drives the dialog (with JavaScript) and +/// the /confirm page (without it), so both show identical wording and the identical command. +/// +internal sealed record ConfirmSpec( + string Action, + string Title, + string Body, + string Command, + string ConfirmLabel, + string BusyLabel, + string PostUrl, + string CancelHref, + (string Name, string Value)[] Fields, + bool Danger = false, + bool NeedsAdmin = false, + string[]? Items = null); + +internal static class ConfirmActions +{ + private static string UninstallPrefix => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet-core-uninstall" : "sudo dotnet-core-uninstall"; + + public static string AdminNote => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "Windows will ask for administrator approval." + : "macOS will ask for your administrator password."; + + public static string RemovalCommand(string version, string filter) => + $"{UninstallPrefix} remove {version} \\\n {filter} \\\n --yes"; + + public const string YesFlagNote = "The --yes flag skips the tool's own prompt, because you confirm here. You approve once, however many items are selected."; + + public static ConfirmSpec VersionRemove(InstallationPage.Row row, InstallationViewOptions? view = null) => new( + "version-remove", + $"Remove {row.Name} {row.DisplayVersion}?", + $"Projects targeting net{row.Channel}.0 may stop building or running unless another compatible version is installed. {YesFlagNote}", + row.EquivalentCommand ?? "", + $"Remove {row.DisplayVersion}", + $"Removing {row.DisplayVersion}…", + "/installation/remove", + "/", + [("version", row.Version), ("kind", row.Kind), .. ViewFields(view)], + Danger: true, + NeedsAdmin: true); + + public static ConfirmSpec ChannelRemove(string channel, InstallationPage.Row[] rows) + { + var count = rows.Length; + return new( + "channel-remove", + $"Remove .NET {channel}?", + $"Projects targeting net{channel}.0 will no longer build on this machine. {YesFlagNote}", + string.Join("\n\n", rows.Select(row => row.EquivalentCommand)), + $"Remove {count} {(count == 1 ? "item" : "items")}", + $"Removing .NET {channel}…", + "/installation/remove", + "/", + [("channel", channel), .. rows.Select(row => ("items", $"{row.Version}|{row.Kind}"))], + Danger: true, + NeedsAdmin: true, + Items: rows.Select(row => $"{row.Name} {row.DisplayVersion}").ToArray()); + } + + public static ConfirmSpec ToolInstall(string packageId, string version, string scope) => new( + "tool-install", + $"Install {packageId}?", + ToolInstallBody(scope), + $"dotnet tool install --{Scope(scope)} \\\n {packageId} \\\n --version {version}", + "Install tool", + $"Installing {packageId}…", + "/tools/install", + $"/tools?scope={Scope(scope)}", + [("packageId", packageId), ("version", version), ("scope", Scope(scope))]); + + public static string ToolInstallBody(string scope) => scope == "local" + ? "The tool is added to this workspace's manifest." + : "The tool is installed for your user account and works in every project."; + + public static ConfirmSpec ToolUninstall(string packageId, string scope) => new( + "tool-uninstall", + $"Remove {packageId}?", + scope == "local" + ? "The tool is removed from this workspace's manifest." + : "The tool is removed for your user account. Its commands stop working in every project.", + $"dotnet tool uninstall --{Scope(scope)} {packageId}", + "Remove tool", + $"Removing {packageId}…", + "/tools/uninstall", + $"/tools?scope={Scope(scope)}", + [("packageId", packageId), ("scope", Scope(scope))], + Danger: true); + + public static ConfirmSpec ToolUpdate(string packageId, string scope, string? latestVersion) => new( + "tool-update", + $"Update {packageId}{(string.IsNullOrEmpty(latestVersion) ? "" : $" to {latestVersion}")}?", + "The newest version from NuGet.org replaces the installed one.", + $"dotnet tool update --{Scope(scope)} {packageId}", + "Update tool", + $"Updating {packageId}…", + "/tools/update", + $"/tools?scope={Scope(scope)}", + [("packageId", packageId), ("scope", Scope(scope))]); + + public static ConfirmSpec ToolsUpdateAll(string scope, string query, string[] items) + { + var parsed = items.Select(item => item.Split('|', 2)).Where(parts => parts[0].Length > 0).ToArray(); + return new( + "tools-update-all", + $"Update {parsed.Length} {(parsed.Length == 1 ? "tool" : "tools")}?", + "The newest versions from NuGet.org replace the installed ones.", + string.Join("\n", parsed.Select(parts => $"dotnet tool update --{Scope(parts.Length > 1 ? parts[1] : scope)} {parts[0]}")), + $"Update {parsed.Length} {(parsed.Length == 1 ? "tool" : "tools")}", + "Updating tools…", + "/tools/update-all", + $"/tools?scope={scope}", + [("scope", scope), ("q", query), .. items.Select(item => ("items", item))], + Items: parsed.Select(parts => parts[0]).ToArray()); + } + + public static ConfirmSpec ManifestInit(string query) => new( + "manifest-init", + "Initialize local tools?", + "Creates .config/dotnet-tools.json in this workspace, so tools can be installed for this project only.", + "dotnet new tool-manifest --output .config", + "Initialize", + "Initializing…", + "/tools/init", + "/tools?scope=local", + [("scope", "local"), ("q", query)]); + + public static ConfirmSpec ManifestRemove(string query) => new( + "manifest-remove", + "Remove local tools from this workspace?", + "Deletes this workspace's .config/dotnet-tools.json. Cached packages are left untouched and inherited manifests are not affected.", + "", + "Remove local tools", + "Removing local tools…", + "/tools/remove-manifest", + "/tools?scope=local", + [("scope", "local"), ("q", query)], + Danger: true); + + public static ConfirmSpec SetupInstall(string? version, string installPath) => new( + "setup-install", + "Install the .NET Uninstall Tool?", + $"Downloads the official release from github.com/dotnet/cli-lab and installs it to {installPath}.", + "", + "Install", + "Installing…", + "/setup/install", + "/setup", + string.IsNullOrEmpty(version) ? [] : [("version", version)], + NeedsAdmin: true); + + public static ConfirmSpec SetupUpdate(string? latestVersion) => new( + "setup-update", + $"Update the .NET Uninstall Tool{(string.IsNullOrEmpty(latestVersion) ? "" : $" to {latestVersion}")}?", + "Downloads the official release from github.com/dotnet/cli-lab and replaces the installed tool.", + "", + "Update", + "Updating…", + "/setup/update", + "/setup", + [], + NeedsAdmin: true); + + public static ConfirmSpec SetupUninstall() => new( + "setup-uninstall", + "Remove the .NET Uninstall Tool?", + "Cockpit can no longer remove SDKs and runtimes for you until the tool is installed again. Installed .NET versions are not affected.", + "", + "Remove tool", + "Removing…", + "/setup/uninstall", + "/setup", + [], + Danger: true, + NeedsAdmin: true); + + /// A form that opens the dialog with JavaScript and the /confirm page without it. + public static string Trigger(ConfirmSpec spec, string controlsHtml, string formClass = "") => $""" +
{string.Concat(spec.Fields.Select(field => $""))}{controlsHtml}
+ """; + + /// The shared dialog; app.js fills it from the data attributes of the submitted trigger. + public static string Dialog => $""" + +
+
+
{Icon("trash", "sm")}
+
+

+

+
+
+ + + +
+ + +
+
+
+ """; + + /// The same confirmation as a page, for browsers without JavaScript and for links from other pages. + public static string RenderPage(ConfirmSpec spec, int issueCount, int? versionsCount, int? toolsCount) => PageLayout.Render($""" +

Confirm

+
+

{Encode(spec.Title)}

+

{Encode(spec.Body)}

+ {(spec.Items is { Length: > 0 } items ? $"
    {string.Concat(items.Select(item => $"
  • {Encode(item)}
  • "))}
" : "")} + {(spec.NeedsAdmin ? $"
{Icon("lock", "sm")}
{AdminNote}
" : "")} + {(spec.Command.Length > 0 ? $"
${Encode(spec.Command)}
" : "")} +
+ {string.Concat(spec.Fields.Where(field => field.Name != "channel").Select(field => $""))} + Cancel + +
+
+ """, "/confirm", "Confirm | .NET Cockpit", issueCount, versionsCount, toolsCount); + + private static string Scope(string scope) => scope == "local" ? "local" : "global"; + + private static (string, string)[] ViewFields(InstallationViewOptions? view) => view is null + ? [] + : [("viewKind", view.Kind), ("q", view.Query), ("sort", view.Sort), ("direction", view.Ascending ? "asc" : "desc")]; +} diff --git a/src/DotnetCockpit/DotnetCockpit.csproj b/src/DotnetCockpit/DotnetCockpit.csproj index e6f732f..e64cf7c 100644 --- a/src/DotnetCockpit/DotnetCockpit.csproj +++ b/src/DotnetCockpit/DotnetCockpit.csproj @@ -11,7 +11,7 @@ true dotnet-cockpit dotnet-cockpit - 0.1.0 + 0.2.0 Jan Ruhlaender A local browser cockpit for your .NET installations. https://github.com/capjan/dotnet-cockpit diff --git a/src/DotnetCockpit/EnvironmentHealth.cs b/src/DotnetCockpit/EnvironmentHealth.cs new file mode 100644 index 0000000..9656516 --- /dev/null +++ b/src/DotnetCockpit/EnvironmentHealth.cs @@ -0,0 +1,129 @@ +using DotnetCockpit.Installation; +using DotnetCockpit.SdkDownloads; + +namespace DotnetCockpit; + +internal sealed record HealthLink(string Label, string Href, string Class = "button sm", string? Icon = null, bool Download = false); + +internal sealed record HealthAction(string Severity, string Icon, string Title, string Detail, HealthLink[] Links); + +/// The single source for the number shown in the ring, the action list and the navigation badge. +internal sealed record HealthReport(HealthAction[] Actions, bool SupportKnown, string Headline) +{ + public int Count => Actions.Length; + + /// The ring shows the action list in proportion, most severe first, with a hairline between parts. + public string RingGradient + { + get + { + if (Count == 0) return SupportKnown ? "var(--ok)" : "var(--line-strong)"; + string[] order = ["bad", "warn", "info"]; + var parts = order + .Select(severity => (Severity: severity, Share: Actions.Count(action => action.Severity == severity) / (double)Count)) + .Where(part => part.Share > 0) + .ToArray(); + var gap = parts.Length > 1 ? 1.5 : 0; + var start = 0.0; + var stops = new List(); + foreach (var (severity, share) in parts) + { + var end = start + share * 100; + stops.Add(FormattableString.Invariant($"var(--{severity}) {start:0.##}% {end - gap:0.##}%")); + if (gap > 0) stops.Add(FormattableString.Invariant($"transparent {end - gap:0.##}% {end:0.##}%")); + start = end; + } + return $"conic-gradient({string.Join(", ", stops)})"; + } + } +} + +internal static class EnvironmentHealth +{ + public static HealthReport Evaluate(InstallationSnapshot snapshot, SdkDownloadSnapshot? catalog, DateOnly? today = null) + { + if (snapshot.Status == "error") + return new([], false, "The .NET installation could not be read."); + + var currentDate = today ?? SupportLifecycle.Today; + var rows = InstallationPage.BuildRows(snapshot, catalog, currentDate); + var supportKnown = catalog?.Channels is { Length: > 0 }; + var actions = new List(); + + if (snapshot.Sdks.Length == 0) + { + actions.Add(new("warn", "code", "Install a .NET SDK", + snapshot.Status == "notFound" ? "No dotnet host was found on PATH." : "No SDK was found on this machine. An SDK is required to build applications.", + [new("Install .NET", "/?install=1", "button sm primary")])); + } + + var channels = rows.GroupBy(row => row.Channel) + .OrderBy(group => group.First().Support.DaysLeft ?? int.MaxValue) + .ToArray(); + var eol = channels.Where(group => group.First().Support.IsEol).ToArray(); + var endingSoon = channels.Where(group => group.First().Support.IsEndingSoon).ToArray(); + var patchable = channels + .Where(group => group.Any(row => row.HasPatch && !row.LatestPatchInstalled)) + .OrderByDescending(group => group.Key.Length).ThenByDescending(group => group.Key, StringComparer.Ordinal) + .ToArray(); + + foreach (var group in eol) + { + var count = group.Count(); + var support = group.First().Support; + actions.Add(new("bad", "shield", + support.EndDate.Length > 0 ? $".NET {group.Key} has been out of support since {support.EndDate}" : $".NET {group.Key} is out of support", + $"{count} installed {(count == 1 ? "item no longer receives" : "items no longer receive")} security fixes.", + [ + new("Details", $"/?selected={Uri.EscapeDataString(group.First().Version)}#inventory", "button sm ghost"), + .. group.Any(row => row.CanRemove) + ? [new HealthLink($"Remove {count} {(count == 1 ? "item" : "items")}", $"/confirm?action=channel-remove&channel={Uri.EscapeDataString(group.Key)}", "button sm", "trash")] + : Array.Empty() + ])); + } + + foreach (var group in endingSoon) + { + var count = group.Count(); + var support = group.First().Support; + actions.Add(new("warn", "clock", + $".NET {group.Key} support ends {SupportLifecycle.RelativeDays(support.DaysLeft ?? 0)}", + $"{support.EndDate}. {count} installed {(count == 1 ? "item stops" : "items stop")} receiving security fixes after that date.", + [new("Details", $"/?selected={Uri.EscapeDataString(group.First().Version)}#inventory", "button sm ghost")])); + } + + foreach (var group in patchable) + { + var row = group.First(candidate => candidate.HasPatch && !candidate.LatestPatchInstalled); + var product = row.Kind == "sdk" ? "SDK" : "Runtime"; + actions.Add(new("info", "download", + $"Patch available for .NET {group.Key}", + $"{product} {row.DisplayVersion} → {row.LatestPatchVersion}.", + [row.PackageUrl is { Length: > 0 } url + ? new("Download", url, "button sm", "download", Download: true) + : new("Details", $"/?selected={Uri.EscapeDataString(row.Version)}#inventory", "button sm ghost")])); + } + + return new([.. actions], supportKnown, BuildHeadline(snapshot, eol, endingSoon, patchable.Length, supportKnown)); + } + + private static string BuildHeadline(InstallationSnapshot snapshot, IGrouping[] eol, IGrouping[] endingSoon, int patchCount, bool supportKnown) + { + var phrases = new List(); + if (snapshot.Sdks.Length == 0) phrases.Add("No .NET SDK is installed"); + if (eol.Length > 0) + phrases.Add($"{Names(eol)} {(eol.Length == 1 ? "is" : "are")} out of support"); + if (endingSoon.Length > 0) + phrases.Add($"{Names(endingSoon)} support ends {SupportLifecycle.RelativeDays(endingSoon[0].First().Support.DaysLeft ?? 0)}"); + if (patchCount > 0) + phrases.Add($"{patchCount} {(patchCount == 1 ? "patch" : "patches")} available"); + + if (phrases.Count > 0) return string.Join(", ", phrases) + "."; + return supportKnown + ? "All installed versions are supported and up to date." + : "Support status could not be checked because the release catalog is unavailable."; + } + + private static string Names(IEnumerable> groups) => + string.Join(" and ", groups.Select(group => $".NET {group.Key}")); +} diff --git a/src/DotnetCockpit/Installation/DotnetInstallationService.cs b/src/DotnetCockpit/Installation/DotnetInstallationService.cs index 1c4d040..f593e88 100644 --- a/src/DotnetCockpit/Installation/DotnetInstallationService.cs +++ b/src/DotnetCockpit/Installation/DotnetInstallationService.cs @@ -2,8 +2,40 @@ namespace DotnetCockpit.Installation; +/// The SDK the dotnet host resolves for Cockpit's working directory, including a global.json pin. +internal sealed record ActiveSdk(string? Version, string? GlobalJsonPath); + internal sealed class DotnetInstallationService { + public async Task ReadActiveSdkAsync(CancellationToken cancellationToken) + { + var globalJson = FindGlobalJson(Environment.CurrentDirectory); + var executable = DotnetLocator.Find(Environment.GetEnvironmentVariable("PATH")); + if (executable is null) return new(null, globalJson); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(15)); + try + { + var output = await DotnetCommand.RunAsync(executable, ["--version"], timeout.Token); + return new(output.Trim(), globalJson); + } + catch (Exception exception) when (IsQueryFailure(exception) && !cancellationToken.IsCancellationRequested) + { + return new(null, globalJson); + } + } + + private static string? FindGlobalJson(string directory) + { + for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent) + { + var candidate = Path.Combine(current.FullName, "global.json"); + if (File.Exists(candidate)) return candidate; + } + return null; + } + public async Task ReadAsync(CancellationToken cancellationToken) { var executable = DotnetLocator.Find(Environment.GetEnvironmentVariable("PATH")); diff --git a/src/DotnetCockpit/Installation/InstallationSnapshot.cs b/src/DotnetCockpit/Installation/InstallationSnapshot.cs index 32de2e3..1462a0a 100644 --- a/src/DotnetCockpit/Installation/InstallationSnapshot.cs +++ b/src/DotnetCockpit/Installation/InstallationSnapshot.cs @@ -29,6 +29,15 @@ internal sealed record InstallationSnapshot( RuntimeInstallation[] Runtimes, string? Error = null) { + /// One row per SDK and one per runtime version, because frameworks sharing a version are grouped. + [JsonIgnore] + public int InstallationCount => Sdks.Length + Runtimes.Select(runtime => runtime.Version).Distinct().Count(); + + /// Changes whenever a version appears or disappears, so an open page can notice without polling. + public string Signature => string.Join('|', Sdks.Select(sdk => $"sdk:{sdk.Version}") + .Concat(Runtimes.Select(runtime => $"{runtime.Name}:{runtime.Version}")) + .Order(StringComparer.Ordinal)); + public static InstallationSnapshot Create( string status, string? executablePath, SdkInstallation[]? sdks = null, RuntimeInstallation[]? runtimes = null, string? error = null) => diff --git a/src/DotnetCockpit/InstallationPage.cs b/src/DotnetCockpit/InstallationPage.cs index cfb6ef5..abd9c01 100644 --- a/src/DotnetCockpit/InstallationPage.cs +++ b/src/DotnetCockpit/InstallationPage.cs @@ -1,38 +1,89 @@ using System.Globalization; +using System.Net; using DotnetCockpit.Installation; +using DotnetCockpit.SdkDownloads; +using DotnetCockpit.Setup; using static DotnetCockpit.PageLayout; +using static DotnetCockpit.SdkDownloadsSection; namespace DotnetCockpit; -internal sealed record InstallationViewOptions(string Query = "", string Kind = "all", string Sort = "version", bool Ascending = false, string Notice = "") +internal sealed record InstallationViewOptions(string Query = "", string Kind = "all", string Sort = "version", bool Ascending = false, string Notice = "", string Selected = "", bool OpenInstall = false, string InstallProduct = "SDK", string InstallView = "latest", bool InstallPreviews = false, string InstallQuery = "") { - public static InstallationViewOptions From(IQueryCollection query) => new( - query["q"].ToString(), - query["kind"].ToString() is "sdks" or "runtimes" ? query["kind"].ToString() : "all", - query["sort"].ToString() is "name" or "path" ? query["sort"].ToString() : "version", - query["direction"] == "asc", - query["notice"].ToString()); + public static InstallationViewOptions From(IQueryCollection query) + { + var kind = query["kind"].ToString() is "sdks" or "runtimes" or "previews" ? query["kind"].ToString() : "all"; + var openInstall = query["install"].ToString() is "1" or "true" or "open" || query["tab"].ToString() is "all-releases" or "all"; + + return new( + query["q"].ToString(), + kind, + query["sort"].ToString() is "name" or "path" ? query["sort"].ToString() : "version", + query["direction"] == "asc", + query["notice"].ToString(), + query["selected"].ToString(), + openInstall, + query["product"].ToString() == "runtime" ? ".NET Runtime" : "SDK", + query["view"].ToString() is "supported" or "lts" or "all" ? query["view"].ToString() : "latest", + query["previews"].ToString() == "true", + query["installQ"].ToString().Trim()); + } } internal static class InstallationPage { - private sealed record Row(string Kind, string Name, string Description, string Version, IReadOnlyList Paths, bool CanRemove) + internal sealed record Row( + string Kind, + string Name, + string Description, + string Version, + string DisplayVersion, + IReadOnlyList Paths, + bool CanRemove, + string Channel, + SupportStatus Support, + bool IsPreview, + string? LatestPatchVersion = null, + bool LatestPatchInstalled = false, + string? PackageVersion = null, + string? PackageDate = null, + string? PackageRuntime = null, + string? PackageUrl = null, + string? PackageSha512 = null, + IReadOnlyList? Cves = null, + string? IncludedRuntime = null, + string? ReleasedDate = null, + string? EquivalentCommand = null) { public string Path => string.Join(" ", Paths); + public bool IsLts => Support.IsLts; + public bool IsEol => Support.IsEol; + public bool HasPatch => !IsEol && !string.IsNullOrEmpty(LatestPatchVersion) && LatestPatchVersion != Version && LatestPatchVersion != DisplayVersion; + public bool HasPackageInfo => !string.IsNullOrEmpty(PackageUrl) || !string.IsNullOrEmpty(PackageSha512); } private static readonly IComparer VersionComparer = Comparer.Create((left, right) => CultureInfo.InvariantCulture.CompareInfo.Compare(left, right, CompareOptions.IgnoreCase | CompareOptions.NumericOrdering)); - public static string Render(InstallationSnapshot snapshot, InstallationViewOptions options) + public static string Render(InstallationSnapshot snapshot, InstallationViewOptions options) => + Render(snapshot, null, options); + + public static string Render(InstallationSnapshot snapshot, SdkDownloadSnapshot? sdkSnapshot, InstallationViewOptions options, DateOnly? today = null, int? navTools = null) { - var allRows = BuildRows(snapshot); + var currentDate = today ?? SupportLifecycle.Today; + var health = EnvironmentHealth.Evaluate(snapshot, sdkSnapshot, currentDate); + var allRows = BuildRows(snapshot, sdkSnapshot, currentDate); var rows = FilterRows(allRows, options); var total = allRows.Length; var runtimeCount = allRows.Count(row => row.Kind == "runtime"); var inventoryDescription = snapshot.ExecutablePath is null ? "Versions reported by dotnet-core-uninstall." : "The .NET installation available on your PATH."; + + var selectedRow = !string.IsNullOrWhiteSpace(options.Selected) + ? allRows.FirstOrDefault(r => r.Version == options.Selected || r.DisplayVersion == options.Selected) + : null; + var content = snapshot.Status switch { "notFound" => """ @@ -40,128 +91,955 @@ public static string Render(InstallationSnapshot snapshot, InstallationViewOptio """, "error" => $"

Installation information is unavailable

{Encode(snapshot.Error ?? "The installation could not be read.")}

Try again
", _ when total == 0 => $"

No SDKs or runtimes found

{(snapshot.ExecutablePath is null ? "dotnet-core-uninstall reported no removable SDKs or runtimes." : "The dotnet host reported no installed SDKs or runtimes.")}

", - _ => RenderTable(rows, options) + _ => RenderVersionsHub(allRows, rows, options, selectedRow) }; + var sdkCount = allRows.Count(row => row.Kind == "sdk"); + var isFiltered = options.Kind != "all" || !string.IsNullOrWhiteSpace(options.Query); + var filterNavHtml = $""" +
+ {KindSegment("All", "all", total, options, " versions")} + {KindSegment("SDKs", "sdks", sdkCount, options)} + {KindSegment("Runtimes", "runtimes", runtimeCount, options)} +
+ Previews + """; + + var hasSuperseded = allRows + .Where(r => r.CanRemove) + .GroupBy(r => (r.Channel, r.Kind)) + .Any(g => g.Count() > 1); + var hasRemovablePreviews = allRows.Any(r => r.CanRemove && r.IsPreview); + var hasRemovableEol = allRows.Any(r => r.CanRemove && r.IsEol); + var canSmartCleanup = hasSuperseded || hasRemovablePreviews || hasRemovableEol; + var defaultCleanupMode = hasSuperseded + ? "superseded" + : hasRemovablePreviews + ? "previews" + : "eol"; + return PageLayout.Render($$""" -
-

Your .NET environment

Installed SDKs and runtimes, in one place.

- {{Icon("refresh")}} Refresh +
+

Versions

+

Installed SDKs and runtimes, organised by release channel.

+
+
+ {{(canSmartCleanup ? $"" : "")}} + {{Icon("download", "xs")}} Install .NET +
{{RenderNotice(options.Notice)}} -
- {{SummaryCard("Installed SDKs", snapshot.Sdks.Length.ToString(CultureInfo.InvariantCulture), "Build and develop applications", "code")}} - {{SummaryCard("Installed runtimes", runtimeCount.ToString(CultureInfo.InvariantCulture), "Run .NET applications", "server")}} - {{SummaryCard("This machine", snapshot.Architecture.ToUpperInvariant(), snapshot.OperatingSystem, "desktop")}} -
+
+ Installed runtimes + {{runtimeCount}} +
+
-

Installed versions

{{inventoryDescription}}

{{total}} installed
-
-
- {{(snapshot.ExecutablePath is null ? "" : $"
dotnet host{Encode(snapshot.ExecutablePath)}
")}} + {{RenderInstallDotnetSheet(sdkSnapshot ?? new("ready", []), snapshot, options, currentDate)}}

Locations include the version subfolder. When dotnet-core-uninstall is installed, only versions it can remove are shown; runtime frameworks that share a version are grouped together. Separate installations and other architectures may not appear here.

- """, "/"); + """, "/", ".NET Cockpit", health.Count, total, navTools, WebUtility.HtmlDecode(RefreshLink(options, snapshot.CheckedAt)), snapshot.Signature); + } + + private static string RenderVersionsHub(Row[] allRows, Row[] rows, InstallationViewOptions options, Row? selectedRow) + { + var showActions = allRows.Any(row => row.CanRemove); + + // Preserve the sorted order of `rows` from FilterRows when grouping by channel + var channelGroups = rows + .GroupBy(r => r.Channel) + .ToArray(); + + var tableBody = string.Concat(channelGroups.Select(group => + { + var channel = group.Key; + var support = group.First().Support; + var isEol = support.IsEol; + var isPreviewChannel = support.State == SupportState.Preview || group.All(r => r.IsPreview); + var lifeTextClass = isEol ? "bad" : support.IsEndingSoon ? "warn" : ""; + var tagClass = isPreviewChannel ? "preview" : (support.IsLts ? "lts" : "sts"); + var tagLabel = isPreviewChannel ? "Preview" : support.ReleaseType; + + var removeAll = group.Any(r => r.CanRemove) + ? $"Remove all" + : ""; + + var headerRow = $""" + + +
+ +
+ .NET {channel} + {(tagLabel.Length > 0 ? $"{tagLabel}" : "")} +
+ {Encode(support.Label)} + {removeAll} +
+ + + """; + + var rowsHtml = string.Concat(group.Select(row => + { + var isSelected = selectedRow?.Version == row.Version; + var fullVersion = $"{Encode(row.Version)}"; + var versionCell = row.HasPatch + ? $"""
{fullVersion}{Encode(row.DisplayVersion)}, patch available: {Encode(row.LatestPatchVersion!)}
""" + : row.IsEol || row.Support.State == SupportState.Unknown + ? $"""
{fullVersion}{Encode(row.DisplayVersion)}
""" + : $"""
{fullVersion}{Encode(row.DisplayVersion)}{Icon("check", "xs")}Latest patch
"""; + + var primaryPath = row.Paths.FirstOrDefault() ?? ""; + var extraPaths = row.Paths.Count > 1 ? $" {Encode(string.Join(" ", row.Paths.Skip(1)))}" : ""; + return $""" + + {Encode(row.Name)}{Encode(ShortFrameworkSubtitle(row.Description))} {Encode(row.Description)} + {Encode(primaryPath)}{extraPaths} + {versionCell} + + Inspect + {RemoveAction(row, options)} + + + """; + })); + + var cardState = isEol ? "eol" : support.IsEndingSoon ? "ending" : "ok"; + return $""" + + {headerRow} + {rowsHtml} + + """; + })); + + var emptyRow = $"""

No matching versions

Try another filter or switch to All versions.

Clear filters
"""; + + var drawerHtml = RenderDrawer(selectedRow, options, allRows); + var jsonDictionary = SerializeRowsJson(allRows); + var multiRemoveForm = showActions + ? $""" + + """ + : ""; + + return $$""" +
+
+
+ + + {{tableBody}} + + {{emptyRow}} + +
InstallationLocationVersionActions
+
+
+ {{drawerHtml}} +
+ +
+
+
{{Icon("trash", "sm")}}
+
+

Remove .NET version from this machine?

+

1 item selected for removal.

+
+
+ +
+
+ {{Icon("lock", "sm")}} +
+ {{ConfirmActions.AdminNote}} +

Removal runs through Microsoft's dotnet-core-uninstall. {{ConfirmActions.YesFlagNote}}

+
+
+
+ $ + sudo dotnet-core-uninstall remove + +
+ {{multiRemoveForm}} +
+ + +
+
+
+ + """; + } + + /// One server-rendered detail panel per row. Without JavaScript the selected one is rendered directly; with it, app.js swaps them in from templates. + private static string RenderDrawer(Row? selected, InstallationViewOptions options, Row[] allRows) + { + var templates = string.Concat(allRows.Select(row => + $"")); + return $""" + + {templates} + """; + } + + private static string RenderDrawerContent(Row row, InstallationViewOptions options, Row[] allRows) + { + var support = row.Support; + var channelType = row.IsPreview + ? "Preview release" + : support.IsLts + ? "Long Term Support" + : support.ReleaseType == "STS" + ? "Standard Term Support" + : "Support type unavailable"; + + var badgeHtml = row.IsEol + ? "End of life" + : row.HasPatch + ? "Patch available" + : ""; + + var hasSdkInChannel = allRows.Any(r => r.Channel == row.Channel && r.Kind == "sdk"); + var patchAlertText = row.Kind == "runtime" && hasSdkInChannel + ? $"A newer update in the .NET {row.Channel} channel includes security and reliability fixes. The matching SDK package updates this runtime too." + : $"A newer update in the .NET {row.Channel} channel includes security and reliability fixes."; + + var supportedChannel = allRows + .Where(r => r.Channel != row.Channel && r.Support.State == SupportState.Supported && !r.IsPreview) + .OrderByDescending(r => r.Version, SdkVersionComparer.Instance) + .FirstOrDefault(); + var upgradeHint = supportedChannel is null + ? "We recommend moving to a supported release." + : $".NET {supportedChannel.Channel} is already installed and supported."; + + var alertHtml = row.IsEol + ? $""" +
+ .NET {row.Channel} no longer receives security fixes +

{Encode(support.Label)}. {Encode(upgradeHint)}

+
+ """ + : support.IsEndingSoon + ? $""" +
+ .NET {row.Channel} support ends {SupportLifecycle.RelativeDays(support.DaysLeft ?? 0)} +

No more security fixes after {Encode(support.EndDate)}. {Encode(upgradeHint)}

+
+ """ + : row.HasPatch && row.LatestPatchInstalled + ? $""" +
+ Newer release {Encode(row.LatestPatchVersion!)} is already installed +

You can remove {Encode(row.DisplayVersion)} if no global.json pins it.

+
+ """ + : row.HasPatch + ? $""" +
+ Newer release {Encode(row.LatestPatchVersion!)} available +

{Encode(patchAlertText)}

+
+ """ + : $"""

{Icon("check", "xs")} {Encode(support.State == SupportState.Unknown ? "Patch and support status unavailable" : $"Latest patch installed. {support.Label}.")}

"""; + + var pkgTitle = row.HasPatch ? "Newest patch" : "Release package & security"; + var pkgVer = row.PackageVersion ?? row.DisplayVersion; + var pkgDate = row.PackageDate ?? row.ReleasedDate; + var pkgRuntime = row.PackageRuntime is { Length: > 0 } packageRuntime ? $"Runtime {ShortDisplayVersion(packageRuntime)}" : ""; + var pkgMeta = string.Join(" · ", new[] { pkgDate, pkgRuntime }.Where(value => !string.IsNullOrEmpty(value))); + var cveBadgeHtml = row.Cves is { Count: > 0 } + ? $"{row.Cves.Count} security {(row.Cves.Count == 1 ? "fix" : "fixes")}" + : ""; + var cvePillsHtml = string.Concat((row.Cves ?? []).Take(6).Select(c => + $"{Encode(c.Id)}")); + + var patchSection = row.HasPackageInfo + ? $""" +
+
+

{pkgTitle}

+
+ {Encode(pkgVer)} + {Encode(pkgMeta)} +
+ {(cveBadgeHtml.Length + cvePillsHtml.Length > 0 ? $"
{cveBadgeHtml}{cvePillsHtml}
" : "")} +
+ {Icon("package", "xs")} {Encode(GetInstallerFileName(row.PackageUrl))} · SHA-512 {Encode(ShortSha(row.PackageSha512))} + +
+
+
+ """ + : ""; + + var command = row.EquivalentCommand ?? ""; + var needsPatchDownload = row.HasPatch && !row.LatestPatchInstalled; + var downloadBtn = string.IsNullOrEmpty(row.PackageUrl) + ? "" + : needsPatchDownload + ? $"{Icon("download", "sm")} Download {Encode(row.LatestPatchVersion!)}" + : $"{Icon("download", "sm")} Download again"; + + var removeAction = row.CanRemove + ? ConfirmActions.Trigger(ConfirmActions.VersionRemove(row, options), $"") + : ""; + + var runtimeLabel = row.Kind == "sdk" ? "Included runtime" : "Frameworks"; + var runtimeValue = row.IncludedRuntime ?? ShortFrameworkSubtitle(row.Description); + var location = row.Paths.FirstOrDefault(); + + var drawerTitleHtml = row.Version.StartsWith(row.DisplayVersion, StringComparison.Ordinal) && row.Version.Length > row.DisplayVersion.Length + ? $"{Encode(row.DisplayVersion)}{Encode(row.Version[row.DisplayVersion.Length..])}" + : Encode(row.Version); + + return $$""" +
+
+ .NET {{row.Channel}} · {{(row.Kind == "sdk" ? "SDK" : "Runtime")}} + {{Icon("x", "sm")}} +
+
+

{{drawerTitleHtml}}

+ {{badgeHtml}} +
+
+
+ {{alertHtml}} +
+

Installed

+
+
Channel
+
{{row.Channel}}.0 · {{channelType}}
+
{{runtimeLabel}}
+
{{Encode(runtimeValue)}}
+ {{(row.ReleasedDate is { Length: > 0 } released ? $"
Released
{Encode(released)}
" : "")}} + {{(location is null ? "" : $$""" +
Location
+
+
+ {{Encode(location)}} + +
+
+ """)}} +
+
+ {{patchSection}} + {{(row.CanRemove ? $$""" +
+

Command to remove this version

+
+ $ + {{Encode(command)}} + +
+
+ """ : "")}} +
+ {{(downloadBtn.Length + removeAction.Length > 0 ? $"
{downloadBtn}{removeAction}
" : "")}} + """; + } + + private static string RenderInstallDotnetSheet(SdkDownloadSnapshot sdkSnapshot, InstallationSnapshot installation, InstallationViewOptions options, DateOnly today) + { + var open = options.OpenInstall; + string SheetLink(string? product = null, string? view = null, bool? previews = null) + { + var targetProduct = product ?? options.InstallProduct; + var targetView = view ?? options.InstallView; + var targetPreviews = previews ?? options.InstallPreviews; + var query = options.InstallQuery.Length > 0 ? $"&installQ={Uri.EscapeDataString(options.InstallQuery)}" : ""; + return Encode($"/?install=1&product={(targetProduct == "SDK" ? "sdk" : "runtime")}&view={targetView}&previews={(targetPreviews ? "true" : "false")}{query}"); + } + string ProductSegment(string product, string label) => + $"{label}"; + string ViewSegment(string view, string label) => + $"{label}"; + + static bool IsDownloadEol(SdkDownload download, DateOnly date) => + string.Equals(download.SupportPhase, "EOL", StringComparison.OrdinalIgnoreCase) || + SupportLifecycle.ParseDate(download.EndOfSupportDate) is { } end && end < date; + + var installedSdks = installation.Sdks + .SelectMany(s => new[] { s.Version, ShortDisplayVersion(s.Version) }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var installedRuntimes = installation.Runtimes + .SelectMany(r => new[] { r.Version, ShortDisplayVersion(r.Version) }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var downloads = sdkSnapshot.Downloads ?? []; + const int releasesPerChannel = 8; + var channelLeaders = downloads + .GroupBy(d => (d.Product, d.Channel)) + .Select(g => g.OrderByDescending(d => d.Version, SdkVersionComparer.Instance).First()) + .ToHashSet(); + var latestStableLeaders = downloads + .Where(d => !d.Version.Contains("-preview", StringComparison.OrdinalIgnoreCase) && !d.Version.Contains("-rc", StringComparison.OrdinalIgnoreCase)) + .GroupBy(d => (d.Product, d.Channel)) + .Select(g => g.OrderByDescending(d => d.Version, SdkVersionComparer.Instance).First()) + .ToHashSet(); + + var curated = downloads + .GroupBy(d => (d.Product, d.Channel)) + .SelectMany(g => g + .OrderByDescending(d => d.Version, SdkVersionComparer.Instance) + .Take(releasesPerChannel)) + .OrderByDescending(d => int.TryParse(d.Channel.Split('.')[0], out var ch) ? ch : 0) + .ThenByDescending(d => d.Version, SdkVersionComparer.Instance) + .ToArray(); + + + string resultsHtml; + if (sdkSnapshot.Status != "ready" || curated.Length == 0) + { + resultsHtml = $""" +
+

Release catalog unavailable

+

{Encode(sdkSnapshot.Error ?? "Could not load official Microsoft release metadata right now.")}

+
+ """; + } + else + { + var channelGroups = curated + .GroupBy(d => d.Channel.Split('.')[0]) + .OrderByDescending(g => int.TryParse(g.Key, out var ch) ? ch : 0) + .ToArray(); + + var totalVisible = 0; + var groupsHtml = string.Concat(channelGroups.Select(channelGroup => + { + var major = channelGroup.Key; + var orderedItems = channelGroup + .OrderByDescending(d => d.Version, SdkVersionComparer.Instance) + .ToArray(); + var sample = orderedItems[0]; + var chIsPreviewOnly = orderedItems.All(d => + d.Version.Contains("-preview", StringComparison.OrdinalIgnoreCase) || + d.Version.Contains("-rc", StringComparison.OrdinalIgnoreCase)); + var chIsEol = orderedItems.All(d => IsDownloadEol(d, today)); + var chIsLts = orderedItems.Any(d => string.Equals(d.ReleaseType, "LTS", StringComparison.OrdinalIgnoreCase)); + var chTagClass = chIsPreviewOnly ? "preview" : (chIsLts ? "lts" : "sts"); + var chTagLabel = chIsPreviewOnly ? "Preview" : (chIsLts ? "LTS" : "STS"); + var chPhaseBadge = chIsEol ? "End of life" : ""; + + var initialVisibleCount = 0; + var rowsHtml = string.Concat(orderedItems.Select(d => + { + var isPreview = d.Version.Contains("-preview", StringComparison.OrdinalIgnoreCase) || d.Version.Contains("-rc", StringComparison.OrdinalIgnoreCase); + var isEol = IsDownloadEol(d, today); + var isLts = string.Equals(d.ReleaseType, "LTS", StringComparison.OrdinalIgnoreCase); + static string tagLabelFor(bool preview, bool lts) => preview ? "Preview" : lts ? "LTS" : "STS"; + var isInstalled = d.Product == "SDK" + ? installedSdks.Contains(d.Version) || installedSdks.Contains(d.InstallationVersion) + : installedRuntimes.Contains(d.Version) || installedRuntimes.Contains(d.InstallationVersion); + var isLeader = channelLeaders.Contains(d) || latestStableLeaders.Contains(d); + var searchText = $"{d.Product} {d.Version} .NET {major} {d.Channel} {tagLabelFor(isPreview, isLts)} {string.Join(' ', d.Cves.Select(c => c.Id))}"; + var matchesView = options.InstallView switch + { + "latest" => !isEol && isLeader, + "supported" => !isEol, + "lts" => !isEol && isLts, + _ => true + }; + var matchesQuery = options.InstallQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries).All(term => searchText.Contains(term, StringComparison.OrdinalIgnoreCase)); + var initiallyHidden = d.Product != options.InstallProduct || !matchesView || (isPreview && !options.InstallPreviews) || !matchesQuery; + if (!initiallyHidden) + { + initialVisibleCount++; + totalVisible++; + } + + var tagClass = isPreview ? "preview" : (isLts ? "lts" : "sts"); + var tagLabel = isPreview ? "Preview" : (isLts ? "LTS" : "STS"); + var statusBadge = isInstalled + ? $"{Icon("check", "xs")} Installed" + : isEol + ? "End of life" + : ""; + + var relDate = FormatReleaseDate(d.ReleaseDate) ?? d.ReleaseDate; + var inclRt = ShortDisplayVersion(d.IncludedRuntimeVersion ?? $"{major}.0"); + var subtitle = d.Product == "SDK" + ? $"Includes .NET Runtime {inclRt} · Released {relDate}" + : $"Shared framework · Released {relDate}"; + + var fileName = GetInstallerFileName(d.Url); + var shortSha = ShortSha(d.Sha512); + var verifyCmd = FormatVerifyInstallCommand(d.Sha512, fileName); + + var cveMeta = d.Cves.Length > 0 + ? $"{Icon("shield", "xs")} {d.Cves.Length} {(d.Cves.Length == 1 ? "CVE fix" : "CVE fixes")}" + : ""; + + return $""" +
c.Id))}")}" + {(initiallyHidden ? "hidden" : "")}> +
+
+ .NET {Encode(d.Product == "SDK" ? "SDK" : "Runtime")} {Encode(d.Version)} + {tagLabel} + {statusBadge} +
+ {Encode(subtitle)} +
+ {(string.IsNullOrEmpty(shortSha) ? "" : $"SHA-512 {Encode(shortSha)}")} + {cveMeta} +
+
+ Install command +
+ $ + {Encode(verifyCmd)} + +
+
+
+ +
+ """; + })); + + int Available(string product) => downloads.Count(d => d.Product == product && d.Channel.Split('.')[0] == major); + var available = Available(options.InstallProduct); + var countLabel = initialVisibleCount >= releasesPerChannel && available > initialVisibleCount + ? $"Newest {initialVisibleCount} of {available}" + : initialVisibleCount == 1 ? "1 release" : $"{initialVisibleCount} releases"; + return $""" +
+ + {Icon("chevd", "xs")} +
+ .NET {Encode(major)} + {chTagLabel} + {chPhaseBadge} +
+ {countLabel} +
+
+ {rowsHtml} +
+
+ """; + })); + + resultsHtml = $""" + {groupsHtml} +
0 ? "hidden" : "")}> +

No matching releases

+

Try another search, or choose Everything above.

+
+ """; + } + + return $$""" + +
+
+
+

Install .NET

+ builds.dotnet.microsoft.com +
+ {{Icon("x", "sm")}} +
+ +
+
+ + + + + + +
+
+
+ {{ProductSegment("SDK", "SDK")}}{{ProductSegment(".NET Runtime", "Runtime")}} +
+
+ {{ViewSegment("latest", "Latest")}}{{ViewSegment("supported", "All patches")}}{{ViewSegment("lts", "LTS")}}{{ViewSegment("all", "Everything")}} +
+ Previews +
+
+ +
+ {{resultsHtml}} +
+ +
+
+ """; } - private static Row[] BuildRows(InstallationSnapshot snapshot) + /// Only what the removal dialog needs. Details live in the server-rendered drawer templates. + private static string SerializeRowsJson(Row[] rows) + { + var newest = rows + .GroupBy(row => (row.Channel, row.Kind)) + .Select(group => group.OrderByDescending(row => row.Version, SdkVersionComparer.Instance).First()) + .ToHashSet(); + var entries = rows.Where(row => row.CanRemove).Select(row => + $"{{\"version\": \"{EscapeJson(row.Version)}\"," + + $"\"displayVersion\": \"{EscapeJson(row.DisplayVersion)}\"," + + $"\"kind\": \"{row.Kind}\"," + + $"\"name\": \"{EscapeJson(row.Name)}\"," + + $"\"channel\": \"{EscapeJson(row.Channel)}\"," + + $"\"isEol\": {(row.IsEol ? "true" : "false")}," + + $"\"isPreview\": {(row.IsPreview ? "true" : "false")}," + + $"\"isSuperseded\": {(newest.Contains(row) ? "false" : "true")}," + + $"\"location\": \"{EscapeJson(row.Paths.FirstOrDefault() ?? "")}\"," + + $"\"command\": \"{EscapeJson(row.EquivalentCommand ?? "")}\"}}"); + return "[" + string.Join(",\n", entries) + "]"; + } + + private static string EscapeJson(string value) => + value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("<", "\\u003c").Replace(">", "\\u003e").Replace("\r", "").Replace("\n", "\\n"); + + internal static Row[] BuildRows(InstallationSnapshot snapshot, SdkDownloadSnapshot? sdkSnapshot, DateOnly today) { + var downloads = sdkSnapshot?.Downloads ?? []; + var installedSdkSet = snapshot.Sdks + .SelectMany(s => new[] { s.Version, ShortDisplayVersion(s.Version) }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var installedRuntimeSet = snapshot.Runtimes + .SelectMany(r => new[] { r.Version, ShortDisplayVersion(r.Version) }) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var sdkRows = snapshot.Sdks.Select(sdk => { - var path = sdk.InstallationPath; - return new Row("sdk", ".NET SDK", "Software development kit", sdk.Version, - string.IsNullOrWhiteSpace(path) ? [] : [path], sdk.CanRemove); + var major = sdk.Version.Split('.')[0]; + var support = SupportLifecycle.For(sdkSnapshot, sdk.Version, today); + var isEol = support.IsEol; + var isPreview = sdk.Version.Contains("-preview", StringComparison.OrdinalIgnoreCase) || sdk.Version.Contains("-rc", StringComparison.OrdinalIgnoreCase); + var shortVer = ShortDisplayVersion(sdk.Version); + + var catalogEntry = downloads.FirstOrDefault(d => d.Product == "SDK" && (d.Version == sdk.Version || d.InstallationVersion == sdk.Version || d.Version == shortVer)); + + var channelDownloads = downloads + .Where(d => d.Product == "SDK" && (d.Channel == major || d.Channel == $"{major}.0") && (isPreview || (!d.Version.Contains("-preview") && !d.Version.Contains("-rc")))) + .OrderByDescending(d => d.Version, SdkVersionComparer.Instance) + .ToArray(); + var latest = channelDownloads.FirstOrDefault(); + + var bandPrefix = GetSdkFeatureBandPrefix(shortVer); + var latestInBand = !string.IsNullOrEmpty(bandPrefix) + ? channelDownloads.FirstOrDefault(d => GetSdkFeatureBandPrefix(d.Version) == bandPrefix) + : null; + + // An SDK in an active feature band (e.g. 10.0.114 in 10.0.1xx) is up-to-date if it is the newest in its band + // and includes the same latest runtime as the channel's newest SDK. + var isCurrentFeatureBandLatest = latestInBand != null && + (latestInBand.Version == sdk.Version || latestInBand.Version == shortVer) && + !string.IsNullOrEmpty(catalogEntry?.IncludedRuntimeVersion) && + catalogEntry.IncludedRuntimeVersion == latest?.IncludedRuntimeVersion; + + var patchTarget = (latestInBand != null && latestInBand.IncludedRuntimeVersion == latest?.IncludedRuntimeVersion) + ? latestInBand + : latest; + + var hasNewerPatch = !isEol && !isCurrentFeatureBandLatest && patchTarget != null && + patchTarget.Version != sdk.Version && patchTarget.InstallationVersion != sdk.Version && + SdkVersionComparer.Instance.Compare(patchTarget.Version, shortVer) > 0; + + var patchAlreadyInstalled = hasNewerPatch && patchTarget != null && + (installedSdkSet.Contains(patchTarget.Version) || installedSdkSet.Contains(patchTarget.InstallationVersion)); + + var targetPkg = (hasNewerPatch ? patchTarget : null) ?? catalogEntry ?? latest; + var inclRt = ShortDisplayVersion(catalogEntry?.IncludedRuntimeVersion ?? InferRuntimeVersion(shortVer, snapshot.Runtimes, major)); + + var cmd = ConfirmActions.RemovalCommand(sdk.Version, "--sdk"); + + return new Row( + Kind: "sdk", + Name: ".NET SDK", + Description: $"incl. Runtime {inclRt}", + Version: sdk.Version, + DisplayVersion: shortVer, + Paths: string.IsNullOrWhiteSpace(sdk.InstallationPath) ? [] : [sdk.InstallationPath], + CanRemove: sdk.CanRemove, + Channel: major, + Support: support, + IsPreview: isPreview, + LatestPatchVersion: hasNewerPatch ? patchTarget!.Version : null, + LatestPatchInstalled: patchAlreadyInstalled, + PackageVersion: targetPkg?.Version ?? shortVer, + PackageDate: FormatReleaseDate(targetPkg?.ReleaseDate), + PackageRuntime: targetPkg?.IncludedRuntimeVersion ?? inclRt, + PackageUrl: targetPkg?.Url, + PackageSha512: targetPkg?.Sha512, + Cves: targetPkg?.Cves ?? [], + IncludedRuntime: $"{inclRt} + ASP.NET Core", + ReleasedDate: FormatReleaseDate(catalogEntry?.ReleaseDate), + EquivalentCommand: cmd); }); + var runtimeRows = snapshot.Runtimes - .GroupBy(runtime => runtime.Version, StringComparer.OrdinalIgnoreCase) + .GroupBy(runtime => runtime.Version) .Select(group => { var runtimes = group.ToArray(); - var names = runtimes.Select(runtime => RuntimeName(runtime.Name)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - var descriptions = runtimes.Select(runtime => runtime.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - var paths = runtimes.Select(runtime => runtime.InstallationPath) - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - return new Row("runtime", names.Length == 1 ? names[0] : ".NET Runtime", string.Join(" · ", descriptions), group.Key, paths, runtimes.Any(runtime => runtime.CanRemove)); + var sample = runtimes[0]; + var major = sample.Version.Split('.')[0]; + var support = SupportLifecycle.For(sdkSnapshot, sample.Version, today); + var isEol = support.IsEol; + var isPreview = sample.Version.Contains("-preview", StringComparison.OrdinalIgnoreCase) || sample.Version.Contains("-rc", StringComparison.OrdinalIgnoreCase); + var shortVer = ShortDisplayVersion(sample.Version); + + var catalogEntry = downloads.FirstOrDefault(d => d.Product == ".NET Runtime" && (d.Version == sample.Version || d.InstallationVersion == sample.Version || d.Version == shortVer)); + + var channelDownloads = downloads + .Where(d => d.Product == ".NET Runtime" && (d.Channel == major || d.Channel == $"{major}.0") && (isPreview || (!d.Version.Contains("-preview") && !d.Version.Contains("-rc")))) + .OrderByDescending(d => d.Version, SdkVersionComparer.Instance) + .ToArray(); + var latest = channelDownloads.FirstOrDefault(); + var hasNewerPatch = !isEol && latest != null && latest.Version != sample.Version && latest.InstallationVersion != sample.Version && SdkVersionComparer.Instance.Compare(latest.Version, shortVer) > 0; + var patchAlreadyInstalled = hasNewerPatch && latest != null && + (installedRuntimeSet.Contains(latest.Version) || installedRuntimeSet.Contains(latest.InstallationVersion)); + var targetPkg = (hasNewerPatch ? latest : null) ?? catalogEntry ?? latest; + + var cmd = string.Join("\n\n", runtimes.Select(runtime => DotnetUninstallToolService.RuntimeFilter(runtime.Name)).Distinct(StringComparer.Ordinal).Select(filter => ConfirmActions.RemovalCommand(sample.Version, filter))); + var description = string.Join(" · ", runtimes.Select(runtime => runtime.Name)); + + return new Row( + Kind: "runtime", + Name: runtimes.Length == 1 ? RuntimeName(sample.Name) : ".NET Runtime", + Description: description, + Version: sample.Version, + DisplayVersion: shortVer, + Paths: runtimes.Select(runtime => runtime.InstallationPath).Where(p => !string.IsNullOrWhiteSpace(p)).Distinct().ToArray(), + CanRemove: runtimes.Any(runtime => runtime.CanRemove), + Channel: major, + Support: support, + IsPreview: isPreview, + LatestPatchVersion: hasNewerPatch ? latest!.Version : null, + LatestPatchInstalled: patchAlreadyInstalled, + PackageVersion: targetPkg?.Version ?? shortVer, + PackageDate: FormatReleaseDate(targetPkg?.ReleaseDate), + PackageRuntime: targetPkg?.Version, + PackageUrl: targetPkg?.Url, + PackageSha512: targetPkg?.Sha512, + Cves: targetPkg?.Cves ?? [], + IncludedRuntime: ShortFrameworkSubtitle(description), + ReleasedDate: FormatReleaseDate(catalogEntry?.ReleaseDate), + EquivalentCommand: cmd); }); - return sdkRows.Concat(runtimeRows).ToArray(); + + return [.. sdkRows, .. runtimeRows]; + } + + internal static string GetSdkFeatureBandPrefix(string version) + { + if (string.IsNullOrWhiteSpace(version) || version.Contains('-')) return ""; + var parts = version.Split('.'); + if (parts.Length < 3 || parts[2].Length < 3) return ""; + return $"{parts[0]}.{parts[1]}.{parts[2][0]}"; + } + + private static string InferRuntimeVersion(string sdkShortVer, RuntimeInstallation[] runtimes, string major) + { + if (!sdkShortVer.Contains('-')) + { + var parts = sdkShortVer.Split('.'); + if (parts.Length >= 3 && parts[2].Length == 3 && int.TryParse(parts[2][1..], out var patchNum)) + { + var candidate = $"{parts[0]}.{parts[1]}.{patchNum}"; + if (runtimes.Any(r => r.Version == candidate) || parts[2][0] == '1') + return candidate; + } + } + return runtimes + .Where(r => r.Version.StartsWith($"{major}.", StringComparison.Ordinal)) + .Select(r => ShortDisplayVersion(r.Version)) + .OrderByDescending(v => v, SdkVersionComparer.Instance) + .FirstOrDefault() ?? $"{major}.0"; + } + + internal static string ShortDisplayVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) return ""; + var dashIdx = version.IndexOf('-'); + if (dashIdx < 0) return version; + var core = version[..dashIdx]; + var pre = version[(dashIdx + 1)..]; + var parts = pre.Split('.'); + if (parts.Length > 2 && (parts[0].Equals("preview", StringComparison.OrdinalIgnoreCase) || parts[0].Equals("rc", StringComparison.OrdinalIgnoreCase))) + return $"{core}-{parts[0]}.{parts[1]}"; + return version; + } + + /// "11.0.100-preview.7" → "11.0.100-rc.3" reads better as "rc.3" once the shared core is on screen. + private static string ShortPatchTarget(string current, string target) + { + var dash = target.IndexOf('-'); + return dash > 0 && current.StartsWith(target[..dash], StringComparison.Ordinal) ? target[(dash + 1)..] : target; } + private static string ShortSha(string? sha) => + string.IsNullOrEmpty(sha) + ? "" + : sha.Length > 12 + ? $"{sha[..4]}…{sha[^4..]}" + : sha; + private static Row[] FilterRows(Row[] allRows, InstallationViewOptions options) { - var rows = allRows.AsEnumerable(); - var terms = options.Query.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - rows = rows.Where(row => (options.Kind == "all" || (options.Kind == "sdks" ? row.Kind == "sdk" : row.Kind == "runtime")) && - terms.All(term => $"{row.Name} {row.Description} {row.Version} {row.Path}".Contains(term, StringComparison.OrdinalIgnoreCase))); - Func key = options.Sort switch { "name" => row => row.Name, "path" => row => row.Path, _ => row => row.Version }; - var comparer = options.Sort == "version" ? VersionComparer : StringComparer.OrdinalIgnoreCase; - return (options.Ascending ? rows.OrderBy(key, comparer) : rows.OrderByDescending(key, comparer)) - .ThenBy(row => row.Name, StringComparer.OrdinalIgnoreCase).ToArray(); + var filtered = allRows.Where(row => MatchesKind(row, options.Kind) && MatchesQuery(row, options.Query)); + return (options.Sort switch + { + "name" => options.Ascending ? filtered.OrderBy(row => row.Name, StringComparer.OrdinalIgnoreCase).ThenBy(row => row.Version, VersionComparer) : filtered.OrderByDescending(row => row.Name, StringComparer.OrdinalIgnoreCase).ThenByDescending(row => row.Version, VersionComparer), + "path" => options.Ascending ? filtered.OrderBy(row => row.Path, StringComparer.OrdinalIgnoreCase).ThenBy(row => row.Version, VersionComparer) : filtered.OrderByDescending(row => row.Path, StringComparer.OrdinalIgnoreCase).ThenByDescending(row => row.Version, VersionComparer), + _ => options.Ascending ? filtered.OrderBy(row => row.Version, VersionComparer) : filtered.OrderByDescending(row => row.Version, VersionComparer) + }).ToArray(); } - private static string RenderTable(Row[] rows, InstallationViewOptions options) + private static bool MatchesKind(Row row, string kind) => kind switch { - var showActions = rows.Any(row => row.CanRemove); - return $$""" -
- - {{SortHeader("Installation", "name", options)}}{{SortHeader("Version", "version", options)}}{{SortHeader("Location", "path", options)}}{{(showActions ? "" : "")}} - {{string.Concat(rows.Select(row => $""" - - - - - {(showActions ? $"" : "")} - - """))}} -
Actions
{Icon(row.Kind == "sdk" ? "code" : "server")}
{Encode(row.Name)}{Encode(row.Description)}
{Encode(row.Version)}
{string.Concat(row.Paths.Select(path => $"{Encode(path)}"))}
{RemoveAction(row, options)}
- {{(rows.Length == 0 ? "

No matching versions

Try another filter or switch to All versions.

Clear filters
" : "")}} -
- """; + "sdks" => row.Kind == "sdk", + "runtimes" => row.Kind == "runtime", + "previews" => row.IsPreview, + _ => true + }; + + private static bool MatchesQuery(Row row, string query) + { + if (string.IsNullOrWhiteSpace(query)) return true; + var terms = query.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + var searchable = $"{row.Name} {row.Description} {row.Version} {row.DisplayVersion} {row.Path} {row.Channel}"; + return terms.All(term => searchable.Contains(term, StringComparison.OrdinalIgnoreCase)); } - private static string SummaryCard(string label, string value, string description, string icon) => - $"
{Encode(label)}{Icon(icon)}
{Encode(value)}{Encode(description)}
"; + private static string ShortFrameworkSubtitle(string description) => + description + .Replace("Microsoft.NETCore.App", ".NET", StringComparison.Ordinal) + .Replace("Microsoft.AspNetCore.App", "ASP.NET Core", StringComparison.Ordinal) + .Replace("Microsoft.WindowsDesktop.App", "Desktop", StringComparison.Ordinal); + + private static string GetInstallerFileName(string? url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) ? System.IO.Path.GetFileName(uri.LocalPath) : ""; + + private static string FormatVerifyInstallCommand(string? sha512, string fileName) => + !string.IsNullOrEmpty(sha512) + ? $"echo \"{sha512} {fileName}\" \\\n | shasum -a 512 -c - \\\n && sudo installer -pkg {fileName} \\\n -target /" + : $"sudo installer -pkg {fileName} \\\n -target /"; + + private static string? FormatReleaseDate(string? dateStr) + { + if (string.IsNullOrWhiteSpace(dateStr)) return null; + if (DateTimeOffset.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) + return dt.ToString("MMM d, yyyy", CultureInfo.InvariantCulture); + return dateStr; + } private static string RemoveAction(Row row, InstallationViewOptions options) => !row.CanRemove ? "" - : $"
"; + : ConfirmActions.Trigger(ConfirmActions.VersionRemove(row, options), $""); private static string RenderNotice(string notice) => notice switch { - "removed" => "
The selected .NET installation was removed.
", - "remove-failed" => "
The selected .NET installation could not be removed. Check the permissions and try again.
", + "removed" => SuccessToast("The selected .NET installation was removed."), + "detected" => SuccessToast("Installed .NET versions changed. This view is up to date."), + "remove-failed" => "
Not everything selected could be removed. The administrator approval may have been declined. Try again, or run the command yourself.
", _ => "" }; - private static string TypeLink(string label, string kind, InstallationViewOptions options) => - $"{label}"; + private static string KindSegment(string label, string kind, int count, InstallationViewOptions options, string srSuffix = "") + { + var active = options.Kind == kind; + var target = options with { Kind = active ? "all" : kind }; + return $"{label}{(srSuffix.Length > 0 ? $"{srSuffix}" : "")}{count}"; + } - private static string SortHeader(string label, string column, InstallationViewOptions options) + private static string SortMenu(InstallationViewOptions options) { - var active = options.Sort == column; - var direction = active ? options.Ascending ? "ascending" : "descending" : "none"; - return $"{label}{(active ? options.Ascending ? " ↑" : " ↓" : "")}"; + var current = (options.Sort, options.Ascending) switch + { + ("name", false) => "Name, Z to A", + ("name", true) => "Name, A to Z", + ("path", false) => "Location, Z to A", + ("path", true) => "Location, A to Z", + (_, true) => "Oldest version", + _ => "Newest version" + }; + string Option(string label, string sort, bool ascending) => + $"{label}"; + return $""" + + """; } private static string RefreshLink(InstallationViewOptions options, DateTimeOffset checkedAt) => Encode( - $"/?q={Uri.EscapeDataString(options.Query)}&kind={options.Kind}&sort={options.Sort}&direction={(options.Ascending ? "asc" : "desc")}&refresh={checkedAt.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)}#inventory"); + $"/?q={Uri.EscapeDataString(options.Query)}&kind={options.Kind}&sort={options.Sort}&direction={(options.Ascending ? "asc" : "desc")}&refresh={checkedAt.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture)}"); private static string Link(InstallationViewOptions options) => Encode( - $"/?q={Uri.EscapeDataString(options.Query)}&kind={options.Kind}&sort={options.Sort}&direction={(options.Ascending ? "asc" : "desc")}#inventory"); + $"/?q={Uri.EscapeDataString(options.Query)}&kind={options.Kind}&sort={options.Sort}&direction={(options.Ascending ? "asc" : "desc")}{(string.IsNullOrEmpty(options.Selected) ? "" : $"&selected={Uri.EscapeDataString(options.Selected)}")}"); private static string RuntimeName(string name) => name switch { diff --git a/src/DotnetCockpit/OverviewPage.cs b/src/DotnetCockpit/OverviewPage.cs new file mode 100644 index 0000000..c400a7b --- /dev/null +++ b/src/DotnetCockpit/OverviewPage.cs @@ -0,0 +1,300 @@ +using System.Globalization; +using DotnetCockpit.Installation; +using DotnetCockpit.SdkDownloads; +using DotnetCockpit.Setup; +using DotnetCockpit.Tools; +using static DotnetCockpit.PageLayout; + +namespace DotnetCockpit; + +internal static class OverviewPage +{ + public static string Render(InstallationSnapshot snapshot, ToolsSnapshot tools, SetupSnapshot setup, SdkDownloadSnapshot sdkSnapshot, ActiveSdk? activeSdk = null, DateOnly? today = null) + { + var currentDate = today ?? SupportLifecycle.Today; + var health = EnvironmentHealth.Evaluate(snapshot, sdkSnapshot, currentDate); + var rows = InstallationPage.BuildRows(snapshot, sdkSnapshot, currentDate); + var runtimeCount = snapshot.Runtimes.Select(r => r.Version).Distinct().Count(); + var issues = health.Count; + var eolRuntimes = rows.Count(row => row.Kind == "runtime" && row.IsEol); + var patchableSdks = rows.Count(row => row.Kind == "sdk" && row.HasPatch && !row.LatestPatchInstalled); + var isHealthy = issues == 0 && health.SupportKnown; + + var newestStableSdk = snapshot.Sdks + .Where(sdk => !sdk.Version.Contains('-')) + .OrderByDescending(sdk => sdk.Version, SdkDownloadsSection.SdkVersionComparer.Instance) + .FirstOrDefault()?.Version; + + var healthBadgeClass = isHealthy ? "ok" : "warn"; + var healthStatus = snapshot.Status == "error" + ? "Not available" + : isHealthy ? "Healthy" : issues == 0 ? "Partly checked" : "Attention needed"; + + var actions = health.Actions.Select(action => $""" +
+
{Icon(action.Icon, "sm")}
+
+ {Encode(action.Title)} +

{Encode(action.Detail)}

+
+
+ {string.Concat(action.Links.Select(link => $"{(link.Icon is null ? "" : Icon(link.Icon, "xs"))} {Encode(link.Label)}"))} +
+
+ """).ToArray(); + var actionsHtml = actions.Length > 0 + ? string.Join("\n", actions) + : $"""
{Icon("check", "sm")}
{(health.SupportKnown ? "Nothing to do" : "No known actions")}

{(health.SupportKnown ? "Every installed version is supported and fully patched." : "Support dates could not be loaded, so end-of-support risks are not listed. Refresh to try again.")}

"""; + + var sdkBadge = patchableSdks > 0 + ? $"{patchableSdks} {(patchableSdks == 1 ? "patch" : "patches")}" + : ""; + + var runtimeBadge = eolRuntimes > 0 + ? $"{eolRuntimes} end of life" + : ""; + + var toolsAvailable = tools.Status == "ready"; + string ToolCount(ToolList list) => toolsAvailable && list.Error is null ? list.Tools.Length.ToString(CultureInfo.InvariantCulture) : "–"; + var toolsNote = toolsAvailable ? null : "Not available"; + + var sdkInUse = activeSdk?.Version is { Length: > 0 } activeVersion + ? $"{Encode(activeVersion)} · {(activeSdk.GlobalJsonPath is null ? "no global.json" : $"pinned by {Encode(ShortenPath(activeSdk.GlobalJsonPath))}")}" + : "Not available"; + + var manifestSummary = !tools.LocalManifestInitialized + ? "No local manifest" + : tools.LocalManifestOwned + ? "Local in .config/dotnet-tools.json" + : $"Inherited from parent .config"; + + var localToolPills = tools.Local.Tools.Length > 0 + ? string.Concat(tools.Local.Tools.Select(t => $"{Encode(t.PackageId)} {Encode(t.Version)}")) + : "None declared"; + + return PageLayout.Render($$""" +
+
+

Overview

+

Here's the state of .NET on this machine.

+
+ +
+ +
+
+
+
+ {{issues}} + to review +
+
+
+
+ System Health + {{healthStatus}} +
+

{{Encode(health.Headline)}}

+
+ {{Icon("cpu", "xs")}} {{Encode(snapshot.OperatingSystem)}} · {{Encode(snapshot.Architecture.ToLowerInvariant())}} + {{Icon("code", "xs")}} {{(snapshot.ExecutablePath is { } hostPath ? $"{Encode(hostPath)}" : "No dotnet host on PATH")}} + {{(newestStableSdk is null ? "" : $"{Icon("shield", "xs")} Newest stable SDK {Encode(newestStableSdk)}")}} +
+
+
+ +
+
+
{{Icon("versions", "xs")}} Installed SDKs
+
+ {{snapshot.Sdks.Length.ToString(CultureInfo.InvariantCulture)}} + {{sdkBadge}} +
+
+
+
{{Icon("server", "xs")}} Installed runtimes
+
+ {{runtimeCount.ToString(CultureInfo.InvariantCulture)}} + {{runtimeBadge}} +
+
+
+
{{Icon("tools", "xs")}} Global tools
+
+ {{ToolCount(tools.Global)}} + {{Encode(toolsNote ?? $"for {Environment.UserName}")}} +
+
+
+
{{Icon("folder", "xs")}} Local tools
+
+ {{ToolCount(tools.Local)}} + {{Encode(toolsNote ?? "this workspace")}} +
+
+
+
+ +
+
+
+
+

Recommended actions

+

Sorted by risk. Every change asks for confirmation first.

+
+
+
+ {{actionsHtml}} +
+
+ +
+
+
+
{{Icon("folder", "sm")}}
+
+

This workspace

+ {{Encode(ShortenPath(tools.WorkingDirectory))}} +
+
+
+
SDK in use
+
{{sdkInUse}}
+
Tool manifest
+
{{manifestSummary}}
+
Local tools
+
{{localToolPills}}
+
+
+ + +
+
+ +
+
+
+

Support Lifecycle Timeline

+

Official Microsoft lifecycle dates. Ringed bars are installed, pale bars are available to install.

+
+
+ LTS + STS + Preview +
+
+ {{RenderTimeline(snapshot, sdkSnapshot, currentDate)}} +
+ """, "/overview", ".NET Cockpit · Overview", issues, snapshot.InstallationCount, toolsAvailable ? tools.Global.Tools.Length + tools.Local.Tools.Length : null, inventorySignature: snapshot.Signature); + } + + private static string RenderTimeline(InstallationSnapshot snapshot, SdkDownloadSnapshot sdkSnapshot, DateOnly today) + { + var channels = sdkSnapshot.Channels ?? []; + if (channels.Length == 0) + return """

Support dates are unavailable

The official release metadata could not be loaded. Refresh to try again.

"""; + + var installedVersions = snapshot.Sdks.Select(sdk => sdk.Version) + .Concat(snapshot.Runtimes.Select(runtime => runtime.Version)) + .ToArray(); + var installed = installedVersions.Select(SupportLifecycle.ChannelVersion).ToHashSet(); + var newestSdkByChannel = snapshot.Sdks + .GroupBy(sdk => SupportLifecycle.ChannelVersion(sdk.Version)) + .ToDictionary(group => group.Key, group => group.OrderByDescending(sdk => sdk.Version, SdkDownloadsSection.SdkVersionComparer.Instance).First().Version); + + var visible = channels + .Select(channel => (Channel: channel, Status: SupportLifecycle.Evaluate(channel, today))) + .Where(entry => (!entry.Status.IsEol || installed.Contains(entry.Channel.Channel)) && entry.Channel.FirstRelease is not null) + .OrderByDescending(entry => entry.Channel.FirstRelease) + .ToArray(); + if (visible.Length == 0) + return """

Support dates are unavailable

The release metadata contains no dates for these channels.

"""; + + var startYear = visible.Min(entry => entry.Channel.FirstRelease!.Value.Year); + var endYear = Math.Max(visible.Max(entry => (entry.Channel.EndOfSupport ?? today).Year), today.Year) + 1; + var rangeStart = new DateOnly(startYear, 1, 1).DayNumber; + var rangeDays = (double)(new DateOnly(endYear, 1, 1).DayNumber - rangeStart); + string Percent(DateOnly date) => (Math.Clamp((date.DayNumber - rangeStart) / rangeDays, 0, 1) * 100).ToString("0.##", CultureInfo.InvariantCulture); + var todayFraction = ((today.DayNumber - rangeStart) / rangeDays).ToString("0.####", CultureInfo.InvariantCulture); + + var years = string.Concat(Enumerable.Range(startYear, endYear - startYear).Select(year => $"{year}")); + var rows = string.Concat(visible.Select(entry => + { + var (channel, status) = entry; + var major = channel.Channel.EndsWith(".0", StringComparison.Ordinal) ? channel.Channel[..^2] : channel.Channel; + var isPreview = status.State == SupportState.Preview; + var isInstalled = installed.Contains(channel.Channel); + var tag = isPreview ? "Preview" : channel.ReleaseType; + var tagClass = isPreview ? "preview" : channel.ReleaseType.ToLowerInvariant(); + var barClass = status.IsEol ? "eol" : isPreview ? "preview" : tagClass; + var left = channel.FirstRelease!.Value; + var right = channel.EndOfSupport ?? today; + var width = Math.Max(Math.Clamp((right.DayNumber - left.DayNumber) / rangeDays, 0, 1) * 100, 1).ToString("0.##", CultureInfo.InvariantCulture); + var barLabel = newestSdkByChannel.TryGetValue(channel.Channel, out var sdk) + ? $"{InstallationPage.ShortDisplayVersion(sdk)} installed" + : isInstalled ? "Runtime installed" : ""; + var (statusClass, statusText) = status.State switch + { + SupportState.EndOfLife => ("bad", channel.EndOfSupport is { } end ? $"ended {end.Year}" : "ended"), + SupportState.EndingSoon => ("warn", $"ends {SupportLifecycle.RelativeDays(status.DaysLeft ?? 0)}"), + SupportState.Preview => ("muted", "preview"), + _ => ("muted", channel.EndOfSupport is { } until ? $"until {until.ToString("MMM yyyy", CultureInfo.InvariantCulture)}" : "supported") + }; + return $""" +
+
+ .NET {Encode(major)} + {Encode(tag)} +
+
+
{Encode(barLabel)}
+
+ {Encode(statusText)} +
+ """; + })); + + return $""" +
+
+ +
{years}
+ +
+
+
+ Today +
+ {rows} +
+
+ """; + } + + private static string ShortenPath(string path) + { + if (string.IsNullOrEmpty(path)) return ""; + var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length <= 3) return path; + return "../" + string.Join("/", parts[^3..]); + } +} diff --git a/src/DotnetCockpit/PageLayout.cs b/src/DotnetCockpit/PageLayout.cs index 020efb1..5a220fe 100644 --- a/src/DotnetCockpit/PageLayout.cs +++ b/src/DotnetCockpit/PageLayout.cs @@ -4,57 +4,242 @@ namespace DotnetCockpit; internal static class PageLayout { - public static string Render(string content, string currentPath, string title = ".NET Cockpit") => $$""" + /// Set at startup from the settings file. Tests and tools that render pages directly get the defaults. + public static Func CurrentSettings { get; set; } = () => new(); + + /// Set at startup. Rendered as an attribute only where the system has an accent color. + public static Func CurrentAccent { get; set; } = () => null; + + public static string AccentAttribute => CurrentAccent() is { } accent ? $" data-accent=\"{accent}\"" : ""; + + private static string WorkspaceName => + Path.GetFileName(Environment.CurrentDirectory.TrimEnd(Path.DirectorySeparatorChar)) is { Length: > 0 } name ? name : Environment.CurrentDirectory; + + public static string Render(string content, string currentPath, string title = ".NET Cockpit", int issueCount = 0, int? versionsCount = null, int? toolsCount = null, string? refreshHref = null, string? inventorySignature = null) => $$""" - + + {{Encode(title)}} - + -
-
.NET Cockpit
-
- Local workspace - -
-
-
-