added aquarium
This commit is contained in:
@@ -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"
|
||||
}'
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 · .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 >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">×</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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
+1
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
+1
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 */
|
||||
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+5393
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user