Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion docs/server/features/admin-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions src/KurrentDB.Components.Tests/GafferLinkTests.cs
Original file line number Diff line number Diff line change
@@ -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
}
}
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('$');

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
Loading
Loading