diff --git a/docs/server/features/admin-ui.md b/docs/server/features/admin-ui.md index 90f8d73cb02..06c7d3ee901 100644 --- a/docs/server/features/admin-ui.md +++ b/docs/server/features/admin-ui.md @@ -135,4 +135,10 @@ The _Scavenges_ page lists [scavenge](../operations/scavenge.md) operations and - **License**: Shows the license status of the cluster node. - **Database Info**: Lets you set a friendly database name and a production flag, both shown in the top bar (the production flag adds a PRODUCTION warning). -- **Navigator**: Links to the [Kurrent Navigator](https://navigator.kurrent.io/) app, including its feature-comparison table. + +## Tools + +The _Tools_ section of the sidebar links out to the tools that work alongside the server, rather than to pages of this UI. + +- **Navigator**: Opens [Kurrent Navigator](https://navigator.kurrent.io/) on the node you are viewing, if it is installed. The link carries no credentials, so Navigator asks you to sign in. If Navigator is not installed, its download page opens instead. +- **Gaffer**: Opens [Gaffer](https://gaffer.kurrent.io/), the toolkit for authoring, debugging, testing and deploying projections. The _Projections_ pages link to it as well. diff --git a/src/KurrentDB.Components.Tests/GafferLinkTests.cs b/src/KurrentDB.Components.Tests/GafferLinkTests.cs new file mode 100644 index 00000000000..c6fd1e43c48 --- /dev/null +++ b/src/KurrentDB.Components.Tests/GafferLinkTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using KurrentDB.Components.Shared; +using Xunit; + +namespace KurrentDB.Components.Tests; + +public class GafferLinkTests { + [Fact] + public void Carries_the_shared_attribution_and_the_placement() { + var url = GafferLink.For("projections_list"); + + Assert.StartsWith("https://gaffer.kurrent.io/?", url); + Assert.Contains("utm_source=embedded-ui", url); + Assert.Contains("utm_medium=referral", url); + Assert.Contains("utm_campaign=projections", url); // the default + Assert.Contains("utm_content=projections_list", url); + } + + [Fact] + public void Campaign_can_be_overridden_for_a_surface_off_the_projections_pages() => + Assert.Contains("utm_campaign=tools&utm_content=sidebar", GafferLink.For("sidebar", campaign: "tools")); + + // Reserved characters would otherwise split the query string and truncate the attribution silently. + [Fact] + public void Reserved_characters_are_escaped_rather_than_ending_the_parameter() { + var url = GafferLink.For("a&b=c d", campaign: "x&y"); + + Assert.Contains("utm_campaign=x%26y", url); + Assert.Contains("utm_content=a%26b%3Dc%20d", url); + Assert.EndsWith("utm_content=a%26b%3Dc%20d", url); // nothing leaked into a new parameter + } +} diff --git a/src/KurrentDB.Components.Tests/NavigatorLinkTests.cs b/src/KurrentDB.Components.Tests/NavigatorLinkTests.cs new file mode 100644 index 00000000000..727660a49ec --- /dev/null +++ b/src/KurrentDB.Components.Tests/NavigatorLinkTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using KurrentDB.Components.Shared; +using Xunit; + +namespace KurrentDB.Components.Tests; + +// The sidebar hands this string to the OS to launch Navigator, so a wrong scheme or a missing tls flag +// surfaces as "the app opened and then failed to connect", with nothing in this UI to explain why. +public class NavigatorLinkTests { + [Fact] + public void Single_node_uses_the_plain_scheme() => + Assert.Equal("kurrentdb://db.example.com:2113", + NavigatorLink.DeepLink("https://db.example.com:2113/", memberCount: 1)); + + // Gossip hasn't reported yet: treat it as a single node rather than sending Navigator through discovery. + [Fact] + public void No_known_members_uses_the_plain_scheme() => + Assert.Equal("kurrentdb://db.example.com:2113", + NavigatorLink.DeepLink("https://db.example.com:2113/", memberCount: 0)); + + [Fact] + public void Cluster_uses_the_discover_scheme() => + Assert.Equal("kurrentdb+discover://db.example.com:2113", + NavigatorLink.DeepLink("https://db.example.com:2113/", memberCount: 3)); + + // An http node is running insecure; without this Navigator would attempt TLS and fail to connect. + [Fact] + public void Insecure_node_carries_tls_false() => + Assert.Equal("kurrentdb://localhost:2113?tls=false", + NavigatorLink.DeepLink("http://localhost:2113/", memberCount: 1)); + + [Fact] + public void Insecure_cluster_carries_tls_false() => + Assert.Equal("kurrentdb+discover://localhost:2113?tls=false", + NavigatorLink.DeepLink("http://localhost:2113/", memberCount: 2)); + + // The port is always explicit, including when it is the scheme default, so Navigator never has to guess. + [Fact] + public void Default_port_is_still_explicit() => + Assert.Equal("kurrentdb://db.example.com:443", + NavigatorLink.DeepLink("https://db.example.com/", memberCount: 1)); + + [Fact] + public void Fallback_is_the_download_page_with_attribution() { + Assert.StartsWith("https://navigator.kurrent.io/", NavigatorLink.Fallback); + Assert.Contains("utm_source=embedded-ui", NavigatorLink.Fallback); + } +} diff --git a/src/KurrentDB.Components.Tests/ProjectionsGafferRibbonTests.cs b/src/KurrentDB.Components.Tests/ProjectionsGafferRibbonTests.cs new file mode 100644 index 00000000000..c9daf611fd4 --- /dev/null +++ b/src/KurrentDB.Components.Tests/ProjectionsGafferRibbonTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using System.Security.Claims; +using System.Threading.Tasks; +using Bunit; +using EventStore.Plugins.Authorization; +using KurrentDB.Components.Cluster; +using KurrentDB.Components.Projections; +using KurrentDB.Components.Tests.TestUtilities; +using KurrentDB.Core.Authorization; +using KurrentDB.Core.Bus; +using KurrentDB.Projections.Core.Messages; +using KurrentDB.Projections.Core.Services; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +// The component class name collides with its namespace; alias the type. +using ProjectionsPage = KurrentDB.Components.Projections.Projections; + +namespace KurrentDB.Components.Tests; + +// The list-page ribbon is unconditional on the working page: it shows regardless of what the grid holds, and +// stays out of the "not enabled" / "go to the leader" states. The detail-page link is user projections only. +public class ProjectionsGafferRibbonTests { + const string RibbonText = "author, debug, test and deploy projections"; + const string DetailLinkText = "Debug and deploy projections"; + + static Task AuthState(string name) => + Task.FromResult(new AuthenticationState( + new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, name)], authenticationType: "Test")))); + + static ProjectionStatistics Projection(string name) => + new() { Name = name, Status = "Running", Mode = ProjectionMode.Continuous, Progress = 100 }; + + // ProjectionsService is hand-built because "projections disabled" is a null publisher (see + // ProjectionsService.Available) and the container won't supply null. GossipMonitor needs an IPublisher of + // its own, hence the stand-in; unstarted, its CurrentState stays null, which is the state that renders + // the grid rather than the leader notice. + static BunitContext PageContext(IPublisher projections) => + MudBunit.NewContext(services => { + services.AddSingleton(projections ?? new ReplyPublisher(_ => { })); + services.AddSingleton(new PassthroughAuthorizationProvider()); + services.AddScoped(sp => new ProjectionsService(projections, sp.GetRequiredService())); + services.AddSingleton(); + }); + + static IPublisher StatsPublisher(params ProjectionStatistics[] projections) => + new ReplyPublisher(msg => { + if (msg is ProjectionManagementMessage.Command.GetStatistics q) + q.Envelope.ReplyWith(new ProjectionManagementMessage.Statistics(projections)); + }); + + // The detail page reads stats, then the source query, then the state. Every reply is supplied so no read + // falls through to its 5s timeout. + static IPublisher DetailPublisher(string name, string query) => + new ReplyPublisher(msg => { + switch (msg) { + case ProjectionManagementMessage.Command.GetStatistics s: + s.Envelope.ReplyWith(new ProjectionManagementMessage.Statistics([Projection(name)])); + break; + case ProjectionManagementMessage.Command.GetQuery q: + q.Envelope.ReplyWith(new ProjectionManagementMessage.ProjectionQuery( + name, query, emitEnabled: false, projectionType: "JS", trackEmittedStreams: false, + checkpointsEnabled: true, definition: null, outputConfig: null)); + break; + case ProjectionManagementMessage.Command.GetState st: + st.Envelope.ReplyWith(new ProjectionManagementMessage.ProjectionState( + name, partition: "", state: "{}", position: null)); + break; + } + }); + + [Fact] + public async Task Ribbon_shows_when_the_grid_has_only_system_projections() { + await using var ctx = PageContext(StatsPublisher(Projection("$by_category"), Projection("$streams"))); + + var cut = ctx.Render(p => p.AddCascadingValue(AuthState("admin"))); + + cut.WaitForAssertion(() => { + Assert.Contains("$by_category", cut.Markup); + Assert.Contains(RibbonText, cut.Markup); + }); + } + + [Fact] + public async Task Ribbon_still_shows_once_user_projections_exist() { + await using var ctx = PageContext(StatsPublisher(Projection("order-totals"))); + + var cut = ctx.Render(p => p.AddCascadingValue(AuthState("admin"))); + + cut.WaitForAssertion(() => { + Assert.Contains("order-totals", cut.Markup); + Assert.Contains(RibbonText, cut.Markup); + }); + } + + [Fact] + public async Task Ribbon_is_absent_when_projections_are_disabled() { + await using var ctx = PageContext(projections: null!); + + var cut = ctx.Render(p => p.AddCascadingValue(AuthState("admin"))); + + cut.WaitForAssertion(() => { + Assert.Contains("Projections are not enabled on this server", cut.Markup); + Assert.DoesNotContain(RibbonText, cut.Markup); + }); + } + + [Fact] + public async Task Detail_offers_the_link_for_a_user_projection() { + await using var ctx = PageContext(DetailPublisher("order-totals", "fromStream('orders')")); + + var cut = ctx.Render(p => p + .Add(d => d.Name, "order-totals") + .AddCascadingValue(AuthState("admin"))); + + cut.WaitForAssertion(() => { + Assert.Contains("fromStream", cut.Markup); + Assert.Contains(DetailLinkText, cut.Markup); + }); + } + + // System projections ship with the server, so there is nothing to author locally and no link to offer. + [Fact] + public async Task Detail_withholds_the_link_for_a_system_projection() { + await using var ctx = PageContext(DetailPublisher("$by_category", "fromAll()")); + + var cut = ctx.Render(p => p + .Add(d => d.Name, "$by_category") + .AddCascadingValue(AuthState("admin"))); + + cut.WaitForAssertion(() => { + Assert.Contains("fromAll", cut.Markup); // the Source section did render + Assert.DoesNotContain(DetailLinkText, cut.Markup); + }); + } +} diff --git a/src/KurrentDB/Components/App.razor b/src/KurrentDB/Components/App.razor index 758efce8a92..56cf3e9987f 100644 --- a/src/KurrentDB/Components/App.razor +++ b/src/KurrentDB/Components/App.razor @@ -54,6 +54,29 @@ document.documentElement.classList.toggle("theme-dark", m !== "light"); } }; + + // Hand a kurrentdb:// connection string to the OS so an installed Navigator opens on this node. No + // browser API reports whether a protocol handler ran, so the page losing focus is taken as the signal + // that something claimed the scheme; still focused after the timeout means nothing did, and we offer the + // download instead. The timeout stays short so the fallback still counts as gesture-driven to popup + // blockers. A handler dialog the user then cancels also blurs us, so no fallback opens in that case - + // which is right, since the app is evidently installed. + window.kurrentNavigator = { + open: (deepLink, fallbackUrl) => { + let handedOff = false; + const claimed = () => { handedOff = true; }; + document.addEventListener("visibilitychange", claimed); + window.addEventListener("blur", claimed); + + location.href = deepLink; + + setTimeout(() => { + document.removeEventListener("visibilitychange", claimed); + window.removeEventListener("blur", claimed); + if (!handedOff) window.open(fallbackUrl, "_blank", "noopener"); + }, 1000); + } + }; diff --git a/src/KurrentDB/Components/Layout/NavMenu.razor b/src/KurrentDB/Components/Layout/NavMenu.razor index c93842a26b0..94cc8a3272f 100644 --- a/src/KurrentDB/Components/Layout/NavMenu.razor +++ b/src/KurrentDB/Components/Layout/NavMenu.razor @@ -1,7 +1,12 @@ @* Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. *@ @* Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). *@ +@using KurrentDB.Components.Cluster +@using KurrentDB.Components.Shared @using KurrentDB.UI.Theme +@inject IJSRuntime JS +@inject NavigationManager Navigation +@inject GossipMonitor Gossip Monitoring @@ -36,6 +41,17 @@ Tools - Navigator + Navigator + Gaffer + +@code { + + Task OpenNavigator() => + JS.InvokeVoidAsync("kurrentNavigator.open", + NavigatorLink.DeepLink(Navigation.BaseUri, Gossip.Members.Count), + NavigatorLink.Fallback).AsTask(); + +} diff --git a/src/KurrentDB/Components/Pages/Navigator.razor b/src/KurrentDB/Components/Pages/Navigator.razor deleted file mode 100644 index d44be05fff4..00000000000 --- a/src/KurrentDB/Components/Pages/Navigator.razor +++ /dev/null @@ -1,19 +0,0 @@ -@* Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. *@ -@* Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). *@ - -@page "/ui/navigator" -@using Microsoft.AspNetCore.Authorization -@attribute [Authorize] - -KurrentDB: Navigator - - - Kurrent Navigator is a replacement for the legacy admin UI of the database. -
- Learn more and download here -
- -
- -@code { -} diff --git a/src/KurrentDB/Components/Projections/ProjectionDetail.razor b/src/KurrentDB/Components/Projections/ProjectionDetail.razor index ef4a29dcae3..c7c2f847574 100644 --- a/src/KurrentDB/Components/Projections/ProjectionDetail.razor +++ b/src/KurrentDB/Components/Projections/ProjectionDetail.razor @@ -3,6 +3,7 @@ @page "/ui/projections/{Name}" @attribute [Authorize] +@using KurrentDB.Components.Shared @using KurrentDB.UI.Theme @using Microsoft.AspNetCore.Authorization @@ -68,7 +69,21 @@ @if (!string.IsNullOrEmpty(_query)) { - Source + + Source + @* A link rather than a MudButton: MudBlazor uppercases button labels, which beside this + read-only source reads as an action on this projection instead of a link out. *@ + @if (!IsSystemProjection) { + + + + Debug and deploy projections + + + + } +
@_query
diff --git a/src/KurrentDB/Components/Projections/ProjectionDetail.razor.cs b/src/KurrentDB/Components/Projections/ProjectionDetail.razor.cs index d36b61a0a91..3714c41d098 100644 --- a/src/KurrentDB/Components/Projections/ProjectionDetail.razor.cs +++ b/src/KurrentDB/Components/Projections/ProjectionDetail.razor.cs @@ -33,6 +33,10 @@ public sealed partial class ProjectionDetail : ComponentBase, IDisposable { ? $"{new Uri(Navigation.BaseUri).Scheme}://{e.Ip}:{e.Port}/ui/projections/{Uri.EscapeDataString(Name)}" : null; + // System projections are built into the server, so there is nothing to author locally and no Gaffer link. + // Same `$` prefix test Navigator splits user from system on. + bool IsSystemProjection => Name.StartsWith('$'); + ProjectionStatistics _stats; string _query = ""; string _state = ""; diff --git a/src/KurrentDB/Components/Projections/Projections.razor b/src/KurrentDB/Components/Projections/Projections.razor index b1c83a2d129..dc9f64e63c4 100644 --- a/src/KurrentDB/Components/Projections/Projections.razor +++ b/src/KurrentDB/Components/Projections/Projections.razor @@ -3,6 +3,7 @@ @page "/ui/projections" @attribute [Authorize] +@using KurrentDB.Components.Shared @using KurrentDB.Core.Data @using KurrentDB.UI.Theme @using Microsoft.AspNetCore.Authorization @@ -46,6 +47,20 @@ } else { + @* Inside this branch so it never stacks under the "not enabled" or "go to the leader" notices. The link + wraps the whole alert: Color.Inherit and Typo.inherit override MudLink's Primary/body1 defaults, and the + hover affordance is in app.css. *@ + + + + Gaffer: author, debug, test and deploy projections + + + + + @if (_error != null) { @_error } diff --git a/src/KurrentDB/Components/Shared/GafferLink.cs b/src/KurrentDB/Components/Shared/GafferLink.cs new file mode 100644 index 00000000000..7ff7c08ee3a --- /dev/null +++ b/src/KurrentDB/Components/Shared/GafferLink.cs @@ -0,0 +1,19 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using System; + +namespace KurrentDB.Components.Shared; + +// UTM-tagged links to Gaffer. `campaign` is the surface the link sits on, `content` the placement within it. +// source/medium and the `projections` campaign match what Navigator sends, so the two admin surfaces +// aggregate as one referral channel rather than looking like separate acquisition channels. +static class GafferLink { + const string Home = "https://gaffer.kurrent.io/"; + + // Both values are escaped: a caller passing a reserved character would otherwise split the query string + // and silently truncate the attribution rather than fail. + public static string For(string content, string campaign = "projections") => + $"{Home}?utm_source=embedded-ui&utm_medium=referral" + + $"&utm_campaign={Uri.EscapeDataString(campaign)}&utm_content={Uri.EscapeDataString(content)}"; +} diff --git a/src/KurrentDB/Components/Shared/NavigatorLink.cs b/src/KurrentDB/Components/Shared/NavigatorLink.cs new file mode 100644 index 00000000000..67ed2a65f94 --- /dev/null +++ b/src/KurrentDB/Components/Shared/NavigatorLink.cs @@ -0,0 +1,31 @@ +// Copyright (c) Kurrent, Inc and/or licensed to Kurrent, Inc under one or more agreements. +// Kurrent, Inc licenses this file to you under the Kurrent License v1 (see LICENSE.md). + +using System; + +namespace KurrentDB.Components.Shared; + +// Navigator registers `kurrentdb` and `kurrentdb+discover` as OS protocol handlers (see its +// electron-builder-config.cjs), so handing the browser a connection string in one of those schemes launches +// the installed app pointed at this node. Mirrors the Cloud console's connect modal (bespin +// ui/src/components/modals/connect-modal/utils/openNavigator.ts). +static class NavigatorLink { + const string Download = "https://navigator.kurrent.io/"; + + public static string Fallback => + $"{Download}?utm_source=embedded-ui&utm_medium=referral&utm_campaign=tools&utm_content=sidebar"; + + // Built from the address the browser reached this node on, not the node's own advertised address, which + // can be cluster-internal and unreachable from the client. `+discover` only for a real cluster, so a + // single node doesn't send Navigator through gossip discovery for one address it already has. + // + // Carries no credentials, unlike the Cloud console, which can assume its own default admin password: + // this UI never sees the signed-in user's password, and a connection string is not the place for one. + // Navigator prompts instead. + public static string DeepLink(string baseUri, int memberCount) { + var uri = new Uri(baseUri); + var scheme = memberCount > 1 ? "kurrentdb+discover" : "kurrentdb"; + var tls = uri.Scheme == Uri.UriSchemeHttps ? "" : "?tls=false"; + return $"{scheme}://{uri.Host}:{uri.Port}{tls}"; + } +} diff --git a/src/KurrentDB/KurrentDB.csproj b/src/KurrentDB/KurrentDB.csproj index 267dedcdba8..6660cfe2221 100644 --- a/src/KurrentDB/KurrentDB.csproj +++ b/src/KurrentDB/KurrentDB.csproj @@ -10,6 +10,9 @@ + + + diff --git a/src/KurrentDB/UI/Theme/KurrentIcons.cs b/src/KurrentDB/UI/Theme/KurrentIcons.cs index 9dfb4cc098b..a7d16577f20 100644 --- a/src/KurrentDB/UI/Theme/KurrentIcons.cs +++ b/src/KurrentDB/UI/Theme/KurrentIcons.cs @@ -88,6 +88,14 @@ public static class KurrentIcons { $"" + $""; + // Gaffer logomark from Navigator. The transform maps its "327 34 52 52" source viewBox onto MudIcon's + // 24x24; the clip id is shared across instances, whose are identical. + public const string Gaffer = + "" + + "" + + "" + + ""; + // Fill-based icons (no stroke). MudIcon's default svg uses fill="currentColor". public const string Check = ""; diff --git a/src/KurrentDB/wwwroot/css/app.css b/src/KurrentDB/wwwroot/css/app.css index 9ef0fdaca9d..139f5ab2a03 100644 --- a/src/KurrentDB/wwwroot/css/app.css +++ b/src/KurrentDB/wwwroot/css/app.css @@ -76,6 +76,16 @@ html.theme-light { height: 200px; } +/* Gaffer ribbon (Components/Projections/Projections.razor): the alert is wrapped in a link carrying no link + colour or underline, so hover is the only affordance. */ +.gaffer-ribbon .mud-alert { + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.gaffer-ribbon:hover .mud-alert { + background-color: var(--mud-palette-action-default-hover); +} + @font-face { font-family: "Solina"; src: url("/fonts/Solina-Light.woff2") format("woff2"), diff --git a/src/KurrentDB/wwwroot/navigator.png b/src/KurrentDB/wwwroot/navigator.png deleted file mode 100644 index 46c537f693c..00000000000 Binary files a/src/KurrentDB/wwwroot/navigator.png and /dev/null differ