ui/dashboard #2

Open
evgeny wants to merge 325 commits from ui/dashboard into main
3 changed files with 208 additions and 10 deletions
Showing only changes of commit 55bd5f7075 - Show all commits

View File

@@ -1,11 +1,12 @@
import { consume } from "@lit/context";
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { gatewayContext, type GatewayState } from "../context/gateway-context.js";
import { icon } from "./icons.js";
/**
* Tiny component that lives inside <gateway-provider> and
* renders the connection health pill.
* Topbar connection pill. When connected, clicking opens a disconnect
* confirmation popover. When offline, shows status + click to retry.
*/
@customElement("connection-status")
export class ConnectionStatus extends LitElement {
@@ -16,13 +17,90 @@ export class ConnectionStatus extends LitElement {
@consume({ context: gatewayContext, subscribe: true })
gateway!: GatewayState;
@state() private showMenu = false;
private handleClick = () => {
const g = this.gateway;
if (!g) {
return;
}
if (g.connected) {
this.showMenu = !this.showMenu;
} else if (g.retryStalled) {
g.retryNow();
}
// While connecting, clicking does nothing (avoid spam)
};
private handleDisconnect = () => {
this.showMenu = false;
// Reconnect with empty credentials effectively disconnects
// and puts the gateway into a disconnected/stalled state
this.gateway?.reconnect({ gatewayUrl: "", sharedSecret: "" });
};
private handleClickOutside = (e: MouseEvent) => {
const path = e.composedPath();
if (!path.includes(this)) {
this.showMenu = false;
}
};
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener("click", this.handleClickOutside, true);
}
override disconnectedCallback(): void {
document.removeEventListener("click", this.handleClickOutside, true);
super.disconnectedCallback();
}
override render() {
const connected = this.gateway?.connected ?? false;
const g = this.gateway;
const connected = g?.connected ?? false;
const connecting = g?.connecting ?? false;
const stalled = g?.retryStalled ?? false;
const pillClass = connected
? "pill pill--connected"
: stalled
? "pill pill--danger"
: "pill pill--connecting";
const label = connected ? "Connected" : stalled ? "Offline" : "Connecting…";
const hint = connected
? "Click to disconnect"
: stalled
? "Click to retry"
: "Establishing connection";
return html`
<div class="pill ${connected ? "" : "pill--danger"}">
<span class="status-dot ${connected ? "status-dot--ok" : ""}"></span>
<span>Health</span>
<span class="mono">${connected ? "OK" : "Offline"}</span>
<div class="connection-status-wrapper">
<button
class="${pillClass} connection-status-btn"
@click=${this.handleClick}
title=${hint}
aria-expanded=${this.showMenu}
?disabled=${connecting && !stalled}
>
<span class="status-dot ${connected ? "status-dot--ok" : stalled ? "" : "status-dot--pulse"}"></span>
<span>${label}</span>
</button>
${
this.showMenu
? html`
<div class="connection-menu">
<button class="connection-menu__item connection-menu__item--danger" @click=${this.handleDisconnect}>
${icon("link", { className: "icon-xs" })}
Disconnect
</button>
</div>
`
: nothing
}
</div>
`;
}

View File

@@ -617,6 +617,126 @@ sidebar-nav {
background: var(--success);
}
/* ─── Connection Status ─── */
.connection-status-wrapper {
position: relative;
}
.connection-status-btn {
/* Match theme-toggle capsule sizing */
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 6px 10px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
border: 1px solid var(--lg-border-color);
background: var(--lg-bg-primary);
color: var(--text);
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background var(--lg-duration-fast) var(--lg-easing-spring),
border-color var(--lg-duration-fast) var(--lg-easing-spring),
transform var(--lg-duration-fast) var(--lg-easing-spring);
}
@supports (backdrop-filter: blur(1px)) {
.connection-status-btn {
backdrop-filter: blur(var(--lg-blur-sm));
-webkit-backdrop-filter: blur(var(--lg-blur-sm));
}
}
.connection-status-btn:hover {
background: var(--lg-bg-interactive);
}
.connection-status-btn:active {
transform: scale(0.95);
}
.connection-status-btn:disabled {
cursor: default;
opacity: 0.7;
}
.connection-status-btn .status-dot {
width: 6px;
height: 6px;
}
.pill--connected {
color: var(--success);
}
.pill--connecting {
color: var(--muted);
}
.status-dot--pulse {
animation: dot-pulse 1.4s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.connection-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 200;
min-width: 160px;
padding: 4px;
border-radius: var(--lg-radius-md);
border: 1px solid var(--lg-border-color);
background: var(--lg-bg-elevated);
box-shadow: var(--lg-shadow-high);
animation: glass-enter var(--lg-duration-fast) var(--lg-easing-spring) both;
}
@supports (backdrop-filter: blur(1px)) {
.connection-menu {
backdrop-filter: blur(var(--lg-blur-lg)) saturate(var(--lg-saturate));
-webkit-backdrop-filter: blur(var(--lg-blur-lg)) saturate(var(--lg-saturate));
}
}
.connection-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: 0;
border-radius: var(--lg-radius-sm);
background: transparent;
color: var(--text);
font-size: 0.85rem;
cursor: pointer;
transition:
background var(--lg-duration-fast) var(--lg-easing-spring),
color var(--lg-duration-fast) var(--lg-easing-spring);
}
.connection-menu__item:hover {
background: var(--lg-bg-interactive);
}
.connection-menu__item--danger {
color: var(--accent);
}
.connection-menu__item--danger:hover {
background: var(--accent-soft);
}
/* ─── Theme Toggle ─── */
.theme-toggle {

View File

@@ -138,7 +138,7 @@ export class OverviewView extends LitElement {
<div class="overview-grid">
${this.renderHealthSection(connected, snapshot)}
${this.renderStatsSection()}
${this.renderConnectionSection(g)}
${connected ? nothing : this.renderConnectionSection(g)}
${this.renderDebugSections(g)}
</div>
`;