Skip to content
Closed
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
4 changes: 2 additions & 2 deletions docs/specs/dashboard-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,9 @@ Historical discovery only includes directories that:
- have readable, valid `run.json` metadata; and
- have the current metadata schema version.

The current run is listed first. Historical runs are ordered by descending start time.
Run discovery orders pinned runs before unpinned runs, then orders each group by descending start time. The run selector applies its presentation order separately: the current run is first, followed by pinned historical runs and then unpinned historical runs, with each historical group ordered by descending start time. Pin state is stored in `run.json`; both current and historical runs can be pinned or unpinned.

`Run` mode retains at most ten runs per application: the current run and the nine newest historical run directories. Pruning happens after a new run writes its metadata. A run is deleted only after the pruner acquires its lock, so a historical run selected by another Dashboard circuit or a run owned by another Dashboard process is skipped. I/O and access failures are logged and do not prevent Dashboard startup.
`Run` mode retains the five newest unpinned historical run directories. The current run and pinned historical runs do not count toward this limit, so the total number of retained runs is not fixed. Pruning happens after a new run writes its metadata. Before deleting a candidate, the pruner acquires its lock and rechecks its pin state. A historical run selected by another Dashboard circuit or a run owned by another Dashboard process is skipped, which can temporarily leave more than five unpinned historical runs. I/O and access failures are logged and do not prevent Dashboard startup.

## Database lifecycle

Expand Down
13 changes: 13 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/AspireMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@
{
<FluentIcon Value="@item.Icon" Style="vertical-align: text-bottom;" Width="16px" Slot="@GetIconSlot(item.Role)" />
}
@if (item.SecondaryActionIcon is not null)
{
<span slot="end" @onclick:stopPropagation="true" @onkeydown:stopPropagation="true">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still reproduces at 65b4a0a after the attempted fix from my prior review: pinning leaves Live and another run checked, and Tab exits the menu without ever reaching the pin action. Stopping keydown propagation on the wrapper does not make the nested button part of FAST's menuitemradio keyboard/selection model. I think this needs a menu structure with one actionable control per item, or another pattern that does not nest the pin control inside the radio item.

<FluentButton Class="@($"aspire-menu-secondary-action{(item.IsSecondaryActionSelected ? " selected" : null)}")"
Appearance="Appearance.Stealth"
Title="@item.SecondaryActionAriaLabel"
AriaLabel="@item.SecondaryActionAriaLabel"
aria-pressed="@(item.IsSecondaryActionSelected ? "true" : "false")"
@onclick="() => HandleSecondaryActionClicked(item)">
<FluentIcon Value="@item.SecondaryActionIcon" Width="16px" />
</FluentButton>
</span>
}
</FluentMenuItem>;
}
}
Expand Down
28 changes: 28 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ public partial class AspireMenu : FluentComponentBase
[Parameter]
public EventCallback OnRenderComplete { get; set; }

/// <summary>
/// Raised after a menu item's secondary action completes so the owner can regenerate the menu items.
/// </summary>
[Parameter]
public EventCallback OnSecondaryActionComplete { get; set; }

[Parameter]
public required IReadOnlyList<MenuButtonItem> Items { get; set; }

Expand Down Expand Up @@ -157,6 +163,28 @@ private async Task HandleItemClicked(MenuButtonItem item)
}
}

private async Task HandleSecondaryActionClicked(MenuButtonItem item)
{
if (item.OnSecondaryActionClick is { } onSecondaryActionClick)
{
await onSecondaryActionClick();
}

if (OnSecondaryActionComplete.HasDelegate)
{
await OnSecondaryActionComplete.InvokeAsync();
}
else
{
StateHasChanged();

if (_menu is { Id: not null } menu)
{
await MenuService.RefreshMenuAsync(menu.Id, isOpen: true);
}
}
}

private async Task OnOpenChanged(bool open)
{
await SetOpenAsync(open);
Expand Down
27 changes: 27 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
::deep .aspire-menu-secondary-action {
opacity: 0;
visibility: hidden;
padding-left: 8px;
}

::deep .aspire-menu-secondary-action::part(control) {
background-color: transparent;
}

::deep .aspire-menu-secondary-action:hover::part(control) {
background-color: var(--neutral-fill-secondary-hover);
}

fluent-menu-item:hover > span[slot="end"] ::deep .aspire-menu-secondary-action,
fluent-menu-item:focus-within > span[slot="end"] ::deep .aspire-menu-secondary-action,
::deep .aspire-menu-secondary-action.selected {
opacity: 1;
visibility: visible;
}

@media (hover: none) {
fluent-menu-item > span[slot="end"] ::deep .aspire-menu-secondary-action {
opacity: 1;
visibility: visible;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@
@* Render lazily because FluentMenu is expensive: building menu items and initializing its JavaScript interop add significant overhead when many menu buttons are displayed. *@
@if (_renderMenu)
{
<AspireMenu Anchor="@MenuButtonId" Open="@_visible" OpenChanged="OnMenuOpenChanged" OnRenderComplete="OnMenuRenderComplete" Items="_items" RestoreFocusOnItemClick="@RestoreFocusOnItemClick" />
<AspireMenu Anchor="@MenuButtonId" Open="@_visible" OpenChanged="OnMenuOpenChanged" OnRenderComplete="OnMenuRenderComplete" OnSecondaryActionComplete="RefreshItems" Items="_items" RestoreFocusOnItemClick="@RestoreFocusOnItemClick" />
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public partial class AspireMenuButton : FluentComponentBase, IAsyncDisposable
[Parameter]
public required Func<IList<MenuButtonItem>> ItemsProvider { get; set; }

// Exposed only for tests to inspect the rendered menu items.
internal IReadOnlyList<MenuButtonItem> Items => _items;

[Parameter]
public Appearance? ButtonAppearance { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace Aspire.Dashboard.Components.Controls;
public partial class DashboardRunSelect : ComponentBase
{
private static readonly Icon s_checkmarkIcon = new Icons.Regular.Size16.Checkmark();
private static readonly Icon s_pinIcon = new Icons.Regular.Size16.Pin();
private static readonly Icon s_pinnedIcon = new Icons.Filled.Size16.Pin();

private string RunSelectTitle => Loc[nameof(LayoutResources.DashboardRunSelectTitle)];
private string RunSelectAccessibleLabel => Loc[nameof(LayoutResources.DashboardRunSelectAccessibleLabel), SelectedRunText];
Expand Down Expand Up @@ -42,20 +44,39 @@ public partial class DashboardRunSelect : ComponentBase
[Inject]
public required IDashboardRunStore RunStore { get; init; }

[Inject]
public required ILogger<DashboardRunSelect> Logger { get; init; }

private IList<MenuButtonItem> LoadRuns()
{
var runs = RunStore.GetRuns().Where(run => !run.IsPruned).ToArray();
var runs = RunStore.GetRuns()
.Where(run => !run.IsPruned)
.OrderByDescending(run => run.IsCurrent)
.ThenByDescending(run => run.IsPinned)
.ThenByDescending(run => run.StartedAtUtc)
.ToArray();
var menuItems = new List<MenuButtonItem>();
foreach (var run in runs)
{
menuItems.Add(new MenuButtonItem
var menuItem = new MenuButtonItem
{
Text = FormatRunOption(run),
Role = MenuItemRole.MenuItemRadio,
Checked = string.Equals(run.RunId, SelectedRunId, StringComparison.Ordinal),
Icon = s_checkmarkIcon,
SecondaryActionIcon = run.IsPinned ? s_pinnedIcon : s_pinIcon,
SecondaryActionAriaLabel = Loc[run.IsPinned
? nameof(LayoutResources.DashboardRunSelectUnpin)
: nameof(LayoutResources.DashboardRunSelectPin)],
IsSecondaryActionSelected = run.IsPinned,
OnSecondaryActionClick = () =>
{
SetRunPinned(run, !run.IsPinned);
return Task.CompletedTask;
},
OnClick = () => SelectedRunIdChanged.InvokeAsync(run.IsCurrent ? null : run.RunId)
});
};
menuItems.Add(menuItem);

if (run.IsCurrent && runs.Any(candidate => !candidate.IsCurrent))
{
Expand All @@ -66,6 +87,18 @@ private IList<MenuButtonItem> LoadRuns()
return menuItems;
}

private void SetRunPinned(DashboardRunDescriptor run, bool isPinned)
{
try
{
RunStore.SetRunPinned(run, isPinned);
}
catch (Exception exception)
{
Logger.LogError(exception, "Failed to update the pinned state of dashboard run '{RunId}'.", run.RunId);
}
}

private string FormatRunOption(DashboardRunDescriptor run)
{
if (run.IsCurrent)
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Dashboard/Model/MenuButtonItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ public class MenuButtonItem
public string? Text { get; set; }
public string? Tooltip { get; set; }
public Icon? Icon { get; set; }
public Icon? SecondaryActionIcon { get; set; }
public string? SecondaryActionAriaLabel { get; set; }
public Func<Task>? OnSecondaryActionClick { get; set; }
public bool IsSecondaryActionSelected { get; set; }
/// <summary>
/// Optional ARIA role for the item. Set to <see cref="MenuItemRole.MenuItemCheckbox"/> or
/// <see cref="MenuItemRole.MenuItemRadio"/> to expose an accessible checked state (via
Expand Down
18 changes: 18 additions & 0 deletions src/Aspire.Dashboard/Resources/Layout.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Aspire.Dashboard/Resources/Layout.resx
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,10 @@
<data name="DashboardRunSelectCurrent" xml:space="preserve">
<value>Live run</value>
</data>
<data name="DashboardRunSelectPin" xml:space="preserve">
<value>Pin run</value>
</data>
<data name="DashboardRunSelectUnpin" xml:space="preserve">
<value>Unpin run</value>
</data>
</root>
10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Resources/xlf/Layout.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Resources/xlf/Layout.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Resources/xlf/Layout.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Resources/xlf/Layout.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Resources/xlf/Layout.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Resources/xlf/Layout.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Resources/xlf/Layout.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading