diff --git a/frontend/packages/ui/src/components/Select/index.tsx b/frontend/packages/ui/src/components/Select/index.tsx index f75268a28e9..8958a5ab098 100644 --- a/frontend/packages/ui/src/components/Select/index.tsx +++ b/frontend/packages/ui/src/components/Select/index.tsx @@ -54,7 +54,16 @@ const MySelect = ( } }); - const activeMenu = useMemo(() => list.find((item) => item.value === value), [list, value]); + const activeMenu = useMemo(() => { + const foundItem = list.find((item) => item.value === value); + if (!foundItem && value) { + return { + label: value, + value: value + }; + } + return foundItem; + }, [list, value]); return ( diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 826cb7e235c..8105e8d1e89 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -606,6 +606,9 @@ importers: '@tanstack/react-query': specifier: ^4.35.3 version: 4.36.1(react-dom@18.2.0)(react@18.2.0) + '@tanstack/react-table': + specifier: ^8.10.7 + version: 8.10.7(react-dom@18.2.0)(react@18.2.0) ansi_up: specifier: ^5.2.1 version: 5.2.1 @@ -615,6 +618,9 @@ importers: base64-stream: specifier: ^1.0.0 version: 1.0.0 + date-fns: + specifier: ^2.30.0 + version: 2.30.0 dayjs: specifier: ^1.11.10 version: 1.11.10 @@ -672,6 +678,9 @@ importers: react: specifier: 18.2.0 version: 18.2.0 + react-day-picker: + specifier: ^8.8.2 + version: 8.9.1(date-fns@2.30.0)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -18093,6 +18102,7 @@ packages: /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + requiresBuild: true dependencies: yallist: 4.0.0 diff --git a/frontend/providers/applaunchpad/data/config.yaml b/frontend/providers/applaunchpad/data/config.yaml index 998a3f0d05e..c9174559f93 100644 --- a/frontend/providers/applaunchpad/data/config.yaml +++ b/frontend/providers/applaunchpad/data/config.yaml @@ -19,6 +19,8 @@ launchpad: url: http://launchpad-monitor.sealos.svc.cluster.local:8428 billing: url: "http://account-service.account-system.svc:2333" + log: + url: "http://service-vlogs.sealos.svc.cluster.local:8428" appResourceFormSliderConfig: default: cpu: [100, 200, 500, 1000, 2000, 3000, 4000, 8000] diff --git a/frontend/providers/applaunchpad/package.json b/frontend/providers/applaunchpad/package.json index 761f2dcabe9..553a4f24d5a 100644 --- a/frontend/providers/applaunchpad/package.json +++ b/frontend/providers/applaunchpad/package.json @@ -23,9 +23,11 @@ "@sealos/driver": "workspace:^", "@sealos/ui": "workspace:^", "@tanstack/react-query": "^4.35.3", + "@tanstack/react-table": "^8.10.7", "ansi_up": "^5.2.1", "axios": "^1.5.1", "base64-stream": "^1.0.0", + "date-fns": "^2.30.0", "dayjs": "^1.11.10", "decimal.js": "^10.4.3", "dns": "^0.2.2", @@ -45,6 +47,7 @@ "nprogress": "^0.2.0", "prettier": "^2.8.8", "react": "18.2.0", + "react-day-picker": "^8.8.2", "react-dom": "18.2.0", "react-hook-form": "^7.46.2", "react-i18next": "^14.1.2", diff --git a/frontend/providers/applaunchpad/public/locales/en/common.json b/frontend/providers/applaunchpad/public/locales/en/common.json index 0062715f00c..dc5a9fdd511 100644 --- a/frontend/providers/applaunchpad/public/locales/en/common.json +++ b/frontend/providers/applaunchpad/public/locales/en/common.json @@ -51,8 +51,8 @@ "ConfigMap Path Conflict": "ConfigMap Path Conflict", "ConfigMap Tip": "ConfigMap", "Configurable number of instances or automatic horizontal scaling": "Configurable replica count and auto horizontal scaling", - "Configuration File": "Configmap", - "Confirm": "Yes", + "Configuration File": "Configmaps", + "Confirm": "Confirm", "Confirm deletion": "Yes", "Confirm Deploy Application?": "Are you sure you want to deploy the application?", "Confirm to restart this application?": "Are you sure you want to update the application?", @@ -86,7 +86,7 @@ "Edit Env Variable": "Edit Environment Variables", "Edit Environment Variables": "Edit Environment Variables", "Env Placeholder": "one per line, key and value separated by colon or equals sign, e.g.:\nmongoUrl=127.0.0.1:8000\nredisUrl:127.0.0.0:8001\n-env1 =test", - "Environment Variables": "Environment", + "Environment Variables": "Environment Variables", "Export": "Export", "Export Domain": "Assigned Domain", "file": "File", @@ -163,7 +163,7 @@ "please enter app name": "please enter: {{appName}}", "Pod": "Pod", "Pod Name": "Pod Name", - "Pods List": "Pods List", + "Pods List": "Pod List", "Port": "Port", "private": "private", "Private": "Private", @@ -197,7 +197,7 @@ "Stateless": "Stateless", "Status": "Status", "storage": "Storage", - "Storage": "Storage", + "Storage": "Mounted Volumes", "Storage path can not empty": "Storage mount path is required", "Storage Range": "Storage Range", "Storage Value can not empty": "Storage size is required", @@ -257,7 +257,7 @@ "total_price_tip": "The estimated cost does not include port fees and traffic fees, and is subject to actual usage.", "nodeports": "NodePorts", "streaming_logs": "Streaming logs", - "within_5_minutes": "Within 5 minutes", + "within_5_minute": "Within 5 minute", "within_1_hour": "Within 1 hour", "within_1_day": "Within 1 day", "terminated_logs": "Terminated logs", @@ -277,5 +277,45 @@ "storage_path_placeholder": "For Example: /data" }, "guide_deploy_button": "Complete creation", - "shared": "Shared" + "filter": "Filter", + "start": "Start", + "end": "End", + "time_zone": "Time Zone", + "recently": "Last", + "minute": "minutes", + "day": "days", + "hour": "hours", + "time": "Range", + "log_number": "Log Number", + "close": "Off", + "normal_filter": "Normal", + "advanced_filter": "Advance", + "json_mode": "JSON Mode", + "only_stderr": "Stderr Only", + "keyword": "Keywords", + "search": "Search", + "equal": "equal", + "greater_than": "Greater than", + "less_than": "Less than", + "field_name": "Select Field", + "value": "Enter Value", + "logNumber": "Log Counts", + "overview": "Overview", + "monitor": "Monitors", + "logs": "Logs", + "application_source": "Source", + "contains": "contains", + "not_contains": "not contains", + "visible": "Visable", + "hidden": "Hidden", + "export_log": "Export", + "no_data_available": "No data available", + "all": "All", + "hour-singular": "hour", + "not_equal": "not", + "field_settings": "Field Setting", + "selected": "Selected", + "please_select": "Please Select", + "refetching_success": "Refresh Successful", + "refresh": "refresh" } diff --git a/frontend/providers/applaunchpad/public/locales/zh/common.json b/frontend/providers/applaunchpad/public/locales/zh/common.json index 95df44ea1ac..107bf9a5b46 100644 --- a/frontend/providers/applaunchpad/public/locales/zh/common.json +++ b/frontend/providers/applaunchpad/public/locales/zh/common.json @@ -30,7 +30,7 @@ "Auto scaling": "弹性伸缩", "Balance": "余额", "Basic Config": "基础配置", - "Basic Information": "基本信息", + "Basic Information": "基础信息", "Can help you deploy any Docker image": "丰富的镜像仓库,支持任意 Docker 镜像", "Can not change storage path": "不允许修改挂载路径", "Cancel": "取消", @@ -162,8 +162,8 @@ "Please enter": "请输入", "please enter app name": "请输入:{{appName}}", "Pod": "实例", - "Pod Name": "实例名", - "Pods List": "实例列表", + "Pod Name": "Pod 名称", + "Pods List": "Pod 列表", "Port": "端口", "private": "私有", "Private": "私有", @@ -257,7 +257,7 @@ "total_price_tip": "预估费用不包括端口费用和流量费用,以实际使用为准", "nodeports": "外网端口", "streaming_logs": "实时日志", - "within_5_minutes": "五分钟内", + "within_5_minute": "五分钟内", "within_1_hour": "一小时内", "within_1_day": "一天内", "terminated_logs": "中断前", @@ -278,5 +278,44 @@ "storage_path_placeholder": "如:/data" }, "guide_deploy_button": "完成创建", - "shared": "共享" + "filter": "筛选", + "start": "开始", + "end": "结束", + "time_zone": "时区", + "recently": "最近", + "minute": "分钟", + "hour": "小时", + "day": "天", + "time": "时间", + "log_number": "日志数", + "close": "关闭", + "normal_filter": "普通筛选", + "advanced_filter": "高级筛选", + "json_mode": "JSON模式", + "only_stderr": "只看 Stderr", + "keyword": "关键词", + "search": "查询", + "field_name": "字段名", + "equal": "等于", + "value": "值", + "logNumber": "日志数量", + "overview": "概览", + "monitor": "监控", + "logs": "日志", + "application_source": "来源", + "contains": "包含", + "not_contains": "不包含", + "visible": "可见", + "hidden": "隐藏", + "piece": "个", + "export_log": "导出日志", + "no_data_available": "暂无数据", + "all": "全部", + "hour-singular": "小时", + "not_equal": "不等于", + "field_settings": "字段设置", + "selected": "已选中", + "please_select": "请选择", + "refetching_success": "刷新成功", + "refresh": "刷新" } diff --git a/frontend/providers/applaunchpad/src/api/app.ts b/frontend/providers/applaunchpad/src/api/app.ts index b18c98e2d7c..10246498165 100644 --- a/frontend/providers/applaunchpad/src/api/app.ts +++ b/frontend/providers/applaunchpad/src/api/app.ts @@ -9,6 +9,8 @@ import { } from '@/utils/adapt'; import type { AppPatchPropsType, PodDetailType } from '@/types/app'; import { MonitorDataResult, MonitorQueryKey } from '@/types/monitor'; +import { LogQueryPayload } from '@/pages/api/log/queryLogs'; +import { PodListQueryPayload } from '@/pages/api/log/queryPodList'; export const postDeployApp = (yamlList: string[]) => POST('/api/applyApp', { yamlList }); @@ -58,4 +60,11 @@ export const getAppMonitorData = (payload: { queryName: string; queryKey: keyof MonitorQueryKey; step: string; + start?: number; + end?: number; }) => GET(`/api/monitor/getMonitorData`, payload); + +export const getAppLogs = (payload: LogQueryPayload) => POST('/api/log/queryLogs', payload); + +export const getLogPodList = (payload: PodListQueryPayload) => + POST('/api/log/queryPodList', payload); diff --git a/frontend/providers/applaunchpad/src/components/AdvancedSelect/index.tsx b/frontend/providers/applaunchpad/src/components/AdvancedSelect/index.tsx new file mode 100644 index 00000000000..e90f931ad73 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/AdvancedSelect/index.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { + Menu, + Box, + MenuList, + MenuItem, + Button, + useDisclosure, + useOutsideClick, + MenuButton, + Flex, + Checkbox +} from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { ChevronDownIcon } from '@chakra-ui/icons'; +import React, { useRef, forwardRef, useMemo } from 'react'; +import type { BoxProps, ButtonProps } from '@chakra-ui/react'; + +export interface ListItem { + label: string | React.ReactNode; + value: string; + checked: boolean; +} + +interface Props extends ButtonProps { + width?: string; + height?: string; + value?: string; + placeholder?: string; + list: ListItem[]; + onchange?: (val: string) => void; + onCheckboxChange?: (list: ListItem[]) => void; + isInvalid?: boolean; + boxStyle?: BoxProps; + checkBoxMode?: boolean; +} + +const AdvancedSelect = ( + { + placeholder, + leftIcon, + value, + width = 'auto', + height = '30px', + list, + onchange, + onCheckboxChange, + isInvalid, + boxStyle, + checkBoxMode = false, + ...props + }: Props, + selectRef: any +) => { + const { t } = useTranslation(); + + const ref = useRef(null); + const SelectRef = useRef(null); + const { isOpen, onOpen, onClose } = useDisclosure(); + + useOutsideClick({ + ref: SelectRef, + handler: () => { + onClose(); + } + }); + + const displayText = useMemo(() => { + const selectedCount = checkBoxMode ? list.filter((item) => item.checked).length : 0; + const activeMenu = list.find((item) => item.value === value); + + if (!checkBoxMode) { + return activeMenu ? activeMenu.label : placeholder; + } + if (selectedCount === 0) { + return placeholder; + } + if (selectedCount === list.length) { + return t('all'); + } + return `${t('selected')} ${selectedCount}`; + }, [checkBoxMode, list, t, value, placeholder]); + + return ( + + + } + width={width} + height={height} + ref={ref} + display={'flex'} + alignItems={'center'} + justifyContent={'center'} + border={'1px solid #E8EBF0'} + borderRadius={'md'} + fontSize={'12px'} + fontWeight={'400'} + color={'grayModern.900'} + variant={'outline'} + _hover={{ + borderColor: 'brightBlue.300', + bg: 'grayModern.50' + }} + _active={{ + transform: '' + }} + boxShadow={'none'} + {...(isOpen + ? { + // boxShadow: '0px 0px 0px 2.4px rgba(33, 155, 244, 0.15)', + borderColor: 'brightBlue.500', + bg: '#FFF' + } + : { + bg: '#F7F8FA', + borderColor: isInvalid ? 'red' : '' + })} + onClick={() => { + isOpen ? onClose() : onOpen(); + }} + {...props} + > + + {displayText} + + + + { + const w = ref.current?.clientWidth; + if (w) { + return `${w}px !important`; + } + return Array.isArray(width) + ? width.map((item) => `${item} !important`) + : `${width} !important`; + })()} + p={'6px'} + borderRadius={'base'} + border={'1px solid #E8EBF0'} + boxShadow={ + '0px 4px 10px 0px rgba(19, 51, 107, 0.10), 0px 0px 1px 0px rgba(19, 51, 107, 0.10)' + } + zIndex={99} + overflow={'overlay'} + maxH={'300px'} + > + {checkBoxMode && ( + + item.checked)} + onChange={() => { + if (onCheckboxChange) { + const newList = list.map((item) => ({ + ...item, + checked: !list.every((item) => item.checked) + })); + onCheckboxChange(newList); + } + }} + sx={{ + 'span.chakra-checkbox__control[data-checked]': { + background: '#f0f4ff', + border: '1px solid #219bf4 ', + boxShadow: '0px 0px 0px 2.4px rgba(33, 155, 244, 0.15)', + color: '#219bf4', + borderRadius: '4px' + }, + 'span.chakra-checkbox__control': { + background: 'white', + border: '1px solid #E8EBF0', + borderRadius: '4px' + } + }} + > + {t('all')} + + + )} + + {list.map((item, index) => ( + { + if (onchange && value !== item.value) { + onchange(item.value); + } + }} + > + {checkBoxMode ? ( + { + if (onCheckboxChange) { + const newList = list.map((listItem) => + listItem.value === item.value + ? { ...listItem, checked: !listItem.checked } + : listItem + ); + onCheckboxChange(newList); + } + }} + sx={{ + 'span.chakra-checkbox__control[data-checked]': { + background: '#f0f4ff ', + border: '1px solid #219bf4 ', + boxShadow: '0px 0px 0px 2.4px rgba(33, 155, 244, 0.15)', + color: '#219bf4', + borderRadius: '4px' + }, + 'span.chakra-checkbox__control': { + background: 'white', + border: '1px solid #E8EBF0', + borderRadius: '4px' + } + }} + > + {item.label} + + ) : ( + {item.label} + )} + + ))} + + + + ); +}; + +export default forwardRef(AdvancedSelect); diff --git a/frontend/providers/applaunchpad/src/components/BaseTable/SwitchPage.tsx b/frontend/providers/applaunchpad/src/components/BaseTable/SwitchPage.tsx new file mode 100644 index 00000000000..fe1c44dd4e2 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/BaseTable/SwitchPage.tsx @@ -0,0 +1,158 @@ +import { Button, ButtonProps, Flex, FlexProps, Text } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; + +import { Icon, IconProps } from '@chakra-ui/react'; + +export function ToLeftIcon(props: IconProps) { + return ( + + + + ); +} + +export function RightFirstIcon(props: IconProps) { + return ( + + + + ); +} + +export function SwitchPage({ + totalPage, + totalItem, + pageSize, + currentPage, + setCurrentPage, + isPreviousData, + ...props +}: { + currentPage: number; + totalPage: number; + totalItem: number; + pageSize: number; + isPreviousData?: boolean; + setCurrentPage: (idx: number) => void; +} & FlexProps) { + const { t } = useTranslation(); + const switchStyle: ButtonProps = { + width: '24px', + height: '24px', + minW: '0', + background: 'grayModern.250', + flexGrow: '0', + borderRadius: 'full', + // variant:'unstyled', + _hover: { + background: 'grayModern.150', + minW: '0' + }, + _disabled: { + borderRadius: 'full', + background: 'grayModern.150', + cursor: 'not-allowed', + minW: '0' + } + }; + return ( + + + {t('Total')}: + + + {totalItem} + + + + + {currentPage} + / + {totalPage} + + + + + {pageSize} + + + /{t('Page')} + + + ); +} diff --git a/frontend/providers/applaunchpad/src/components/BaseTable/index.tsx b/frontend/providers/applaunchpad/src/components/BaseTable/index.tsx new file mode 100644 index 00000000000..fc867a16879 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/BaseTable/index.tsx @@ -0,0 +1,120 @@ +import { + HTMLChakraProps, + Spinner, + Table, + TableContainer, + TableContainerProps, + Tbody, + Td, + Th, + Thead, + Tr +} from '@chakra-ui/react'; +import { Column, Table as ReactTable, flexRender } from '@tanstack/react-table'; +import { CSSProperties } from 'react'; + +const getCommonPinningStyles = (column: Column): CSSProperties => { + const isPinned = column.getIsPinned(); + + return { + position: isPinned ? 'sticky' : 'relative', + left: isPinned === 'left' ? 0 : undefined, + right: isPinned === 'right' ? 0 : undefined, + zIndex: isPinned ? 10 : 0 + }; +}; + +export function BaseTable({ + table, + isLoading, + tdStyle, + isHeaderFixed = false, + ...props +}: { + table: ReactTable; + isLoading: boolean; + tdStyle?: HTMLChakraProps<'td'>; + isHeaderFixed?: boolean; +} & TableContainerProps) { + return ( + + + + {table.getHeaderGroups().map((headers) => { + return ( + + {headers.headers.map((header, i) => { + return ( + + ); + })} + + ); + })} + + + {isLoading ? ( + + + + ) : ( + table.getRowModel().rows.map((item, index) => { + return ( + + {item.getAllCells().map((cell, i) => { + const isPinned = cell.column.getIsPinned(); + return ( + + ); + })} + + ); + }) + )} + +
)} + > + {flexRender(header.column.columnDef.header, header.getContext())} +
+ +
)} + {...tdStyle} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); +} diff --git a/frontend/providers/applaunchpad/src/components/DatePicker/index.tsx b/frontend/providers/applaunchpad/src/components/DatePicker/index.tsx new file mode 100644 index 00000000000..746c60dcc6c --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/DatePicker/index.tsx @@ -0,0 +1,485 @@ +'use client'; + +import { + Button, + ButtonGroup, + Divider, + Flex, + FlexProps, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Text, + useDisclosure +} from '@chakra-ui/react'; +import { endOfDay, format, isAfter, isBefore, isMatch, isValid, parse, startOfDay } from 'date-fns'; +import { enUS, zhCN } from 'date-fns/locale'; +import { useTranslation } from 'next-i18next'; +import { ChangeEventHandler, useMemo, useState } from 'react'; +import { DateRange, DayPicker, SelectRangeEventHandler } from 'react-day-picker'; +import useDateTimeStore from '@/store/date'; +import { formatTimeRange, parseTimeRange } from '@/utils/timeRange'; +import { MySelect } from '@sealos/ui'; +import MyIcon from '../Icon'; + +interface DatePickerProps extends FlexProps { + isDisabled?: boolean; +} + +interface RecentDate { + label: string; + value: DateRange; + compareValue: string; +} + +const DatePicker = ({ isDisabled = false, ...props }: DatePickerProps) => { + const { t, i18n } = useTranslation(); + const currentLang = i18n.language; + const { isOpen, onClose, onOpen } = useDisclosure(); + + const { startDateTime, endDateTime, setStartDateTime, setEndDateTime, timeZone, setTimeZone } = + useDateTimeStore(); + + const initState = { + from: startDateTime, + to: endDateTime + }; + + const recentDateList = useMemo( + () => [ + { + label: `${t('recently')} 5 ${t('minute')}`, + value: getDateRange('5m'), + compareValue: '5m' + }, + { + label: `${t('recently')} 15 ${t('minute')}`, + value: getDateRange('15m'), + compareValue: '15m' + }, + { + label: `${t('recently')} 30 ${t('minute')}`, + value: getDateRange('30m'), + compareValue: '30m' + }, + { + label: `${t('recently')} 1 ${t('hour-singular')}`, + value: getDateRange('1h'), + compareValue: '1h' + }, + { + label: `${t('recently')} 3 ${t('hour')}`, + value: getDateRange('3h'), + compareValue: '3h' + }, + { + label: `${t('recently')} 6 ${t('hour')}`, + value: getDateRange('6h'), + compareValue: '6h' + }, + { + label: `${t('recently')} 24 ${t('hour')}`, + value: getDateRange('24h'), + compareValue: '24h' + }, + { + label: `${t('recently')} 2 ${t('day')}`, + value: getDateRange('2d'), + compareValue: '2d' + }, + { + label: `${t('recently')} 3 ${t('day')}`, + value: getDateRange('3d'), + compareValue: '3d' + }, + { + label: `${t('recently')} 7 ${t('day')}`, + value: getDateRange('7d'), + compareValue: '7d' + } + ], + [t] + ); + + const defaultRecentDate = useMemo(() => { + const currentTimeRange = formatTimeRange(startDateTime, endDateTime); + return ( + recentDateList.find((item) => item.compareValue === currentTimeRange) || + recentDateList.find((item) => item.compareValue === '30m') || + recentDateList[0] + ); + }, [startDateTime, endDateTime, recentDateList]); + + const [inputState, setInputState] = useState<0 | 1>(0); + const [recentDate, setRecentDate] = useState(defaultRecentDate); + + const [fromDateString, setFromDateString] = useState(format(initState.from, 'y-MM-dd')); + const [toDateString, setToDateString] = useState(format(initState.to, 'y-MM-dd')); + const [fromTimeString, setFromTimeString] = useState(format(initState.from, 'HH:mm:ss')); + const [toTimeString, setToTimeString] = useState(format(initState.to, 'HH:mm:ss')); + + const [fromDateError, setFromDateError] = useState(null); + const [toDateError, setToDateError] = useState(null); + const [fromTimeError, setFromTimeError] = useState(null); + const [toTimeError, setToTimeError] = useState(null); + const [fromDateShake, setFromDateShake] = useState(false); + const [toDateShake, setToDateShake] = useState(false); + const [fromTimeShake, setFromTimeShake] = useState(false); + const [toTimeShake, setToTimeShake] = useState(false); + + const [selectedRange, setSelectedRange] = useState(initState); + + const onSubmit = () => { + if (fromDateError || fromTimeError || toDateError || toTimeError) { + if (fromDateError) setFromDateShake(true); + if (toDateError) setToDateShake(true); + if (fromTimeError) setFromTimeShake(true); + if (toTimeError) setToTimeShake(true); + setTimeout(() => { + setFromDateShake(false); + setToDateShake(false); + setFromTimeShake(false); + setToTimeShake(false); + }, 300); + + return; + } + selectedRange?.from && setStartDateTime(selectedRange.from); + selectedRange?.to && setEndDateTime(selectedRange.to); + onClose(); + }; + + const handleFromChange = (value: string, type: 'date' | 'time') => { + let newDateTimeString; + + if (type === 'date') { + setFromDateString(value); + if (!isMatch(value, 'y-MM-dd')) { + setFromDateError('Invalid date format'); + return; + } + setFromDateError(null); + newDateTimeString = `${value} ${fromTimeString}`; + } else { + setFromTimeString(value); + if (!isMatch(value, 'HH:mm:ss')) { + setFromTimeError('Invalid time format'); + return; + } + setFromTimeError(null); + newDateTimeString = `${fromDateString} ${value}`; + } + + console.log(newDateTimeString); + + const date = parse(newDateTimeString, 'y-MM-dd HH:mm:ss', new Date()); + + if (!isValid(date)) { + return setSelectedRange({ from: undefined, to: selectedRange?.to }); + } + + if (selectedRange?.to) { + if (isAfter(date, selectedRange.to)) { + setSelectedRange({ from: selectedRange.to, to: date }); + } else { + setSelectedRange({ from: date, to: selectedRange?.to }); + } + } else { + setSelectedRange({ from: date, to: date }); + } + }; + + const handleToChange = (value: string, type: 'date' | 'time') => { + let newDateTimeString; + + if (type === 'date') { + setToDateString(value); + if (!isMatch(value, 'y-MM-dd')) { + setToDateError('Invalid date format'); + return; + } + setToDateError(null); + newDateTimeString = `${value} ${toTimeString}`; + } else { + setToTimeString(value); + if (!isMatch(value, 'HH:mm:ss')) { + setToTimeError('Invalid time format'); + return; + } + setToTimeError(null); + newDateTimeString = `${toDateString} ${value}`; + } + + const date = parse(newDateTimeString, 'y-MM-dd HH:mm:ss', new Date()); + + if (!isValid(date)) { + return setSelectedRange({ from: selectedRange?.from, to: undefined }); + } + if (selectedRange?.from) { + if (isBefore(date, selectedRange.from)) { + setSelectedRange({ from: date, to: selectedRange.from }); + } else { + setSelectedRange({ from: selectedRange?.from, to: date }); + } + } else { + setSelectedRange({ from: date, to: date }); + } + }; + + const handleRangeSelect: SelectRangeEventHandler = (range: DateRange | undefined) => { + if (range) { + let { from, to } = range; + if (inputState === 0) { + // from + if (from === selectedRange?.from) { + // when 'to' is changed + from = to; + } else { + to = from; + } + setInputState(1); + } else { + setInputState(0); + } + setSelectedRange({ + from, + to + }); + if (from) { + setFromDateString(format(startOfDay(from), 'y-MM-dd')); + setFromTimeString(format(startOfDay(from), 'HH:mm:ss')); + } else { + setFromDateString(format(new Date(), 'y-MM-dd')); + setFromTimeString(format(new Date(), 'HH:mm:ss')); + } + if (to) { + setToDateString(format(endOfDay(to), 'y-MM-dd')); + setToTimeString(format(endOfDay(to), 'HH:mm:ss')); + } else { + setToDateString(format(from ? from : new Date(), 'y-MM-dd')); + setToTimeString(format(from ? from : new Date(), 'HH:mm:ss')); + } + } else { + // default is cancel + if (fromDateString && fromTimeString && selectedRange?.from) { + setToDateString(fromDateString); + setToTimeString(fromTimeString); + setSelectedRange({ + ...selectedRange, + to: selectedRange.from + }); + setInputState(1); + } + } + }; + + const handleRecentDateClick = (item: RecentDate) => { + setFromDateError(null); + setFromTimeError(null); + setToDateError(null); + setToTimeError(null); + + setRecentDate(item); + setSelectedRange(item.value); + if (item.value.from) { + setFromDateString(format(item.value.from, 'y-MM-dd')); + setFromTimeString(format(item.value.from, 'HH:mm:ss')); + } + if (item.value.to) { + setToDateString(format(item.value.to, 'y-MM-dd')); + setToTimeString(format(item.value.to, 'HH:mm:ss')); + } + }; + + return ( + + + + + {format(startDateTime, 'y-MM-dd HH:mm:ss')} + + {format(endDateTime, 'y-MM-dd HH:mm:ss')} + + + + + + + + + + + {t('start')} + + + handleFromChange(e.target.value, 'date')} + error={!!fromDateError} + showError={fromDateShake} + /> + handleFromChange(e.target.value, 'time')} + error={!!fromTimeError} + showError={fromTimeShake} + /> + + + + + + {t('end')} + + + handleToChange(e.target.value, 'date')} + error={!!toDateError} + showError={toDateShake} + /> + handleToChange(e.target.value, 'time')} + error={!!toTimeError} + showError={toTimeShake} + /> + + + + + + + {recentDateList.map((item) => ( + + ))} + + + + + + setTimeZone(val)} + /> + + + + + + + + + + ); +}; + +interface DatePickerInputProps { + value: string; + onChange: ChangeEventHandler | undefined; + error: boolean; + showError: boolean; +} + +const DatePickerInput = ({ value, onChange, error, showError }: DatePickerInputProps) => { + return ( + + ); +}; + +const getDateRange = (value: string): DateRange => { + const { startTime: from, endTime: to } = parseTimeRange(value); + return { from, to }; +}; + +export default DatePicker; diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/arrowLeft.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/arrowLeft.svg index 9e246b28759..045c63cbce5 100644 --- a/frontend/providers/applaunchpad/src/components/Icon/icons/arrowLeft.svg +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/arrowLeft.svg @@ -1,10 +1,3 @@ - - - - - - - - - + + \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/arrowRight.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/arrowRight.svg new file mode 100644 index 00000000000..80e943436d2 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/arrowRight.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/calendar.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/calendar.svg new file mode 100644 index 00000000000..f864a731f26 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/chart.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/chart.svg new file mode 100644 index 00000000000..c6902b1966c --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/chart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/configMap.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/configMap.svg index f027353e49b..88d5f534019 100644 --- a/frontend/providers/applaunchpad/src/components/Icon/icons/configMap.svg +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/configMap.svg @@ -1 +1,11 @@ - \ No newline at end of file + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/container.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/container.svg new file mode 100644 index 00000000000..c15441ffd1c --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/container.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/copy.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/copy.svg index bfd38df8ede..adc04ee6850 100644 --- a/frontend/providers/applaunchpad/src/components/Icon/icons/copy.svg +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/copy.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/emptyChart.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/emptyChart.svg new file mode 100644 index 00000000000..af039d00ace --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/emptyChart.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/export.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/export.svg new file mode 100644 index 00000000000..6fbd16dbd6a --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/export.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/log.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/log.svg index 798a41665eb..5257fb78e83 100644 --- a/frontend/providers/applaunchpad/src/components/Icon/icons/log.svg +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/log.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/monitor.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/monitor.svg new file mode 100644 index 00000000000..c81668789e4 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/monitor.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/refresh.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/refresh.svg new file mode 100644 index 00000000000..302e566a1f5 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/refresh.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/store.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/store.svg index 7e43075a539..d212d3c1169 100644 --- a/frontend/providers/applaunchpad/src/components/Icon/icons/store.svg +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/store.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + \ No newline at end of file diff --git a/frontend/providers/applaunchpad/src/components/Icon/icons/to.svg b/frontend/providers/applaunchpad/src/components/Icon/icons/to.svg new file mode 100644 index 00000000000..af8c21bdf83 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Icon/icons/to.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/providers/applaunchpad/src/components/Icon/index.tsx b/frontend/providers/applaunchpad/src/components/Icon/index.tsx index bf1f410e152..b4e2ceb9fe0 100644 --- a/frontend/providers/applaunchpad/src/components/Icon/index.tsx +++ b/frontend/providers/applaunchpad/src/components/Icon/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { IconProps } from '@chakra-ui/react'; import { Icon } from '@chakra-ui/react'; -const map = { +export const IconMap = { more: require('./icons/more.svg').default, store: require('./icons/store.svg').default, configMap: require('./icons/configMap.svg').default, @@ -51,20 +51,35 @@ const map = { upload: require('./icons/upload.svg').default, search: require('./icons/search.svg').default, pods: require('./icons/pods.svg').default, + monitor: require('./icons/monitor.svg').default, hardDrive: require('./icons/hardDrive.svg').default, - download: require('./icons/download.svg').default + download: require('./icons/download.svg').default, + calendar: require('./icons/calendar.svg').default, + to: require('./icons/to.svg').default, + refresh: require('./icons/refresh.svg').default, + container: require('./icons/container.svg').default, + arrowRight: require('./icons/arrowRight.svg').default, + chart: require('./icons/chart.svg').default, + export: require('./icons/export.svg').default }; -export type IconType = keyof typeof map; +export type IconType = keyof typeof IconMap; const MyIcon = ({ name, w = 'auto', h = 'auto', ...props -}: { name: keyof typeof map } & IconProps) => { - return map[name] ? ( - +}: { name: keyof typeof IconMap } & IconProps) => { + return IconMap[name] ? ( + ) : null; }; diff --git a/frontend/providers/applaunchpad/src/components/LangSelect/index.tsx b/frontend/providers/applaunchpad/src/components/LangSelect/index.tsx index d848a87309f..3e1401a1d1a 100644 --- a/frontend/providers/applaunchpad/src/components/LangSelect/index.tsx +++ b/frontend/providers/applaunchpad/src/components/LangSelect/index.tsx @@ -1,6 +1,6 @@ import { setLangStore } from '@/utils/cookieUtils'; import { Menu, MenuButton, MenuButtonProps, MenuItem, MenuList, Text } from '@chakra-ui/react'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; const langIcon = ( { + const { screenWidth } = useGlobalStore(); + const xData = useMemo( + () => + data?.xData?.map((time) => dayjs(time * 1000).format('MM-DD HH:mm')) || new Array(30).fill(0), + [data?.xData] + ); + const yData = data?.yData || new Array(30).fill(''); + + const Dom = useRef(null); + const myChart = useRef(); + const resizeObserver = useRef(); + + const optionStyle = useMemo( + () => ({ + areaStyle: { + color: map[type].backgroundColor + }, + lineStyle: { + width: '1', + color: map[type].lineColor + }, + itemStyle: { + width: 1.5, + color: map[type].lineColor + } + }), + [type] + ); + + const option = useRef({ + xAxis: { + type: 'category', + data: xData, + boundaryGap: true, + axisLine: { + lineStyle: { + color: '#E8EBF0' + } + }, + axisLabel: { + color: '#667085' + } + }, + yAxis: { + type: 'value', + splitNumber: 3, + splitLine: { + lineStyle: { + type: 'dashed', + color: '#E4E7EC' + } + } + }, + series: [ + { + data: yData, + type: 'bar', + animationDuration: 300, + barWidth: '90%', + ...optionStyle + } + ], + grid: { + left: 0, + right: 0, + bottom: 0, + top: 5, + containLabel: true + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'line' + }, + formatter: (params: any[]) => { + const axisValue = params[0]?.axisValue; + return `${axisValue} ${params[0]?.value || 0}`; + } + } + }); + + // init chart + useEffect(() => { + if (!Dom.current || myChart?.current?.getOption() || !visible) return; + myChart.current = echarts.init(Dom.current); + myChart.current && myChart.current.setOption(option.current); + }, [Dom, visible]); + + // data changed, update + useEffect(() => { + if (!myChart.current || !myChart?.current?.getOption() || !visible) return; + option.current.xAxis.data = xData; + option.current.series[0].data = yData; + myChart.current.setOption(option.current); + }, [xData, yData, visible]); + + // type changed, update + useEffect(() => { + if (!myChart.current || !myChart?.current?.getOption()) return; + option.current.series[0] = { + ...option.current.series[0], + ...optionStyle + }; + myChart.current.setOption(option.current); + }, [optionStyle]); + + // resize chart + useEffect(() => { + if (!myChart.current || !myChart.current.getOption()) return; + myChart.current.resize(); + }, [screenWidth]); + + useEffect(() => { + if (!Dom.current || !visible) return; + + resizeObserver.current = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry?.contentRect && myChart.current) { + if (entry.contentRect.width > 0 && entry.contentRect.height > 0) { + myChart.current.resize(); + } + } + }); + + resizeObserver.current.observe(Dom.current); + + return () => { + resizeObserver.current?.disconnect(); + }; + }, [visible]); + + useEffect(() => { + return () => { + if (myChart.current) { + myChart.current.dispose(); + } + resizeObserver.current?.disconnect(); + }; + }, []); + + return
; +}; + +export default LogBarChart; diff --git a/frontend/providers/applaunchpad/src/components/Monitor/Header.tsx b/frontend/providers/applaunchpad/src/components/Monitor/Header.tsx new file mode 100644 index 00000000000..97ae5ea38cf --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Monitor/Header.tsx @@ -0,0 +1,158 @@ +import MyIcon from '@/components/Icon'; +import { + Box, + Button, + ButtonGroup, + Flex, + Menu, + MenuButton, + MenuItem, + MenuList, + Text +} from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import AdvancedSelect, { ListItem } from '../AdvancedSelect'; +import DynamicTime from './Time'; + +import { REFRESH_INTERVAL_OPTIONS } from '@/constants/monitor'; +import useDateTimeStore from '@/store/date'; +import { ChevronDownIcon } from '@chakra-ui/icons'; +import dynamic from 'next/dynamic'; +import { MyTooltip } from '@sealos/ui'; +const DatePicker = dynamic(() => import('@/components/DatePicker'), { ssr: false }); + +export default function Header({ + podList, + setPodList, + refetchData +}: { + podList: ListItem[]; + setPodList: (val: ListItem[]) => void; + refetchData: () => void; +}) { + const { t } = useTranslation(); + const { refreshInterval, setRefreshInterval } = useDateTimeStore(); + + return ( + + + + {t('monitor')} + + + ({t('Update Time')}   + ) + + + + } + width={'fit-content'} + value={'hello-sql-postgresql-0'} + list={podList} + onCheckboxChange={(val) => { + setPodList(val); + }} + placeholder={t('please_select')} + /> + + + + + + + + + + {refreshInterval === 0 ? null : ( + {`${refreshInterval / 1000}s`} + )} + + + + + {REFRESH_INTERVAL_OPTIONS.map((item) => ( + { + setRefreshInterval(item.value); + }} + {...(refreshInterval === item.value + ? { + color: 'brightBlue.600' + } + : {})} + borderRadius={'4px'} + _hover={{ + bg: 'rgba(17, 24, 36, 0.05)', + color: 'brightBlue.600' + }} + p={'6px'} + > + {item.label} + + ))} + + + + + ); +} diff --git a/frontend/providers/applaunchpad/src/components/Monitor/Time.tsx b/frontend/providers/applaunchpad/src/components/Monitor/Time.tsx new file mode 100644 index 00000000000..f9fee040c00 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/Monitor/Time.tsx @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react'; +import dayjs from 'dayjs'; +import { Box } from '@chakra-ui/react'; + +export default function DynamicTime() { + const [time, setTime] = useState('--:--:--'); + + useEffect(() => { + setTime(dayjs().format('HH:mm:ss')); + const timer = setInterval(() => { + setTime(dayjs().format('HH:mm:ss')); + }, 1000); + + return () => clearInterval(timer); + }, []); + + return {time}; +} diff --git a/frontend/providers/applaunchpad/src/components/MonitorChart/index.module.css b/frontend/providers/applaunchpad/src/components/MonitorChart/index.module.css new file mode 100644 index 00000000000..24a191906a6 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/MonitorChart/index.module.css @@ -0,0 +1,58 @@ +.tooltip { + background: white; + border-radius: 8px; + padding: 16px; + border: 1px solid #e8ebf0; + box-shadow: 0px 24px 48px -12px rgba(19, 51, 107, 0.2), 0px 0px 1px 0px rgba(19, 51, 107, 0.2); +} + +.tooltipHeader { + font-size: 12px; + font-weight: 500; + color: #111824; + margin-bottom: 12px; + border-bottom: 1px solid #eee; + padding-bottom: 12px; +} + +.tooltipItem { + display: flex; + align-items: center; +} + +.tooltipItem:not(:last-child) { + margin-bottom: 12px; +} + +.tooltipDot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 8px; +} + +.tooltipName { + color: #333; + margin-right: 12px; +} + +.tooltipValue { + font-weight: 500; + margin-right: 12px; +} + +.tooltipButton { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + background: #f4f4f7; + color: #485264; + border: none; + padding: 6px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; +} diff --git a/frontend/providers/applaunchpad/src/components/MonitorChart/index.tsx b/frontend/providers/applaunchpad/src/components/MonitorChart/index.tsx new file mode 100644 index 00000000000..da851edfba4 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/MonitorChart/index.tsx @@ -0,0 +1,250 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import * as echarts from 'echarts'; +import { useGlobalStore } from '@/store/global'; +import dayjs from 'dayjs'; +import { LineStyleMap } from '@/constants/monitor'; +import { Flex, FlexProps, Text } from '@chakra-ui/react'; +import MyIcon from '../Icon'; +import { useTranslation } from 'next-i18next'; +import styles from './index.module.css'; + +type MonitorChart = FlexProps & { + data: { + xData: string[]; + yData: { + name: string; + type: string; + data: number[]; + lineStyleType?: string; + }[]; + }; + type?: 'blue' | 'deepBlue' | 'green' | 'purple'; + title: string; + yAxisLabelFormatter?: (value: number) => string; + yDataFormatter?: (values: number[]) => number[]; + unit?: string; + isShowLegend?: boolean; +}; + +const MonitorChart = ({ + type, + data, + title, + yAxisLabelFormatter, + yDataFormatter, + unit, + isShowLegend = true, + ...props +}: MonitorChart) => { + const { screenWidth } = useGlobalStore(); + const chartDom = useRef(null); + const myChart = useRef(); + const { t } = useTranslation(); + + const option = useMemo( + () => ({ + tooltip: { + trigger: 'axis', + enterable: true, + extraCssText: ` + box-shadow: none; + padding: 0; + background-color: transparent; + border: none; + `, + formatter: (params: any) => { + let axisValue = params[0]?.axisValue; + return ` +
+
${axisValue}
+ ${params + .map( + (item: any) => ` +
+ + ${item.seriesName} + ${item.value}${unit || ''} + +
+ ` + ) + .join('')} +
+ `; + }, + + // @ts-ignore + position: (point, params, dom, rect, size) => { + let xPos = point[0]; + let yPos = point[1] + 10; + let chartWidth = size.viewSize[0]; + let chartHeight = size.viewSize[1]; + let tooltipWidth = dom.offsetWidth; + let tooltipHeight = dom.offsetHeight; + + if (xPos + tooltipWidth > chartWidth) { + xPos = xPos - tooltipWidth; + } + + if (xPos < 0) { + xPos = 0; + } + + return [xPos, yPos]; + } + }, + grid: { + left: '4px', + bottom: '4px', + top: '10px', + right: '20px', + containLabel: true + }, + xAxis: { + show: true, + type: 'category', + offset: 4, + boundaryGap: false, + axisLabel: { + interval: (index: number, value: string) => { + const total = data?.xData?.length || 0; + if (index === 0 || index === total - 1) return false; + return index % Math.floor(total / 6) === 0; + }, + textStyle: { + color: '#667085' + }, + hideOverlap: true + }, + axisTick: { + show: false + }, + axisLine: { + show: true, + lineStyle: { + color: '#E4E7EC', + type: 'solid' + } + }, + data: data?.xData?.map((time) => dayjs(parseFloat(time) * 1000).format('MM-DD HH:mm')) + }, + yAxis: { + type: 'value', + splitNumber: 2, + max: 100, + min: 0, + boundaryGap: false, + axisLabel: { + formatter: yAxisLabelFormatter + }, + axisLine: { + show: false + }, + splitLine: { + lineStyle: { + type: 'dashed', + color: '#E4E7EC' + } + } + }, + series: data?.yData?.map((item, index) => { + return { + name: item.name, + data: item.data, + type: 'line', + smooth: true, + showSymbol: false, + animationDuration: 300, + animationEasingUpdate: 'linear', + areaStyle: { + color: LineStyleMap[index % LineStyleMap.length].backgroundColor + }, + lineStyle: { + width: '1', + color: LineStyleMap[index % LineStyleMap.length].lineColor, + type: item?.lineStyleType || 'solid' + }, + itemStyle: { + width: 1.5, + color: LineStyleMap[index % LineStyleMap.length].lineColor + }, + emphasis: { + // highlight + disabled: true + } + }; + }) + }), + [data?.xData, data?.yData] + ); + + useEffect(() => { + if (!chartDom.current) return; + + if (!myChart.current) { + myChart.current = echarts.init(chartDom.current); + } else { + myChart.current.dispose(); + myChart.current = echarts.init(chartDom.current); + } + + myChart.current.setOption(option); + }, [data, option]); + + useEffect(() => { + return () => { + if (myChart.current) { + myChart.current.dispose(); + } + }; + }, []); + + // resize chart + useEffect(() => { + if (!myChart.current || !myChart.current.getOption()) return; + myChart.current.resize(); + }, [screenWidth]); + + return ( + + + {isShowLegend && ( + + {data?.yData?.map((item, index) => ( + + + + {item?.name} + + + ))} + + )} + + ); +}; + +export default MonitorChart; diff --git a/frontend/providers/applaunchpad/src/components/PodLineChart/index.tsx b/frontend/providers/applaunchpad/src/components/PodLineChart/index.tsx index f1de5f0bdb8..17c295603f2 100644 --- a/frontend/providers/applaunchpad/src/components/PodLineChart/index.tsx +++ b/frontend/providers/applaunchpad/src/components/PodLineChart/index.tsx @@ -150,6 +150,9 @@ const PodLineChart = ({ min: 0, axisLabel: { show: isShowLabel + }, + splitLine: { + show: false } }, grid: { diff --git a/frontend/providers/applaunchpad/src/components/app/detail/index/AdvancedInfo.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/AdvancedInfo.tsx new file mode 100644 index 00000000000..45874323c34 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/app/detail/index/AdvancedInfo.tsx @@ -0,0 +1,315 @@ +import MyIcon from '@/components/Icon'; +import { MOCK_APP_DETAIL } from '@/mock/apps'; +import type { AppDetailType } from '@/types/app'; +import { useCopyData } from '@/utils/tools'; +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + Center, + Divider, + Flex, + Text, + useTheme +} from '@chakra-ui/react'; +import { MyTooltip } from '@sealos/ui'; +import { useTranslation } from 'next-i18next'; +import dynamic from 'next/dynamic'; +import React, { useState } from 'react'; +import styles from '@/components/app/detail/index/index.module.scss'; + +const ConfigMapDetailModal = dynamic(() => import('./ConfigMapDetailModal')); + +const AdvancedInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const { copyData } = useCopyData(); + const [detailConfigMap, setDetailConfigMap] = useState<{ + mountPath: string; + value: string; + }>(); + const [isExpanded, setIsExpanded] = useState(false); + + return ( + + setIsExpanded(expandedIndex === 0)}> + + + + {t('Advanced Configuration')} + + + + + {t('Command')}: {app.runCMD || 'Not Configured'} + + + + {t('Environment Variables')}: {app.envs?.length} + + + ConfigMaps: {app.configMapList?.length} + + + {t('Storage')}: {app.storeList?.length} + + + + + + + + + + + + {t('Command')} + + {[ + { label: 'Command', value: app.runCMD || 'Not Configured' }, + { label: 'Parameters', value: app.cmdParam || 'Not Configured' } + ].map((item) => ( + + + {t(item.label)} + + {item.value} + + ))} + + + + {t('Environment Variables')} + + {app.envs?.length > 0 ? ( + + {app.envs.map((env, index) => { + const valText = env.value + ? env.value + : env.valueFrom + ? 'value from | ***' + : ''; + return ( + + + {env.key} + + + copyData(valText)} + > + {valText} + + + + ); + })} + + ) : ( +
+ {t('no_data_available')} +
+ )} +
+
+
+ + + {t('Configuration File')} + + {app.configMapList?.length > 0 ? ( + + {app.configMapList.map((item) => ( + + + + + {item.mountPath} + + + {item.value} + + + + ))} + + ) : ( +
+ {t('no_data_available')} +
+ )} +
+
+ + {t('Storage')} + + {app.storeList?.length > 0 ? ( + + {app.storeList.map((item) => ( + + + + + {item.path} + + + {item.value} Gi + + + + ))} + + ) : ( +
+ {t('no_data_available')} +
+ )} +
+
+
+
+
+
+
+ + {detailConfigMap && ( + setDetailConfigMap(undefined)} /> + )} +
+ ); +}; + +export default React.memo(AdvancedInfo); diff --git a/frontend/providers/applaunchpad/src/components/app/detail/index/AppBaseInfo.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/AppBaseInfo.tsx new file mode 100644 index 00000000000..f7f86921d9a --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/app/detail/index/AppBaseInfo.tsx @@ -0,0 +1,224 @@ +import GPUItem from '@/components/GPUItem'; +import MyIcon from '@/components/Icon'; +import { MOCK_APP_DETAIL } from '@/mock/apps'; +import { useUserStore } from '@/store/user'; +import type { AppDetailType } from '@/types/app'; +import { printMemory, useCopyData } from '@/utils/tools'; +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + Divider, + Flex, + Tag, + Text, + useTheme +} from '@chakra-ui/react'; +import { MyTooltip } from '@sealos/ui'; +import { useTranslation } from 'next-i18next'; +import dynamic from 'next/dynamic'; +import React, { useMemo, useState } from 'react'; +import { sealosApp } from 'sealos-desktop-sdk/app'; + +const ConfigMapDetailModal = dynamic(() => import('./ConfigMapDetailModal')); + +const AppBaseInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const { copyData } = useCopyData(); + const { userSourcePrice } = useUserStore(); + const [detailConfigMap, setDetailConfigMap] = useState<{ + mountPath: string; + value: string; + }>(); + + const appInfoTable = useMemo< + { + name: string; + iconName: string; + items: { + label: string; + value?: string; + copy?: string; + render?: React.ReactNode; + }[]; + }[] + >( + () => [ + { + name: 'Basic Information', + iconName: 'formInfo', + items: [ + { label: 'Creation Time', value: app.createTime }, + { + label: `${t('Image Name')} ${app.secret.use ? '(Private)' : ''}`, + value: app.imageName + }, + { label: 'Limit CPU', value: `${app.cpu / 1000} Core` }, + { + label: 'Limit Memory', + value: printMemory(app.memory) + }, + ...(userSourcePrice?.gpu + ? [ + { + label: 'GPU', + render: + } + ] + : []) + ] + }, + { + name: 'Deployment Mode', + iconName: 'deployMode', + items: app.hpa.use + ? [ + { + label: `${app.hpa.target} ${t('target_value')}`, + value: `${app.hpa.value}${app.hpa.target === 'gpu' ? '' : '%'}` + }, + { + label: 'Number of Instances', + value: `${app.hpa.minReplicas} ~ ${app.hpa.maxReplicas}` + } + ] + : [{ label: `Number of Instances`, value: `${app.replicas}` }] + } + ], + [app] + ); + + const appTags = useMemo( + () => [ + ...(app.networks.find((item) => item.openPublicDomain) ? ['Public Access'] : []), + ...(app.hpa.use ? ['Auto scaling'] : ['Fixed instance']), + ...(app.storeList.length > 0 ? ['Stateful'] : ['Stateless']) + ], + [app] + ); + + const persistentVolumes = useMemo(() => { + return app.volumes + .filter((item) => 'persistentVolumeClaim' in item) + .reduce( + ( + acc: { + path: string; + name: string; + }[], + volume + ) => { + const mount = app.volumeMounts.find((m) => m.name === volume.name); + if (mount) { + acc.push({ + path: mount.mountPath, + name: volume.name + }); + } + return acc; + }, + [] + ); + }, [app.volumes, app.volumeMounts]); + + return ( + + {appInfoTable.map((info, index) => ( + + + {t(info.name)} + + + {app?.source?.hasSource && index === 0 && ( + + { + if (!app?.source?.sourceName) return; + if (app.source.sourceType === 'app_store') { + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-template', + pathname: '/instance', + query: { instanceName: app.source.sourceName } + }); + } + if (app.source.sourceType === 'sealaf') { + sealosApp.runEvents('openDesktopApp', { + appKey: 'system-sealaf', + pathname: '/', + query: { instanceName: app.source.sourceName } + }); + } + }} + > + + {t('application_source')} + + + {t(app.source?.sourceType)} + + {t('Manage all resources')} + + + + + )} + {info.items.map((item, i) => ( + + + {t(item.label)} + + + + item.value && !!item.copy && copyData(item.copy)} + > + {item.render ? item.render : item.value} + + + + + ))} + + {index !== appInfoTable.length - 1 && } + + ))} + + {detailConfigMap && ( + setDetailConfigMap(undefined)} /> + )} + + ); +}; + +export default React.memo(AppBaseInfo); diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/AppMainInfo.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/AppMainInfo.tsx similarity index 75% rename from frontend/providers/applaunchpad/src/pages/app/detail/components/AppMainInfo.tsx rename to frontend/providers/applaunchpad/src/components/app/detail/index/AppMainInfo.tsx index 53efbbae50b..aa9a95778c8 100644 --- a/frontend/providers/applaunchpad/src/pages/app/detail/components/AppMainInfo.tsx +++ b/frontend/providers/applaunchpad/src/components/app/detail/index/AppMainInfo.tsx @@ -8,7 +8,7 @@ import { DOMAIN_PORT } from '@/store/static'; import type { AppDetailType } from '@/types/app'; import { useCopyData } from '@/utils/tools'; import { getUserNamespace } from '@/utils/user'; -import { Box, Button, Center, Flex, Grid, useDisclosure } from '@chakra-ui/react'; +import { Box, Button, Center, Flex, Grid, Text, useDisclosure } from '@chakra-ui/react'; import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; import { useMemo } from 'react'; @@ -35,41 +35,29 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { ); return ( - + <> - - - - {t('Real-time Monitoring')} - - - ({t('Update Time')}  - {dayjs().format('HH:mm')}) + + {t('Real-time Monitoring')} + + ({t('Update Time')} {dayjs().format('HH:mm')}) - CPU ({app.usedCpu.yData[app.usedCpu.yData.length - 1]}%) @@ -85,17 +73,11 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { - - - - {t('Network Configuration')}({networks.length}) - + + {t('Network Configuration')} + + ({networks.length}) + @@ -117,6 +99,7 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { copyData(network.inline)} @@ -133,6 +116,7 @@ const AppMainInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { placement={'bottom-start'} > { {!!network.public && ( - copyData(network.public)} - /> + cursor={'pointer'} + > + copyData(network.public)} + /> + )} diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/ConfigMapDetailModal.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/ConfigMapDetailModal.tsx similarity index 100% rename from frontend/providers/applaunchpad/src/pages/app/detail/components/ConfigMapDetailModal.tsx rename to frontend/providers/applaunchpad/src/components/app/detail/index/ConfigMapDetailModal.tsx diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/DelModal.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/DelModal.tsx similarity index 100% rename from frontend/providers/applaunchpad/src/pages/app/detail/components/DelModal.tsx rename to frontend/providers/applaunchpad/src/components/app/detail/index/DelModal.tsx diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/Header.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/Header.tsx similarity index 85% rename from frontend/providers/applaunchpad/src/pages/app/detail/components/Header.tsx rename to frontend/providers/applaunchpad/src/components/app/detail/index/Header.tsx index a2b9910268a..cb529906062 100644 --- a/frontend/providers/applaunchpad/src/pages/app/detail/components/Header.tsx +++ b/frontend/providers/applaunchpad/src/components/app/detail/index/Header.tsx @@ -112,19 +112,20 @@ const Header = ({ }, [appName, refetch, toast]); return ( - +
router.replace('/apps')}>
- + {appName} - {!isLargeScreen && ( + {/* {!isLargeScreen && ( - )} + )} */} {/* btns */} {isPause ? ( ) : ( )}
@@ -269,6 +261,12 @@ const Pods = ({ fontSize={'12px'} fontWeight={'500'} color={'grayModern.600'} + _first={{ + borderLeftRadius: '6px' + }} + _last={{ + borderRightRadius: '6px' + }} > {t(item.title)} @@ -279,7 +277,7 @@ const Pods = ({ {pods.map((app, i) => ( {columns.map((col) => ( -
+ {col.render ? col.render(app, i) : col.dataIndex @@ -293,7 +291,6 @@ const Pods = ({
- {logsPodIndex !== undefined && ( pod.status.value === PodStatusEnum.running) .map((item, i) => ({ - alias: `${appName}-${i + 1}`, + alias: item.podName, podName: item.podName }))} - podAlias={`${appName}-${logsPodIndex + 1}`} + podAlias={pods[logsPodIndex]?.podName || ''} setLogsPodName={(name: string) => setLogsPodIndex(pods.findIndex((item) => item.podName === name)) } @@ -314,9 +311,9 @@ const Pods = ({ {detailPodIndex !== undefined && ( ({ - alias: `${appName}-${i + 1}`, + alias: item.podName, podName: item.podName }))} setPodDetail={(e: string) => @@ -331,9 +328,9 @@ const Pods = ({ isOpen={isOpenPodFile} onClose={onClosePodFile} pod={pods[detailFilePodIndex]} - podAlias={`${appName}-${detailFilePodIndex + 1}`} + podAlias={pods[detailFilePodIndex]?.podName || ''} pods={pods.map((item, i) => ({ - alias: `${appName}-${i + 1}`, + alias: item.podName, podName: item.podName }))} setPodDetail={(e: string) => diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/UpdateModal.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/UpdateModal.tsx similarity index 100% rename from frontend/providers/applaunchpad/src/pages/app/detail/components/UpdateModal.tsx rename to frontend/providers/applaunchpad/src/components/app/detail/index/UpdateModal.tsx diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/empty.module.scss b/frontend/providers/applaunchpad/src/components/app/detail/index/empty.module.scss similarity index 100% rename from frontend/providers/applaunchpad/src/pages/app/detail/components/empty.module.scss rename to frontend/providers/applaunchpad/src/components/app/detail/index/empty.module.scss diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/empty.tsx b/frontend/providers/applaunchpad/src/components/app/detail/index/empty.tsx similarity index 100% rename from frontend/providers/applaunchpad/src/pages/app/detail/components/empty.tsx rename to frontend/providers/applaunchpad/src/components/app/detail/index/empty.tsx diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/index.module.scss b/frontend/providers/applaunchpad/src/components/app/detail/index/index.module.scss similarity index 100% rename from frontend/providers/applaunchpad/src/pages/app/detail/index.module.scss rename to frontend/providers/applaunchpad/src/components/app/detail/index/index.module.scss diff --git a/frontend/providers/applaunchpad/src/components/app/detail/logs/Filter.tsx b/frontend/providers/applaunchpad/src/components/app/detail/logs/Filter.tsx new file mode 100644 index 00000000000..6c7e28a3bef --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/app/detail/logs/Filter.tsx @@ -0,0 +1,228 @@ +import MyIcon from '@/components/Icon'; +import { JsonFilterItem, LogsFormData } from '@/pages/app/detail/logs'; +import { Button, ButtonProps, Center, Flex, Input, Switch, Text } from '@chakra-ui/react'; +import { MySelect } from '@sealos/ui'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import { UseFormReturn, useFieldArray } from 'react-hook-form'; + +export const Filter = ({ + formHook, + refetchData +}: { + formHook: UseFormReturn; + refetchData: () => void; +}) => { + const { t } = useTranslation(); + const [activeId, setActiveId] = useState('normal_filter'); + const [inputKeyword, setInputKeyword] = useState(formHook.watch('keyword')); + + const isJsonMode = formHook.watch('isJsonMode'); + const isOnlyStderr = formHook.watch('isOnlyStderr'); + const filterKeys = formHook.watch('filterKeys'); + + const { fields, append, remove } = useFieldArray({ + control: formHook.control, + name: 'jsonFilters' + }); + + return ( + + {/* tab */} + {/* + + */} + {/* operator button */} + + + + {t('json_mode')} + + { + formHook.setValue('isJsonMode', !isJsonMode); + formHook.setValue('jsonFilters', []); + }} + /> + + + + {t('only_stderr')} + + formHook.setValue('isOnlyStderr', !isOnlyStderr)} + /> + + + setInputKeyword(e.target.value)} + /> + + + + + {/* json mode */} + {isJsonMode && ( + + {filterKeys.length > 0 || fields.length > 0 ? ( + + append({ + key: '', + value: '', + mode: '=' + }) + } + /> + ) : ( +
+ + {t('no_data_available')} + +
+ )} + + {fields.map((field, index) => ( + + formHook.setValue(`jsonFilters.${index}.key`, val)} + /> + + formHook.setValue(`jsonFilters.${index}.mode`, val as JsonFilterItem['mode']) + } + /> + formHook.setValue(`jsonFilters.${index}.value`, e.target.value)} + border={'1px solid #E8EBF0'} + boxShadow={ + '0px 1px 2px 0px rgba(19, 51, 107, 0.05),0px 0px 1px 0px rgba(19, 51, 107, 0.08)' + } + /> + + {index === fields.length - 1 && ( + + append({ + key: '', + value: '', + mode: '=' + }) + } + /> + )} + + ))} +
+ )} +
+ ); +}; + +const AppendJSONFormItemButton = (props: ButtonProps) => { + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/frontend/providers/applaunchpad/src/components/app/detail/logs/Header.tsx b/frontend/providers/applaunchpad/src/components/app/detail/logs/Header.tsx new file mode 100644 index 00000000000..00867deb7c0 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/app/detail/logs/Header.tsx @@ -0,0 +1,204 @@ +import { ChevronDownIcon } from '@chakra-ui/icons'; +import { + Box, + Button, + ButtonGroup, + Flex, + Grid, + Input, + Menu, + MenuButton, + MenuItem, + MenuList, + Text, + useMediaQuery +} from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import dynamic from 'next/dynamic'; + +import AdvancedSelect from '@/components/AdvancedSelect'; +import MyIcon from '@/components/Icon'; +import { REFRESH_INTERVAL_OPTIONS } from '@/constants/monitor'; +import { LogsFormData } from '@/pages/app/detail/logs'; +import useDateTimeStore from '@/store/date'; +import { UseFormReturn } from 'react-hook-form'; +import { MyTooltip } from '@sealos/ui'; + +const DatePicker = dynamic(() => import('@/components/DatePicker'), { ssr: false }); + +export const Header = ({ + formHook, + refetchData +}: { + formHook: UseFormReturn; + refetchData: () => void; +}) => { + const { t } = useTranslation(); + const { refreshInterval, setRefreshInterval } = useDateTimeStore(); + const [isLargerThan1440] = useMediaQuery('(min-width: 1440px)'); + + return ( + + + + + {t('time')} + + + + + + Pods + + } + width={'fit-content'} + value={'hello-sql-postgresql-0'} + onCheckboxChange={(val) => { + formHook.setValue('pods', val); + }} + list={formHook.watch('pods')} + /> + + + + + + Containers + + } + value={'hello-sql-postgresql-0'} + list={formHook.watch('containers')} + onCheckboxChange={(val) => { + formHook.setValue('containers', val); + }} + /> + + + + {t('log_number')} + + { + const val = Number(e.target.value); + if (isNaN(val)) { + formHook.setValue('limit', 1); + } else if (val > 500) { + formHook.setValue('limit', 500); + } else if (val < 1) { + formHook.setValue('limit', 1); + } else { + formHook.setValue('limit', val); + } + }} + /> + + + + + + + {refreshInterval === 0 ? null : ( + {`${refreshInterval / 1000}s`} + )} + + + + + {REFRESH_INTERVAL_OPTIONS.map((item) => ( + { + setRefreshInterval(item.value); + }} + {...(refreshInterval === item.value + ? { + color: 'brightBlue.600' + } + : {})} + borderRadius={'4px'} + _hover={{ + bg: 'rgba(17, 24, 36, 0.05)', + color: 'brightBlue.600' + }} + p={'6px'} + > + {item.label} + + ))} + + + + + + + ); +}; diff --git a/frontend/providers/applaunchpad/src/components/app/detail/logs/LogCounts.tsx b/frontend/providers/applaunchpad/src/components/app/detail/logs/LogCounts.tsx new file mode 100644 index 00000000000..5a963906da8 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/app/detail/logs/LogCounts.tsx @@ -0,0 +1,85 @@ +import MyIcon from '@/components/Icon'; +import LogBarChart from '@/components/LogBarChart'; +import { Box, Button, Center, Collapse, Flex, Spinner, Text } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import EmptyChart from '@/components/Icon/icons/emptyChart.svg'; + +export const LogCounts = ({ + logCountsData, + isLogCountsLoading +}: { + logCountsData: { logs_total: string; _time: string }[]; + isLogCountsLoading?: boolean; +}) => { + const { t } = useTranslation(); + const [onOpenChart, setOnOpenChart] = useState(true); + + const processChartData = (rawData: Array<{ _time: string; logs_total: string }>) => { + const sortedData = [...rawData].sort( + (a, b) => new Date(a._time).getTime() - new Date(b._time).getTime() + ); + const xData = sortedData.map((item) => Math.floor(new Date(item._time).getTime() / 1000)); + const yData = sortedData.map((item) => item.logs_total); + + return { + xData, + yData + }; + }; + + return ( + + + + + {/* charts */} + + + {isLogCountsLoading ? ( +
+ +
+ ) : logCountsData.length > 0 ? ( + + ) : ( +
+ + + {t('no_data_available')} + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/providers/applaunchpad/src/components/app/detail/logs/LogTable.tsx b/frontend/providers/applaunchpad/src/components/app/detail/logs/LogTable.tsx new file mode 100644 index 00000000000..500196eb0f5 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/app/detail/logs/LogTable.tsx @@ -0,0 +1,305 @@ +import { BaseTable } from '@/components/BaseTable/index'; +import { + Box, + Button, + Checkbox, + CheckboxGroup, + Collapse, + Divider, + Flex, + Text +} from '@chakra-ui/react'; +import { + ColumnDef, + getCoreRowModel, + getFilteredRowModel, + useReactTable +} from '@tanstack/react-table'; +import { get } from 'lodash'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import MyIcon from '@/components/Icon'; +import { formatTime } from '@/utils/tools'; +import { LogsFormData } from '@/pages/app/detail/logs'; +import { UseFormReturn } from 'react-hook-form'; +import { useLogStore } from '@/store/logStore'; + +interface FieldItem { + value: string; + label: string; + checked: boolean; + accessorKey: string; +} + +interface LogData { + stream: string; + [key: string]: any; +} + +export const LogTable = ({ + data, + isLoading, + formHook +}: { + data: any[]; + isLoading: boolean; + formHook: UseFormReturn; +}) => { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + + const [onOpenField, setOnOpenField] = useState(false); + const [hiddenFieldCount, setHiddenFieldCount] = useState(0); + const [visibleFieldCount, setVisibleFieldCount] = useState(0); + const isJsonMode = formHook.watch('isJsonMode'); + const { exportLogs } = useLogStore(); + + const generateFieldList = useCallback((data: any[], prevFieldList: FieldItem[] = []) => { + if (!data.length) return []; + + const uniqueKeys = new Set(); + data.forEach((item) => { + Object.keys(item).forEach((key) => { + uniqueKeys.add(key); + }); + }); + + const prevFieldStates = prevFieldList.reduce((acc, field) => { + acc[field.value] = field.checked; + return acc; + }, {} as Record); + + return Array.from(uniqueKeys).map((key) => ({ + value: key, + label: key, + checked: key in prevFieldStates ? prevFieldStates[key] : true, + accessorKey: key + })); + }, []); + + const [fieldList, setFieldList] = useState([]); + + useEffect(() => { + setFieldList((prevFieldList) => generateFieldList(data, prevFieldList)); + const excludeFields = ['_time', '_msg', 'container', 'pod', 'stream']; + formHook.setValue( + 'filterKeys', + generateFieldList(data) + .filter((field) => !excludeFields.includes(field.value)) + .map((field) => ({ value: field.value, label: field.label })) + ); + }, [data, generateFieldList, formHook]); + + useEffect(() => { + const visibleCount = fieldList.filter((field) => field.checked).length; + setVisibleFieldCount(visibleCount); + setHiddenFieldCount(fieldList.length - visibleCount); + }, [fieldList]); + + const columns = useMemo>>(() => { + return fieldList + .filter((field) => field.checked) + .map((field) => ({ + accessorKey: field.accessorKey, + header: () => { + if (field.label === '_time' || field.label === '_msg') { + return field.label.substring(1); + } + return field.label; + }, + cell: ({ row }) => { + let value = get(row.original, field.accessorKey, ''); + + if (field.accessorKey === '_time') { + value = formatTime(value, 'YYYY-MM-DD HH:mm:ss'); + } + + return ( + + {value?.toString() || ''} + + ); + }, + meta: { + isError: (row: any) => row.stream === 'stderr' + } + })); + }, [fieldList]); + + const table = useReactTable({ + data: data, + columns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel() + }); + + return ( + + + + + {t('Log')} + + {isJsonMode && ( + + + + + {t('visible')}: + + + {visibleFieldCount} {lang === 'zh' ? t('piece') : ''} + + + + + + + + {t('hidden')}: + + + {hiddenFieldCount} {lang === 'zh' ? t('piece') : ''} + + + + )} + + + + + {isJsonMode && ( + + + + {fieldList.map((item) => ( + + setFieldList( + fieldList.map((field) => + field.value === item.value ? { ...field, checked: !field.checked } : field + ) + ) + } + sx={{ + 'span.chakra-checkbox__control[data-checked]': { + background: '#f0f4ff ', + border: '1px solid #219bf4 ', + boxShadow: '0px 0px 0px 2.4px rgba(33, 155, 244, 0.15)', + color: '#219bf4', + borderRadius: '4px' + } + }} + > + {item.label} + + ))} + + + + )} + + {data.length > 0 ? ( + + ) : ( + + + + {t('no_data_available')} + + + )} + + ); +}; diff --git a/frontend/providers/applaunchpad/src/pages/apps/components/appList.tsx b/frontend/providers/applaunchpad/src/components/apps/appList.tsx similarity index 98% rename from frontend/providers/applaunchpad/src/pages/apps/components/appList.tsx rename to frontend/providers/applaunchpad/src/components/apps/appList.tsx index 7acda1631c3..8688e35ada6 100644 --- a/frontend/providers/applaunchpad/src/pages/apps/components/appList.tsx +++ b/frontend/providers/applaunchpad/src/components/apps/appList.tsx @@ -26,9 +26,9 @@ import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import React, { useCallback, useMemo, useState } from 'react'; import type { ThemeType } from '@sealos/ui'; -import UpdateModal from '@/pages/app/detail/components/UpdateModal'; +import UpdateModal from '@/components/app/detail/index/UpdateModal'; -const DelModal = dynamic(() => import('@/pages/app/detail/components/DelModal')); +const DelModal = dynamic(() => import('@/components/app/detail/index/DelModal')); const AppList = ({ apps = [], diff --git a/frontend/providers/applaunchpad/src/pages/apps/components/empty.module.scss b/frontend/providers/applaunchpad/src/components/apps/empty.module.scss similarity index 100% rename from frontend/providers/applaunchpad/src/pages/apps/components/empty.module.scss rename to frontend/providers/applaunchpad/src/components/apps/empty.module.scss diff --git a/frontend/providers/applaunchpad/src/pages/apps/components/empty.tsx b/frontend/providers/applaunchpad/src/components/apps/empty.tsx similarity index 100% rename from frontend/providers/applaunchpad/src/pages/apps/components/empty.tsx rename to frontend/providers/applaunchpad/src/components/apps/empty.tsx diff --git a/frontend/providers/applaunchpad/src/components/layouts/DetailLayout.tsx b/frontend/providers/applaunchpad/src/components/layouts/DetailLayout.tsx new file mode 100644 index 00000000000..5bf271955fd --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/layouts/DetailLayout.tsx @@ -0,0 +1,77 @@ +import Sidebar, { ROUTES } from '@/components/layouts/Sidebar'; +import { useToast } from '@/hooks/useToast'; +import { MOCK_APP_DETAIL } from '@/mock/apps'; +import Header from '@/components/app/detail/index/Header'; +import { useAppStore } from '@/store/app'; +import { useGlobalStore } from '@/store/global'; +import { Flex } from '@chakra-ui/react'; +import { useQuery } from '@tanstack/react-query'; +import React, { useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; + +interface DetailLayoutProps { + children: React.ReactNode; + appName: string; +} + +export default function DetailLayout({ children, appName }: DetailLayoutProps) { + const { toast } = useToast(); + const router = useRouter(); + const { screenWidth } = useGlobalStore(); + const isLargeScreen = useMemo(() => screenWidth > 1280, [screenWidth]); + + const { + appDetail = MOCK_APP_DETAIL, + setAppDetail, + intervalLoadPods, + loadDetailMonitorData + } = useAppStore(); + + const [showSlider, setShowSlider] = useState(false); + + const { refetch } = useQuery(['setAppDetail'], () => setAppDetail(appName), { + onError(err) { + toast({ + title: String(err), + status: 'error' + }); + } + }); + + useQuery( + ['app-detail-pod'], + () => { + if (appDetail?.isPause) return null; + return intervalLoadPods(appName, true); + }, + { + refetchOnMount: true, + refetchInterval: router.pathname === ROUTES.OVERVIEW ? 3000 : 5000, + staleTime: router.pathname === ROUTES.OVERVIEW ? 3000 : 5000 + } + ); + + return ( + +
+ + + {children} + + + ); +} diff --git a/frontend/providers/applaunchpad/src/components/layouts/Sidebar.tsx b/frontend/providers/applaunchpad/src/components/layouts/Sidebar.tsx new file mode 100644 index 00000000000..e0e85565827 --- /dev/null +++ b/frontend/providers/applaunchpad/src/components/layouts/Sidebar.tsx @@ -0,0 +1,91 @@ +import { Center, Text, Stack } from '@chakra-ui/react'; +import MyIcon from '../Icon'; +import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; + +export const ROUTES = { + OVERVIEW: '/app/detail', + MONITOR: '/app/detail/monitor', + LOGS: '/app/detail/logs' +} as const; + +export default function Sidebar() { + const { t } = useTranslation(); + const router = useRouter(); + + const siderbarMap = [ + { + label: t('overview'), + icon: ( + + ), + path: ROUTES.OVERVIEW + }, + { + label: t('monitor'), + icon: ( + + ), + path: ROUTES.MONITOR + }, + { + label: t('Log'), + icon: ( + + ), + path: ROUTES.LOGS + } + ]; + + return ( + + {siderbarMap.map((item) => ( +
{ + console.log(router.query); + router.push({ + pathname: item.path, + query: { ...router.query } + }); + }} + > + {item.icon} + + {item.label} + +
+ ))} +
+ ); +} diff --git a/frontend/providers/applaunchpad/src/constants/monitor.ts b/frontend/providers/applaunchpad/src/constants/monitor.ts index 713e057f8b5..30faa0e1ccf 100644 --- a/frontend/providers/applaunchpad/src/constants/monitor.ts +++ b/frontend/providers/applaunchpad/src/constants/monitor.ts @@ -1,36 +1,42 @@ export const LineStyleMap = [ { - backgroundColor: '#EBF5FB', - lineColor: '#5EBDF2' + backgroundColor: 'rgba(209, 244, 255, 0.3)', + lineColor: '#11B6FC' }, { - backgroundColor: 'rgba(241, 240, 249, 1)', - lineColor: 'rgba(154, 142, 224, 1)' + backgroundColor: 'rgba(255, 221, 252, 0.3)', + lineColor: '#8774EE' }, { - backgroundColor: 'rgba(237, 247, 247, 1)', - lineColor: 'rgba(108, 211, 204, 1)' + backgroundColor: 'rgba(254, 206, 255, 0.3)', + lineColor: '#C172E7' }, - { - backgroundColor: 'rgba(250, 239, 244, 1)', - lineColor: 'rgba(241, 130, 170, 1)' + backgroundColor: 'rgba(199, 255, 248, 0.3)', + lineColor: '#13C4B9' }, { - backgroundColor: 'rgba(108, 211, 204, 0.1)', - lineColor: 'rgba(108, 211, 204, 1)' + backgroundColor: 'rgba(255, 224, 235, 0.3)', + lineColor: '#FF81AE' }, { - backgroundColor: 'rgba(250, 239, 244, 1)', - lineColor: 'rgba(252, 150, 99, 1)' + backgroundColor: 'rgba(255, 238, 231, 0.3)', + lineColor: '#FB6514' }, - { - backgroundColor: 'rgba(251, 235, 238, 1)', - lineColor: 'rgba(255, 91, 110, 1)' + backgroundColor: 'rgba(255, 224, 224, 0.05)', + lineColor: '#F04438' }, { - backgroundColor: 'rgba(249, 248, 234, 1)', - lineColor: 'rgba(236, 218, 70, 1)' + backgroundColor: 'rgba(241, 255, 185, 0.3)', + lineColor: '#E7D435' } ]; + +export const REFRESH_INTERVAL_OPTIONS = [ + { value: 0, label: 'close' }, + { value: 1000, label: '1s' }, + { value: 2000, label: '2s' }, + { value: 5000, label: '5s' }, + { value: 10000, label: '10s' } +]; diff --git a/frontend/providers/applaunchpad/src/constants/theme.ts b/frontend/providers/applaunchpad/src/constants/theme.ts index 482ab43103e..c48ece76017 100644 --- a/frontend/providers/applaunchpad/src/constants/theme.ts +++ b/frontend/providers/applaunchpad/src/constants/theme.ts @@ -23,7 +23,8 @@ export const theme = extendTheme(sealosTheme, { 'html, body': { fontSize: 'md', height: '100%', - overflow: 'overlay', + backgroundColor: '#F4F4F7', + overflowX: 'auto', fontWeight: 400, minWidth: '1024px' } diff --git a/frontend/providers/applaunchpad/src/hooks/useConfirm.tsx b/frontend/providers/applaunchpad/src/hooks/useConfirm.tsx index 02174ab4b4c..18ddc98f010 100644 --- a/frontend/providers/applaunchpad/src/hooks/useConfirm.tsx +++ b/frontend/providers/applaunchpad/src/hooks/useConfirm.tsx @@ -31,7 +31,12 @@ export const useConfirm = ({ title = 'Warning', content }: { title?: string; con ), ConfirmChild: useCallback( () => ( - + diff --git a/frontend/providers/applaunchpad/src/hooks/useRequest.tsx b/frontend/providers/applaunchpad/src/hooks/useRequest.tsx index b491a1abf38..ca2a33d354e 100644 --- a/frontend/providers/applaunchpad/src/hooks/useRequest.tsx +++ b/frontend/providers/applaunchpad/src/hooks/useRequest.tsx @@ -2,7 +2,7 @@ import { useToast } from '@/hooks/useToast'; import { useMutation } from '@tanstack/react-query'; import type { UseMutationOptions } from '@tanstack/react-query'; import { getErrText } from '@/utils/tools'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; interface Props extends UseMutationOptions { successToast?: string | null; diff --git a/frontend/providers/applaunchpad/src/pages/_app.tsx b/frontend/providers/applaunchpad/src/pages/_app.tsx index 3f4e5f25314..61db547f2d4 100644 --- a/frontend/providers/applaunchpad/src/pages/_app.tsx +++ b/frontend/providers/applaunchpad/src/pages/_app.tsx @@ -12,15 +12,14 @@ import { appWithTranslation, useTranslation } from 'next-i18next'; import type { AppProps } from 'next/app'; import Head from 'next/head'; import Router, { useRouter } from 'next/router'; -import NProgress from 'nprogress'; //nprogress module +import NProgress from 'nprogress'; import { useEffect, useState } from 'react'; import { EVENT_NAME } from 'sealos-desktop-sdk'; import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app'; +import 'react-day-picker/dist/style.css'; import '@/styles/reset.scss'; import 'nprogress/nprogress.css'; import '@sealos/driver/src/driver.css'; -import { AppEditSyncedFields } from '@/types/app'; -import Script from 'next/script'; //Binding events. Router.events.on('routeChangeStart', () => NProgress.start()); diff --git a/frontend/providers/applaunchpad/src/pages/api/log/queryLogs.ts b/frontend/providers/applaunchpad/src/pages/api/log/queryLogs.ts new file mode 100644 index 00000000000..2f96f174c98 --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/api/log/queryLogs.ts @@ -0,0 +1,108 @@ +import { JsonFilterItem } from '@/pages/app/detail/logs'; +import { authSession } from '@/services/backend/auth'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { ApiResp } from '@/services/kubernet'; + +import type { NextApiRequest, NextApiResponse } from 'next'; + +export interface LogQueryPayload { + app?: string; + time?: string; + namespace?: string; + limit?: string; + jsonMode?: string; + stderrMode?: string; + numberMode?: string; + numberLevel?: string; + pod?: string[]; + container?: string[]; + keyword?: string; + jsonQuery?: JsonFilterItem[]; + exportMode?: boolean; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const logUrl = global.AppConfig.launchpad.components.log.url; + + if (!logUrl) { + return jsonRes(res, { + code: 400, + error: 'logUrl is not set' + }); + } + + if (req.method !== 'POST') { + return jsonRes(res, { + code: 405, + error: 'Method not allowed' + }); + } + + try { + const kubeconfig = await authSession(req.headers); + const { namespace } = await getK8s({ + kubeconfig: kubeconfig + }); + + if (!req.body.app) { + return jsonRes(res, { + code: 400, + error: 'app is required' + }); + } + + const { + time = '30d', + app = '', + limit = '10', + jsonMode = 'true', + stderrMode = 'false', + numberMode = 'false', + numberLevel = '', + pod = [], + container = [], + keyword = '', + jsonQuery = [], + exportMode = false + } = req.body as LogQueryPayload; + + const params: LogQueryPayload = { + time: time, + namespace: namespace, + app: app, + limit: limit, + jsonMode: jsonMode, + stderrMode: stderrMode, + numberMode: numberMode, + ...(numberLevel && { numberLevel: numberLevel }), + pod: Array.isArray(pod) ? pod : [], + container: Array.isArray(container) ? container : [], + keyword: keyword, + jsonQuery: Array.isArray(jsonQuery) ? jsonQuery : [] + }; + + console.log(params, 'params'); + const result = await fetch(logUrl + '/queryLogsByParams', { + method: 'POST', + body: JSON.stringify(params), + headers: { + 'Content-Type': 'application/json', + Authorization: encodeURIComponent(kubeconfig) + } + }); + console.log('fetch /queryLogsByParams: ', result.status); + const data = await result.text(); + + jsonRes(res, { + code: 200, + data: data + }); + } catch (error) { + console.log(error, 'error'); + jsonRes(res, { + code: 500, + error: error + }); + } +} diff --git a/frontend/providers/applaunchpad/src/pages/api/log/queryPodList.ts b/frontend/providers/applaunchpad/src/pages/api/log/queryPodList.ts new file mode 100644 index 00000000000..554c9308233 --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/api/log/queryPodList.ts @@ -0,0 +1,84 @@ +import { JsonFilterItem } from '@/pages/app/detail/logs'; +import { authSession } from '@/services/backend/auth'; +import { getK8s } from '@/services/backend/kubernetes'; +import { jsonRes } from '@/services/backend/response'; +import { ApiResp } from '@/services/kubernet'; + +import type { NextApiRequest, NextApiResponse } from 'next'; + +export interface PodListQueryPayload { + app?: string; + time?: string; + namespace?: string; + podQuery?: string; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const logUrl = global.AppConfig.launchpad.components.log.url; + + if (!logUrl) { + return jsonRes(res, { + code: 400, + error: 'logUrl is not set' + }); + } + + if (req.method !== 'POST') { + return jsonRes(res, { + code: 405, + error: 'Method not allowed' + }); + } + + try { + const kubeconfig = await authSession(req.headers); + const { namespace } = await getK8s({ + kubeconfig: kubeconfig + }); + + if (!req.body.app) { + return jsonRes(res, { + code: 400, + error: 'app is required' + }); + } + + const { time = '30d', app = '', podQuery = 'true' } = req.body as PodListQueryPayload; + + const params: PodListQueryPayload = { + time: time, + namespace: namespace, + app: app, + podQuery: podQuery + }; + + console.log(params, 'params'); + const result = await fetch(logUrl + '/queryPodList', { + method: 'POST', + body: JSON.stringify(params), + headers: { + 'Content-Type': 'application/json', + Authorization: encodeURIComponent(kubeconfig) + } + }); + console.log('fetch /queryPodList: ', result.status); + if (result.status !== 200) { + return jsonRes(res, { + data: [] + }); + } + + const data = await result.json(); + + jsonRes(res, { + code: 200, + data: data + }); + } catch (error) { + console.log(error, 'error'); + jsonRes(res, { + code: 500, + error: error + }); + } +} diff --git a/frontend/providers/applaunchpad/src/pages/api/monitor/getMonitorData.ts b/frontend/providers/applaunchpad/src/pages/api/monitor/getMonitorData.ts index c7c2be4405e..f1f90746ef7 100644 --- a/frontend/providers/applaunchpad/src/pages/api/monitor/getMonitorData.ts +++ b/frontend/providers/applaunchpad/src/pages/api/monitor/getMonitorData.ts @@ -87,15 +87,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< const { queryName, queryKey, start, end, step = '1m' } = req.query; // One hour of monitoring data - const endTime = Date.now(); - const startTime = endTime - 60 * 60 * 1000; + const endTime = end ? Number(end) : Date.now(); + const startTime = start ? Number(start) : endTime - 60 * 60 * 1000; const params = { type: queryKey, launchPadName: queryName, namespace: namespace, - start: startTime / 1000, - end: endTime / 1000, + start: Math.floor(startTime / 1000), + end: Math.floor(endTime / 1000), step: step }; @@ -106,7 +106,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< }, kubeconfig ).then((res) => { - // console.log(res.data.result, res.data.result[0].values.length, 'AdapterChartData'); // @ts-ignore return AdapterChartData[queryKey] ? // @ts-ignore diff --git a/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts b/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts index 0162c5488ff..e2a052b8192 100644 --- a/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts +++ b/frontend/providers/applaunchpad/src/pages/api/platform/getInitData.ts @@ -51,6 +51,9 @@ export const defaultAppConfig: AppConfigType = { }, billing: { url: 'http://account-service.account-system.svc:2333' + }, + log: { + url: 'http://localhost:8080' } }, appResourceFormSliderConfig: { diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/components/AppBaseInfo.tsx b/frontend/providers/applaunchpad/src/pages/app/detail/components/AppBaseInfo.tsx deleted file mode 100644 index 4448388b6ac..00000000000 --- a/frontend/providers/applaunchpad/src/pages/app/detail/components/AppBaseInfo.tsx +++ /dev/null @@ -1,477 +0,0 @@ -import GPUItem from '@/components/GPUItem'; -import MyIcon from '@/components/Icon'; -import { MOCK_APP_DETAIL } from '@/mock/apps'; -import { useUserStore } from '@/store/user'; -import type { AppDetailType } from '@/types/app'; -import { printMemory, useCopyData } from '@/utils/tools'; -import { - Accordion, - AccordionButton, - AccordionIcon, - AccordionItem, - AccordionPanel, - Box, - Flex, - Tag, - useTheme -} from '@chakra-ui/react'; -import { MyTooltip } from '@sealos/ui'; -import { useTranslation } from 'next-i18next'; -import dynamic from 'next/dynamic'; -import React, { useMemo, useState } from 'react'; -import { sealosApp } from 'sealos-desktop-sdk/app'; -import styles from '../index.module.scss'; - -const ConfigMapDetailModal = dynamic(() => import('./ConfigMapDetailModal')); - -const AppBaseInfo = ({ app = MOCK_APP_DETAIL }: { app: AppDetailType }) => { - const { t } = useTranslation(); - const theme = useTheme(); - const { copyData } = useCopyData(); - const { userSourcePrice } = useUserStore(); - const [detailConfigMap, setDetailConfigMap] = useState<{ - mountPath: string; - value: string; - }>(); - - const appInfoTable = useMemo< - { - name: string; - iconName: string; - items: { - label: string; - value?: string; - copy?: string; - render?: React.ReactNode; - }[]; - }[] - >( - () => [ - { - name: 'Basic Information', - iconName: 'formInfo', - items: [ - { label: 'Creation Time', value: app.createTime }, - { - label: `${t('Image Name')} ${app.secret.use ? '(Private)' : ''}`, - value: app.imageName - }, - { label: 'Limit CPU', value: `${app.cpu / 1000} Core` }, - { - label: 'Limit Memory', - value: printMemory(app.memory) - }, - ...(userSourcePrice?.gpu - ? [ - { - label: 'GPU', - render: - } - ] - : []) - ] - }, - { - name: 'Deployment Mode', - iconName: 'deployMode', - items: app.hpa.use - ? [ - { - label: `${app.hpa.target} ${t('target_value')}`, - value: `${app.hpa.value}${app.hpa.target === 'gpu' ? '' : '%'}` - }, - { - label: 'Number of Instances', - value: `${app.hpa.minReplicas} ~ ${app.hpa.maxReplicas}` - } - ] - : [{ label: `Number of Instances`, value: `${app.replicas}` }] - } - ], - [app] - ); - - const appTags = useMemo( - () => [ - ...(app.networks.find((item) => item.openPublicDomain) ? ['Public Access'] : []), - ...(app.hpa.use ? ['Auto scaling'] : ['Fixed instance']), - ...(app.storeList.length > 0 ? ['Stateful'] : ['Stateless']) - ], - [app] - ); - - const persistentVolumes = useMemo(() => { - return app.volumes - .filter((item) => 'persistentVolumeClaim' in item) - .reduce( - ( - acc: { - path: string; - name: string; - }[], - volume - ) => { - const mount = app.volumeMounts.find((m) => m.name === volume.name); - if (mount) { - acc.push({ - path: mount.mountPath, - name: volume.name - }); - } - return acc; - }, - [] - ); - }, [app.volumes, app.volumeMounts]); - - return ( - - {app?.source?.hasSource && ( - - - - {t('Application Source')} - - - { - if (!app?.source?.sourceName) return; - if (app.source.sourceType === 'app_store') { - sealosApp.runEvents('openDesktopApp', { - appKey: 'system-template', - pathname: '/instance', - query: { instanceName: app.source.sourceName } - }); - } - if (app.source.sourceType === 'sealaf') { - sealosApp.runEvents('openDesktopApp', { - appKey: 'system-sealaf', - pathname: '/', - query: { instanceName: app.source.sourceName } - }); - } - }} - > - - {t(app.source?.sourceType)} - - {t('Manage all resources')} - - - - - )} - - <> - - - {t('Application Type')} - - - {appTags.map((tag) => ( - - {t(tag)} - - ))} - - - {appInfoTable.map((info) => ( - - - - {t(info.name)} - - - {info.items.map((item, i) => ( - - - {t(item.label)} - - - - item.value && !!item.copy && copyData(item.copy)} - > - {item.render ? item.render : item.value} - - - - - ))} - - - ))} - - - - {t('Advanced Configuration')} - - - {[ - { label: 'Command', value: app.runCMD || 'Not Configured' }, - { label: 'Parameters', value: app.cmdParam || 'Not Configured' } - ].map((item) => ( - - - {t(item.label)} - - - ))} - {/* env */} - - - - {t('Environment Variables')} - - - - {app.envs?.length > 0 && ( - - {app.envs.map((env, index) => { - const valText = env.value - ? env.value - : env.valueFrom - ? 'value from | ***' - : ''; - return ( - - - {env.key} - - - copyData(valText)} - > - {valText} - - - - ); - })} - - )} - - - - {/* configMap */} - - - - {t('Configuration File')} - - - - 0 - ? { - mb: 3, - border: theme.borders.base - } - : {})} - > - {app.configMapList.map((item) => ( - setDetailConfigMap(item)} - _notLast={{ - borderBottom: theme.borders.base - }} - > - - - {item.mountPath} - - {item.value} - - - - ))} - - - - - {/* store */} - - - - {t('Storage')} - - - - 0 || persistentVolumes.length > 0 - ? { - mb: 4, - border: theme.borders.base - } - : {})} - > - {app.storeList.map((item) => ( - - - - - {item.path} - - - {item.value} Gi - - - - ))} - {persistentVolumes.map((item) => ( - - - - - {item.path} - - - - {t('shared')} - - - ))} - - - - - - - - {detailConfigMap && ( - setDetailConfigMap(undefined)} /> - )} - - ); -}; - -export default React.memo(AppBaseInfo); diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx b/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx index c9432e8e565..5842914996b 100644 --- a/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/detail/index.tsx @@ -8,12 +8,15 @@ import { serviceSideProps } from '@/utils/i18n'; import { Box, Flex, useTheme } from '@chakra-ui/react'; import { useQuery } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; -import React, { useMemo, useState } from 'react'; -import AppBaseInfo from './components/AppBaseInfo'; -import Header from './components/Header'; -import Pods from './components/Pods'; +import React, { useMemo } from 'react'; +import AppBaseInfo from '@/components/app/detail/index/AppBaseInfo'; +import Pods from '@/components/app/detail/index/Pods'; +import DetailLayout from '@/components/layouts/DetailLayout'; +import AdvancedInfo from '@/components/app/detail/index/AdvancedInfo'; -const AppMainInfo = dynamic(() => import('./components/AppMainInfo'), { ssr: false }); +const AppMainInfo = dynamic(() => import('@/components/app/detail/index/AppMainInfo'), { + ssr: false +}); const AppDetail = ({ appName }: { appName: string }) => { const { startGuide } = useDetailDriver(); @@ -30,9 +33,6 @@ const AppDetail = ({ appName }: { appName: string }) => { loadDetailMonitorData } = useAppStore(); - const [podsLoaded, setPodsLoaded] = useState(false); - const [showSlider, setShowSlider] = useState(false); - const { refetch, isSuccess } = useQuery(['setAppDetail'], () => setAppDetail(appName), { onError(err) { toast({ @@ -42,21 +42,6 @@ const AppDetail = ({ appName }: { appName: string }) => { } }); - useQuery( - ['app-detail-pod'], - () => { - if (appDetail?.isPause) return null; - return intervalLoadPods(appName, true); - }, - { - refetchOnMount: true, - refetchInterval: 3000, - onSettled() { - setPodsLoaded(true); - } - } - ); - useQuery( ['loadDetailMonitorData', appName, appDetail?.isPause], () => { @@ -70,82 +55,31 @@ const AppDetail = ({ appName }: { appName: string }) => { ); return ( - - -
- - - - {appDetail ? : } - - - - {appDetail ? : } + + + + + - - + + {appDetail ? : } + + + + + + - {/* mask */} - {!isLargeScreen && showSlider && ( - setShowSlider(false)} - /> - )} - + ); }; diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/logs.tsx b/frontend/providers/applaunchpad/src/pages/app/detail/logs.tsx new file mode 100644 index 00000000000..853fa7c6344 --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/app/detail/logs.tsx @@ -0,0 +1,263 @@ +import { useTranslation } from 'next-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { Box, useTheme, Flex, Divider } from '@chakra-ui/react'; +import { useAppStore } from '@/store/app'; +import { serviceSideProps } from '@/utils/i18n'; +import DetailLayout from '@/components/layouts/DetailLayout'; +import { Header } from '@/components/app/detail/logs/Header'; +import { Filter } from '@/components/app/detail/logs/Filter'; +import { LogTable } from '@/components/app/detail/logs/LogTable'; +import { LogCounts } from '@/components/app/detail/logs/LogCounts'; +import { useEffect, useMemo, useState } from 'react'; +import { ListItem } from '@/components/AdvancedSelect'; +import useDateTimeStore from '@/store/date'; +import { getAppLogs, getLogPodList } from '@/api/app'; +import { useForm } from 'react-hook-form'; +import { formatTimeRange } from '@/utils/timeRange'; +import { downLoadBold } from '@/utils/tools'; +import { useLogStore } from '@/store/logStore'; +import { useRouter } from 'next/router'; +import { useMessage } from '@sealos/ui'; + +export interface JsonFilterItem { + key: string; + value: string; + mode: '=' | '!=' | '~' | '!~'; +} + +export interface LogsFormData { + pods: ListItem[]; + containers: ListItem[]; + limit: number; + keyword: string; + isJsonMode: boolean; + isOnlyStderr: boolean; + jsonFilters: JsonFilterItem[]; + refreshInterval: number; + filterKeys: { + value: string; + label: string; + }[]; +} + +export default function LogsPage({ appName }: { appName: string }) { + const theme = useTheme(); + const router = useRouter(); + const { message } = useMessage(); + const { t } = useTranslation(); + const { appDetail, appDetailPods } = useAppStore(); + + const { refreshInterval, setRefreshInterval, startDateTime, endDateTime } = useDateTimeStore(); + const { setLogs, exportLogs, parsedLogs, logCounts, setLogCounts } = useLogStore(); + + const formHook = useForm({ + defaultValues: { + pods: [], + containers: [], + limit: 100, + keyword: '', + isJsonMode: false, + isOnlyStderr: false, + jsonFilters: [], + refreshInterval: 0 + } + }); + + const selectedPods = formHook.watch('pods').filter((pod) => pod.checked); + const selectedContainers = formHook.watch('containers').filter((container) => container.checked); + const jsonFilters = formHook + .watch('jsonFilters') + .filter((item) => item.key && item.key.trim() !== ''); + const timeRange = formatTimeRange(startDateTime, endDateTime); + + const { isLoading, refetch: refetchLogsData } = useQuery( + [ + 'logs-data', + appName, + timeRange, + formHook.watch('isOnlyStderr'), + formHook.watch('limit'), + formHook.watch('isJsonMode'), + formHook.watch('keyword'), + selectedPods, + selectedContainers + ], + () => + getAppLogs({ + time: timeRange, + app: appName, + stderrMode: formHook.watch('isOnlyStderr').toString(), + limit: formHook.watch('limit').toString(), + jsonMode: formHook.watch('isJsonMode').toString(), + keyword: formHook.watch('keyword'), + pod: + selectedPods.length === formHook.watch('pods').length + ? [] + : selectedPods.map((pod) => pod.value), + container: + selectedContainers.length === formHook.watch('containers').length + ? [] + : selectedContainers.map((container) => container.value), + jsonQuery: jsonFilters + }), + { + retry: 1, + staleTime: 3000, + cacheTime: 3000, + refetchInterval: refreshInterval, + onError: (error: any) => { + console.log(error, 'error'); + setRefreshInterval(0); + }, + onSuccess: (data) => { + setLogs(data); + } + } + ); + + // log counts + const { refetch: refetchLogCountsData, isLoading: isLogCountsLoading } = useQuery( + [ + 'log-counts-data', + appName, + timeRange, + formHook.watch('isOnlyStderr'), + selectedPods, + selectedContainers, + formHook.watch('isJsonMode'), + formHook.watch('keyword') + ], + () => + getAppLogs({ + app: appName, + numberMode: 'true', + numberLevel: timeRange.slice(-1), + jsonMode: formHook.watch('isJsonMode').toString(), + time: timeRange, + stderrMode: formHook.watch('isOnlyStderr').toString(), + pod: + selectedPods.length === formHook.watch('pods').length + ? [] + : selectedPods.map((pod) => pod.value), + container: + selectedContainers.length === formHook.watch('containers').length + ? [] + : selectedContainers.map((container) => container.value), + jsonQuery: jsonFilters, + keyword: formHook.watch('keyword') + }), + { + refetchInterval: refreshInterval, + staleTime: 3000, + cacheTime: 3000, + onSuccess: (data) => { + setLogCounts(data); + } + } + ); + + const { refetch: refetchPodListData, isLoading: isPodListLoading } = useQuery( + ['log-pod-list-data', appName, timeRange, appDetailPods?.length], + () => + getLogPodList({ + app: appName, + time: timeRange + }), + { + staleTime: 3000, + cacheTime: 3000, + onSuccess: (data) => { + console.log('isInitialized', appDetailPods); + + if (appDetailPods?.length > 0) { + const podList = Array.isArray(data) ? data : []; + const urlPodName = router.query.pod as string; + + const podNamesSet = new Set([ + ...podList, + ...appDetailPods + .map((pod) => pod.metadata?.name) + .filter((name): name is string => !!name) + ]); + + const allPods: ListItem[] = Array.from(podNamesSet).map((podName) => ({ + value: podName, + label: podName, + checked: urlPodName ? podName === urlPodName : true + })); + + formHook.setValue('pods', allPods); + + const containers = appDetailPods + .flatMap((pod) => pod.spec?.containers || []) + .map((container) => ({ + value: container.name, + label: container.name, + checked: true + })) + .filter((item, index, self) => index === self.findIndex((t) => t.value === item.value)); + formHook.setValue('containers', containers); + } + } + } + ); + + const refetchData = () => { + message({ + title: t('refetching_success') + }); + refetchLogsData(); + refetchLogCountsData(); + refetchPodListData(); + }; + + return ( + + + +
+ + + + + + + 0 ? '400px' : '200px'} + > + + + + + ); +} + +export async function getServerSideProps(content: any) { + const appName = content?.query?.name || ''; + + return { + props: { + appName, + ...(await serviceSideProps(content)) + } + }; +} diff --git a/frontend/providers/applaunchpad/src/pages/app/detail/monitor.tsx b/frontend/providers/applaunchpad/src/pages/app/detail/monitor.tsx new file mode 100644 index 00000000000..b58847850ed --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/app/detail/monitor.tsx @@ -0,0 +1,234 @@ +import DetailLayout from '@/components/layouts/DetailLayout'; +import { useToast } from '@/hooks/useToast'; +import { useAppStore } from '@/store/app'; +import { serviceSideProps } from '@/utils/i18n'; +import { Box, Center, Skeleton, SkeletonText, Stack, Text } from '@chakra-ui/react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import Header from '@/components/Monitor/Header'; +import MonitorChart from '@/components/MonitorChart'; +import { useEffect, useMemo, useState } from 'react'; +import { ListItem } from '@/components/AdvancedSelect'; +import useDateTimeStore from '@/store/date'; +import { getAppMonitorData } from '@/api/app'; +import EmptyChart from '@/components/Icon/icons/emptyChart.svg'; + +export default function MonitorPage({ appName }: { appName: string }) { + const { toast } = useToast(); + const { appDetail, appDetailPods } = useAppStore(); + const { t } = useTranslation(); + const { startDateTime, endDateTime } = useDateTimeStore(); + const [podList, setPodList] = useState([]); + const { refreshInterval } = useDateTimeStore(); + + useEffect(() => { + if (appDetailPods?.length > 0 && podList.length === 0) { + setPodList( + appDetailPods.map((pod) => ({ + value: pod.podName, + label: pod.podName, + checked: true + })) + ); + } + }, [appDetailPods, podList]); + + const { + data: memoryData, + isLoading, + refetch: refetchMemoryData + } = useQuery( + ['monitor-data-memory', appName, appDetailPods?.[0]?.podName, startDateTime, endDateTime], + () => + getAppMonitorData({ + queryKey: 'memory', + queryName: appDetailPods?.[0]?.podName || appName, + step: '2m', + start: startDateTime.getTime(), + end: endDateTime.getTime() + }), + { + refetchInterval: refreshInterval, + enabled: !!appDetailPods?.[0]?.podName + } + ); + + const memoryLatestAvg = useMemo(() => { + if (!memoryData?.length) return 0; + + const sum = memoryData.reduce((acc, pod) => { + const lastValue = Number(pod.yData[pod.yData.length - 1]); + return acc + lastValue; + }, 0); + + return (sum / memoryData.length).toFixed(2); + }, [memoryData]); + + const { data: cpuData, refetch: refetchCpuData } = useQuery( + ['monitor-data-cpu', appName, appDetailPods?.[0]?.podName, startDateTime, endDateTime], + () => + getAppMonitorData({ + queryKey: 'cpu', + queryName: appDetailPods?.[0]?.podName || appName, + step: '2m', + start: startDateTime.getTime(), + end: endDateTime.getTime() + }), + { + refetchInterval: refreshInterval, + enabled: !!appDetailPods?.[0]?.podName + } + ); + + const cpuLatestAvg = useMemo(() => { + if (!cpuData?.length) return 0; + + const sum = cpuData.reduce((acc, pod) => { + const lastValue = Number(pod.yData[pod.yData.length - 1]); + return acc + lastValue; + }, 0); + + return (sum / cpuData.length).toFixed(2); + }, [cpuData]); + + const memoryChartData = useMemo(() => { + const selectedPods = podList.filter((pod) => pod.checked); + + const filteredData = memoryData?.filter((item) => + selectedPods.some((pod) => pod.value === item.name) + ); + + if (filteredData?.length === 0) { + return { + xData: [] as string[], + yData: [] as { name: string; type: string; data: number[] }[] + }; + } + + const xData = filteredData?.[0]?.xData.map(String) || []; + const yData = + filteredData?.map((item) => ({ + name: item.name || 'unknown', + type: 'line', + data: item.yData.map(Number) + })) || []; + + return { + xData, + yData + }; + }, [memoryData, podList]); + + const cpuChartData = useMemo(() => { + const selectedPods = podList.filter((pod) => pod.checked); + const filteredData = cpuData?.filter((item) => + selectedPods.some((pod) => pod.value === item.name) + ); + + if (filteredData?.length === 0) { + return { + xData: [] as string[], + yData: [] as { name: string; type: string; data: number[] }[] + }; + } + + const xData = filteredData?.[0]?.xData.map(String) || []; + const yData = + filteredData?.map((item) => ({ + name: item.name || 'unknown', + type: 'line', + data: item.yData.map(Number) + })) || []; + + return { + xData, + yData + }; + }, [cpuData, podList]); + + const refetchData = () => { + refetchCpuData(); + refetchMemoryData(); + }; + + return ( + + +
+ {!isLoading ? ( + <> + + CPU: {cpuLatestAvg}% + + + {cpuChartData?.yData?.length > 0 ? ( + + ) : ( +
+ + + {t('no_data_available')} + +
+ )} +
+ + Memory: {memoryLatestAvg}% + + + {memoryChartData?.yData?.length > 0 ? ( + + ) : ( +
+ + + {t('no_data_available')} + +
+ )} +
+ + ) : ( + + + + + + )} + + + ); +} + +export async function getServerSideProps(content: any) { + const appName = content?.query?.name || ''; + + return { + props: { + appName, + ...(await serviceSideProps(content)) + } + }; +} diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/CustomAccessModal.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/CustomAccessModal.tsx index 86dfeafb451..30fd00a1dd4 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/CustomAccessModal.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/CustomAccessModal.tsx @@ -14,7 +14,7 @@ import { ModalContent, ModalHeader } from '@chakra-ui/react'; -import { useTranslation } from 'react-i18next'; +import { useTranslation } from 'next-i18next'; import { Tip } from '@sealos/ui'; import { InfoOutlineIcon } from '@chakra-ui/icons'; import { useRequest } from '@/hooks/useRequest'; diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx index 8aec2ab98d2..ac33d401b71 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/Form.tsx @@ -435,7 +435,7 @@ const Form = ({ pr={`${pxVal}px`} height={'100%'} position={'relative'} - overflowY={'scroll'} + // overflowY={'scroll'} > {/* base info */} @@ -1108,8 +1108,8 @@ const Form = ({ onClick={() => setConfigEdit(item)} bg={'grayModern.25'} > - - + + {item.mountPath} @@ -1180,8 +1180,8 @@ const Form = ({ bg={'grayModern.25'} onClick={() => setStoreEdit(item)} > - - + + {item.path} @@ -1232,8 +1232,8 @@ const Form = ({ cursor={'not-allowed'} bg={'grayModern.25'} > - - + + {item.path} diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx index e252323346c..73f6b41f6ef 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx @@ -113,6 +113,7 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => amount: 0, manufacturers: '' }); + const [isSubmitting, setIsSubmitting] = useState(false); const { openConfirm, ConfirmChild } = useConfirm({ content: applyMessage }); @@ -169,7 +170,7 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => await putApp({ patch, appName, - stateFulSetYaml: yamlList.find((item) => item.filename === 'statefulSet.yaml')?.value + stateFulSetYaml: yamlList.find((item) => item.filename === 'statefulset.yaml')?.value }); } else { await postDeployApp(parsedNewYamlList); @@ -328,6 +329,7 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => h={'100%'} minWidth={'1024px'} backgroundColor={'grayModern.100'} + overflowY={'auto'} >
yamlList={yamlList} applyBtnText={applyBtnText} applyCb={() => { + if (isSubmitting) return; closeGuide(); + setIsSubmitting(true); formHook.handleSubmit(async (data) => { const parseYamls = formData2Yamls(data); setYamlList(parseYamls); @@ -390,7 +394,10 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => } } - openConfirm(() => submitSuccess(parseYamls))(); + openConfirm( + () => submitSuccess(parseYamls), + () => setIsSubmitting(false) + )(); }, submitError)(); }} /> diff --git a/frontend/providers/applaunchpad/src/pages/apps/index.tsx b/frontend/providers/applaunchpad/src/pages/apps.tsx similarity index 97% rename from frontend/providers/applaunchpad/src/pages/apps/index.tsx rename to frontend/providers/applaunchpad/src/pages/apps.tsx index 1fa79452138..5621a7336f4 100644 --- a/frontend/providers/applaunchpad/src/pages/apps/index.tsx +++ b/frontend/providers/applaunchpad/src/pages/apps.tsx @@ -5,10 +5,9 @@ import { serviceSideProps } from '@/utils/i18n'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/router'; import { useCallback, useEffect, useRef, useState } from 'react'; - import { RequestController, isElementInViewport } from '@/utils/tools'; -import AppList from './components/appList'; -import Empty from './components/empty'; +import AppList from '@/components/apps/appList'; +import Empty from '@/components/apps/empty'; const Home = () => { const router = useRouter(); diff --git a/frontend/providers/applaunchpad/src/pages/apps/index.module.scss b/frontend/providers/applaunchpad/src/pages/apps/index.module.scss deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/frontend/providers/applaunchpad/src/pages/icons.tsx b/frontend/providers/applaunchpad/src/pages/icons.tsx new file mode 100644 index 00000000000..af28bbfecc2 --- /dev/null +++ b/frontend/providers/applaunchpad/src/pages/icons.tsx @@ -0,0 +1,51 @@ +import MyIcon, { IconMap } from '@/components/Icon'; +import { useCopyData } from '@/utils/tools'; +import { Box, SimpleGrid } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +export default function IconsPage() { + const router = useRouter(); + const iconNames = Object.keys(IconMap) as Array; + const { copyData } = useCopyData(); + + const copyIconName = (iconName: string) => { + const iconCode = ``; + copyData(iconCode); + }; + + useEffect(() => { + if (process.env.NODE_ENV === 'production') { + router.replace('/apps'); + } + }, [router]); + + return ( + + + {iconNames.map((iconName) => ( + copyIconName(iconName)} + display="flex" + flexDirection="column" + alignItems="center" + _hover={{ bg: 'grayModern.50' }} + > + + + + + {iconName} + + + ))} + + + ); +} diff --git a/frontend/providers/applaunchpad/src/services/logFetch.ts b/frontend/providers/applaunchpad/src/services/logFetch.ts new file mode 100644 index 00000000000..712dae82ebe --- /dev/null +++ b/frontend/providers/applaunchpad/src/services/logFetch.ts @@ -0,0 +1,26 @@ +import { AxiosRequestConfig } from 'axios'; + +export const logFetch = async (props: AxiosRequestConfig, kubeconfig: string) => { + const { url, params } = props; + const queryString = typeof params === 'object' ? new URLSearchParams(params).toString() : params; + const requestOptions = { + method: 'GET', + headers: { + Authorization: encodeURIComponent(kubeconfig) + } + }; + const doMain = + global.AppConfig.launchpad.components.monitor.url || + 'http://launchpad-monitor.sealos.svc.cluster.local:8428'; + + try { + const response = await fetch(`${doMain}${url}?${queryString}`, requestOptions); + + if (!response.ok) { + throw new Error(`Error monitorFetch ${response.status}`); + } + return await response.json(); + } catch (error) { + throw error; + } +}; diff --git a/frontend/providers/applaunchpad/src/store/date.ts b/frontend/providers/applaunchpad/src/store/date.ts new file mode 100644 index 00000000000..36681c2e59e --- /dev/null +++ b/frontend/providers/applaunchpad/src/store/date.ts @@ -0,0 +1,29 @@ +import { subDays, subMinutes } from 'date-fns'; +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +type DateTimeState = { + startDateTime: Date; + endDateTime: Date; + timeZone: 'local' | 'utc'; + refreshInterval: number; + setStartDateTime: (time: Date) => void; + setEndDateTime: (time: Date) => void; + setTimeZone: (timeZone: 'local' | 'utc') => void; + setRefreshInterval: (val: number) => void; +}; + +const useDateTimeStore = create()( + immer((set, get) => ({ + startDateTime: subMinutes(new Date(), 30), + endDateTime: new Date(), + timeZone: 'local', + refreshInterval: 0, + setStartDateTime: (datetime) => set({ startDateTime: datetime }), + setEndDateTime: (datetime) => set({ endDateTime: datetime }), + setTimeZone: (timeZone) => set({ timeZone }), + setRefreshInterval: (val) => set({ refreshInterval: val }) + })) +); + +export default useDateTimeStore; diff --git a/frontend/providers/applaunchpad/src/store/logStore.ts b/frontend/providers/applaunchpad/src/store/logStore.ts new file mode 100644 index 00000000000..7327b4fcd96 --- /dev/null +++ b/frontend/providers/applaunchpad/src/store/logStore.ts @@ -0,0 +1,72 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import { devtools } from 'zustand/middleware'; + +interface LogState { + rawLogs: string; + parsedLogs: { + container: string; + pod: string; + logs_total: string; + _msg: string; + _time: string; + stream: 'stderr' | 'stdout'; + }[]; + logCounts: { logs_total: string; _time: string }[]; + setLogs: (data: string) => void; + exportLogs: () => void; + setLogCounts: (data: string) => void; +} + +export const useLogStore = create()( + devtools( + immer((set, get) => ({ + rawLogs: '', + parsedLogs: [], + logCounts: [], + setLogs: (data: string) => + set((state) => { + if (!data) { + state.rawLogs = ''; + state.parsedLogs = []; + return; + } + state.rawLogs = data; + const logLines = data.split('\n').filter((line) => line.trim()); + state.parsedLogs = logLines.map((line) => { + try { + return JSON.parse(line); + } catch (e) { + return { raw: line, parseError: true }; + } + }); + }), + setLogCounts: (data: string) => + set((state) => { + if (!data) { + state.logCounts = []; + return; + } + const logLines = data.split('\n').filter((line) => line.trim()); + + state.logCounts = logLines.map((line) => { + try { + return JSON.parse(line); + } catch (e) { + return { raw: line, parseError: true }; + } + }); + }), + exportLogs: () => { + const { rawLogs } = get(); + const blob = new Blob([rawLogs], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `logs-${new Date().toISOString()}.txt`; + a.click(); + URL.revokeObjectURL(url); + } + })) + ) +); diff --git a/frontend/providers/applaunchpad/src/styles/global.scss b/frontend/providers/applaunchpad/src/styles/global.scss index af9808bd3ec..bd7270d51c7 100644 --- a/frontend/providers/applaunchpad/src/styles/global.scss +++ b/frontend/providers/applaunchpad/src/styles/global.scss @@ -1,24 +1,38 @@ .table-cross { - border-radius: 6px; overflow: hidden; table-layout: fixed; width: 100%; th { - border: 1px solid transparent; text-align: left; padding: 8px 16px; user-select: all; font-weight: 500; color: #485264; + line-height: 16px; + &:first-child { + border-radius: 4px 0 0 4px; + } + &:last-child { + border-radius: 0 4px 4px 0; + } } thead tr { - background: #f4f4f7; + background: #f7f8fa; + } + + tbody tr:nth-child(even) { + background: #fbfbfc; } - tbody tr:nth-child(odd) { - background: #fafafc; + tbody tr { + &:first-child { + border-radius: 4px 0 0 4px; + } + &:last-child { + border-radius: 0 4px 4px 0; + } } } @@ -33,3 +47,33 @@ max-width: 600px; border: 1px solid #94b5ff; } + +// date picker +div.rdp { + --rdp-cell-size: 30px; + --rdp-accent-color: black; + --rdp-background-color: #f4f6f8; + --rdp-outline: 2px solid var(--rdp-accent-color); + --rdp-outline-selected: 2px solid rgba(0, 0, 0, 0.75); + margin: 12px 16px 8px 16px; +} + +button.rdp-button_reset.rdp-button.rdp-day { + border-radius: 6px; +} + +button.rdp-day_today { + background-color: #dbf3ff; + color: #0884dd; +} + +button.rdp-button.rdp-day.rdp-day_range_middle { + border-radius: 0px; + background-color: #f4f4f7; + color: #111824; +} + +button.rdp-button.rdp-day.rdp-day_range_end { + background-color: #111824; + color: white; +} diff --git a/frontend/providers/applaunchpad/src/types/index.d.ts b/frontend/providers/applaunchpad/src/types/index.d.ts index 51cbc29e43c..2e4b4a74578 100644 --- a/frontend/providers/applaunchpad/src/types/index.d.ts +++ b/frontend/providers/applaunchpad/src/types/index.d.ts @@ -53,6 +53,9 @@ export type AppConfigType = { billing: { url: string; }; + log: { + url: string; + }; }; appResourceFormSliderConfig: FormSliderListType; fileManger: FileMangerType; diff --git a/frontend/providers/applaunchpad/src/types/monitor.d.ts b/frontend/providers/applaunchpad/src/types/monitor.d.ts index 658b991ccf5..2ea2ac1eae2 100644 --- a/frontend/providers/applaunchpad/src/types/monitor.d.ts +++ b/frontend/providers/applaunchpad/src/types/monitor.d.ts @@ -42,7 +42,7 @@ export type MonitorQueryKey = { }; export type MonitorDataResult = { - name: string; + name?: string; xData: number[]; yData: string[]; }; diff --git a/frontend/providers/applaunchpad/src/utils/timeRange.ts b/frontend/providers/applaunchpad/src/utils/timeRange.ts new file mode 100644 index 00000000000..c94a4d7dd39 --- /dev/null +++ b/frontend/providers/applaunchpad/src/utils/timeRange.ts @@ -0,0 +1,73 @@ +import { subHours, subDays, subMinutes, subMonths } from 'date-fns'; + +type TimeUnit = 'h' | 'm' | 'd' | 'M'; + +interface TimeRange { + startTime: Date; + endTime: Date; +} + +/** + * Parse time range string + * @param range Time range string, e.g. "1h", "7d", "30m", "1M" + * @param endTime End time, defaults to current time + * @returns Object containing start and end time + */ +export function parseTimeRange(range: string, endTime: Date = new Date()): TimeRange { + const match = range.match(/^(\d+)([hmdM])$/i); + if (!match) { + throw new Error('Invalid time range format. Supported formats: 1h, 7d, 30m, 1M'); + } + + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase() as TimeUnit; + + let startTime: Date; + switch (unit) { + case 'h': + startTime = subHours(endTime, value); + break; + case 'm': + startTime = subMinutes(endTime, value); + break; + case 'd': + startTime = subDays(endTime, value); + break; + case 'M': + startTime = subMonths(endTime, value); + break; + default: + throw new Error('Unsupported time unit'); + } + + return { + startTime, + endTime + }; +} + +/** + * Convert time range to string format + * @param startTime Start time + * @param endTime End time + * @returns Time range string, e.g. "1h", "7d" + */ +export function formatTimeRange(startTime: Date, endTime: Date): string { + const diffMs = endTime.getTime() - startTime.getTime(); + const diffMinutes = Math.round(diffMs / (1000 * 60)); + const diffHours = Math.round(diffMs / (1000 * 60 * 60)); + const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); + const diffMonths = Math.round(diffMs / (1000 * 60 * 60 * 24 * 30)); + + if (diffMinutes < 60) { + return `${diffMinutes}m`; + } else if (diffHours < 24) { + return `${diffHours}h`; + } else if (diffDays === 1) { + return '24h'; + } else if (diffDays < 30) { + return `${diffDays}d`; + } else { + return `${diffMonths}M`; + } +}