Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/KurrentDB.Components.Tests/NavigatorLinkTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
138 changes: 138 additions & 0 deletions src/KurrentDB.Components.Tests/ProjectionsGafferRibbonTests.cs
Original file line number Diff line number Diff line change
@@ -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<AuthenticationState> 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<IPublisher>(projections ?? new ReplyPublisher(_ => { }));
services.AddSingleton<IAuthorizationProvider>(new PassthroughAuthorizationProvider());
services.AddScoped(sp => new ProjectionsService(projections, sp.GetRequiredService<IAuthorizationProvider>()));
services.AddSingleton<GossipMonitor>();
});

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<ProjectionsPage>(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<ProjectionsPage>(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<ProjectionsPage>(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<ProjectionDetail>(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<ProjectionDetail>(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);
});
}
}
23 changes: 23 additions & 0 deletions src/KurrentDB/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
George-Payne marked this conversation as resolved.
}
};
</script>
</body>

Expand Down
18 changes: 17 additions & 1 deletion src/KurrentDB/Components/Layout/NavMenu.razor
Original file line number Diff line number Diff line change
@@ -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

<MudNavMenu>
<MudText Typo="Typo.overline" Class="pl-4" Style="opacity: 0.6;">Monitoring</MudText>
Expand Down Expand Up @@ -36,6 +41,17 @@

<MudDivider Class="my-2"/>
<MudText Typo="Typo.overline" Class="pl-4" Style="opacity: 0.6;">Tools</MudText>
<MudNavLink Href="/ui/navigator" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Stream">Navigator</MudNavLink>
<MudNavLink OnClick="OpenNavigator" Icon="@Icons.Material.Filled.Stream">Navigator</MudNavLink>
<MudNavLink Href="@GafferLink.For("sidebar", campaign: "tools")" Target="_blank"
Icon="@KurrentIcons.Gaffer">Gaffer</MudNavLink>

</MudNavMenu>

@code {

Task OpenNavigator() =>
JS.InvokeVoidAsync("kurrentNavigator.open",
NavigatorLink.DeepLink(Navigation.BaseUri, Gossip.Members.Count),
NavigatorLink.Fallback).AsTask();

}
19 changes: 0 additions & 19 deletions src/KurrentDB/Components/Pages/Navigator.razor

This file was deleted.

17 changes: 16 additions & 1 deletion src/KurrentDB/Components/Projections/ProjectionDetail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

@page "/ui/projections/{Name}"
@attribute [Authorize]
@using KurrentDB.Components.Shared
@using KurrentDB.UI.Theme
@using Microsoft.AspNetCore.Authorization

Expand Down Expand Up @@ -68,7 +69,21 @@
</MudSimpleTable>

@if (!string.IsNullOrEmpty(_query)) {
<MudText Typo="Typo.h6">Source</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
<MudText Typo="Typo.h6">Source</MudText>
@* 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) {
<MudLink Href="@GafferLink.For("projection_detail")" Target="_blank"
Typo="Typo.body2" Class="d-inline-flex">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@KurrentIcons.Gaffer" Style="font-size: 1rem;"/>
<span>Debug and deploy projections</span>
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Style="font-size: 1rem;"/>
</MudStack>
</MudLink>
}
</MudStack>
<MudPaper Class="pa-4" Outlined="true">
<pre style="white-space: pre-wrap; word-break: break-all; margin: 0; font-family: monospace;">@_query</pre>
</MudPaper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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('$') == true;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated

ProjectionStatistics _stats;
string _query = "";
string _state = "";
Expand Down
15 changes: 15 additions & 0 deletions src/KurrentDB/Components/Projections/Projections.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,6 +47,20 @@
</MudAlert>
} 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. *@
<MudLink Href="@GafferLink.For("projections_list")" Target="_blank"
Underline="Underline.None" Color="Color.Inherit" Typo="Typo.inherit"
Class="gaffer-ribbon d-block">
<MudAlert Severity="Severity.Normal" Dense="true" Icon="@KurrentIcons.Gaffer">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<span><strong>Gaffer</strong>: author, debug, test and deploy projections</span>
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Style="font-size: 1rem;"/>
</MudStack>
</MudAlert>
</MudLink>

@if (_error != null) {
<MudAlert Severity="Severity.Error">@_error</MudAlert>
}
Expand Down
14 changes: 14 additions & 0 deletions src/KurrentDB/Components/Shared/GafferLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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).

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/";

public static string For(string content, string campaign = "projections") =>
$"{Home}?utm_source=embedded-ui&utm_medium=referral&utm_campaign={campaign}&utm_content={content}";
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}
31 changes: 31 additions & 0 deletions src/KurrentDB/Components/Shared/NavigatorLink.cs
Original file line number Diff line number Diff line change
@@ -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}";
}
}
3 changes: 3 additions & 0 deletions src/KurrentDB/KurrentDB.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.GC.HeapHardLimitPercent" Value="60" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="KurrentDB.Components.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BlazorMonaco" />
<PackageReference Include="Extensions.MudBlazor.StaticInput" />
Expand Down
Loading
Loading