settings page: add notify, add password input

add total usage percent to storage table
This commit is contained in:
NlightN22 2024-03-14 17:58:06 +07:00
parent b896c88f6c
commit b52ea6ff37
10 changed files with 145 additions and 59 deletions

View File

@ -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

View File

@ -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",

View File

@ -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: "Извините, мы не можем найти такую страницу",

View File

@ -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}
/> />
))} ))}

View File

@ -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,
}); });

View File

@ -1,44 +1,57 @@
.root { .root {
position: relative; position: relative;
}
.label {
position: absolute;
z-index: 2;
top: 0.5rem;
left: 0.5rem;
pointer-events: none;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition:
transform 150ms ease,
font-size 150ms ease,
color 150ms ease;
&[data-floating] {
transform: translate(-0.5rem, -2rem);
font-size: var(--mantine-font-size-xm);
font-weight: 500;
}
}
.required {
transition: opacity 150ms ease;
opacity: 0;
[data-floating] & {
opacity: 1;
}
}
.input {
&::placeholder {
transition: color 150ms ease;
color: transparent;
} }
.label { &[data-floating] {
position: absolute;
z-index: 2;
top: rem(7px);
left: 0.5rem;
pointer-events: none;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition:
transform 150ms ease,
font-size 150ms ease,
color 150ms ease;
&[data-floating] {
transform: translate(-0.5rem, -1.625rem);
font-size: var(--mantine-font-size-xm);
font-weight: 500;
}
}
.required {
transition: opacity 150ms ease;
opacity: 0;
[data-floating] & {
opacity: 1;
}
}
.input {
&::placeholder { &::placeholder {
transition: color 150ms ease; color: var(--mantine-color-placeholder);
color: transparent;
}
&[data-floating] {
&::placeholder {
color: var(--mantine-color-placeholder);
}
} }
} }
}
.innerInput {
&::placeholder {
transition: color 150ms ease;
color: transparent;
}
&[data-floating] {
&::placeholder {
color: var(--mantine-color-placeholder);
}
}
}

View File

@ -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

View File

@ -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);
} }

View File

@ -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 {

View File

@ -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>
); );