🎉 init: impl dashboard overview

This commit is contained in:
2026-08-23 17:36:27 +08:00
parent ce5c3c5773
commit f090c7481b
19 changed files with 2366 additions and 16 deletions
+128
View File
@@ -0,0 +1,128 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { SystemStatus, RemoteLinkItem, SSEEventData } from '@/types/api'
interface UseKlalbSSEResult {
status: SystemStatus | null
links: RemoteLinkItem[]
isConnected: boolean
error: string | null
reconnectAll: () => Promise<boolean>
refreshStatus: () => Promise<void>
}
export function useKlalbSSE(): UseKlalbSSEResult {
const [status, setStatus] = useState<SystemStatus | null>(null)
const [links, setLinks] = useState<RemoteLinkItem[]>([])
const [isConnected, setIsConnected] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
const refreshStatus = useCallback(async () => {
try {
const res = await fetch('/api/status')
if (res.ok) {
const data = (await res.json()) as SystemStatus
setStatus(data)
setError(null)
}
} catch (err) {
console.warn('Failed to fetch status fallback:', err)
}
}, [])
const reconnectAll = useCallback(async (): Promise<boolean> => {
try {
const res = await fetch('/api/links/reconnect', { method: 'POST' })
return res.ok
} catch (err) {
console.error('Failed to trigger reconnect all:', err)
return false
}
}, [])
useEffect(() => {
let isUnmounted = false
function connectSSE() {
if (isUnmounted) return
try {
const es = new EventSource('/api/events')
eventSourceRef.current = es
es.onopen = () => {
if (!isUnmounted) {
setIsConnected(true)
setError(null)
}
}
es.onmessage = (event) => {
if (isUnmounted) return
try {
const data = JSON.parse(event.data) as SSEEventData
if (data.status) {
setStatus(data.status)
}
if (data.links) {
setLinks(data.links)
}
setIsConnected(true)
setError(null)
} catch (e) {
console.error('Failed to parse SSE event data:', e)
}
}
es.onerror = () => {
if (isUnmounted) return
setIsConnected(false)
setError('SSE connection lost, reconnecting...')
es.close()
eventSourceRef.current = null
// Fallback poll
refreshStatus()
// Schedule reconnection in 2s
if (reconnectTimeoutRef.current) {
window.clearTimeout(reconnectTimeoutRef.current)
}
reconnectTimeoutRef.current = window.setTimeout(() => {
connectSSE()
}, 2000)
}
} catch (e) {
console.error('SSE initialization error:', e)
if (!isUnmounted) {
setIsConnected(false)
setError('Failed to establish event stream')
}
}
}
refreshStatus()
connectSSE()
return () => {
isUnmounted = true
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
if (reconnectTimeoutRef.current) {
window.clearTimeout(reconnectTimeoutRef.current)
}
}
}, [refreshStatus])
return {
status,
links,
isConnected,
error,
reconnectAll,
refreshStatus,
}
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}