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 { useRef, useLayoutEffect, useState, useMemo, ReactElement, Children, cloneElement } from 'react'
import { useRef, useState, useMemo, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from '@components'
import { noop } from '@lib/helper'
import { BaseComponentProps } from '@models'
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
* 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) {
const { value, onSelect, children, className: cn, style } = props
export function Select<T extends string | number> (props: SelectProps<T>) {
const { value, options, onSelect, className: cn, style } = props
const portalRef = useRef<HTMLDivElement>(document.createElement('div'))
const attachmentRef = useRef<HTMLDivElement>(null)
const portalRef = useRef(document.createElement('div'))
const targetRef = useRef<HTMLDivElement>(null)
const [showDropDownList, setShowDropDownList] = useState(false)
const [hasCreateDropList, setHasCreateDropList] = useState(false)
const dropdownListStyles = useMemo(() => {
if (targetRef.current != null) {
const targetRectInfo = targetRef.current.getBoundingClientRect()
return {
top: Math.floor(targetRectInfo.top) - 10,
left: Math.floor(targetRectInfo.left) - 10,
}
}
return {}
const [dropdownListStyles, setDropdownListStyles] = useState<React.CSSProperties>({})
useLayoutEffect(() => {
const targetRectInfo = targetRef.current!.getBoundingClientRect()
setDropdownListStyles({
top: Math.floor(targetRectInfo.top + targetRectInfo.height) + 6,
left: Math.floor(targetRectInfo.left) - 10,
})
}, [])
function handleGlobalClick (e: MouseEvent) {
const el = attachmentRef.current
if (el?.contains(e.target as Node)) {
setShowDropDownList(false)
}
}
useLayoutEffect(() => {
const current = portalRef.current
document.body.appendChild(current)
document.addEventListener('click', handleGlobalClick, true)
return () => {
document.addEventListener('click', handleGlobalClick, true)
document.body.removeChild(current)
}
}, [])
function handleShowDropList () {
if (!hasCreateDropList) {
setHasCreateDropList(true)
}
setShowDropDownList(true)
setShowDropDownList(!showDropDownList)
}
const matchChild = useMemo(() => {
let matchChild: React.ReactElement | null = null
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 matchChild = useMemo(
() => options.find(o => o.value === value),
[value, options],
)
const dropDownList = (
<div
className={classnames('select-list', { 'select-list-show': showDropDownList })}
ref={attachmentRef}
style={dropdownListStyles}
>
<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>
</div>
)
@ -122,28 +93,25 @@ export function Select (props: SelectProps) {
ref={targetRef}
onClick={handleShowDropList}
>
{matchChild?.props?.children}
{matchChild?.label}
<Icon type="triangle-down" />
</div>
{
hasCreateDropList && createPortal(dropDownList, portalRef.current)
}
{createPortal(dropDownList, portalRef.current)}
</>
)
}
interface OptionProps extends BaseComponentProps {
key: React.Key
value: OptionValue
interface OptionProps<T> extends BaseComponentProps {
value: T
disabled?: boolean
onClick?: (e: React.MouseEvent<HTMLLIElement>) => void
}
export function Option (props: OptionProps) {
const { className: cn, style, key, disabled = false, children, onClick = noop } = props
function Option<T> (props: OptionProps<T>) {
const { className: cn, style, disabled = false, children, onClick = noop } = props
const className = classnames('option', { disabled }, cn)
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;
max-width: 170px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5);
opacity: 0.8;
pointer-events: none;
transform: scaleY(0);
transform-origin: top;
transition: all 200ms linear;
transition: all 200ms ease;
.list {
opacity: 0;
max-height: 300px;
overflow: auto;
background: $color-white;
padding: 5px 0;
transform: scaleY(2);
transform-origin: top;
transition: all 200ms linear;
transition: all 200ms ease;
> .option {
color: $color-primary-darken;
@ -52,11 +46,12 @@
}
.select-list-show {
opacity: 1;
pointer-events: visible;
transform: scaleY(1);
box-shadow: 0 2px 5px rgba($color: $color-gray-dark, $alpha: 0.5);
.list {
opacity: 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 classnames from 'classnames'
import produce from 'immer'
@ -53,6 +53,7 @@ export default function Connections () {
const { translation, lang } = useI18n()
const t = useMemo(() => translation('Connections').t, [translation])
const connStreamReader = useConnectionStreamReader()
const readerRef = useSyncedRef(connStreamReader)
const client = useClient()
const cardRef = useRef<HTMLDivElement>(null)
@ -156,9 +157,11 @@ export default function Connections () {
connStreamReader?.subscribe('data', handleConnection)
return () => {
connStreamReader?.unsubscribe('data', handleConnection)
connStreamReader?.destory()
}
}, [connStreamReader, feed, setTraffic])
useUnmountEffect(() => {
readerRef.current?.destory()
})
const instance = useTableInstance(table, {
data,

View File

@ -1,17 +1,34 @@
import dayjs from 'dayjs'
import { camelCase } from 'lodash-es'
import { useLayoutEffect, useEffect, useRef, useState } from 'react'
import { Card, Header } from '@components'
import { Select, Card, Header } from '@components'
import { Log } from '@models/Log'
import { useI18n, useLogsStreamReader } from '@stores'
import { useConfig, useI18n, useLogsStreamReader } from '@stores'
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 () {
const listRef = useRef<HTMLUListElement>(null)
const logsRef = useRef<Log[]>([])
const [logs, setLogs] = useState<Log[]>([])
const { translation } = useI18n()
const { data: { logLevel }, set: setConfig } = useConfig()
const { t } = translation('Logs')
const logsStreamReader = useLogsStreamReader()
const scrollHeightRef = useRef(listRef.current?.scrollHeight ?? 0)
@ -35,21 +52,29 @@ export default function Logs () {
logsRef.current = logsStreamReader.buffer()
setLogs(logsRef.current)
}
return () => logsStreamReader?.unsubscribe('data', handleLog)
}, [logsStreamReader])
return (
<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">
<ul className="logs-panel" ref={listRef}>
{
logs.map(
(log, index) => (
<li className="leading-5 inline-block" key={index}>
<span className="mr-4 text-gray-400 text-opacity-90">{ dayjs(log.time).format('YYYY-MM-DD HH:mm:ss') }</span>
<span>[{ log.type }] { log.payload }</span>
<li className="leading-5 inline-block text-[11px]" key={index}>
<span className="mr-2 text-orange-400">[{ dayjs(log.time).format('YYYY-MM-DD HH:mm:ss') }]</span>
<span className={logMap.get(log.type)}>[{ log.type.toUpperCase() }]</span>
<span> { log.payload }</span>
</li>
),
)

View File

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

View File

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

View File

@ -1,11 +1,7 @@
import EventEmitter from 'eventemitter3'
import { ResultAsync } from 'neverthrow'
import { SetRequired } from 'type-fest'
export interface Config {
url: string
useWebsocket: boolean
token?: string
bufferLength?: number
retryInterval?: number
}
@ -17,30 +13,25 @@ export class StreamReader<T> {
protected innerBuffer: T[] = []
protected isClose = false
protected url = ''
protected connection: WebSocket | null = null
constructor (config: Config) {
this.config = Object.assign(
{
bufferLength: 0,
retryInterval: 5000,
headers: {},
},
config,
)
this.config.useWebsocket
? this.websocketLoop()
: this.loop()
}
protected websocketLoop () {
const url = new URL(this.config.url)
url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'
url.searchParams.set('token', this.config.token ?? '')
protected connectWebsocket () {
const url = new URL(this.url)
const connection = new WebSocket(url.toString())
connection.addEventListener('message', msg => {
this.connection = new WebSocket(url.toString())
this.connection.addEventListener('message', msg => {
const data = JSON.parse(msg.data)
this.EE.emit('data', [data])
if (this.config.bufferLength > 0) {
@ -51,59 +42,20 @@ export class StreamReader<T> {
}
})
connection.addEventListener('close', () => setTimeout(this.websocketLoop, this.config.retryInterval))
connection.addEventListener('error', err => {
this.connection.addEventListener('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 () {
const result = await ResultAsync.fromPromise(fetch(
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'))
connect (url: string) {
if (this.url === url && this.connection) {
return
}
const reader = result.value.body.getReader()
const decoder = new TextDecoder()
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)
}
this.url = url
this.connection?.close()
this.connectWebsocket()
}
subscribe (event: string, callback: (data: T[]) => void) {
@ -120,6 +72,7 @@ export class StreamReader<T> {
destory () {
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 produce from 'immer'
import { atom, useAtom } from 'jotai'
import { atom, useAtom, useAtomValue } from 'jotai'
import { atomWithImmer } from 'jotai/immer'
import { atomWithStorage, useUpdateAtom } from 'jotai/utils'
import { get } from 'lodash-es'
import { ResultAsync } from 'neverthrow'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import useSWR from 'swr'
import { Get } from 'type-fest'
@ -86,6 +87,7 @@ export function useRuleProviders () {
export const configAtom = atomWithStorage('profile', {
breakConnections: false,
logLevel: '',
})
export function useConfig () {
@ -244,49 +246,44 @@ export function useRule () {
return { rules: data, update }
}
const logsAtom = atom({
key: '',
instance: null as StreamReader<Log> | null,
})
const logsAtom = atom(new StreamReader<Log>({ bufferLength: 200 }))
export function useLogsStreamReader () {
const apiInfo = useAPIInfo()
const { general } = useGeneral()
const version = useVersion()
const [item, setItem] = useAtom(logsAtom)
const { data: { logLevel } } = useConfig()
const item = useAtomValue(logsAtom)
if (!version.version || !general.logLevel) {
return null
}
const level = logLevel || general.logLevel
const previousKey = usePreviousDistinct(
`${apiInfo.protocol}//${apiInfo.hostname}:${apiInfo.port}/logs?level=${level}&secret=${apiInfo.secret}`,
)
const useWebsocket = !!version.version || true
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 apiInfoRef = useSyncedRef(apiInfo)
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 ?? ''}`
const instance = new StreamReader<Log>({ url: logUrl, bufferLength: 200, token: apiInfo.secret, useWebsocket })
setItem({ key, instance })
if (oldInstance != null) {
oldInstance.destory()
}
return instance
return item
}
export function useConnectionStreamReader () {
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`
return useMemo(
() => version.version ? new StreamReader<Snapshot>({ url, bufferLength: 200, token: apiInfo.secret, useWebsocket }) : null,
[apiInfo.secret, url, useWebsocket, version.version],
)
const protocol = apiInfo.protocol === 'http:' ? 'ws:' : 'wss:'
const url = `${protocol}//${apiInfo.hostname}:${apiInfo.port}/connections?token=${apiInfo.secret}`
useEffect(() => {
connection.current.connect(url)
}, [url])
return connection.current
}

View File

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