Skip to content
Merged
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
1,988 changes: 0 additions & 1,988 deletions docs/superpowers/plans/2026-07-08-lambda-frontend-redesign.md

This file was deleted.

147 changes: 0 additions & 147 deletions docs/superpowers/specs/2026-07-08-lambda-frontend-redesign-design.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="LocalStack.Provisioning.Frontend.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230a1120'/%3E%3Cpath d='M18 4 7 19h7l-2 9L23 13h-7z' fill='%235eead4'/%3E%3C/svg%3E" />
<ImportMap />
<HeadOutlet />
</head>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@using LocalStack.Client.Options
@using Microsoft.Extensions.Options

@inject IConfiguration Configuration
@inject IOptions<LocalStackOptions> LocalStackOptions

<div class="backdrop" @onclick="() => OnClose.InvokeAsync()"></div>
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
<header class="drawer-header">
<h2 id="drawer-title">AppHost configuration</h2>
<button type="button" class="icon-button" aria-label="Close configuration" @onclick="() => OnClose.InvokeAsync()">×</button>
</header>
<div class="drawer-content">
@if (LocalStackOptions.Value.UseLocalStack)
{
<p class="drawer-note">All AWS SDK clients in this app are routed to LocalStack.</p>
}

<h3>Stack outputs (AWS:Resources)</h3>
<dl class="drawer-meta">
@foreach (var item in Configuration.GetSection("AWS:Resources").AsEnumerable(makePathsRelative: true).Where(entry => entry.Value is not null).OrderBy(entry => entry.Key, StringComparer.Ordinal))
{
<dt>@item.Key</dt>
<dd>@item.Value</dd>
}
</dl>

<h3>LocalStack options</h3>
<dl class="drawer-meta">
<dt>UseLocalStack</dt><dd>@LocalStackOptions.Value.UseLocalStack</dd>
<dt>Host</dt><dd>@LocalStackOptions.Value.Config.LocalStackHost</dd>
<dt>Edge port</dt><dd>@LocalStackOptions.Value.Config.EdgePort</dd>
<dt>UseSsl</dt><dd>@LocalStackOptions.Value.Config.UseSsl</dd>
<dt>Region</dt><dd>@LocalStackOptions.Value.Session.RegionName</dd>
</dl>

<h3>Client endpoints</h3>
<dl class="drawer-meta">
<dt>SQS</dt><dd>@EndpointDisplay.Resolve(LocalStackOptions.Value, LocalStackOptions.Value.Session.RegionName, "sqs")</dd>
<dt>DynamoDB</dt><dd>@EndpointDisplay.Resolve(LocalStackOptions.Value, LocalStackOptions.Value.Session.RegionName, "dynamodb")</dd>
<dt>SNS</dt><dd>@EndpointDisplay.Resolve(LocalStackOptions.Value, LocalStackOptions.Value.Session.RegionName, "sns")</dd>
</dl>
</div>
</aside>

@code {
[Parameter] public EventCallback OnClose { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
@using System.Globalization
@using Amazon.DynamoDBv2
@using Amazon.DynamoDBv2.Model

@inject IAmazonDynamoDB DynamoDb
@inject IConfiguration Configuration
@inject MessageFlowNotifier Notifier
@inject IJSRuntime Js
@implements IAsyncDisposable

<section class="panel" aria-labelledby="messages-heading">
<div class="panel-heading">
<h2 id="messages-heading">Chat messages</h2>
<span class="count-pill">@_messages.Count</span>
</div>

@if (_error is not null)
{
<p class="panel-error" role="alert">@_error</p>
}
else if (_messages.Count == 0)
{
<p class="empty-note">No messages yet. Publish one above and watch it arrive through the pipeline.</p>
}
else
{
<ol class="message-feed">
@foreach (var message in _messages)
{
<li class="message-card" @key="message.MessageId"
title="@($"{message.MessageId} · stored {message.ProcessedAt ?? "n/a"}")">
<span class="badge badge-created">@message.Recipient</span>
<span class="message-text">@message.Message</span>
<time>@TimeFormatting.Relative(message.Timestamp)</time>
</li>
}
</ol>
}
</section>

@code {
private const int MaxItems = 25;
private const int ReconcileSeconds = 10;

private sealed record ChatMessageRecord(string MessageId, string Recipient, string Message, DateTimeOffset Timestamp, string? ProcessedAt);

[Parameter] public EventCallback<bool> OnHealthChanged { get; set; }
[Parameter] public EventCallback<int> OnCountChanged { get; set; }

private readonly List<ChatMessageRecord> _messages = [];
private string? _error;
private string _tableName = "ChatMessages";
private PeriodicTimer? _timer;
private IJSObjectReference? _visibilityModule;

protected override async Task OnInitializedAsync()
{
_tableName = Configuration["AWS:Resources:ChatMessagesTableName"] ?? "ChatMessages";
Notifier.FlowEvent += OnFlowEvent;
await ScanAsync();
_timer = new PeriodicTimer(TimeSpan.FromSeconds(ReconcileSeconds));
_ = ReconcileLoopAsync(_timer);
}

private async Task ReconcileLoopAsync(PeriodicTimer timer)
{
// Disposing the timer completes the pending tick with false, ending the loop without cancellation.
while (await timer.WaitForNextTickAsync())
{
if (await IsTabHiddenAsync())
{
continue;
}

await ScanAsync();
await InvokeAsync(StateHasChanged);
}
}

private async Task<bool> IsTabHiddenAsync()
{
try
{
_visibilityModule ??= await Js.InvokeAsync<IJSObjectReference>("import", "./js/visibility.js");
return await _visibilityModule.InvokeAsync<bool>("isHidden");
}
catch (JSException)
{
return false;
}
}
Comment on lines +80 to +91

private void OnFlowEvent(object? sender, MessageFlowEventArgs flowEvent)
{
if (flowEvent.Stage != MessageFlowStage.Stored || flowEvent.MessageId is null)
{
return;
}

_ = InvokeAsync(() =>
{
if (!_messages.Any(existing => string.Equals(existing.MessageId, flowEvent.MessageId, StringComparison.Ordinal)))
{
_messages.Insert(0, new ChatMessageRecord(
flowEvent.MessageId,
flowEvent.Recipient,
flowEvent.Message ?? string.Empty,
flowEvent.Timestamp,
ProcessedAt: null));

if (_messages.Count > MaxItems)
{
_messages.RemoveAt(_messages.Count - 1);
}
}

_ = OnCountChanged.InvokeAsync(_messages.Count);
StateHasChanged();
});
}

private async Task ScanAsync()
{
try
{
var response = await DynamoDb.ScanAsync(new ScanRequest { TableName = _tableName });

var records = response.Items
.Select(ToRecord)
.OrderByDescending(record => record.Timestamp)
.Take(MaxItems)
.ToList();
Comment on lines +124 to +132

_messages.Clear();
_messages.AddRange(records);
_error = null;
await OnHealthChanged.InvokeAsync(true);
await OnCountChanged.InvokeAsync(_messages.Count);
}
catch (AmazonDynamoDBException ex)
{
_error = $"DynamoDB error: {ex.Message}";
await OnHealthChanged.InvokeAsync(false);
}
catch (HttpRequestException ex)
{
_error = $"Connection error: {ex.Message}";
await OnHealthChanged.InvokeAsync(false);
}
}

private static ChatMessageRecord ToRecord(Dictionary<string, AttributeValue> item)
{
var timestampMs = item.TryGetValue("Timestamp", out var ts) && long.TryParse(ts.N, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0L;

return new ChatMessageRecord(
item.TryGetValue("MessageId", out var id) ? id.S : Guid.NewGuid().ToString(),
item.TryGetValue("Recipient", out var recipient) ? recipient.S : "Unknown",
item.TryGetValue("Message", out var message) ? message.S : string.Empty,
DateTimeOffset.FromUnixTimeMilliseconds(timestampMs),
item.TryGetValue("ProcessedAt", out var processedAt) ? processedAt.S : null);
}

public async ValueTask DisposeAsync()
{
Notifier.FlowEvent -= OnFlowEvent;
_timer?.Dispose();
if (_visibilityModule is not null)
{
try
{
await _visibilityModule.DisposeAsync();
}
catch (JSDisconnectedException)
{
// Circuit already gone; nothing to release on the JS side.
_visibilityModule = null;
}
}
}
}
Loading
Loading