settings page: add notify, add password input
add total usage percent to storage table
This commit is contained in:
parent
b896c88f6c
commit
b52ea6ff37
@ -3,6 +3,9 @@ version: '3.0'
|
|||||||
services:
|
services:
|
||||||
front:
|
front:
|
||||||
image: oncharterliz/multi-frigate:latest
|
image: oncharterliz/multi-frigate:latest
|
||||||
|
volumes:
|
||||||
|
- /etc/timezone:/etc/timezone:ro # for Unix TZ
|
||||||
|
- /etc/localtime:/etc/localtime:ro # for Unix Time
|
||||||
environment:
|
environment:
|
||||||
FRIGATE_PROXY: http://localhost:4000
|
FRIGATE_PROXY: http://localhost:4000
|
||||||
OPENID_SERVER: https://server:port/realms/your-realm
|
OPENID_SERVER: https://server:port/realms/your-realm
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const en = {
|
|||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
usagePercent: 'Usage %',
|
usagePercent: 'Usage %',
|
||||||
sreamBandwidth: 'Stream Bandwidth',
|
sreamBandwidth: 'Stream Bandwidth',
|
||||||
|
total: 'Total',
|
||||||
},
|
},
|
||||||
cameraStatTable: {
|
cameraStatTable: {
|
||||||
process: 'Process',
|
process: 'Process',
|
||||||
@ -55,6 +56,7 @@ const en = {
|
|||||||
doubleClickToFullHint: 'Double click for fullscreen',
|
doubleClickToFullHint: 'Double click for fullscreen',
|
||||||
rating: 'Rating',
|
rating: 'Rating',
|
||||||
},
|
},
|
||||||
|
|
||||||
version: 'Version',
|
version: 'Version',
|
||||||
uptime: 'Uptime',
|
uptime: 'Uptime',
|
||||||
pleaseSelectRole: 'Please select Role',
|
pleaseSelectRole: 'Please select Role',
|
||||||
@ -92,6 +94,7 @@ const en = {
|
|||||||
retry: "Retry",
|
retry: "Retry",
|
||||||
youCanRetryOrGoToMain: "You can retry or return to the main page",
|
youCanRetryOrGoToMain: "You can retry or return to the main page",
|
||||||
errors: {
|
errors: {
|
||||||
|
emptyResponse: 'Empty response',
|
||||||
somthingGoesWrong: "Something went wrong",
|
somthingGoesWrong: "Something went wrong",
|
||||||
403: "Sorry, you do not have access",
|
403: "Sorry, you do not have access",
|
||||||
404: "Sorry, we cannot find that page",
|
404: "Sorry, we cannot find that page",
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const ru = {
|
|||||||
usage: 'Занято',
|
usage: 'Занято',
|
||||||
usagePercent: 'Занято %',
|
usagePercent: 'Занято %',
|
||||||
sreamBandwidth: 'Скорость потока',
|
sreamBandwidth: 'Скорость потока',
|
||||||
|
total: 'Итого',
|
||||||
},
|
},
|
||||||
cameraStatTable: {
|
cameraStatTable: {
|
||||||
process: 'Процесс',
|
process: 'Процесс',
|
||||||
@ -92,6 +93,7 @@ const ru = {
|
|||||||
retry: "Повторить",
|
retry: "Повторить",
|
||||||
youCanRetryOrGoToMain: "Вы можете повторить или вернуться на главную",
|
youCanRetryOrGoToMain: "Вы можете повторить или вернуться на главную",
|
||||||
errors: {
|
errors: {
|
||||||
|
emptyResponse: 'Пустой ответ',
|
||||||
somthingGoesWrong: "Что-то пошло не так",
|
somthingGoesWrong: "Что-то пошло не так",
|
||||||
403: "Извините, у вас нет доступа",
|
403: "Извините, у вас нет доступа",
|
||||||
404: "Извините, мы не можем найти такую страницу",
|
404: "Извините, мы не можем найти такую страницу",
|
||||||
|
|||||||
@ -11,13 +11,16 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Context } from '..';
|
import { Context } from '..';
|
||||||
import { useAdminRole } from '../hooks/useAdminRole';
|
import { useAdminRole } from '../hooks/useAdminRole';
|
||||||
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
|
||||||
import { GetConfig } from '../services/frigate.proxy/frigate.schema';
|
import { GetConfig, PutConfig } from '../services/frigate.proxy/frigate.schema';
|
||||||
import { FloatingLabelInput } from '../shared/components/inputs/FloatingLabelInput';
|
import { FloatingLabelInput } from '../shared/components/inputs/FloatingLabelInput';
|
||||||
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
import CenterLoader from '../shared/components/loaders/CenterLoader';
|
||||||
import { dimensions } from '../shared/dimensions/dimensions';
|
import { dimensions } from '../shared/dimensions/dimensions';
|
||||||
import { isProduction } from '../shared/env.const';
|
import { isProduction } from '../shared/env.const';
|
||||||
import Forbidden from './403';
|
import Forbidden from './403';
|
||||||
import RetryErrorPage from './RetryErrorPage';
|
import RetryErrorPage from './RetryErrorPage';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react';
|
||||||
|
|
||||||
const SettingsPage = () => {
|
const SettingsPage = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@ -41,7 +44,7 @@ const SettingsPage = () => {
|
|||||||
const { isAdmin, isLoading: adminLoading } = useAdminRole()
|
const { isAdmin, isLoading: adminLoading } = useAdminRole()
|
||||||
|
|
||||||
|
|
||||||
const ecryptedTemplate = '**********'
|
const ecryptedTemplate = 'encrypted value is exist'
|
||||||
const mapEncryptedToView = (data: GetConfig[] | undefined): GetConfig[] | undefined => {
|
const mapEncryptedToView = (data: GetConfig[] | undefined): GetConfig[] | undefined => {
|
||||||
return data?.map(item => {
|
return data?.map(item => {
|
||||||
const { value, encrypted, ...rest } = item
|
const { value, encrypted, ...rest } = item
|
||||||
@ -54,10 +57,35 @@ const SettingsPage = () => {
|
|||||||
const isMobile = useMediaQuery(dimensions.mobileSize)
|
const isMobile = useMediaQuery(dimensions.mobileSize)
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: frigateApi.putConfig,
|
mutationFn: (config: PutConfig[]) => frigateApi.putConfig(config).catch(error => {
|
||||||
|
if (error.response && error.response.data) {
|
||||||
|
return Promise.reject(error.response.data)
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: [frigateQueryKeys.getConfig] })
|
queryClient.invalidateQueries({ queryKey: [frigateQueryKeys.getConfig] })
|
||||||
|
notifications.show({
|
||||||
|
id: v4(),
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: 5000,
|
||||||
|
title: `Sucessfully`,
|
||||||
|
message: `Sucessfully saved`,
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCircleCheck />
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
notifications.show({
|
||||||
|
id: e.message,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: false,
|
||||||
|
title: "Error",
|
||||||
|
message: e.message,
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconAlertCircle />,
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
@ -122,6 +150,7 @@ const SettingsPage = () => {
|
|||||||
label={config.description}
|
label={config.description}
|
||||||
value={config.value}
|
value={config.value}
|
||||||
placeholder={config.description}
|
placeholder={config.description}
|
||||||
|
encrypted={config.encrypted}
|
||||||
ecryptedValue={ecryptedTemplate}
|
ecryptedValue={ecryptedTemplate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const AutoUpdatedImage = ({
|
|||||||
queryKey: [imageUrl],
|
queryKey: [imageUrl],
|
||||||
queryFn: () => proxyApi.getImageFrigate(imageUrl, 522),
|
queryFn: () => proxyApi.getImageFrigate(imageUrl, 522),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
gcTime: Infinity,
|
gcTime: 5 * 60 * 1000,
|
||||||
refetchInterval: isVisible ? 30 * 1000 : undefined,
|
refetchInterval: isVisible ? 30 * 1000 : undefined,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
.label {
|
.label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
top: rem(7px);
|
top: 0.5rem;
|
||||||
left: 0.5rem;
|
left: 0.5rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
|
||||||
@ -15,7 +15,7 @@
|
|||||||
color 150ms ease;
|
color 150ms ease;
|
||||||
|
|
||||||
&[data-floating] {
|
&[data-floating] {
|
||||||
transform: translate(-0.5rem, -1.625rem);
|
transform: translate(-0.5rem, -2rem);
|
||||||
font-size: var(--mantine-font-size-xm);
|
font-size: var(--mantine-font-size-xm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -42,3 +42,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.innerInput {
|
||||||
|
&::placeholder {
|
||||||
|
transition: color 150ms ease;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-floating] {
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--mantine-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,22 @@
|
|||||||
import { TextInput, TextInputProps, createStyles } from '@mantine/core';
|
import { PasswordInput, TextInput, TextInputProps, createStyles } from '@mantine/core';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import classes from './FloatingLabelInput.module.css';
|
import classes from './FloatingLabelInput.module.css';
|
||||||
|
|
||||||
interface FloatingLabelInputProps extends TextInputProps {
|
interface FloatingLabelInputProps extends TextInputProps {
|
||||||
value?: string,
|
value?: string
|
||||||
ecryptedValue?: string,
|
encrypted?: boolean
|
||||||
|
ecryptedValue?: string
|
||||||
onChangeValue?: (key: string, value: string) => void
|
onChangeValue?: (key: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const FloatingLabelInput = (props: FloatingLabelInputProps) => {
|
export const FloatingLabelInput: React.FC<FloatingLabelInputProps> = ({
|
||||||
const { value: propVal, onChangeValue, ecryptedValue, ...rest } = props
|
value: propVal,
|
||||||
|
onChangeValue,
|
||||||
|
encrypted,
|
||||||
|
ecryptedValue,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [value, setValue] = useState(propVal || '');
|
const [value, setValue] = useState(propVal || '');
|
||||||
const floating = value?.trim().length !== 0 || focused || undefined;
|
const floating = value?.trim().length !== 0 || focused || undefined;
|
||||||
@ -26,10 +32,26 @@ export const FloatingLabelInput = (props: FloatingLabelInputProps) => {
|
|||||||
|
|
||||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setValue(event.currentTarget.value);
|
setValue(event.currentTarget.value);
|
||||||
if (onChangeValue && props.name) {
|
if (onChangeValue && rest.name) {
|
||||||
onChangeValue(props.name, event.currentTarget.value);
|
onChangeValue(rest.name, event.currentTarget.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (encrypted) {
|
||||||
|
return (
|
||||||
|
<PasswordInput
|
||||||
|
value={value}
|
||||||
|
classNames={classes}
|
||||||
|
onChange={handleChange}
|
||||||
|
onFocus={handleFocused}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
mt='2rem'
|
||||||
|
autoComplete="nope"
|
||||||
|
data-floating={floating}
|
||||||
|
labelProps={{ 'data-floating': floating }}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@ -7,7 +7,8 @@ export const formatBytes = (bytes: number, decimals = 2): string => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatMBytes = (mb: number, decimals?: number): string => {
|
export const formatMBytes = (mb?: number | null, decimals?: number): string => {
|
||||||
|
if (!mb) return '0'
|
||||||
const bytes = mb * 1024 * 1024;
|
const bytes = mb * 1024 * 1024;
|
||||||
return formatBytes(bytes, decimals);
|
return formatBytes(bytes, decimals);
|
||||||
}
|
}
|
||||||
@ -3,9 +3,9 @@ export interface GetHostStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraStorage {
|
export interface CameraStorage {
|
||||||
bandwidth: number // MiB/hr
|
bandwidth?: number // MiB/hr
|
||||||
usage: number // MB
|
usage?: number // MB
|
||||||
usage_percent: number // Usage / 1024 / Total storage size * 100
|
usage_percent?: number // Usage / 1024 / Total storage size * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetVaInfo {
|
export interface GetVaInfo {
|
||||||
|
|||||||
@ -46,9 +46,9 @@ const FrigateStorageStateTable: React.FC<TableProps> = ({
|
|||||||
return Object.entries(data).map<StorageItem>(([name, storage]) => {
|
return Object.entries(data).map<StorageItem>(([name, storage]) => {
|
||||||
return {
|
return {
|
||||||
cameraName: name,
|
cameraName: name,
|
||||||
usage: storage.usage,
|
usage: storage.usage || 0,
|
||||||
usagePercent: storage.usage_percent,
|
usagePercent: storage.usage_percent || 0,
|
||||||
sreamBandwidth: storage.bandwidth,
|
sreamBandwidth: storage.bandwidth || 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [data])
|
}, [data])
|
||||||
@ -71,8 +71,7 @@ const FrigateStorageStateTable: React.FC<TableProps> = ({
|
|||||||
|
|
||||||
if (isPending) return <CogwheelLoader />
|
if (isPending) return <CogwheelLoader />
|
||||||
if (isError) return <RetryError onRetry={refetch} />
|
if (isError) return <RetryError onRetry={refetch} />
|
||||||
// if (!tableData || tableData.length < 1) return <Center><Text>Empty response</Text></Center>
|
if (!tableData ) return <Center><Text>{t('errors.emptyResponse')}</Text></Center>
|
||||||
|
|
||||||
|
|
||||||
const headTitle: TableHead[] = [
|
const headTitle: TableHead[] = [
|
||||||
{ propertyName: 'cameraName', title: t('camera') },
|
{ propertyName: 'cameraName', title: t('camera') },
|
||||||
@ -93,18 +92,31 @@ const FrigateStorageStateTable: React.FC<TableProps> = ({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const rows = tableData.map(item => {
|
const rows = tableData.map(item => {
|
||||||
return (
|
return (
|
||||||
<tr key={item.cameraName}>
|
<tr key={item.cameraName}>
|
||||||
<td><Text align='center'>{item.cameraName}</Text></td>
|
<td><Text align='center'>{item.cameraName}</Text></td>
|
||||||
<td><Text align='center'>{formatMBytes(item.usage)}</Text></td>
|
<td><Text align='center'>{formatMBytes(item.usage)}</Text></td>
|
||||||
<td><Text align='center'>{item.usagePercent.toFixed(4)} %</Text></td>
|
<td><Text align='center'>{item.usagePercent.toFixed(4)} %</Text></td>
|
||||||
<td><Text align='center'>{item.sreamBandwidth} MiB/hr</Text></td>
|
<td><Text align='center'>{item.sreamBandwidth.toFixed(2) } MiB/hr</Text></td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const totalRow = () => {
|
||||||
|
const totalUsage = tableData.reduce((acc, curr) => acc + curr.usage, 0)
|
||||||
|
const totalStreamBandwidth = tableData.reduce((acc, curr) => acc + curr.sreamBandwidth, 0)
|
||||||
|
const totalUsagePercent = tableData.reduce((acc, curr) => acc + curr.usagePercent, 0)
|
||||||
|
return (
|
||||||
|
<tr key={totalUsage}>
|
||||||
|
<td><Text align='center'>{t('cameraStorageTable.total')}</Text></td>
|
||||||
|
<td><Text align='center'>{formatMBytes(totalUsage)}</Text></td>
|
||||||
|
<td><Text align='center'>{totalUsagePercent.toFixed(4)} %</Text></td>
|
||||||
|
<td><Text align='center'>{totalStreamBandwidth.toFixed(2)} MiB/hr</Text></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table >
|
<Table >
|
||||||
<thead>
|
<thead>
|
||||||
@ -114,6 +126,7 @@ const FrigateStorageStateTable: React.FC<TableProps> = ({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows}
|
{rows}
|
||||||
|
{totalRow()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user