added aquarium

This commit is contained in:
Michal Vrba
2026-09-29 14:25:04 +02:00
parent da4df83e13
commit a5410bb5d7
80 changed files with 62854 additions and 1 deletions
+84
View File
@@ -0,0 +1,84 @@
# 🌊 Network Aquarium
> **Živý network traffic vizualizovaný ako podmorské akvárium v .NET 10.**
> Každý request, stream a chyba v tvojej infraštruktúre ožíva ako morský tvor s nulovou záťažou na produkčný server.
---
## 🐠 Morská hierarchia (Traffic Taxonomy)
| Tvor | Sieťový traffic | Vizuál a fyzika |
| :--- | :--- | :--- |
| 🐟 **Rybky (Klauni, Tetry, Skaláre)** | Štandardný HTTP GET / POST / PUT / DELETE | Farba podľa metódy (GET = Cyan, POST = Emerald, PUT = Violet, DELETE = Rose). Rýchlosť plávania = Latencia (nízky ping = blesková rybka). |
| 🐬 **Delfíny** | WebSockets / SSE / Live Streamy | Plávajú v sínusoidách, skáču k hladine a pri každom data frame emitujú sonarové vlny. |
| 🐋 **Veľryby** | Veľké downloady & payloady (> 500 KB – 10 MB+) | Majestátne, obrovské, pomalé tvory s vodným gejzírom z dýchacieho otvoru. |
| 🪼 **Medúzy (Červené / Jantárové)** | Chyby klienta (4xx) & Chyby servera (5xx) | Svietiace pulzujúce medúzy vznášajúce sa nahor s dlhými chápadlami. |
| 🦈 **Žraloky** | Rate Limits (429) & DDoS / Traffic Spikes | Rýchle dravce križujúce stredné hĺbky pri preťažení servera. |
| 🦀 **Krabíci** | Databázové dotazy & Background Cron / Workers | Sliedia po piesočnatom dne a prenášajú dáta. |
---
## 🏛️ Architektúra (.NET 10 Solution)
Riešenie `VrbaOneProjects.slnx` obsahuje 3 projekty:
1. **`Aquarium.Shared`** (.NET 10 Class Library)
- Modely `TrafficEvent`, `CreatureSpawnDto`, `AquariumStats`.
- `CreatureClassifier` – inteligentné pravidlá mapovania trafficu na biómy, rýchlosť, škálovanie a farby.
2. **`Aquarium.Server`** (.NET 10 ASP.NET Core Minimal API + SignalR)
- `TrafficTelemetryMiddleware` – zachytáva live HTTP požiadavky bez blokovania.
- `ITrafficEventChannel` – in-memory `System.Threading.Channels` fronta (zero-allocation, mikrosekundový throughput).
- `TrafficBroadcasterService` – agreguje RPS, latenciu a vysiela eventy cez SignalR do pripojených klientov.
- `TrafficSimulatorService` – generuje realistický traffic pre testovanie a demo účely.
3. **`Aquarium.Client`** (.NET 10 Blazor WebAssembly Standalone)
- 60 FPS HTML5 Canvas 2D engine (`aquarium.js`) s realistickými časticami, lomom svetla (caustics) a morskými prúdmi.
- **Click-to-Inspect HUD:** Kliknutím na akéhokoľvek tvora sa otvorí detailný cyberpunkový drawer s metrikami (`Method`, `Path`, `Status`, `Latency`, `Size`, `Client IP/Country`, `Trace ID`).
- Syntetizované audio efekty (Web Audio API) s možnosťou stlmenia.
- Offline / Standalone Demo Fallback – ak backend nebeží, klient sa automaticky prepne do lokálnej simulácie.
---
## 🚀 Spustenie projektu
### 1. Spustenie Backend Servera (Port 5050)
```powershell
cd src/Aquarium.Server
dotnet run --urls "http://localhost:5050"
```
### 2. Spustenie Blazor WASM Klienta (Port 5100)
```powershell
cd src/Aquarium.Client
dotnet run --urls "http://localhost:5100"
```
Otvorte prehliadač na **`http://localhost:5100`**.
---
## 🔌 Ako napojiť reálny server
Máte 2 jednoduché spôsoby:
### Možnosť A: Vloženie Middleware do existujúcej ASP.NET Core aplikácie
Pridajte referenciu na `Aquarium.Server` alebo vložte `TrafficTelemetryMiddleware`:
```csharp
app.UseMiddleware<TrafficTelemetryMiddleware>();
```
### Možnosť B: Externé posielanie cez REST Ingestion API
Akákoľvek reverzná proxy (Nginx, Caddy, Cloudflare Worker, Node.js) môže poslať event priamo na endpoint servera:
```bash
curl -X POST http://localhost:5050/api/telemetry/event \
-H "Content-Type: application/json" \
-d '{
"method": "GET",
"path": "/api/v1/users",
"statusCode": 200,
"durationMs": 42.5,
"bytes": 12400,
"clientCountry": "SK",
"clientCity": "Bratislava",
"clientIp": "178.40.12.8"
}'
```
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
+6
View File
@@ -0,0 +1,6 @@
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.12" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Aquarium.Shared\Aquarium.Shared.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,5 @@
@inherits LayoutComponentBase
<div class="aquarium-app-root">
@Body
</div>
@@ -0,0 +1,77 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
+39
View File
@@ -0,0 +1,39 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">VrbaOne.Aquarium.Client</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}
@@ -0,0 +1,83 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
.nav-scrollable {
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
+243
View File
@@ -0,0 +1,243 @@
@page "/"
@inject AquariumClientService Aquarium
@inject IJSRuntime JS
@implements IDisposable
<div class="aquarium-container">
<!-- HTML5 60 FPS Marine Canvas -->
<canvas id="aquariumCanvas"></canvas>
<!-- Top Floating Cyber-Metrics Bar -->
<header class="top-metrics-bar glass-panel">
<div class="brand-section">
<span class="brand-logo">🌊</span>
<div>
<h1 class="brand-title">NETWORK AQUARIUM</h1>
<p class="brand-subtitle">Realtime Server Traffic Visualizer &middot; .NET 10 WASM</p>
</div>
</div>
<div class="kpi-container">
<div class="kpi-item">
<span class="kpi-label">Throughput</span>
<span class="kpi-value cyan">@Aquarium.CurrentStats.RequestsPerSecond <small style="font-size:0.65rem;">req/s</small></span>
</div>
<div class="kpi-item">
<span class="kpi-label">Avg Latency</span>
<span class="kpi-value @(Aquarium.CurrentStats.AverageLatencyMs < 100 ? "emerald" : "amber")">@Aquarium.CurrentStats.AverageLatencyMs <small style="font-size:0.65rem;">ms</small></span>
</div>
<div class="kpi-item">
<span class="kpi-label">Error Rate</span>
<span class="kpi-value @(Aquarium.CurrentStats.ErrorRatePercent == 0 ? "emerald" : "rose")">@Aquarium.CurrentStats.ErrorRatePercent%</span>
</div>
<div class="kpi-item">
<span class="kpi-label">Total Data</span>
<span class="kpi-value">@FormatBytes(Aquarium.CurrentStats.TotalBytesTransferred)</span>
</div>
<div class="status-badge @(Aquarium.IsUsingLocalSimulation ? "reconnecting" : "online")">
<span class="pulsing-dot"></span>
<span>@(Aquarium.IsUsingLocalSimulation ? "DEMO SIMULATION" : "LIVE SERVER")</span>
</div>
</div>
</header>
<!-- Floating Left Filter & Biomes Panel -->
<aside class="left-controls glass-panel">
<div class="control-title">🤿 Marine Biomes</div>
<div class="biome-btn-group">
<button class="btn-biome @(_selectedBiome == "ALL" ? "active" : "")" @onclick='() => SetBiomeFilter("ALL")'>
🌐 All Depths
</button>
<button class="btn-biome @(_selectedBiome == "Surface" ? "active" : "")" @onclick='() => SetBiomeFilter("Surface")'>
☀️ Surface (CDN & WS)
</button>
<button class="btn-biome @(_selectedBiome == "Pelagic" ? "active" : "")" @onclick='() => SetBiomeFilter("Pelagic")'>
🐠 Pelagic (REST API)
</button>
<button class="btn-biome @(_selectedBiome == "Abyssal" ? "active" : "")" @onclick='() => SetBiomeFilter("Abyssal")'>
🪸 Abyssal (DB & Jobs)
</button>
</div>
<div class="control-title" style="margin-top: 12px;">🔍 Filter Traffic</div>
<div class="search-box">
<input type="text" class="search-input" placeholder="Search path / regex..." value="@_searchQuery" @oninput="OnSearchChanged" />
</div>
<div style="display: flex; gap: 8px; margin-top: 10px;">
<button class="btn-action" style="flex: 1; justify-content: center;" @onclick="ToggleAudio">
@(Aquarium.SoundEnabled ? "🔊 Sound ON" : "🔇 Sound OFF")
</button>
<button class="btn-action" style="justify-content: center;" title="Clear screen" @onclick="ClearCreatures">
🧹
</button>
</div>
</aside>
<!-- Bottom Simulation & Trigger Toolbar -->
<div class="bottom-toolbar glass-panel">
<span style="font-size: 0.75rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; margin-right: 4px;">Inject Traffic:</span>
<button class="btn-action" @onclick="() => Spawn(CreatureType.Fish)">🐟 HTTP Fish</button>
<button class="btn-action dolphin" @onclick="() => Spawn(CreatureType.Dolphin)">🐬 WebSocket</button>
<button class="btn-action whale" @onclick="() => Spawn(CreatureType.Whale)">🐋 >5MB Whale</button>
<button class="btn-action jelly" @onclick="() => Spawn(CreatureType.Jellyfish)">🪼 500 Error Jelly</button>
<button class="btn-action shark" @onclick="() => Spawn(CreatureType.Shark)">🦈 429 Shark</button>
<button class="btn-action crab" @onclick="() => Spawn(CreatureType.Crab)">🦀 DB Crab</button>
</div>
<!-- Marine Legend at Bottom Left -->
<div class="legend-panel glass-panel">
<div class="legend-item"><span>🐟</span> <span>GET/POST</span></div>
<div class="legend-item"><span>🐬</span> <span>WebSockets</span></div>
<div class="legend-item"><span>🐋</span> <span>Payload &gt;1MB</span></div>
<div class="legend-item"><span>🪼</span> <span>4xx/5xx Errors</span></div>
<div class="legend-item"><span>🦈</span> <span>Rate-Limit 429</span></div>
<div class="legend-item"><span>🦀</span> <span>DB / Cron</span></div>
</div>
<!-- Slide-over Telemetry Inspector Drawer -->
<section class="inspector-drawer glass-panel @(Aquarium.SelectedEvent == null ? "closed" : "")">
@if (Aquarium.SelectedEvent != null)
{
var e = Aquarium.SelectedEvent;
<div class="inspector-header">
<div class="inspector-title">📡 TELEMETRY INSPECT</div>
<button class="btn-close" @onclick="CloseDrawer">&times;</button>
</div>
<!-- Route & Status Card -->
<div class="inspect-card">
<div class="card-row">
<span class="http-badge @e.Method">@e.Method</span>
<span class="status-tag @(e.StatusCode >= 500 ? "s5xx" : e.StatusCode >= 400 ? "s4xx" : "s2xx")">
@e.StatusCode @(e.StatusCode == 200 ? "OK" : e.StatusCode == 101 ? "Switching Protocols" : e.StatusCode == 404 ? "Not Found" : e.StatusCode == 500 ? "Internal Error" : "")
</span>
</div>
<div class="path-box">@e.Path</div>
<div class="card-row">
<span class="card-label">Timestamp</span>
<span class="card-value">@e.Timestamp.ToString("HH:mm:ss.fff")</span>
</div>
</div>
<!-- Performance Card -->
<div class="inspect-card">
<div class="card-row">
<span class="card-label">⏱ Response Time (Latency)</span>
<span class="card-value" style="color: #38bdf8;">@e.DurationMs.ToString("F1") ms</span>
</div>
<div class="card-row">
<span class="card-label">📦 Transferred Size</span>
<span class="card-value">@FormatBytes(e.Bytes)</span>
</div>
@if (e.DatabaseTimeMs.HasValue)
{
<div class="card-row">
<span class="card-label">🗄 DB Execution Time</span>
<span class="card-value" style="color: #f97316;">@e.DatabaseTimeMs.Value.ToString("F1") ms (@e.QueryCount queries)</span>
</div>
}
</div>
<!-- Client Info Card -->
<div class="inspect-card">
<div class="card-row">
<span class="card-label">🌍 Client Origin</span>
<span class="card-value">@e.ClientCity, @e.ClientCountry</span>
</div>
<div class="card-row">
<span class="card-label">🖥 Client IP</span>
<span class="card-value">@e.ClientIp</span>
</div>
<div class="card-row">
<span class="card-label">🛡 Service Target</span>
<span class="card-value">@e.ServiceName</span>
</div>
</div>
<!-- Diagnostic Trace ID -->
<div class="inspect-card">
<div class="card-row">
<span class="card-label">🔗 Trace Identifier</span>
</div>
<div class="card-row" style="margin-top: 4px;">
<span class="card-value" style="font-size: 0.72rem; color: #a5b4fc; word-break: break-all;">@e.TraceId</span>
</div>
@if (!string.IsNullOrWhiteSpace(e.ErrorMessage))
{
<div style="margin-top: 10px; padding: 8px; background: rgba(239, 68, 68, 0.15); border-left: 3px solid #ef4444; border-radius: 4px; font-size: 0.72rem; color: #fca5a5; font-family: 'JetBrains Mono', monospace;">
@e.ErrorMessage
</div>
}
</div>
}
</section>
</div>
@code {
private string _selectedBiome = "ALL";
private string _searchQuery = "";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
Aquarium.OnStateChanged += StateHasChanged;
await Aquarium.InitializeAsync("aquariumCanvas");
}
}
private async Task Spawn(CreatureType type)
{
await Aquarium.TriggerCreatureAsync(type);
}
private async Task ToggleAudio()
{
await Aquarium.ToggleSoundAsync();
}
private async Task ClearCreatures()
{
await Aquarium.ClearAllCreaturesAsync();
}
private async Task SetBiomeFilter(string biome)
{
_selectedBiome = biome;
await Aquarium.SetFiltersAsync("ALL", _selectedBiome, _searchQuery);
}
private async Task OnSearchChanged(ChangeEventArgs e)
{
_searchQuery = e.Value?.ToString() ?? "";
await Aquarium.SetFiltersAsync("ALL", _selectedBiome, _searchQuery);
}
private void CloseDrawer()
{
Aquarium.CloseInspector();
}
private string FormatBytes(long bytes)
{
string[] sizes = ["B", "KB", "MB", "GB"];
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.#} {sizes[order]}";
}
public void Dispose()
{
Aquarium.OnStateChanged -= StateHasChanged;
}
}
+5
View File
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Aquarium.Client;
using Aquarium.Client.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<AquariumClientService>();
await builder.Build().RunAsync();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5100",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7100;http://localhost:5100",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,298 @@
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.JSInterop;
using Aquarium.Shared;
namespace Aquarium.Client.Services;
public class AquariumClientService : IAsyncDisposable
{
private readonly IJSRuntime _js;
private HubConnection? _hubConnection;
private DotNetObjectReference<AquariumClientService>? _dotNetRef;
private CancellationTokenSource? _localSimulationCts;
private readonly Random _rnd = new();
public string ServerUrl { get; set; } = "http://localhost:5050/hubs/aquarium";
public HubConnectionState ConnectionState => _hubConnection?.State ?? HubConnectionState.Disconnected;
public bool IsUsingLocalSimulation { get; private set; } = false;
public bool SoundEnabled { get; set; } = false;
public AquariumStats CurrentStats { get; private set; } = new()
{
RequestsPerSecond = 0,
AverageLatencyMs = 28,
ErrorRatePercent = 0.0,
TotalRequests = 0,
TotalBytesTransferred = 0
};
public TrafficEvent? SelectedEvent { get; private set; }
public event Action? OnStateChanged;
public event Action<TrafficEvent>? OnCreatureClicked;
public AquariumClientService(IJSRuntime js)
{
_js = js;
}
public async Task InitializeAsync(string canvasId)
{
_dotNetRef = DotNetObjectReference.Create(this);
await _js.InvokeVoidAsync("AquariumEngine.init", canvasId, _dotNetRef);
await ConnectToServerAsync();
}
public async Task ConnectToServerAsync()
{
try
{
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
_hubConnection = new HubConnectionBuilder()
.WithUrl(ServerUrl)
.WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) })
.Build();
_hubConnection.On<CreatureSpawnDto>("OnCreatureSpawn", async creature =>
{
await _js.InvokeVoidAsync("AquariumEngine.spawnCreature", creature);
});
_hubConnection.On<List<CreatureSpawnDto>>("OnBatchSpawn", async batch =>
{
await _js.InvokeVoidAsync("AquariumEngine.spawnBatch", batch);
});
_hubConnection.On<AquariumStats>("OnStatsUpdated", stats =>
{
CurrentStats = stats;
OnStateChanged?.Invoke();
});
_hubConnection.Reconnecting += ex =>
{
OnStateChanged?.Invoke();
return Task.CompletedTask;
};
_hubConnection.Reconnected += id =>
{
IsUsingLocalSimulation = false;
StopLocalSimulation();
OnStateChanged?.Invoke();
return Task.CompletedTask;
};
_hubConnection.Closed += ex =>
{
StartLocalSimulationFallback();
OnStateChanged?.Invoke();
return Task.CompletedTask;
};
await _hubConnection.StartAsync();
IsUsingLocalSimulation = false;
StopLocalSimulation();
OnStateChanged?.Invoke();
}
catch (Exception)
{
StartLocalSimulationFallback();
OnStateChanged?.Invoke();
}
}
public async Task ToggleSoundAsync()
{
SoundEnabled = await _js.InvokeAsync<bool>("AquariumEngine.toggleSound");
OnStateChanged?.Invoke();
}
public async Task SetFiltersAsync(string method, string biome, string query)
{
await _js.InvokeVoidAsync("AquariumEngine.setFilters", method, biome, query);
}
public async Task ClearAllCreaturesAsync()
{
await _js.InvokeVoidAsync("AquariumEngine.clearAll");
SelectedEvent = null;
OnStateChanged?.Invoke();
}
public async Task TriggerCreatureAsync(CreatureType type)
{
var evt = GenerateMockEvent(type);
var spawnDto = CreatureSpawnDto.FromEvent(evt);
await _js.InvokeVoidAsync("AquariumEngine.spawnCreature", spawnDto);
CurrentStats.TotalRequests++;
CurrentStats.TotalBytesTransferred += evt.Bytes;
if (evt.StatusCode >= 500) CurrentStats.Total5xx++;
else if (evt.StatusCode >= 400) CurrentStats.Total4xx++;
else CurrentStats.Total2xx++;
OnStateChanged?.Invoke();
}
[JSInvokable]
public void OnCreatureSelected(TrafficEvent selected)
{
SelectedEvent = selected;
OnCreatureClicked?.Invoke(selected);
OnStateChanged?.Invoke();
}
public void CloseInspector()
{
SelectedEvent = null;
OnStateChanged?.Invoke();
}
private void StartLocalSimulationFallback()
{
if (IsUsingLocalSimulation) return;
IsUsingLocalSimulation = true;
_localSimulationCts?.Cancel();
_localSimulationCts = new CancellationTokenSource();
var token = _localSimulationCts.Token;
_ = Task.Run(async () =>
{
while (!token.IsCancellationRequested)
{
try
{
var roll = _rnd.Next(100);
CreatureType type;
if (roll < 5) type = CreatureType.Jellyfish;
else if (roll < 10) type = CreatureType.Whale;
else if (roll < 15) type = CreatureType.Dolphin;
else if (roll < 19) type = CreatureType.Crab;
else if (roll < 22) type = CreatureType.Shark;
else type = CreatureType.Fish;
var evt = GenerateMockEvent(type);
var spawn = CreatureSpawnDto.FromEvent(evt);
await _js.InvokeVoidAsync("AquariumEngine.spawnCreature", spawn);
CurrentStats.TotalRequests++;
CurrentStats.TotalBytesTransferred += evt.Bytes;
CurrentStats.RequestsPerSecond = Math.Round(2.0 + _rnd.NextDouble() * 4.0, 1);
CurrentStats.AverageLatencyMs = Math.Round(35.0 + _rnd.NextDouble() * 30.0, 1);
var delay = _rnd.Next(400, 1400);
await Task.Delay(delay, token);
}
catch (OperationCanceledException) { break; }
catch { await Task.Delay(1000, token); }
}
}, token);
}
private void StopLocalSimulation()
{
_localSimulationCts?.Cancel();
_localSimulationCts = null;
}
private TrafficEvent GenerateMockEvent(CreatureType type)
{
return type switch
{
CreatureType.Whale => new TrafficEvent
{
Method = "GET",
Path = "/download/release-v2.iso",
StatusCode = 200,
DurationMs = 1200,
Bytes = 8_400_000,
ClientCountry = "DE",
ClientCity = "Frankfurt",
ClientIp = "194.25.0.12",
TraceId = $"whl_{Guid.NewGuid():N}"[..12]
},
CreatureType.Dolphin => new TrafficEvent
{
Method = "WS",
Path = "/signalr/traffic-feed",
StatusCode = 101,
DurationMs = 8,
Bytes = 1024,
IsWebSocket = true,
ClientCountry = "SK",
ClientCity = "Bratislava",
ClientIp = "178.40.12.8",
TraceId = $"ws_{Guid.NewGuid():N}"[..12]
},
CreatureType.Jellyfish => new TrafficEvent
{
Method = "POST",
Path = "/api/v1/auth/session/renew",
StatusCode = _rnd.Next(0, 2) == 0 ? 500 : 404,
DurationMs = 340,
Bytes = 512,
ClientCountry = "CZ",
ClientCity = "Brno",
ClientIp = "89.24.18.99",
TraceId = $"err_{Guid.NewGuid():N}"[..12],
ErrorMessage = "System.TimeoutException: Database pool exhausted"
},
CreatureType.Shark => new TrafficEvent
{
Method = "GET",
Path = "/api/v1/search?term=ddos_burst",
StatusCode = 429,
DurationMs = 15,
Bytes = 256,
ClientCountry = "US",
ClientCity = "Ashburn",
ClientIp = "34.201.88.19",
TraceId = $"shk_{Guid.NewGuid():N}"[..12],
ErrorMessage = "RateLimit: 429 Too Many Requests"
},
CreatureType.Crab => new TrafficEvent
{
Method = "JOB",
Path = "/cron/db-cache-prune",
StatusCode = 200,
DurationMs = 450,
Bytes = 4096,
ClientCountry = "SK",
ClientCity = "Bratislava",
ClientIp = "127.0.0.1",
TraceId = $"job_{Guid.NewGuid():N}"[..12],
DatabaseTimeMs = 38.0,
QueryCount = 8
},
_ => new TrafficEvent
{
Method = new[] { "GET", "POST", "PUT", "DELETE" }[_rnd.Next(4)],
Path = new[] { "/api/v1/users", "/api/v1/stats", "/api/v1/orders", "/api/v1/feed" }[_rnd.Next(4)],
StatusCode = 200,
DurationMs = _rnd.Next(15, 95),
Bytes = _rnd.Next(1024, 25000),
ClientCountry = "SK",
ClientCity = "Bratislava",
ClientIp = $"188.167.{_rnd.Next(1, 250)}.{_rnd.Next(1, 250)}",
TraceId = $"trc_{Guid.NewGuid():N}"[..12]
}
};
}
public async ValueTask DisposeAsync()
{
StopLocalSimulation();
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
_dotNetRef?.Dispose();
}
}
+12
View File
@@ -0,0 +1,12 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Aquarium.Client
@using Aquarium.Client.Layout
@using Aquarium.Client.Services
@using Aquarium.Shared
+439
View File
@@ -0,0 +1,439 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Orbitron:wght@600;800;900&display=swap');
:root {
--bg-ocean-dark: #000814;
--bg-panel: rgba(10, 25, 47, 0.75);
--bg-panel-border: rgba(56, 189, 248, 0.2);
--accent-cyan: #06b6d4;
--accent-blue: #38bdf8;
--accent-emerald: #10b981;
--accent-amber: #f59e0b;
--accent-rose: #f43f5e;
--accent-purple: #a855f7;
--text-main: #f8fafc;
--text-muted: #94a3b8;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
}
body, html {
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: var(--bg-ocean-dark);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text-main);
}
.aquarium-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#aquariumCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
z-index: 1;
}
/* Glassmorphism utility */
.glass-panel {
background: var(--bg-panel);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--bg-panel-border);
border-radius: 12px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.45);
}
/* Top Metrics Bar */
.top-metrics-bar {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
pointer-events: auto;
}
.brand-section {
display: flex;
align-items: center;
gap: 12px;
}
.brand-logo {
font-size: 24px;
animation: waveBounce 3s ease-in-out infinite;
}
@keyframes waveBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.brand-title {
font-family: 'Orbitron', sans-serif;
font-size: 1.1rem;
font-weight: 800;
letter-spacing: 1.5px;
background: linear-gradient(135deg, #38bdf8 0%, #06b6d4 50%, #10b981 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.brand-subtitle {
font-size: 0.72rem;
font-family: 'JetBrains Mono', monospace;
color: var(--text-muted);
}
.kpi-container {
display: flex;
align-items: center;
gap: 24px;
}
.kpi-item {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.kpi-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
}
.kpi-value {
font-family: 'JetBrains Mono', monospace;
font-size: 1.15rem;
font-weight: 700;
color: var(--text-main);
}
.kpi-value.cyan { color: var(--accent-cyan); }
.kpi-value.emerald { color: var(--accent-emerald); }
.kpi-value.rose { color: var(--accent-rose); }
.kpi-value.amber { color: var(--accent-amber); }
.status-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.status-badge.online {
background: rgba(16, 185, 129, 0.15);
border: 1px solid rgba(16, 185, 129, 0.4);
color: #34d399;
}
.status-badge.reconnecting {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.4);
color: #fbbf24;
}
.status-badge.offline {
background: rgba(244, 63, 94, 0.15);
border: 1px solid rgba(244, 63, 94, 0.4);
color: #fb7185;
}
.pulsing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
animation: pulseDot 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulseDot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.85); }
}
/* Floating Left Controls / Biomes */
.left-controls {
position: absolute;
top: 90px;
left: 16px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 12px;
width: 220px;
padding: 16px;
}
.control-title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 6px;
}
.biome-btn-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.btn-biome {
display: flex;
align-items: center;
gap: 8px;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-muted);
padding: 8px 12px;
border-radius: 8px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-biome:hover {
background: rgba(56, 189, 248, 0.15);
border-color: rgba(56, 189, 248, 0.3);
color: var(--text-main);
}
.btn-biome.active {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.3) 0%, rgba(56, 189, 248, 0.2) 100%);
border-color: var(--accent-blue);
color: #fff;
box-shadow: 0 0 12px rgba(56, 189, 248, 0.2);
}
/* Search input */
.search-box {
position: relative;
width: 100%;
}
.search-input {
width: 100%;
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
font-family: 'JetBrains Mono', monospace;
color: #fff;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: var(--accent-blue);
}
/* Bottom Simulation & Trigger Toolbar */
.bottom-toolbar {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
}
.btn-action {
display: flex;
align-items: center;
gap: 6px;
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-main);
padding: 8px 14px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-action:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.btn-action.whale:hover { border-color: #3b82f6; background: rgba(59, 130, 246, 0.2); }
.btn-action.dolphin:hover { border-color: #06b6d4; background: rgba(6, 182, 212, 0.2); }
.btn-action.jelly:hover { border-color: #ef4444; background: rgba(239, 68, 68, 0.2); }
.btn-action.shark:hover { border-color: #e11d48; background: rgba(225, 29, 72, 0.2); }
.btn-action.crab:hover { border-color: #f97316; background: rgba(249, 115, 22, 0.2); }
/* Slide-in Telemetry Inspector Drawer */
.inspector-drawer {
position: absolute;
top: 90px;
right: 16px;
bottom: 16px;
width: 360px;
z-index: 20;
display: flex;
flex-direction: column;
padding: 20px;
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
transform: translateX(0);
opacity: 1;
overflow-y: auto;
}
.inspector-drawer.closed {
transform: translateX(400px);
opacity: 0;
pointer-events: none;
}
.inspector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 16px;
}
.inspector-title {
font-family: 'Orbitron', sans-serif;
font-size: 0.95rem;
font-weight: 700;
color: var(--accent-blue);
}
.btn-close {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.2rem;
cursor: pointer;
line-height: 1;
}
.btn-close:hover { color: #fff; }
.inspect-card {
background: rgba(15, 23, 42, 0.55);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.card-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 0.8rem;
}
.card-row:last-child { margin-bottom: 0; }
.card-label { color: var(--text-muted); }
.card-value {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: var(--text-main);
}
.http-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.72rem;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
}
.http-badge.GET { background: rgba(6, 182, 212, 0.2); color: #22d3ee; border: 1px solid #0891b2; }
.http-badge.POST { background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid #059669; }
.http-badge.PUT { background: rgba(139, 92, 246, 0.2); color: #a78bfa; border: 1px solid #7c3aed; }
.http-badge.DELETE { background: rgba(244, 63, 94, 0.2); color: #fb7185; border: 1px solid #e11d48; }
.http-badge.WS { background: rgba(56, 189, 248, 0.2); color: #38bdf8; border: 1px solid #0284c7; }
.http-badge.JOB { background: rgba(249, 115, 22, 0.2); color: #fb923c; border: 1px solid #ea580c; }
.status-tag {
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem;
font-weight: 700;
}
.status-tag.s2xx { background: rgba(16, 185, 129, 0.2); color: #34d399; }
.status-tag.s4xx { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
.status-tag.s5xx { background: rgba(239, 68, 68, 0.25); color: #f87171; border: 1px solid #ef4444; }
.path-box {
background: rgba(0, 0, 0, 0.4);
border-radius: 6px;
padding: 8px 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
color: #38bdf8;
word-break: break-all;
margin: 8px 0;
}
.copy-btn {
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.7rem;
color: var(--text-muted);
cursor: pointer;
}
.copy-btn:hover { background: rgba(255, 255, 255, 0.15); color: #fff; }
/* Marine Legend at Bottom Left */
.legend-panel {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 10;
padding: 10px 14px;
display: flex;
align-items: center;
gap: 14px;
font-size: 0.72rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🌊 Network Aquarium - Realtime Server Traffic Visualizer</title>
<base href="/" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
</head>
<body>
<div id="app">
<div style="position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #000814; color: #38bdf8; font-family: sans-serif;">
<div style="font-size: 3rem; margin-bottom: 1rem; animation: pulse 1.5s infinite;">🌊</div>
<h2 style="font-family: 'Orbitron', monospace; font-size: 1.2rem; letter-spacing: 2px;">DIVING INTO NETWORK AQUARIUM...</h2>
<p style="color: #64748b; font-size: 0.85rem; margin-top: 0.5rem;">Booting .NET 10 WebAssembly Engine</p>
</div>
</div>
<div id="blazor-error-ui" style="display: none; position: fixed; bottom: 0; left: 0; right: 0; background: #7f1d1d; color: white; padding: 12px; text-align: center; z-index: 1000;">
An unhandled error has occurred.
<a href="" class="reload" style="color: #fca5a5; font-weight: bold; margin-left: 10px;">Reload</a>
</div>
<script src="js/aquarium.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
+831
View File
@@ -0,0 +1,831 @@
/**
* Network Aquarium Engine
* High-performance 60 FPS HTML5 Canvas Marine Ecosystem
*/
(function () {
window.AquariumEngine = {
canvas: null,
ctx: null,
dotNetRef: null,
creatures: [],
bubbles: [],
caustics: [],
audioContext: null,
soundEnabled: false,
selectedCreature: null,
hoveredCreature: null,
filterMethod: 'ALL',
filterBiome: 'ALL',
searchQuery: '',
animationFrameId: null,
lastTime: 0,
width: 0,
height: 0,
dpr: 1,
// Initialize engine with canvas element and Blazor component reference
init: function (canvasId, dotNetHelper) {
this.canvas = document.getElementById(canvasId);
if (!this.canvas) return;
this.ctx = this.canvas.getContext('2d');
this.dotNetRef = dotNetHelper;
this.dpr = window.devicePixelRatio || 1;
this.resize();
window.addEventListener('resize', () => this.resize());
// Interaction events
this.canvas.addEventListener('mousemove', (e) => this.handleMouseMove(e));
this.canvas.addEventListener('click', (e) => this.handleClick(e));
this.canvas.addEventListener('mouseleave', () => { this.hoveredCreature = null; });
// Initialize background ambient elements
this.initBackgroundElements();
// Start 60 FPS loop
this.lastTime = performance.now();
this.loop = this.loop.bind(this);
this.animationFrameId = requestAnimationFrame(this.loop);
console.log('🌊 Network Aquarium Engine initialized successfully.');
},
resize: function () {
if (!this.canvas) return;
const rect = this.canvas.getBoundingClientRect();
this.width = rect.width;
this.height = rect.height;
this.canvas.width = this.width * this.dpr;
this.canvas.height = this.height * this.dpr;
this.ctx.scale(this.dpr, this.dpr);
},
initBackgroundElements: function () {
this.bubbles = [];
for (let i = 0; i < 45; i++) {
this.bubbles.push({
x: Math.random() * (this.width || 1200),
y: Math.random() * (this.height || 800),
radius: 1 + Math.random() * 4,
speed: 0.3 + Math.random() * 0.8,
wobbleSpeed: 0.02 + Math.random() * 0.03,
wobbleAmp: 0.5 + Math.random() * 1.5,
opacity: 0.15 + Math.random() * 0.35,
phase: Math.random() * Math.PI * 2
});
}
},
initAudio: function () {
if (!this.audioContext) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (AudioCtx) {
this.audioContext = new AudioCtx();
}
}
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
},
toggleSound: function () {
this.soundEnabled = !this.soundEnabled;
if (this.soundEnabled) {
this.initAudio();
this.playTone(440, 'sine', 0.1, 0.05);
}
return this.soundEnabled;
},
playTone: function (freq, type, duration, gainLevel = 0.05) {
if (!this.soundEnabled || !this.audioContext) return;
try {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = type || 'sine';
osc.frequency.setValueAtTime(freq, this.audioContext.currentTime);
gain.gain.setValueAtTime(gainLevel, this.audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, this.audioContext.currentTime + duration);
osc.connect(gain);
gain.connect(this.audioContext.destination);
osc.start();
osc.stop(this.audioContext.currentTime + duration);
} catch (e) { }
},
playSoundForCreature: function (type) {
if (!this.soundEnabled || !this.audioContext) return;
switch (type) {
case 'Fish':
this.playTone(520 + Math.random() * 200, 'sine', 0.15, 0.03);
break;
case 'Dolphin':
this.playTone(880, 'triangle', 0.25, 0.06);
setTimeout(() => this.playTone(1320, 'sine', 0.2, 0.04), 80);
break;
case 'Whale':
this.playTone(110, 'sine', 1.2, 0.12);
break;
case 'Jellyfish':
this.playTone(220, 'sawtooth', 0.4, 0.07);
break;
case 'Shark':
this.playTone(150, 'sawtooth', 0.35, 0.08);
break;
case 'Crab':
this.playTone(680, 'square', 0.08, 0.02);
break;
}
},
spawnCreature: function (spawnDto) {
if (!spawnDto || !spawnDto.event) return;
const cType = spawnDto.visuals.creatureType || 'Fish';
const depthP = spawnDto.visuals.targetDepthPercent || 0.5;
const targetY = (this.height || 600) * depthP;
const creature = {
id: spawnDto.event.id || Math.random().toString(36).substring(2),
type: cType,
event: spawnDto.event,
visuals: spawnDto.visuals,
x: -120, // Spawn off-screen left
y: targetY + (Math.random() * 40 - 20),
vx: (1.2 + Math.random() * 0.8) * (spawnDto.visuals.speed || 1.0),
vy: 0,
baseY: targetY,
scale: spawnDto.visuals.scale || 1.0,
primaryColor: spawnDto.visuals.primaryColor || '#38bdf8',
secondaryColor: spawnDto.visuals.secondaryColor || '#0284c7',
time: Math.random() * 100,
pulse: 0,
sonarPulses: [],
waterJets: [],
dead: false,
opacity: 1.0
};
// Custom positioning/speed overrides
if (cType === 'Dolphin') {
creature.vx = 2.4;
creature.arcAmp = 45;
creature.arcFreq = 0.03;
} else if (cType === 'Whale') {
creature.vx = 0.65;
creature.x = -240;
} else if (cType === 'Jellyfish') {
creature.vx = 0.5;
creature.vy = -0.3; // floats slightly upward
} else if (cType === 'Shark') {
creature.vx = 3.2;
} else if (cType === 'Crab') {
creature.vx = 0.7;
creature.y = (this.height || 600) - 40;
creature.baseY = (this.height || 600) - 40;
}
this.creatures.push(creature);
this.playSoundForCreature(cType);
// Limit maximum active creatures on canvas to avoid slowdown
if (this.creatures.length > 150) {
this.creatures.shift();
}
},
spawnBatch: function (dtos) {
if (!dtos || !Array.isArray(dtos)) return;
dtos.forEach(d => this.spawnCreature(d));
},
setFilters: function (method, biome, query) {
this.filterMethod = method || 'ALL';
this.filterBiome = biome || 'ALL';
this.searchQuery = (query || '').toLowerCase().trim();
},
clearAll: function () {
this.creatures = [];
this.selectedCreature = null;
this.hoveredCreature = null;
},
// Click / Hit-testing
handleMouseMove: function (e) {
const rect = this.canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
let found = null;
for (let i = this.creatures.length - 1; i >= 0; i--) {
const c = this.creatures[i];
const hitRadius = Math.max(25, 30 * c.scale);
const dx = mx - c.x;
const dy = my - c.y;
if (dx * dx + dy * dy < hitRadius * hitRadius) {
found = c;
break;
}
}
this.hoveredCreature = found;
this.canvas.style.cursor = found ? 'pointer' : 'default';
},
handleClick: function (e) {
if (this.hoveredCreature) {
this.selectedCreature = this.hoveredCreature;
if (this.dotNetRef) {
this.dotNetRef.invokeMethodAsync('OnCreatureSelected', this.selectedCreature.event);
}
this.playTone(800, 'sine', 0.1, 0.04);
}
},
selectCreatureById: function (id) {
const c = this.creatures.find(item => item.id === id);
if (c) {
this.selectedCreature = c;
}
},
// 60 FPS Animation loop
loop: function (currentTime) {
const dt = Math.min(0.1, (currentTime - this.lastTime) / 1000);
this.lastTime = currentTime;
this.update(dt);
this.render();
this.animationFrameId = requestAnimationFrame(this.loop);
},
update: function (dt) {
const w = this.width;
const h = this.height;
// Update ambient bubbles
this.bubbles.forEach(b => {
b.y -= b.speed * 60 * dt;
b.phase += b.wobbleSpeed;
b.currentX = b.x + Math.sin(b.phase) * b.wobbleAmp * 10;
if (b.y < -10) {
b.y = h + 10;
b.x = Math.random() * w;
}
});
// Update creatures
for (let i = this.creatures.length - 1; i >= 0; i--) {
const c = this.creatures[i];
c.time += dt * 5;
// Physics movement
if (c.type === 'Dolphin') {
c.x += c.vx * 60 * dt;
c.y = c.baseY + Math.sin(c.time * c.arcFreq * 10) * c.arcAmp;
// Spawn sonar pulse every 1.5s
if (Math.random() < 0.02) {
c.sonarPulses.push({ radius: 10, maxRadius: 70, opacity: 0.8 });
}
} else if (c.type === 'Whale') {
c.x += c.vx * 60 * dt;
c.y = c.baseY + Math.sin(c.time * 0.15) * 8;
// Water blowhole jet
if (Math.random() < 0.03) {
c.waterJets.push({ yOff: 0, opacity: 0.9 });
}
} else if (c.type === 'Jellyfish') {
c.x += c.vx * 60 * dt;
c.pulse = Math.sin(c.time * 0.8);
c.y += (c.vy + c.pulse * 0.4) * 60 * dt;
} else if (c.type === 'Shark') {
c.x += c.vx * 60 * dt;
c.y = c.baseY + Math.sin(c.time * 0.4) * 12;
} else if (c.type === 'Crab') {
c.x += c.vx * 60 * dt;
c.y = (h - 35) + Math.abs(Math.sin(c.time * 2)) * 3;
} else {
// Regular fish
c.x += c.vx * 60 * dt;
c.y = c.baseY + Math.sin(c.time * 0.6) * 12;
}
// Update sonar pulses
if (c.sonarPulses) {
for (let j = c.sonarPulses.length - 1; j >= 0; j--) {
const sp = c.sonarPulses[j];
sp.radius += 40 * dt;
sp.opacity -= 0.8 * dt;
if (sp.opacity <= 0 || sp.radius >= sp.maxRadius) {
c.sonarPulses.splice(j, 1);
}
}
}
// Remove creatures that fully swam across screen
if (c.x > w + 250) {
this.creatures.splice(i, 1);
if (this.selectedCreature === c) {
this.selectedCreature = null;
}
}
}
},
render: function () {
const ctx = this.ctx;
const w = this.width;
const h = this.height;
if (!ctx || w === 0 || h === 0) return;
// 1. Ocean Background (Gradient Depth)
const oceanGrad = ctx.createLinearGradient(0, 0, 0, h);
oceanGrad.addColorStop(0, '#03203f'); // Sunlight surface turquoise-blue
oceanGrad.addColorStop(0.3, '#02182b'); // Pelagic navy
oceanGrad.addColorStop(0.7, '#01101e'); // Deep twilight
oceanGrad.addColorStop(1, '#00070e'); // Abyssal trench
ctx.fillStyle = oceanGrad;
ctx.fillRect(0, 0, w, h);
// 2. Sunlight Rays & Caustics at Top
this.renderSunlightRays(ctx, w, h);
// 3. Depth Biome Guide Lines (Subtle water layer dividers)
this.renderBiomeDividers(ctx, w, h);
// 4. Ambient Bubbles
ctx.save();
this.bubbles.forEach(b => {
ctx.beginPath();
ctx.arc(b.currentX || b.x, b.y, b.radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(186, 230, 253, ${b.opacity})`;
ctx.fill();
});
ctx.restore();
// 5. Ocean Floor & Seaweed
this.renderOceanFloor(ctx, w, h);
// 6. Render All Creatures
this.creatures.forEach(c => {
const isFiltered = this.isCreatureFiltered(c);
if (isFiltered) return;
const isHovered = (this.hoveredCreature === c);
const isSelected = (this.selectedCreature === c);
ctx.save();
ctx.translate(c.x, c.y);
// Highlight glow if hovered/selected
if (isSelected || isHovered) {
ctx.beginPath();
ctx.arc(0, 0, 36 * c.scale, 0, Math.PI * 2);
ctx.strokeStyle = isSelected ? '#38bdf8' : 'rgba(255,255,255,0.6)';
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.setLineDash([4, 4]);
ctx.stroke();
ctx.setLineDash([]);
}
// Render specific creature shape
switch (c.type) {
case 'Dolphin':
this.drawDolphin(ctx, c);
break;
case 'Whale':
this.drawWhale(ctx, c);
break;
case 'Jellyfish':
this.drawJellyfish(ctx, c);
break;
case 'Shark':
this.drawShark(ctx, c);
break;
case 'Crab':
this.drawCrab(ctx, c);
break;
default:
this.drawFish(ctx, c);
break;
}
// Render floating status tag
this.drawCreatureBadge(ctx, c, isSelected || isHovered);
ctx.restore();
});
},
renderSunlightRays: function (ctx, w, h) {
ctx.save();
ctx.globalCompositeOperation = 'screen';
const rayGrad = ctx.createLinearGradient(0, 0, w, h * 0.4);
rayGrad.addColorStop(0, 'rgba(56, 189, 248, 0.12)');
rayGrad.addColorStop(0.5, 'rgba(14, 165, 233, 0.04)');
rayGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = rayGrad;
for (let i = 0; i < 5; i++) {
const rayX = (w * 0.2 * i) + Math.sin(this.lastTime * 0.0005 + i) * 30;
ctx.beginPath();
ctx.moveTo(rayX, 0);
ctx.lineTo(rayX + 100, 0);
ctx.lineTo(rayX + 220, h * 0.6);
ctx.lineTo(rayX + 40, h * 0.6);
ctx.closePath();
ctx.fill();
}
ctx.restore();
},
renderBiomeDividers: function (ctx, w, h) {
ctx.save();
ctx.font = '10px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(148, 163, 184, 0.25)';
ctx.strokeStyle = 'rgba(148, 163, 184, 0.1)';
ctx.setLineDash([6, 12]);
// Surface / Pelagic line
ctx.beginPath();
ctx.moveTo(20, h * 0.25);
ctx.lineTo(w - 20, h * 0.25);
ctx.stroke();
ctx.fillText('▲ SURFACE / CDN & WEBSOCKETS (0 - 50m)', 30, h * 0.25 - 6);
// Pelagic / Abyssal line
ctx.beginPath();
ctx.moveTo(20, h * 0.75);
ctx.lineTo(w - 20, h * 0.75);
ctx.stroke();
ctx.fillText('▼ ABYSSAL / DATABASE & BACKGROUND WORKERS (200m+)', 30, h * 0.75 - 6);
ctx.restore();
},
renderOceanFloor: function (ctx, w, h) {
ctx.save();
// Sand floor
const floorGrad = ctx.createLinearGradient(0, h - 35, 0, h);
floorGrad.addColorStop(0, '#0a192f');
floorGrad.addColorStop(1, '#020c1b');
ctx.fillStyle = floorGrad;
ctx.beginPath();
ctx.moveTo(0, h);
ctx.lineTo(0, h - 25);
for (let x = 0; x <= w; x += 40) {
ctx.lineTo(x, h - 25 + Math.sin(x * 0.02) * 5);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
// Animated Seaweed
ctx.strokeStyle = '#059669';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
const weedClusters = [60, 180, 340, 520, 720, 940, 1150, 1380];
weedClusters.forEach((wx, idx) => {
if (wx > w) return;
const sway = Math.sin(this.lastTime * 0.0015 + idx) * 12;
ctx.beginPath();
ctx.moveTo(wx, h - 15);
ctx.quadraticCurveTo(wx + sway, h - 55, wx + sway * 1.5, h - 90);
ctx.stroke();
});
ctx.restore();
},
// Creature Renderers
drawFish: function (ctx, c) {
const s = c.scale * 1.0;
const wag = Math.sin(c.time * 2.2) * 6;
// Fish body
ctx.beginPath();
ctx.ellipse(0, 0, 22 * s, 11 * s, 0, 0, Math.PI * 2);
ctx.fillStyle = c.primaryColor;
ctx.shadowColor = c.primaryColor;
ctx.shadowBlur = 8;
ctx.fill();
ctx.shadowBlur = 0;
// Belly / accent highlight
ctx.beginPath();
ctx.ellipse(2 * s, 3 * s, 14 * s, 5 * s, 0, 0, Math.PI * 2);
ctx.fillStyle = c.secondaryColor;
ctx.fill();
// Tail fin (oscillating)
ctx.beginPath();
ctx.moveTo(-18 * s, 0);
ctx.lineTo(-32 * s, -10 * s + wag);
ctx.lineTo(-27 * s, 0);
ctx.lineTo(-32 * s, 10 * s + wag);
ctx.closePath();
ctx.fillStyle = c.primaryColor;
ctx.fill();
// Dorsal & Pectoral fin
ctx.beginPath();
ctx.moveTo(-4 * s, -10 * s);
ctx.quadraticCurveTo(2 * s, -18 * s, 8 * s, -10 * s);
ctx.fillStyle = c.secondaryColor;
ctx.fill();
// Eye
ctx.beginPath();
ctx.arc(12 * s, -3 * s, 3.5 * s, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.beginPath();
ctx.arc(13 * s, -3 * s, 1.8 * s, 0, Math.PI * 2);
ctx.fillStyle = '#0f172a';
ctx.fill();
},
drawDolphin: function (ctx, c) {
const s = c.scale * 1.2;
const wag = Math.sin(c.time * 1.8) * 5;
// Sonar waves
if (c.sonarPulses) {
c.sonarPulses.forEach(p => {
ctx.save();
ctx.beginPath();
ctx.arc(20 * s, 0, p.radius, -Math.PI * 0.4, Math.PI * 0.4);
ctx.strokeStyle = `rgba(6, 182, 212, ${p.opacity})`;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
});
}
// Sleek Dolphin body
ctx.beginPath();
ctx.moveTo(32 * s, 0); // Snout/Beak
ctx.quadraticCurveTo(15 * s, -14 * s, -12 * s, -8 * s);
ctx.quadraticCurveTo(-28 * s, -4 * s, -40 * s, wag);
ctx.quadraticCurveTo(-25 * s, 10 * s, 5 * s, 10 * s);
ctx.quadraticCurveTo(24 * s, 6 * s, 32 * s, 0);
ctx.closePath();
ctx.fillStyle = '#06b6d4';
ctx.shadowColor = '#0891b2';
ctx.shadowBlur = 12;
ctx.fill();
ctx.shadowBlur = 0;
// Belly
ctx.beginPath();
ctx.moveTo(25 * s, 2 * s);
ctx.quadraticCurveTo(6 * s, 8 * s, -20 * s, 6 * s);
ctx.quadraticCurveTo(-5 * s, 0, 25 * s, 2 * s);
ctx.fillStyle = '#e0f2fe';
ctx.fill();
// Dorsal fin
ctx.beginPath();
ctx.moveTo(-2 * s, -12 * s);
ctx.quadraticCurveTo(4 * s, -24 * s, 12 * s, -11 * s);
ctx.fillStyle = '#0891b2';
ctx.fill();
// Tail fluke
ctx.beginPath();
ctx.moveTo(-38 * s, wag);
ctx.lineTo(-50 * s, -12 * s + wag);
ctx.lineTo(-45 * s, wag);
ctx.lineTo(-50 * s, 12 * s + wag);
ctx.closePath();
ctx.fillStyle = '#06b6d4';
ctx.fill();
// Eye
ctx.beginPath();
ctx.arc(18 * s, -4 * s, 2.5 * s, 0, Math.PI * 2);
ctx.fillStyle = '#0f172a';
ctx.fill();
},
drawWhale: function (ctx, c) {
const s = c.scale * 1.5;
const wag = Math.sin(c.time * 0.8) * 6;
// Giant Whale Body
ctx.beginPath();
ctx.moveTo(55 * s, -4 * s);
ctx.quadraticCurveTo(25 * s, -26 * s, -35 * s, -15 * s);
ctx.quadraticCurveTo(-65 * s, -6 * s, -85 * s, wag);
ctx.quadraticCurveTo(-55 * s, 24 * s, 0 * s, 22 * s);
ctx.quadraticCurveTo(42 * s, 18 * s, 55 * s, -4 * s);
ctx.closePath();
ctx.fillStyle = '#1d4ed8';
ctx.shadowColor = '#3b82f6';
ctx.shadowBlur = 16;
ctx.fill();
ctx.shadowBlur = 0;
// Belly grooves
ctx.strokeStyle = '#60a5fa';
ctx.lineWidth = 1.5;
for (let g = 0; g < 4; g++) {
ctx.beginPath();
ctx.moveTo(35 * s - g * 10 * s, 12 * s);
ctx.quadraticCurveTo(10 * s - g * 8 * s, 18 * s, -15 * s - g * 12 * s, 10 * s);
ctx.stroke();
}
// Flukes
ctx.beginPath();
ctx.moveTo(-82 * s, wag);
ctx.lineTo(-102 * s, -22 * s + wag);
ctx.lineTo(-94 * s, wag);
ctx.lineTo(-102 * s, 22 * s + wag);
ctx.closePath();
ctx.fillStyle = '#1e40af';
ctx.fill();
// Tiny eye
ctx.beginPath();
ctx.arc(38 * s, -6 * s, 2.5 * s, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.beginPath();
ctx.arc(39 * s, -6 * s, 1.2 * s, 0, Math.PI * 2);
ctx.fillStyle = '#020617';
ctx.fill();
},
drawJellyfish: function (ctx, c) {
const s = c.scale * 1.2;
const p = c.pulse || 0;
const isDanger = c.visuals.isDangerous;
const col = isDanger ? '#ef4444' : '#f59e0b';
// Pulsing translucent Bell/Dome
ctx.beginPath();
ctx.arc(0, -8 * s, 20 * s + p * 3, Math.PI, 0);
ctx.quadraticCurveTo(15 * s, 4 * s + p * 2, 0, 2 * s);
ctx.quadraticCurveTo(-15 * s, 4 * s + p * 2, -20 * s - p * 3, -8 * s);
ctx.fillStyle = isDanger ? 'rgba(239, 68, 68, 0.75)' : 'rgba(245, 158, 11, 0.75)';
ctx.shadowColor = col;
ctx.shadowBlur = 18;
ctx.fill();
ctx.shadowBlur = 0;
// Warning Inner Core
ctx.beginPath();
ctx.arc(0, -10 * s, 8 * s, 0, Math.PI * 2);
ctx.fillStyle = isDanger ? '#fee2e2' : '#fef3c7';
ctx.fill();
// Undulating Tentacles
ctx.strokeStyle = col;
ctx.lineWidth = 1.8;
ctx.lineCap = 'round';
for (let t = -3; t <= 3; t++) {
const tx = t * 5 * s;
const wave = Math.sin(c.time * 1.5 + t) * 8;
ctx.beginPath();
ctx.moveTo(tx, 0);
ctx.quadraticCurveTo(tx + wave, 18 * s, tx - wave, 36 * s);
ctx.stroke();
}
},
drawShark: function (ctx, c) {
const s = c.scale * 1.3;
const wag = Math.sin(c.time * 2.5) * 8;
// Predator Shark Body
ctx.beginPath();
ctx.moveTo(42 * s, 0);
ctx.quadraticCurveTo(15 * s, -16 * s, -20 * s, -10 * s);
ctx.lineTo(-55 * s, wag);
ctx.quadraticCurveTo(-25 * s, 14 * s, 10 * s, 12 * s);
ctx.closePath();
ctx.fillStyle = '#334155';
ctx.shadowColor = '#e11d48';
ctx.shadowBlur = 14;
ctx.fill();
ctx.shadowBlur = 0;
// Sharp Dorsal Fin
ctx.beginPath();
ctx.moveTo(2 * s, -14 * s);
ctx.lineTo(12 * s, -30 * s);
ctx.lineTo(18 * s, -12 * s);
ctx.closePath();
ctx.fillStyle = '#1e293b';
ctx.fill();
// Shark Tail
ctx.beginPath();
ctx.moveTo(-50 * s, wag);
ctx.lineTo(-68 * s, -18 * s + wag);
ctx.lineTo(-60 * s, wag);
ctx.lineTo(-68 * s, 14 * s + wag);
ctx.closePath();
ctx.fillStyle = '#334155';
ctx.fill();
// Threatening red eye & gills
ctx.beginPath();
ctx.arc(28 * s, -3 * s, 2.5 * s, 0, Math.PI * 2);
ctx.fillStyle = '#e11d48';
ctx.fill();
},
drawCrab: function (ctx, c) {
const s = c.scale * 1.0;
const legWiggle = Math.sin(c.time * 4) * 4;
// Crab Body/Shell
ctx.beginPath();
ctx.ellipse(0, 0, 16 * s, 10 * s, 0, 0, Math.PI * 2);
ctx.fillStyle = '#ea580c';
ctx.shadowColor = '#f97316';
ctx.shadowBlur = 8;
ctx.fill();
ctx.shadowBlur = 0;
// Claws
ctx.strokeStyle = '#c2410c';
ctx.lineWidth = 3;
// Left claw
ctx.beginPath();
ctx.moveTo(-10 * s, -4 * s);
ctx.lineTo(-18 * s, -14 * s);
ctx.stroke();
// Right claw
ctx.beginPath();
ctx.moveTo(10 * s, -4 * s);
ctx.lineTo(18 * s, -14 * s);
ctx.stroke();
// Legs
ctx.lineWidth = 2;
for (let l = -2; l <= 2; l++) {
if (l === 0) continue;
ctx.beginPath();
ctx.moveTo(l * 6 * s, 4 * s);
ctx.lineTo(l * 10 * s, 14 * s + legWiggle);
ctx.stroke();
}
// Stalk eyes
ctx.beginPath();
ctx.arc(-5 * s, -11 * s, 2 * s, 0, Math.PI * 2);
ctx.arc(5 * s, -11 * s, 2 * s, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
},
drawCreatureBadge: function (ctx, c, isEmphasized) {
const label = c.visuals.displayLabel || `${c.event.method} ${c.event.statusCode}`;
const s = c.scale;
const tagY = -24 * s - (c.type === 'Whale' ? 14 : 0);
ctx.save();
ctx.font = isEmphasized ? 'bold 11px "JetBrains Mono", monospace' : '10px "JetBrains Mono", monospace';
const metrics = ctx.measureText(label);
const padX = 6;
const tagW = metrics.width + padX * 2;
const tagH = 16;
// Badge Background pill
ctx.beginPath();
ctx.roundRect(-tagW / 2, tagY - tagH / 2, tagW, tagH, 4);
ctx.fillStyle = isEmphasized ? 'rgba(15, 23, 42, 0.95)' : 'rgba(15, 23, 42, 0.65)';
ctx.fill();
ctx.strokeStyle = isEmphasized ? c.primaryColor : 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 1;
ctx.stroke();
// Badge Text
ctx.fillStyle = isEmphasized ? '#ffffff' : '#cbd5e1';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, 0, tagY);
ctx.restore();
},
isCreatureFiltered: function (c) {
if (this.filterMethod !== 'ALL' && c.event.method !== this.filterMethod) {
return true;
}
if (this.filterBiome !== 'ALL' && c.visuals.biome !== this.filterBiome) {
return true;
}
if (this.searchQuery && !c.event.path.toLowerCase().includes(this.searchQuery)) {
return true;
}
return false;
}
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
[
{
"date": "2022-01-06",
"temperatureC": 1,
"summary": "Freezing"
},
{
"date": "2022-01-07",
"temperatureC": 14,
"summary": "Bracing"
},
{
"date": "2022-01-08",
"temperatureC": -13,
"summary": "Freezing"
},
{
"date": "2022-01-09",
"temperatureC": -16,
"summary": "Balmy"
},
{
"date": "2022-01-10",
"temperatureC": -2,
"summary": "Chilly"
}
]
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Aquarium.Shared\Aquarium.Shared.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.SignalR;
using Aquarium.Shared;
namespace Aquarium.Server.Hubs;
public interface IAquariumClient
{
Task OnCreatureSpawn(CreatureSpawnDto creature);
Task OnBatchSpawn(List<CreatureSpawnDto> creatures);
Task OnStatsUpdated(AquariumStats stats);
Task OnDolphinPulse(string streamId, string message);
}
public class AquariumHub : Hub<IAquariumClient>
{
private readonly ILogger<AquariumHub> _logger;
public AquariumHub(ILogger<AquariumHub> logger)
{
_logger = logger;
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation("Aquarium client connected: {ConnectionId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Aquarium client disconnected: {ConnectionId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,83 @@
using System.Diagnostics;
using Aquarium.Server.Services;
using Aquarium.Shared;
namespace Aquarium.Server.Middleware;
public class TrafficTelemetryMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TrafficTelemetryMiddleware> _logger;
public TrafficTelemetryMiddleware(RequestDelegate next, ILogger<TrafficTelemetryMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, ITrafficEventChannel channel)
{
var path = context.Request.Path.Value ?? "/";
// Ignore SignalR hub internal connections from self-telemetry to prevent recursive loops
if (path.StartsWith("/hubs/aquarium") || path.StartsWith("/favicon.ico"))
{
await _next(context);
return;
}
var sw = Stopwatch.StartNew();
var originalBodyStream = context.Response.Body;
using var responseBody = new MemoryStream();
context.Response.Body = responseBody;
Exception? capturedException = null;
try
{
await _next(context);
}
catch (Exception ex)
{
capturedException = ex;
context.Response.StatusCode = 500;
throw;
}
finally
{
sw.Stop();
var durationMs = sw.Elapsed.TotalMilliseconds;
var statusCode = context.Response.StatusCode;
var bytes = responseBody.Length;
responseBody.Seek(0, SeekOrigin.Begin);
await responseBody.CopyToAsync(originalBodyStream);
context.Response.Body = originalBodyStream;
var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
var userAgent = context.Request.Headers.UserAgent.ToString();
var isWs = context.WebSockets.IsWebSocketRequest || statusCode == 101;
var evt = new TrafficEvent
{
Id = Guid.NewGuid().ToString("N"),
Timestamp = DateTimeOffset.UtcNow,
Method = isWs ? "WS" : context.Request.Method,
Path = path,
StatusCode = statusCode,
DurationMs = Math.Round(durationMs, 1),
Bytes = bytes > 0 ? bytes : 512,
ClientIp = clientIp,
ClientCountry = "SK",
ClientCity = "Bratislava",
UserAgent = string.IsNullOrWhiteSpace(userAgent) ? "Client" : userAgent,
TraceId = context.TraceIdentifier,
ServiceName = "Server",
IsWebSocket = isWs,
ErrorMessage = capturedException?.Message
};
channel.TryPublish(evt);
}
}
}
+81
View File
@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Mvc;
using Aquarium.Server.Hubs;
using Aquarium.Server.Middleware;
using Aquarium.Server.Services;
using Aquarium.Shared;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.SetIsOriginAllowed(_ => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = true;
options.MaximumReceiveMessageSize = 1024 * 1024; // 1 MB
});
// Singletons & Hosted Services
builder.Services.AddSingleton<ITrafficEventChannel, TrafficEventChannel>();
builder.Services.AddSingleton<TrafficSimulatorService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TrafficSimulatorService>());
builder.Services.AddSingleton<TrafficBroadcasterService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<TrafficBroadcasterService>());
var app = builder.Build();
app.UseCors("AllowAll");
// Custom Middleware for live server traffic telemetry
app.UseMiddleware<TrafficTelemetryMiddleware>();
// SignalR Hub Endpoint
app.MapHub<AquariumHub>("/hubs/aquarium");
// REST API Endpoints for external telemetry ingestion
app.MapPost("/api/telemetry/event", (TrafficEvent evt, ITrafficEventChannel channel) =>
{
channel.TryPublish(evt);
return Results.Accepted();
});
// REST API Endpoints for testing & simulation control
app.MapPost("/api/simulator/trigger/{creatureType}", (string creatureType, TrafficSimulatorService simulator) =>
{
if (Enum.TryParse<CreatureType>(creatureType, true, out var type))
{
simulator.TriggerSpecificCreature(type);
return Results.Ok(new { status = "triggered", type = type.ToString() });
}
return Results.BadRequest(new { error = $"Invalid creature type: {creatureType}" });
});
app.MapPost("/api/simulator/toggle", ([FromQuery] bool? enable, [FromQuery] double? intensity, TrafficSimulatorService simulator) =>
{
if (enable.HasValue) simulator.IsEnabled = enable.Value;
if (intensity.HasValue) simulator.IntensityMultiplier = Math.Clamp(intensity.Value, 0.1, 10.0);
return Results.Ok(new
{
enabled = simulator.IsEnabled,
intensity = simulator.IntensityMultiplier
});
});
app.MapGet("/api/stats", (TrafficBroadcasterService broadcaster) =>
{
return Results.Ok(broadcaster.CurrentStats);
});
app.MapGet("/api/health", () => Results.Ok(new { status = "Healthy", server = "Network Aquarium Server", version = "1.0.0-net10" }));
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5050",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7050;http://localhost:5050",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,172 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using Aquarium.Server.Hubs;
using Aquarium.Shared;
namespace Aquarium.Server.Services;
public class TrafficBroadcasterService : BackgroundService
{
private readonly ITrafficEventChannel _channel;
private readonly IHubContext<AquariumHub, IAquariumClient> _hubContext;
private readonly ILogger<TrafficBroadcasterService> _logger;
private readonly ConcurrentQueue<DateTimeOffset> _requestTimestamps = new();
private long _totalRequests;
private long _totalBytes;
private int _total2xx;
private int _total4xx;
private int _total5xx;
private double _latencySum;
private long _latencyCount;
public TrafficBroadcasterService(
ITrafficEventChannel channel,
IHubContext<AquariumHub, IAquariumClient> hubContext,
ILogger<TrafficBroadcasterService> logger)
{
_channel = channel;
_hubContext = hubContext;
_logger = logger;
}
public AquariumStats CurrentStats => ComputeCurrentStats();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("TrafficBroadcasterService started.");
var statsTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
var statsTask = Task.Run(async () =>
{
while (!stoppingToken.IsCancellationRequested && await statsTimer.WaitForNextTickAsync(stoppingToken))
{
try
{
var stats = ComputeCurrentStats();
await _hubContext.Clients.All.OnStatsUpdated(stats);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
_logger.LogTrace(ex, "Error broadcasting stats");
}
}
}, stoppingToken);
var buffer = new List<CreatureSpawnDto>(32);
var lastFlush = DateTimeOffset.UtcNow;
while (!stoppingToken.IsCancellationRequested)
{
try
{
while (_channel.Reader.TryRead(out var evt))
{
RecordMetrics(evt);
var creature = CreatureSpawnDto.FromEvent(evt);
buffer.Add(creature);
if (buffer.Count >= 20)
{
await FlushBufferAsync(buffer);
lastFlush = DateTimeOffset.UtcNow;
}
}
if (buffer.Count > 0 && (DateTimeOffset.UtcNow - lastFlush).TotalMilliseconds > 50)
{
await FlushBufferAsync(buffer);
lastFlush = DateTimeOffset.UtcNow;
}
if (await _channel.Reader.WaitToReadAsync(stoppingToken))
{
continue;
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in TrafficBroadcaster loop");
await Task.Delay(100, stoppingToken);
}
}
await statsTask;
}
private async Task FlushBufferAsync(List<CreatureSpawnDto> buffer)
{
if (buffer.Count == 1)
{
await _hubContext.Clients.All.OnCreatureSpawn(buffer[0]);
}
else if (buffer.Count > 1)
{
await _hubContext.Clients.All.OnBatchSpawn(new List<CreatureSpawnDto>(buffer));
}
buffer.Clear();
}
private void RecordMetrics(TrafficEvent evt)
{
Interlocked.Increment(ref _totalRequests);
Interlocked.Add(ref _totalBytes, evt.Bytes);
if (evt.StatusCode is >= 200 and < 300) Interlocked.Increment(ref _total2xx);
else if (evt.StatusCode is >= 400 and < 500) Interlocked.Increment(ref _total4xx);
else if (evt.StatusCode >= 500) Interlocked.Increment(ref _total5xx);
lock (_requestTimestamps)
{
_latencySum += evt.DurationMs;
_latencyCount++;
}
var now = DateTimeOffset.UtcNow;
_requestTimestamps.Enqueue(now);
var cutoff = now.AddSeconds(-5);
while (_requestTimestamps.TryPeek(out var oldest) && oldest < cutoff)
{
_requestTimestamps.TryDequeue(out _);
}
}
private AquariumStats ComputeCurrentStats()
{
var now = DateTimeOffset.UtcNow;
var cutoff = now.AddSeconds(-5);
while (_requestTimestamps.TryPeek(out var oldest) && oldest < cutoff)
{
_requestTimestamps.TryDequeue(out _);
}
var rps = _requestTimestamps.Count / 5.0;
double avgLatency;
lock (_requestTimestamps)
{
avgLatency = _latencyCount > 0 ? _latencySum / _latencyCount : 0.0;
}
var totalReq = Interlocked.Read(ref _totalRequests);
var errCount = _total4xx + _total5xx;
var errRate = totalReq > 0 ? (double)errCount / totalReq * 100.0 : 0.0;
return new AquariumStats
{
RequestsPerSecond = Math.Round(rps, 1),
AverageLatencyMs = Math.Round(avgLatency, 1),
ErrorRatePercent = Math.Round(errRate, 1),
TotalRequests = totalReq,
TotalBytesTransferred = Interlocked.Read(ref _totalBytes),
Total2xx = _total2xx,
Total4xx = _total4xx,
Total5xx = _total5xx,
LastUpdated = now
};
}
}
@@ -0,0 +1,34 @@
using System.Threading.Channels;
using Aquarium.Shared;
namespace Aquarium.Server.Services;
public interface ITrafficEventChannel
{
bool TryPublish(TrafficEvent evt);
ValueTask PublishAsync(TrafficEvent evt, CancellationToken ct = default);
ChannelReader<TrafficEvent> Reader { get; }
}
public class TrafficEventChannel : ITrafficEventChannel
{
private readonly Channel<TrafficEvent> _channel;
public TrafficEventChannel(int capacity = 10000)
{
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<TrafficEvent>(options);
}
public bool TryPublish(TrafficEvent evt) => _channel.Writer.TryWrite(evt);
public ValueTask PublishAsync(TrafficEvent evt, CancellationToken ct = default)
=> _channel.Writer.WriteAsync(evt, ct);
public ChannelReader<TrafficEvent> Reader => _channel.Reader;
}
@@ -0,0 +1,263 @@
using Aquarium.Shared;
namespace Aquarium.Server.Services;
public class TrafficSimulatorService : BackgroundService
{
private readonly ITrafficEventChannel _channel;
private readonly ILogger<TrafficSimulatorService> _logger;
private readonly Random _rnd = new();
public bool IsEnabled { get; set; } = true;
public double IntensityMultiplier { get; set; } = 1.0;
public TrafficSimulatorService(ITrafficEventChannel channel, ILogger<TrafficSimulatorService> logger)
{
_channel = channel;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("TrafficSimulatorService initialized (Enabled: {Enabled}).", IsEnabled);
// Initial burst on startup so aquarium isn't empty
await Task.Delay(1000, stoppingToken);
for (int i = 0; i < 8; i++)
{
GenerateRandomEvent();
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (IsEnabled)
{
GenerateRandomEvent();
}
var delayMs = (int)(_rnd.Next(250, 1200) / Math.Max(0.1, IntensityMultiplier));
await Task.Delay(delayMs, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in TrafficSimulator loop");
await Task.Delay(1000, stoppingToken);
}
}
}
public void TriggerSpecificCreature(CreatureType type)
{
var evt = type switch
{
CreatureType.Whale => new TrafficEvent
{
Method = "GET",
Path = "/downloads/system-backup-v2.tar.gz",
StatusCode = 200,
DurationMs = _rnd.Next(800, 2400),
Bytes = _rnd.Next(4_000_000, 15_000_000),
ClientCountry = "DE",
ClientCity = "Frankfurt",
ClientIp = "194.25.0.12",
TraceId = $"whl_{Guid.NewGuid():N}"[..16]
},
CreatureType.Dolphin => new TrafficEvent
{
Method = "WS",
Path = "/ws/live-feed",
StatusCode = 101,
DurationMs = 12,
Bytes = 1024,
IsWebSocket = true,
ClientCountry = "SK",
ClientCity = "Košice",
ClientIp = "178.40.12.8",
TraceId = $"ws_{Guid.NewGuid():N}"[..16]
},
CreatureType.Jellyfish => new TrafficEvent
{
Method = "POST",
Path = "/api/v1/auth/token/refresh",
StatusCode = _rnd.Next(0, 2) == 0 ? 500 : 404,
DurationMs = _rnd.Next(150, 600),
Bytes = 512,
ClientCountry = "CZ",
ClientCity = "Praha",
ClientIp = "89.24.18.99",
TraceId = $"err_{Guid.NewGuid():N}"[..16],
ErrorMessage = "InternalServerError: NullReferenceException at AuthService.ValidateRefreshTokenAsync"
},
CreatureType.Shark => new TrafficEvent
{
Method = "GET",
Path = "/api/v1/search?q=exploit_probe",
StatusCode = 429,
DurationMs = 15,
Bytes = 256,
ClientCountry = "US",
ClientCity = "Ashburn",
ClientIp = "34.201.88.19",
TraceId = $"shk_{Guid.NewGuid():N}"[..16],
ErrorMessage = "RateLimitExceeded: Client exceeded 100 req/min quota"
},
CreatureType.Crab => new TrafficEvent
{
Method = "JOB",
Path = "/cron/db-vacuum-analyze",
StatusCode = 200,
DurationMs = _rnd.Next(300, 1200),
Bytes = 4096,
ClientCountry = "SK",
ClientCity = "Bratislava",
ClientIp = "10.0.0.1",
TraceId = $"job_{Guid.NewGuid():N}"[..16],
DatabaseTimeMs = 85.4,
QueryCount = 14
},
_ => GenerateRandomEvent(forceReturn: true)
};
if (evt != null)
{
_channel.TryPublish(evt);
}
}
private TrafficEvent? GenerateRandomEvent(bool forceReturn = false)
{
var roll = _rnd.Next(100);
TrafficEvent evt;
var countries = new[] { ("SK", "Bratislava"), ("SK", "Košice"), ("CZ", "Praha"), ("AT", "Wien"), ("DE", "Berlin"), ("US", "San Francisco") };
var loc = countries[_rnd.Next(countries.Length)];
if (roll < 4) // 4% Server Error -> Red Jellyfish
{
evt = new TrafficEvent
{
Method = "POST",
Path = "/api/v1/orders/checkout",
StatusCode = 500,
DurationMs = _rnd.Next(180, 850),
Bytes = 640,
ClientCountry = loc.Item1,
ClientCity = loc.Item2,
ClientIp = $"195.91.{_rnd.Next(1, 250)}.{_rnd.Next(1, 250)}",
TraceId = $"trc_{Guid.NewGuid():N}"[..14],
ErrorMessage = "DatabaseTimeout: Connection to PostgreSQL replica pool timed out after 500ms"
};
}
else if (roll < 9) // 5% Client Error -> Amber Jellyfish
{
var code = _rnd.Next(0, 3) switch { 0 => 404, 1 => 401, _ => 403 };
var p = code switch
{
404 => "/api/v1/unknown-endpoint",
401 => "/api/v1/user/secret-vault",
_ => "/admin/super-settings"
};
evt = new TrafficEvent
{
Method = "GET",
Path = p,
StatusCode = code,
DurationMs = _rnd.Next(10, 80),
Bytes = 380,
ClientCountry = loc.Item1,
ClientCity = loc.Item2,
ClientIp = $"178.143.{_rnd.Next(1, 250)}.{_rnd.Next(1, 250)}",
TraceId = $"trc_{Guid.NewGuid():N}"[..14]
};
}
else if (roll < 14) // 5% Heavy payload -> Whale
{
var sizeBytes = _rnd.Next(800_000, 6_500_000);
evt = new TrafficEvent
{
Method = "GET",
Path = "/api/v1/media/video-stream-hd.mp4",
StatusCode = 200,
DurationMs = _rnd.Next(600, 2200),
Bytes = sizeBytes,
ClientCountry = loc.Item1,
ClientCity = loc.Item2,
ClientIp = $"85.216.{_rnd.Next(1, 250)}.{_rnd.Next(1, 250)}",
TraceId = $"trc_{Guid.NewGuid():N}"[..14]
};
}
else if (roll < 20) // 6% WebSocket stream -> Dolphin
{
evt = new TrafficEvent
{
Method = "WS",
Path = "/signalr/aquarium-hub",
StatusCode = 101,
DurationMs = _rnd.Next(5, 25),
Bytes = _rnd.Next(512, 4096),
IsWebSocket = true,
ClientCountry = loc.Item1,
ClientCity = loc.Item2,
ClientIp = $"213.81.{_rnd.Next(1, 250)}.{_rnd.Next(1, 250)}",
TraceId = $"ws_{Guid.NewGuid():N}"[..14]
};
}
else if (roll < 25) // 5% DB / Worker -> Crab
{
evt = new TrafficEvent
{
Method = "JOB",
Path = "/db/cleanup-expired-tokens",
StatusCode = 200,
DurationMs = _rnd.Next(120, 480),
Bytes = 1024,
ClientCountry = "SK",
ClientCity = "Bratislava",
ClientIp = "127.0.0.1",
TraceId = $"job_{Guid.NewGuid():N}"[..14],
DatabaseTimeMs = 45.2,
QueryCount = 6
};
}
else // 75% Regular HTTP Requests -> Fish
{
var endpoints = new[]
{
("GET", "/api/v1/users/me", 200, _rnd.Next(15, 65), 1840L),
("GET", "/api/v1/dashboard/metrics", 200, _rnd.Next(25, 110), 4520L),
("POST", "/api/v1/auth/login", 200, _rnd.Next(60, 180), 1200L),
("GET", "/api/v1/products?category=dev", 200, _rnd.Next(30, 95), 18400L),
("PUT", "/api/v1/settings/theme", 200, _rnd.Next(40, 120), 850L),
("DELETE", "/api/v1/notifications/44", 200, _rnd.Next(20, 70), 200L),
("GET", "/_content/app.bundle.js", 200, _rnd.Next(8, 35), 45000L),
("GET", "/css/theme.css", 200, _rnd.Next(5, 20), 8200L),
("GET", "/api/v1/search?term=network", 200, _rnd.Next(45, 140), 9600L)
};
var ep = endpoints[_rnd.Next(endpoints.Length)];
evt = new TrafficEvent
{
Method = ep.Item1,
Path = ep.Item2,
StatusCode = ep.Item3,
DurationMs = ep.Item4,
Bytes = ep.Item5,
ClientCountry = loc.Item1,
ClientCity = loc.Item2,
ClientIp = $"188.167.{_rnd.Next(1, 250)}.{_rnd.Next(1, 250)}",
TraceId = $"trc_{Guid.NewGuid():N}"[..14]
};
}
if (forceReturn) return evt;
_channel.TryPublish(evt);
return evt;
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+192
View File
@@ -0,0 +1,192 @@
namespace Aquarium.Shared;
public static class CreatureClassifier
{
private static readonly string[] FishSpecies = ["Clownfish", "NeonTetra", "Angelfish", "Tang", "Guppy", "Mandarinfish"];
private static readonly Random Rnd = new();
public static CreatureVisualParams Classify(TrafficEvent evt)
{
var result = new CreatureVisualParams();
var path = evt.Path.ToLowerInvariant();
// 1. Errors -> Jellyfish (Red/Orange)
if (evt.StatusCode >= 500)
{
result.CreatureType = CreatureType.Jellyfish;
result.Biome = DetermineBiome(path);
result.PrimaryColor = "#ef4444"; // Vivid Red
result.SecondaryColor = "#b91c1c";
result.Scale = 1.3;
result.Speed = 0.5; // slow ominous float
result.SubSpecies = "Crimson Jellyfish";
result.DisplayLabel = $"{evt.Method} {evt.StatusCode} SERVER ERR";
result.IsDangerous = true;
result.TargetDepthPercent = 0.4 + (Rnd.NextDouble() * 0.3);
return result;
}
if (evt.StatusCode >= 400)
{
if (evt.StatusCode == 429) // Rate limited / DDoS probe -> Shark!
{
result.CreatureType = CreatureType.Shark;
result.Biome = ServiceBiome.Pelagic;
result.PrimaryColor = "#e11d48";
result.SecondaryColor = "#475569";
result.Scale = 1.5;
result.Speed = 2.2;
result.SubSpecies = "Rate-Limit Shark";
result.DisplayLabel = $"429 TOO MANY REQ";
result.IsDangerous = true;
result.TargetDepthPercent = 0.35;
return result;
}
result.CreatureType = CreatureType.Jellyfish;
result.Biome = DetermineBiome(path);
result.PrimaryColor = "#f59e0b"; // Amber/Orange
result.SecondaryColor = "#d97706";
result.Scale = 1.0;
result.Speed = 0.7;
result.SubSpecies = "Amber Jellyfish";
result.DisplayLabel = $"{evt.Method} {evt.StatusCode} CLIENT ERR";
result.IsDangerous = false;
result.TargetDepthPercent = 0.3 + (Rnd.NextDouble() * 0.4);
return result;
}
// 2. WebSockets / SSE / Long Streams -> Dolphin
if (evt.IsWebSocket || evt.StatusCode == 101 || evt.Method.Equals("WS", StringComparison.OrdinalIgnoreCase) || path.Contains("/signalr") || path.Contains("/ws") || path.Contains("/stream"))
{
result.CreatureType = CreatureType.Dolphin;
result.Biome = ServiceBiome.Surface;
result.PrimaryColor = "#06b6d4"; // Cyan/Turquoise
result.SecondaryColor = "#0e7490";
result.Scale = 1.3;
result.Speed = 1.4;
result.SubSpecies = "Stream Dolphin";
result.DisplayLabel = "LIVE STREAM";
result.TargetDepthPercent = 0.15; // Near surface / jumping
return result;
}
// 3. Heavy Downloads / Payloads (> 500 KB) -> Whale
if (evt.Bytes > 500_000 || path.EndsWith(".iso") || path.EndsWith(".zip") || path.EndsWith(".mp4") || path.EndsWith(".tar.gz") || path.Contains("/download") || path.Contains("/export"))
{
result.CreatureType = CreatureType.Whale;
result.Biome = ServiceBiome.Pelagic;
result.PrimaryColor = "#3b82f6"; // Royal Blue
result.SecondaryColor = "#1e3a8a";
// Scale scales with payload size up to 2.2x
var sizeMb = evt.Bytes / (1024.0 * 1024.0);
result.Scale = Math.Min(2.2, 1.4 + (sizeMb * 0.1));
result.Speed = 0.6; // Slow and majestic
result.SubSpecies = "Data Whale";
result.DisplayLabel = $"{FormatBytes(evt.Bytes)} PAYLOAD";
result.TargetDepthPercent = 0.5 + (Rnd.NextDouble() * 0.2);
return result;
}
// 4. Background jobs / Database intensive -> Crab on ocean floor
if (evt.Method.Equals("JOB", StringComparison.OrdinalIgnoreCase) || path.Contains("/db/") || path.Contains("/cron/") || path.Contains("/queue/"))
{
result.CreatureType = CreatureType.Crab;
result.Biome = ServiceBiome.Abyssal;
result.PrimaryColor = "#f97316"; // Bright Crab Orange
result.SecondaryColor = "#9a3412";
result.Scale = 0.9;
result.Speed = 0.8;
result.SubSpecies = "Worker Crab";
result.DisplayLabel = "DB / WORKER";
result.TargetDepthPercent = 0.92; // Ocean floor
return result;
}
// 5. Standard HTTP Requests -> Regular Fish (species & colors by Method)
result.CreatureType = CreatureType.Fish;
result.Biome = DetermineBiome(path);
var (pColor, sColor) = evt.Method.ToUpperInvariant() switch
{
"GET" => ("#06b6d4", "#0891b2"), // Cyan / Teal
"POST" => ("#10b981", "#059669"), // Emerald Green
"PUT" => ("#8b5cf6", "#6d28d9"), // Violet
"DELETE" => ("#f43f5e", "#be123c"), // Rose Red
"PATCH" => ("#ec4899", "#be185d"), // Pink
_ => ("#38bdf8", "#0284c7")
};
result.PrimaryColor = pColor;
result.SecondaryColor = sColor;
// Speed based on latency (low latency = fast swimmer, high latency = slower)
result.Speed = evt.DurationMs switch
{
< 30 => 1.8, // Blesková ryba
< 100 => 1.4,
< 300 => 1.0,
< 1000 => 0.7,
_ => 0.45 // Lagging fish
};
// Scale based on response size
result.Scale = evt.Bytes switch
{
< 2048 => 0.85,
< 32768 => 1.0,
< 262144 => 1.25,
_ => 1.4
};
// Target depth according to biome
result.TargetDepthPercent = result.Biome switch
{
ServiceBiome.Surface => 0.1 + (Rnd.NextDouble() * 0.2),
ServiceBiome.Pelagic => 0.35 + (Rnd.NextDouble() * 0.35),
ServiceBiome.Abyssal => 0.75 + (Rnd.NextDouble() * 0.18),
_ => 0.5
};
result.SubSpecies = FishSpecies[Math.Abs(evt.Path.GetHashCode()) % FishSpecies.Length];
result.DisplayLabel = $"{evt.Method} {evt.StatusCode} · {evt.DurationMs:F0}ms";
return result;
}
private static ServiceBiome DetermineBiome(string path)
{
if (path.StartsWith("/api/") || path.StartsWith("/v1/") || path.StartsWith("/v2/") || path.Contains("/graphql"))
{
return ServiceBiome.Pelagic;
}
if (path.StartsWith("/_content") || path.StartsWith("/css") || path.StartsWith("/js") ||
path.EndsWith(".js") || path.EndsWith(".css") || path.EndsWith(".png") ||
path.EndsWith(".svg") || path.EndsWith(".ico") || path.EndsWith(".woff2"))
{
return ServiceBiome.Surface;
}
if (path.Contains("/db") || path.Contains("/cache") || path.Contains("/internal") || path.Contains("/queue"))
{
return ServiceBiome.Abyssal;
}
return ServiceBiome.Pelagic;
}
private static string FormatBytes(long bytes)
{
string[] sizes = ["B", "KB", "MB", "GB"];
double len = bytes;
int order = 0;
while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len /= 1024;
}
return $"{len:0.#} {sizes[order]}";
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace Aquarium.Shared;
public class CreatureSpawnDto
{
public TrafficEvent Event { get; set; } = new();
public CreatureVisualParams Visuals { get; set; } = new();
public static CreatureSpawnDto FromEvent(TrafficEvent evt)
{
return new CreatureSpawnDto
{
Event = evt,
Visuals = CreatureClassifier.Classify(evt)
};
}
}
+22
View File
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Aquarium.Shared;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CreatureType
{
Fish = 0, // Standard HTTP requests (GET, POST, etc.)
Dolphin = 1, // WebSockets / SSE / Long-lived streams
Whale = 2, // Heavy downloads / uploads (> 1 MB)
Jellyfish = 3, // Errors (4xx client error, 5xx server error)
Shark = 4, // Traffic spikes, rate limits (429), suspicious bursts
Crab = 5 // Database queries, background cron jobs, queue workers
}
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ServiceBiome
{
Surface = 0, // CDN, Cloudflare, static assets (.js, .css, images)
Pelagic = 1, // REST API, GraphQL, Microservices (/api/*)
Abyssal = 2 // Database, Cache, Background workers, Message Bus
}
+59
View File
@@ -0,0 +1,59 @@
using System.Text.Json.Serialization;
namespace Aquarium.Shared;
public class TrafficEvent
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow;
public string Method { get; set; } = "GET";
public string Path { get; set; } = "/";
public int StatusCode { get; set; } = 200;
public double DurationMs { get; set; } = 25.0;
public long Bytes { get; set; } = 1024;
public string ClientIp { get; set; } = "127.0.0.1";
public string ClientCountry { get; set; } = "SK";
public string ClientCity { get; set; } = "Bratislava";
public string UserAgent { get; set; } = "Mozilla/5.0";
public string TraceId { get; set; } = "";
public string ServiceName { get; set; } = "Server";
public bool IsCached { get; set; } = false;
public bool IsWebSocket { get; set; } = false;
// Additional diagnostics
public double? DatabaseTimeMs { get; set; }
public int? QueryCount { get; set; }
public string? ErrorMessage { get; set; }
}
public class CreatureVisualParams
{
public CreatureType CreatureType { get; set; }
public ServiceBiome Biome { get; set; }
public string PrimaryColor { get; set; } = "#38bdf8";
public string SecondaryColor { get; set; } = "#0284c7";
public double Scale { get; set; } = 1.0;
public double Speed { get; set; } = 1.0;
public double TargetDepthPercent { get; set; } = 0.5; // 0.0 = surface, 1.0 = bottom
public string SubSpecies { get; set; } = "Clownfish";
public string DisplayLabel { get; set; } = "GET 200";
public bool IsDangerous { get; set; } = false;
}
public class AquariumStats
{
public int ActiveCreatures { get; set; }
public double RequestsPerSecond { get; set; }
public double AverageLatencyMs { get; set; }
public double ErrorRatePercent { get; set; }
public long TotalRequests { get; set; }
public long TotalBytesTransferred { get; set; }
public int Total2xx { get; set; }
public int Total4xx { get; set; }
public int Total5xx { get; set; }
public int ActiveWebSockets { get; set; }
public DateTimeOffset LastUpdated { get; set; } = DateTimeOffset.UtcNow;
}
+5 -1
View File
@@ -1 +1,5 @@
<Solution />
<Solution>
<Project Path="Aquarium.Client/Aquarium.Client.csproj" />
<Project Path="Aquarium.Server/Aquarium.Server.csproj" />
<Project Path="Aquarium.Shared/Aquarium.Shared.csproj" />
</Solution>