[codex] Switch dashboard sections to view routing
Switch dashboard navigation from long section scrolling to hash-backed views, preserving Usage as the default operational view and keeping mobile drawer navigation visible.
This commit is contained in:
@@ -27,9 +27,28 @@ interface DashboardState {
|
|||||||
type UsageRow = DashboardOverview['usage']['rows'][number];
|
type UsageRow = DashboardOverview['usage']['rows'][number];
|
||||||
type RiskLevel = 'ok' | 'warn' | 'critical';
|
type RiskLevel = 'ok' | 'warn' | 'critical';
|
||||||
type UsageGroup = 'primary' | 'codex';
|
type UsageGroup = 'primary' | 'codex';
|
||||||
|
type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled';
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 15_000;
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
|
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
|
||||||
|
const DEFAULT_VIEW: DashboardView = 'usage';
|
||||||
|
|
||||||
|
function isDashboardView(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): value is DashboardView {
|
||||||
|
return (
|
||||||
|
value === 'usage' ||
|
||||||
|
value === 'health' ||
|
||||||
|
value === 'rooms' ||
|
||||||
|
value === 'scheduled'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readViewFromHash(): DashboardView {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_VIEW;
|
||||||
|
const raw = window.location.hash.replace(/^#\/?/, '');
|
||||||
|
return isDashboardView(raw) ? raw : DEFAULT_VIEW;
|
||||||
|
}
|
||||||
|
|
||||||
function readInitialLocale(): Locale {
|
function readInitialLocale(): Locale {
|
||||||
const stored =
|
const stored =
|
||||||
@@ -110,10 +129,10 @@ function queueLabel(
|
|||||||
|
|
||||||
function navItems(t: Messages) {
|
function navItems(t: Messages) {
|
||||||
return [
|
return [
|
||||||
{ href: '#overview', label: t.nav.health },
|
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
|
||||||
{ href: '#usage', label: t.nav.usage },
|
{ href: '#/health', label: t.nav.health, view: 'health' as const },
|
||||||
{ href: '#rooms', label: t.nav.rooms },
|
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
|
||||||
{ href: '#work', label: t.nav.scheduled },
|
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,15 +189,19 @@ function LoadingSkeleton({ t }: { t: Messages }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SideRail({
|
function SideRail({
|
||||||
|
activeView,
|
||||||
lastRefreshed,
|
lastRefreshed,
|
||||||
locale,
|
locale,
|
||||||
|
onNavigate,
|
||||||
onLocaleChange,
|
onLocaleChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
|
activeView: DashboardView;
|
||||||
lastRefreshed: string | null;
|
lastRefreshed: string | null;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
onNavigate: (view: DashboardView) => void;
|
||||||
onLocaleChange: (locale: Locale) => void;
|
onLocaleChange: (locale: Locale) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
@@ -192,7 +215,13 @@ function SideRail({
|
|||||||
</div>
|
</div>
|
||||||
<nav aria-label={t.nav.drawerNavAria}>
|
<nav aria-label={t.nav.drawerNavAria}>
|
||||||
{navItems(t).map((item) => (
|
{navItems(t).map((item) => (
|
||||||
<a href={item.href} key={item.href}>
|
<a
|
||||||
|
aria-current={activeView === item.view ? 'page' : undefined}
|
||||||
|
className={activeView === item.view ? 'is-active' : undefined}
|
||||||
|
href={item.href}
|
||||||
|
key={item.href}
|
||||||
|
onClick={() => onNavigate(item.view)}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -216,26 +245,33 @@ function SideRail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SectionNav({
|
function SectionNav({
|
||||||
|
activeView,
|
||||||
drawerOpen,
|
drawerOpen,
|
||||||
lastRefreshed,
|
lastRefreshed,
|
||||||
locale,
|
locale,
|
||||||
onCloseDrawer,
|
onCloseDrawer,
|
||||||
onLocaleChange,
|
onLocaleChange,
|
||||||
|
onNavigate,
|
||||||
onOpenDrawer,
|
onOpenDrawer,
|
||||||
refreshing,
|
refreshing,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
|
activeView: DashboardView;
|
||||||
drawerOpen: boolean;
|
drawerOpen: boolean;
|
||||||
lastRefreshed: string | null;
|
lastRefreshed: string | null;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
onCloseDrawer: () => void;
|
onCloseDrawer: () => void;
|
||||||
onLocaleChange: (locale: Locale) => void;
|
onLocaleChange: (locale: Locale) => void;
|
||||||
|
onNavigate: (view: DashboardView) => void;
|
||||||
onOpenDrawer: () => void;
|
onOpenDrawer: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
t: Messages;
|
t: Messages;
|
||||||
}) {
|
}) {
|
||||||
|
const activeLabel =
|
||||||
|
navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.usage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="section-nav" aria-label={t.nav.aria}>
|
<nav className="section-nav" aria-label={t.nav.aria}>
|
||||||
@@ -251,7 +287,7 @@ function SectionNav({
|
|||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</button>
|
</button>
|
||||||
<strong className="topbar-label">{t.nav.usage}</strong>
|
<strong className="topbar-label">{activeLabel}</strong>
|
||||||
<button
|
<button
|
||||||
aria-busy={refreshing}
|
aria-busy={refreshing}
|
||||||
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
|
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
|
||||||
@@ -297,7 +333,16 @@ function SectionNav({
|
|||||||
</div>
|
</div>
|
||||||
<nav aria-label={t.nav.drawerNavAria}>
|
<nav aria-label={t.nav.drawerNavAria}>
|
||||||
{navItems(t).map((item) => (
|
{navItems(t).map((item) => (
|
||||||
<a href={item.href} key={item.href} onClick={onCloseDrawer}>
|
<a
|
||||||
|
aria-current={activeView === item.view ? 'page' : undefined}
|
||||||
|
className={activeView === item.view ? 'is-active' : undefined}
|
||||||
|
href={item.href}
|
||||||
|
key={item.href}
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate(item.view);
|
||||||
|
onCloseDrawer();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -761,6 +806,7 @@ function App() {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [lastRefreshed, setLastRefreshed] = useState<string | null>(null);
|
const [lastRefreshed, setLastRefreshed] = useState<string | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [activeView, setActiveView] = useState<DashboardView>(readViewFromHash);
|
||||||
const [locale, setLocale] = useState<Locale>(readInitialLocale);
|
const [locale, setLocale] = useState<Locale>(readInitialLocale);
|
||||||
const t = messages[locale];
|
const t = messages[locale];
|
||||||
|
|
||||||
@@ -769,6 +815,13 @@ function App() {
|
|||||||
window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
|
window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateToView(view: DashboardView) {
|
||||||
|
setActiveView(view);
|
||||||
|
if (window.location.hash !== `#/${view}`) {
|
||||||
|
window.location.hash = `/${view}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh(showSpinner = false) {
|
async function refresh(showSpinner = false) {
|
||||||
if (showSpinner) setRefreshing(true);
|
if (showSpinner) setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
@@ -788,6 +841,16 @@ function App() {
|
|||||||
document.documentElement.lang = localeTags[locale];
|
document.documentElement.lang = localeTags[locale];
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleHashChange() {
|
||||||
|
setActiveView(readViewFromHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHashChange();
|
||||||
|
window.addEventListener('hashchange', handleHashChange);
|
||||||
|
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
@@ -814,8 +877,10 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<SideRail
|
<SideRail
|
||||||
|
activeView={activeView}
|
||||||
lastRefreshed={lastRefreshed}
|
lastRefreshed={lastRefreshed}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
onNavigate={navigateToView}
|
||||||
onLocaleChange={setDashboardLocale}
|
onLocaleChange={setDashboardLocale}
|
||||||
onRefresh={() => void refresh(true)}
|
onRefresh={() => void refresh(true)}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -823,11 +888,13 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<main className="dashboard-content">
|
<main className="dashboard-content">
|
||||||
<SectionNav
|
<SectionNav
|
||||||
|
activeView={activeView}
|
||||||
drawerOpen={drawerOpen}
|
drawerOpen={drawerOpen}
|
||||||
lastRefreshed={lastRefreshed}
|
lastRefreshed={lastRefreshed}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onCloseDrawer={() => setDrawerOpen(false)}
|
onCloseDrawer={() => setDrawerOpen(false)}
|
||||||
onLocaleChange={setDashboardLocale}
|
onLocaleChange={setDashboardLocale}
|
||||||
|
onNavigate={navigateToView}
|
||||||
onOpenDrawer={() => setDrawerOpen(true)}
|
onOpenDrawer={() => setDrawerOpen(true)}
|
||||||
onRefresh={() => void refresh(true)}
|
onRefresh={() => void refresh(true)}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -846,41 +913,50 @@ function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{data ? (
|
{data ? (
|
||||||
<>
|
<div className={`view-stack view-${activeView}`}>
|
||||||
<section className="panel usage-first" id="usage">
|
{activeView === 'usage' ? (
|
||||||
<div className="panel-title">
|
<>
|
||||||
<h2>{t.panels.usage}</h2>
|
<section className="panel usage-first" id="usage">
|
||||||
<span>{t.panels.usageWindow}</span>
|
<div className="panel-title">
|
||||||
</div>
|
<h2>{t.panels.usage}</h2>
|
||||||
<UsagePanel locale={locale} overview={data.overview} t={t} />
|
<span>{t.panels.usageWindow}</span>
|
||||||
</section>
|
</div>
|
||||||
|
<UsagePanel locale={locale} overview={data.overview} t={t} />
|
||||||
|
</section>
|
||||||
|
<ControlRail data={data} t={t} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ControlRail data={data} t={t} />
|
{activeView === 'health' ? (
|
||||||
|
<section className="panel view-panel" id="health">
|
||||||
|
<div className="panel-title">
|
||||||
|
<h2>{t.panels.health}</h2>
|
||||||
|
<span>{t.panels.heartbeat}</span>
|
||||||
|
</div>
|
||||||
|
<ServicePanel locale={locale} overview={data.overview} t={t} />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="panel" id="agents">
|
{activeView === 'rooms' ? (
|
||||||
<div className="panel-title">
|
<section className="panel view-panel" id="rooms">
|
||||||
<h2>{t.panels.health}</h2>
|
<div className="panel-title">
|
||||||
<span>{t.panels.heartbeat}</span>
|
<h2>{t.panels.rooms}</h2>
|
||||||
</div>
|
<span>{t.panels.queue}</span>
|
||||||
<ServicePanel locale={locale} overview={data.overview} t={t} />
|
</div>
|
||||||
</section>
|
<RoomPanel snapshots={data.snapshots} t={t} />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="panel" id="rooms">
|
{activeView === 'scheduled' ? (
|
||||||
<div className="panel-title">
|
<section className="panel view-panel" id="scheduled">
|
||||||
<h2>{t.panels.rooms}</h2>
|
<div className="panel-title">
|
||||||
<span>{t.panels.queue}</span>
|
<h2>{t.panels.scheduled}</h2>
|
||||||
</div>
|
<span>{t.panels.promptPreviews}</span>
|
||||||
<RoomPanel snapshots={data.snapshots} t={t} />
|
</div>
|
||||||
</section>
|
<TaskPanel locale={locale} tasks={data.tasks} t={t} />
|
||||||
|
</section>
|
||||||
<section className="panel" id="work">
|
) : null}
|
||||||
<div className="panel-title">
|
</div>
|
||||||
<h2>{t.panels.scheduled}</h2>
|
|
||||||
<span>{t.panels.promptPreviews}</span>
|
|
||||||
</div>
|
|
||||||
<TaskPanel locale={locale} tasks={data.tasks} t={t} />
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,8 +70,20 @@ table {
|
|||||||
|
|
||||||
.dashboard-content {
|
.dashboard-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: calc(100vh - 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-panel {
|
||||||
|
min-height: calc(100vh - 36px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell::before {
|
.shell::before {
|
||||||
@@ -248,6 +260,15 @@ button:disabled {
|
|||||||
background: rgba(191, 95, 44, 0.12);
|
background: rgba(191, 95, 44, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-rail nav a.is-active,
|
||||||
|
.side-rail nav a[aria-current='page'],
|
||||||
|
.nav-drawer nav a.is-active,
|
||||||
|
.nav-drawer nav a[aria-current='page'] {
|
||||||
|
color: #fffaf0;
|
||||||
|
background: linear-gradient(135deg, #2b3726, #1c211c);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 250, 240, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.side-refresh {
|
.side-refresh {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
@@ -1024,6 +1045,10 @@ progress::-moz-progress-bar {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-nav .menu-button span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.section-nav button {
|
.section-nav button {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
|
|||||||
Reference in New Issue
Block a user