mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
feat(web): Delete events from Event page and API (#991)
Co-authored-by: Scott Roach <scott@thinkpivot.io> Co-authored-by: Paul Armstrong <paul@spaceyak.com>
This commit is contained in:
parent
482399d82f
commit
ebb6d348a3
@ -21,13 +21,13 @@ RUN apt-get -qq update \
|
|||||||
&& apt-get -qq install --no-install-recommends -y \
|
&& apt-get -qq install --no-install-recommends -y \
|
||||||
gnupg wget unzip tzdata nginx libnginx-mod-rtmp \
|
gnupg wget unzip tzdata nginx libnginx-mod-rtmp \
|
||||||
&& apt-get -qq install --no-install-recommends -y \
|
&& apt-get -qq install --no-install-recommends -y \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
&& pip3 install -U /wheels/*.whl \
|
&& pip3 install -U /wheels/*.whl \
|
||||||
&& APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \
|
&& APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \
|
||||||
&& echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \
|
&& echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \
|
||||||
&& echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \
|
&& echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \
|
||||||
&& apt-get -qq update && apt-get -qq install --no-install-recommends -y \
|
&& apt-get -qq update && apt-get -qq install --no-install-recommends -y \
|
||||||
libedgetpu1-max=15.0 \
|
libedgetpu1-max=15.0 \
|
||||||
&& rm -rf /var/lib/apt/lists/* /wheels \
|
&& rm -rf /var/lib/apt/lists/* /wheels \
|
||||||
&& (apt-get autoremove -y; apt-get autoclean -y)
|
&& (apt-get autoremove -y; apt-get autoclean -y)
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ title: HTTP API
|
|||||||
|
|
||||||
A web server is available on port 5000 with the following endpoints.
|
A web server is available on port 5000 with the following endpoints.
|
||||||
|
|
||||||
### `/api/<camera_name>`
|
### `GET /api/<camera_name>`
|
||||||
|
|
||||||
An mjpeg stream for debugging. Keep in mind the mjpeg endpoint is for debugging only and will put additional load on the system when in use.
|
An mjpeg stream for debugging. Keep in mind the mjpeg endpoint is for debugging only and will put additional load on the system when in use.
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ Accepts the following query string parameters:
|
|||||||
|
|
||||||
You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:5000/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:5000/back?fps=10` or both with `?fps=10&h=1000`.
|
You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:5000/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:5000/back?fps=10` or both with `?fps=10&h=1000`.
|
||||||
|
|
||||||
### `/api/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]`
|
### `GET /api/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]`
|
||||||
|
|
||||||
The best snapshot for any object type. It is a full resolution image by default.
|
The best snapshot for any object type. It is a full resolution image by default.
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ Example parameters:
|
|||||||
- `h=300`: resizes the image to 300 pixes tall
|
- `h=300`: resizes the image to 300 pixes tall
|
||||||
- `crop=1`: crops the image to the region of the detection rather than returning the entire image
|
- `crop=1`: crops the image to the region of the detection rather than returning the entire image
|
||||||
|
|
||||||
### `/api/<camera_name>/latest.jpg[?h=300]`
|
### `GET /api/<camera_name>/latest.jpg[?h=300]`
|
||||||
|
|
||||||
The most recent frame that frigate has finished processing. It is a full resolution image by default.
|
The most recent frame that frigate has finished processing. It is a full resolution image by default.
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ Example parameters:
|
|||||||
|
|
||||||
- `h=300`: resizes the image to 300 pixes tall
|
- `h=300`: resizes the image to 300 pixes tall
|
||||||
|
|
||||||
### `/api/stats`
|
### `GET /api/stats`
|
||||||
|
|
||||||
Contains some granular debug info that can be used for sensors in HomeAssistant.
|
Contains some granular debug info that can be used for sensors in HomeAssistant.
|
||||||
|
|
||||||
@ -150,15 +150,15 @@ Sample response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `/api/config`
|
### `GET /api/config`
|
||||||
|
|
||||||
A json representation of your configuration
|
A json representation of your configuration
|
||||||
|
|
||||||
### `/api/version`
|
### `GET /api/version`
|
||||||
|
|
||||||
Version info
|
Version info
|
||||||
|
|
||||||
### `/api/events`
|
### `GET /api/events`
|
||||||
|
|
||||||
Events from the database. Accepts the following query string parameters:
|
Events from the database. Accepts the following query string parameters:
|
||||||
|
|
||||||
@ -174,19 +174,23 @@ Events from the database. Accepts the following query string parameters:
|
|||||||
| `has_clip` | int | Filter to events that have clips (0 or 1) |
|
| `has_clip` | int | Filter to events that have clips (0 or 1) |
|
||||||
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
|
| `include_thumbnails` | int | Include thumbnails in the response (0 or 1) |
|
||||||
|
|
||||||
### `/api/events/summary`
|
### `GET /api/events/summary`
|
||||||
|
|
||||||
Returns summary data for events in the database. Used by the HomeAssistant integration.
|
Returns summary data for events in the database. Used by the HomeAssistant integration.
|
||||||
|
|
||||||
### `/api/events/<id>`
|
### `GET /api/events/<id>`
|
||||||
|
|
||||||
Returns data for a single event.
|
Returns data for a single event.
|
||||||
|
|
||||||
### `/api/events/<id>/thumbnail.jpg`
|
### `DELETE /api/events/<id>`
|
||||||
|
|
||||||
|
Permanently deletes the event along with any clips/snapshots.
|
||||||
|
|
||||||
|
### `GET /api/events/<id>/thumbnail.jpg`
|
||||||
|
|
||||||
Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio.
|
Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio.
|
||||||
|
|
||||||
### `/api/events/<id>/snapshot.jpg`
|
### `GET /api/events/<id>/snapshot.jpg`
|
||||||
|
|
||||||
Returns the snapshot image for the event id. Works while the event is in progress and after completion.
|
Returns the snapshot image for the event id. Works while the event is in progress and after completion.
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import gevent
|
import gevent
|
||||||
@ -178,15 +179,36 @@ def events_summary():
|
|||||||
return jsonify([e for e in groups.dicts()])
|
return jsonify([e for e in groups.dicts()])
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/events/<id>")
|
@bp.route("/events/<id>", methods=("GET",))
|
||||||
def event(id):
|
def event(id):
|
||||||
try:
|
try:
|
||||||
return model_to_dict(Event.get(Event.id == id))
|
return model_to_dict(Event.get(Event.id == id))
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
|
@bp.route('/events/<id>', methods=('DELETE',))
|
||||||
|
def delete_event(id):
|
||||||
|
try:
|
||||||
|
event = Event.get(Event.id == id)
|
||||||
|
except DoesNotExist:
|
||||||
|
return make_response(jsonify({"success": False, "message": "Event" + id + " not found"}),404)
|
||||||
|
|
||||||
@bp.route("/events/<id>/thumbnail.jpg")
|
|
||||||
|
media_name = f"{event.camera}-{event.id}"
|
||||||
|
if event.has_snapshot:
|
||||||
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||||
|
media.unlink(missing_ok=True)
|
||||||
|
if event.has_clip:
|
||||||
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
||||||
|
media.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
event.delete_instance()
|
||||||
|
return make_response(jsonify({"success": True, "message": "Event" + id + " deleted"}),200)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/events/<id>/thumbnail.jpg')
|
||||||
def event_thumbnail(id):
|
def event_thumbnail(id):
|
||||||
format = request.args.get("format", "ios")
|
format = request.args.get("format", "ios")
|
||||||
thumbnail_bytes = None
|
thumbnail_bytes = None
|
||||||
|
@ -112,6 +112,7 @@ http {
|
|||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
add_header 'Access-Control-Allow-Origin' '*';
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
|
||||||
add_header Cache-Control "no-store";
|
add_header Cache-Control "no-store";
|
||||||
proxy_pass http://frigate_api/;
|
proxy_pass http://frigate_api/;
|
||||||
proxy_pass_request_headers on;
|
proxy_pass_request_headers on;
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="z-0"></div>
|
<div id="root" class="z-0"></div>
|
||||||
|
<div id="dialogs" class="z-0"></div>
|
||||||
<div id="menus" class="z-0"></div>
|
<div id="menus" class="z-0"></div>
|
||||||
<div id="tooltips" class="z-0"></div>
|
<div id="tooltips" class="z-0"></div>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
47
web/src/components/Dialog.jsx
Normal file
47
web/src/components/Dialog.jsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import Button from './Button';
|
||||||
|
import Heading from './Heading';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
|
||||||
|
export default function Dialog({ actions = [], portalRootID = 'dialogs', title, text }) {
|
||||||
|
const portalRoot = portalRootID && document.getElementById(portalRootID);
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setShow(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
|
<Fragment>
|
||||||
|
<div
|
||||||
|
data-testid="scrim"
|
||||||
|
key="scrim"
|
||||||
|
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="modal"
|
||||||
|
className={`absolute rounded shadow-2xl bg-white dark:bg-gray-700 max-w-sm text-gray-900 dark:text-white transition-transform transition-opacity duration-75 transform scale-90 opacity-0 ${
|
||||||
|
show ? 'scale-100 opacity-100' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<Heading size="lg">{title}</Heading>
|
||||||
|
<p>{text}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
|
||||||
|
{actions.map(({ color, text, onClick, ...props }, i) => (
|
||||||
|
<Button className="ml-2" color={color} key={i} onClick={onClick} type="text" {...props}>
|
||||||
|
{text}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
return portalRoot ? createPortal(dialog, portalRoot) : dialog;
|
||||||
|
}
|
38
web/src/components/__tests__/Dialog.test.jsx
Normal file
38
web/src/components/__tests__/Dialog.test.jsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import Dialog from '../Dialog';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/preact';
|
||||||
|
|
||||||
|
describe('Dialog', () => {
|
||||||
|
let portal;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
portal = document.createElement('div');
|
||||||
|
portal.id = 'dialogs';
|
||||||
|
document.body.appendChild(portal);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
document.body.removeChild(portal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders to a portal', async () => {
|
||||||
|
render(<Dialog title="Tacos" text="This is the dialog" />);
|
||||||
|
expect(screen.getByText('Tacos')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders action buttons', async () => {
|
||||||
|
const handleClick = jest.fn();
|
||||||
|
render(
|
||||||
|
<Dialog
|
||||||
|
actions={[
|
||||||
|
{ color: 'red', text: 'Delete' },
|
||||||
|
{ text: 'Okay', onClick: handleClick },
|
||||||
|
]}
|
||||||
|
title="Tacos"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
|
||||||
|
expect(handleClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
13
web/src/icons/Delete.jsx
Normal file
13
web/src/icons/Delete.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Delete({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||||
|
<path d="M6 21h12V7H6v14zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Delete);
|
@ -1,5 +1,10 @@
|
|||||||
import { h, Fragment } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
|
import { useCallback, useState } from 'preact/hooks';
|
||||||
|
import { route } from 'preact-router';
|
||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
|
import Button from '../components/Button';
|
||||||
|
import Delete from '../icons/Delete'
|
||||||
|
import Dialog from '../components/Dialog';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Link from '../components/Link';
|
import Link from '../components/Link';
|
||||||
import { FetchStatus, useApiHost, useEvent } from '../api';
|
import { FetchStatus, useApiHost, useEvent } from '../api';
|
||||||
@ -8,9 +13,39 @@ import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
|
|||||||
export default function Event({ eventId }) {
|
export default function Event({ eventId }) {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
const { data, status } = useEvent(eventId);
|
const { data, status } = useEvent(eventId);
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
|
||||||
|
|
||||||
|
const handleClickDelete = () => {
|
||||||
|
setShowDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissDeleteDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleClickDeleteDialog = useCallback(async () => {
|
||||||
|
|
||||||
|
let success;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiHost}/api/events/${eventId}`, { method: 'DELETE' });
|
||||||
|
success = await (response.status < 300 ? response.json() : { success: true });
|
||||||
|
setDeleteStatus(success ? FetchStatus.LOADED : FetchStatus.ERROR);
|
||||||
|
} catch (e) {
|
||||||
|
setDeleteStatus(FetchStatus.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setDeleteStatus(FetchStatus.LOADED);
|
||||||
|
setShowDialog(false);
|
||||||
|
route('/events', true);
|
||||||
|
|
||||||
|
}
|
||||||
|
}, [apiHost, eventId, setShowDialog]);
|
||||||
|
|
||||||
if (status !== FetchStatus.LOADED) {
|
if (status !== FetchStatus.LOADED) {
|
||||||
return <ActivityIndicator />;
|
return <ActivityIndicator />
|
||||||
}
|
}
|
||||||
|
|
||||||
const startime = new Date(data.start_time * 1000);
|
const startime = new Date(data.start_time * 1000);
|
||||||
@ -18,9 +53,27 @@ export default function Event({ eventId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Heading>
|
<div className="flex">
|
||||||
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
<Heading className="flex-grow">
|
||||||
</Heading>
|
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span>
|
||||||
|
</Heading>
|
||||||
|
<Button className="self-start" color="red" onClick={handleClickDelete}>
|
||||||
|
<Delete className="w-6" /> Delete event
|
||||||
|
</Button>
|
||||||
|
{showDialog ? (
|
||||||
|
<Dialog
|
||||||
|
onDismiss={handleDismissDeleteDialog}
|
||||||
|
title="Delete Event?"
|
||||||
|
text="This event will be permanently deleted along with any related clips and snapshots"
|
||||||
|
actions={[
|
||||||
|
deleteStatus !== FetchStatus.LOADING
|
||||||
|
? { text: 'Delete', color: 'red', onClick: handleClickDeleteDialog }
|
||||||
|
: { text: 'Deleting…', color: 'red', disabled: true },
|
||||||
|
{ text: 'Cancel', onClick: handleDismissDeleteDialog },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Table class="w-full">
|
<Table class="w-full">
|
||||||
<Thead>
|
<Thead>
|
||||||
|
@ -2,6 +2,7 @@ import { h } from 'preact';
|
|||||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||||
import ArrowDropup from '../icons/ArrowDropup';
|
import ArrowDropup from '../icons/ArrowDropup';
|
||||||
import Button from '../components/Button';
|
import Button from '../components/Button';
|
||||||
|
import Dialog from '../components/Dialog';
|
||||||
import Heading from '../components/Heading';
|
import Heading from '../components/Heading';
|
||||||
import Select from '../components/Select';
|
import Select from '../components/Select';
|
||||||
import Switch from '../components/Switch';
|
import Switch from '../components/Switch';
|
||||||
@ -10,6 +11,7 @@ import { useCallback, useState } from 'preact/hooks';
|
|||||||
|
|
||||||
export default function StyleGuide() {
|
export default function StyleGuide() {
|
||||||
const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false });
|
const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false });
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
const handleSwitch = useCallback(
|
const handleSwitch = useCallback(
|
||||||
(id, checked) => {
|
(id, checked) => {
|
||||||
@ -18,6 +20,10 @@ export default function StyleGuide() {
|
|||||||
[switches]
|
[switches]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDismissDialog = () => {
|
||||||
|
setShowDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Heading size="md">Button</Heading>
|
<Heading size="md">Button</Heading>
|
||||||
@ -59,6 +65,26 @@ export default function StyleGuide() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Heading size="md">Dialog</Heading>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Show Dialog
|
||||||
|
</Button>
|
||||||
|
{showDialog ? (
|
||||||
|
<Dialog
|
||||||
|
onDismiss={handleDismissDialog}
|
||||||
|
title="This is a dialog"
|
||||||
|
text="Would you like to see more?"
|
||||||
|
actions={[
|
||||||
|
{ text: 'Yes', color: 'red', onClick: handleDismissDialog },
|
||||||
|
{ text: 'No', onClick: handleDismissDialog },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Heading size="md">Switch</Heading>
|
<Heading size="md">Switch</Heading>
|
||||||
<div className="flex-col space-y-4 max-w-4xl">
|
<div className="flex-col space-y-4 max-w-4xl">
|
||||||
<Switch label="Disabled, off" labelPosition="after" />
|
<Switch label="Disabled, off" labelPosition="after" />
|
||||||
|
Loading…
Reference in New Issue
Block a user