This commit is contained in:
6d486f49
2026-08-14 15:56:17 -04:00
parent af64b9e95c
commit 09a8adfc4a
827 changed files with 65045 additions and 71849 deletions
+28
View File
@@ -0,0 +1,28 @@
using Cloud.Models;
using Microsoft.EntityFrameworkCore;
using Model;
namespace Cloud;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<CardNote> CardNotes { get; set; }
public DbSet<UserDeck> UserDecks { get; set; }
public DbSet<PasskeyCredential> PasskeyCredentials { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CardNote>().HasKey(n => n.CardName);
modelBuilder.Entity<UserDeck>(b =>
{
b.HasKey(d => d.Id);
b.Property(d => d.Cards).HasColumnType("jsonb");
b.Property(d => d.Divers).HasColumnType("jsonb");
});
}
}
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Cloud;
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql("Host=localhost;Database=chrono;Username=postgres;Password=postgres")
.Options;
return new AppDbContext(options);
}
}
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<title>Chrono CCG - Database Test Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"/>
<link rel="stylesheet" href="app.css"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,97 @@
@inherits LayoutComponentBase
@implements IDisposable
<div class="page">
<div class="sidebar">
<NavMenu/>
</div>
<main>
<header class="top-navbar">
<div class="d-flex align-items-center gap-3">
<div class="status-pill @GetStatusClass()">
<span class="status-dot @GetStatusDotClass()"></span>
<span>@GetStatusText()</span>
</div>
@if (health?.IsConnected == true)
{
<span class="text-secondary small">
<i class="bi bi-clock-history"></i> @(health.ResponseTimeMs)ms latency
</span>
}
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-chrono-secondary" @onclick="RefreshHealthAsync" title="Refresh Health Check">
<i class="bi bi-arrow-clockwise @(isChecking ? "spin" : "")"></i> Refresh
</button>
<a href="test-suite" class="btn btn-sm btn-chrono-primary">
<i class="bi bi-play-circle-fill"></i> Run Test Suite
</a>
</div>
</header>
<article class="content-container">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" class="bg-danger text-white p-3 position-fixed bottom-0 start-0 end-0 d-none">
An unhandled error has occurred.
<a href="." class="text-white text-decoration-underline ms-2">Reload</a>
</div>
@code {
private DatabaseHealthStatus? health;
private bool isChecking;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await RefreshHealthAsync();
}
private async Task RefreshHealthAsync()
{
isChecking = true;
StateHasChanged();
health = await DbService.CheckHealthAsync();
isChecking = false;
StateHasChanged();
}
private void HandleConfigChanged()
{
_ = RefreshHealthAsync();
}
private string GetStatusClass()
{
if (health == null) return "status-inmemory";
if (!health.IsConnected) return "status-disconnected";
if (health.ProviderType == DatabaseProviderType.InMemory) return "status-inmemory";
return "status-connected";
}
private string GetStatusDotClass()
{
if (health == null) return "purple";
if (!health.IsConnected) return "red";
if (health.ProviderType == DatabaseProviderType.InMemory) return "purple";
return "green";
}
private string GetStatusText()
{
if (health == null) return "Checking connection...";
if (!health.IsConnected) return "Disconnected";
if (health.ProviderType == DatabaseProviderType.InMemory) return "In-Memory Active";
return "PostgreSQL Connected";
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,98 @@
.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;
}
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -0,0 +1,76 @@
@implements IDisposable
<div class="nav-brand">
<i class="bi bi-database-fill-gear"></i>
<span>Chrono DB Hub</span>
</div>
<div class="px-3 pt-3 pb-2">
<div class="p-2 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="text-secondary small fw-semibold">PROVIDER</span>
<span class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")" style="font-size: 0.72rem;">
@ConfigService.ProviderType
</span>
</div>
<div class="small text-truncate text-light" style="font-size: 0.78rem;" title="@ConfigService.GetActiveProviderDescription()">
@(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "PostgreSQL Server" : "In-Memory Test DB")
</div>
</div>
</div>
<div class="nav-menu">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<i class="bi bi-speedometer2"></i>
<span>Dashboard</span>
</NavLink>
<NavLink class="nav-link" href="card-notes">
<i class="bi bi-card-text"></i>
<span>Card Notes</span>
</NavLink>
<NavLink class="nav-link" href="user-decks">
<i class="bi bi-collection-play"></i>
<span>User Decks</span>
</NavLink>
<NavLink class="nav-link" href="passkeys">
<i class="bi bi-key-fill"></i>
<span>Passkeys</span>
</NavLink>
<NavLink class="nav-link" href="test-suite">
<i class="bi bi-clipboard2-check-fill"></i>
<span>Test Suite Runner</span>
</NavLink>
<NavLink class="nav-link" href="diagnostics">
<i class="bi bi-sliders"></i>
<span>DB & Migrations</span>
</NavLink>
</div>
<div class="sidebar-footer">
<div class="d-flex align-items-center justify-content-between">
<span>Chrono CCG Cloud DB</span>
<span class="badge bg-secondary">.NET 10</span>
</div>
</div>
@code {
protected override void OnInitialized()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
}
private void HandleConfigChanged()
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.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 .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br/>Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br/>Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
@@ -0,0 +1,343 @@
@page "/card-notes"
@implements IDisposable
<PageTitle>Card Notes Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Card Notes Database (`CardNote`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete notes associated with Chrono CCG cards.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadNotesAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add New Note
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<div class="chrono-card">
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by card name or note content..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterNotes" />
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
}
</div>
<div class="text-secondary small">
Showing @notes.Count note(s)
</div>
</div>
@if (isLoading)
{
<div class="text-center py-5 text-secondary">
<div class="spinner-border text-primary mb-2" role="status"></div>
<div>Loading card notes from database...</div>
</div>
}
else if (!notes.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-journal-x fs-1 d-block mb-2"></i>
<h5>No Card Notes Found</h5>
<p class="small">There are currently no card notes matching your criteria.</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add First Note
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedSampleNotesAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Notes
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 25%;">Card Name (Primary Key)</th>
<th>Note Content</th>
<th style="width: 140px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var note in notes)
{
<tr>
<td>
<div class="fw-semibold text-white d-flex align-items-center gap-2">
<i class="bi bi-card-heading text-primary"></i>
@note.CardName
</div>
</td>
<td>
<div class="text-light text-break">@note.Note</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(note)" title="Edit Note">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(note)" title="Delete Note">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Add / Edit Modal -->
@if (showModal)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isEditing ? "bi-pencil-square" : "bi-plus-circle") text-primary me-2"></i>
@(isEditing ? "Edit Card Note" : "Add New Card Note")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">CARD NAME</label>
@if (isEditing)
{
<input type="text" class="form-control form-control-chrono" value="@currentNote.CardName" disabled />
<div class="form-text text-secondary">Card name acts as the primary key and cannot be altered.</div>
}
else
{
<input type="text" class="form-control form-control-chrono" list="cardSuggestions"
placeholder="Enter or select card name..." @bind="currentNote.CardName" />
<datalist id="cardSuggestions">
@foreach (var c in availableCardNames.Take(50))
{
<option value="@c" />
}
</datalist>
<div class="form-text text-secondary">Select an existing card from the database or enter a custom name.</div>
}
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">STRATEGIC NOTE</label>
<textarea class="form-control form-control-chrono" rows="5"
placeholder="Enter strategic card observations, deck synergies, mulligan advice, or balancing notes..."
@bind="currentNote.Note"></textarea>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveNoteAsync" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Note")
</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (showDeleteModal && noteToDelete != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Note
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete the note for:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3">
@noteToDelete.CardName
</div>
<p class="text-secondary small mb-0">This action will remove the record from the database permanently.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Permanently")
</button>
</div>
</div>
</div>
}
@code {
private List<CardNote> notes = [];
private List<string> availableCardNames = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private CardNote currentNote = new();
private bool showDeleteModal;
private CardNote? noteToDelete;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
try
{
availableCardNames = CardDatabase.Cards
.Select(c => c.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n)
.ToList();
}
catch
{
availableCardNames = [];
}
await LoadNotesAsync();
}
private void HandleConfigChanged()
{
_ = LoadNotesAsync();
}
private async Task LoadNotesAsync()
{
isLoading = true;
try
{
notes = await DbService.GetCardNotesAsync(searchTerm);
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Error loading card notes: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task FilterNotes()
{
notes = await DbService.GetCardNotesAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = "";
await LoadNotesAsync();
}
private void OpenAddModal()
{
isEditing = false;
currentNote = new CardNote();
showModal = true;
}
private void OpenEditModal(CardNote note)
{
isEditing = true;
currentNote = new CardNote
{
CardName = note.CardName,
Note = note.Note
};
showModal = true;
}
private void CloseModal()
{
showModal = false;
}
private async Task SaveNoteAsync()
{
if (string.IsNullOrWhiteSpace(currentNote.CardName))
{
alertIsSuccess = false;
alertMessage = "Card name cannot be empty.";
return;
}
isSaving = true;
var result = await DbService.SaveCardNoteAsync(currentNote, !isEditing);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
if (result.Success)
{
showModal = false;
await LoadNotesAsync();
}
}
private void OpenDeleteModal(CardNote note)
{
noteToDelete = note;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (noteToDelete == null) return;
isSaving = true;
var result = await DbService.DeleteCardNoteAsync(noteToDelete.CardName);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
showDeleteModal = false;
noteToDelete = null;
await LoadNotesAsync();
}
private async Task SeedSampleNotesAsync()
{
var result = await DbService.SeedSampleDataAsync();
alertIsSuccess = result.Success;
alertMessage = result.Message;
await LoadNotesAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,20 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
@@ -0,0 +1,360 @@
@page "/diagnostics"
@implements IDisposable
<PageTitle>Database Configuration & Migrations - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Settings & Migrations</h2>
<p class="text-secondary mb-0">Configure database providers, update connection strings, inspect EF Core migrations, and run maintenance tasks.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh Status
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<div class="row g-4 mb-4">
<!-- Connection Configuration -->
<div class="col-lg-6">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-hdd-network-fill text-primary"></i>
Database Provider & Connection
</h5>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">ACTIVE PROVIDER</label>
<div class="d-flex gap-2">
<button class="btn flex-fill @(selectedProvider == DatabaseProviderType.PostgreSQL ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.PostgreSQL)">
<i class="bi bi-database me-1"></i> PostgreSQL (Cloud DB)
</button>
<button class="btn flex-fill @(selectedProvider == DatabaseProviderType.InMemory ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.InMemory)">
<i class="bi bi-cpu me-1"></i> In-Memory Test DB
</button>
</div>
</div>
@if (selectedProvider == DatabaseProviderType.PostgreSQL)
{
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">POSTGRESQL CONNECTION STRING</label>
<textarea class="form-control form-control-chrono font-monospace" rows="3"
placeholder="Host=localhost;Port=5432;Database=chrono;Username=postgres;Password=postgres"
@bind="connectionStringInput"></textarea>
<div class="form-text text-secondary">
Standard Npgsql connection string format used by <code>Cloud.AppDbContext</code>.
</div>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">CONNECTION PRESETS</label>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary"
@onclick='() => SetPreset("Host=localhost;Port=5432;Database=chrono;Username=postgres;Password=postgres")'>
Default Localhost (5432)
</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
@onclick='() => SetPreset("Host=host.docker.internal;Port=5432;Database=chrono;Username=postgres;Password=postgres")'>
Docker Host Internal
</button>
</div>
</div>
}
else
{
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">IN-MEMORY DATABASE INSTANCE NAME</label>
<input type="text" class="form-control form-control-chrono font-monospace"
@bind="inMemoryNameInput" />
<div class="form-text text-secondary">
In-memory EF Core provider creates an isolated database in RAM for instant testing.
</div>
</div>
}
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-chrono-primary" @onclick="SaveConfigurationAsync" disabled="@isSaving">
<i class="bi bi-check2-circle me-1"></i>
@(isSaving ? "Connecting..." : "Save & Test Connection")
</button>
</div>
</div>
</div>
<!-- Live Database Diagnostics & Table Counts -->
<div class="col-lg-6">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-activity text-info"></i>
Database Health & Entity Counts
</h5>
<span class="badge @(health?.IsConnected == true ? "bg-success" : "bg-danger")">
@(health?.IsConnected == true ? "ONLINE" : "OFFLINE")
</span>
</div>
@if (health != null)
{
<div class="d-flex flex-column gap-2 mb-4">
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">Connection Status</span>
<span class="fw-bold @(health.IsConnected ? "text-success" : "text-danger")">
@(health.IsConnected ? "Connected" : "Disconnected")
</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">Query Latency</span>
<span class="text-white font-monospace">@(health.ResponseTimeMs) ms</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">CardNotes Table Rows</span>
<span class="text-primary fw-bold">@health.CardNotesCount</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">UserDecks Table Rows</span>
<span class="text-success fw-bold">@health.UserDecksCount</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">PasskeyCredentials Table Rows</span>
<span class="text-purple fw-bold">@health.PasskeysCount</span>
</div>
</div>
@if (!health.IsConnected && !string.IsNullOrEmpty(health.ErrorMessage))
{
<div class="alert alert-danger p-2 small mb-0">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
@health.ErrorMessage
</div>
}
}
</div>
</div>
</div>
<!-- EF Core Migrations Section -->
<div class="chrono-card">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-layer-forward text-warning"></i>
EF Core Migrations Status
</h5>
@if (migrationReport?.SupportsMigrations == true && migrationReport.PendingMigrations.Any())
{
<button class="btn btn-chrono-primary btn-sm" @onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<i class="bi bi-arrow-up-circle-fill me-1"></i> Apply Pending Migrations (@migrationReport.PendingMigrations.Count)
</button>
}
</div>
@if (migrationReport == null)
{
<div class="text-center py-4 text-secondary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> Loading migration report...
</div>
}
else if (!migrationReport.SupportsMigrations)
{
<div class="p-3 bg-dark rounded border border-secondary text-secondary small">
<i class="bi bi-info-circle me-1"></i>
Migrations are managed via relational schema (PostgreSQL). The active in-memory provider does not use relational migration tables.
</div>
}
else
{
<div class="row g-4">
<!-- Applied Migrations -->
<div class="col-md-6">
<h6 class="text-secondary small fw-bold mb-2">
<i class="bi bi-check2-all text-success me-1"></i>
APPLIED MIGRATIONS (@migrationReport.AppliedMigrations.Count)
</h6>
@if (!migrationReport.AppliedMigrations.Any())
{
<div class="text-secondary small fst-italic p-2 bg-dark rounded border border-secondary">
No migrations have been applied to this database yet.
</div>
}
else
{
<div class="d-flex flex-column gap-2" style="max-height: 250px; overflow-y: auto;">
@foreach (var m in migrationReport.AppliedMigrations)
{
<div class="p-2 rounded bg-dark border border-success border-opacity-50 d-flex align-items-center justify-content-between">
<span class="text-white small font-monospace">@m</span>
<span class="badge bg-success"><i class="bi bi-check"></i> Applied</span>
</div>
}
</div>
}
</div>
<!-- Pending Migrations -->
<div class="col-md-6">
<h6 class="text-secondary small fw-bold mb-2">
<i class="bi bi-clock-history text-warning me-1"></i>
PENDING MIGRATIONS (@migrationReport.PendingMigrations.Count)
</h6>
@if (!migrationReport.PendingMigrations.Any())
{
<div class="p-3 bg-dark rounded border border-success border-opacity-25 text-success small">
<i class="bi bi-shield-check me-1"></i> All migrations are applied. Schema is in sync with AppDbContext.
</div>
}
else
{
<div class="d-flex flex-column gap-2" style="max-height: 250px; overflow-y: auto;">
@foreach (var m in migrationReport.PendingMigrations)
{
<div class="p-2 rounded bg-dark border border-warning border-opacity-50 d-flex align-items-center justify-content-between">
<span class="text-warning small font-monospace">@m</span>
<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Pending</span>
</div>
}
</div>
}
</div>
</div>
}
</div>
<!-- Database Maintenance Section -->
<div class="chrono-card">
<div class="chrono-card-header">
<h5 class="chrono-card-title text-danger">
<i class="bi bi-tools text-danger"></i>
Database Maintenance & Tools
</h5>
</div>
<div class="d-flex flex-wrap gap-3">
<button class="btn btn-chrono-secondary" @onclick="SeedDataAsync" disabled="@isActionBusy">
<i class="bi bi-box-arrow-in-down me-1"></i> Seed Realistic Sample Data
</button>
<button class="btn btn-chrono-danger" @onclick="ResetDatabaseAsync" disabled="@isActionBusy">
<i class="bi bi-trash3-fill me-1"></i> Wipe / Reset All Tables
</button>
</div>
</div>
@code {
private DatabaseHealthStatus? health;
private MigrationReport? migrationReport;
private DatabaseProviderType selectedProvider;
private string connectionStringInput = "";
private string inMemoryNameInput = "";
private bool isSaving;
private bool isActionBusy;
private string? alertMessage;
private bool alertIsSuccess;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
selectedProvider = ConfigService.ProviderType;
connectionStringInput = ConfigService.ConnectionString;
inMemoryNameInput = ConfigService.InMemoryDbName;
await RefreshAsync();
}
private void HandleConfigChanged()
{
selectedProvider = ConfigService.ProviderType;
connectionStringInput = ConfigService.ConnectionString;
inMemoryNameInput = ConfigService.InMemoryDbName;
_ = RefreshAsync();
}
private async Task RefreshAsync()
{
health = await DbService.CheckHealthAsync();
migrationReport = await DbService.GetMigrationStatusAsync();
}
private void SelectProvider(DatabaseProviderType provider)
{
selectedProvider = provider;
}
private void SetPreset(string conn)
{
connectionStringInput = conn;
}
private async Task SaveConfigurationAsync()
{
isSaving = true;
ConfigService.SetConnectionString(connectionStringInput);
ConfigService.SetInMemoryDbName(inMemoryNameInput);
ConfigService.SetProvider(selectedProvider);
if (selectedProvider == DatabaseProviderType.InMemory)
{
await DbService.EnsureDatabaseCreatedAsync();
}
await RefreshAsync();
isSaving = false;
alertIsSuccess = health?.IsConnected == true;
alertMessage = health?.IsConnected == true
? "Configuration updated successfully and connection verified."
: $"Configuration updated, but connection failed: {health?.ErrorMessage}";
}
private async Task ApplyMigrationsAsync()
{
isActionBusy = true;
var result = await DbService.ApplyMigrationsAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task SeedDataAsync()
{
isActionBusy = true;
var result = await DbService.SeedSampleDataAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task ResetDatabaseAsync()
{
isActionBusy = true;
var result = await DbService.ResetDatabaseAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,313 @@
@page "/"
@implements IDisposable
<PageTitle>Chrono DB Hub - Dashboard</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Test Dashboard</h2>
<p class="text-secondary mb-0">Monitor, manage, and validate all database features referenced in Chrono Cloud</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync" disabled="@isLoading">
<i class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></i> Refresh Status
</button>
<a href="test-suite" class="btn btn-chrono-primary">
<i class="bi bi-play-circle-fill"></i> Run All Tests
</a>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<!-- Database Health & Connection Banner -->
@if (health != null)
{
@if (!health.IsConnected)
{
<div class="chrono-card border-danger mb-4" style="background-color: rgba(248, 81, 73, 0.08);">
<div class="d-flex align-items-start justify-content-between">
<div class="d-flex gap-3">
<i class="bi bi-exclamation-octagon-fill text-danger fs-2"></i>
<div>
<h5 class="text-danger fw-bold mb-1">PostgreSQL Connection Offline</h5>
<p class="text-secondary small mb-2">
@(health.ErrorMessage ?? "Could not connect to the database server. Ensure PostgreSQL is running on the configured host/port.")
</p>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-sm btn-chrono-primary" @onclick="SwitchToInMemoryAsync">
<i class="bi bi-lightning-charge-fill"></i> Switch to In-Memory DB (Instant Testing)
</button>
<a href="diagnostics" class="btn btn-sm btn-chrono-secondary">
<i class="bi bi-gear-fill"></i> Configure Connection
</a>
<button class="btn btn-sm btn-outline-secondary" @onclick="RefreshAsync">
<i class="bi bi-arrow-repeat"></i> Retry Connection
</button>
</div>
</div>
</div>
</div>
</div>
}
else if (health.ProviderType == DatabaseProviderType.InMemory)
{
<div class="chrono-card border-info mb-4" style="background-color: rgba(57, 197, 207, 0.08);">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-cpu-fill text-info fs-2"></i>
<div>
<h6 class="text-info fw-bold mb-0">In-Memory Database Active (@health.ConnectionString)</h6>
<span class="text-secondary small">Running zero-config in-memory tests. Full CRUD and test suites are active.</span>
</div>
</div>
<button class="btn btn-sm btn-chrono-secondary" @onclick="SwitchToPostgresAsync">
<i class="bi bi-database"></i> Switch to PostgreSQL
</button>
</div>
</div>
}
}
<!-- Metrics Row -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<a href="card-notes" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-blue">
<i class="bi bi-card-text"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.CardNotesCount.ToString() : "-")</div>
<div class="metric-label">Card Notes</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<a href="user-decks" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-green">
<i class="bi bi-collection-play"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.UserDecksCount.ToString() : "-")</div>
<div class="metric-label">User Decks</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<a href="passkeys" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-purple">
<i class="bi bi-key-fill"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.PasskeysCount.ToString() : "-")</div>
<div class="metric-label">Passkeys</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<a href="diagnostics" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
<i class="bi bi-arrow-left-right"></i>
</div>
<div>
<div class="metric-value">
@(health?.ProviderType == DatabaseProviderType.PostgreSQL
? $"{health.AppliedMigrationsCount} / {health.AppliedMigrationsCount + health.PendingMigrationsCount}"
: "N/A")
</div>
<div class="metric-label">Applied Migrations</div>
</div>
</div>
</a>
</div>
</div>
<!-- Quick Actions & Features -->
<div class="row g-4">
<div class="col-lg-7">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-stars text-primary"></i>
Cloud Database Features Under Test
</h5>
<span class="badge bg-secondary">Cloud.AppDbContext</span>
</div>
<div class="d-flex flex-column gap-3">
<div class="p-3 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-bold text-white"><i class="bi bi-card-text text-primary me-2"></i> Card Notes Entity (`CardNote`)</span>
<a href="card-notes" class="btn btn-sm btn-chrono-secondary">Manage Notes</a>
</div>
<p class="text-secondary small mb-0">
Tracks developer and user strategic card notes with primary key indexing on <code>CardName</code>. Supports live search, adding, updating, and deletion.
</p>
</div>
<div class="p-3 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-bold text-white"><i class="bi bi-collection-play text-success me-2"></i> User Decks & JSONB (`UserDeck`)</span>
<a href="user-decks" class="btn btn-sm btn-chrono-secondary">Manage Decks</a>
</div>
<p class="text-secondary small mb-0">
Stores full player decks with native PostgreSQL <code>jsonb</code> mapping for <code>Cards</code> list (with counts) and <code>Divers</code> list.
</p>
</div>
<div class="p-3 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-bold text-white"><i class="bi bi-key-fill text-purple me-2"></i> Passkey Credentials (`PasskeyCredential`)</span>
<a href="passkeys" class="btn btn-sm btn-chrono-secondary">Manage Passkeys</a>
</div>
<p class="text-secondary small mb-0">
Stores WebAuthn / FIDO2 binary credentials (raw <code>byte[]</code> for CredentialId, PublicKey, UserHandle) with counter verification.
</p>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-wrench-adjustable text-warning"></i>
Quick Action Hub
</h5>
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-chrono-primary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SeedDataAsync" disabled="@isActionBusy">
<span><i class="bi bi-box-arrow-in-down me-2"></i> Seed Realistic Sample Data</span>
<i class="bi bi-arrow-right"></i>
</button>
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<span><i class="bi bi-database-check me-2"></i> Apply Pending Migrations</span>
<i class="bi bi-arrow-right"></i>
</button>
@if (ConfigService.ProviderType == DatabaseProviderType.PostgreSQL)
{
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SwitchToInMemoryAsync">
<span><i class="bi bi-cpu me-2"></i> Switch to In-Memory DB</span>
<span class="badge bg-purple">Instant</span>
</button>
}
else
{
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SwitchToPostgresAsync">
<span><i class="bi bi-database me-2"></i> Switch to PostgreSQL</span>
<span class="badge bg-primary">Live</span>
</button>
}
<a href="test-suite" class="btn btn-chrono-outline-primary py-2 text-start d-flex align-items-center justify-content-between">
<span><i class="bi bi-clipboard2-check me-2"></i> Open Test Suite Runner</span>
<i class="bi bi-arrow-right"></i>
</a>
<button class="btn btn-chrono-danger py-2 text-start d-flex align-items-center justify-content-between mt-2" @onclick="ResetDatabaseAsync" disabled="@isActionBusy">
<span><i class="bi bi-trash3-fill me-2"></i> Wipe & Reset Database</span>
<i class="bi bi-exclamation-triangle"></i>
</button>
</div>
</div>
</div>
</div>
@code {
private DatabaseHealthStatus? health;
private bool isLoading;
private bool isActionBusy;
private string? alertMessage;
private bool alertIsSuccess;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await RefreshAsync();
}
private async Task RefreshAsync()
{
isLoading = true;
health = await DbService.CheckHealthAsync();
isLoading = false;
}
private void HandleConfigChanged()
{
_ = RefreshAsync();
}
private async Task SwitchToInMemoryAsync()
{
ConfigService.SetProvider(DatabaseProviderType.InMemory);
var result = await DbService.EnsureDatabaseCreatedAsync();
alertIsSuccess = result.Success;
alertMessage = "Switched to In-Memory Test Database. " + result.Message;
await RefreshAsync();
}
private async Task SwitchToPostgresAsync()
{
ConfigService.SetProvider(DatabaseProviderType.PostgreSQL);
alertIsSuccess = true;
alertMessage = "Switched provider to PostgreSQL.";
await RefreshAsync();
}
private async Task SeedDataAsync()
{
isActionBusy = true;
var result = await DbService.SeedSampleDataAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task ApplyMigrationsAsync()
{
isActionBusy = true;
var result = await DbService.ApplyMigrationsAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task ResetDatabaseAsync()
{
isActionBusy = true;
var result = await DbService.ResetDatabaseAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -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,509 @@
@page "/passkeys"
@implements IDisposable
@using System.Security.Cryptography
<PageTitle>Passkeys Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Passkey Credentials Database (`PasskeyCredential`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete WebAuthn / FIDO2 security credentials with raw binary byte arrays.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadPasskeysAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add New Passkey
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<div class="chrono-card">
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by AaGuid, ID, or SignCount..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterPasskeys" />
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
}
</div>
<div class="text-secondary small">
Showing @passkeys.Count credential(s)
</div>
</div>
@if (isLoading)
{
<div class="text-center py-5 text-secondary">
<div class="spinner-border text-primary mb-2" role="status"></div>
<div>Loading passkeys from database...</div>
</div>
}
else if (!passkeys.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-shield-lock fs-1 d-block mb-2"></i>
<h5>No Passkeys Found</h5>
<p class="small">No passkey credentials were found in the database.</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add First Passkey
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedSamplePasskeysAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Passkeys
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th style="width: 220px;">AAGUID</th>
<th style="width: 120px;">Sign Count</th>
<th>Credential ID (Byte[])</th>
<th>Public Key (Byte[])</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var p in passkeys)
{
<tr>
<td>
<span class="badge bg-dark border border-secondary text-white">#@p.Id</span>
</td>
<td>
<div class="text-white small font-monospace">@(string.IsNullOrEmpty(p.AaGuid) ? "(None)" : p.AaGuid)</div>
</td>
<td>
<span class="badge bg-purple">@p.SignCount</span>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.CredentialId)">
@Convert.ToHexString(p.CredentialId)[..Math.Min(16, p.CredentialId.Length * 2)]... (@p.CredentialId.Length bytes)
</div>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.PublicKey)">
@Convert.ToHexString(p.PublicKey)[..Math.Min(16, p.PublicKey.Length * 2)]... (@p.PublicKey.Length bytes)
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(p)" title="View Binary Details">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(p)" title="Edit Passkey">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(p)" title="Delete Passkey">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Add / Edit Modal -->
@if (showModal)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 700px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isEditing ? "bi-pencil-square" : "bi-plus-circle") text-primary me-2"></i>
@(isEditing ? $"Edit Passkey #{currentPasskey.Id}" : "Add New Passkey")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
@if (!isEditing)
{
<div class="mb-3 d-flex justify-content-end">
<button type="button" class="btn btn-sm btn-chrono-secondary" @onclick="GenerateRandomPasskey">
<i class="bi bi-dice-5-fill text-warning me-1"></i> Generate Random Test Key
</button>
</div>
}
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label text-secondary small fw-bold">AAGUID</label>
<input type="text" class="form-control form-control-chrono font-monospace" placeholder="e.g. 00000000-0000-0000-0000-000000000000"
@bind="currentPasskey.AaGuid" />
</div>
<div class="col-md-4">
<label class="form-label text-secondary small fw-bold">SIGN COUNT</label>
<input type="number" class="form-control form-control-chrono" min="0"
@bind="currentPasskey.SignCount" />
</div>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">CREDENTIAL ID (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(credIdHex) bytes</span>
</div>
<textarea class="form-control form-control-chrono font-monospace" rows="2"
placeholder="e.g. AABBCCDD01020304"
@bind="credIdHex"></textarea>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">PUBLIC KEY (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(pubKeyHex) bytes</span>
</div>
<textarea class="form-control form-control-chrono font-monospace" rows="2"
placeholder="e.g. 3059301306072A8648CE3D020106082A8648CE3D030107"
@bind="pubKeyHex"></textarea>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">USER HANDLE (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(userHandleHex) bytes</span>
</div>
<input type="text" class="form-control form-control-chrono font-monospace"
placeholder="e.g. DEADBEAF01020304"
@bind="userHandleHex" />
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SavePasskeyAsync" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Passkey")
</button>
</div>
</div>
</div>
}
<!-- View Binary Details Modal -->
@if (showViewModal && viewedPasskey != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 650px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi bi-shield-check text-purple me-2"></i>
Passkey Binary Inspection (#@viewedPasskey.Id)
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showViewModal = false"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">CREDENTIAL ID</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.CredentialId)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.CredentialId)</pre>
</div>
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">PUBLIC KEY</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.PublicKey)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.PublicKey)</pre>
</div>
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">USER HANDLE</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.UserHandle)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.UserHandle)</pre>
</div>
<div class="row g-2">
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary">
<span class="text-secondary small d-block">AAGUID</span>
<span class="text-white font-monospace small">@viewedPasskey.AaGuid</span>
</div>
</div>
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary">
<span class="text-secondary small d-block">SIGN COUNT</span>
<span class="text-purple font-monospace small fw-bold">@viewedPasskey.SignCount</span>
</div>
</div>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (showDeleteModal && passkeyToDelete != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Passkey
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete passkey credential:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3 font-monospace">
Passkey #@passkeyToDelete.Id (AAGUID: @(passkeyToDelete.AaGuid ?? "N/A"))
</div>
<p class="text-secondary small mb-0">This will permanently delete the binary security credential from the database.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Passkey")
</button>
</div>
</div>
</div>
}
@code {
private List<PasskeyCredential> passkeys = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private PasskeyCredential currentPasskey = new()
{
CredentialId = [],
PublicKey = [],
UserHandle = []
};
private string credIdHex = "";
private string pubKeyHex = "";
private string userHandleHex = "";
private bool showViewModal;
private PasskeyCredential? viewedPasskey;
private bool showDeleteModal;
private PasskeyCredential? passkeyToDelete;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await LoadPasskeysAsync();
}
private void HandleConfigChanged()
{
_ = LoadPasskeysAsync();
}
private async Task LoadPasskeysAsync()
{
isLoading = true;
try
{
passkeys = await DbService.GetPasskeysAsync(searchTerm);
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Error loading passkeys: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task FilterPasskeys()
{
passkeys = await DbService.GetPasskeysAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = "";
await LoadPasskeysAsync();
}
private void OpenAddModal()
{
isEditing = false;
currentPasskey = new PasskeyCredential
{
AaGuid = Guid.NewGuid().ToString(),
SignCount = 0,
CredentialId = [],
PublicKey = [],
UserHandle = []
};
GenerateRandomPasskey();
showModal = true;
}
private void GenerateRandomPasskey()
{
var cred = new byte[16];
var pub = new byte[32];
var user = new byte[8];
RandomNumberGenerator.Fill(cred);
RandomNumberGenerator.Fill(pub);
RandomNumberGenerator.Fill(user);
credIdHex = Convert.ToHexString(cred);
pubKeyHex = Convert.ToHexString(pub);
userHandleHex = Convert.ToHexString(user);
currentPasskey.AaGuid = Guid.NewGuid().ToString();
currentPasskey.SignCount = Random.Shared.Next(1, 100);
}
private void OpenEditModal(PasskeyCredential passkey)
{
isEditing = true;
currentPasskey = new PasskeyCredential
{
Id = passkey.Id,
AaGuid = passkey.AaGuid,
SignCount = passkey.SignCount,
CredentialId = passkey.CredentialId,
PublicKey = passkey.PublicKey,
UserHandle = passkey.UserHandle
};
credIdHex = Convert.ToHexString(passkey.CredentialId);
pubKeyHex = Convert.ToHexString(passkey.PublicKey);
userHandleHex = Convert.ToHexString(passkey.UserHandle);
showModal = true;
}
private void CloseModal()
{
showModal = false;
}
private static int GetByteCount(string hex)
{
var clean = hex.Replace(" ", "").Replace("-", "");
return clean.Length / 2;
}
private async Task SavePasskeyAsync()
{
try
{
var cleanCred = credIdHex.Replace(" ", "").Replace("-", "");
var cleanPub = pubKeyHex.Replace(" ", "").Replace("-", "");
var cleanUser = userHandleHex.Replace(" ", "").Replace("-", "");
if (string.IsNullOrWhiteSpace(cleanCred) || cleanCred.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid Credential ID hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanPub) || cleanPub.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid Public Key hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanUser) || cleanUser.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid User Handle hex string (must be even length).";
return;
}
currentPasskey.CredentialId = Convert.FromHexString(cleanCred);
currentPasskey.PublicKey = Convert.FromHexString(cleanPub);
currentPasskey.UserHandle = Convert.FromHexString(cleanUser);
isSaving = true;
var result = await DbService.SavePasskeyAsync(currentPasskey, !isEditing);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
if (result.Success)
{
showModal = false;
await LoadPasskeysAsync();
}
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Failed to parse hex or save passkey: " + ex.Message;
}
}
private void OpenViewModal(PasskeyCredential passkey)
{
viewedPasskey = passkey;
showViewModal = true;
}
private void OpenDeleteModal(PasskeyCredential passkey)
{
passkeyToDelete = passkey;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (passkeyToDelete == null) return;
isSaving = true;
var result = await DbService.DeletePasskeyAsync(passkeyToDelete.Id);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
showDeleteModal = false;
passkeyToDelete = null;
await LoadPasskeysAsync();
}
private async Task SeedSamplePasskeysAsync()
{
var result = await DbService.SeedSampleDataAsync();
alertIsSuccess = result.Success;
alertMessage = result.Message;
await LoadPasskeysAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,263 @@
@page "/test-suite"
@implements IDisposable
<PageTitle>Database Test Suite Runner - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Feature Test Suite</h2>
<p class="text-secondary mb-0">Automated end-to-end verification of all database features, constraints, and JSONB mappings referenced in Chrono Cloud.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="ClearResults" disabled="@isRunning">
<i class="bi bi-eraser-fill"></i> Clear Results
</button>
<button class="btn btn-chrono-primary" @onclick="RunTestsAsync" disabled="@isRunning">
<i class="bi bi-play-circle-fill @(isRunning ? "spin" : "")"></i>
@(isRunning ? "Running Test Suite..." : "Run All Database Tests")
</button>
</div>
</div>
<!-- Scorecard Summary -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box metric-icon-blue">
<i class="bi bi-list-check"></i>
</div>
<div>
<div class="metric-value">@testResults.Count</div>
<div class="metric-label">Total Tests</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box metric-icon-green">
<i class="bi bi-check-circle-fill"></i>
</div>
<div>
<div class="metric-value text-success">@testResults.Count(t => t.Status == TestStatus.Passed)</div>
<div class="metric-label">Passed</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box @(testResults.Any(t => t.Status == TestStatus.Failed) ? "metric-icon-red" : "metric-icon-purple")">
<i class="bi @(testResults.Any(t => t.Status == TestStatus.Failed) ? "bi-x-circle-fill text-danger" : "bi-shield-check")"></i>
</div>
<div>
<div class="metric-value @(testResults.Any(t => t.Status == TestStatus.Failed) ? "text-danger" : "")">
@testResults.Count(t => t.Status == TestStatus.Failed)
</div>
<div class="metric-label">Failed</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
<i class="bi bi-stopwatch-fill"></i>
</div>
<div>
<div class="metric-value">@(testResults.Sum(t => t.DurationMs))ms</div>
<div class="metric-label">Total Duration</div>
</div>
</div>
</div>
</div>
@if (isRunning)
{
<div class="chrono-card mb-4 border-primary" style="background-color: rgba(88, 166, 255, 0.08);">
<div class="d-flex align-items-center gap-3">
<div class="spinner-border text-primary" role="status"></div>
<div>
<h6 class="text-primary fw-bold mb-0">Executing Test Suite...</h6>
<span class="text-secondary small">@currentProgressStatus</span>
</div>
</div>
</div>
}
<!-- Test Cases List -->
<div class="chrono-card">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-card-checklist text-primary"></i>
Test Suite Execution Results
</h5>
<div class="d-flex align-items-center gap-2">
<span class="text-secondary small">Target Provider:</span>
<span class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")">
@ConfigService.ProviderType
</span>
</div>
</div>
@if (!testResults.Any() && !isRunning)
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-play-circle fs-1 d-block mb-2"></i>
<h5>Test Suite Ready</h5>
<p class="small">Click "Run All Database Tests" above to execute tests against the database.</p>
<button class="btn btn-chrono-primary mt-2" @onclick="RunTestsAsync">
<i class="bi bi-play-circle-fill"></i> Start Test Run
</button>
</div>
}
else
{
<div class="d-flex flex-column gap-3">
@foreach (var test in testResults)
{
<div class="p-3 rounded bg-dark border @GetBorderClass(test.Status)">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary font-monospace">@test.Id</span>
<span class="fw-bold text-white fs-6">@test.Name</span>
<span class="badge bg-dark border border-secondary text-secondary small">@test.Category</span>
</div>
<div class="d-flex align-items-center gap-3">
<span class="text-secondary small font-monospace">@(test.DurationMs)ms</span>
<span class="badge @GetBadgeClass(test.Status) px-3 py-2">
@if (test.Status == TestStatus.Running)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
else if (test.Status == TestStatus.Passed)
{
<i class="bi bi-check-lg me-1"></i>
}
else if (test.Status == TestStatus.Failed)
{
<i class="bi bi-x-lg me-1"></i>
}
@test.Status.ToString().ToUpper()
</span>
</div>
</div>
<p class="text-secondary small mb-2">@test.Description</p>
@if (!string.IsNullOrEmpty(test.ErrorMessage))
{
<div class="alert alert-danger p-2 mb-2 small">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Failure:</strong> @test.ErrorMessage
</div>
}
<!-- Expandable Logs -->
<div class="mt-2">
<button class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.78rem;"
@onclick="() => ToggleLogs(test.Id)">
<i class="bi @(expandedTestLogs.Contains(test.Id) ? "bi-chevron-up" : "bi-chevron-down") me-1"></i>
@(expandedTestLogs.Contains(test.Id) ? "Hide Log Details" : $"View Logs ({test.Logs.Count} events)")
</button>
@if (expandedTestLogs.Contains(test.Id))
{
<div class="log-console mt-2">
@if (test.Logs.Any())
{
@foreach (var log in test.Logs)
{
<div>@log</div>
}
}
else
{
<div class="text-secondary fst-italic">No logs recorded.</div>
}
</div>
}
</div>
</div>
}
</div>
}
</div>
@code {
private List<DatabaseTestCaseResult> testResults = [];
private HashSet<string> expandedTestLogs = [];
private bool isRunning;
private string currentProgressStatus = "";
protected override void OnInitialized()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
}
private void HandleConfigChanged()
{
ClearResults();
InvokeAsync(StateHasChanged);
}
private async Task RunTestsAsync()
{
isRunning = true;
testResults.Clear();
expandedTestLogs.Clear();
currentProgressStatus = "Initializing test suite...";
StateHasChanged();
testResults = await DbService.RunAllTestsAsync(msg =>
{
currentProgressStatus = msg;
InvokeAsync(StateHasChanged);
});
// Automatically expand any failed tests for convenience
foreach (var failed in testResults.Where(t => t.Status == TestStatus.Failed))
{
expandedTestLogs.Add(failed.Id);
}
isRunning = false;
StateHasChanged();
}
private void ClearResults()
{
testResults.Clear();
expandedTestLogs.Clear();
}
private void ToggleLogs(string testId)
{
if (expandedTestLogs.Contains(testId))
expandedTestLogs.Remove(testId);
else
expandedTestLogs.Add(testId);
}
private static string GetBorderClass(TestStatus status) => status switch
{
TestStatus.Passed => "border-success border-opacity-50",
TestStatus.Failed => "border-danger border-opacity-75",
TestStatus.Running => "border-primary border-opacity-50",
_ => "border-secondary border-opacity-25"
};
private static string GetBadgeClass(TestStatus status) => status switch
{
TestStatus.Passed => "bg-success",
TestStatus.Failed => "bg-danger",
TestStatus.Running => "bg-primary",
_ => "bg-secondary"
};
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,604 @@
@page "/user-decks"
@implements IDisposable
@using System.Text.Json
<PageTitle>User Decks Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">User Decks Database (`UserDeck`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete deck builds with native JSONB collection mappings.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadDecksAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Create New Deck
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<div class="chrono-card">
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search decks by name, season, or notes..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterDecks" />
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
}
</div>
<div class="text-secondary small">
Showing @decks.Count deck(s)
</div>
</div>
@if (isLoading)
{
<div class="text-center py-5 text-secondary">
<div class="spinner-border text-primary mb-2" role="status"></div>
<div>Loading decks from database...</div>
</div>
}
else if (!decks.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-collection fs-1 d-block mb-2"></i>
<h5>No User Decks Found</h5>
<p class="small">No decks were found matching your search.</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Create First Deck
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedSampleDecksAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Decks
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th>Deck Name</th>
<th style="width: 120px;">Season</th>
<th style="width: 130px;">Cards (JSONB)</th>
<th style="width: 110px;">Divers</th>
<th>Notes</th>
<th style="width: 150px;">Updated (UTC)</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var deck in decks)
{
var totalCards = deck.Cards?.Count ?? 0;
var uniqueCards = deck.Cards?.Distinct().Count() ?? 0;
var diverCount = deck.Divers?.Count ?? 0;
<tr>
<td>
<div class="fw-bold text-white">@deck.Name</div>
<div class="text-secondary" style="font-size: 0.75rem;">ID: @deck.Id</div>
</td>
<td>
<span class="badge bg-secondary">@(string.IsNullOrEmpty(deck.Season) ? "General" : deck.Season)</span>
</td>
<td>
<span class="badge bg-primary" title="@($"{uniqueCards} unique card type(s)")">
<i class="bi bi-stack me-1"></i> @totalCards cards
</span>
</td>
<td>
<span class="badge @(diverCount > 0 ? "bg-purple" : "bg-dark border border-secondary text-secondary")">
<i class="bi bi-person-fill me-1"></i> @diverCount
</span>
</td>
<td>
<div class="text-secondary text-truncate" style="max-width: 220px;" title="@deck.Notes">
@(string.IsNullOrEmpty(deck.Notes) ? "-" : deck.Notes)
</div>
</td>
<td>
<div class="small text-secondary">
@deck.UpdatedAt.ToString("yyyy-MM-dd HH:mm")
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(deck)" title="View JSON & Breakdown">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(deck)" title="Edit Deck">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(deck)" title="Delete Deck">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Add / Edit Modal -->
@if (showModal)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 750px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isEditing ? "bi-pencil-square" : "bi-plus-circle") text-primary me-2"></i>
@(isEditing ? "Edit User Deck" : "Create New User Deck")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label text-secondary small fw-bold">DECK NAME</label>
<input type="text" class="form-control form-control-chrono" placeholder="Enter deck name..."
@bind="currentDeck.Name" />
</div>
<div class="col-md-4">
<label class="form-label text-secondary small fw-bold">SEASON</label>
<input type="text" class="form-control form-control-chrono" placeholder="e.g. Season 1, Tournament"
@bind="currentDeck.Season" />
</div>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">DECK STRATEGY & NOTES</label>
<textarea class="form-control form-control-chrono" rows="2"
placeholder="Notes about curve, combos, sideboarding..."
@bind="currentDeck.Notes"></textarea>
</div>
<!-- Cards (JSONB Collection) Section -->
<div class="chrono-card mb-3 p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="fw-bold text-white small">
<i class="bi bi-stack text-primary me-1"></i>
DECK CARDS (@(currentDeck.Cards?.Count ?? 0) Total)
</span>
</div>
<!-- Add Card Form -->
<div class="row g-2 mb-3">
<div class="col-md-7">
<input type="text" class="form-control form-control-chrono form-control-sm"
list="deckCardSuggestions" placeholder="Card name..." @bind="newCardName" />
<datalist id="deckCardSuggestions">
@foreach (var c in allCardNames.Take(50))
{
<option value="@c" />
}
</datalist>
</div>
<div class="col-md-2">
<select class="form-select form-select-chrono form-select-sm" @bind="newCardCount">
<option value="1">x1</option>
<option value="2">x2</option>
<option value="3">x3</option>
</select>
</div>
<div class="col-md-3">
<button type="button" class="btn btn-sm btn-chrono-secondary w-100" @onclick="AddCardToDeck">
<i class="bi bi-plus-circle"></i> Add Card
</button>
</div>
</div>
<!-- Current Cards List (Grouped for display) -->
@if (currentDeck.Cards == null || !currentDeck.Cards.Any())
{
<div class="text-secondary small fst-italic p-2 bg-dark rounded border border-secondary border-opacity-25">
No cards added yet. Use the selector above to add cards to this deck.
</div>
}
else
{
<div class="d-flex flex-wrap gap-2" style="max-height: 160px; overflow-y: auto;">
@foreach (var group in currentDeck.Cards.GroupBy(c => c))
{
var cardName = group.Key;
var count = group.Count();
<div class="badge bg-dark border border-secondary p-2 d-flex align-items-center gap-2">
<span class="text-white">@cardName</span>
<span class="badge bg-primary">x@count</span>
<button type="button" class="btn btn-sm p-0 text-secondary" title="Remove one" @onclick="() => RemoveOneCardFromDeck(cardName)">
<i class="bi bi-dash"></i>
</button>
<button type="button" class="btn btn-sm p-0 text-danger" title="Remove all" @onclick="() => RemoveAllCardsFromDeck(cardName)">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
}
</div>
}
</div>
<!-- Divers (JSONB Collection) Section -->
<div class="chrono-card mb-0 p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="fw-bold text-white small">
<i class="bi bi-person-fill text-purple me-1"></i>
DIVERS / AGENTS (@(currentDeck.Divers?.Count ?? 0))
</span>
</div>
<div class="row g-2 mb-3">
<div class="col-md-9">
<input type="text" class="form-control form-control-chrono form-control-sm"
list="diverSuggestions" placeholder="Agent / Diver name..." @bind="newDiverName" />
<datalist id="diverSuggestions">
@foreach (var c in agentCardNames.Take(40))
{
<option value="@c" />
}
</datalist>
</div>
<div class="col-md-3">
<button type="button" class="btn btn-sm btn-chrono-secondary w-100" @onclick="AddDiverToDeck">
<i class="bi bi-plus-circle"></i> Add Diver
</button>
</div>
</div>
@if (currentDeck.Divers == null || !currentDeck.Divers.Any())
{
<div class="text-secondary small fst-italic p-2 bg-dark rounded border border-secondary border-opacity-25">
No divers specified for this deck.
</div>
}
else
{
<div class="d-flex flex-wrap gap-2">
@foreach (var diver in currentDeck.Divers)
{
<div class="badge bg-dark border border-purple p-2 d-flex align-items-center gap-2">
<span class="text-purple">@diver</span>
<button type="button" class="btn btn-sm p-0 text-danger" @onclick="() => RemoveDiverFromDeck(diver)">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
}
</div>
}
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveDeckAsync" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Deck")
</button>
</div>
</div>
</div>
}
<!-- View JSON & Breakdown Modal -->
@if (showViewModal && viewedDeck != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 650px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi bi-file-earmark-code text-primary me-2"></i>
Deck Inspection: @viewedDeck.Name
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showViewModal = false"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<h6 class="text-secondary small fw-bold mb-2">RAW JSON DATABASE RECORD</h6>
<pre class="code-block" style="max-height: 250px;">@GetFormattedDeckJson(viewedDeck)</pre>
</div>
<div class="mb-2">
<h6 class="text-secondary small fw-bold mb-2">CARDS BREAKDOWN</h6>
<div class="row g-2">
@foreach (var group in (viewedDeck.Cards ?? []).GroupBy(c => c))
{
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary d-flex align-items-center justify-content-between">
<span class="text-white small">@group.Key</span>
<span class="badge bg-primary">x@group.Count()</span>
</div>
</div>
}
</div>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (showDeleteModal && deckToDelete != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Deck
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete deck:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3">
@deckToDelete.Name
</div>
<p class="text-secondary small mb-0">This will remove the deck record and its JSONB associations from the database.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Deck")
</button>
</div>
</div>
</div>
}
@code {
private List<UserDeck> decks = [];
private List<string> allCardNames = [];
private List<string> agentCardNames = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private UserDeck currentDeck = new();
private string newCardName = "";
private int newCardCount = 1;
private string newDiverName = "";
private bool showViewModal;
private UserDeck? viewedDeck;
private bool showDeleteModal;
private UserDeck? deckToDelete;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
try
{
allCardNames = CardDatabase.Cards
.Select(c => c.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n)
.ToList();
agentCardNames = CardDatabase.Cards
.Where(c => c.IsAgent)
.Select(c => c.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n)
.ToList();
}
catch
{
allCardNames = [];
agentCardNames = [];
}
await LoadDecksAsync();
}
private void HandleConfigChanged()
{
_ = LoadDecksAsync();
}
private async Task LoadDecksAsync()
{
isLoading = true;
try
{
decks = await DbService.GetUserDecksAsync(searchTerm);
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Error loading user decks: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task FilterDecks()
{
decks = await DbService.GetUserDecksAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = "";
await LoadDecksAsync();
}
private void OpenAddModal()
{
isEditing = false;
currentDeck = new UserDeck
{
Id = Guid.NewGuid(),
Name = "",
Season = "Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = new List<string>(),
Divers = new List<string>()
};
newCardName = "";
newCardCount = 1;
newDiverName = "";
showModal = true;
}
private void OpenEditModal(UserDeck deck)
{
isEditing = true;
currentDeck = new UserDeck
{
Id = deck.Id,
Name = deck.Name,
Season = deck.Season,
Notes = deck.Notes,
UpdatedAt = deck.UpdatedAt,
Cards = deck.Cards?.ToList() ?? new(),
Divers = deck.Divers?.ToList() ?? new()
};
newCardName = "";
newCardCount = 1;
newDiverName = "";
showModal = true;
}
private void CloseModal()
{
showModal = false;
}
private void AddCardToDeck()
{
if (string.IsNullOrWhiteSpace(newCardName)) return;
currentDeck.Cards ??= new List<string>();
for (int i = 0; i < newCardCount; i++)
{
currentDeck.Cards.Add(newCardName.Trim());
}
newCardName = "";
newCardCount = 1;
}
private void RemoveOneCardFromDeck(string cardName)
{
currentDeck.Cards?.Remove(cardName);
}
private void RemoveAllCardsFromDeck(string cardName)
{
currentDeck.Cards?.RemoveAll(c => c == cardName);
}
private void AddDiverToDeck()
{
if (string.IsNullOrWhiteSpace(newDiverName)) return;
currentDeck.Divers ??= new List<string>();
if (!currentDeck.Divers.Contains(newDiverName.Trim(), StringComparer.OrdinalIgnoreCase))
{
currentDeck.Divers.Add(newDiverName.Trim());
}
newDiverName = "";
}
private void RemoveDiverFromDeck(string diver)
{
currentDeck.Divers?.Remove(diver);
}
private async Task SaveDeckAsync()
{
if (string.IsNullOrWhiteSpace(currentDeck.Name))
{
alertIsSuccess = false;
alertMessage = "Deck name cannot be empty.";
return;
}
isSaving = true;
var result = await DbService.SaveUserDeckAsync(currentDeck, !isEditing);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
if (result.Success)
{
showModal = false;
await LoadDecksAsync();
}
}
private void OpenViewModal(UserDeck deck)
{
viewedDeck = deck;
showViewModal = true;
}
private string GetFormattedDeckJson(UserDeck deck)
{
return JsonSerializer.Serialize(deck, new JsonSerializerOptions { WriteIndented = true });
}
private void OpenDeleteModal(UserDeck deck)
{
deckToDelete = deck;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (deckToDelete == null) return;
isSaving = true;
var result = await DbService.DeleteUserDeckAsync(deckToDelete.Id);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
showDeleteModal = false;
deckToDelete = null;
await LoadDecksAsync();
}
private async Task SeedSampleDecksAsync()
{
var result = await DbService.SeedSampleDataAsync();
alertIsSuccess = result.Success;
alertMessage = result.Message;
await LoadDecksAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,67 @@
@page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p>
<em>Loading...</em>
</p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate streaming rendering
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
@@ -0,0 +1,19 @@
@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 static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using DatabaseTest
@using DatabaseTest.Components
@using DatabaseTest.Components.Layout
@using DatabaseTest.Models
@using DatabaseTest.Services
@using Cloud.Models
@using Model
@inject IDatabaseConfigService ConfigService
@inject IDatabaseService DbService
@inject NavigationManager Navigation
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\Generated\Cards.g.cs" Link="Generated\Cards.g.cs" />
<Compile Include="..\Shared\Generated\Decks.g.cs" Link="Generated\Decks.g.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
</Project>
@@ -0,0 +1,43 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Cloud;
#nullable disable
namespace Cloud.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260618210058_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chrono.Model.CardNote", b =>
{
b.Property<string>("CardName")
.HasColumnType("text");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.HasKey("CardName");
b.ToTable("CardNotes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cloud.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CardNotes",
columns: table => new
{
CardName = table.Column<string>(type: "text", nullable: false),
Note = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CardNotes", x => x.CardName);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CardNotes");
}
}
}
@@ -0,0 +1,76 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Cloud;
#nullable disable
namespace Cloud.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703051336_AddUserDecks")]
partial class AddUserDecks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chrono.Model.CardNote", b =>
{
b.Property<string>("CardName")
.HasColumnType("text");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.HasKey("CardName");
b.ToTable("CardNotes");
});
modelBuilder.Entity("Cloud.Models.UserDeck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<string>("Cards")
.IsRequired()
.HasColumnType("jsonb");
b.PrimitiveCollection<string>("Divers")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Season")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("UserDecks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cloud.Migrations
{
/// <inheritdoc />
public partial class AddUserDecks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserDecks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Cards = table.Column<string>(type: "jsonb", nullable: false),
Divers = table.Column<string>(type: "jsonb", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
Season = table.Column<string>(type: "text", nullable: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserDecks", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserDecks");
}
}
}
@@ -0,0 +1,110 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Cloud;
#nullable disable
namespace Cloud.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260703120000_AddPasskeyCredentials")]
partial class AddPasskeyCredentials
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chrono.Model.CardNote", b =>
{
b.Property<string>("CardName")
.HasColumnType("text");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.HasKey("CardName");
b.ToTable("CardNotes");
});
modelBuilder.Entity("Cloud.Models.PasskeyCredential", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AaGuid")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.ToTable("PasskeyCredentials");
});
modelBuilder.Entity("Cloud.Models.UserDeck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<string>("Cards")
.IsRequired()
.HasColumnType("jsonb");
b.PrimitiveCollection<string>("Divers")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Season")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("UserDecks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Cloud.Migrations
{
/// <inheritdoc />
public partial class AddPasskeyCredentials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PasskeyCredentials",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CredentialId = table.Column<byte[]>(type: "bytea", nullable: false),
PublicKey = table.Column<byte[]>(type: "bytea", nullable: false),
SignCount = table.Column<long>(type: "bigint", nullable: false),
UserHandle = table.Column<byte[]>(type: "bytea", nullable: false),
AaGuid = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PasskeyCredentials", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PasskeyCredentials");
}
}
}
@@ -0,0 +1,106 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Cloud.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chrono.Model.CardNote", b =>
{
b.Property<string>("CardName")
.HasColumnType("text");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.HasKey("CardName");
b.ToTable("CardNotes");
});
modelBuilder.Entity("Cloud.Models.PasskeyCredential", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AaGuid")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.ToTable("PasskeyCredentials");
});
modelBuilder.Entity("Cloud.Models.UserDeck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<string>("Cards")
.IsRequired()
.HasColumnType("jsonb");
b.PrimitiveCollection<string>("Divers")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Season")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("UserDecks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,77 @@
namespace DatabaseTest.Models;
public class DatabaseHealthStatus
{
public bool IsConnected { get; set; }
public DatabaseProviderType ProviderType { get; set; }
public string ProviderName { get; set; } = string.Empty;
public string ConnectionString { get; set; } = string.Empty;
public long ResponseTimeMs { get; set; }
public int CardNotesCount { get; set; }
public int UserDecksCount { get; set; }
public int PasskeysCount { get; set; }
public int PendingMigrationsCount { get; set; }
public int AppliedMigrationsCount { get; set; }
public string? ErrorMessage { get; set; }
public DateTime CheckedAt { get; set; } = DateTime.UtcNow;
}
public class OperationResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public string? ErrorDetails { get; set; }
public static OperationResult Ok(string message) => new() { Success = true, Message = message };
public static OperationResult Fail(string message, string? errorDetails = null) => new()
{
Success = false,
Message = message,
ErrorDetails = errorDetails
};
}
public class SeedResult
{
public bool Success { get; set; }
public int CardNotesSeeded { get; set; }
public int UserDecksSeeded { get; set; }
public int PasskeysSeeded { get; set; }
public string Message { get; set; } = string.Empty;
}
public class MigrationReport
{
public DatabaseProviderType ProviderType { get; set; }
public List<string> AppliedMigrations { get; set; } = [];
public List<string> PendingMigrations { get; set; } = [];
public bool SupportsMigrations { get; set; }
public string? Error { get; set; }
}
public enum TestStatus
{
Pending,
Running,
Passed,
Failed,
Skipped
}
public class DatabaseTestCaseResult
{
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public TestStatus Status { get; set; } = TestStatus.Pending;
public long DurationMs { get; set; }
public List<string> Logs { get; set; } = [];
public string? ErrorMessage { get; set; }
public string? StackTrace { get; set; }
public void AddLog(string message)
{
Logs.Add($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}");
}
}
@@ -0,0 +1,7 @@
namespace DatabaseTest.Models;
public enum DatabaseProviderType
{
PostgreSQL,
InMemory
}
@@ -0,0 +1,12 @@
namespace Cloud.Models;
public class PasskeyCredential
{
public int Id { get; set; }
public required byte[] CredentialId { get; set; }
public required byte[] PublicKey { get; set; }
public long SignCount { get; set; }
public required byte[] UserHandle { get; set; }
public string AaGuid { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+12
View File
@@ -0,0 +1,12 @@
namespace Cloud.Models;
public class UserDeck
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public List<string> Cards { get; set; } = [];
public List<string> Divers { get; set; } = [];
public string? Notes { get; set; }
public string? Season { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+31
View File
@@ -0,0 +1,31 @@
using DatabaseTest.Components;
using DatabaseTest.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddSingleton<IDatabaseConfigService, DatabaseConfigService>();
builder.Services.AddScoped<IDatabaseService, DatabaseService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5229",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7035;http://localhost:5229",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,111 @@
using Cloud;
using DatabaseTest.Models;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Cloud.Models;
namespace DatabaseTest.Services;
public interface IDatabaseConfigService
{
DatabaseProviderType ProviderType { get; }
string ConnectionString { get; }
string InMemoryDbName { get; }
event Action? OnConfigurationChanged;
void SetProvider(DatabaseProviderType providerType);
void SetConnectionString(string connectionString);
void SetInMemoryDbName(string name);
AppDbContext CreateDbContext();
string GetActiveProviderDescription();
}
public class DatabaseConfigService : IDatabaseConfigService
{
private readonly IConfiguration _configuration;
private DatabaseProviderType _providerType;
private string _connectionString;
private string _inMemoryDbName = "chrono_database_test";
public event Action? OnConfigurationChanged;
public DatabaseConfigService(IConfiguration configuration)
{
_configuration = configuration;
var configuredConn = _configuration.GetConnectionString("DefaultConnection")
?? _configuration.GetConnectionString("Postgres")
?? Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? "Host=localhost;Port=5432;Database=chrono;Username=postgres;Password=postgres";
_connectionString = configuredConn;
_providerType = DatabaseProviderType.PostgreSQL;
}
public DatabaseProviderType ProviderType => _providerType;
public string ConnectionString => _connectionString;
public string InMemoryDbName => _inMemoryDbName;
public void SetProvider(DatabaseProviderType providerType)
{
if (_providerType != providerType)
{
_providerType = providerType;
OnConfigurationChanged?.Invoke();
}
}
public void SetConnectionString(string connectionString)
{
if (_connectionString != connectionString)
{
_connectionString = connectionString;
OnConfigurationChanged?.Invoke();
}
}
public void SetInMemoryDbName(string name)
{
if (_inMemoryDbName != name)
{
_inMemoryDbName = name;
OnConfigurationChanged?.Invoke();
}
}
public string GetActiveProviderDescription()
{
return _providerType switch
{
DatabaseProviderType.PostgreSQL => $"PostgreSQL ({GetMaskedConnectionString(_connectionString)})",
DatabaseProviderType.InMemory => $"In-Memory Test Database ({_inMemoryDbName})",
_ => _providerType.ToString()
};
}
private static string GetMaskedConnectionString(string conn)
{
if (string.IsNullOrWhiteSpace(conn)) return "Not configured";
// Hide password in display
return System.Text.RegularExpressions.Regex.Replace(conn, @"(?i)Password=[^;]+", "Password=******");
}
public AppDbContext CreateDbContext()
{
var builder = new DbContextOptionsBuilder<AppDbContext>();
if (_providerType == DatabaseProviderType.PostgreSQL)
{
builder.UseNpgsql(_connectionString, npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(15);
});
}
else
{
builder.UseInMemoryDatabase(_inMemoryDbName);
}
return new AppDbContext(builder.Options);
}
}
@@ -0,0 +1,972 @@
using System.Diagnostics;
using System.Text.Json;
using Cloud;
using Cloud.Models;
using DatabaseTest.Models;
using Microsoft.EntityFrameworkCore;
using Model;
namespace DatabaseTest.Services;
public interface IDatabaseService
{
Task<DatabaseHealthStatus> CheckHealthAsync();
Task<MigrationReport> GetMigrationStatusAsync();
Task<OperationResult> ApplyMigrationsAsync();
Task<OperationResult> EnsureDatabaseCreatedAsync();
Task<OperationResult> ResetDatabaseAsync();
Task<SeedResult> SeedSampleDataAsync();
// Card Notes
Task<List<CardNote>> GetCardNotesAsync(string? search = null);
Task<CardNote?> GetCardNoteAsync(string cardName);
Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew);
Task<OperationResult> DeleteCardNoteAsync(string cardName);
// User Decks
Task<List<UserDeck>> GetUserDecksAsync(string? search = null);
Task<UserDeck?> GetUserDeckAsync(Guid id);
Task<OperationResult> SaveUserDeckAsync(UserDeck deck, bool isNew);
Task<OperationResult> DeleteUserDeckAsync(Guid id);
// Passkey Credentials
Task<List<PasskeyCredential>> GetPasskeysAsync(string? search = null);
Task<PasskeyCredential?> GetPasskeyAsync(int id);
Task<OperationResult> SavePasskeyAsync(PasskeyCredential passkey, bool isNew);
Task<OperationResult> DeletePasskeyAsync(int id);
// Automated Test Suite
Task<List<DatabaseTestCaseResult>> RunAllTestsAsync(Action<string>? progressCallback = null);
}
public class DatabaseService : IDatabaseService
{
private readonly IDatabaseConfigService _configService;
private readonly ILogger<DatabaseService> _logger;
public DatabaseService(IDatabaseConfigService configService, ILogger<DatabaseService> logger)
{
_configService = configService;
_logger = logger;
}
public async Task<DatabaseHealthStatus> CheckHealthAsync()
{
var status = new DatabaseHealthStatus
{
ProviderType = _configService.ProviderType,
ProviderName = _configService.ProviderType.ToString(),
ConnectionString = _configService.ConnectionString,
CheckedAt = DateTime.UtcNow
};
var sw = Stopwatch.StartNew();
try
{
await using var db = _configService.CreateDbContext();
var canConnect = await db.Database.CanConnectAsync();
sw.Stop();
status.ResponseTimeMs = sw.ElapsedMilliseconds;
status.IsConnected = canConnect;
if (canConnect)
{
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
var pending = await db.Database.GetPendingMigrationsAsync();
var applied = await db.Database.GetAppliedMigrationsAsync();
status.PendingMigrationsCount = pending.Count();
status.AppliedMigrationsCount = applied.Count();
}
try
{
status.CardNotesCount = await db.CardNotes.CountAsync();
status.UserDecksCount = await db.UserDecks.CountAsync();
status.PasskeysCount = await db.PasskeyCredentials.CountAsync();
}
catch (Exception ex)
{
status.ErrorMessage = $"Connected, but table count failed (migrations may be pending): {ex.Message}";
}
}
else
{
status.ErrorMessage = "Unable to connect to database. Verify connection string and server status.";
}
}
catch (Exception ex)
{
sw.Stop();
status.ResponseTimeMs = sw.ElapsedMilliseconds;
status.IsConnected = false;
status.ErrorMessage = ex.Message;
}
return status;
}
public async Task<MigrationReport> GetMigrationStatusAsync()
{
var report = new MigrationReport
{
ProviderType = _configService.ProviderType
};
if (_configService.ProviderType != DatabaseProviderType.PostgreSQL)
{
report.SupportsMigrations = false;
return report;
}
report.SupportsMigrations = true;
try
{
await using var db = _configService.CreateDbContext();
report.AppliedMigrations = (await db.Database.GetAppliedMigrationsAsync()).ToList();
report.PendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToList();
}
catch (Exception ex)
{
report.Error = ex.Message;
}
return report;
}
public async Task<OperationResult> ApplyMigrationsAsync()
{
try
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
if (!pending.Any())
{
return OperationResult.Ok("Database schema is already up-to-date. No pending migrations.");
}
await db.Database.MigrateAsync();
return OperationResult.Ok($"Successfully applied {pending.Count} migration(s): {string.Join(", ", pending)}");
}
else
{
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database schema ensured.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply migrations.");
return OperationResult.Fail("Failed to apply migrations: " + ex.Message, ex.ToString());
}
}
public async Task<OperationResult> EnsureDatabaseCreatedAsync()
{
try
{
await using var db = _configService.CreateDbContext();
var created = await db.Database.EnsureCreatedAsync();
return OperationResult.Ok(created ? "Database created successfully." : "Database already exists.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to ensure database creation.");
return OperationResult.Fail("Failed to ensure database created: " + ex.Message, ex.ToString());
}
}
public async Task<OperationResult> ResetDatabaseAsync()
{
try
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
// Delete rows from all tables
db.CardNotes.RemoveRange(db.CardNotes);
db.UserDecks.RemoveRange(db.UserDecks);
db.PasskeyCredentials.RemoveRange(db.PasskeyCredentials);
await db.SaveChangesAsync();
return OperationResult.Ok("All database tables cleared successfully.");
}
else
{
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database wiped and recreated successfully.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reset database.");
return OperationResult.Fail("Failed to reset database: " + ex.Message, ex.ToString());
}
}
public async Task<SeedResult> SeedSampleDataAsync()
{
var result = new SeedResult();
try
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
await db.Database.MigrateAsync();
}
else
{
await db.Database.EnsureCreatedAsync();
}
// 1. Seed Card Notes
var existingNoteNames = (await db.CardNotes.Select(n => n.CardName).ToListAsync()).ToHashSet();
var sampleNotes = new List<CardNote>
{
new() { CardName = "Kaelen, Arcane Weaver", Note = "Key combo enabler for control decks. High priority removal target." },
new() { CardName = "Valkyrie Ascendant", Note = "Strong mid-game aerial presence. Synergizes well with Divine Shield buffs." },
new() { CardName = "Chronos, Time Shifter", Note = "Turn rewind capability allows recovering from early tempo loss." },
new() { CardName = "Cyber Samurai", Note = "Fast aggro card with stealth. Great opener." },
new() { CardName = "Temporal Rift", Note = "Essential spell for board reset in late game." }
};
foreach (var note in sampleNotes.Where(n => !existingNoteNames.Contains(n.CardName)))
{
db.CardNotes.Add(note);
result.CardNotesSeeded++;
}
// 2. Seed User Decks
var existingDeckNames = (await db.UserDecks.Select(d => d.Name).ToListAsync()).ToHashSet();
var sampleDecks = new List<UserDeck>
{
new()
{
Id = Guid.NewGuid(),
Name = "Chrono Temporal Control",
Notes = "Built around time-shift mechanics and spell stalling.",
Season = "Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = new List<string>
{
"Chronos, Time Shifter",
"Chronos, Time Shifter",
"Temporal Rift",
"Temporal Rift",
"Temporal Rift",
"Kaelen, Arcane Weaver",
"Kaelen, Arcane Weaver"
},
Divers = new List<string> { "Chronos, Time Shifter" }
},
new()
{
Id = Guid.NewGuid(),
Name = "Valkyrie Aggro Burst",
Notes = "Fast tempo rush deck targeting rapid face damage.",
Season = "Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = new List<string>
{
"Valkyrie Ascendant",
"Valkyrie Ascendant",
"Valkyrie Ascendant",
"Cyber Samurai",
"Cyber Samurai",
"Cyber Samurai"
},
Divers = new List<string>()
}
};
foreach (var deck in sampleDecks.Where(d => !existingDeckNames.Contains(d.Name)))
{
db.UserDecks.Add(deck);
result.UserDecksSeeded++;
}
// 3. Seed Passkey Credentials
var existingPasskeyCount = await db.PasskeyCredentials.CountAsync();
if (existingPasskeyCount == 0)
{
var samplePasskeys = new List<PasskeyCredential>
{
new()
{
CredentialId = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80 },
PublicKey = new byte[] { 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07 },
UserHandle = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 },
AaGuid = Guid.NewGuid().ToString(),
SignCount = 42
},
new()
{
CredentialId = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 },
PublicKey = new byte[] { 0x04, 0x1F, 0x2E, 0x3D, 0x4C, 0x5B, 0x6A, 0x79, 0x88, 0x97, 0xA6, 0xB5, 0xC4, 0xD3, 0xE2, 0xF1 },
UserHandle = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE, 0x05, 0x06, 0x07, 0x08 },
AaGuid = Guid.NewGuid().ToString(),
SignCount = 100
}
};
db.PasskeyCredentials.AddRange(samplePasskeys);
result.PasskeysSeeded += samplePasskeys.Count;
}
await db.SaveChangesAsync();
result.Success = true;
result.Message = $"Sample data seeded: {result.CardNotesSeeded} notes, {result.UserDecksSeeded} decks, {result.PasskeysSeeded} passkeys.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to seed sample data.");
result.Success = false;
result.Message = "Failed to seed data: " + ex.Message;
}
return result;
}
// ── Card Notes CRUD ────────────────────────────────────────────────────────
public async Task<List<CardNote>> GetCardNotesAsync(string? search = null)
{
await using var db = _configService.CreateDbContext();
var query = db.CardNotes.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(n => n.CardName.ToLower().Contains(s) || n.Note.ToLower().Contains(s));
}
return await query.OrderBy(n => n.CardName).ToListAsync();
}
public async Task<CardNote?> GetCardNoteAsync(string cardName)
{
await using var db = _configService.CreateDbContext();
return await db.CardNotes.AsNoTracking().FirstOrDefaultAsync(n => n.CardName == cardName);
}
public async Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew)
{
if (string.IsNullOrWhiteSpace(note.CardName))
return OperationResult.Fail("Card name is required.");
try
{
await using var db = _configService.CreateDbContext();
if (isNew)
{
var exists = await db.CardNotes.AnyAsync(n => n.CardName == note.CardName);
if (exists)
return OperationResult.Fail($"A note for card '{note.CardName}' already exists.");
db.CardNotes.Add(note);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created note for '{note.CardName}'.");
}
else
{
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == note.CardName);
if (existing == null)
return OperationResult.Fail($"Note for card '{note.CardName}' was not found.");
existing.Note = note.Note;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated note for '{note.CardName}'.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save card note {CardName}", note.CardName);
return OperationResult.Fail($"Error saving card note: {ex.Message}", ex.ToString());
}
}
public async Task<OperationResult> DeleteCardNoteAsync(string cardName)
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == cardName);
if (existing == null)
return OperationResult.Fail($"Note for '{cardName}' not found.");
db.CardNotes.Remove(existing);
await db.SaveChangesAsync();
return OperationResult.Ok($"Deleted note for '{cardName}'.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete card note {CardName}", cardName);
return OperationResult.Fail($"Error deleting note: {ex.Message}", ex.ToString());
}
}
// ── User Decks CRUD ────────────────────────────────────────────────────────
public async Task<List<UserDeck>> GetUserDecksAsync(string? search = null)
{
await using var db = _configService.CreateDbContext();
var query = db.UserDecks.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(d => d.Name.ToLower().Contains(s)
|| (d.Notes != null && d.Notes.ToLower().Contains(s))
|| (d.Season != null && d.Season.ToLower().Contains(s)));
}
return await query.OrderByDescending(d => d.UpdatedAt).ToListAsync();
}
public async Task<UserDeck?> GetUserDeckAsync(Guid id)
{
await using var db = _configService.CreateDbContext();
return await db.UserDecks.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id);
}
public async Task<OperationResult> SaveUserDeckAsync(UserDeck deck, bool isNew)
{
if (string.IsNullOrWhiteSpace(deck.Name))
return OperationResult.Fail("Deck name is required.");
try
{
await using var db = _configService.CreateDbContext();
deck.UpdatedAt = DateTime.UtcNow;
if (isNew)
{
if (deck.Id == Guid.Empty)
deck.Id = Guid.NewGuid();
db.UserDecks.Add(deck);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created deck '{deck.Name}'.");
}
else
{
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == deck.Id);
if (existing == null)
return OperationResult.Fail($"Deck with ID {deck.Id} was not found.");
existing.Name = deck.Name;
existing.Notes = deck.Notes;
existing.Season = deck.Season;
existing.Cards = deck.Cards ?? [];
existing.Divers = deck.Divers ?? [];
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated deck '{deck.Name}'.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save deck {DeckName}", deck.Name);
return OperationResult.Fail($"Error saving deck: {ex.Message}", ex.ToString());
}
}
public async Task<OperationResult> DeleteUserDeckAsync(Guid id)
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == id);
if (existing == null)
return OperationResult.Fail($"Deck with ID {id} was not found.");
var name = existing.Name;
db.UserDecks.Remove(existing);
await db.SaveChangesAsync();
return OperationResult.Ok($"Deleted deck '{name}'.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete deck {DeckId}", id);
return OperationResult.Fail($"Error deleting deck: {ex.Message}", ex.ToString());
}
}
// ── Passkey Credentials CRUD ───────────────────────────────────────────────
public async Task<List<PasskeyCredential>> GetPasskeysAsync(string? search = null)
{
await using var db = _configService.CreateDbContext();
var query = db.PasskeyCredentials.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(p => (p.AaGuid != null && p.AaGuid.ToLower().Contains(s))
|| p.Id.ToString().Contains(s)
|| p.SignCount.ToString().Contains(s));
}
return await query.OrderBy(p => p.Id).ToListAsync();
}
public async Task<PasskeyCredential?> GetPasskeyAsync(int id)
{
await using var db = _configService.CreateDbContext();
return await db.PasskeyCredentials.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<OperationResult> SavePasskeyAsync(PasskeyCredential passkey, bool isNew)
{
if (passkey.CredentialId == null || passkey.CredentialId.Length == 0)
return OperationResult.Fail("Credential ID bytes are required.");
if (passkey.PublicKey == null || passkey.PublicKey.Length == 0)
return OperationResult.Fail("Public Key bytes are required.");
if (passkey.UserHandle == null || passkey.UserHandle.Length == 0)
return OperationResult.Fail("User Handle bytes are required.");
try
{
await using var db = _configService.CreateDbContext();
if (isNew)
{
db.PasskeyCredentials.Add(passkey);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created Passkey credential #{passkey.Id}.");
}
else
{
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == passkey.Id);
if (existing == null)
return OperationResult.Fail($"Passkey with ID {passkey.Id} was not found.");
existing.CredentialId = passkey.CredentialId;
existing.PublicKey = passkey.PublicKey;
existing.UserHandle = passkey.UserHandle;
existing.AaGuid = passkey.AaGuid;
existing.SignCount = passkey.SignCount;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated Passkey credential #{passkey.Id}.");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save passkey credential {Id}", passkey.Id);
return OperationResult.Fail($"Error saving passkey: {ex.Message}", ex.ToString());
}
}
public async Task<OperationResult> DeletePasskeyAsync(int id)
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == id);
if (existing == null)
return OperationResult.Fail($"Passkey with ID {id} was not found.");
db.PasskeyCredentials.Remove(existing);
await db.SaveChangesAsync();
return OperationResult.Ok($"Deleted Passkey credential #{id}.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete passkey credential {Id}", id);
return OperationResult.Fail($"Error deleting passkey: {ex.Message}", ex.ToString());
}
}
// ── Automated Test Suite Runner ───────────────────────────────────────────
public async Task<List<DatabaseTestCaseResult>> RunAllTestsAsync(Action<string>? progressCallback = null)
{
var results = new List<DatabaseTestCaseResult>();
// 1. Connection Test
results.Add(await RunTestCaseAsync("DB-01", "Database Connectivity", "Connection",
"Validates socket/connection pool access to the target database server.",
async test =>
{
test.AddLog($"Connecting to {_configService.GetActiveProviderDescription()}...");
await using var db = _configService.CreateDbContext();
var canConnect = await db.Database.CanConnectAsync();
if (!canConnect)
throw new InvalidOperationException("Failed to establish a connection to the database.");
test.AddLog("Database connection established successfully.");
}, progressCallback));
// 2. Migrations & Schema Check
results.Add(await RunTestCaseAsync("DB-02", "Schema & Migrations", "Schema",
"Verifies applied EF Core migrations and checks that all tables exist.",
async test =>
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
test.AddLog("Querying EF Core migration history...");
var applied = (await db.Database.GetAppliedMigrationsAsync()).ToList();
test.AddLog($"Applied migrations count: {applied.Count}");
foreach (var m in applied) test.AddLog($" - Applied: {m}");
var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
if (pending.Any())
{
test.AddLog($"Warning: {pending.Count} pending migrations detected. Applying them now...");
await db.Database.MigrateAsync();
test.AddLog("Pending migrations applied successfully.");
}
else
{
test.AddLog("No pending migrations. Schema is up to date.");
}
}
else
{
test.AddLog("In-memory provider: Ensuring database schema is created...");
await db.Database.EnsureCreatedAsync();
test.AddLog("In-memory schema verified.");
}
}, progressCallback));
// 3. CardNote CRUD Test
results.Add(await RunTestCaseAsync("DB-03", "CardNote Full CRUD Lifecycle", "CardNotes",
"Tests Insert, Query, Update, and Delete operations on the CardNotes entity.",
async test =>
{
var testCardName = $"__Test_Card_{Guid.NewGuid():N}";
var initialNote = "Initial test note description";
var updatedNote = "Updated test note content with extra details";
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Creating test CardNote for '{testCardName}'...");
db.CardNotes.Add(new CardNote { CardName = testCardName, Note = initialNote });
await db.SaveChangesAsync();
test.AddLog("CardNote inserted successfully.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Querying inserted CardNote from database...");
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
if (fetched == null || fetched.Note != initialNote)
throw new Exception($"Expected note '{initialNote}', but retrieved '{fetched?.Note}'");
test.AddLog("CardNote retrieved and validated.");
test.AddLog("3. Updating CardNote text...");
fetched.Note = updatedNote;
await db.SaveChangesAsync();
test.AddLog("CardNote updated in database.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Verifying persisted updated note...");
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
if (fetched == null || fetched.Note != updatedNote)
throw new Exception($"Expected updated note '{updatedNote}', but found '{fetched?.Note}'");
test.AddLog("Update persistence verified.");
test.AddLog("5. Deleting test CardNote...");
db.CardNotes.Remove(fetched);
await db.SaveChangesAsync();
test.AddLog("CardNote deleted.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("6. Verifying CardNote is deleted...");
var exists = await db.CardNotes.AnyAsync(n => n.CardName == testCardName);
if (exists)
throw new Exception("CardNote still exists after deletion.");
test.AddLog("CardNote deletion verified.");
}
}, progressCallback));
// 4. UserDeck CRUD & JSONB Serialization Test
results.Add(await RunTestCaseAsync("DB-04", "UserDeck CRUD & JSONB Mapping", "UserDecks",
"Validates JSON column mapping for Cards and Divers collections in PostgreSQL/EF Core.",
async test =>
{
var testDeckId = Guid.NewGuid();
var testDeckName = $"Test Deck {Guid.NewGuid():N}";
var initialCards = new List<string>
{
"Agent Alpha",
"Agent Alpha",
"Agent Alpha",
"Spell Omega",
"Spell Omega"
};
var initialDivers = new List<string> { "Agent Alpha" };
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Inserting UserDeck '{testDeckName}' with {initialCards.Count} cards and {initialDivers.Count} divers...");
var deck = new UserDeck
{
Id = testDeckId,
Name = testDeckName,
Notes = "Automated test deck notes",
Season = "Test Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = initialCards,
Divers = initialDivers
};
db.UserDecks.Add(deck);
await db.SaveChangesAsync();
test.AddLog("UserDeck saved with JSON structures.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Querying UserDeck and checking deserialized JSON lists...");
var fetched = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == testDeckId);
if (fetched == null) throw new Exception("UserDeck not found after insertion.");
if (fetched.Cards == null || fetched.Cards.Count != 5)
throw new Exception($"Expected 5 cards in deck, found {fetched.Cards?.Count ?? 0}");
if (fetched.Divers == null || fetched.Divers.Count != 1 || fetched.Divers[0] != "Agent Alpha")
throw new Exception($"Diver entry deserialization mismatch.");
test.AddLog("JSON properties successfully verified.");
test.AddLog("3. Modifying cards list in UserDeck...");
fetched.Cards.Add("Added Card Gamma");
fetched.Divers.Add("Added Diver");
fetched.Name = testDeckName + " (Updated)";
await db.SaveChangesAsync();
test.AddLog("Updated deck with additional cards and divers.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Validating updated cards and divers...");
var fetched = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == testDeckId);
if (fetched == null || fetched.Cards?.Count != 6 || fetched.Divers?.Count != 2)
throw new Exception("Updated JSON collections did not persist correctly.");
test.AddLog("Updated JSON verified.");
test.AddLog("5. Deleting UserDeck...");
db.UserDecks.Remove(fetched);
await db.SaveChangesAsync();
test.AddLog("UserDeck deleted.");
}
await using (var db = _configService.CreateDbContext())
{
var exists = await db.UserDecks.AnyAsync(d => d.Id == testDeckId);
if (exists) throw new Exception("UserDeck still exists after deletion.");
test.AddLog("UserDeck deletion confirmed.");
}
}, progressCallback));
// 5. PasskeyCredential Binary Handling Test
results.Add(await RunTestCaseAsync("DB-05", "PasskeyCredential Binary Storage", "Passkeys",
"Validates byte array (byte[]) handling for CredentialId, PublicKey, and UserHandle.",
async test =>
{
var credId = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xDD, 0x10, 0x20, 0x30, 0x40 };
var pubKey = new byte[] { 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86 };
var userHandle = new byte[] { 0xFE, 0xDC, 0xBA, 0x98 };
var aaGuid = Guid.NewGuid().ToString();
long signCount = 100;
int savedId = 0;
await using (var db = _configService.CreateDbContext())
{
test.AddLog("1. Creating PasskeyCredential with raw byte arrays...");
var passkey = new PasskeyCredential
{
CredentialId = credId,
PublicKey = pubKey,
UserHandle = userHandle,
AaGuid = aaGuid,
SignCount = signCount
};
db.PasskeyCredentials.Add(passkey);
await db.SaveChangesAsync();
savedId = passkey.Id;
test.AddLog($"Passkey stored with generated ID: {savedId}");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Querying Passkey and verifying byte-for-byte exact equality...");
var fetched = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == savedId);
if (fetched == null) throw new Exception("Passkey not found after insertion.");
if (!fetched.CredentialId.SequenceEqual(credId))
throw new Exception("CredentialId byte array does not match original bytes.");
if (!fetched.PublicKey.SequenceEqual(pubKey))
throw new Exception("PublicKey byte array does not match original bytes.");
if (!fetched.UserHandle.SequenceEqual(userHandle))
throw new Exception("UserHandle byte array does not match original bytes.");
if (fetched.SignCount != signCount)
throw new Exception($"SignCount mismatch: expected {signCount}, got {fetched.SignCount}");
test.AddLog("Binary fields verified successfully.");
test.AddLog("3. Incrementing SignCount...");
fetched.SignCount += 1;
await db.SaveChangesAsync();
test.AddLog($"Updated SignCount to {fetched.SignCount}.");
}
await using (var db = _configService.CreateDbContext())
{
var fetched = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == savedId);
if (fetched == null || fetched.SignCount != 101)
throw new Exception("SignCount increment was not persisted.");
test.AddLog("4. Deleting Passkey...");
db.PasskeyCredentials.Remove(fetched);
await db.SaveChangesAsync();
test.AddLog("Passkey deleted.");
}
await using (var db = _configService.CreateDbContext())
{
var exists = await db.PasskeyCredentials.AnyAsync(p => p.Id == savedId);
if (exists) throw new Exception("Passkey still exists after deletion.");
test.AddLog("Passkey deletion confirmed.");
}
}, progressCallback));
// 6. Primary Key & Duplicate Key Constraint Test
results.Add(await RunTestCaseAsync("DB-06", "Primary Key & Duplicate Constraints", "Integrity",
"Verifies that attempting to insert a duplicate primary key throws DbUpdateException.",
async test =>
{
var duplicateKey = $"__DupTest_{Guid.NewGuid():N}";
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Inserting initial record with key '{duplicateKey}'...");
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "First note" });
await db.SaveChangesAsync();
test.AddLog("Initial record created.");
}
try
{
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Attempting to insert duplicate record with same primary key...");
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "Second duplicate note" });
await db.SaveChangesAsync();
}
throw new Exception("Expected exception on duplicate primary key, but operation succeeded!");
}
catch (DbUpdateException dbEx)
{
test.AddLog($"Expected DbUpdateException caught: {dbEx.Message}");
}
catch (ArgumentException argEx)
{
test.AddLog($"Expected ArgumentException caught: {argEx.Message}");
}
catch (InvalidOperationException invEx)
{
test.AddLog($"Expected key constraint exception caught: {invEx.Message}");
}
finally
{
// Cleanup
await using var db = _configService.CreateDbContext();
var toClean = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == duplicateKey);
if (toClean != null)
{
db.CardNotes.Remove(toClean);
await db.SaveChangesAsync();
test.AddLog("Cleaned up test record.");
}
}
}, progressCallback));
// 7. Transaction Rollback Verification
results.Add(await RunTestCaseAsync("DB-07", "Transaction Rollback Support", "Transactions",
"Verifies that uncommitted transactions roll back changes cleanly on failure.",
async test =>
{
if (_configService.ProviderType != DatabaseProviderType.PostgreSQL)
{
test.AddLog("Transaction testing requires relational provider (PostgreSQL). Skipping for In-Memory.");
return;
}
var transCard = $"__Trans_{Guid.NewGuid():N}";
await using (var db = _configService.CreateDbContext())
{
test.AddLog("1. Beginning transaction...");
await using var transaction = await db.Database.BeginTransactionAsync();
test.AddLog($"2. Inserting card '{transCard}' inside transaction...");
db.CardNotes.Add(new CardNote { CardName = transCard, Note = "Transaction Note" });
await db.SaveChangesAsync();
test.AddLog("3. Rolling back transaction explicitly without committing...");
await transaction.RollbackAsync();
test.AddLog("Transaction rolled back.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Verifying that the rolled back card note was NOT persisted...");
var exists = await db.CardNotes.AnyAsync(n => n.CardName == transCard);
if (exists)
throw new Exception("Record was persisted despite transaction rollback!");
test.AddLog("Rollback verified: record does not exist in database.");
}
}, progressCallback));
return results;
}
private static async Task<DatabaseTestCaseResult> RunTestCaseAsync(
string id,
string name,
string category,
string description,
Func<DatabaseTestCaseResult, Task> testAction,
Action<string>? progressCallback)
{
var test = new DatabaseTestCaseResult
{
Id = id,
Name = name,
Category = category,
Description = description,
Status = TestStatus.Running
};
progressCallback?.Invoke($"Running {name}...");
var sw = Stopwatch.StartNew();
try
{
await testAction(test);
sw.Stop();
test.DurationMs = sw.ElapsedMilliseconds;
test.Status = TestStatus.Passed;
test.AddLog($"Test PASSED in {test.DurationMs}ms.");
progressCallback?.Invoke($"Passed {name} ({test.DurationMs}ms)");
}
catch (Exception ex)
{
sw.Stop();
test.DurationMs = sw.ElapsedMilliseconds;
test.Status = TestStatus.Failed;
test.ErrorMessage = ex.Message;
test.StackTrace = ex.StackTrace;
test.AddLog($"Test FAILED: {ex.Message}");
progressCallback?.Invoke($"Failed {name}: {ex.Message}");
}
return test;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+462
View File
@@ -0,0 +1,462 @@
/* Modern Chrono CCG Database Test Dashboard Styles */
:root {
--chrono-bg: #0d1117;
--chrono-card-bg: #161b22;
--chrono-card-border: #30363d;
--chrono-primary: #58a6ff;
--chrono-success: #3fb950;
--chrono-warning: #d29922;
--chrono-danger: #f85149;
--chrono-purple: #bc8cff;
--chrono-cyan: #39c5cf;
--chrono-text-primary: #c9d1d9;
--chrono-text-secondary: #8b949e;
--chrono-hover-bg: #21262d;
}
body {
background-color: var(--chrono-bg);
color: var(--chrono-text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
}
.page {
position: relative;
display: flex;
flex-direction: row;
min-height: 100vh;
}
.sidebar {
width: 260px;
background-color: #010409;
border-right: 1px solid var(--chrono-card-border);
flex-shrink: 0;
display: flex;
flex-direction: column;
}
main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.top-navbar {
background-color: #161b22;
border-bottom: 1px solid var(--chrono-card-border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.content-container {
padding: 1.5rem 2rem;
flex: 1;
}
/* Sidebar Styling */
.nav-brand {
padding: 1.25rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid var(--chrono-card-border);
color: #f0f6fc;
font-size: 1.15rem;
font-weight: 700;
text-decoration: none;
}
.nav-brand i {
color: var(--chrono-primary);
font-size: 1.4rem;
}
.nav-menu {
padding: 1rem 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-menu .nav-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.9rem;
color: var(--chrono-text-secondary);
border-radius: 6px;
font-weight: 500;
font-size: 0.92rem;
text-decoration: none;
transition: all 0.15s ease-in-out;
}
.nav-menu .nav-link:hover {
color: #f0f6fc;
background-color: var(--chrono-hover-bg);
}
.nav-menu .nav-link.active {
color: #f0f6fc;
background-color: #1f6feb;
font-weight: 600;
}
.nav-menu .nav-link i {
font-size: 1.1rem;
width: 20px;
text-align: center;
}
.sidebar-footer {
margin-top: auto;
padding: 1rem;
border-top: 1px solid var(--chrono-card-border);
font-size: 0.8rem;
color: var(--chrono-text-secondary);
}
/* Status Badges */
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
border-radius: 9999px;
font-size: 0.82rem;
font-weight: 600;
}
.status-connected {
background-color: rgba(63, 185, 80, 0.15);
color: #3fb950;
border: 1px solid rgba(63, 185, 80, 0.4);
}
.status-disconnected {
background-color: rgba(248, 81, 73, 0.15);
color: #f85149;
border: 1px solid rgba(248, 81, 73, 0.4);
}
.status-inmemory {
background-color: rgba(188, 140, 255, 0.15);
color: #bc8cff;
border: 1px solid rgba(188, 140, 255, 0.4);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.green {
background-color: #3fb950;
box-shadow: 0 0 8px #3fb950;
}
.status-dot.red {
background-color: #f85149;
box-shadow: 0 0 8px #f85149;
}
.status-dot.purple {
background-color: #bc8cff;
box-shadow: 0 0 8px #bc8cff;
}
/* Chrono Cards & Containers */
.chrono-card {
background-color: var(--chrono-card-bg);
border: 1px solid var(--chrono-card-border);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.chrono-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 0.9rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--chrono-card-border);
}
.chrono-card-title {
font-size: 1.1rem;
font-weight: 600;
color: #f0f6fc;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Metric Cards */
.metric-card {
background-color: var(--chrono-card-bg);
border: 1px solid var(--chrono-card-border);
border-radius: 8px;
padding: 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
transition: transform 0.15s ease, border-color 0.15s ease;
}
.metric-card:hover {
transform: translateY(-2px);
border-color: var(--chrono-primary);
}
.metric-icon-box {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.metric-icon-blue {
background-color: rgba(88, 166, 255, 0.15);
color: var(--chrono-primary);
}
.metric-icon-green {
background-color: rgba(63, 185, 80, 0.15);
color: var(--chrono-success);
}
.metric-icon-purple {
background-color: rgba(188, 140, 255, 0.15);
color: var(--chrono-purple);
}
.metric-icon-yellow {
background-color: rgba(210, 153, 34, 0.15);
color: var(--chrono-warning);
}
.metric-value {
font-size: 1.6rem;
font-weight: 700;
color: #f0f6fc;
line-height: 1.1;
}
.metric-label {
font-size: 0.82rem;
color: var(--chrono-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.2rem;
}
/* Custom Table */
.chrono-table {
width: 100%;
color: var(--chrono-text-primary);
border-collapse: separate;
border-spacing: 0;
}
.chrono-table th {
background-color: #0d1117;
color: var(--chrono-text-secondary);
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--chrono-card-border);
}
.chrono-table td {
padding: 0.85rem 1rem;
border-bottom: 1px solid #21262d;
font-size: 0.9rem;
vertical-align: middle;
}
.chrono-table tbody tr:hover {
background-color: var(--chrono-hover-bg);
}
/* Custom Modals */
.modal-backdrop-custom {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
padding: 1rem;
backdrop-filter: blur(2px);
}
.modal-dialog-custom {
background-color: #161b22;
border: 1px solid var(--chrono-card-border);
border-radius: 10px;
width: 100%;
max-width: 650px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
max-height: 90vh;
}
.modal-header-custom {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--chrono-card-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title-custom {
font-size: 1.15rem;
font-weight: 600;
color: #f0f6fc;
margin: 0;
}
.modal-body-custom {
padding: 1.25rem;
overflow-y: auto;
}
.modal-footer-custom {
padding: 0.9rem 1.25rem;
border-top: 1px solid var(--chrono-card-border);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Form Controls */
.form-control-chrono, .form-select-chrono {
background-color: #0d1117;
border: 1px solid #30363d;
color: #f0f6fc;
border-radius: 6px;
padding: 0.55rem 0.85rem;
font-size: 0.9rem;
width: 100%;
}
.form-control-chrono:focus, .form-select-chrono:focus {
background-color: #0d1117;
border-color: var(--chrono-primary);
color: #f0f6fc;
outline: none;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.25);
}
/* Log Console */
.log-console {
background-color: #090d13;
border: 1px solid var(--chrono-card-border);
border-radius: 6px;
padding: 1rem;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.85rem;
color: #7ee787;
max-height: 350px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.code-block {
background-color: #090d13;
border: 1px solid #30363d;
border-radius: 6px;
padding: 0.75rem 1rem;
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.84rem;
color: #a5d6ff;
overflow-x: auto;
}
/* Buttons */
.btn-chrono-primary {
background-color: #238636;
border: 1px solid rgba(240, 246, 252, 0.1);
color: #ffffff;
font-weight: 500;
padding: 0.45rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
transition: background-color 0.15s ease;
}
.btn-chrono-primary:hover {
background-color: #2ea043;
color: #ffffff;
}
.btn-chrono-secondary {
background-color: #21262d;
border: 1px solid #363b42;
color: #c9d1d9;
font-weight: 500;
padding: 0.45rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
transition: background-color 0.15s ease, color 0.15s ease;
}
.btn-chrono-secondary:hover {
background-color: #30363d;
color: #f0f6fc;
}
.btn-chrono-danger {
background-color: #b62324;
border: 1px solid rgba(240, 246, 252, 0.1);
color: #ffffff;
font-weight: 500;
padding: 0.45rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
transition: background-color 0.15s ease;
}
.btn-chrono-danger:hover {
background-color: #da3633;
color: #ffffff;
}
.btn-chrono-outline-primary {
background-color: transparent;
border: 1px solid var(--chrono-primary);
color: var(--chrono-primary);
font-weight: 500;
padding: 0.45rem 0.9rem;
border-radius: 6px;
font-size: 0.88rem;
}
.btn-chrono-outline-primary:hover {
background-color: rgba(88, 166, 255, 0.15);
color: #ffffff;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long