Test
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user