diff --git a/locales/en/public.json b/locales/en/public.json index f2e89a947..92becdeb0 100644 --- a/locales/en/public.json +++ b/locales/en/public.json @@ -12,6 +12,23 @@ "OPEN_SOURCE_LICENSE": "Open Source License", "VERSION": "version" }, + "CHART_CARD": { + "BUTTONS": { + "CREATE": { + "LABEL": "Create" + }, + "POPOUT": { + "LABEL": "Pop out {{chartKind}} chart" + }, + "SYNC": { + "LABEL": "Synchronize {{chartKind}} chart" + } + }, + "NO_RECORDING": { + "DESCRIPTION": "Metrics cards display data taken from running flight recordings with the label . No such recordings are currently available.", + "TITLE": "No source recording" + } + }, "DashboardCardActionMenu": { "RESET_SIZE": "Reset Size" }, @@ -55,6 +72,11 @@ "LANGUAGE_REGION": "Language & Region", "NOTIFICATION_MESSAGE": "Notifications & Messages" }, + "CHARTS_CONFIG": { + "DESCRIPTION": "", + "REFRESH_RATE_SETTING": "Configure the minimum time to wait between data refreshes. Individual metrics cards may still request updates on a faster cycle, but the client application instance will throttle update requests to the server according to this setting.", + "TITLE": "Dashboard Metrics Configuration" + }, "CREDENTIALS_STORAGE": { "BACKEND": { "DESCRIPTION": "Keep credentials in encrypted Cryostat backend storage. These credentials will be available to other users and will be used for Automated Rules.", diff --git a/package.json b/package.json index 891b1d3bb..ed36d3a38 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/enzyme-adapter-react-16": "^1.0.6", "@types/jest": "^27.0.2", "@types/js-base64": "3.3.1", + "@types/react-test-renderer": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", "@vue/preload-webpack-plugin": "^2.0.0", diff --git a/src/app/CreateRecording/CreateRecording.tsx b/src/app/CreateRecording/CreateRecording.tsx index ddc139b34..8ec9853ed 100644 --- a/src/app/CreateRecording/CreateRecording.tsx +++ b/src/app/CreateRecording/CreateRecording.tsx @@ -35,6 +35,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import { RecordingLabel } from '@app/RecordingMetadata/RecordingLabel'; import { TemplateType } from '@app/Shared/Services/Api.service'; import { TargetView } from '@app/TargetView/TargetView'; import { Card, CardBody, Tab, Tabs } from '@patternfly/react-core'; @@ -45,8 +46,14 @@ import { CustomRecordingForm } from './CustomRecordingForm'; import { SnapshotRecordingForm } from './SnapshotRecordingForm'; export interface CreateRecordingProps { + restartExisting?: boolean; + name?: string; templateName?: string; templateType?: TemplateType; + labels?: RecordingLabel[]; + duration?: number; + maxAge?: number; + maxSize?: number; } export interface EventTemplate { @@ -63,8 +70,14 @@ const Comp: React.FC, StaticContext, C const prefilled = React.useMemo( () => ({ + restartExisting: props.location?.state?.restartExisting, + name: props.location?.state?.name, templateName: props.location?.state?.templateName, templateType: props.location?.state?.templateType, + labels: props.location?.state?.labels, + duration: props.location?.state?.duration, + maxAge: props.location?.state?.maxAge, + maxSize: props.location?.state?.maxSize, }), [props.location] ); diff --git a/src/app/CreateRecording/CustomRecordingForm.tsx b/src/app/CreateRecording/CustomRecordingForm.tsx index 814896212..fc5fc1e39 100644 --- a/src/app/CreateRecording/CustomRecordingForm.tsx +++ b/src/app/CreateRecording/CustomRecordingForm.tsx @@ -75,8 +75,14 @@ import { EventTemplate } from './CreateRecording'; export interface CustomRecordingFormProps { prefilled?: { + restartExisting?: boolean; + name?: string; templateName?: string; templateType?: TemplateType; + labels?: RecordingLabel[]; + duration?: number; + maxAge?: number; + maxSize?: number; }; } @@ -89,22 +95,29 @@ export const CustomRecordingForm: React.FC = ({ prefil const history = useHistory(); const addSubscription = useSubscriptions(); - const [recordingName, setRecordingName] = React.useState(''); - const [nameValid, setNameValid] = React.useState(ValidatedOptions.default); - const [continuous, setContinuous] = React.useState(false); + const [recordingName, setRecordingName] = React.useState(prefilled?.name || ''); + const [nameValid, setNameValid] = React.useState( + prefilled?.name + ? RecordingNamePattern.test(recordingName) + ? ValidatedOptions.success + : ValidatedOptions.error + : ValidatedOptions.default + ); + const [restartExisting, setRestartExisting] = React.useState(prefilled?.restartExisting || false); + const [continuous, setContinuous] = React.useState((prefilled?.duration || 30) < 1); const [archiveOnStop, setArchiveOnStop] = React.useState(true); - const [duration, setDuration] = React.useState(30); + const [duration, setDuration] = React.useState(prefilled?.duration || 30); const [durationUnit, setDurationUnit] = React.useState(1000); const [durationValid, setDurationValid] = React.useState(ValidatedOptions.success); const [templates, setTemplates] = React.useState([] as EventTemplate[]); const [templateName, setTemplateName] = React.useState(prefilled?.templateName); const [templateType, setTemplateType] = React.useState(prefilled?.templateType); - const [maxAge, setMaxAge] = React.useState(0); + const [maxAge, setMaxAge] = React.useState(prefilled?.maxAge || 0); const [maxAgeUnits, setMaxAgeUnits] = React.useState(1); - const [maxSize, setMaxSize] = React.useState(0); + const [maxSize, setMaxSize] = React.useState(prefilled?.maxSize || 0); const [maxSizeUnits, setMaxSizeUnits] = React.useState(1); const [toDisk, setToDisk] = React.useState(true); - const [labels, setLabels] = React.useState([] as RecordingLabel[]); + const [labels, setLabels] = React.useState(prefilled?.labels || []); const [labelsValid, setLabelsValid] = React.useState(ValidatedOptions.default); const [loading, setLoading] = React.useState(false); const [errorMessage, setErrorMessage] = React.useState(''); @@ -119,7 +132,7 @@ export const CustomRecordingForm: React.FC = ({ prefil .subscribe((resp) => { setLoading(false); if (resp && resp.ok) { - history.push('/recordings'); + history.goBack(); } }) ); @@ -127,6 +140,13 @@ export const CustomRecordingForm: React.FC = ({ prefil [addSubscription, context.api, history, setLoading] ); + const handleRestartExistingChange = React.useCallback( + (checked) => { + setRestartExisting(checked); + }, + [setRestartExisting] + ); + const handleContinuousChange = React.useCallback( (checked) => { setContinuous(checked); @@ -228,12 +248,12 @@ export const CustomRecordingForm: React.FC = ({ prefil const setRecordingOptions = React.useCallback( (options: RecordingOptions) => { // toDisk is not set, and defaults to true because of https://github.com/cryostatio/cryostat/issues/263 - setMaxAge(options.maxAge || 0); + setMaxAge(prefilled?.maxAge || options.maxAge || 0); setMaxAgeUnits(1); - setMaxSize(options.maxSize || 0); + setMaxSize(prefilled?.maxSize || options.maxSize || 0); setMaxSizeUnits(1); }, - [setMaxAge, setMaxAgeUnits, setMaxSize, setMaxSizeUnits] + [setMaxAge, setMaxAgeUnits, setMaxSize, setMaxSizeUnits, prefilled] ); const handleSubmit = React.useCallback(() => { @@ -249,6 +269,7 @@ export const CustomRecordingForm: React.FC = ({ prefil } const options: RecordingOptions = { + restart: restartExisting, toDisk: toDisk, maxAge: toDisk ? (continuous ? maxAge * maxAgeUnits : undefined) : undefined, maxSize: toDisk ? maxSize * maxSizeUnits : undefined, @@ -276,6 +297,7 @@ export const CustomRecordingForm: React.FC = ({ prefil nameValid, notifications, recordingName, + restartExisting, toDisk, handleCreateRecording, ]); @@ -397,6 +419,15 @@ export const CustomRecordingForm: React.FC = ({ prefil onChange={handleRecordingNameChange} validated={nameValid} /> + { onChange={handleNumeric(ctrl.key)} onPlus={handleNumericStep(ctrl.key, 1)} onMinus={handleNumericStep(ctrl.key, -1)} + min={ctrl.extras?.min} + max={ctrl.extras?.max} /> ); break; diff --git a/src/app/Dashboard/Charts/ChartCard.tsx b/src/app/Dashboard/Charts/ChartCard.tsx new file mode 100644 index 000000000..eab645f1a --- /dev/null +++ b/src/app/Dashboard/Charts/ChartCard.tsx @@ -0,0 +1,358 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { CreateRecordingProps } from '@app/CreateRecording/CreateRecording'; +import { LoadingView } from '@app/LoadingView/LoadingView'; +import { ServiceContext } from '@app/Shared/Services/Services'; +import { FeatureLevel } from '@app/Shared/Services/Settings.service'; +import { useSubscriptions } from '@app/utils/useSubscriptions'; +import { + Bullseye, + Button, + CardActions, + CardBody, + CardHeader, + CardTitle, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Label, + Title, +} from '@patternfly/react-core'; +import { DataSourceIcon, ExternalLinkAltIcon, SyncAltIcon } from '@patternfly/react-icons'; +import * as React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; +import { interval } from 'rxjs'; +import { DashboardCardDescriptor, DashboardCardProps, DashboardCardSizes } from '../Dashboard'; +import { DashboardCard } from '../DashboardCard'; +import { ChartContext } from './ChartContext'; +import { ControllerState, RECORDING_NAME } from './ChartController'; + +export interface ChartCardProps extends DashboardCardProps { + theme: string; + chartKind: string; + duration: number; + period: number; +} + +// TODO these need to be localized +export enum ChartKind { + 'Core Count' = 1, + 'Thread Count' = 2, + 'CPU Load' = 3, + 'Heap Usage' = 4, + 'Memory Usage' = 5, + 'Total Memory' = 6, + 'Recording Start Time' = 7, + 'Recording Duration' = 8, + 'Classloading Statistics' = 9, + 'Metaspace Summary' = 10, + 'Network Utilization' = 11, + 'Metaspace GC Threshold' = 12, + 'Thread Statistics' = 13, + 'Exception Statistics' = 14, + 'Thread Context Switch Rate' = 15, + 'Compiler Statistics' = 16, + + 'Safepoint Duration' = 18, + 'File I/O' = 19, + 'Compiler Total Time' = 20, + + 'Compiler Peak Time' = 24, + + 'Object Allocation Sample' = 38, +} + +export function kindToId(kind: string): number { + return ChartKind[kind]; +} + +export const ChartCard: React.FC = (props) => { + const [t] = useTranslation(); + const serviceContext = React.useContext(ServiceContext); + const controllerContext = React.useContext(ChartContext); + const history = useHistory(); + const addSubscription = useSubscriptions(); + const [controllerState, setControllerState] = React.useState(ControllerState.NO_DATA); + const [randomKey, setRandomKey] = React.useState(Math.floor(Math.random())); + const [chartSrc, setChartSrc] = React.useState(''); + const [dashboardUrl, setDashboardUrl] = React.useState(''); + + const updateRandomKey = React.useCallback(() => { + setRandomKey((prev) => { + let next = prev + 1; + if (next >= 10) { + next = 0; + } + return next; + }); + }, [setRandomKey]); + + React.useEffect(() => { + addSubscription(serviceContext.api.grafanaDashboardUrl().subscribe(setDashboardUrl)); + }, [addSubscription, serviceContext, setDashboardUrl]); + + React.useEffect(() => { + if (!dashboardUrl) { + return; + } + const u = new URL('/d-solo/main', dashboardUrl); + u.searchParams.append('theme', props.theme); + u.searchParams.append('panelId', String(kindToId(props.chartKind))); + u.searchParams.append('to', 'now'); + u.searchParams.append('from', `now-${props.duration}s`); + u.searchParams.append('refresh', `${props.period}s`); + setChartSrc(u.toString()); + }, [dashboardUrl, setControllerState, props.theme, props.chartKind, props.duration, props.period, setChartSrc]); + + React.useEffect(() => { + addSubscription(controllerContext.controller.attach().subscribe(setControllerState)); + }, [addSubscription, controllerContext, setControllerState]); + + const refresh = React.useCallback(() => { + controllerContext.controller.requestRefresh(); + }, [controllerContext]); + + React.useEffect(() => { + refresh(); + addSubscription(interval(props.period * 1000).subscribe(() => refresh())); + }, [addSubscription, props.period, refresh]); + + const popout = React.useCallback(() => { + if (chartSrc && dashboardUrl) { + window.open(chartSrc, '_blank'); + } + }, [chartSrc, dashboardUrl]); + + const cardStyle = React.useMemo(() => { + if (controllerState !== ControllerState.READY) { + return {}; + } + let height: number; + switch (props.chartKind) { + case 'Core Count': + height = 250; + break; + default: + height = 380; + break; + } + return { height }; + }, [controllerState, props.chartKind]); + + const resyncButton = React.useMemo(() => { + return ( +