Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend: Add a way for plugins to react to Headlamp events #1455

Merged
merged 2 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/development/plugins/functionality.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,14 @@ Change what tables across Headlamp show with [registerResourceTableColumnsProces

- Example plugin shows [How to add a context menu to each row in the pods list table](https://github.com/kinvolk/headlamp/tree/main/plugins/examples/tables).
- API reference for [registerResourceTableColumnsProcessor](../api/modules/plugin_registry.md#registerresourcetablecolumnsprocessor)

### Headlamp Events

Headlamp has the concept of "Headlamp events". Those are fired when something relevant happens in Headlamp.

React to Headlamp events with [registerHeadlampEventCallback](../api/modules/plugin_registry.md#registerheadlampeventcallback).

![screenshot of a snackbar notification when an event occurred](./images/event-snackbar.png)

- Example plugin shows [How to show snackbars for Headlamp events](https://github.com/kinvolk/headlamp/tree/main/plugins/examples/headlamp-events).
- API reference for [registerHeadlampEventCallback](../api/modules/plugin_registry.md#registerheadlampeventcallback)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isValidElement, ReactElement, ReactNode, useMemo } from 'react';
import React, { isValidElement, ReactElement, ReactNode, useMemo } from 'react';
import { KubeObject } from '../../lib/k8s/cluster';
import { HeadlampEventType, useEventCallback } from '../../redux/headlampEventSlice';
import { useTypedSelector } from '../../redux/reducers/reducers';
import ErrorBoundary from '../common/ErrorBoundary';

Expand All @@ -20,6 +21,12 @@ export type DetailsViewSectionType =
export default function DetailsViewSection(props: DetailsViewSectionProps) {
const { resource } = props;
const detailViews = useTypedSelector(state => state.detailsViewSection.detailViews);
const dispatchHeadlampEvent = useEventCallback(HeadlampEventType.DETAILS_VIEW);

React.useEffect(() => {
dispatchHeadlampEvent({ resource });
}, [resource]);

const memoizedComponents = useMemo(
() =>
detailViews.map((Component, index) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Children, Component, ComponentType, isValidElement, ReactElement } from 'react';
import { eventAction, HeadlampEventType } from '../../../redux/headlampEventSlice';
import store from '../../../redux/stores/store';

export interface ErrorBoundaryProps {
fallback?: ComponentType<{ error: Error }> | ReactElement | null;
Expand Down Expand Up @@ -41,6 +43,9 @@ export default class ErrorBoundary extends Component<ErrorBoundaryProps, State>

render() {
const { error } = this.state;
if (error) {
store.dispatch(eventAction({ type: HeadlampEventType.ERROR_BOUNDARY, data: error }));
}
if (!error) {
return this.props.children;
}
Expand Down
86 changes: 86 additions & 0 deletions frontend/src/components/common/ErrorPage/ErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Grid, Typography } from '@mui/material';
import Link from '@mui/material/Link';
import makeStyles from '@mui/styles/makeStyles';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import headlampBrokenImage from '../../../assets/headlamp-broken.svg';

const useStyles = makeStyles(() => ({
root: {
textAlign: 'center',
},
h1: {
fontSize: '2.125rem',
lineHeight: 1.2,
fontWeight: 400,
},
h2: {
fontSize: '1.25rem',
lineHeight: 3.6,
fontWeight: 500,
},
img: {
width: '100%',
},
}));

export interface ErrorComponentProps {
/** The main title to display. By default it is: "Uh-oh! Something went wrong." */
title?: React.ReactNode;
/** The message to display. By default it is: "Head back <a href="..."> home</a>." */
message?: React.ReactNode;
/** The graphic or element to display as a main graphic. If used as a string, it will be
* used as the source for displaying an image. By default it is "headlamp-broken.svg". */
graphic?: React.ReactChild;
/** Whether to use Typography or not. By default it is true. */
withTypography?: boolean;
}

export default function ErrorComponent(props: ErrorComponentProps) {
const { t } = useTranslation();
const {
title = t('Uh-oh! Something went wrong.'),
message = '',
withTypography = true,
graphic = headlampBrokenImage,
} = props;
const classes = useStyles();
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.root}
>
<Grid item xs={12}>
{typeof graphic === 'string' ? (
<img src={graphic} alt="" className={classes.img} />
) : (
graphic
)}
{withTypography ? (
<Typography variant="h1" className={classes.h1}>
{title}
</Typography>
) : (
title
)}
{withTypography ? (
<Typography variant="h2" className={classes.h2}>
{!!message ? (
message
) : (
<Trans t={t}>
Head back <Link href="/">home</Link>.
</Trans>
)}
</Typography>
) : (
message
)}
</Grid>
</Grid>
);
}
88 changes: 3 additions & 85 deletions frontend/src/components/common/ErrorPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,4 @@
import { Grid, Typography } from '@mui/material';
import Link from '@mui/material/Link';
import makeStyles from '@mui/styles/makeStyles';
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import headlampBrokenImage from '../../../assets/headlamp-broken.svg';
import ErrorComponent from './ErrorPage';

const useStyles = makeStyles(() => ({
root: {
textAlign: 'center',
},
h1: {
fontSize: '2.125rem',
lineHeight: 1.2,
fontWeight: 400,
},
h2: {
fontSize: '1.25rem',
lineHeight: 3.6,
fontWeight: 500,
},
img: {
width: '100%',
},
}));

export interface ErrorComponentProps {
/** The main title to display. By default it is: "Uh-oh! Something went wrong." */
title?: React.ReactNode;
/** The message to display. By default it is: "Head back <a href="..."> home</a>." */
message?: React.ReactNode;
/** The graphic or element to display as a main graphic. If used as a string, it will be
* used as the source for displaying an image. By default it is "headlamp-broken.svg". */
graphic?: React.ReactChild;
/** Whether to use Typography or not. By default it is true. */
withTypography?: boolean;
}

export default function ErrorComponent(props: ErrorComponentProps) {
const { t } = useTranslation();
const {
title = t('Uh-oh! Something went wrong.'),
message = '',
withTypography = true,
graphic = headlampBrokenImage,
} = props;
const classes = useStyles();
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.root}
>
<Grid item xs={12}>
{typeof graphic === 'string' ? (
<img src={graphic} alt="" className={classes.img} />
) : (
graphic
)}
{withTypography ? (
<Typography variant="h1" className={classes.h1}>
{title}
</Typography>
) : (
title
)}
{withTypography ? (
<Typography variant="h2" className={classes.h2}>
{!!message ? (
message
) : (
<Trans t={t}>
Head back <Link href="/">home</Link>.
</Trans>
)}
</Typography>
) : (
message
)}
</Grid>
</Grid>
);
}
export * from './ErrorPage';
export default ErrorComponent;
10 changes: 9 additions & 1 deletion frontend/src/components/common/ObjectEventList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { KubeObject } from '../../lib/k8s/cluster';
import Event, { KubeEvent } from '../../lib/k8s/event';
import { localeDate, timeAgo } from '../../lib/util';
import { HeadlampEventType, useEventCallback } from '../../redux/headlampEventSlice';
import { HoverInfoLabel, SectionBox, SimpleTable } from '../common';
import ShowHideLabel from './ShowHideLabel';

Expand All @@ -11,7 +12,14 @@ export interface ObjectEventListProps {
}

export default function ObjectEventList(props: ObjectEventListProps) {
const [events, setEvents] = useState([]);
const [events, setEvents] = useState<Event[]>([]);
const dispatchEventList = useEventCallback(HeadlampEventType.OBJECT_EVENTS);

useEffect(() => {
if (events) {
dispatchEventList(events, props.object);
}
}, [events]);

async function fetchEvents() {
const events = await Event.objectEvents(props.object);
Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/common/Resource/CreateButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { useLocation } from 'react-router-dom';
import { apply } from '../../../lib/k8s/apiProxy';
import { KubeObjectInterface } from '../../../lib/k8s/cluster';
import { clusterAction } from '../../../redux/clusterActionSlice';
import {
EventStatus,
HeadlampEventType,
useEventCallback,
} from '../../../redux/headlampEventSlice';
import ActionButton from '../ActionButton';
import EditorDialog from './EditorDialog';

Expand All @@ -21,6 +26,7 @@ export default function CreateButton(props: CreateButtonProps) {
const [errorMessage, setErrorMessage] = React.useState('');
const location = useLocation();
const { t } = useTranslation(['translation']);
const dispatchCreateEvent = useEventCallback(HeadlampEventType.CREATE_RESOURCE);

const applyFunc = async (newItems: KubeObjectInterface[]) => {
await Promise.allSettled(newItems.map(newItem => apply(newItem))).then((values: any) => {
Expand Down Expand Up @@ -89,6 +95,10 @@ export default function CreateButton(props: CreateButtonProps) {
cancelUrl,
})
);

dispatchCreateEvent({
status: EventStatus.CONFIRMED,
});
}

return (
Expand All @@ -105,7 +115,9 @@ export default function CreateButton(props: CreateButtonProps) {
/>
) : (
<Button
onClick={() => setOpenDialog(true)}
onClick={() => {
setOpenDialog(true);
}}
startIcon={<InlineIcon icon="mdi:plus" />}
color="primary"
variant="contained"
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/components/common/Resource/DeleteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { KubeObject } from '../../../lib/k8s/cluster';
import { CallbackActionOptions, clusterAction } from '../../../redux/clusterActionSlice';
import {
EventStatus,
HeadlampEventType,
useEventCallback,
} from '../../../redux/headlampEventSlice';
import ActionButton from '../ActionButton';
import { ConfirmDialog } from '../Dialog';
import AuthVisible from './AuthVisible';
Expand All @@ -19,6 +24,7 @@ export default function DeleteButton(props: DeleteButtonProps) {
const [openAlert, setOpenAlert] = React.useState(false);
const location = useLocation();
const { t } = useTranslation(['translation']);
const dispatchDeleteEvent = useEventCallback(HeadlampEventType.DELETE_RESOURCE);

const deleteFunc = React.useCallback(
() => {
Expand Down Expand Up @@ -62,15 +68,23 @@ export default function DeleteButton(props: DeleteButtonProps) {
>
<ActionButton
description={t('translation|Delete')}
onClick={() => setOpenAlert(true)}
onClick={() => {
setOpenAlert(true);
}}
icon="mdi:delete"
/>
<ConfirmDialog
open={openAlert}
title={t('translation|Delete item')}
description={t('translation|Are you sure you want to delete this item?')}
handleClose={() => setOpenAlert(false)}
onConfirm={() => deleteFunc()}
onConfirm={() => {
deleteFunc();
dispatchDeleteEvent({
resource: item,
status: EventStatus.CONFIRMED,
});
}}
/>
</AuthVisible>
);
Expand Down
19 changes: 18 additions & 1 deletion frontend/src/components/common/Resource/EditButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { KubeObject, KubeObjectInterface } from '../../../lib/k8s/cluster';
import { CallbackActionOptions, clusterAction } from '../../../redux/clusterActionSlice';
import {
EventStatus,
HeadlampEventType,
useEventCallback,
} from '../../../redux/headlampEventSlice';
import ActionButton from '../ActionButton';
import AuthVisible from './AuthVisible';
import EditorDialog from './EditorDialog';
Expand All @@ -22,6 +27,7 @@ export default function EditButton(props: EditButtonProps) {
const [errorMessage, setErrorMessage] = React.useState<string>('');
const location = useLocation();
const { t } = useTranslation(['translation', 'resource']);
const dispatchHeadlampEditEvent = useEventCallback(HeadlampEventType.EDIT_RESOURCE);

function makeErrorMessage(err: any) {
const status: number = err.status;
Expand Down Expand Up @@ -62,6 +68,11 @@ export default function EditButton(props: EditButtonProps) {
...options,
})
);

dispatchHeadlampEditEvent({
resource: item,
status: EventStatus.CLOSED,
});
}

if (!item) {
Expand All @@ -86,7 +97,13 @@ export default function EditButton(props: EditButtonProps) {
>
<ActionButton
description={t('translation|Edit')}
onClick={() => setOpenDialog(true)}
onClick={() => {
setOpenDialog(true);
dispatchHeadlampEditEvent({
resource: item,
status: EventStatus.OPENED,
});
}}
icon="mdi:pencil"
/>
{openDialog && (
Expand Down
Loading
Loading