Moved admin to /admin and created blank placeholder for v2 frontend
This commit is contained in:
267
web/pages/admin/access-tokens.tsx
Normal file
267
web/pages/admin/access-tokens.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Tag,
|
||||
Space,
|
||||
Button,
|
||||
Modal,
|
||||
Checkbox,
|
||||
Input,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
import format from 'date-fns/format';
|
||||
|
||||
import {
|
||||
fetchData,
|
||||
ACCESS_TOKENS,
|
||||
DELETE_ACCESS_TOKEN,
|
||||
CREATE_ACCESS_TOKEN,
|
||||
} from '../../utils/apis';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const availableScopes = {
|
||||
CAN_SEND_SYSTEM_MESSAGES: {
|
||||
name: 'System messages',
|
||||
description: 'Can send official messages on behalf of the system.',
|
||||
color: 'purple',
|
||||
},
|
||||
CAN_SEND_MESSAGES: {
|
||||
name: 'User chat messages',
|
||||
description: 'Can send chat messages on behalf of the owner of this token.',
|
||||
color: 'green',
|
||||
},
|
||||
HAS_ADMIN_ACCESS: {
|
||||
name: 'Has admin access',
|
||||
description: 'Can perform administrative actions such as moderation, get server statuses, etc.',
|
||||
color: 'red',
|
||||
},
|
||||
};
|
||||
|
||||
function convertScopeStringToTag(scopeString: string) {
|
||||
if (!scopeString || !availableScopes[scopeString]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scope = availableScopes[scopeString];
|
||||
|
||||
return (
|
||||
<Tooltip key={scopeString} title={scope.description}>
|
||||
<Tag color={scope.color}>{scope.name}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onOk: any; // todo: make better type
|
||||
visible: boolean;
|
||||
}
|
||||
function NewTokenModal(props: Props) {
|
||||
const { onOk, onCancel, visible } = props;
|
||||
const [selectedScopes, setSelectedScopes] = useState([]);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const scopes = Object.keys(availableScopes).map(key => ({
|
||||
value: key,
|
||||
label: availableScopes[key].description,
|
||||
}));
|
||||
|
||||
function onChange(checkedValues) {
|
||||
setSelectedScopes(checkedValues);
|
||||
}
|
||||
|
||||
function saveToken() {
|
||||
onOk(name, selectedScopes);
|
||||
|
||||
// Clear the modal
|
||||
setSelectedScopes([]);
|
||||
setName('');
|
||||
}
|
||||
|
||||
const okButtonProps = {
|
||||
disabled: selectedScopes.length === 0 || name === '',
|
||||
};
|
||||
|
||||
function selectAll() {
|
||||
setSelectedScopes(Object.keys(availableScopes));
|
||||
}
|
||||
const checkboxes = scopes.map(singleEvent => (
|
||||
<Col span={8} key={singleEvent.value}>
|
||||
<Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox>
|
||||
</Col>
|
||||
));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create New Access token"
|
||||
visible={visible}
|
||||
onOk={saveToken}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<p>
|
||||
<p>
|
||||
The name will be displayed as the chat user when sending messages with this access token.
|
||||
</p>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="Name of bot, service, or integration"
|
||||
onChange={input => setName(input.currentTarget.value)}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Select the permissions this access token will have. It cannot be edited after it's
|
||||
created.
|
||||
</p>
|
||||
<Checkbox.Group style={{ width: '100%' }} value={selectedScopes} onChange={onChange}>
|
||||
<Row>{checkboxes}</Row>
|
||||
</Checkbox.Group>
|
||||
|
||||
<p>
|
||||
<Button type="primary" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccessTokens() {
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [isTokenModalVisible, setIsTokenModalVisible] = useState(false);
|
||||
|
||||
function handleError(error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
|
||||
async function getAccessTokens() {
|
||||
try {
|
||||
const result = await fetchData(ACCESS_TOKENS);
|
||||
setTokens(result);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
getAccessTokens();
|
||||
}, []);
|
||||
|
||||
async function handleDeleteToken(token) {
|
||||
try {
|
||||
await fetchData(DELETE_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
data: { token },
|
||||
});
|
||||
getAccessTokens();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveToken(name: string, scopes: string[]) {
|
||||
try {
|
||||
const newToken = await fetchData(CREATE_ACCESS_TOKEN, {
|
||||
method: 'POST',
|
||||
data: { name, scopes },
|
||||
});
|
||||
setTokens(tokens.concat(newToken));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
key: 'delete',
|
||||
render: (text, record) => (
|
||||
<Space size="middle">
|
||||
<Button onClick={() => handleDeleteToken(record.accessToken)} icon={<DeleteOutlined />} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'displayName',
|
||||
key: 'displayName',
|
||||
},
|
||||
{
|
||||
title: 'Token',
|
||||
dataIndex: 'accessToken',
|
||||
key: 'accessToken',
|
||||
render: text => <Input.Password size="small" bordered={false} value={text} />,
|
||||
},
|
||||
{
|
||||
title: 'Scopes',
|
||||
dataIndex: 'scopes',
|
||||
key: 'scopes',
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
render: scopes => <>{scopes.map(scope => convertScopeStringToTag(scope))}</>,
|
||||
},
|
||||
{
|
||||
title: 'Last Used',
|
||||
dataIndex: 'lastUsed',
|
||||
key: 'lastUsed',
|
||||
render: lastUsed => {
|
||||
if (!lastUsed) {
|
||||
return 'Never';
|
||||
}
|
||||
const dateObject = new Date(lastUsed);
|
||||
return format(dateObject, 'P p');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const showCreateTokenModal = () => {
|
||||
setIsTokenModalVisible(true);
|
||||
};
|
||||
|
||||
const handleTokenModalSaveButton = (name, scopes) => {
|
||||
setIsTokenModalVisible(false);
|
||||
handleSaveToken(name, scopes);
|
||||
};
|
||||
|
||||
const handleTokenModalCancel = () => {
|
||||
setIsTokenModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title>Access Tokens</Title>
|
||||
<Paragraph>
|
||||
Access tokens are used to allow external, 3rd party tools to perform specific actions on
|
||||
your Owncast server. They should be kept secure and never included in client code, instead
|
||||
they should be kept on a server that you control.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Read more about how to use these tokens, with examples, at{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/integrations/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
our documentation
|
||||
</a>
|
||||
.
|
||||
</Paragraph>
|
||||
|
||||
<Table rowKey="token" columns={columns} dataSource={tokens} pagination={false} />
|
||||
<br />
|
||||
<Button type="primary" onClick={showCreateTokenModal}>
|
||||
Create Access Token
|
||||
</Button>
|
||||
<NewTokenModal
|
||||
visible={isTokenModalVisible}
|
||||
onOk={handleTokenModalSaveButton}
|
||||
onCancel={handleTokenModalCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
313
web/pages/admin/actions.tsx
Normal file
313
web/pages/admin/actions.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { Button, Checkbox, Input, Modal, Space, Table, Typography } from 'antd';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import FormStatusIndicator from '../../components/config/form-status-indicator';
|
||||
import {
|
||||
API_EXTERNAL_ACTIONS,
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
} from '../../utils/config-constants';
|
||||
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/urls';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
let resetTimer = null;
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onOk: any; // todo: make better type
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function NewActionModal(props: Props) {
|
||||
const { onOk, onCancel, visible } = props;
|
||||
|
||||
const [actionUrl, setActionUrl] = useState('');
|
||||
const [actionTitle, setActionTitle] = useState('');
|
||||
const [actionDescription, setActionDescription] = useState('');
|
||||
const [actionIcon, setActionIcon] = useState('');
|
||||
const [actionColor, setActionColor] = useState('');
|
||||
const [openExternally, setOpenExternally] = useState(false);
|
||||
|
||||
function save() {
|
||||
onOk(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally);
|
||||
setActionUrl('');
|
||||
setActionTitle('');
|
||||
setActionDescription('');
|
||||
setActionIcon('');
|
||||
setActionColor('');
|
||||
setOpenExternally(false);
|
||||
}
|
||||
|
||||
function canSave(): Boolean {
|
||||
try {
|
||||
const validationObject = new URL(actionUrl);
|
||||
if (validationObject.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValidUrl(actionUrl) && actionTitle !== '';
|
||||
}
|
||||
|
||||
const okButtonProps = {
|
||||
disabled: !canSave(),
|
||||
};
|
||||
|
||||
const onOpenExternallyChanged = checkbox => {
|
||||
setOpenExternally(checkbox.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create New Action"
|
||||
visible={visible}
|
||||
onOk={save}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<div>
|
||||
Add the URL for the external action you want to present.{' '}
|
||||
<strong>Only HTTPS urls are supported.</strong>
|
||||
<p>
|
||||
<a
|
||||
href="https://owncast.online/thirdparty/actions/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read more about external actions.
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<Input
|
||||
value={actionUrl}
|
||||
required
|
||||
placeholder="https://myserver.com/action (required)"
|
||||
onChange={input => setActionUrl(input.currentTarget.value.trim())}
|
||||
type="url"
|
||||
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Input
|
||||
value={actionTitle}
|
||||
required
|
||||
placeholder="Your action title (required)"
|
||||
onChange={input => setActionTitle(input.currentTarget.value)}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Input
|
||||
value={actionDescription}
|
||||
placeholder="Optional description"
|
||||
onChange={input => setActionDescription(input.currentTarget.value)}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Input
|
||||
value={actionIcon}
|
||||
placeholder="https://myserver.com/action/icon.png (optional)"
|
||||
onChange={input => setActionIcon(input.currentTarget.value)}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Input
|
||||
type="color"
|
||||
value={actionColor}
|
||||
onChange={input => setActionColor(input.currentTarget.value)}
|
||||
/>
|
||||
Optional background color of the action button.
|
||||
</p>
|
||||
<Checkbox
|
||||
checked={openExternally}
|
||||
defaultChecked={openExternally}
|
||||
onChange={onOpenExternallyChanged}
|
||||
>
|
||||
Open in a new tab instead of within your page.
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Actions() {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const { externalActions } = serverConfig;
|
||||
const [actions, setActions] = useState([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState(null);
|
||||
|
||||
const resetStates = () => {
|
||||
setSubmitStatus(null);
|
||||
resetTimer = null;
|
||||
clearTimeout(resetTimer);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setActions(externalActions || []);
|
||||
}, [externalActions]);
|
||||
|
||||
async function save(actionsData) {
|
||||
await postConfigUpdateToAPI({
|
||||
apiPath: API_EXTERNAL_ACTIONS,
|
||||
data: { value: actionsData },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({ fieldName: 'externalActions', value: actionsData, path: '' });
|
||||
setSubmitStatus(createInputStatus(STATUS_SUCCESS, 'Updated.'));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
console.log(message);
|
||||
setSubmitStatus(createInputStatus(STATUS_ERROR, message));
|
||||
resetTimer = setTimeout(resetStates, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete(action) {
|
||||
const actionsData = [...actions];
|
||||
const index = actions.findIndex(item => item.url === action.url);
|
||||
actionsData.splice(index, 1);
|
||||
|
||||
try {
|
||||
setActions(actionsData);
|
||||
save(actionsData);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(
|
||||
url: string,
|
||||
title: string,
|
||||
description: string,
|
||||
icon: string,
|
||||
color: string,
|
||||
openExternally: boolean,
|
||||
) {
|
||||
try {
|
||||
const actionsData = [...actions];
|
||||
const updatedActions = actionsData.concat({
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
openExternally,
|
||||
});
|
||||
setActions(updatedActions);
|
||||
await save(updatedActions);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const showCreateModal = () => {
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleModalSaveButton = (
|
||||
actionUrl: string,
|
||||
actionTitle: string,
|
||||
actionDescription: string,
|
||||
actionIcon: string,
|
||||
actionColor: string,
|
||||
openExternally: boolean,
|
||||
) => {
|
||||
setIsModalVisible(false);
|
||||
handleSave(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally);
|
||||
};
|
||||
|
||||
const handleModalCancelButton = () => {
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
key: 'delete',
|
||||
render: (text, record) => (
|
||||
<Space size="middle">
|
||||
<Button onClick={() => handleDelete(record)} icon={<DeleteOutlined />} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
},
|
||||
{
|
||||
title: 'Icon',
|
||||
dataIndex: 'icon',
|
||||
key: 'icon',
|
||||
render: (url: string) => (url ? <img style={{ width: '2vw' }} src={url} alt="" /> : null),
|
||||
},
|
||||
{
|
||||
title: 'Color',
|
||||
dataIndex: 'color',
|
||||
key: 'color',
|
||||
render: (color: string) =>
|
||||
color ? <div style={{ backgroundColor: color, height: '30px' }}>{color}</div> : null,
|
||||
},
|
||||
{
|
||||
title: 'Opens',
|
||||
dataIndex: 'openExternally',
|
||||
key: 'openExternally',
|
||||
render: (openExternally: boolean) => (openExternally ? 'In a new tab' : 'In a modal'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title>External Actions</Title>
|
||||
<Paragraph>
|
||||
External action URLs are 3rd party UI you can display, embedded, into your Owncast page when
|
||||
a user clicks on a button to launch your action.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Read more about how to use actions, with examples, at{' '}
|
||||
<a
|
||||
href="https://owncast.online/thirdparty/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
our documentation
|
||||
</a>
|
||||
.
|
||||
</Paragraph>
|
||||
|
||||
<Table
|
||||
rowKey={record => `${record.title}-${record.url}`}
|
||||
columns={columns}
|
||||
dataSource={actions}
|
||||
pagination={false}
|
||||
/>
|
||||
<br />
|
||||
<Button type="primary" onClick={showCreateModal}>
|
||||
Create New Action
|
||||
</Button>
|
||||
<FormStatusIndicator status={submitStatus} />
|
||||
|
||||
<NewActionModal
|
||||
visible={isModalVisible}
|
||||
onOk={handleModalSaveButton}
|
||||
onCancel={handleModalCancelButton}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
web/pages/admin/admin-layout.tsx
Normal file
18
web/pages/admin/admin-layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppProps } from 'next/app';
|
||||
import ServerStatusProvider from '../../utils/server-status-context';
|
||||
import AlertMessageProvider from '../../utils/alert-message-context';
|
||||
import MainLayout from '../../components/main-layout';
|
||||
|
||||
function AdminLayout({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<ServerStatusProvider>
|
||||
<AlertMessageProvider>
|
||||
<MainLayout>
|
||||
<Component {...pageProps} />
|
||||
</MainLayout>
|
||||
</AlertMessageProvider>
|
||||
</ServerStatusProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
253
web/pages/admin/chat/messages.tsx
Normal file
253
web/pages/admin/chat/messages.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Typography, Button } from 'antd';
|
||||
import { CheckCircleFilled, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import classNames from 'classnames';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import format from 'date-fns/format';
|
||||
|
||||
import { MessageType } from '../../../types/chat';
|
||||
import {
|
||||
CHAT_HISTORY,
|
||||
fetchData,
|
||||
FETCH_INTERVAL,
|
||||
UPDATE_CHAT_MESSGAE_VIZ,
|
||||
} from '../../../utils/apis';
|
||||
import { isEmptyObject } from '../../../utils/format';
|
||||
import MessageVisiblityToggle from '../../../components/message-visiblity-toggle';
|
||||
import UserPopover from '../../../components/user-popover';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
function createUserNameFilters(messages: MessageType[]) {
|
||||
const filtered = messages.reduce((acc, curItem) => {
|
||||
const curAuthor = curItem.user.id;
|
||||
if (!acc.some(item => item.text === curAuthor)) {
|
||||
acc.push({ text: curAuthor, value: curAuthor });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// sort by name
|
||||
return filtered.sort((a, b) => {
|
||||
const nameA = a.text.toUpperCase(); // ignore upper and lowercase
|
||||
const nameB = b.text.toUpperCase(); // ignore upper and lowercase
|
||||
if (nameA < nameB) {
|
||||
return -1;
|
||||
}
|
||||
if (nameA > nameB) {
|
||||
return 1;
|
||||
}
|
||||
// names must be equal
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
export const OUTCOME_TIMEOUT = 3000;
|
||||
|
||||
export default function Chat() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [selectedRowKeys, setSelectedRows] = useState([]);
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false);
|
||||
const [bulkOutcome, setBulkOutcome] = useState(null);
|
||||
const [bulkAction, setBulkAction] = useState('');
|
||||
let outcomeTimeout = null;
|
||||
let chatReloadInterval = null;
|
||||
|
||||
const getInfo = async () => {
|
||||
try {
|
||||
const result = await fetchData(CHAT_HISTORY, { auth: true });
|
||||
if (isEmptyObject(result)) {
|
||||
setMessages([]);
|
||||
} else {
|
||||
setMessages(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getInfo();
|
||||
|
||||
chatReloadInterval = setInterval(() => {
|
||||
getInfo();
|
||||
}, FETCH_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearTimeout(outcomeTimeout);
|
||||
clearTimeout(chatReloadInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const nameFilters = createUserNameFilters(messages);
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedKeys: string[]) => {
|
||||
setSelectedRows(selectedKeys);
|
||||
},
|
||||
};
|
||||
|
||||
const updateMessage = message => {
|
||||
const messageIndex = messages.findIndex(m => m.id === message.id);
|
||||
messages.splice(messageIndex, 1, message);
|
||||
setMessages([...messages]);
|
||||
};
|
||||
|
||||
const resetBulkOutcome = () => {
|
||||
outcomeTimeout = setTimeout(() => {
|
||||
setBulkOutcome(null);
|
||||
setBulkAction('');
|
||||
}, OUTCOME_TIMEOUT);
|
||||
};
|
||||
const handleSubmitBulk = async bulkVisibility => {
|
||||
setBulkProcessing(true);
|
||||
const result = await fetchData(UPDATE_CHAT_MESSGAE_VIZ, {
|
||||
auth: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
visible: bulkVisibility,
|
||||
idArray: selectedRowKeys,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success && result.message === 'changed') {
|
||||
setBulkOutcome(<CheckCircleFilled />);
|
||||
resetBulkOutcome();
|
||||
|
||||
// update messages
|
||||
const updatedList = [...messages];
|
||||
selectedRowKeys.map(key => {
|
||||
const messageIndex = updatedList.findIndex(m => m.id === key);
|
||||
const newMessage = { ...messages[messageIndex], visible: bulkVisibility };
|
||||
updatedList.splice(messageIndex, 1, newMessage);
|
||||
return null;
|
||||
});
|
||||
setMessages(updatedList);
|
||||
setSelectedRows([]);
|
||||
} else {
|
||||
setBulkOutcome(<ExclamationCircleFilled />);
|
||||
resetBulkOutcome();
|
||||
}
|
||||
setBulkProcessing(false);
|
||||
};
|
||||
const handleSubmitBulkShow = () => {
|
||||
setBulkAction('show');
|
||||
handleSubmitBulk(true);
|
||||
};
|
||||
const handleSubmitBulkHide = () => {
|
||||
setBulkAction('hide');
|
||||
handleSubmitBulk(false);
|
||||
};
|
||||
|
||||
const chatColumns: ColumnsType<MessageType> = [
|
||||
{
|
||||
title: 'Time',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
className: 'timestamp-col',
|
||||
defaultSortOrder: 'descend',
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return format(dateObject, 'PP pp');
|
||||
},
|
||||
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: 'User',
|
||||
dataIndex: 'user',
|
||||
key: 'user',
|
||||
className: 'name-col',
|
||||
filters: nameFilters,
|
||||
onFilter: (value, record) => record.user.id === value,
|
||||
sorter: (a, b) => a.user.displayName.localeCompare(b.user.displayName),
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
ellipsis: true,
|
||||
render: user => {
|
||||
const { displayName } = user;
|
||||
return <UserPopover user={user}>{displayName}</UserPopover>;
|
||||
},
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: 'Message',
|
||||
dataIndex: 'body',
|
||||
key: 'body',
|
||||
className: 'message-col',
|
||||
width: 320,
|
||||
render: body => (
|
||||
<div
|
||||
className="message-contents"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: body }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'hiddenAt',
|
||||
key: 'hiddenAt',
|
||||
className: 'toggle-col',
|
||||
filters: [
|
||||
{ text: 'Visible messages', value: true },
|
||||
{ text: 'Hidden messages', value: false },
|
||||
],
|
||||
onFilter: (value, record) => record.visible === value,
|
||||
render: (hiddenAt, record) => (
|
||||
<MessageVisiblityToggle isVisible={!hiddenAt} message={record} setMessage={updateMessage} />
|
||||
),
|
||||
width: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const bulkDivClasses = classNames({
|
||||
'bulk-editor': true,
|
||||
active: selectedRowKeys.length,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="chat-messages">
|
||||
<Title>Chat Messages</Title>
|
||||
<p>Manage the messages from viewers that show up on your stream.</p>
|
||||
<div className={bulkDivClasses}>
|
||||
<span className="label">Check multiple messages to change their visibility to: </span>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
className="button"
|
||||
loading={bulkAction === 'show' && bulkProcessing}
|
||||
icon={bulkAction === 'show' && bulkOutcome}
|
||||
disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'show')}
|
||||
onClick={handleSubmitBulkShow}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
className="button"
|
||||
loading={bulkAction === 'hide' && bulkProcessing}
|
||||
icon={bulkAction === 'hide' && bulkOutcome}
|
||||
disabled={!selectedRowKeys.length || (bulkAction && bulkAction !== 'hide')}
|
||||
onClick={handleSubmitBulkHide}
|
||||
>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
className="table-container"
|
||||
pagination={{ defaultPageSize: 100, showSizeChanger: true }}
|
||||
scroll={{ y: 540 }}
|
||||
rowClassName={record => (record.hiddenAt ? 'hidden' : '')}
|
||||
dataSource={messages}
|
||||
columns={chatColumns}
|
||||
rowKey={row => row.id}
|
||||
rowSelection={rowSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
web/pages/admin/chat/users.tsx
Normal file
107
web/pages/admin/chat/users.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import { ServerStatusContext } from '../../../utils/server-status-context';
|
||||
import {
|
||||
CONNECTED_CLIENTS,
|
||||
fetchData,
|
||||
DISABLED_USERS,
|
||||
MODERATORS,
|
||||
BANNED_IPS,
|
||||
} from '../../../utils/apis';
|
||||
import UserTable from '../../../components/user-table';
|
||||
import ClientTable from '../../../components/client-table';
|
||||
import BannedIPsTable from '../../../components/banned-ips-table';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
export const FETCH_INTERVAL = 10 * 1000; // 10 sec
|
||||
|
||||
export default function ChatUsers() {
|
||||
const context = useContext(ServerStatusContext);
|
||||
const { online } = context || {};
|
||||
|
||||
const [disabledUsers, setDisabledUsers] = useState([]);
|
||||
const [ipBans, setIPBans] = useState([]);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [moderators, setModerators] = useState([]);
|
||||
|
||||
const getInfo = async () => {
|
||||
try {
|
||||
const result = await fetchData(DISABLED_USERS);
|
||||
setDisabledUsers(result);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchData(CONNECTED_CLIENTS);
|
||||
setClients(result);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchData(MODERATORS);
|
||||
setModerators(result);
|
||||
} catch (error) {
|
||||
console.error('error fetching moderators', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchData(BANNED_IPS);
|
||||
setIPBans(result);
|
||||
} catch (error) {
|
||||
console.error('error fetching banned ips', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let getStatusIntervalId = null;
|
||||
|
||||
getInfo();
|
||||
|
||||
getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL);
|
||||
// returned function will be called on component unmount
|
||||
return () => {
|
||||
clearInterval(getStatusIntervalId);
|
||||
};
|
||||
}, [online]);
|
||||
|
||||
const connectedUsers = online ? (
|
||||
<>
|
||||
<ClientTable data={clients} />
|
||||
<p className="description">
|
||||
Visit the{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/viewers/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>{' '}
|
||||
to configure additional details about your viewers.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="description">
|
||||
When a stream is active and chat is enabled, connected chat clients will be displayed here.
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultActiveKey="1">
|
||||
<TabPane tab={<span>Connected {online ? `(${clients.length})` : '(offline)'}</span>} key="1">
|
||||
{connectedUsers}
|
||||
</TabPane>
|
||||
<TabPane tab={<span>Banned Users ({disabledUsers.length})</span>} key="2">
|
||||
<UserTable data={disabledUsers} />
|
||||
</TabPane>
|
||||
<TabPane tab={<span>IP Bans ({ipBans.length})</span>} key="3">
|
||||
<BannedIPsTable data={ipBans} />
|
||||
</TabPane>
|
||||
<TabPane tab={<span>Moderators ({moderators.length})</span>} key="4">
|
||||
<UserTable data={moderators} />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
222
web/pages/admin/config-chat.tsx
Normal file
222
web/pages/admin/config-chat.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Typography } from 'antd';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { TEXTFIELD_TYPE_TEXTAREA } from '../../components/config/form-textfield';
|
||||
import TextFieldWithSubmit from '../../components/config/form-textfield-with-submit';
|
||||
import ToggleSwitch from '../../components/config/form-toggleswitch';
|
||||
import EditValueArray from '../../components/config/edit-string-array';
|
||||
import {
|
||||
createInputStatus,
|
||||
StatusState,
|
||||
STATUS_ERROR,
|
||||
STATUS_SUCCESS,
|
||||
} from '../../utils/input-statuses';
|
||||
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import {
|
||||
API_CHAT_FORBIDDEN_USERNAMES,
|
||||
API_CHAT_SUGGESTED_USERNAMES,
|
||||
FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED,
|
||||
CHAT_ESTABLISHED_USER_MODE,
|
||||
FIELD_PROPS_DISABLE_CHAT,
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES,
|
||||
TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES,
|
||||
TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE,
|
||||
} from '../../utils/config-constants';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
|
||||
export default function ConfigChat() {
|
||||
const { Title } = Typography;
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const [forbiddenUsernameSaveState, setForbiddenUsernameSaveState] = useState<StatusState>(null);
|
||||
const [suggestedUsernameSaveState, setSuggestedUsernameSaveState] = useState<StatusState>(null);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
|
||||
const {
|
||||
chatDisabled,
|
||||
chatJoinMessagesEnabled,
|
||||
forbiddenUsernames,
|
||||
instanceDetails,
|
||||
suggestedUsernames,
|
||||
chatEstablishedUserMode,
|
||||
} = serverConfig;
|
||||
const { welcomeMessage } = instanceDetails;
|
||||
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
function handleChatDisableChange(disabled: boolean) {
|
||||
handleFieldChange({ fieldName: 'chatDisabled', value: !disabled });
|
||||
}
|
||||
|
||||
function handleChatJoinMessagesEnabledChange(enabled: boolean) {
|
||||
handleFieldChange({ fieldName: 'chatJoinMessagesEnabled', value: enabled });
|
||||
}
|
||||
|
||||
function handleEstablishedUserModeChange(enabled: boolean) {
|
||||
handleFieldChange({ fieldName: 'chatEstablishedUserMode', value: enabled });
|
||||
}
|
||||
|
||||
function resetForbiddenUsernameState() {
|
||||
setForbiddenUsernameSaveState(null);
|
||||
}
|
||||
|
||||
function saveForbiddenUsernames() {
|
||||
postConfigUpdateToAPI({
|
||||
apiPath: API_CHAT_FORBIDDEN_USERNAMES,
|
||||
data: { value: formDataValues.forbiddenUsernames },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'forbiddenUsernames',
|
||||
value: formDataValues.forbiddenUsernames,
|
||||
});
|
||||
setForbiddenUsernameSaveState(createInputStatus(STATUS_SUCCESS));
|
||||
setTimeout(resetForbiddenUsernameState, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setForbiddenUsernameSaveState(createInputStatus(STATUS_ERROR, message));
|
||||
setTimeout(resetForbiddenUsernameState, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteForbiddenUsernameIndex(index: number) {
|
||||
formDataValues.forbiddenUsernames.splice(index, 1);
|
||||
saveForbiddenUsernames();
|
||||
}
|
||||
|
||||
function handleCreateForbiddenUsername(tag: string) {
|
||||
formDataValues.forbiddenUsernames.push(tag);
|
||||
handleFieldChange({
|
||||
fieldName: 'forbiddenUsernames',
|
||||
value: formDataValues.forbiddenUsernames,
|
||||
});
|
||||
saveForbiddenUsernames();
|
||||
}
|
||||
|
||||
function resetSuggestedUsernameState() {
|
||||
setSuggestedUsernameSaveState(null);
|
||||
}
|
||||
|
||||
function saveSuggestedUsernames() {
|
||||
postConfigUpdateToAPI({
|
||||
apiPath: API_CHAT_SUGGESTED_USERNAMES,
|
||||
data: { value: formDataValues.suggestedUsernames },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'suggestedUsernames',
|
||||
value: formDataValues.suggestedUsernames,
|
||||
});
|
||||
setSuggestedUsernameSaveState(createInputStatus(STATUS_SUCCESS));
|
||||
setTimeout(resetSuggestedUsernameState, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setForbiddenUsernameSaveState(createInputStatus(STATUS_ERROR, message));
|
||||
setTimeout(resetSuggestedUsernameState, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteSuggestedUsernameIndex(index: number) {
|
||||
formDataValues.suggestedUsernames.splice(index, 1);
|
||||
saveSuggestedUsernames();
|
||||
}
|
||||
|
||||
function handleCreateSuggestedUsername(tag: string) {
|
||||
formDataValues.suggestedUsernames.push(tag);
|
||||
handleFieldChange({
|
||||
fieldName: 'suggestedUsernames',
|
||||
value: formDataValues.suggestedUsernames,
|
||||
});
|
||||
saveSuggestedUsernames();
|
||||
}
|
||||
|
||||
function getSuggestedUsernamesLimitWarning(length: number): StatusState | null {
|
||||
if (length === 0)
|
||||
return createInputStatus('success', TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.no_entries);
|
||||
if (length > 0 && length < 10)
|
||||
return createInputStatus('warning', TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.min_not_reached);
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
chatDisabled,
|
||||
chatJoinMessagesEnabled,
|
||||
forbiddenUsernames,
|
||||
suggestedUsernames,
|
||||
welcomeMessage,
|
||||
chatEstablishedUserMode,
|
||||
});
|
||||
}, [serverConfig]);
|
||||
|
||||
if (!formDataValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-server-details-form">
|
||||
<Title>Chat Settings</Title>
|
||||
<div className="form-module config-server-details-container">
|
||||
<ToggleSwitch
|
||||
fieldName="chatDisabled"
|
||||
{...FIELD_PROPS_DISABLE_CHAT}
|
||||
checked={!formDataValues.chatDisabled}
|
||||
reversed
|
||||
onChange={handleChatDisableChange}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="chatJoinMessagesEnabled"
|
||||
{...FIELD_PROPS_CHAT_JOIN_MESSAGES_ENABLED}
|
||||
checked={formDataValues.chatJoinMessagesEnabled}
|
||||
onChange={handleChatJoinMessagesEnabledChange}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="establishedUserMode"
|
||||
{...CHAT_ESTABLISHED_USER_MODE}
|
||||
checked={formDataValues.chatEstablishedUserMode}
|
||||
onChange={handleEstablishedUserModeChange}
|
||||
/>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="welcomeMessage"
|
||||
{...TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE}
|
||||
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||
value={formDataValues.welcomeMessage}
|
||||
initialValue={welcomeMessage}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<EditValueArray
|
||||
title={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.label}
|
||||
placeholder={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.placeholder}
|
||||
description={TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES.tip}
|
||||
values={formDataValues.forbiddenUsernames}
|
||||
handleDeleteIndex={handleDeleteForbiddenUsernameIndex}
|
||||
handleCreateString={handleCreateForbiddenUsername}
|
||||
submitStatus={forbiddenUsernameSaveState}
|
||||
/>
|
||||
<br />
|
||||
<br />
|
||||
<EditValueArray
|
||||
title={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.label}
|
||||
placeholder={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.placeholder}
|
||||
description={TEXTFIELD_PROPS_CHAT_SUGGESTED_USERNAMES.tip}
|
||||
values={formDataValues.suggestedUsernames}
|
||||
handleDeleteIndex={handleDeleteSuggestedUsernameIndex}
|
||||
handleCreateString={handleCreateSuggestedUsername}
|
||||
submitStatus={suggestedUsernameSaveState}
|
||||
continuousStatusMessage={getSuggestedUsernamesLimitWarning(
|
||||
formDataValues.suggestedUsernames.length,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
web/pages/admin/config-federation.tsx
Normal file
334
web/pages/admin/config-federation.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
import { Typography, Modal, Button, Row, Col, Alert } from 'antd';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
TEXTFIELD_TYPE_TEXT,
|
||||
TEXTFIELD_TYPE_TEXTAREA,
|
||||
TEXTFIELD_TYPE_URL,
|
||||
} from '../../components/config/form-textfield';
|
||||
import TextFieldWithSubmit from '../../components/config/form-textfield-with-submit';
|
||||
import ToggleSwitch from '../../components/config/form-toggleswitch';
|
||||
import EditValueArray from '../../components/config/edit-string-array';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import {
|
||||
FIELD_PROPS_ENABLE_FEDERATION,
|
||||
TEXTFIELD_PROPS_FEDERATION_LIVE_MESSAGE,
|
||||
TEXTFIELD_PROPS_FEDERATION_DEFAULT_USER,
|
||||
FIELD_PROPS_FEDERATION_IS_PRIVATE,
|
||||
FIELD_PROPS_SHOW_FEDERATION_ENGAGEMENT,
|
||||
TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL,
|
||||
FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS,
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
API_FEDERATION_BLOCKED_DOMAINS,
|
||||
FIELD_PROPS_FEDERATION_NSFW,
|
||||
} from '../../utils/config-constants';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
|
||||
|
||||
function FederationInfoModal({ cancelPressed, okPressed }) {
|
||||
return (
|
||||
<Modal
|
||||
width="70%"
|
||||
title="Enable Social Features"
|
||||
visible
|
||||
onCancel={cancelPressed}
|
||||
footer={
|
||||
<div>
|
||||
<Button onClick={cancelPressed}>Do not enable</Button>
|
||||
<Button type="primary" onClick={okPressed}>
|
||||
Enable Social Features
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Title level={3}>How do Owncast's social features work?</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
Owncast's social features are accomplished by having your server join The{' '}
|
||||
<a href="https://en.wikipedia.org/wiki/Fediverse" rel="noopener noreferrer" target="_blank">
|
||||
Fediverse
|
||||
</a>
|
||||
, a decentralized, open, collection of independent servers, like yours.
|
||||
</Typography.Paragraph>
|
||||
Please{' '}
|
||||
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
|
||||
read more
|
||||
</a>{' '}
|
||||
about these features, the details behind them, and how they work.
|
||||
<Typography.Paragraph />
|
||||
<Typography.Title level={3}>What do you need to know?</Typography.Title>
|
||||
<ul>
|
||||
<li>
|
||||
These features are brand new. Given the variability of interfacing with the rest of the
|
||||
world, bugs are possible. Please report anything that you think isn't working quite right.
|
||||
</li>
|
||||
<li>You must always host your Owncast server with SSL using a https url.</li>
|
||||
<li>
|
||||
You should not change your server name URL or social username once people begin following
|
||||
you, as your server will be seen as a completely different user on the Fediverse, and the
|
||||
old user will disappear.
|
||||
</li>
|
||||
<li>
|
||||
Turning on <i>Private mode</i> will allow you to manually approve each follower and limit
|
||||
the visibility of your posts to followers only.
|
||||
</li>
|
||||
</ul>
|
||||
<Typography.Title level={3}>Learn more about The Fediverse</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
If these concepts are new you should discover more about what this functionality has to
|
||||
offer. Visit{' '}
|
||||
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
|
||||
our documentation
|
||||
</a>{' '}
|
||||
to be pointed at some resources that will help get you started on The Fediverse.
|
||||
</Typography.Paragraph>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
FederationInfoModal.propTypes = {
|
||||
cancelPressed: PropTypes.func.isRequired,
|
||||
okPressed: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default function ConfigFederation() {
|
||||
const { Title } = Typography;
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const [isInfoModalOpen, setIsInfoModalOpen] = useState(false);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig, setFieldInConfigState } = serverStatusData || {};
|
||||
const [blockedDomainSaveState, setBlockedDomainSaveState] = useState(null);
|
||||
|
||||
const { federation, yp, instanceDetails } = serverConfig;
|
||||
const { enabled, isPrivate, username, goLiveMessage, showEngagement, blockedDomains } =
|
||||
federation;
|
||||
const { instanceUrl } = yp;
|
||||
const { nsfw } = instanceDetails;
|
||||
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledSwitchChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
enabled: false,
|
||||
});
|
||||
} else {
|
||||
setIsInfoModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// if instanceUrl is empty, we should also turn OFF the `enabled` field of directory.
|
||||
const handleSubmitInstanceUrl = () => {
|
||||
const hasInstanceUrl = formDataValues.instanceUrl !== '';
|
||||
const isInstanceUrlSecure = formDataValues.instanceUrl.startsWith('https://');
|
||||
|
||||
if (!hasInstanceUrl || !isInstanceUrlSecure) {
|
||||
postConfigUpdateToAPI({
|
||||
apiPath: FIELD_PROPS_ENABLE_FEDERATION.apiPath,
|
||||
data: { value: false },
|
||||
});
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function federationInfoModalCancelPressed() {
|
||||
setIsInfoModalOpen(false);
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
function federationInfoModalOkPressed() {
|
||||
setIsInfoModalOpen(false);
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resetBlockedDomainsSaveState() {
|
||||
setBlockedDomainSaveState(null);
|
||||
}
|
||||
|
||||
function saveBlockedDomains() {
|
||||
try {
|
||||
postConfigUpdateToAPI({
|
||||
apiPath: API_FEDERATION_BLOCKED_DOMAINS,
|
||||
data: { value: formDataValues.blockedDomains },
|
||||
onSuccess: () => {
|
||||
setFieldInConfigState({
|
||||
fieldName: 'forbiddenUsernames',
|
||||
value: formDataValues.forbiddenUsernames,
|
||||
});
|
||||
setBlockedDomainSaveState(STATUS_SUCCESS);
|
||||
setTimeout(resetBlockedDomainsSaveState, RESET_TIMEOUT);
|
||||
},
|
||||
onError: (message: string) => {
|
||||
setBlockedDomainSaveState(createInputStatus(STATUS_ERROR, message));
|
||||
setTimeout(resetBlockedDomainsSaveState, RESET_TIMEOUT);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setBlockedDomainSaveState(STATUS_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteBlockedDomain(index: number) {
|
||||
formDataValues.blockedDomains.splice(index, 1);
|
||||
saveBlockedDomains();
|
||||
}
|
||||
|
||||
function handleCreateBlockedDomain(domain: string) {
|
||||
let newDomain;
|
||||
try {
|
||||
const u = new URL(domain);
|
||||
newDomain = u.host;
|
||||
} catch (_) {
|
||||
newDomain = domain;
|
||||
}
|
||||
|
||||
formDataValues.blockedDomains.push(newDomain);
|
||||
handleFieldChange({
|
||||
fieldName: 'blockedDomains',
|
||||
value: formDataValues.blockedDomains,
|
||||
});
|
||||
saveBlockedDomains();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
enabled,
|
||||
isPrivate,
|
||||
username,
|
||||
goLiveMessage,
|
||||
showEngagement,
|
||||
blockedDomains,
|
||||
nsfw,
|
||||
instanceUrl: yp.instanceUrl,
|
||||
});
|
||||
}, [serverConfig, yp]);
|
||||
|
||||
if (!formDataValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasInstanceUrl = instanceUrl !== '';
|
||||
const isInstanceUrlSecure = instanceUrl.startsWith('https://');
|
||||
const configurationWarning = !isInstanceUrlSecure && (
|
||||
<>
|
||||
<Alert
|
||||
message="You must set your server URL before you can enable this feature."
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
<br />
|
||||
<TextFieldWithSubmit
|
||||
fieldName="instanceUrl"
|
||||
{...TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL}
|
||||
value={formDataValues.instanceUrl}
|
||||
initialValue={yp.instanceUrl}
|
||||
type={TEXTFIELD_TYPE_URL}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={handleSubmitInstanceUrl}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Title>Configure Social Features</Title>
|
||||
<p>
|
||||
Owncast provides the ability for people to follow and engage with your instance. It's a
|
||||
great way to promote alerting, sharing and engagement of your stream.
|
||||
</p>
|
||||
<p>
|
||||
Once enabled you'll alert your followers when you go live as well as gain the ability to
|
||||
compose custom posts to share any information you like.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://owncast.online/docs/social" rel="noopener noreferrer" target="_blank">
|
||||
Read more about the specifics of these social features.
|
||||
</a>
|
||||
</p>
|
||||
<Row>
|
||||
<Col span={15} className="form-module" style={{ marginRight: '15px' }}>
|
||||
{configurationWarning}
|
||||
<ToggleSwitch
|
||||
fieldName="enabled"
|
||||
onChange={handleEnabledSwitchChange}
|
||||
{...FIELD_PROPS_ENABLE_FEDERATION}
|
||||
checked={formDataValues.enabled}
|
||||
disabled={!hasInstanceUrl || !isInstanceUrlSecure}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="isPrivate"
|
||||
{...FIELD_PROPS_FEDERATION_IS_PRIVATE}
|
||||
checked={formDataValues.isPrivate}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="nsfw"
|
||||
useSubmit
|
||||
{...FIELD_PROPS_FEDERATION_NSFW}
|
||||
checked={formDataValues.nsfw}
|
||||
disabled={!hasInstanceUrl}
|
||||
/>
|
||||
<TextFieldWithSubmit
|
||||
required
|
||||
fieldName="username"
|
||||
type={TEXTFIELD_TYPE_TEXT}
|
||||
{...TEXTFIELD_PROPS_FEDERATION_DEFAULT_USER}
|
||||
value={formDataValues.username}
|
||||
initialValue={username}
|
||||
onChange={handleFieldChange}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<TextFieldWithSubmit
|
||||
fieldName="goLiveMessage"
|
||||
{...TEXTFIELD_PROPS_FEDERATION_LIVE_MESSAGE}
|
||||
type={TEXTFIELD_TYPE_TEXTAREA}
|
||||
value={formDataValues.goLiveMessage}
|
||||
initialValue={goLiveMessage}
|
||||
onChange={handleFieldChange}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="showEngagement"
|
||||
{...FIELD_PROPS_SHOW_FEDERATION_ENGAGEMENT}
|
||||
checked={formDataValues.showEngagement}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8} className="form-module">
|
||||
<EditValueArray
|
||||
title={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.label}
|
||||
placeholder={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.placeholder}
|
||||
description={FIELD_PROPS_FEDERATION_BLOCKED_DOMAINS.tip}
|
||||
values={formDataValues.blockedDomains}
|
||||
handleDeleteIndex={handleDeleteBlockedDomain}
|
||||
handleCreateString={handleCreateBlockedDomain}
|
||||
submitStatus={createInputStatus(blockedDomainSaveState)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{isInfoModalOpen && (
|
||||
<FederationInfoModal
|
||||
cancelPressed={federationInfoModalCancelPressed}
|
||||
okPressed={federationInfoModalOkPressed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
web/pages/admin/config-notify.tsx
Normal file
148
web/pages/admin/config-notify.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Alert, Button, Col, Row, Typography } from 'antd';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import Discord from '../../components/config/notification/discord';
|
||||
import Browser from '../../components/config/notification/browser';
|
||||
import Twitter from '../../components/config/notification/twitter';
|
||||
import Federation from '../../components/config/notification/federation';
|
||||
import TextFieldWithSubmit, {
|
||||
TEXTFIELD_TYPE_URL,
|
||||
} from '../../components/config/form-textfield-with-submit';
|
||||
import { TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL } from '../../utils/config-constants';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import { UpdateArgs } from '../../types/config-section';
|
||||
import isValidUrl from '../utils/urls';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function ConfigNotify() {
|
||||
const [formDataValues, setFormDataValues] = useState(null);
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
const { yp } = serverConfig;
|
||||
const { instanceUrl } = yp;
|
||||
const [urlValid, setUrlValid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFormDataValues({
|
||||
instanceUrl,
|
||||
});
|
||||
}, [yp]);
|
||||
|
||||
const handleSubmitInstanceUrl = () => {
|
||||
if (!urlValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
enabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFieldChange = ({ fieldName, value }: UpdateArgs) => {
|
||||
setUrlValid(isValidUrl(value));
|
||||
|
||||
setFormDataValues({
|
||||
...formDataValues,
|
||||
[fieldName]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const enabled = instanceUrl !== '';
|
||||
const configurationWarning = !enabled && (
|
||||
<>
|
||||
<Alert
|
||||
message="You must set your server URL before you can enable this feature."
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
<br />
|
||||
<TextFieldWithSubmit
|
||||
fieldName="instanceUrl"
|
||||
{...TEXTFIELD_PROPS_FEDERATION_INSTANCE_URL}
|
||||
value={formDataValues?.instanceUrl || ''}
|
||||
initialValue={yp.instanceUrl}
|
||||
type={TEXTFIELD_TYPE_URL}
|
||||
onChange={handleFieldChange}
|
||||
onSubmit={handleSubmitInstanceUrl}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>Notifications</Title>
|
||||
<p className="description">
|
||||
Let your viewers know when you go live by supporting any of the below notification channels.{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/notifications/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more about live notifications.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{configurationWarning}
|
||||
|
||||
<Row>
|
||||
<Col
|
||||
span={10}
|
||||
className={`form-module ${enabled ? '' : 'disabled'}`}
|
||||
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Browser />
|
||||
</Col>
|
||||
<Col
|
||||
span={10}
|
||||
className={`form-module ${enabled ? '' : 'disabled'}`}
|
||||
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Twitter />
|
||||
</Col>
|
||||
|
||||
<Col
|
||||
span={10}
|
||||
className={`form-module ${enabled ? '' : 'disabled'}`}
|
||||
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Discord />
|
||||
</Col>
|
||||
|
||||
<Col
|
||||
span={10}
|
||||
className={`form-module ${enabled ? '' : 'disabled'}`}
|
||||
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Federation />
|
||||
</Col>
|
||||
|
||||
<Col
|
||||
span={10}
|
||||
className={`form-module ${enabled ? '' : 'disabled'}`}
|
||||
style={{ margin: '5px', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Title>Custom</Title>
|
||||
<p className="description">Build your own notifications by using custom webhooks.</p>
|
||||
|
||||
<Link passHref href="/webhooks">
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
position: 'relative',
|
||||
marginLeft: 'auto',
|
||||
right: '0',
|
||||
marginTop: '20px',
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Link>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
web/pages/admin/config-public-details.tsx
Normal file
50
web/pages/admin/config-public-details.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import EditInstanceDetails from '../../components/config/edit-instance-details';
|
||||
import EditInstanceTags from '../../components/config/edit-tags';
|
||||
import EditSocialLinks from '../../components/config/edit-social-links';
|
||||
import EditPageContent from '../../components/config/edit-page-content';
|
||||
import EditCustomStyles from '../../components/config/edit-custom-css';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function PublicFacingDetails() {
|
||||
return (
|
||||
<div className="config-public-details-page">
|
||||
<Title>General Settings</Title>
|
||||
<p className="description">
|
||||
The following are displayed on your site to describe your stream and its content.{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/website/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="top-container">
|
||||
<div className="form-module instance-details-container">
|
||||
<EditInstanceDetails />
|
||||
</div>
|
||||
|
||||
<div className="form-module social-items-container ">
|
||||
<div className="form-module tags-module">
|
||||
<EditInstanceTags />
|
||||
</div>
|
||||
|
||||
<div className="form-module social-handles-container">
|
||||
<EditSocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-module page-content-module">
|
||||
<EditPageContent />
|
||||
</div>
|
||||
<div className="form-module page-content-module">
|
||||
<EditCustomStyles />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
web/pages/admin/config-server-details.tsx
Normal file
20
web/pages/admin/config-server-details.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import EditServerDetails from '../../components/config/edit-server-details';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function ConfigServerDetails() {
|
||||
return (
|
||||
<div className="config-server-details-form">
|
||||
<Title>Server Settings</Title>
|
||||
<p className="description">
|
||||
You should change your stream key from the default and keep it safe. For most people
|
||||
it's likely the other settings will not need to be changed.
|
||||
</p>
|
||||
<div className="form-module config-server-details-container">
|
||||
<EditServerDetails />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
web/pages/admin/config-social-items.tsx
Normal file
15
web/pages/admin/config-social-items.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import EditSocialLinks from '../../components/config/edit-social-links';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function ConfigSocialThings() {
|
||||
return (
|
||||
<div className="config-social-items">
|
||||
<Title>Social Items</Title>
|
||||
|
||||
<EditSocialLinks />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
web/pages/admin/config-storage.tsx
Normal file
34
web/pages/admin/config-storage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import EditStorage from '../../components/config/edit-storage';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function ConfigStorageInfo() {
|
||||
return (
|
||||
<>
|
||||
<Title>Storage</Title>
|
||||
<p className="description">
|
||||
Owncast supports optionally using external storage providers to stream your video. Learn
|
||||
more about this by visiting our{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/storage/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Storage Documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="description">
|
||||
Configuring this incorrectly will likely cause your video to be unplayable. Double check the
|
||||
documentation for your storage provider on how to configure the bucket you created for
|
||||
Owncast.
|
||||
</p>
|
||||
<p className="description">
|
||||
Keep in mind this is for live streaming, not for archival, recording or VOD purposes.
|
||||
</p>
|
||||
<EditStorage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
web/pages/admin/config-video.tsx
Normal file
50
web/pages/admin/config-video.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Col, Collapse, Row, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import VideoCodecSelector from '../../components/config/video-codec-selector';
|
||||
import VideoLatency from '../../components/config/video-latency';
|
||||
import VideoVariantsTable from '../../components/config/video-variants-table';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function ConfigVideoSettings() {
|
||||
return (
|
||||
<div className="config-video-variants">
|
||||
<Title>Video configuration</Title>
|
||||
<p className="description">
|
||||
Before changing your video configuration{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/video?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
visit the video documentation
|
||||
</a>{' '}
|
||||
to learn how it impacts your stream performance. The general rule is to start conservatively
|
||||
by having one middle quality stream output variant and experiment with adding more of varied
|
||||
qualities.
|
||||
</p>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col md={24} lg={12}>
|
||||
<div className="form-module variants-table-module">
|
||||
<VideoVariantsTable />
|
||||
</div>
|
||||
</Col>
|
||||
<Col md={24} lg={12}>
|
||||
<div className="form-module latency-module">
|
||||
<VideoLatency />
|
||||
</div>
|
||||
|
||||
<Collapse className="advanced-settings codec-module">
|
||||
<Panel header="Advanced Settings" key="1">
|
||||
<div className="form-module variants-table-module">
|
||||
<VideoCodecSelector />
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
web/pages/admin/federation/actions.tsx
Normal file
136
web/pages/admin/federation/actions.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table/interface';
|
||||
import format from 'date-fns/format';
|
||||
import { FEDERATION_ACTIONS, fetchData } from '../../../utils/apis';
|
||||
|
||||
import { isEmptyObject } from '../../../utils/format';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export interface Action {
|
||||
iri: string;
|
||||
actorIRI: string;
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export default function FediverseActions() {
|
||||
const [actions, setActions] = useState<Action[]>([]);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
|
||||
const getActions = async () => {
|
||||
try {
|
||||
const limit = 50;
|
||||
const offset = currentPage * limit;
|
||||
const u = `${FEDERATION_ACTIONS}?offset=${offset}&limit=${limit}`;
|
||||
const result = await fetchData(u, { auth: true });
|
||||
const { results, total } = result;
|
||||
setTotalCount(total);
|
||||
if (isEmptyObject(results)) {
|
||||
setActions([]);
|
||||
} else {
|
||||
setActions(results);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getActions();
|
||||
}, [currentPage]);
|
||||
|
||||
const columns: ColumnsType<Action> = [
|
||||
{
|
||||
title: 'Action',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 50,
|
||||
render: (_, record) => {
|
||||
let image;
|
||||
let title;
|
||||
switch (record.type) {
|
||||
case 'FEDIVERSE_ENGAGEMENT_REPOST':
|
||||
image = '/img/repost.svg';
|
||||
title = 'Share';
|
||||
break;
|
||||
case 'FEDIVERSE_ENGAGEMENT_LIKE':
|
||||
image = '/img/like.svg';
|
||||
title = 'Like';
|
||||
break;
|
||||
case 'FEDIVERSE_ENGAGEMENT_FOLLOW':
|
||||
image = '/img/follow.svg';
|
||||
title = 'Follow';
|
||||
break;
|
||||
default:
|
||||
image = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<img src={image} width="70%" alt={title} title={title} />
|
||||
<div style={{ fontSize: '0.7rem' }}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'From',
|
||||
dataIndex: 'actorIRI',
|
||||
key: 'from',
|
||||
render: (_, record) => <a href={record.actorIRI}>{record.actorIRI}</a>,
|
||||
},
|
||||
{
|
||||
title: 'When',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
render: (_, record) => {
|
||||
const dateObject = new Date(record.timestamp);
|
||||
return format(dateObject, 'P pp');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function makeTable(data: Action[], tableColumns: ColumnsType<Action>) {
|
||||
return (
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={tableColumns}
|
||||
size="small"
|
||||
rowKey={row => row.iri}
|
||||
pagination={{
|
||||
pageSize: 50,
|
||||
hideOnSinglePage: true,
|
||||
showSizeChanger: false,
|
||||
total: totalCount,
|
||||
}}
|
||||
onChange={pagination => {
|
||||
const page = pagination.current;
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={3}>Fediverse Actions</Title>
|
||||
<Paragraph>
|
||||
Below is a list of actions that were taken by others in response to your posts as well as
|
||||
people who requested to follow you.
|
||||
</Paragraph>
|
||||
{makeTable(actions, columns)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
web/pages/admin/federation/followers.tsx
Normal file
336
web/pages/admin/federation/followers.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import React, { useEffect, useState, useContext } from 'react';
|
||||
import { Table, Avatar, Button, Tabs } from 'antd';
|
||||
import { ColumnsType, SortOrder } from 'antd/lib/table/interface';
|
||||
import format from 'date-fns/format';
|
||||
import { UserAddOutlined, UserDeleteOutlined } from '@ant-design/icons';
|
||||
import { ServerStatusContext } from '../../../utils/server-status-context';
|
||||
import {
|
||||
FOLLOWERS,
|
||||
FOLLOWERS_PENDING,
|
||||
SET_FOLLOWER_APPROVAL,
|
||||
FOLLOWERS_BLOCKED,
|
||||
fetchData,
|
||||
} from '../../../utils/apis';
|
||||
import { isEmptyObject } from '../../../utils/format';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
export interface Follower {
|
||||
link: string;
|
||||
username: string;
|
||||
image: string;
|
||||
name: string;
|
||||
timestamp: Date;
|
||||
approved: Date;
|
||||
}
|
||||
|
||||
export default function FediverseFollowers() {
|
||||
const [followersPending, setFollowersPending] = useState<Follower[]>([]);
|
||||
const [followersBlocked, setFollowersBlocked] = useState<Follower[]>([]);
|
||||
const [followers, setFollowers] = useState<Follower[]>([]);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { serverConfig } = serverStatusData || {};
|
||||
const { federation } = serverConfig;
|
||||
const { isPrivate } = federation;
|
||||
|
||||
const getFollowers = async () => {
|
||||
try {
|
||||
const limit = 50;
|
||||
const offset = currentPage * limit;
|
||||
const u = `${FOLLOWERS}?offset=${offset}&limit=${limit}`;
|
||||
|
||||
// Active followers
|
||||
const result = await fetchData(u, { auth: true });
|
||||
const { results, total } = result;
|
||||
|
||||
if (isEmptyObject(results)) {
|
||||
setFollowers([]);
|
||||
} else {
|
||||
setTotalCount(total);
|
||||
setFollowers(results);
|
||||
}
|
||||
|
||||
// Pending follow requests
|
||||
const pendingFollowersResult = await fetchData(FOLLOWERS_PENDING, { auth: true });
|
||||
if (isEmptyObject(pendingFollowersResult)) {
|
||||
setFollowersPending([]);
|
||||
} else {
|
||||
setFollowersPending(pendingFollowersResult);
|
||||
}
|
||||
|
||||
// Blocked/rejected followers
|
||||
const blockedFollowersResult = await fetchData(FOLLOWERS_BLOCKED, { auth: true });
|
||||
if (isEmptyObject(followersBlocked)) {
|
||||
setFollowersBlocked([]);
|
||||
} else {
|
||||
setFollowersBlocked(blockedFollowersResult);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getFollowers();
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<Follower> = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'image',
|
||||
key: 'image',
|
||||
width: 90,
|
||||
render: image => <Avatar size={40} src={image || '/img/logo.svg'} />,
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (_, follower) => (
|
||||
<a href={follower.link} target="_blank" rel="noreferrer">
|
||||
{follower.name || follower.username}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'link',
|
||||
key: 'link',
|
||||
render: (_, follower) => (
|
||||
<a href={follower.link} target="_blank" rel="noreferrer">
|
||||
{follower.link}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function makeTable(data: Follower[], tableColumns: ColumnsType<Follower>) {
|
||||
return (
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={tableColumns}
|
||||
size="small"
|
||||
rowKey={row => row.link}
|
||||
pagination={{
|
||||
pageSize: 50,
|
||||
hideOnSinglePage: true,
|
||||
showSizeChanger: false,
|
||||
total: totalCount,
|
||||
}}
|
||||
onChange={pagination => {
|
||||
const page = pagination.current;
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function approveFollowRequest(request) {
|
||||
try {
|
||||
await fetchData(SET_FOLLOWER_APPROVAL, {
|
||||
auth: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
actorIRI: request.link,
|
||||
approved: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch and update the current data.
|
||||
getFollowers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectFollowRequest(request) {
|
||||
try {
|
||||
await fetchData(SET_FOLLOWER_APPROVAL, {
|
||||
auth: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
actorIRI: request.link,
|
||||
approved: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch and update the current data.
|
||||
getFollowers();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingColumns: ColumnsType<Follower> = [...columns];
|
||||
pendingColumns.unshift(
|
||||
{
|
||||
title: 'Approve',
|
||||
dataIndex: null,
|
||||
key: null,
|
||||
render: request => (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UserAddOutlined />}
|
||||
onClick={() => {
|
||||
approveFollowRequest(request);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
title: 'Reject',
|
||||
dataIndex: null,
|
||||
key: null,
|
||||
render: request => (
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<UserDeleteOutlined />}
|
||||
onClick={() => {
|
||||
rejectFollowRequest(request);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 50,
|
||||
},
|
||||
);
|
||||
|
||||
pendingColumns.push({
|
||||
title: 'Requested',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'requested',
|
||||
width: 200,
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return <>{format(dateObject, 'P')}</>;
|
||||
},
|
||||
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
defaultSortOrder: 'descend' as SortOrder,
|
||||
});
|
||||
|
||||
const blockedColumns: ColumnsType<Follower> = [...columns];
|
||||
blockedColumns.unshift({
|
||||
title: 'Approve',
|
||||
dataIndex: null,
|
||||
key: null,
|
||||
render: request => (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UserAddOutlined />}
|
||||
size="large"
|
||||
onClick={() => {
|
||||
approveFollowRequest(request);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 50,
|
||||
});
|
||||
|
||||
blockedColumns.push(
|
||||
{
|
||||
title: 'Requested',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'requested',
|
||||
width: 200,
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return <>{format(dateObject, 'P')}</>;
|
||||
},
|
||||
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
defaultSortOrder: 'descend' as SortOrder,
|
||||
},
|
||||
{
|
||||
title: 'Rejected/Blocked',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'disabled_at',
|
||||
width: 200,
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return <>{format(dateObject, 'P')}</>;
|
||||
},
|
||||
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
defaultSortOrder: 'descend' as SortOrder,
|
||||
},
|
||||
);
|
||||
|
||||
const followersColumns: ColumnsType<Follower> = [...columns];
|
||||
|
||||
followersColumns.push(
|
||||
{
|
||||
title: 'Added',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 200,
|
||||
render: timestamp => {
|
||||
const dateObject = new Date(timestamp);
|
||||
return <>{format(dateObject, 'P')}</>;
|
||||
},
|
||||
sorter: (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
sortDirections: ['descend', 'ascend'] as SortOrder[],
|
||||
defaultSortOrder: 'descend' as SortOrder,
|
||||
},
|
||||
{
|
||||
title: 'Remove',
|
||||
dataIndex: null,
|
||||
key: null,
|
||||
render: request => (
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<UserDeleteOutlined />}
|
||||
onClick={() => {
|
||||
rejectFollowRequest(request);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 50,
|
||||
},
|
||||
);
|
||||
|
||||
const pendingRequestsTab = isPrivate && (
|
||||
<TabPane
|
||||
tab={<span>Requests {followersPending.length > 0 && `(${followersPending.length})`}</span>}
|
||||
key="2"
|
||||
>
|
||||
<p>
|
||||
The following people are requesting to follow your Owncast server on the{' '}
|
||||
<a href="https://en.wikipedia.org/wiki/Fediverse" target="_blank" rel="noopener noreferrer">
|
||||
Fediverse
|
||||
</a>{' '}
|
||||
and be alerted to when you go live. Each must be approved.
|
||||
</p>
|
||||
{makeTable(followersPending, pendingColumns)}
|
||||
</TabPane>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="followers-section">
|
||||
<Tabs defaultActiveKey="1">
|
||||
<TabPane
|
||||
tab={<span>Followers {followers.length > 0 && `(${followers.length})`}</span>}
|
||||
key="1"
|
||||
>
|
||||
<p>The following accounts get notified when you go live or send a post.</p>
|
||||
{makeTable(followers, followersColumns)}{' '}
|
||||
</TabPane>
|
||||
{pendingRequestsTab}
|
||||
<TabPane
|
||||
tab={<span>Blocked {followersBlocked.length > 0 && `(${followersBlocked.length})`}</span>}
|
||||
key="3"
|
||||
>
|
||||
<p>
|
||||
The following people were either rejected or blocked by you. You can approve them as a
|
||||
follower.
|
||||
</p>
|
||||
<p>{makeTable(followersBlocked, blockedColumns)}</p>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
web/pages/admin/hardware-info.tsx
Normal file
111
web/pages/admin/hardware-info.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { Row, Col, Typography } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { fetchData, FETCH_INTERVAL, HARDWARE_STATS } from '../../utils/apis';
|
||||
import Chart from '../../components/chart';
|
||||
import StatisticItem from '../../components/statistic';
|
||||
|
||||
// TODO: FIX TS WARNING FROM THIS.
|
||||
// interface TimedValue {
|
||||
// time: Date;
|
||||
// value: Number;
|
||||
// }
|
||||
|
||||
export default function HardwareInfo() {
|
||||
const [hardwareStatus, setHardwareStatus] = useState({
|
||||
cpu: [], // Array<TimedValue>(),
|
||||
memory: [], // Array<TimedValue>(),
|
||||
disk: [], // Array<TimedValue>(),
|
||||
message: '',
|
||||
});
|
||||
|
||||
const getHardwareStatus = async () => {
|
||||
try {
|
||||
const result = await fetchData(HARDWARE_STATS);
|
||||
setHardwareStatus({ ...result });
|
||||
} catch (error) {
|
||||
setHardwareStatus({ ...hardwareStatus, message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let getStatusIntervalId = null;
|
||||
|
||||
getHardwareStatus();
|
||||
getStatusIntervalId = setInterval(getHardwareStatus, FETCH_INTERVAL); // runs every 1 min.
|
||||
|
||||
// returned function will be called on component unmount
|
||||
return () => {
|
||||
clearInterval(getStatusIntervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!hardwareStatus.cpu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentCPUUsage = hardwareStatus.cpu[hardwareStatus.cpu.length - 1]?.value;
|
||||
const currentRamUsage = hardwareStatus.memory[hardwareStatus.memory.length - 1]?.value;
|
||||
const currentDiskUsage = hardwareStatus.disk[hardwareStatus.disk.length - 1]?.value;
|
||||
|
||||
const series = [
|
||||
{
|
||||
name: 'CPU',
|
||||
color: '#B63FFF',
|
||||
data: hardwareStatus.cpu,
|
||||
},
|
||||
{
|
||||
name: 'Memory',
|
||||
color: '#2087E2',
|
||||
data: hardwareStatus.memory,
|
||||
},
|
||||
{
|
||||
name: 'Disk',
|
||||
color: '#FF7700',
|
||||
data: hardwareStatus.disk,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Title>Hardware Info</Typography.Title>
|
||||
<br />
|
||||
<div>
|
||||
<Row gutter={[16, 16]} justify="space-around">
|
||||
<Col>
|
||||
<StatisticItem
|
||||
title={series[0].name}
|
||||
value={`${Math.round(currentCPUUsage) || 0}`}
|
||||
prefix={<LaptopOutlined style={{ color: series[0].color }} />}
|
||||
color={series[0].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<StatisticItem
|
||||
title={series[1].name}
|
||||
value={`${Math.round(currentRamUsage) || 0}`}
|
||||
prefix={<BulbOutlined style={{ color: series[1].color }} />}
|
||||
color={series[1].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<StatisticItem
|
||||
title={series[2].name}
|
||||
value={`${Math.round(currentDiskUsage) || 0}`}
|
||||
prefix={<SaveOutlined style={{ color: series[2].color }} />}
|
||||
color={series[2].color}
|
||||
progress
|
||||
centered
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Chart title="% used" dataCollections={series} color="#FF7700" unit="%" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
228
web/pages/admin/help.tsx
Normal file
228
web/pages/admin/help.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Button, Card, Col, Divider, Result, Row } from 'antd';
|
||||
import Meta from 'antd/lib/card/Meta';
|
||||
import Title from 'antd/lib/typography/Title';
|
||||
import {
|
||||
ApiTwoTone,
|
||||
BugTwoTone,
|
||||
CameraTwoTone,
|
||||
DatabaseTwoTone,
|
||||
EditTwoTone,
|
||||
Html5TwoTone,
|
||||
LinkOutlined,
|
||||
QuestionCircleTwoTone,
|
||||
SettingTwoTone,
|
||||
SlidersTwoTone,
|
||||
} from '@ant-design/icons';
|
||||
import React from 'react';
|
||||
|
||||
export default function Help() {
|
||||
const questions = [
|
||||
{
|
||||
icon: <SettingTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I want to configure my owncast instance',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/configuration/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkOutlined /> Learn more
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <CameraTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'Help configuring my broadcasting software',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/broadcasting/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkOutlined /> Learn more
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Html5TwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I want to embed my stream into another site',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/embed/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkOutlined /> Learn more
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <EditTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I want to customize my website',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/website/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkOutlined /> Learn more
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <SlidersTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I want to tweak my video output',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/encoding/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkOutlined /> Learn more
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <DatabaseTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I want to use an external storage provider',
|
||||
content: (
|
||||
<div>
|
||||
<a
|
||||
href="https://owncast.online/docs/storage/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkOutlined /> Learn more
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const otherResources = [
|
||||
{
|
||||
icon: <BugTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I found a bug',
|
||||
content: (
|
||||
<div>
|
||||
If you found a bug, then please
|
||||
<a
|
||||
href="https://github.com/owncast/owncast/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
let us know
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <QuestionCircleTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I have a general question',
|
||||
content: (
|
||||
<div>
|
||||
Most general questions are answered in our
|
||||
<a
|
||||
href="https://owncast.online/docs/faq/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{' '}
|
||||
FAQ
|
||||
</a>{' '}
|
||||
or exist in our{' '}
|
||||
<a
|
||||
href="https://github.com/owncast/owncast/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
discussions
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <ApiTwoTone style={{ fontSize: '24px' }} />,
|
||||
title: 'I want to build add-ons for Owncast',
|
||||
content: (
|
||||
<div>
|
||||
You can build your own bots, overlays, tools and add-ons with our
|
||||
<a
|
||||
href="https://owncast.online/thirdparty?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
developer APIs.
|
||||
</a>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="help-page">
|
||||
<Title style={{ textAlign: 'center' }}>How can we help you?</Title>
|
||||
<Row gutter={[16, 16]} justify="space-around" align="middle">
|
||||
<Col xs={24} lg={12} style={{ textAlign: 'center' }}>
|
||||
<Result status="500" />
|
||||
<Title level={2}>Troubleshooting</Title>
|
||||
<Button
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://owncast.online/docs/troubleshooting/?source=admin"
|
||||
icon={<LinkOutlined />}
|
||||
type="primary"
|
||||
>
|
||||
Fix your problems
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} lg={12} style={{ textAlign: 'center' }}>
|
||||
<Result status="404" />
|
||||
<Title level={2}>Documentation</Title>
|
||||
<Button
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://owncast.online/docs?source=admin"
|
||||
icon={<LinkOutlined />}
|
||||
type="primary"
|
||||
>
|
||||
Read the Docs
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider />
|
||||
<Title level={2}>Common tasks</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{questions.map(question => (
|
||||
<Col xs={24} lg={12} key={question.title}>
|
||||
<Card>
|
||||
<Meta avatar={question.icon} title={question.title} description={question.content} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Divider />
|
||||
<Title level={2}>Other</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{otherResources.map(question => (
|
||||
<Col xs={24} lg={12} key={question.title}>
|
||||
<Card>
|
||||
<Meta avatar={question.icon} title={question.title} description={question.content} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
web/pages/admin/index.tsx
Normal file
180
web/pages/admin/index.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Skeleton, Card, Statistic, Row, Col } from 'antd';
|
||||
import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { formatDistanceToNow, formatRelative } from 'date-fns';
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
import LogTable from '../../components/log-table';
|
||||
import Offline from '../../components/offline-notice';
|
||||
import StreamHealthOverview from '../../components/stream-health-overview';
|
||||
|
||||
import { LOGS_WARN, fetchData, FETCH_INTERVAL } from '../../utils/apis';
|
||||
import { formatIPAddress, isEmptyObject } from '../../utils/format';
|
||||
import NewsFeed from '../../components/news-feed';
|
||||
|
||||
function streamDetailsFormatter(streamDetails) {
|
||||
return (
|
||||
<ul className="statistics-list">
|
||||
<li>
|
||||
{streamDetails.videoCodec || 'Unknown'} @ {streamDetails.videoBitrate || 'Unknown'} kbps
|
||||
</li>
|
||||
<li>{streamDetails.framerate || 'Unknown'} fps</li>
|
||||
<li>
|
||||
{streamDetails.width} x {streamDetails.height}
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const serverStatusData = useContext(ServerStatusContext);
|
||||
const { broadcaster, serverConfig: configData } = serverStatusData || {};
|
||||
const { remoteAddr, streamDetails } = broadcaster || {};
|
||||
|
||||
const encoder = streamDetails?.encoder || 'Unknown encoder';
|
||||
|
||||
const [logsData, setLogs] = useState([]);
|
||||
const getLogs = async () => {
|
||||
try {
|
||||
const result = await fetchData(LOGS_WARN);
|
||||
setLogs(result);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
const getMoreStats = () => {
|
||||
getLogs();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getMoreStats();
|
||||
|
||||
let intervalId = null;
|
||||
intervalId = setInterval(getMoreStats, FETCH_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isEmptyObject(configData) || isEmptyObject(serverStatusData)) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton active />
|
||||
<Skeleton active />
|
||||
<Skeleton active />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!broadcaster) {
|
||||
return <Offline logs={logsData} config={configData} />;
|
||||
}
|
||||
|
||||
// map out settings
|
||||
const videoQualitySettings = serverStatusData?.currentBroadcast?.outputSettings?.map(setting => {
|
||||
const { audioPassthrough, videoPassthrough, audioBitrate, videoBitrate, framerate } = setting;
|
||||
|
||||
const audioSetting = audioPassthrough
|
||||
? `${streamDetails.audioCodec || 'Unknown'}, ${streamDetails.audioBitrate} kbps`
|
||||
: `${audioBitrate || 'Unknown'} kbps`;
|
||||
|
||||
const videoSetting = videoPassthrough
|
||||
? `${streamDetails.videoBitrate || 'Unknown'} kbps, ${streamDetails.framerate} fps ${
|
||||
streamDetails.width
|
||||
} x ${streamDetails.height}`
|
||||
: `${videoBitrate || 'Unknown'} kbps, ${framerate} fps`;
|
||||
|
||||
return (
|
||||
<div className="stream-details-item-container">
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Outbound Video Stream"
|
||||
value={videoSetting}
|
||||
/>
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Outbound Audio Stream"
|
||||
value={audioSetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// inbound
|
||||
const { viewerCount, sessionPeakViewerCount } = serverStatusData;
|
||||
|
||||
const streamAudioDetailString = `${streamDetails.audioCodec}, ${
|
||||
streamDetails.audioBitrate || 'Unknown'
|
||||
} kbps`;
|
||||
|
||||
const broadcastDate = new Date(broadcaster.time);
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
<div className="sections-container">
|
||||
<div className="online-status-section">
|
||||
<Card size="small" type="inner" className="online-details-card">
|
||||
<Row gutter={[16, 16]} align="middle">
|
||||
<Col span={8} sm={24} md={8}>
|
||||
<Statistic
|
||||
title={`Stream started ${formatRelative(broadcastDate, Date.now())}`}
|
||||
value={formatDistanceToNow(broadcastDate)}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8} sm={24} md={8}>
|
||||
<Statistic title="Viewers" value={viewerCount} prefix={<UserOutlined />} />
|
||||
</Col>
|
||||
<Col span={8} sm={24} md={8}>
|
||||
<Statistic
|
||||
title="Peak viewer count"
|
||||
value={sessionPeakViewerCount}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<StreamHealthOverview />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]} className="section stream-details-section">
|
||||
<Col className="stream-details" span={12} sm={24} md={24} lg={12}>
|
||||
<Card
|
||||
size="small"
|
||||
title="Outbound Stream Details"
|
||||
type="inner"
|
||||
className="outbound-details"
|
||||
>
|
||||
{videoQualitySettings}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Inbound Stream Details" type="inner">
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Input"
|
||||
value={`${encoder} ${formatIPAddress(remoteAddr)}`}
|
||||
/>
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Inbound Video Stream"
|
||||
value={streamDetails}
|
||||
formatter={streamDetailsFormatter}
|
||||
/>
|
||||
<Statistic
|
||||
className="stream-details-item"
|
||||
title="Inbound Audio Stream"
|
||||
value={streamAudioDetailString}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={12}>
|
||||
<NewsFeed />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<br />
|
||||
<LogTable logs={logsData} pageSize={5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
web/pages/admin/logs.tsx
Normal file
34
web/pages/admin/logs.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import LogTable from '../../components/log-table';
|
||||
|
||||
import { LOGS_ALL, fetchData } from '../../utils/apis';
|
||||
|
||||
const FETCH_INTERVAL = 5 * 1000; // 5 sec
|
||||
|
||||
export default function Logs() {
|
||||
const [logs, setLogs] = useState([]);
|
||||
|
||||
const getInfo = async () => {
|
||||
try {
|
||||
const result = await fetchData(LOGS_ALL);
|
||||
setLogs(result);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let getStatusIntervalId = null;
|
||||
|
||||
setInterval(getInfo, FETCH_INTERVAL);
|
||||
getInfo();
|
||||
|
||||
getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL);
|
||||
// returned function will be called on component unmount
|
||||
return () => {
|
||||
clearInterval(getStatusIntervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <LogTable logs={logs} pageSize={20} />;
|
||||
}
|
||||
412
web/pages/admin/stream-health.tsx
Normal file
412
web/pages/admin/stream-health.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
/* eslint-disable react/no-unescaped-entities */
|
||||
// import { BulbOutlined, LaptopOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { Row, Col, Typography, Space, Statistic, Card, Alert, Spin } from 'antd';
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { ClockCircleOutlined, WarningOutlined, WifiOutlined } from '@ant-design/icons';
|
||||
import { fetchData, FETCH_INTERVAL, API_STREAM_HEALTH_METRICS } from '../../utils/apis';
|
||||
import Chart from '../../components/chart';
|
||||
import StreamHealthOverview from '../../components/stream-health-overview';
|
||||
|
||||
interface TimedValue {
|
||||
time: Date;
|
||||
value: Number;
|
||||
}
|
||||
|
||||
interface DescriptionBoxProps {
|
||||
title: String;
|
||||
description: ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionBox({ title, description }: DescriptionBoxProps) {
|
||||
return (
|
||||
<div className="description-box">
|
||||
<Typography.Title>{title}</Typography.Title>
|
||||
<Typography.Paragraph>{description}</Typography.Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StreamHealth() {
|
||||
const [errors, setErrors] = useState<TimedValue[]>([]);
|
||||
const [qualityVariantChanges, setQualityVariantChanges] = useState<TimedValue[]>([]);
|
||||
|
||||
const [lowestLatency, setLowestLatency] = useState<TimedValue[]>();
|
||||
const [highestLatency, setHighestLatency] = useState<TimedValue[]>();
|
||||
const [medianLatency, setMedianLatency] = useState<TimedValue[]>([]);
|
||||
|
||||
const [medianSegmentDownloadDurations, setMedianSegmentDownloadDurations] = useState<
|
||||
TimedValue[]
|
||||
>([]);
|
||||
const [maximumSegmentDownloadDurations, setMaximumSegmentDownloadDurations] = useState<
|
||||
TimedValue[]
|
||||
>([]);
|
||||
const [minimumSegmentDownloadDurations, setMinimumSegmentDownloadDurations] = useState<
|
||||
TimedValue[]
|
||||
>([]);
|
||||
const [minimumPlayerBitrate, setMinimumPlayerBitrate] = useState<TimedValue[]>([]);
|
||||
const [medianPlayerBitrate, setMedianPlayerBitrate] = useState<TimedValue[]>([]);
|
||||
const [maximumPlayerBitrate, setMaximumPlayerBitrate] = useState<TimedValue[]>([]);
|
||||
const [availableBitrates, setAvailableBitrates] = useState<Number[]>([]);
|
||||
const [segmentLength, setSegmentLength] = useState(0);
|
||||
|
||||
const getMetrics = async () => {
|
||||
try {
|
||||
const result = await fetchData(API_STREAM_HEALTH_METRICS);
|
||||
setErrors(result.errors);
|
||||
setQualityVariantChanges(result.qualityVariantChanges);
|
||||
|
||||
setHighestLatency(result.highestLatency);
|
||||
setLowestLatency(result.lowestLatency);
|
||||
setMedianLatency(result.medianLatency);
|
||||
|
||||
setMedianSegmentDownloadDurations(result.medianSegmentDownloadDuration);
|
||||
setMaximumSegmentDownloadDurations(result.maximumSegmentDownloadDuration);
|
||||
setMinimumSegmentDownloadDurations(result.minimumSegmentDownloadDuration);
|
||||
|
||||
setMinimumPlayerBitrate(result.minPlayerBitrate);
|
||||
setMedianPlayerBitrate(result.medianPlayerBitrate);
|
||||
setMaximumPlayerBitrate(result.maxPlayerBitrate);
|
||||
|
||||
setAvailableBitrates(result.availableBitrates);
|
||||
setSegmentLength(result.segmentLength - 0.3);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let getStatusIntervalId = null;
|
||||
|
||||
getMetrics();
|
||||
getStatusIntervalId = setInterval(getMetrics, FETCH_INTERVAL); // runs every 1 min.
|
||||
|
||||
// returned function will be called on component unmount
|
||||
return () => {
|
||||
clearInterval(getStatusIntervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const noData = (
|
||||
<div>
|
||||
<Typography.Title>Stream Performance</Typography.Title>
|
||||
<Alert
|
||||
type="info"
|
||||
message="
|
||||
Data has not yet been collected. Once a stream has begun and viewers are watching this page
|
||||
will be available."
|
||||
/>
|
||||
<Spin size="large">
|
||||
<div style={{ marginTop: '50px', height: '100px' }} />
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
if (!errors?.length) {
|
||||
return noData;
|
||||
}
|
||||
|
||||
if (!medianLatency?.length) {
|
||||
return noData;
|
||||
}
|
||||
|
||||
if (!medianSegmentDownloadDurations?.length) {
|
||||
return noData;
|
||||
}
|
||||
|
||||
const errorChart = [
|
||||
{
|
||||
name: 'Errors',
|
||||
color: '#B63FFF',
|
||||
options: { radius: 3 },
|
||||
data: errors,
|
||||
},
|
||||
{
|
||||
name: 'Quality changes',
|
||||
color: '#2087E2',
|
||||
options: { radius: 2 },
|
||||
data: qualityVariantChanges,
|
||||
},
|
||||
];
|
||||
|
||||
const latencyChart = [
|
||||
{
|
||||
name: 'Median stream latency',
|
||||
color: '#00FFFF',
|
||||
options: { radius: 2 },
|
||||
data: medianLatency,
|
||||
},
|
||||
{
|
||||
name: 'Lowest stream latency',
|
||||
color: '#02FD0D',
|
||||
options: { radius: 2 },
|
||||
data: lowestLatency,
|
||||
},
|
||||
{
|
||||
name: 'Highest stream latency',
|
||||
color: '#B63FFF',
|
||||
options: { radius: 2 },
|
||||
data: highestLatency,
|
||||
},
|
||||
];
|
||||
|
||||
const segmentDownloadDurationChart = [
|
||||
{
|
||||
name: 'Max download duration',
|
||||
color: '#B63FFF',
|
||||
options: { radius: 2 },
|
||||
data: maximumSegmentDownloadDurations,
|
||||
},
|
||||
{
|
||||
name: 'Median download duration',
|
||||
color: '#00FFFF',
|
||||
options: { radius: 2 },
|
||||
data: medianSegmentDownloadDurations,
|
||||
},
|
||||
{
|
||||
name: 'Min download duration',
|
||||
color: '#02FD0D',
|
||||
options: { radius: 2 },
|
||||
data: minimumSegmentDownloadDurations,
|
||||
},
|
||||
{
|
||||
name: `Approximate limit`,
|
||||
color: '#003FFF',
|
||||
data: medianSegmentDownloadDurations.map(item => ({
|
||||
time: item.time,
|
||||
value: segmentLength,
|
||||
})),
|
||||
options: { radius: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
const bitrateChart = [
|
||||
{
|
||||
name: 'Lowest player speed',
|
||||
color: '#B63FFF',
|
||||
data: minimumPlayerBitrate,
|
||||
options: { radius: 2 },
|
||||
},
|
||||
{
|
||||
name: 'Median player speed',
|
||||
color: '#00FFFF',
|
||||
data: medianPlayerBitrate,
|
||||
options: { radius: 2 },
|
||||
},
|
||||
{
|
||||
name: 'Maximum player speed',
|
||||
color: '#02FD0D',
|
||||
data: maximumPlayerBitrate,
|
||||
options: { radius: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
availableBitrates.forEach(bitrate => {
|
||||
bitrateChart.push({
|
||||
name: `Available bitrate`,
|
||||
color: '#003FFF',
|
||||
data: minimumPlayerBitrate.map(item => ({
|
||||
time: item.time,
|
||||
value: bitrate,
|
||||
})),
|
||||
options: { radius: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
const currentSpeed = bitrateChart[0]?.data[bitrateChart[0].data.length - 1]?.value;
|
||||
const currentDownloadSeconds =
|
||||
medianSegmentDownloadDurations[medianSegmentDownloadDurations.length - 1]?.value;
|
||||
const lowestVariant = availableBitrates[0]; // TODO: get lowest bitrate from available bitrates
|
||||
|
||||
const latencyMedian = medianLatency[medianLatency.length - 1]?.value || 0;
|
||||
const latencyMax = highestLatency[highestLatency.length - 1]?.value || 0;
|
||||
const latencyMin = lowestLatency[lowestLatency.length - 1]?.value || 0;
|
||||
const latencyStat = (Number(latencyMax) + Number(latencyMin) + Number(latencyMedian)) / 3;
|
||||
|
||||
let recentErrorCount = 0;
|
||||
const errorValueCount = errorChart[0]?.data.length || 0;
|
||||
if (errorValueCount > 5) {
|
||||
const values = errorChart[0].data.slice(-5);
|
||||
recentErrorCount = values.reduce((acc, curr) => acc + Number(curr.value), 0);
|
||||
} else {
|
||||
recentErrorCount = errorChart[0].data.reduce((acc, curr) => acc + Number(curr.value), 0);
|
||||
}
|
||||
const showStats = currentSpeed > 0 || currentDownloadSeconds > 0 || recentErrorCount > 0;
|
||||
let bitrateError = null;
|
||||
let speedError = null;
|
||||
|
||||
if (currentSpeed !== 0 && currentSpeed < lowestVariant) {
|
||||
bitrateError = `One of your viewers is playing your stream at ${currentSpeed}kbps, slower than ${lowestVariant}kbps, the lowest quality you made available. Consider adding a lower quality with a lower bitrate if the errors over time warrant this.`;
|
||||
}
|
||||
|
||||
if (currentDownloadSeconds > segmentLength) {
|
||||
speedError =
|
||||
'Your viewers may be consuming your video slower than required. This may be due to slow networks or your latency configuration. You need to decrease the amount of time viewers are taking to consume your video. Consider adding a lower quality with a lower bitrate or experiment with increasing the latency buffer setting.';
|
||||
}
|
||||
|
||||
const errorStatColor = recentErrorCount > 0 ? '#B63FFF' : '#FFFFFF';
|
||||
const statStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '80px',
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Typography.Title>Stream Performance</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
This tool hopes to help you identify and troubleshoot problems you may be experiencing with
|
||||
your stream. It aims to aggregate experiences across your viewers, meaning one viewer with
|
||||
an exceptionally bad experience may throw off numbers for the whole, especially with a low
|
||||
number of viewers.
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph>
|
||||
The data is only collected by those using the Owncast web interface and is unable to gain
|
||||
insight into external players people may be using such as VLC, MPV, QuickTime, etc.
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical" size="middle">
|
||||
<Row justify="space-around">
|
||||
<Col style={{ width: '100%' }}>
|
||||
<Card type="inner">
|
||||
<StreamHealthOverview showTroubleshootButton={false} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row
|
||||
gutter={[16, 16]}
|
||||
justify="space-around"
|
||||
style={{ display: showStats ? 'flex' : 'none' }}
|
||||
>
|
||||
<Col>
|
||||
<Card type="inner">
|
||||
<div style={statStyle}>
|
||||
<Statistic
|
||||
title="Viewer Playback Speed"
|
||||
value={`${currentSpeed}`}
|
||||
prefix={<WifiOutlined style={{ marginRight: '5px' }} />}
|
||||
precision={0}
|
||||
suffix="kbps"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card type="inner">
|
||||
<div style={statStyle}>
|
||||
<Statistic
|
||||
title="Viewer Latency"
|
||||
value={`${latencyStat}`}
|
||||
prefix={<ClockCircleOutlined style={{ marginRight: '5px' }} />}
|
||||
precision={0}
|
||||
suffix="seconds"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card type="inner">
|
||||
<div style={statStyle}>
|
||||
<Statistic
|
||||
title="Recent Playback Errors"
|
||||
value={`${recentErrorCount || 0}`}
|
||||
valueStyle={{ color: errorStatColor }}
|
||||
prefix={<WarningOutlined style={{ marginRight: '5px' }} />}
|
||||
suffix=""
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<DescriptionBox
|
||||
title="Video Segment Download"
|
||||
description={
|
||||
<>
|
||||
<Typography.Paragraph>
|
||||
Once a video segment takes too long to download a viewer will experience
|
||||
buffering. If you see slow downloads you should offer a lower quality for your
|
||||
viewers, or find other ways, possibly an external storage provider, a CDN or a
|
||||
faster network, to improve your stream quality. Increasing your latency buffer can
|
||||
also help for some viewers.
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph>
|
||||
In short, once the pink line consistently gets near the blue line, your stream is
|
||||
likely experiencing problems for viewers.
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{speedError && (
|
||||
<Alert message="Slow downloads" description={speedError} type="error" showIcon />
|
||||
)}
|
||||
<Chart
|
||||
title="Seconds"
|
||||
dataCollections={segmentDownloadDurationChart}
|
||||
color="#FF7700"
|
||||
unit="s"
|
||||
yLogarithmic
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<DescriptionBox
|
||||
title="Player Network Speed"
|
||||
description={
|
||||
<>
|
||||
<Typography.Paragraph>
|
||||
The playback bitrate of your viewers. Once somebody's bitrate drops below the
|
||||
lowest video variant bitrate they will experience buffering. If you see viewers
|
||||
with slow connections trying to play your video you should consider offering an
|
||||
additional, lower quality.
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph>
|
||||
In short, once the pink line gets near the lowest blue line, your stream is likely
|
||||
experiencing problems for at least one of your viewers.
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{bitrateError && (
|
||||
<Alert
|
||||
message="Low bandwidth viewers"
|
||||
description={bitrateError}
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
<Chart
|
||||
title="Lowest Player Bitrate"
|
||||
dataCollections={bitrateChart}
|
||||
color="#FF7700"
|
||||
unit="kbps"
|
||||
yLogarithmic
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<DescriptionBox
|
||||
title="Errors and Quality Changes"
|
||||
description={
|
||||
<>
|
||||
<Typography.Paragraph>
|
||||
Recent number of errors, including buffering, and quality changes from across all
|
||||
your viewers. Errors can occur for many reasons, including browser issues,
|
||||
plugins, wifi problems, and they don't all represent fatal issues or something you
|
||||
have control over.
|
||||
</Typography.Paragraph>
|
||||
A quality change is not necessarily a negative thing, but if it's excessive and
|
||||
coinciding with errors you should consider adding another quality variant.
|
||||
<Typography.Paragraph />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Chart title="#" dataCollections={errorChart} color="#FF7700" unit="" />
|
||||
</Card>
|
||||
<Card>
|
||||
<DescriptionBox
|
||||
title="Viewer Latency"
|
||||
description="An approximate number of seconds that your viewers are behind your live video. The largest cause of latency spikes is buffering. High latency itself is not a problem, and optimizing for low latency can result in buffering, resulting in even higher latency."
|
||||
/>
|
||||
<Chart title="Seconds" dataCollections={latencyChart} color="#FF7700" unit="s" />
|
||||
</Card>
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
web/pages/admin/upgrade.tsx
Normal file
74
web/pages/admin/upgrade.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Table, Typography } from 'antd';
|
||||
import { getGithubRelease } from '../../utils/apis';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
function AssetTable(assets) {
|
||||
const data = Object.values(assets) as object[];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, entry) => <a href={entry.browser_download_url}>{text}</a>,
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
render: text => `${(text / 1024 / 1024).toFixed(2)} MB`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
rowKey={record => record.id}
|
||||
size="large"
|
||||
pagination={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const [release, setRelease] = useState({
|
||||
html_url: '',
|
||||
name: '',
|
||||
created_at: null,
|
||||
body: '',
|
||||
assets: [],
|
||||
});
|
||||
|
||||
const getRelease = async () => {
|
||||
try {
|
||||
const result = await getGithubRelease();
|
||||
setRelease(result);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getRelease();
|
||||
}, []);
|
||||
|
||||
if (!release) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upgrade-page">
|
||||
<Title level={2}>
|
||||
<a href={release.html_url}>{release.name}</a>
|
||||
</Title>
|
||||
<Title level={5}>{new Date(release.created_at).toDateString()}</Title>
|
||||
<ReactMarkdown>{release.body}</ReactMarkdown>
|
||||
<h3>Downloads</h3>
|
||||
<AssetTable {...release.assets} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
web/pages/admin/viewer-info.tsx
Normal file
149
web/pages/admin/viewer-info.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Row, Col, Typography, Menu, Dropdown, Spin, Alert } from 'antd';
|
||||
import { DownOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { getUnixTime, sub } from 'date-fns';
|
||||
import Chart from '../../components/chart';
|
||||
import StatisticItem from '../../components/statistic';
|
||||
import ViewerTable from '../../components/viewer-table';
|
||||
|
||||
import { ServerStatusContext } from '../../utils/server-status-context';
|
||||
|
||||
import { VIEWERS_OVER_TIME, ACTIVE_VIEWER_DETAILS, fetchData } from '../../utils/apis';
|
||||
|
||||
const FETCH_INTERVAL = 60 * 1000; // 1 min
|
||||
|
||||
export default function ViewersOverTime() {
|
||||
const context = useContext(ServerStatusContext);
|
||||
const { online, broadcaster, viewerCount, overallPeakViewerCount, sessionPeakViewerCount } =
|
||||
context || {};
|
||||
let streamStart;
|
||||
if (broadcaster && broadcaster.time) {
|
||||
streamStart = new Date(broadcaster.time);
|
||||
}
|
||||
|
||||
const times = [
|
||||
{ title: 'Current stream', start: streamStart },
|
||||
{ title: 'Last 12 hours', start: sub(new Date(), { hours: 12 }) },
|
||||
{ title: 'Last 24 hours', start: sub(new Date(), { hours: 24 }) },
|
||||
{ title: 'Last 7 days', start: sub(new Date(), { days: 7 }) },
|
||||
{ title: 'Last 30 days', start: sub(new Date(), { days: 30 }) },
|
||||
{ title: 'Last 3 months', start: sub(new Date(), { months: 3 }) },
|
||||
{ title: 'Last 6 months', start: sub(new Date(), { months: 6 }) },
|
||||
];
|
||||
|
||||
const [loadingChart, setLoadingChart] = useState(true);
|
||||
const [viewerInfo, setViewerInfo] = useState([]);
|
||||
const [viewerDetails, setViewerDetails] = useState([]);
|
||||
const [timeWindowStart, setTimeWindowStart] = useState(times[1]);
|
||||
|
||||
const getInfo = async () => {
|
||||
try {
|
||||
const url = `${VIEWERS_OVER_TIME}?windowStart=${getUnixTime(timeWindowStart.start)}`;
|
||||
const result = await fetchData(url);
|
||||
setViewerInfo(result);
|
||||
setLoadingChart(false);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchData(ACTIVE_VIEWER_DETAILS);
|
||||
setViewerDetails(result);
|
||||
} catch (error) {
|
||||
console.log('==== error', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let getStatusIntervalId = null;
|
||||
|
||||
getInfo();
|
||||
if (online) {
|
||||
getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL);
|
||||
// returned function will be called on component unmount
|
||||
return () => {
|
||||
clearInterval(getStatusIntervalId);
|
||||
};
|
||||
}
|
||||
|
||||
return () => [];
|
||||
}, [online, timeWindowStart]);
|
||||
|
||||
const onTimeWindowSelect = ({ key }) => {
|
||||
setTimeWindowStart(times[key]);
|
||||
};
|
||||
|
||||
const menu = (
|
||||
<Menu>
|
||||
{online && streamStart && (
|
||||
<Menu.Item key="0" onClick={onTimeWindowSelect}>
|
||||
{times[0].title}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{times.slice(1).map((time, index) => (
|
||||
// The array is hard coded, so it's safe to use the index as a key.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Menu.Item key={index + 1} onClick={onTimeWindowSelect}>
|
||||
{time.title}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Title>Viewer Info</Typography.Title>
|
||||
<br />
|
||||
<Row gutter={[16, 16]} justify="space-around">
|
||||
{online && (
|
||||
<Col span={8} md={8}>
|
||||
<StatisticItem
|
||||
title="Current viewers"
|
||||
value={viewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<Col md={online ? 8 : 12}>
|
||||
<StatisticItem
|
||||
title={online ? 'Max viewers this stream' : 'Max viewers last stream'}
|
||||
value={sessionPeakViewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={online ? 8 : 12}>
|
||||
<StatisticItem
|
||||
title="All-time max viewers"
|
||||
value={overallPeakViewerCount.toString()}
|
||||
prefix={<UserOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{!viewerInfo.length && (
|
||||
<Alert
|
||||
style={{ marginTop: '10px' }}
|
||||
banner
|
||||
message="Please wait"
|
||||
description="No viewer data has been collected yet."
|
||||
type="info"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Spin spinning={!viewerInfo.length || loadingChart}>
|
||||
<Dropdown overlay={menu} trigger={['click']}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ float: 'right', background: 'transparent', border: 'unset' }}
|
||||
>
|
||||
{timeWindowStart.title} <DownOutlined />
|
||||
</button>
|
||||
</Dropdown>
|
||||
{viewerInfo.length > 0 && (
|
||||
<Chart title="Viewers" data={viewerInfo} color="#2087E2" unit="" />
|
||||
)}
|
||||
|
||||
<ViewerTable data={viewerDetails} />
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
web/pages/admin/webhooks.tsx
Normal file
250
web/pages/admin/webhooks.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable react/destructuring-assignment */
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { CREATE_WEBHOOK, DELETE_WEBHOOK, fetchData, WEBHOOKS } from '../../utils/apis';
|
||||
import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/urls';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const availableEvents = {
|
||||
CHAT: { name: 'Chat messages', description: 'When a user sends a chat message', color: 'purple' },
|
||||
USER_JOINED: { name: 'User joined', description: 'When a user joins the chat', color: 'green' },
|
||||
NAME_CHANGE: {
|
||||
name: 'User name changed',
|
||||
description: 'When a user changes their name',
|
||||
color: 'blue',
|
||||
},
|
||||
'VISIBILITY-UPDATE': {
|
||||
name: 'Message visibility changed',
|
||||
description: 'When a message visibility changes, likely due to moderation',
|
||||
color: 'red',
|
||||
},
|
||||
STREAM_STARTED: { name: 'Stream started', description: 'When a stream starts', color: 'orange' },
|
||||
STREAM_STOPPED: { name: 'Stream stopped', description: 'When a stream stops', color: 'cyan' },
|
||||
};
|
||||
|
||||
function convertEventStringToTag(eventString: string) {
|
||||
if (!eventString || !availableEvents[eventString]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = availableEvents[eventString];
|
||||
|
||||
return (
|
||||
<Tooltip key={eventString} title={event.description}>
|
||||
<Tag color={event.color}>{event.name}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onOk: any; // todo: make better type
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function NewWebhookModal(props: Props) {
|
||||
const { onOk, onCancel, visible } = props;
|
||||
|
||||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
|
||||
const events = Object.keys(availableEvents).map(key => ({
|
||||
value: key,
|
||||
label: availableEvents[key].description,
|
||||
}));
|
||||
|
||||
function onChange(checkedValues) {
|
||||
setSelectedEvents(checkedValues);
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
setSelectedEvents(Object.keys(availableEvents));
|
||||
}
|
||||
|
||||
function save() {
|
||||
onOk(webhookUrl, selectedEvents);
|
||||
|
||||
// Reset the modal
|
||||
setWebhookUrl('');
|
||||
setSelectedEvents(null);
|
||||
}
|
||||
|
||||
const okButtonProps = {
|
||||
disabled: selectedEvents?.length === 0 || !isValidUrl(webhookUrl),
|
||||
};
|
||||
|
||||
const checkboxes = events.map(singleEvent => (
|
||||
<Col span={8} key={singleEvent.value}>
|
||||
<Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox>
|
||||
</Col>
|
||||
));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create New Webhook"
|
||||
visible={visible}
|
||||
onOk={save}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={okButtonProps}
|
||||
>
|
||||
<div>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
placeholder="https://myserver.com/webhook"
|
||||
onChange={input => setWebhookUrl(input.currentTarget.value.trim())}
|
||||
type="url"
|
||||
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p>Select the events that will be sent to this webhook.</p>
|
||||
<Checkbox.Group style={{ width: '100%' }} value={selectedEvents} onChange={onChange}>
|
||||
<Row>{checkboxes}</Row>
|
||||
</Checkbox.Group>
|
||||
<p>
|
||||
<Button type="primary" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Webhooks() {
|
||||
const [webhooks, setWebhooks] = useState([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
function handleError(error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
|
||||
async function getWebhooks() {
|
||||
try {
|
||||
const result = await fetchData(WEBHOOKS);
|
||||
setWebhooks(result);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getWebhooks();
|
||||
}, []);
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await fetchData(DELETE_WEBHOOK, { method: 'POST', data: { id } });
|
||||
getWebhooks();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(url: string, events: string[]) {
|
||||
try {
|
||||
const newHook = await fetchData(CREATE_WEBHOOK, {
|
||||
method: 'POST',
|
||||
data: { url, events },
|
||||
});
|
||||
setWebhooks(webhooks.concat(newHook));
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
const showCreateModal = () => {
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleModalSaveButton = (url, events) => {
|
||||
setIsModalVisible(false);
|
||||
handleSave(url, events);
|
||||
};
|
||||
|
||||
const handleModalCancelButton = () => {
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
key: 'delete',
|
||||
render: (text, record) => (
|
||||
<Space size="middle">
|
||||
<Button onClick={() => handleDelete(record.id)} icon={<DeleteOutlined />} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
},
|
||||
{
|
||||
title: 'Events',
|
||||
dataIndex: 'events',
|
||||
key: 'events',
|
||||
render: events => (
|
||||
<>
|
||||
{
|
||||
// eslint-disable-next-line arrow-body-style
|
||||
events.map(event => {
|
||||
return convertEventStringToTag(event);
|
||||
})
|
||||
}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title>Webhooks</Title>
|
||||
<Paragraph>
|
||||
A webhook is a callback made to an external API in response to an event that takes place
|
||||
within Owncast. This can be used to build chat bots or sending automatic notifications that
|
||||
you've started streaming.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Read more about how to use webhooks, with examples, at{' '}
|
||||
<a
|
||||
href="https://owncast.online/docs/integrations/?source=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
our documentation
|
||||
</a>
|
||||
.
|
||||
</Paragraph>
|
||||
|
||||
<Table
|
||||
rowKey={record => record.id}
|
||||
columns={columns}
|
||||
dataSource={webhooks}
|
||||
pagination={false}
|
||||
/>
|
||||
<br />
|
||||
<Button type="primary" onClick={showCreateModal}>
|
||||
Create Webhook
|
||||
</Button>
|
||||
<NewWebhookModal
|
||||
visible={isModalVisible}
|
||||
onOk={handleModalSaveButton}
|
||||
onCancel={handleModalCancelButton}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user