Add dashboard PWA shell and status UX

This commit is contained in:
Eyejoker
2026-04-27 10:53:27 +09:00
committed by GitHub
parent 76b0ab754b
commit d7fe6fa13a
11 changed files with 681 additions and 7 deletions

View File

@@ -52,6 +52,12 @@ type InboxActionKey = `${string}:${DashboardInboxAction}`;
type ServiceActionKey = 'stack:restart';
type InboxFilter = 'all' | InboxItem['kind'];
type HealthLevel = 'ok' | 'stale' | 'down';
type FreshnessLevel = 'fresh' | 'stale' | 'offline';
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
};
interface RoomOption {
jid: string;
@@ -64,6 +70,7 @@ const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
const DEFAULT_VIEW: DashboardView = 'inbox';
const HEALTH_STALE_MS = 5 * 60_000;
const HEALTH_DOWN_MS = 15 * 60_000;
const DASHBOARD_STALE_MS = 75_000;
function makeClientRequestId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -119,6 +126,47 @@ function formatDate(value: string | null | undefined, locale: Locale): string {
}).format(date);
}
function dashboardAgeMs(value: string | null | undefined): number | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return Math.max(0, Date.now() - date.getTime());
}
function dashboardFreshness(
online: boolean,
generatedAt: string | null | undefined,
): FreshnessLevel {
if (!online) return 'offline';
const age = dashboardAgeMs(generatedAt);
if (age !== null && age > DASHBOARD_STALE_MS) return 'stale';
return 'fresh';
}
function freshnessLabel(level: FreshnessLevel, t: Messages): string {
if (level === 'offline') return t.pwa.offline;
if (level === 'stale') return t.pwa.stale;
return t.pwa.fresh;
}
function isStandaloneDisplay(): boolean {
if (typeof window === 'undefined') return false;
const standaloneNavigator = navigator as Navigator & { standalone?: boolean };
return (
standaloneNavigator.standalone === true ||
(typeof window.matchMedia === 'function' &&
window.matchMedia('(display-mode: standalone)').matches)
);
}
function canUsePwaCore(): boolean {
return (
typeof window !== 'undefined' &&
window.isSecureContext &&
'serviceWorker' in navigator
);
}
function formatTaskDate(
value: string | null | undefined,
locale: Locale,
@@ -517,21 +565,41 @@ function LoadingSkeleton({ t }: { t: Messages }) {
function SideRail({
activeView,
canInstall,
installed,
locale,
onNavigate,
onInstall,
onLocaleChange,
onRefresh,
online,
offlineReady,
refreshing,
secureContext,
t,
}: {
activeView: DashboardView;
canInstall: boolean;
installed: boolean;
locale: Locale;
onNavigate: (view: DashboardView) => void;
onInstall: () => void;
onLocaleChange: (locale: Locale) => void;
onRefresh: () => void;
online: boolean;
offlineReady: boolean;
refreshing: boolean;
secureContext: boolean;
t: Messages;
}) {
const pwaState = !secureContext
? t.pwa.secureRequired
: installed
? t.pwa.installed
: offlineReady
? t.pwa.ready
: t.pwa.app;
return (
<aside className="side-rail" aria-label={t.nav.drawerAria}>
<div className="side-rail-brand">
@@ -552,6 +620,15 @@ function SideRail({
))}
</nav>
<LanguageSelector locale={locale} onLocaleChange={onLocaleChange} t={t} />
<div className={`pwa-card ${online ? 'is-online' : 'is-offline'}`}>
<span>{online ? t.pwa.online : t.pwa.offline}</span>
<strong>{pwaState}</strong>
</div>
{canInstall ? (
<button className="side-install" onClick={onInstall} type="button">
{t.pwa.install}
</button>
) : null}
<button
aria-busy={refreshing}
className="side-refresh"
@@ -568,24 +645,36 @@ function SideRail({
function SectionNav({
activeView,
drawerOpen,
freshness,
installed,
locale,
canInstall,
onCloseDrawer,
onInstall,
onLocaleChange,
onNavigate,
onOpenDrawer,
offlineReady,
refreshing,
onRefresh,
secureContext,
t,
}: {
activeView: DashboardView;
drawerOpen: boolean;
freshness: FreshnessLevel;
installed: boolean;
locale: Locale;
canInstall: boolean;
onCloseDrawer: () => void;
onInstall: () => void;
onLocaleChange: (locale: Locale) => void;
onNavigate: (view: DashboardView) => void;
onOpenDrawer: () => void;
offlineReady: boolean;
refreshing: boolean;
onRefresh: () => void;
secureContext: boolean;
t: Messages;
}) {
const activeLabel =
@@ -607,6 +696,9 @@ function SectionNav({
<span />
</button>
<strong className="topbar-label">{activeLabel}</strong>
<span className={`topbar-status topbar-status-${freshness}`}>
{freshnessLabel(freshness, t)}
</span>
<button
aria-busy={refreshing}
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
@@ -668,6 +760,22 @@ function SectionNav({
onLocaleChange={onLocaleChange}
t={t}
/>
<div className="drawer-pwa-row">
<span>
{!secureContext
? t.pwa.secureRequired
: installed
? t.pwa.installed
: offlineReady
? t.pwa.ready
: t.pwa.app}
</span>
{canInstall ? (
<button onClick={onInstall} type="button">
{t.pwa.install}
</button>
) : null}
</div>
</aside>
</>
) : null}
@@ -675,7 +783,27 @@ function SectionNav({
);
}
function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
function ControlRail({
canInstall,
data,
installed,
locale,
offlineReady,
onInstall,
online,
secureContext,
t,
}: {
canInstall: boolean;
data: DashboardState;
installed: boolean;
locale: Locale;
offlineReady: boolean;
onInstall: () => void;
online: boolean;
secureContext: boolean;
t: Messages;
}) {
const queue = data.snapshots.reduce(
(acc, snapshot) => {
for (const entry of snapshot.entries) {
@@ -686,9 +814,44 @@ function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
},
{ pendingTasks: 0, pendingMessageRooms: 0 },
);
const freshness = dashboardFreshness(online, data.overview.generatedAt);
const age = dashboardAgeMs(data.overview.generatedAt);
return (
<section className="ops-strip" id="overview" aria-label={t.control.aria}>
<div className={`ops-tile-freshness ops-${freshness}`}>
<span>{t.pwa.updated}</span>
<strong>{freshnessLabel(freshness, t)}</strong>
<small>
{formatDate(data.overview.generatedAt, locale)}
{age === null ? '' : ` · ${formatDuration(age, t)}`}
</small>
</div>
<div className="ops-tile-pwa">
<span>{t.pwa.app}</span>
<strong>
{!secureContext
? t.pwa.secureRequired
: installed
? t.pwa.installed
: offlineReady
? t.pwa.ready
: t.pwa.app}
</strong>
{canInstall ? (
<button onClick={onInstall} type="button">
{t.pwa.install}
</button>
) : (
<small>
{!secureContext
? t.pwa.secureRequired
: offlineReady
? t.pwa.cached
: t.pwa.online}
</small>
)}
</div>
<div>
<span>{t.metrics.rooms}</span>
<strong>
@@ -1771,6 +1934,13 @@ function App() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeView, setActiveView] = useState<DashboardView>(readViewFromHash);
const [locale, setLocale] = useState<Locale>(readInitialLocale);
const [online, setOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
);
const [offlineReady, setOfflineReady] = useState(false);
const [installPrompt, setInstallPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(isStandaloneDisplay);
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
null,
);
@@ -1781,6 +1951,8 @@ function App() {
useState<ServiceActionKey | null>(null);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
const t = messages[locale];
const secureContext =
typeof window === 'undefined' ? true : window.isSecureContext;
function setDashboardLocale(nextLocale: Locale) {
setLocale(nextLocale);
@@ -1922,10 +2094,90 @@ function App() {
}
}
async function handleInstallApp() {
if (!installPrompt) return;
await installPrompt.prompt();
await installPrompt.userChoice;
setInstallPrompt(null);
setInstalled(isStandaloneDisplay());
}
useEffect(() => {
document.documentElement.lang = localeTags[locale];
}, [locale]);
useEffect(() => {
function handleOnline() {
setOnline(true);
}
function handleOffline() {
setOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
if (!import.meta.env.PROD || !canUsePwaCore()) {
setOfflineReady(false);
return;
}
let cancelled = false;
void navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
if (!cancelled) {
setOfflineReady(
Boolean(
registration.active ||
registration.waiting ||
registration.installing,
),
);
}
return navigator.serviceWorker.ready;
})
.then(() => {
if (!cancelled) setOfflineReady(true);
})
.catch(() => {
if (!cancelled) setOfflineReady(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
function handleBeforeInstallPrompt(event: Event) {
event.preventDefault();
setInstallPrompt(event as BeforeInstallPromptEvent);
}
function handleInstalled() {
setInstalled(true);
setInstallPrompt(null);
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleInstalled);
return () => {
window.removeEventListener(
'beforeinstallprompt',
handleBeforeInstallPrompt,
);
window.removeEventListener('appinstalled', handleInstalled);
};
}, []);
useEffect(() => {
function handleHashChange() {
setActiveView(readViewFromHash());
@@ -1960,29 +2212,43 @@ function App() {
}
const roomOptions = data ? buildRoomOptions(data.snapshots) : [];
const freshness = dashboardFreshness(online, data?.overview.generatedAt);
const canInstall = Boolean(secureContext && installPrompt && !installed);
return (
<div className="shell">
<SideRail
activeView={activeView}
canInstall={canInstall}
installed={installed}
locale={locale}
offlineReady={offlineReady}
online={online}
onInstall={() => void handleInstallApp()}
onNavigate={navigateToView}
onLocaleChange={setDashboardLocale}
onRefresh={() => void refresh(true)}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
<main className="dashboard-content">
<SectionNav
activeView={activeView}
canInstall={canInstall}
drawerOpen={drawerOpen}
freshness={freshness}
installed={installed}
locale={locale}
onCloseDrawer={() => setDrawerOpen(false)}
onInstall={() => void handleInstallApp()}
onLocaleChange={setDashboardLocale}
onNavigate={navigateToView}
onOpenDrawer={() => setDrawerOpen(true)}
offlineReady={offlineReady}
onRefresh={() => void refresh(true)}
refreshing={refreshing}
secureContext={secureContext}
t={t}
/>
@@ -2008,7 +2274,17 @@ function App() {
</div>
<UsagePanel overview={data.overview} t={t} />
</section>
<ControlRail data={data} t={t} />
<ControlRail
canInstall={canInstall}
data={data}
installed={installed}
locale={locale}
offlineReady={offlineReady}
online={online}
onInstall={() => void handleInstallApp()}
secureContext={secureContext}
t={t}
/>
</>
) : null}

View File

@@ -38,6 +38,19 @@ export interface Messages {
activeRooms: string;
pendingRooms: string;
};
pwa: {
app: string;
install: string;
installed: string;
ready: string;
cached: string;
online: string;
offline: string;
fresh: string;
stale: string;
secureRequired: string;
updated: string;
};
metrics: {
agents: string;
rooms: string;
@@ -299,6 +312,19 @@ export const messages = {
activeRooms: '처리 + 대기 룸',
pendingRooms: '메시지 대기 룸',
},
pwa: {
app: 'PWA',
install: '설치',
installed: '설치됨',
ready: '오프라인 준비',
cached: '캐시됨',
online: '온라인',
offline: '오프라인',
fresh: '최신',
stale: '지연',
secureRequired: 'HTTPS 필요',
updated: '갱신',
},
metrics: {
agents: '에이전트',
rooms: '룸',
@@ -544,6 +570,19 @@ export const messages = {
activeRooms: 'processing + waiting rooms',
pendingRooms: 'rooms with pending messages',
},
pwa: {
app: 'PWA',
install: 'Install',
installed: 'Installed',
ready: 'Offline ready',
cached: 'Cached',
online: 'Online',
offline: 'Offline',
fresh: 'Fresh',
stale: 'Stale',
secureRequired: 'HTTPS required',
updated: 'Updated',
},
metrics: {
agents: 'agents',
rooms: 'rooms',
@@ -789,6 +828,19 @@ export const messages = {
activeRooms: '处理中 + 等待房间',
pendingRooms: '有待处理消息的房间',
},
pwa: {
app: 'PWA',
install: '安装',
installed: '已安装',
ready: '离线可用',
cached: '已缓存',
online: '在线',
offline: '离线',
fresh: '最新',
stale: '延迟',
secureRequired: '需要 HTTPS',
updated: '更新',
},
metrics: {
agents: '代理',
rooms: '房间',
@@ -1034,6 +1086,19 @@ export const messages = {
activeRooms: '処理中 + 待機ルーム',
pendingRooms: 'メッセージ待ちルーム',
},
pwa: {
app: 'PWA',
install: 'インストール',
installed: 'インストール済み',
ready: 'オフライン可',
cached: 'キャッシュ済み',
online: 'オンライン',
offline: 'オフライン',
fresh: '最新',
stale: '遅延',
secureRequired: 'HTTPS 必須',
updated: '更新',
},
metrics: {
agents: 'エージェント',
rooms: 'ルーム',

View File

@@ -206,6 +206,29 @@ button:disabled {
white-space: nowrap;
}
.topbar-status {
display: inline-flex;
min-height: 34px;
align-items: center;
padding: 0 11px;
border-radius: 999px;
color: var(--muted);
background: rgba(43, 55, 38, 0.075);
font-size: 12px;
font-weight: 900;
white-space: nowrap;
}
.topbar-status-offline {
color: #7c2518;
background: rgba(183, 71, 52, 0.14);
}
.topbar-status-stale {
color: #6e4a05;
background: rgba(181, 138, 34, 0.16);
}
.side-rail {
position: sticky;
top: 18px;
@@ -271,6 +294,46 @@ button:disabled {
background: rgba(191, 95, 44, 0.12);
}
.side-install {
width: 100%;
min-height: 44px;
background: linear-gradient(135deg, #2b3726, #1c211c);
box-shadow: 0 12px 26px rgba(28, 33, 28, 0.18);
font-weight: 950;
}
.pwa-card {
display: grid;
gap: 3px;
min-width: 0;
padding: 11px 12px;
border: 1px solid rgba(43, 55, 38, 0.1);
border-radius: 17px;
background: rgba(43, 55, 38, 0.045);
}
.pwa-card span,
.drawer-pwa-row span {
color: var(--muted);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pwa-card strong {
min-width: 0;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.pwa-card.is-offline {
border-color: rgba(183, 71, 52, 0.24);
background: rgba(183, 71, 52, 0.08);
}
.language-select {
display: grid;
gap: 8px;
@@ -347,6 +410,19 @@ button:disabled {
box-shadow: none;
}
.drawer-pwa-row {
display: grid;
gap: 8px;
padding-top: 2px;
}
.drawer-pwa-row button {
min-height: 44px;
padding: 0 14px;
font-size: 13px;
font-weight: 950;
}
.nav-drawer nav {
display: grid;
align-content: start;
@@ -409,7 +485,7 @@ button:disabled {
.ops-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
margin: 0;
overflow: hidden;
@@ -457,6 +533,27 @@ button:disabled {
line-height: 1.35;
}
.ops-fresh {
border-color: rgba(63, 127, 81, 0.16);
}
.ops-stale {
border-color: rgba(181, 138, 34, 0.22);
}
.ops-offline {
border-color: rgba(183, 71, 52, 0.28);
}
.ops-tile-pwa button {
min-height: 30px;
width: fit-content;
padding: 0 10px;
box-shadow: none;
font-size: 12px;
font-weight: 950;
}
.skeleton-card {
display: grid;
gap: 8px;
@@ -1787,7 +1884,7 @@ progress::-moz-progress-bar {
top: max(8px, env(safe-area-inset-top));
right: 10px;
left: 10px;
grid-template-columns: 52px minmax(0, 1fr) 54px;
grid-template-columns: 52px minmax(0, 1fr) auto 54px;
gap: 6px;
margin: 0;
padding: 8px;
@@ -1818,6 +1915,12 @@ progress::-moz-progress-bar {
min-width: 0;
}
.topbar-status {
min-height: 30px;
padding: 0 8px;
font-size: 10px;
}
.section-nav .refresh-button::before {
color: #fffaf0;
content: '↻';
@@ -2004,6 +2107,12 @@ progress::-moz-progress-bar {
.section-nav {
right: 8px;
left: 8px;
grid-template-columns: 48px minmax(0, 1fr) auto 50px;
}
.topbar-status {
padding: 0 7px;
letter-spacing: -0.01em;
}
.usage-summary {
@@ -2057,6 +2166,12 @@ progress::-moz-progress-bar {
}
}
@media (display-mode: standalone) {
.shell {
padding-top: max(18px, env(safe-area-inset-top));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,