Feature: add log-level toggle button in log page (#94)

Co-authored-by: MetaCubeX <maze.y2b@github.com>
Co-authored-by: GyDi <segydi@foxmail.com>
Co-authored-by: Dreamacro <8615343+Dreamacro@users.noreply.github.com>
This commit is contained in:
Meta 2022-05-06 11:27:24 +08:00 committed by GitHub
parent a928672ba1
commit e252f85aa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 145 additions and 197 deletions

View File

@ -1,115 +1,86 @@
import classnames from 'classnames' import classnames from 'classnames'
import { useRef, useLayoutEffect, useState, useMemo, ReactElement, Children, cloneElement } from 'react' import { useRef, useState, useMemo, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Icon } from '@components' import { Icon } from '@components'
import { noop } from '@lib/helper' import { noop } from '@lib/helper'
import { BaseComponentProps } from '@models' import { BaseComponentProps } from '@models'
import './style.scss' import './style.scss'
type OptionValue = string | number export interface SelectOptions<T extends string | number> {
label: string
value: T
disabled?: boolean
key?: React.Key
}
interface SelectProps extends BaseComponentProps { interface SelectProps<T extends string | number> extends BaseComponentProps {
/** /**
* selected value * selected value
* must match one of options * must match one of options
*/ */
value: OptionValue value: T
children?: ReactElement options: Array<SelectOptions<T>>
onSelect?: (value: OptionValue, e: React.MouseEvent<HTMLLIElement>) => void onSelect?: (value: T, e: React.MouseEvent<HTMLLIElement>) => void
} }
export function Select (props: SelectProps) { export function Select<T extends string | number> (props: SelectProps<T>) {
const { value, onSelect, children, className: cn, style } = props const { value, options, onSelect, className: cn, style } = props
const portalRef = useRef<HTMLDivElement>(document.createElement('div')) const portalRef = useRef(document.createElement('div'))
const attachmentRef = useRef<HTMLDivElement>(null)
const targetRef = useRef<HTMLDivElement>(null) const targetRef = useRef<HTMLDivElement>(null)
const [showDropDownList, setShowDropDownList] = useState(false) const [showDropDownList, setShowDropDownList] = useState(false)
const [hasCreateDropList, setHasCreateDropList] = useState(false) const [dropdownListStyles, setDropdownListStyles] = useState<React.CSSProperties>({})
const dropdownListStyles = useMemo(() => { useLayoutEffect(() => {
if (targetRef.current != null) { const targetRectInfo = targetRef.current!.getBoundingClientRect()
const targetRectInfo = targetRef.current.getBoundingClientRect() setDropdownListStyles({
return { top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6,
top: Math.floor(targetRectInfo.top) - 10, left: Math.floor(targetRectInfo.left) - 10,
left: Math.floor(targetRectInfo.left) - 10, })
}
}
return {}
}, []) }, [])
function handleGlobalClick (e: MouseEvent) {
const el = attachmentRef.current
if (el?.contains(e.target as Node)) {
setShowDropDownList(false)
}
}
useLayoutEffect(() => { useLayoutEffect(() => {
const current = portalRef.current const current = portalRef.current
document.body.appendChild(current) document.body.appendChild(current)
document.addEventListener('click', handleGlobalClick, true)
return () => { return () => {
document.addEventListener('click', handleGlobalClick, true)
document.body.removeChild(current) document.body.removeChild(current)
} }
}, []) }, [])
function handleShowDropList () { function handleShowDropList () {
if (!hasCreateDropList) { setShowDropDownList(!showDropDownList)
setHasCreateDropList(true)
}
setShowDropDownList(true)
} }
const matchChild = useMemo(() => { const matchChild = useMemo(
let matchChild: React.ReactElement | null = null () => options.find(o => o.value === value),
[value, options],
Children.forEach(children, (child) => { )
if (child?.props?.value === value) {
matchChild = child
}
})
return matchChild as React.ReactElement | null
}, [value, children])
const hookedChildren = useMemo(() => {
return Children.map(children ?? [], child => {
if (!child.props || !child.type) {
return child
}
// add classname for selected option
const className = child.props.value === value
? classnames(child.props.className, 'selected')
: child.props.className
// hook element onclick event
const rawOnClickEvent = child.props.onClick
return cloneElement(child, Object.assign({}, child.props, {
onClick: (e: React.MouseEvent<HTMLLIElement>) => {
onSelect?.(child.props.value, e)
setShowDropDownList(false)
rawOnClickEvent?.(e)
},
className,
}))
})
}, [children, value, onSelect])
const dropDownList = ( const dropDownList = (
<div <div
className={classnames('select-list', { 'select-list-show': showDropDownList })} className={classnames('select-list', { 'select-list-show': showDropDownList })}
ref={attachmentRef}
style={dropdownListStyles} style={dropdownListStyles}
> >
<ul className="list"> <ul className="list">
{ hookedChildren } {
options.map(option => (
<Option
className={classnames({ selected: option.value === value })}
onClick={e => {
onSelect?.(option.value, e)
setShowDropDownList(false)
}}
disabled={option.disabled}
key={option.key ?? option.value}
value={option.value}>
{option.label}
</Option>
))
}
</ul> </ul>
</div> </div>
) )
@ -122,28 +93,25 @@ export function Select (props: SelectProps) {
ref={targetRef} ref={targetRef}
onClick={handleShowDropList} onClick={handleShowDropList}
> >
{matchChild?.props?.children} {matchChild?.label}
<Icon type="triangle-down" /> <Icon type="triangle-down" />
</div> </div>
{ {createPortal(dropDownList, portalRef.current)}
hasCreateDropList && createPortal(dropDownList, portalRef.current)
}
</> </>
) )
} }
interface OptionProps extends BaseComponentProps { interface OptionProps<T> extends BaseComponentProps {
key: React.Key value: T
value: OptionValue
disabled?: boolean disabled?: boolean
onClick?: (e: React.MouseEvent<HTMLLIElement>) => void onClick?: (e: React.MouseEvent<HTMLLIElement>) => void
} }
export function Option (props: OptionProps) { function Option<T> (props: OptionProps<T>) {
const { className: cn, style, key, disabled = false, children, onClick = noop } = props const { className: cn, style, disabled = false, children, onClick = noop } = props
const className = classnames('option', { disabled }, cn) const className = classnames('option', { disabled }, cn)
return ( return (
<li className={className} style={style} key={key} onClick={onClick}>{children}</li> <li className={className} style={style} onClick={onClick}>{children}</li>
) )
} }

View File

@ -16,22 +16,16 @@
position: absolute; position: absolute;
max-width: 170px; max-width: 170px;
border-radius: 4px; border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5);
opacity: 0.8;
pointer-events: none; pointer-events: none;
transform: scaleY(0); transition: all 200ms ease;
transform-origin: top;
transition: all 200ms linear;
.list { .list {
opacity: 0;
max-height: 300px; max-height: 300px;
overflow: auto; overflow: auto;
background: $color-white; background: $color-white;
padding: 5px 0; padding: 5px 0;
transform: scaleY(2); transition: all 200ms ease;
transform-origin: top;
transition: all 200ms linear;
> .option { > .option {
color: $color-primary-darken; color: $color-primary-darken;
@ -52,11 +46,12 @@
} }
.select-list-show { .select-list-show {
opacity: 1;
pointer-events: visible; pointer-events: visible;
transform: scaleY(1); transform: scaleY(1);
box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5);
.list { .list {
opacity: 1;
transform: scaleY(1); transform: scaleY(1);
} }
} }

View File

@ -1,4 +1,4 @@
import { useIntersectionObserver, useSyncedRef } from '@react-hookz/web/esm' import { useIntersectionObserver, useSyncedRef, useUnmountEffect } from '@react-hookz/web/esm'
import { useTableInstance, createTable, getSortedRowModelSync, getColumnFilteredRowModelSync, getCoreRowModelSync } from '@tanstack/react-table' import { useTableInstance, createTable, getSortedRowModelSync, getColumnFilteredRowModelSync, getCoreRowModelSync } from '@tanstack/react-table'
import classnames from 'classnames' import classnames from 'classnames'
import produce from 'immer' import produce from 'immer'
@ -53,6 +53,7 @@ export default function Connections () {
const { translation, lang } = useI18n() const { translation, lang } = useI18n()
const t = useMemo(() => translation('Connections').t, [translation]) const t = useMemo(() => translation('Connections').t, [translation])
const connStreamReader = useConnectionStreamReader() const connStreamReader = useConnectionStreamReader()
const readerRef = useSyncedRef(connStreamReader)
const client = useClient() const client = useClient()
const cardRef = useRef<HTMLDivElement>(null) const cardRef = useRef<HTMLDivElement>(null)
@ -156,9 +157,11 @@ export default function Connections () {
connStreamReader?.subscribe('data', handleConnection) connStreamReader?.subscribe('data', handleConnection)
return () => { return () => {
connStreamReader?.unsubscribe('data', handleConnection) connStreamReader?.unsubscribe('data', handleConnection)
connStreamReader?.destory()
} }
}, [connStreamReader, feed, setTraffic]) }, [connStreamReader, feed, setTraffic])
useUnmountEffect(() => {
readerRef.current?.destory()
})
const instance = useTableInstance(table, { const instance = useTableInstance(table, {
data, data,

View File

@ -1,17 +1,34 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { camelCase } from 'lodash-es'
import { useLayoutEffect, useEffect, useRef, useState } from 'react' import { useLayoutEffect, useEffect, useRef, useState } from 'react'
import { Card, Header } from '@components' import { Select, Card, Header } from '@components'
import { Log } from '@models/Log' import { Log } from '@models/Log'
import { useI18n, useLogsStreamReader } from '@stores' import { useConfig, useI18n, useLogsStreamReader } from '@stores'
import './style.scss' import './style.scss'
const logLevelOptions = [
{ label: 'Default', value: '' },
{ label: 'Debug', value: 'debug' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warning' },
{ label: 'Error', value: 'error' },
{ label: 'Silent', value: 'silent' },
]
const logMap = new Map([
['debug', 'text-teal-500'],
['info', 'text-sky-500'],
['warning', 'text-pink-500'],
['error', 'text-rose-500'],
])
export default function Logs () { export default function Logs () {
const listRef = useRef<HTMLUListElement>(null) const listRef = useRef<HTMLUListElement>(null)
const logsRef = useRef<Log[]>([]) const logsRef = useRef<Log[]>([])
const [logs, setLogs] = useState<Log[]>([]) const [logs, setLogs] = useState<Log[]>([])
const { translation } = useI18n() const { translation } = useI18n()
const { data: { logLevel }, set: setConfig } = useConfig()
const { t } = translation('Logs') const { t } = translation('Logs')
const logsStreamReader = useLogsStreamReader() const logsStreamReader = useLogsStreamReader()
const scrollHeightRef = useRef(listRef.current?.scrollHeight ?? 0) const scrollHeightRef = useRef(listRef.current?.scrollHeight ?? 0)
@ -35,21 +52,29 @@ export default function Logs () {
logsRef.current = logsStreamReader.buffer() logsRef.current = logsStreamReader.buffer()
setLogs(logsRef.current) setLogs(logsRef.current)
} }
return () => logsStreamReader?.unsubscribe('data', handleLog) return () => logsStreamReader?.unsubscribe('data', handleLog)
}, [logsStreamReader]) }, [logsStreamReader])
return ( return (
<div className="page"> <div className="page">
<Header title={ t('title') } /> <Header title={ t('title') } >
<span className="text-sm text-primary-darken mr-2">{t('levelLabel')}:</span>
<Select
options={logLevelOptions}
value={camelCase(logLevel)}
onSelect={level => setConfig(c => { c.logLevel = level })}
/>
</Header>
<Card className="flex flex-col flex-1 mt-2.5 md:mt-4"> <Card className="flex flex-col flex-1 mt-2.5 md:mt-4">
<ul className="logs-panel" ref={listRef}> <ul className="logs-panel" ref={listRef}>
{ {
logs.map( logs.map(
(log, index) => ( (log, index) => (
<li className="leading-5 inline-block" key={index}> <li className="leading-5 inline-block text-[11px]" key={index}>
<span className="mr-4 text-gray-400 text-opacity-90">{ dayjs(log.time).format('YYYY-MM-DD HH:mm:ss') }</span> <span className="mr-2 text-orange-400">[{ dayjs(log.time).format('YYYY-MM-DD HH:mm:ss') }]</span>
<span>[{ log.type }] { log.payload }</span> <span className={logMap.get(log.type)}>[{ log.type.toUpperCase() }]</span>
<span> { log.payload }</span>
</li> </li>
), ),
) )

View File

@ -41,6 +41,7 @@ const EN = {
}, },
Logs: { Logs: {
title: 'Logs', title: 'Logs',
levelLabel: 'Log level',
}, },
Rules: { Rules: {
title: 'Rules', title: 'Rules',

View File

@ -41,6 +41,7 @@ const CN = {
}, },
Logs: { Logs: {
title: '日志', title: '日志',
levelLabel: '日志等级',
}, },
Rules: { Rules: {
title: '规则', title: '规则',

View File

@ -1,11 +1,7 @@
import EventEmitter from 'eventemitter3' import EventEmitter from 'eventemitter3'
import { ResultAsync } from 'neverthrow'
import { SetRequired } from 'type-fest' import { SetRequired } from 'type-fest'
export interface Config { export interface Config {
url: string
useWebsocket: boolean
token?: string
bufferLength?: number bufferLength?: number
retryInterval?: number retryInterval?: number
} }
@ -17,30 +13,25 @@ export class StreamReader<T> {
protected innerBuffer: T[] = [] protected innerBuffer: T[] = []
protected isClose = false protected url = ''
protected connection: WebSocket | null = null
constructor (config: Config) { constructor (config: Config) {
this.config = Object.assign( this.config = Object.assign(
{ {
bufferLength: 0, bufferLength: 0,
retryInterval: 5000, retryInterval: 5000,
headers: {},
}, },
config, config,
) )
this.config.useWebsocket
? this.websocketLoop()
: this.loop()
} }
protected websocketLoop () { protected connectWebsocket () {
const url = new URL(this.config.url) const url = new URL(this.url)
url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
url.searchParams.set('token', this.config.token ?? '')
const connection = new WebSocket(url.toString()) this.connection = new WebSocket(url.toString())
connection.addEventListener('message', msg => { this.connection.addEventListener('message', msg => {
const data = JSON.parse(msg.data) const data = JSON.parse(msg.data)
this.EE.emit('data', [data]) this.EE.emit('data', [data])
if (this.config.bufferLength > 0) { if (this.config.bufferLength > 0) {
@ -51,59 +42,20 @@ export class StreamReader<T> {
} }
}) })
connection.addEventListener('close', () => setTimeout(this.websocketLoop, this.config.retryInterval)) this.connection.addEventListener('error', err => {
connection.addEventListener('error', err => {
this.EE.emit('error', err) this.EE.emit('error', err)
setTimeout(this.websocketLoop, this.config.retryInterval) this.connection?.close()
setTimeout(this.connectWebsocket, this.config.retryInterval)
}) })
} }
protected async loop () { connect (url: string) {
const result = await ResultAsync.fromPromise(fetch( if (this.url === url && this.connection) {
this.config.url,
{
mode: 'cors',
headers: this.config.token ? { Authorization: `Bearer ${this.config.token}` } : {},
},
), e => e as Error)
if (result.isErr()) {
this.retry(result.error)
return
} else if (result.value.body == null) {
this.retry(new Error('fetch body error'))
return return
} }
this.url = url
const reader = result.value.body.getReader() this.connection?.close()
const decoder = new TextDecoder() this.connectWebsocket()
while (true) {
if (this.isClose) {
break
}
const result = await ResultAsync.fromPromise(reader?.read(), e => e as Error)
if (result.isErr()) {
this.retry(result.error)
break
}
const lines = decoder.decode(result.value.value).trim().split('\n')
const data = lines.map(l => JSON.parse(l))
this.EE.emit('data', data)
if (this.config.bufferLength > 0) {
this.innerBuffer.push(...data)
if (this.innerBuffer.length > this.config.bufferLength) {
this.innerBuffer.splice(0, this.innerBuffer.length - this.config.bufferLength)
}
}
}
}
protected retry (err: Error) {
if (!this.isClose) {
this.EE.emit('error', err)
window.setTimeout(() => { this.loop() }, this.config.retryInterval)
}
} }
subscribe (event: string, callback: (data: T[]) => void) { subscribe (event: string, callback: (data: T[]) => void) {
@ -120,6 +72,7 @@ export class StreamReader<T> {
destory () { destory () {
this.EE.removeAllListeners() this.EE.removeAllListeners()
this.isClose = true this.connection?.close()
this.connection = null
} }
} }

View File

@ -1,11 +1,12 @@
import { usePreviousDistinct, useSyncedRef } from '@react-hookz/web/esm'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import produce from 'immer' import produce from 'immer'
import { atom, useAtom } from 'jotai' import { atom, useAtom, useAtomValue } from 'jotai'
import { atomWithImmer } from 'jotai/immer' import { atomWithImmer } from 'jotai/immer'
import { atomWithStorage, useUpdateAtom } from 'jotai/utils' import { atomWithStorage, useUpdateAtom } from 'jotai/utils'
import { get } from 'lodash-es' import { get } from 'lodash-es'
import { ResultAsync } from 'neverthrow' import { ResultAsync } from 'neverthrow'
import { useCallback, useEffect, useMemo } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import useSWR from 'swr' import useSWR from 'swr'
import { Get } from 'type-fest' import { Get } from 'type-fest'
@ -86,6 +87,7 @@ export function useRuleProviders () {
export const configAtom = atomWithStorage('profile', { export const configAtom = atomWithStorage('profile', {
breakConnections: false, breakConnections: false,
logLevel: '',
}) })
export function useConfig () { export function useConfig () {
@ -244,49 +246,44 @@ export function useRule () {
return { rules: data, update } return { rules: data, update }
} }
const logsAtom = atom({ const logsAtom = atom(new StreamReader<Log>({ bufferLength: 200 }))
key: '',
instance: null as StreamReader<Log> | null,
})
export function useLogsStreamReader () { export function useLogsStreamReader () {
const apiInfo = useAPIInfo() const apiInfo = useAPIInfo()
const { general } = useGeneral() const { general } = useGeneral()
const version = useVersion() const { data: { logLevel } } = useConfig()
const [item, setItem] = useAtom(logsAtom) const item = useAtomValue(logsAtom)
if (!version.version || !general.logLevel) { const level = logLevel || general.logLevel
return null const previousKey = usePreviousDistinct(
} `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${level}&secret=${apiInfo.secret}`,
)
const useWebsocket = !!version.version || true const apiInfoRef = useSyncedRef(apiInfo)
const key = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${general.logLevel ?? ''}&useWebsocket=${useWebsocket}&secret=${apiInfo.secret}`
if (item.key === key) {
return item.instance!
}
const oldInstance = item.instance useEffect(() => {
if (level) {
const apiInfo = apiInfoRef.current
const protocol = apiInfo.protocol === 'http:' ? 'ws:' : 'wss:'
const logUrl = `${protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${level}&token=${apiInfo.secret}`
item.connect(logUrl)
}
}, [apiInfoRef, item, level, previousKey])
const logUrl = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${general.logLevel ?? ''}` return item
const instance = new StreamReader<Log>({ url: logUrl, bufferLength: 200, token: apiInfo.secret, useWebsocket })
setItem({ key, instance })
if (oldInstance != null) {
oldInstance.destory()
}
return instance
} }
export function useConnectionStreamReader () { export function useConnectionStreamReader () {
const apiInfo = useAPIInfo() const apiInfo = useAPIInfo()
const version = useVersion()
const useWebsocket = !!version.version || true const connection = useRef(new StreamReader<Snapshot>({ bufferLength: 200 }))
const url = `${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/connections` const protocol = apiInfo.protocol === 'http:' ? 'ws:' : 'wss:'
return useMemo( const url = `${protocol}//${apiInfo.hostname}:${apiInfo.port}/connections?token=${apiInfo.secret}`
() => version.version ? new StreamReader<Snapshot>({ url, bufferLength: 200, token: apiInfo.secret, useWebsocket }) : null,
[apiInfo.secret, url, useWebsocket, version.version], useEffect(() => {
) connection.current.connect(url)
}, [url])
return connection.current
} }

View File

@ -11,6 +11,11 @@ export default defineConfig({
red: '#f56c6c', red: '#f56c6c',
green: '#67c23a', green: '#67c23a',
}, },
textColor: {
primary: {
darken: '#54759a',
},
},
textShadow: { textShadow: {
primary: '0 0 6px rgb(44 138 248 / 40%)', primary: '0 0 6px rgb(44 138 248 / 40%)',
}, },