fix(frontend): share one theme setting between the login page and console #7
@@ -1,4 +1,6 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
// Shared with the console (useTheme.ts, index.html) so one choice applies everywhere.
|
||||||
|
const THEME_KEY = 'floci-theme';
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const buttons = document.querySelectorAll('.tg button');
|
const buttons = document.querySelectorAll('.tg button');
|
||||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
@@ -6,7 +8,7 @@
|
|||||||
function setTheme(theme) {
|
function setTheme(theme) {
|
||||||
root.setAttribute('data-theme', theme);
|
root.setAttribute('data-theme', theme);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('thecloud-theme', theme);
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
} catch (ignored) {
|
} catch (ignored) {
|
||||||
// Theme persistence is optional when browser storage is unavailable.
|
// Theme persistence is optional when browser storage is unavailable.
|
||||||
}
|
}
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
|
|
||||||
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||||
try {
|
try {
|
||||||
const savedTheme = localStorage.getItem('thecloud-theme');
|
const savedTheme = localStorage.getItem(THEME_KEY) ?? localStorage.getItem('thecloud-theme');
|
||||||
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
|
||||||
initialTheme = savedTheme;
|
initialTheme = savedTheme;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -779,6 +779,47 @@ pre,
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.topbar {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .search input {
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .kbd,
|
||||||
|
.topbar .account-meta,
|
||||||
|
.topbar .connection-target {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .connection-state {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .connection {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar .account-trigger {
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-status:empty + .account-switcher {
|
.topbar-status:empty + .account-switcher {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import {useEffect, useState} from 'react'
|
|||||||
|
|
||||||
export type Theme = 'dark' | 'light' | 'cyber'
|
export type Theme = 'dark' | 'light' | 'cyber'
|
||||||
|
|
||||||
|
/** Shared with the login page (public/login.js) and index.html. */
|
||||||
|
const THEME_KEY = 'floci-theme'
|
||||||
|
|
||||||
function getStored(): Theme {
|
function getStored(): Theme {
|
||||||
const v = localStorage.getItem('floci-theme')
|
try {
|
||||||
return v === 'light' || v === 'cyber' ? v : 'dark'
|
const v = localStorage.getItem(THEME_KEY)
|
||||||
|
return v === 'light' || v === 'cyber' ? v : 'dark'
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode) — fall back to the default theme.
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
@@ -12,7 +20,11 @@ export function useTheme() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
localStorage.setItem('floci-theme', theme)
|
try {
|
||||||
|
localStorage.setItem(THEME_KEY, theme)
|
||||||
|
} catch {
|
||||||
|
// Persisting the choice is optional when storage is unavailable.
|
||||||
|
}
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
return {theme, setTheme}
|
return {theme, setTheme}
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ test('summarises the runtime and lists failing console services first', async ()
|
|||||||
expect(screen.getByText('1 not running')).toBeInTheDocument()
|
expect(screen.getByText('1 not running')).toBeInTheDocument()
|
||||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||||
|
|
||||||
const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row')
|
const consoleServices = screen.getByRole('region', {name: 'Console services'})
|
||||||
|
const rows = within(consoleServices).getAllByRole('row')
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
expect(rows[1]).toHaveTextContent('messaging')
|
expect(rows[1]).toHaveTextContent('messaging')
|
||||||
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
|
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
|
||||||
|
|
||||||
@@ -59,6 +61,25 @@ test('summarises the runtime and lists failing console services first', async ()
|
|||||||
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
|
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('keeps responding console services behind a toggle', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
api.getCloudDiagnostics.mockResolvedValue({
|
||||||
|
...healthy,
|
||||||
|
consoleServices: healthy.consoleServices.map((service) => ({...service, runtime: 'reachable', error: null, errorCode: null})),
|
||||||
|
} satisfies RuntimeDiagnostics)
|
||||||
|
renderPage()
|
||||||
|
const consoleServices = await screen.findByRole('region', {name: 'Console services'})
|
||||||
|
|
||||||
|
expect(within(consoleServices).getByText('All 2 responding')).toBeInTheDocument()
|
||||||
|
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(within(consoleServices).getByRole('button', {name: 'Show 2 responding'}))
|
||||||
|
expect(within(consoleServices).getAllByRole('row')).toHaveLength(3)
|
||||||
|
|
||||||
|
await user.click(within(consoleServices).getByRole('button', {name: 'Hide responding'}))
|
||||||
|
expect(within(consoleServices).queryByRole('table')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test('filters emulated services by console coverage and state', async () => {
|
test('filters emulated services by console coverage and state', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
api.getCloudDiagnostics.mockResolvedValue(healthy)
|
||||||
|
|||||||
@@ -88,17 +88,25 @@ function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
|
||||||
|
const [showResponding, setShowResponding] = useState(false)
|
||||||
const failing = services.filter((service) => service.runtime !== 'reachable')
|
const failing = services.filter((service) => service.runtime !== 'reachable')
|
||||||
const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')]
|
const responding = services.filter((service) => service.runtime === 'reachable')
|
||||||
|
const ordered = showResponding ? [...failing, ...responding] : failing
|
||||||
return (
|
return (
|
||||||
<section className="table-panel" aria-labelledby="diagnostics-console-services">
|
<section className="table-panel" aria-labelledby="diagnostics-console-services">
|
||||||
<header className="diagnostics-section-header">
|
<header className="diagnostics-section-header">
|
||||||
<h2 id="diagnostics-console-services">Console services</h2>
|
<h2 id="diagnostics-console-services">Console services</h2>
|
||||||
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`}</span>
|
<div className="diagnostics-filters">
|
||||||
|
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `All ${services.length} responding`}</span>
|
||||||
|
{responding.length > 0 && (
|
||||||
|
<button className="button compact" type="button" aria-expanded={showResponding} onClick={() => setShowResponding((value) => !value)}>
|
||||||
|
{showResponding ? 'Hide responding' : `Show ${responding.length} responding`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{services.length === 0
|
{services.length === 0 && <p className="muted padded">No console services are registered for this cloud.</p>}
|
||||||
? <p className="muted padded">No console services are registered for this cloud.</p>
|
{ordered.length > 0 && <table className="table">
|
||||||
: <table className="table">
|
|
||||||
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
|
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
|
||||||
<tbody>{ordered.map((service) => (
|
<tbody>{ordered.map((service) => (
|
||||||
<tr key={service.service}>
|
<tr key={service.service}>
|
||||||
|
|||||||
Reference in New Issue
Block a user