RSS Git Download  Clone
Raw Blame History 10kB 216 lines
/**
 * Stream key type renderer — exact port of Angular key-stream.component.
 * Block layout with timestamp ID header + field/value pairs.
 */
import { useState, useEffect, useCallback } from 'react'
import { Box, Tooltip } from '@mui/material'
import { Delete, TableChart, ContentCopy, Download, Add } from '@mui/icons-material'
import { useTheme } from '@mui/material'
import { useI18nStore } from '../../../stores/i18n.store'
import { useRedisStateStore } from '../../../stores/redis-state.store'
import { useCommonStore } from '../../../stores/common.store'
import { request } from '../../../stores/socket.service'
import { KeyTypeProps, createPaging, Paging, formatValue, truncateDisplay, isTruncated, copyValue } from './key-type-base'
import KeyPagerInline from './KeyPagerInline'
import KeyNewOrSetDialog from '../../../dialogs/KeyNewOrSetDialog'
import JsonViewDialog from '../../../dialogs/JsonViewDialog'

const intlLocaleMap: Record<string, string> = { 'zn': 'zh-CN', 'no': 'nb', 'fil': 'tl' }

interface StreamEntry {
    id: string
    fields: Array<[string, string]>
    data: any
    hasDuplicateFields: boolean
}

function parseFieldValue(value: string): any {
    try { return JSON.parse(value) } catch { return value }
}

function hasDuplicateFields(fields: Array<[string, string]>): boolean {
    const seen = new Set<string>()
    for (const [key] of fields) { if (seen.has(key)) return true; seen.add(key) }
    return false
}

function fieldsToObject(fields: Array<[string, string]>): any {
    const obj: any = {}
    for (const [key, value] of fields) obj[key] = parseFieldValue(value)
    return obj
}

function fieldsToArray(fields: Array<[string, string]>): Array<{ field: string; value: any }> {
    return fields.map(([field, value]) => ({ field, value: parseFieldValue(value) }))
}

function buildEntries(value: any[]): StreamEntry[] {
    if (!value) return []
    return value.map((entry: any) => {
        const id = entry[0]
        const rawData = entry[1]
        const fields: Array<[string, string]> = []
        for (let i = 0; i < rawData.length; i += 2) fields.push([rawData[i], rawData[i + 1]])
        const hasDup = hasDuplicateFields(fields)
        const data = hasDup ? fieldsToArray(fields) : fieldsToObject(fields)
        return { id, fields, data, hasDuplicateFields: hasDup }
    })
}

function entryToExport(entry: StreamEntry): any {
    if (entry.hasDuplicateFields) return { id: entry.id, fields: entry.data }
    return { id: entry.id, ...entry.data }
}

export default function KeyStream({ response, value, valueBuffer, keyName, valueFormat, onRefresh }: KeyTypeProps) {
    const strings = useI18nStore(s => s.strings)
    const currentLang = useI18nStore(s => s.currentLang)
    const connection = useRedisStateStore(s => s.connection)
    const { toast, confirm, generalHandleError } = useCommonStore()
    const muiTheme = useTheme()
    const isReadonly = connection?.readonly === true
    const isDark = muiTheme.palette.mode === 'dark'

    const [allEntries, setAllEntries] = useState<StreamEntry[]>([])
    const [paging, setPaging] = useState<Paging>(() => createPaging(0))
    const [pagedEntries, setPagedEntries] = useState<StreamEntry[]>([])
    const [editDialogOpen, setEditDialogOpen] = useState(false)
    const [editDialogData, setEditDialogData] = useState<any>(null)
    const [jsonViewOpen, setJsonViewOpen] = useState(false)
    const [jsonViewValue, setJsonViewValue] = useState('')

    useEffect(() => {
        const entries = buildEntries(value)
        setAllEntries(entries)
        const p = createPaging(entries.length)
        setPaging(p)
        setPagedEntries(entries.slice(p.startIndex, p.endIndex))
    }, [value])

    const updatePagedItems = useCallback((p: Paging) => {
        setPaging(p)
        setPagedEntries(allEntries.slice(p.startIndex, p.endIndex))
    }, [allEntries])

    const showTimestamp = useCallback((id: string): string => {
        try {
            const ms = parseInt(id.slice(0, id.indexOf('-')))
            const lang = currentLang || 'en'
            const locale = intlLocaleMap[lang] || lang
            return new Date(ms).toLocaleString(locale, {
                year: 'numeric', month: 'numeric', day: 'numeric',
                hour: '2-digit', minute: '2-digit', second: '2-digit',
            })
        } catch { return id }
    }, [currentLang])

    const addStream = useCallback(() => {
        setEditDialogData({ type: 'append', model: { type: 'stream', key: keyName } })
        setEditDialogOpen(true)
    }, [keyName])

    const deleteStreamTimestamp = useCallback(async (id: string) => {
        try {
            await confirm({ message: strings?.confirm?.deleteStreamTimestamp ?? strings?.confirm?.areYouSure ?? 'Are you sure?' })
            await request({ action: 'key-stream-delete-timestamp', payload: { key: keyName, streamTimestamp: id } })
            toast(strings?.status?.deletedStreamTimestamp ?? strings?.status?.deletedKey)
            onRefresh()
        } catch (e) { if (e) generalHandleError(e) }
    }, [keyName, strings, confirm, toast, onRefresh, generalHandleError])

    const copyEntry = useCallback((entry: StreamEntry) => {
        copyValue(JSON.stringify(entryToExport(entry), null, 2))
    }, [])

    const downloadEntry = useCallback((entry: StreamEntry) => {
        const lines = [entry.id]
        for (const [field, val] of entry.fields) { lines.push(field); lines.push(val) }
        const blob = new Blob([lines.join('\n')], { type: 'text/plain' })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url; a.download = `${keyName}-${entry.id}.txt`; a.click()
        URL.revokeObjectURL(url)
    }, [keyName])

    const viewEntryJson = useCallback((entry: StreamEntry) => {
        setJsonViewValue(JSON.stringify(entryToExport(entry)))
        setJsonViewOpen(true)
    }, [])

    const handleEditClose = useCallback((result?: any) => {
        setEditDialogOpen(false); setEditDialogData(null)
        if (result) onRefresh()
    }, [onRefresh])

    const oddBg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)'
    const hoverBg = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
    const listBorder = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'
    const iconSx = (color: string) => ({ fontSize: 18, cursor: 'pointer', mx: '2px', opacity: 0.7, color, '&:hover': { opacity: 1 } })

    return (
        <Box>
            <KeyPagerInline paging={paging} onPageChange={updatePagedItems} />
            <Box>
                {/* Header */}
                <Box sx={{
                    display: 'flex', alignItems: 'center', gap: 1, px: 2, py: 1, fontWeight: 'bold',
                    bgcolor: muiTheme.palette.primary.main, color: muiTheme.palette.primary.contrastText,
                    borderBottom: `2px solid ${listBorder}`,
                }}>
                    <Box component="span" sx={{ flex: 1 }}>{strings?.page?.key?.stream?.table?.timestamp ?? 'Timestamp ID'}</Box>
                    <Box component="span" sx={{ textAlign: 'right' }}>
                        {!isReadonly && (
                            <Tooltip title={strings?.intention?.add}><Add sx={{ cursor: 'pointer', color: 'inherit' }} onClick={addStream} /></Tooltip>
                        )}
                    </Box>
                </Box>

                {/* Entries */}
                {pagedEntries.map((entry, i) => (
                    <Box key={entry.id} sx={{
                        borderBottom: `1px solid ${listBorder}`,
                        bgcolor: i % 2 === 0 ? oddBg : 'transparent',
                        '&:hover': { bgcolor: `${hoverBg} !important` },
                    }}>
                        {/* Entry header */}
                        <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, py: '6px', fontSize: 13 }}>
                            <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
                                <strong>{entry.id}</strong>
                                <Box component="span" sx={{ opacity: 0.5, fontSize: 12 }}>{showTimestamp(entry.id)}</Box>
                            </Box>
                            <Box sx={{ whiteSpace: 'nowrap' }}>
                                <Tooltip title={strings?.intention?.copy}><ContentCopy sx={iconSx('secondary.main')} onClick={() => copyEntry(entry)} /></Tooltip>
                                <Tooltip title={strings?.intention?.downloadBuffer}><Download sx={iconSx('secondary.main')} onClick={() => downloadEntry(entry)} /></Tooltip>
                                <Tooltip title={strings?.intention?.jsonViewShow}><TableChart sx={iconSx('secondary.main')} onClick={() => viewEntryJson(entry)} /></Tooltip>
                                {!isReadonly && (
                                    <Tooltip title={strings?.intention?.delete}><Delete sx={iconSx('error.main')} onClick={() => deleteStreamTimestamp(entry.id)} /></Tooltip>
                                )}
                            </Box>
                        </Box>

                        {/* Entry fields */}
                        <Box sx={{ px: 2, pb: 1, overflow: 'auto', maxHeight: 300 }}>
                            {entry.fields.map(([field, val], fi) => (
                                <Box key={`${field}-${fi}`} sx={{ display: 'flex', gap: 1, py: '2px' }}>
                                    <Box component="span" sx={{ minWidth: 120, opacity: 0.7 }}>{field}</Box>
                                    <Box component="span" sx={{
                                        flex: 1, maxHeight: 200, overflow: 'auto',
                                        whiteSpace: 'pre-wrap', wordBreak: 'break-all',
                                        fontFamily: "'Roboto Mono', monospace",
                                    }}>
                                        {formatValue(truncateDisplay(val), valueFormat)}
                                        {isTruncated(val) && <span style={{ opacity: 0.5 }}>...</span>}
                                    </Box>
                                </Box>
                            ))}
                        </Box>
                    </Box>
                ))}
            </Box>

            <KeyNewOrSetDialog open={editDialogOpen} data={editDialogData} onClose={handleEditClose} />
            <JsonViewDialog open={jsonViewOpen} value={jsonViewValue} onClose={() => setJsonViewOpen(false)} />
        </Box>
    )
}