diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html index 434ca72..3fdc00b 100644 --- a/apps/dashboard/index.html +++ b/apps/dashboard/index.html @@ -1,9 +1,19 @@ - + - - EJClaw Dashboard + + + + + + + + + EJClaw Ops
diff --git a/apps/dashboard/public/icons/icon-192.png b/apps/dashboard/public/icons/icon-192.png new file mode 100644 index 0000000..90ed779 Binary files /dev/null and b/apps/dashboard/public/icons/icon-192.png differ diff --git a/apps/dashboard/public/icons/icon-192.svg b/apps/dashboard/public/icons/icon-192.svg new file mode 100644 index 0000000..7ac3f43 --- /dev/null +++ b/apps/dashboard/public/icons/icon-192.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/dashboard/public/icons/icon-512.png b/apps/dashboard/public/icons/icon-512.png new file mode 100644 index 0000000..9c4c78a Binary files /dev/null and b/apps/dashboard/public/icons/icon-512.png differ diff --git a/apps/dashboard/public/icons/icon-512.svg b/apps/dashboard/public/icons/icon-512.svg new file mode 100644 index 0000000..7594125 --- /dev/null +++ b/apps/dashboard/public/icons/icon-512.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/dashboard/public/manifest.webmanifest b/apps/dashboard/public/manifest.webmanifest new file mode 100644 index 0000000..3c78ff5 --- /dev/null +++ b/apps/dashboard/public/manifest.webmanifest @@ -0,0 +1,27 @@ +{ + "name": "EJClaw Ops", + "short_name": "EJClaw", + "description": "EJClaw operations console", + "start_url": "/", + "scope": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "browser"], + "background_color": "#f3efe4", + "theme_color": "#2b3726", + "orientation": "portrait-primary", + "categories": ["productivity", "utilities"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/apps/dashboard/public/offline.html b/apps/dashboard/public/offline.html new file mode 100644 index 0000000..f71f86a --- /dev/null +++ b/apps/dashboard/public/offline.html @@ -0,0 +1,87 @@ + + + + + + + EJClaw Offline + + + +
+ EJClaw +

Offline

+

네트워크가 복구되면 대시보드를 다시 불러옵니다.

+ +
+ + diff --git a/apps/dashboard/public/sw.js b/apps/dashboard/public/sw.js new file mode 100644 index 0000000..ec1b737 --- /dev/null +++ b/apps/dashboard/public/sw.js @@ -0,0 +1,78 @@ +const CACHE_NAME = 'ejclaw-dashboard-v1'; +const PRECACHE_URLS = [ + '/', + '/index.html', + '/offline.html', + '/manifest.webmanifest', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon-192.svg', + '/icons/icon-512.svg', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(PRECACHE_URLS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + if (request.method !== 'GET' || url.origin !== self.location.origin) { + return; + } + + if (url.pathname.startsWith('/api/')) { + event.respondWith(fetch(request)); + return; + } + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put('/', copy)); + return response; + }) + .catch(() => + caches + .match('/') + .then((cached) => cached || caches.match('/offline.html')), + ), + ); + return; + } + + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request).then((response) => { + if (!response.ok) return response; + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }); + }), + ); +}); diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 703aa54..f8dcd61 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -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; + 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 ( ) : 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 (
+
+ {t.pwa.updated} + {freshnessLabel(freshness, t)} + + {formatDate(data.overview.generatedAt, locale)} + {age === null ? '' : ` · ${formatDuration(age, t)}`} + +
+
+ {t.pwa.app} + + {!secureContext + ? t.pwa.secureRequired + : installed + ? t.pwa.installed + : offlineReady + ? t.pwa.ready + : t.pwa.app} + + {canInstall ? ( + + ) : ( + + {!secureContext + ? t.pwa.secureRequired + : offlineReady + ? t.pwa.cached + : t.pwa.online} + + )} +
{t.metrics.rooms} @@ -1771,6 +1934,13 @@ function App() { const [drawerOpen, setDrawerOpen] = useState(false); const [activeView, setActiveView] = useState(readViewFromHash); const [locale, setLocale] = useState(readInitialLocale); + const [online, setOnline] = useState(() => + typeof navigator === 'undefined' ? true : navigator.onLine, + ); + const [offlineReady, setOfflineReady] = useState(false); + const [installPrompt, setInstallPrompt] = + useState(null); + const [installed, setInstalled] = useState(isStandaloneDisplay); const [taskActionKey, setTaskActionKey] = useState( null, ); @@ -1781,6 +1951,8 @@ function App() { useState(null); const [roomMessageKey, setRoomMessageKey] = useState(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 (
void handleInstallApp()} onNavigate={navigateToView} onLocaleChange={setDashboardLocale} onRefresh={() => void refresh(true)} refreshing={refreshing} + secureContext={secureContext} t={t} />
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() {
- + void handleInstallApp()} + secureContext={secureContext} + t={t} + /> ) : null} diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index 618ad93..836e66b 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -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: 'ルーム', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index af84265..c781d43 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -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,