/** * 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 = { '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() 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([]) const [paging, setPaging] = useState(() => createPaging(0)) const [pagedEntries, setPagedEntries] = useState([]) const [editDialogOpen, setEditDialogOpen] = useState(false) const [editDialogData, setEditDialogData] = useState(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 ( {/* Header */} {strings?.page?.key?.stream?.table?.timestamp ?? 'Timestamp ID'} {!isReadonly && ( )} {/* Entries */} {pagedEntries.map((entry, i) => ( {/* Entry header */} {entry.id} {showTimestamp(entry.id)} copyEntry(entry)} /> downloadEntry(entry)} /> viewEntryJson(entry)} /> {!isReadonly && ( deleteStreamTimestamp(entry.id)} /> )} {/* Entry fields */} {entry.fields.map(([field, val], fi) => ( {field} {formatValue(truncateDisplay(val), valueFormat)} {isTruncated(val) && ...} ))} ))} setJsonViewOpen(false)} /> ) }