ui/dashboard #2
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user