feat: add comprehensive dashboard with live metrics, traffic leaders, and audit stream visualization
Replace placeholder dashboard with full operational overview including user/device/gateway summary hero, four-metric stats grid, top devices by traffic, recent audit events stream, service exposure posture, and gateway enforcement note. Add formatBytes and formatRelativeDate utility functions for human-readable data display. Fetch users, devices, gateways, policies, services, and audit data with
This commit is contained in:
@@ -17,17 +17,17 @@ export function Page({ title, subtitle, actions, children }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="page-title-block">
|
<div className="page-heading">
|
||||||
<div className="page-title-icon" aria-hidden="true">
|
<div className="page-title-block">
|
||||||
<Icon size={22} strokeWidth={2} />
|
<div className="page-title-icon" aria-hidden="true">
|
||||||
|
<Icon size={22} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div className="page-title-copy">
|
||||||
|
<p className="eyebrow">{meta.eyebrow}</p>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-title-copy">
|
<p className="page-subtitle">{subtitle}</p>
|
||||||
<p className="eyebrow">{meta.eyebrow}</p>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="page-subtitle-block">
|
|
||||||
<p>{subtitle}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,291 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQueries } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowUpRight,
|
||||||
|
FileClock,
|
||||||
|
Network,
|
||||||
|
Shield,
|
||||||
|
Users
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "../../api/client";
|
||||||
import { Card } from "../../components/Card";
|
import { Card } from "../../components/Card";
|
||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
if (!bytes) {
|
||||||
|
return "0 B";
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value >= 100 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeDate(value: string) {
|
||||||
|
const target = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(target)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = Date.now() - target;
|
||||||
|
const diffMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||||
|
|
||||||
|
if (diffMinutes < 1) {
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
if (diffMinutes < 60) {
|
||||||
|
return `${diffMinutes}m ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60);
|
||||||
|
if (diffHours < 24) {
|
||||||
|
return `${diffHours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
|
const results = useQueries({
|
||||||
|
queries: [
|
||||||
|
{ queryKey: ["users"], queryFn: api.users },
|
||||||
|
{ queryKey: ["devices"], queryFn: api.devices },
|
||||||
|
{ queryKey: ["gateways"], queryFn: api.gateways },
|
||||||
|
{ queryKey: ["policies"], queryFn: api.policies },
|
||||||
|
{ queryKey: ["services"], queryFn: api.services },
|
||||||
|
{ queryKey: ["audit"], queryFn: api.audit }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const [usersQuery, devicesQuery, gatewaysQuery, policiesQuery, servicesQuery, auditQuery] = results;
|
||||||
|
const isLoading = results.some((query) => query.isLoading);
|
||||||
|
const isError = results.some((query) => query.isError);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const users = usersQuery.data ?? [];
|
||||||
|
const devices = devicesQuery.data ?? [];
|
||||||
|
const gateways = gatewaysQuery.data ?? [];
|
||||||
|
const policies = policiesQuery.data ?? [];
|
||||||
|
const services = servicesQuery.data ?? [];
|
||||||
|
const auditEvents = auditQuery.data ?? [];
|
||||||
|
|
||||||
|
const activeUsers = users.filter((user) => user.is_active);
|
||||||
|
const adminUsers = users.filter((user) => user.role === "admin" && user.is_active);
|
||||||
|
const activeDevices = devices.filter((device) => device.status === "active");
|
||||||
|
const revokedDevices = devices.filter((device) => device.status === "revoked");
|
||||||
|
const activeGateways = gateways.filter((gateway) => gateway.is_active);
|
||||||
|
const activePolicies = policies.filter((policy) => policy.is_active);
|
||||||
|
const fullTunnelPolicies = activePolicies.filter((policy) => policy.full_tunnel);
|
||||||
|
const servicePolicies = activePolicies.filter((policy) => (policy.services?.length ?? 0) > 0);
|
||||||
|
const totalRX = devices.reduce((sum, device) => sum + (device.rx_bytes ?? 0), 0);
|
||||||
|
const totalTX = devices.reduce((sum, device) => sum + (device.tx_bytes ?? 0), 0);
|
||||||
|
|
||||||
|
const topDevices = [...devices]
|
||||||
|
.sort((left, right) => (right.rx_bytes + right.tx_bytes) - (left.rx_bytes + left.tx_bytes))
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const recentAudit = [...auditEvents]
|
||||||
|
.sort((left, right) => new Date(right.created_at).getTime() - new Date(left.created_at).getTime())
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeUserCount: activeUsers.length,
|
||||||
|
adminUserCount: adminUsers.length,
|
||||||
|
activeDeviceCount: activeDevices.length,
|
||||||
|
revokedDeviceCount: revokedDevices.length,
|
||||||
|
activeGatewayCount: activeGateways.length,
|
||||||
|
gatewayHealth: activeGateways.length > 0 ? "Healthy" : "No active gateway",
|
||||||
|
activePolicyCount: activePolicies.length,
|
||||||
|
fullTunnelPolicyCount: fullTunnelPolicies.length,
|
||||||
|
servicePolicyCount: servicePolicies.length,
|
||||||
|
serviceCount: services.filter((service) => service.is_active).length,
|
||||||
|
totalRX,
|
||||||
|
totalTX,
|
||||||
|
topDevices,
|
||||||
|
recentAudit
|
||||||
|
};
|
||||||
|
}, [auditQuery.data, devicesQuery.data, gatewaysQuery.data, policiesQuery.data, servicesQuery.data, usersQuery.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="Dashboard" subtitle="Operational visibility across users, devices, gateways, and policy activity.">
|
<Page
|
||||||
<div className="grid three">
|
title="Dashboard"
|
||||||
|
subtitle="Operational visibility for users, gateways, device activity, and policy posture across your NexaVPN estate."
|
||||||
|
actions={<span className="pill">Live admin overview</span>}
|
||||||
|
>
|
||||||
|
{isError ? <p className="notice">Unable to load one or more dashboard data sources.</p> : null}
|
||||||
|
|
||||||
|
<div className="dashboard-hero card">
|
||||||
|
<div className="dashboard-hero-copy">
|
||||||
|
<p className="eyebrow">VPN Operations</p>
|
||||||
|
<h4>Private access posture at a glance</h4>
|
||||||
|
<p>
|
||||||
|
Track active identities, connected endpoints, gateway health, policy exposure, and the busiest devices from one place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-hero-metrics">
|
||||||
|
<div className="dashboard-mini-stat">
|
||||||
|
<span className="dashboard-mini-label">Users</span>
|
||||||
|
<strong>{summary.activeUserCount}</strong>
|
||||||
|
<span>{summary.adminUserCount} admins</span>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-mini-stat">
|
||||||
|
<span className="dashboard-mini-label">Devices</span>
|
||||||
|
<strong>{summary.activeDeviceCount}</strong>
|
||||||
|
<span>{summary.revokedDeviceCount} revoked</span>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-mini-stat">
|
||||||
|
<span className="dashboard-mini-label">Gateways</span>
|
||||||
|
<strong>{summary.activeGatewayCount}</strong>
|
||||||
|
<span>{summary.gatewayHealth}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-stats-grid">
|
||||||
<Card>
|
<Card>
|
||||||
<p className="metric-label">Active users</p>
|
<div className="dashboard-stat-head">
|
||||||
<strong className="metric-value">248</strong>
|
<div className="dashboard-stat-icon"><Users size={18} strokeWidth={2} /></div>
|
||||||
<span>12 enrolled this week</span>
|
<p className="metric-label">Identity coverage</p>
|
||||||
|
</div>
|
||||||
|
<strong className="metric-value">{summary.activeUserCount}</strong>
|
||||||
|
<p className="dashboard-stat-support">{summary.adminUserCount} administrators currently active in the control plane.</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<p className="metric-label">Connected devices</p>
|
<div className="dashboard-stat-head">
|
||||||
<strong className="metric-value">181</strong>
|
<div className="dashboard-stat-icon"><Activity size={18} strokeWidth={2} /></div>
|
||||||
<span>7 pending rotation</span>
|
<p className="metric-label">Endpoint activity</p>
|
||||||
|
</div>
|
||||||
|
<strong className="metric-value">{summary.activeDeviceCount}</strong>
|
||||||
|
<p className="dashboard-stat-support">{formatBytes(summary.totalRX + summary.totalTX)} total observed tunnel traffic.</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<p className="metric-label">Gateway health</p>
|
<div className="dashboard-stat-head">
|
||||||
<strong className="metric-value">Healthy</strong>
|
<div className="dashboard-stat-icon"><Network size={18} strokeWidth={2} /></div>
|
||||||
<span>Last sync 36s ago</span>
|
<p className="metric-label">Gateway posture</p>
|
||||||
|
</div>
|
||||||
|
<strong className="metric-value">{summary.gatewayHealth}</strong>
|
||||||
|
<p className="dashboard-stat-support">{summary.activeGatewayCount} active gateway nodes enforcing policy.</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="dashboard-stat-head">
|
||||||
|
<div className="dashboard-stat-icon"><Shield size={18} strokeWidth={2} /></div>
|
||||||
|
<p className="metric-label">Policy model</p>
|
||||||
|
</div>
|
||||||
|
<strong className="metric-value">{summary.activePolicyCount}</strong>
|
||||||
|
<p className="dashboard-stat-support">
|
||||||
|
{summary.servicePolicyCount} service-based and {summary.fullTunnelPolicyCount} full-tunnel policies active.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid two">
|
|
||||||
|
<div className="dashboard-main-grid">
|
||||||
<Card>
|
<Card>
|
||||||
<h4>Recent enrollments</h4>
|
<div className="dashboard-section-head">
|
||||||
<p>New devices are issued profiles automatically after successful sign-in.</p>
|
<div>
|
||||||
|
<p className="eyebrow">Traffic leaders</p>
|
||||||
|
<h4>Top devices by observed transfer</h4>
|
||||||
|
</div>
|
||||||
|
<span className="pill">Live device stats</span>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-list">
|
||||||
|
{isLoading ? <p className="dashboard-empty">Loading device telemetry...</p> : null}
|
||||||
|
{!isLoading && summary.topDevices.length === 0 ? <p className="dashboard-empty">No device activity has been recorded yet.</p> : null}
|
||||||
|
{summary.topDevices.map((device) => (
|
||||||
|
<div className="dashboard-list-row" key={device.id}>
|
||||||
|
<div className="dashboard-list-copy">
|
||||||
|
<strong>{device.name}</strong>
|
||||||
|
<span>{device.assigned_ip ?? "No VPN IP"} · {device.platform}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-list-metric">
|
||||||
|
<span>{formatBytes((device.rx_bytes ?? 0) + (device.tx_bytes ?? 0))}</span>
|
||||||
|
<small>{device.status}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h4>Policy posture</h4>
|
<div className="dashboard-section-head">
|
||||||
<p>Gateway enforcement is generated from effective allow-lists backed by nftables.</p>
|
<div>
|
||||||
|
<p className="eyebrow">Recent activity</p>
|
||||||
|
<h4>Audit stream</h4>
|
||||||
|
</div>
|
||||||
|
<span className="pill">{summary.recentAudit.length} events</span>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-list">
|
||||||
|
{isLoading ? <p className="dashboard-empty">Loading audit events...</p> : null}
|
||||||
|
{!isLoading && summary.recentAudit.length === 0 ? <p className="dashboard-empty">No audit events available.</p> : null}
|
||||||
|
{summary.recentAudit.map((event) => (
|
||||||
|
<div className="dashboard-list-row" key={event.id}>
|
||||||
|
<div className="dashboard-list-copy">
|
||||||
|
<strong>{event.event_type}</strong>
|
||||||
|
<span>{event.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-list-metric">
|
||||||
|
<span>{formatRelativeDate(event.created_at)}</span>
|
||||||
|
<small>{event.status}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-bottom-grid">
|
||||||
|
<Card>
|
||||||
|
<div className="dashboard-section-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Access model</p>
|
||||||
|
<h4>Service exposure</h4>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-inline-icon">
|
||||||
|
<ArrowUpRight size={16} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-posture-grid">
|
||||||
|
<div className="dashboard-posture-item">
|
||||||
|
<span className="dashboard-posture-label">Published services</span>
|
||||||
|
<strong>{summary.serviceCount}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-posture-item">
|
||||||
|
<span className="dashboard-posture-label">Service-based policies</span>
|
||||||
|
<strong>{summary.servicePolicyCount}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-posture-item">
|
||||||
|
<span className="dashboard-posture-label">Full tunnel policies</span>
|
||||||
|
<strong>{summary.fullTunnelPolicyCount}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="dashboard-section-head">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Operations note</p>
|
||||||
|
<h4>Gateway enforcement</h4>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-inline-icon">
|
||||||
|
<FileClock size={16} strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="dashboard-note">
|
||||||
|
Effective WireGuard peer state and nftables enforcement are generated from active policies, selected device profiles,
|
||||||
|
and service-level access rules. Use this dashboard to spot drift before it becomes an outage.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -273,7 +273,13 @@ button {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 16px;
|
gap: 22px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title-block {
|
.page-title-block {
|
||||||
@@ -287,14 +293,11 @@ button {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle-block {
|
.page-subtitle {
|
||||||
grid-column: 1 / 2;
|
|
||||||
padding-left: 68px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-subtitle-block p {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding-left: 68px;
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title-icon {
|
.page-title-icon {
|
||||||
@@ -433,6 +436,153 @@ button {
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr);
|
||||||
|
gap: 22px;
|
||||||
|
padding: 26px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(68, 230, 231, 0.1), transparent 32%),
|
||||||
|
linear-gradient(180deg, rgba(18, 31, 67, 0.92), rgba(10, 18, 40, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-hero-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-hero-copy h4,
|
||||||
|
.dashboard-section-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-hero-copy p:last-child,
|
||||||
|
.dashboard-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-hero-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-mini-stat {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(8, 14, 30, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-mini-stat strong {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-mini-stat span:last-child,
|
||||||
|
.dashboard-mini-label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-head,
|
||||||
|
.dashboard-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-icon,
|
||||||
|
.dashboard-inline-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid rgba(68, 230, 231, 0.14);
|
||||||
|
background: rgba(8, 14, 30, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-support {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-bottom-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(8, 14, 30, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-list-copy,
|
||||||
|
.dashboard-list-metric,
|
||||||
|
.dashboard-posture-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-list-copy strong,
|
||||||
|
.dashboard-list-metric span,
|
||||||
|
.dashboard-posture-item strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-list-copy span,
|
||||||
|
.dashboard-list-metric small,
|
||||||
|
.dashboard-posture-label,
|
||||||
|
.dashboard-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-posture-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-posture-item {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(8, 14, 30, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #ffcf9b;
|
color: #ffcf9b;
|
||||||
@@ -556,12 +706,25 @@ button {
|
|||||||
.auth-brand,
|
.auth-brand,
|
||||||
.brand-block,
|
.brand-block,
|
||||||
.topbar-brand,
|
.topbar-brand,
|
||||||
.topbar-actions,
|
.topbar-actions {
|
||||||
.page-header {
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.dashboard-hero,
|
||||||
|
.dashboard-main-grid,
|
||||||
|
.dashboard-bottom-grid,
|
||||||
|
.dashboard-stats-grid,
|
||||||
|
.dashboard-posture-grid,
|
||||||
|
.dashboard-hero-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-logo-full {
|
.brand-logo-full {
|
||||||
width: min(100%, 200px);
|
width: min(100%, 200px);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user