diff --git a/package.json b/package.json index 9493a56..2a9dc24 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "inquirer": "^7.3.3", "inquirer-checkbox-plus-prompt": "^1.0.1", "node-fetch": "^2.6.1", - "prettier": "^1.19.1", + "prettier": "^3.0.0", "querystring": "^0.2.0", "sharp": "^0.29.2", "xmldoc": "^1.1.2" diff --git a/template/next.config.js b/template/next.config.js index 982410a..78b50f7 100644 --- a/template/next.config.js +++ b/template/next.config.js @@ -17,8 +17,9 @@ limitations under the License. const fs = require('fs'); const path = require('path'); const settings = Object.assign({}, require('./package.json').dashpub.settings); +const withVideos = require('next-videos'); -module.exports = { +module.exports = withVideos({ webpack(config, { buildId, webpack }) { const snapshotPath = path.join(__dirname, 'src/pages/api/data/_snapshot.json'); if (!fs.existsSync(snapshotPath)) { @@ -40,4 +41,4 @@ module.exports = { return config; }, -}; +}); diff --git a/template/package.json b/template/package.json index 1dacbd4..898c5dc 100644 --- a/template/package.json +++ b/template/package.json @@ -5,67 +5,45 @@ "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.9.2", - "@splunk/charting-bundle": "24.5.0", - "@splunk/dashboard-context": "24.5.0", - "@splunk/dashboard-core": "24.5.0", - "@splunk/dashboard-definition": "24.5.0", - "@splunk/dashboard-event-handlers": "24.5.0", - "@splunk/dashboard-icons": "24.5.0", - "@splunk/dashboard-inputs": "24.5.0", - "@splunk/dashboard-layouts": "24.5.0", - "@splunk/dashboard-presets": "24.5.0", - "@splunk/dashboard-telemetry": "24.5.0", - "@splunk/dashboard-ui": "24.5.0", - "@splunk/dashboard-utils": "24.5.0", - "@splunk/dashboard-visualizations": "24.5.0", - "@splunk/datasource-utils": "24.5.0", - "@splunk/datasources": "24.5.0", - "@splunk/react-icons": "3.2.0", - "@splunk/react-ui": "4.7.0", - "@splunk/visualization-color-palettes": "24.5.0", - "@splunk/visualization-context": "24.5.0", - "@splunk/visualization-encoding": "24.5.0", - "@splunk/visualization-encoding-parsers": "24.5.0", - "@splunk/visualization-icons": "24.5.0", - "@splunk/visualization-themes": "24.5.0", - "@splunk/visualizations": "24.5.0", - "@splunk/visualizations-shared": "24.5.0", + "@splunk/charting-bundle": "25.9.0", + "@splunk/dashboard-context": "26.0.1", + "@splunk/dashboard-core": "26.0.1", + "@splunk/dashboard-definition": "26.0.1", + "@splunk/dashboard-event-handlers": "26.0.1", + "@splunk/dashboard-icons": "26.0.1", + "@splunk/dashboard-inputs": "26.0.1", + "@splunk/dashboard-layouts": "26.0.1", + "@splunk/dashboard-presets": "26.0.1", + "@splunk/dashboard-telemetry": "26.0.1", + "@splunk/dashboard-ui": "26.0.1", + "@splunk/dashboard-utils": "26.0.1", + "@splunk/dashboard-visualizations": "25.9.0", + "@splunk/datasource-utils": "26.0.1", + "@splunk/datasources": "26.0.1", + "@splunk/react-icons": "^3.3.1", + "@splunk/react-ui": "4.16.3", + "@splunk/visualization-color-palettes": "25.8.1", + "@splunk/visualization-context": "25.8.1", + "@splunk/visualization-encoding": "25.8.1", + "@splunk/visualization-encoding-parsers": "25.8.1", + "@splunk/visualization-icons": "25.8.1", + "@splunk/visualization-themes": "25.8.1", + "@splunk/visualizations": "25.8.1", + "@splunk/visualizations-shared": "25.8.1", "fast-text-encoding": "^1.0.2", + "fullscreen-react": "^1.0.4", + "interweave": "^13.1.0", "next": "^9.3.6", - "prettier": "^2.0.5", + "next-videos": "^1.4.1", + "prettier": "^2.8.8", "querystring": "^0.2.0", "react": "16.14.0", + "react-component-rotator": "^0.1.1", "react-dom": "16.14.0", "react-full-screen": "^0.2.4", + "react-html-parser": "^2.0.2", "styled-components": "^5.0.0" }, - "resolutions": { - "@splunk/charting-bundle": "24.5.0", - "@splunk/dashboard-context": "24.5.0", - "@splunk/dashboard-core": "24.5.0", - "@splunk/dashboard-definition": "24.5.0", - "@splunk/dashboard-event-handlers": "24.5.0", - "@splunk/dashboard-icons": "24.5.0", - "@splunk/dashboard-inputs": "24.5.0", - "@splunk/dashboard-layouts": "24.5.0", - "@splunk/dashboard-presets": "24.5.0", - "@splunk/dashboard-telemetry": "24.5.0", - "@splunk/dashboard-ui": "24.5.0", - "@splunk/dashboard-utils": "24.5.0", - "@splunk/dashboard-visualizations": "24.5.0", - "@splunk/datasource-utils": "24.5.0", - "@splunk/datasources": "24.5.0", - "@splunk/react-icons": "3.2.0", - "@splunk/react-ui": "4.7.0", - "@splunk/visualization-color-palettes": "24.5.0", - "@splunk/visualization-context": "24.5.0", - "@splunk/visualization-encoding": "24.5.0", - "@splunk/visualization-encoding-parsers": "24.5.0", - "@splunk/visualization-icons": "24.5.0", - "@splunk/visualization-themes": "24.5.0", - "@splunk/visualizations": "24.5.0", - "@splunk/visualizations-shared": "24.5.0" - }, "scripts": { "dev": "next dev", "build": "next build", diff --git a/template/public/video_assist_preparing.mov b/template/public/video_assist_preparing.mov new file mode 100644 index 0000000..d7e31c5 Binary files /dev/null and b/template/public/video_assist_preparing.mov differ diff --git a/template/public/video_inspector_running.mov b/template/public/video_inspector_running.mov new file mode 100644 index 0000000..86a8bd2 Binary files /dev/null and b/template/public/video_inspector_running.mov differ diff --git a/template/src/components/dashboard.js b/template/src/components/dashboard.js index 5437e71..7f8f822 100644 --- a/template/src/components/dashboard.js +++ b/template/src/components/dashboard.js @@ -15,18 +15,17 @@ limitations under the License. */ import { DashboardContextProvider } from '@splunk/dashboard-context'; -import GeoRegistry from '@splunk/dashboard-context/GeoRegistry'; -import GeoJsonProvider from '@splunk/dashboard-context/GeoJsonProvider'; +import { GeoJsonProvider, GeoRegistry } from '@splunk/dashboard-context'; import DashboardCore from '@splunk/dashboard-core'; import React, { Suspense, useMemo, useEffect, useRef } from 'react'; import Loading from './loading'; import defaultPreset from '../preset'; import { SayCheese, registerScreenshotReadinessDep } from '../ready'; import { testTileConfig } from '@splunk/visualization-context/MapContext'; +import Fullscreen from '@splunk/react-icons/Fullscreen'; const mapTileConfig = { defaultTileConfig: testTileConfig }; - const PROD_SRC_PREFIXES = [ // Add URL prefixes here that will be replaced with the page's current origin ]; @@ -110,18 +109,44 @@ export default function Dashboard({ definition, preset, width = '100vw', height }; }, []); + const toggleFullSceen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + document.body.style.cursor = 'none'; + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + }; + return ( - - }> - - - - + <> + toggleFullSceen()} + > + + }> + + + + + ); } diff --git a/template/src/components/home.js b/template/src/components/home.js index 6a96f0a..3865f90 100644 --- a/template/src/components/home.js +++ b/template/src/components/home.js @@ -18,6 +18,7 @@ import React from 'react'; import styled from 'styled-components'; import { variables } from '@splunk/themes'; import dashboardManifest from '../_dashboards.json'; +import Chip from '@splunk/react-ui/Chip'; const Wrapper = styled.div` width: 100vw; @@ -37,7 +38,8 @@ const DashLink = styled.a` align-items: center; justify-content: center; text-align: center; - width: 280px; + width: 500px; + height: 80px; border: 1px solid #eee; margin: 10px 10px 0 0; @@ -59,8 +61,9 @@ export default function Home({ title = 'Dashboards' }) { Dashboards {Object.keys(dashboardManifest).map((k) => ( - - {dashboardManifest[k]} + Demo : <>} ))} diff --git a/template/src/components/loading.js b/template/src/components/loading.js index 27f0720..abe323a 100644 --- a/template/src/components/loading.js +++ b/template/src/components/loading.js @@ -37,7 +37,17 @@ export default function Loading() { return ( - Loading... +
+ {' '} +
); diff --git a/template/src/pages/[dashboard].jsx b/template/src/pages/[dashboard].jsx index 3cfaac6..b298d83 100644 --- a/template/src/pages/[dashboard].jsx +++ b/template/src/pages/[dashboard].jsx @@ -12,7 +12,7 @@ export default function DashboardPage({ definition, dashboardId, baseUrl }) { description={definition.description} imageUrl={`/screens/${dashboardId}.png`} path={`/${dashboardId}`} - backgroundColor={definition.layout.options.backgroundColor} + backgroundColor={definition.layout.options.backgroundColor ? definition.layout.options.backgroundColor : '#000'} theme={definition.theme} baseUrl={baseUrl} > diff --git a/template/src/pages/advancedrotator/index.jsx b/template/src/pages/advancedrotator/index.jsx new file mode 100755 index 0000000..ae58708 --- /dev/null +++ b/template/src/pages/advancedrotator/index.jsx @@ -0,0 +1,219 @@ +import React, { lazy, Suspense, useState, useEffect } from 'react'; +import Loading from '../../components/loading'; +import NoSSR from '../../components/nossr'; +import Page from '../../components/page'; +import { useRouter } from 'next/router'; +import Fullscreen from '@splunk/react-icons/Fullscreen'; + +//This rotator takes following parameters: +//pages: comma separated values. This expects the following parameters, pages and timer: +// type, id + +//type: either video, or dashboard +//id: either the dashboard id, the movie file hosted in this project + +//timer: Note: for video files, we will simply play the entire video and ignore this. For dashboards, this is how long to show the dashboard, in ms. + +//So for example: +//https://yourproject.vercel.app/advancedrotator?pages=video,movie.mov,90000,swag_store,dashboard,5000 + +const Dashboard = lazy(() => import('../../components/dashboard')); + +export default function DashboardPage({}) { + //These are the URL params + const { query } = useRouter(); + + //This let's us know the timer is ticking + const [ticking, setTicking] = useState(true); + + //This is the current count of the timer + const [count, setCount] = useState(0); + + //This is the value of the timer. For example "how long do we want each dashboard to show" + const [timerValue, setTimerValue] = useState(0); + + //These are the final pages that will be shown + const [finalpages, setFinalPages] = useState([[]]); + + //This is the index of the current page + const [currPageIndex, setCurrPageIndex] = useState(0); + + //This is the current type of page + const [currType, setCurrType] = useState('dashboard'); + + //This let's us know a video is done playing + const [videoPlayOver, setVideoPlayOver] = useState(false); + + useEffect(() => { + console.log(currType); + + if (currType == 'dashboard') { + if (count > timerValue) { + setCount(0); + if (currPageIndex == finalpages.length - 1) { + setCurrPageIndex(0); + setCurrType(finalpages[0][1]); + } else { + setCurrPageIndex(currPageIndex + 1); + setCurrType(finalpages[currPageIndex + 1][1]); + } + } + } + + if (currType == 'video') { + if (videoPlayOver) { + setCount(0); + if (currPageIndex == finalpages.length - 1) { + setCurrPageIndex(0); + setCurrType(finalpages[0][1]); + setVideoPlayOver(false); + } else { + setCurrPageIndex(currPageIndex + 1); + setCurrType(finalpages[currPageIndex + 1][1]); + setVideoPlayOver(false); + } + } + } + const timer = setTimeout(() => ticking && setCount(count + 1), 1e3); + return () => clearTimeout(timer); + }, [count, ticking]); + + useEffect(() => { + setTimerValue(query.timer / 1000); + }, [query.timer]); + + useEffect(() => { + if (typeof query.pages != 'undefined') { + var dashboards = []; + var dashboards = query.pages.split(','); + setCurrType(dashboards[0]); + + var iterator = 0; + + var pages = []; + var new_obj = {}; + for (var dashboard in dashboards) { + if (iterator == 2) { + iterator = 0; + } + + if (iterator == 0) { + new_obj['type'] = dashboards[dashboard]; + } + if (iterator == 1) { + new_obj.id = dashboards[dashboard]; + pages.push(new_obj); + new_obj = {}; + } + iterator = iterator + 1; + } + + var baseUrl = `https://${process.env.VERCEL_URL}`; + + var temp_pages = []; + + for (var page in pages) { + if (pages[page].type == 'dashboard') { + var definition = require(`../../dashboards/${pages[page].id}/definition.json`); + console.log(definition); + + temp_pages.push([ + + + }> + + + + , + 'dashboard', + ]); + } + + if (pages[page].type == 'video') { + temp_pages.push([ + +
+ +
+
+ {' '} +
+
, + 'video', + ]); + } + } + setFinalPages(temp_pages); + console.log(temp_pages); + } + }, [query.pages]); + + const toggleFullSceen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + document.body.style.cursor = 'none'; + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + }; + + if (!query.pages || !query.timer) { + return <>; + } else { + return ( + <> + toggleFullSceen()} + > + +
+ {finalpages[currPageIndex][0]} +
+ + ); + } +} diff --git a/template/src/pages/error.jsx b/template/src/pages/error.jsx new file mode 100755 index 0000000..d03bdd0 --- /dev/null +++ b/template/src/pages/error.jsx @@ -0,0 +1,48 @@ +import React from "react"; +import Page from "../components/page"; + +export default function Home({}) { + return ( + +
+ +
+
+ {" "} +
+
+ ); +} diff --git a/template/src/pages/rotator/index.jsx b/template/src/pages/rotator/index.jsx new file mode 100755 index 0000000..e13d1f4 --- /dev/null +++ b/template/src/pages/rotator/index.jsx @@ -0,0 +1,99 @@ +import React, { lazy, Suspense, useState } from "react"; +import Loading from "../../components/loading"; +import NoSSR from "../../components/nossr"; +import Page from "../../components/page"; +import { useRouter } from "next/router"; +import ComponentRotator from "react-component-rotator"; +import FullscreenLight from "@splunk/react-icons/FullscreenLight"; + +const Dashboard = lazy(() => import("../../components/dashboard")); + +export default function DashboardPage({ dashboardId, baseUrl }) { + const { query } = useRouter(); + + const toggleFullSceen = () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + document.body.style.cursor = "none"; + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + }; + + if (!query.dashboards || !query.timer) { + return null; + } else { + var dashboards = []; + var dashboards = query.dashboards.split(","); + + var defs = {}; + + var baseUrl = `https://${process.env.VERCEL_URL}`; + + for (var dashboard in dashboards) { + defs[ + dashboards[dashboard] + ] = require(`../../dashboards/${dashboards[dashboard]}/definition.json`); + } + + var pages = []; + for (var def in defs) { + pages.push( + + + }> + + + + + ); + } + return ( + <> + toggleFullSceen()} + > +
+ +
+ + ); + } +} diff --git a/template/src/pages/timelapse.jsx b/template/src/pages/timelapse.jsx new file mode 100644 index 0000000..3423101 --- /dev/null +++ b/template/src/pages/timelapse.jsx @@ -0,0 +1,36 @@ +import React, { lazy, Suspense, useEffect, useState } from "react"; +import { useRouter } from "next/router"; + +import Loading from "../components/loading"; +import NoSSR from "../components/nossr"; +import Page from "../components/page"; + +const TimelapseDashboard = lazy(() => import("../timelapse")); + +export default function DashboardPage() { + const { query } = useRouter(); + + const [dashboardDef, setDashboardDef] = useState({}); + + useEffect(() => { + if (typeof query.dashboard != "undefined") { + var definition = require(`../dashboards/${query.dashboard}/definition.json`); + setDashboardDef(definition); + } + }, [query]); + return ( + + + }> + + + + + ); +} diff --git a/template/src/preset.js b/template/src/preset.js index b54816c..0db168e 100644 --- a/template/src/preset.js +++ b/template/src/preset.js @@ -17,7 +17,10 @@ limitations under the License. import React, { lazy } from 'react'; import CdnDataSource from './datasource'; import DrilldownHandler from './drilldown'; +import TestDataSource from '@splunk/datasources/TestDataSource'; import { polyfillTextDecoder } from './polyfills'; +import { AbsoluteLayoutViewer } from '@splunk/dashboard-layouts'; +import { SetToken } from '@splunk/dashboard-event-handlers'; import { DropdownInput, TimeRangeInput, MultiselectInput, TextInput, NumberInput } from '@splunk/dashboard-inputs'; const fixRequestParams = (LazyComponent) => (props) => { @@ -41,13 +44,15 @@ const lazyViz = (fn) => { const PRESET = { layouts: { - absolute: lazyViz(() => import('@splunk/dashboard-layouts/AbsoluteLayoutViewer')), + absolute: AbsoluteLayoutViewer, }, dataSources: { 'ds.cdn': CdnDataSource, + 'ds.test': TestDataSource, }, eventHandlers: { 'drilldown.customUrl': DrilldownHandler, + 'drilldown.setToken': SetToken, }, visualizations: { // legacy @@ -103,7 +108,7 @@ const PRESET = { 'splunk.map': commonFlags(lazyViz(() => import('@splunk/visualizations/Map'))), 'splunk.table': commonFlags(lazyViz(() => import('@splunk/visualizations/Table'))), }, - inputs:{ + inputs: { 'input.dropdown': DropdownInput, 'input.timerange': TimeRangeInput, 'input.text': TextInput, diff --git a/template/src/timelapse/controls.js b/template/src/timelapse/controls.js new file mode 100644 index 0000000..46b4e57 --- /dev/null +++ b/template/src/timelapse/controls.js @@ -0,0 +1,230 @@ +import React, { useEffect, useMemo, useCallback } from "react"; +import styled from "styled-components"; +import ColumnChart from "@splunk/react-visualizations/Column"; +import Play from "@splunk/react-icons/Play"; +import Pause from "@splunk/react-icons/Pause"; +import Spinner from "@splunk/react-ui/WaitSpinner"; +import Slider from "./slider"; +import { + globalTime, + useTimeList, + useCurrentTime, + usePlaybackStatus, + usePlaybackSpeed, +} from "./timecontext"; +import { useState } from "react"; + +const Wrapper = styled.div` + position: fixed; + tops: 0; + left: 0; + right: 0; + height: 125px; + background: #ffffff; + border-bottom: 5px solid rgb(8, 9, 10); + color: #444444; + z-index: 999; + display: flex; + flex-direction: row; + align-items: center; +`; + +const LoadingCt = styled.div` + flex: 1; + display: flex; + justify-content: center; +`; + +const PlaybackControls = styled.div` + width: 120px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +`; + +const CurrentValueCt = styled.div` + width: 190px; + display: flex; + align-items: center; + justify-content: center; + font-size: 25px; +`; + +const Timeline = styled.div` + flex: 1; + position: relative; +`; + +const SliderCt = styled.div` + box-sizing: border-box; + padding: 0 10px 0 10px; + background: rgba(255, 255, 255, 0.001); + position: absolute; + top: 0; + width: 100%; + height: 120px; + z-index: 999; +`; +let i = 0; + +function minutes_with_leading_zeros(dt) { + return (dt.getMinutes() < 10 ? "0" : "") + dt.getMinutes(); +} + +function CurrentTimeValue() { + const val = useCurrentTime(); + if (!val) { + return null; + } + return ( +
+ {val.getMonth() + 1}/{val.getDate()}/{val.getYear() % 100}{" "} + {val.getHours()}:{minutes_with_leading_zeros(val)} +
+ ); +} + +const Btn = styled.a` + display: block; + color: #444444; + text-decoration: none; + padding: 10px; + cursor: pointer; +`; + +function PlayPauseButton() { + const isPlaybackRunning = usePlaybackStatus(); + + const startPlayback = useCallback(() => { + globalTime.startPlayback(); + }, []); + + const stopPlayback = useCallback(() => { + globalTime.stopPlayback(); + }, []); + + return ( + + {isPlaybackRunning ? ( + + ) : ( + + )} + + ); +} + +function PlaybackSpeed() { + const curSpeed = usePlaybackSpeed(); + const handleChange = useCallback((e) => { + globalTime.speed = +e.target.value; + }, []); + + return ( + + ); +} + +export default function TimelapseControls({ definition }) { + const [values, setValues] = useState([]); + useEffect(() => { + let active = true; + (async () => { + const ds = Object.values(definition.dataSources).find( + (def) => def.name === "Time Series" + ); + if (!ds) { + throw new Error("MISSING TIMELINE DATASOURCE"); + } + const res = await fetch(ds.options.uri); + const data = await res.json(); + if (active) { + const times = data.columns[data.fields.indexOf("_time")]; + const caseNumbers = data.columns[data.fields.indexOf("x")]; + + const newValues = times + .sort() + .map((_, i) => i) + .map((v) => parseFloat(caseNumbers[v])) + .reduce( + (res, v, i, all) => { + return i >= all.length - 1 + ? res + : res.concat( + Math.min(0.6, 0.05 + Math.max(0, Math.log2(all[i + 1] / v))) + ); + }, + [0.05] + ); + + globalTime.setTimes(times); + setValues(newValues); + globalTime.startPlayback(); + } + })().catch((e) => { + console.error(e); + }); + + return () => { + active = false; + }; + }, [definition, setValues]); + + const times = useTimeList(); + + if (!times.length || !values.length) { + return ( + + + + + + ); + } + return ( + + + + {/* */} + + + + + + + + + + + + ); +} diff --git a/template/src/timelapse/index.js b/template/src/timelapse/index.js new file mode 100644 index 0000000..711c877 --- /dev/null +++ b/template/src/timelapse/index.js @@ -0,0 +1,45 @@ +import React, { useEffect } from "react"; +import styled from "styled-components"; +import TimelapseControls from "./controls"; +import Dashboard from "../components/dashboard"; +import DEFAULT_PRESET from "../preset"; +import TimelapseDataSource from "./timelapseds"; +import jsCharting from "@splunk/charting-bundle"; + +function hackDisableProgressiveRender() { + const c = jsCharting.createChart(document.createElement("div"), {}); + c.constructor.prototype.shouldProgressiveDraw = () => false; + c.destroy(); +} + +window.jsCharting = jsCharting; + +const DashboardWrapper = styled.div` + padding-top: 125px; +`; + +const TIMELAPSE_PRESET = { + ...DEFAULT_PRESET, + dataSources: { + "ds.cdn": TimelapseDataSource, + }, +}; + +export default function TimelapseDashboard({ dashDef }) { + useEffect(() => { + hackDisableProgressiveRender(); + }, []); + + return ( + <> + + + + + + ); +} diff --git a/template/src/timelapse/slider.js b/template/src/timelapse/slider.js new file mode 100644 index 0000000..5465851 --- /dev/null +++ b/template/src/timelapse/slider.js @@ -0,0 +1,107 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { globalTime } from './timecontext'; +import EventListener from 'react-event-listener'; + +const Wrapper = styled.div` + width: 100%; + height: 120px; + background: rgba(200, 0, 0, 0.001); + cursor: pointer; + position: relative; +`; + +const Thumb = styled.div` + height: 120px; + background: rgba(255, 255, 255, 0.001); + cursor: move; + position: absolute; + display: flex; + align-items: center; + justify-content: center; +`; + +const ThumbMarker = styled.div` + height: 100px; + width: 1px; + border: 1px solid #d5e3d7; + background: #d5e3d7; + border-radius: 5px; + + ${Thumb}:hover & { + border: 2px solid #d5e3d7; + } + [data-selected='true'] & { + border: 2px solid #d5e3d7; + } +`; + +export default function Slider({ times }) { + const [dragging, setDragging] = useState(false); + const [val, setVal] = useState(0); + const [selected, setSelected] = useState(false); + const trackEl = useRef(); + + useEffect( + () => + globalTime.subscribe(() => { + setVal(globalTime._cur); + }), + [setVal] + ); + + const handleClick = useCallback( + e => { + const boundingRect = trackEl.current.getBoundingClientRect(); + const offset = e.clientX - boundingRect.left; + const value = Math.floor((offset / boundingRect.width) * times.length); + setVal(value + 0.5); + globalTime.stopPlayback(); + globalTime.setTime(value + 0.5); + }, + [setSelected, setVal, trackEl] + ); + + const handleDragStart = useCallback( + e => { + if (e.button > 0) { + return; + } + e.preventDefault(); + setDragging(true); + }, + [setDragging] + ); + + const handleDragEnd = useCallback(e => { + setDragging(false); + }, []); + + const handleDrag = useCallback( + e => { + const boundingRect = trackEl.current.getBoundingClientRect(); + const offset = e.clientX - boundingRect.left; + const value = Math.min(Math.floor((offset / boundingRect.width) * times.length), times.length - 1); + setVal(value + 0.5); + globalTime.stopPlayback(); + globalTime.setTime(value + 0.5); + }, + [dragging] + ); + + return ( + + {dragging && } + + + + + ); +} diff --git a/template/src/timelapse/timecontext.js b/template/src/timelapse/timecontext.js new file mode 100644 index 0000000..e898d2e --- /dev/null +++ b/template/src/timelapse/timecontext.js @@ -0,0 +1,218 @@ +import { useState, useEffect } from 'react'; + +class GlobalTime { + _times = []; + _subs = new Set(); + _cur = null; + _timer = null; + _loop = true; + _speed = 10; + span = 43200000; + + setRange(from, to) { + const times = []; + let start = from; + const now = to || new Date(); + while (start < now) { + times.push(start.getTime()); + start = new Date(start.getTime() + this.span); + } + this._times = times; + this.setTime(0); + } + + setTimes(times) { + this._times = times.map(t => new Date(t).getTime()); + this._times.sort(); + this.setTime(0); + } + + subscribe(callback) { + this._subs.add(callback); + if (this._cur != null) { + callback(this._cur); + } + return () => { + this._subs.delete(callback); + }; + } + + subscribeToTime(callback) { + let cur = this.currentTime; + if (cur != null) { + callback(cur); + } + return this.subscribe(() => { + const next = this.currentTime; + if (next?.getTime() != cur?.getTime()) { + callback(next); + cur = next; + } + }); + } + + subscribeToTime(callback) { + let cur = this.currentTime; + if (cur != null) { + callback(cur); + } + return this.subscribe(() => { + const next = this.currentTime; + if (next?.getTime() != cur?.getTime()) { + callback(next); + cur = next; + } + }); + } + + subscribeToTimeSpan(callback) { + let cur = this._cur; + if (cur != null) { + callback(this.timeSpanAt(cur)); + } + return this.subscribe(() => { + const next = this._cur; + if (next != cur) { + callback(this.timeSpanAt(next)); + cur = next; + } + }); + } + + get currentTime() { + const dateVal = this._times[Math.max(0, Math.min(this._times.length - 1, Math.floor(this._cur)))]; + return dateVal != null ? new Date(dateVal) : null; + } + + timeSpanAt(idx) { + if (idx != null) { + idx = Math.max(0, Math.min(this._times.length - 1, Math.floor(idx))); + const start = this._times[idx]; + if (start != null) { + const endVal = this._times[idx + 1]; + const end = endVal != null ? endVal : start + this.span; + return [start, end]; + } + } + return null; + } + + get currentTimeSpan() { + return this._cur != null ? this.timeSpanAt(this._cur) : null; + } + + get current() { + return this._cur; + } + + get timeList() { + return this._times; + } + + get speed() { + return this._speed; + } + + set speed(val) { + this._speed = val; + if (this.isPlaybackRunning()) { + this.stopPlayback(); + this.startPlayback(); + } + } + + setTime(time) { + if (time !== this._cur) { + this._cur = time; + this.notify(); + } + } + + notify() { + const newVal = this.currentTime; + for (const sub of this._subs.values()) { + sub(newVal); + } + } + + isPlaybackRunning() { + return this._timer != null; + } + + startPlayback() { + this.stopPlayback(); + this._timer = setInterval(() => { + if (this._cur < this._times.length) { + this.setTime(this._cur + 0.1); + } else { + if (this._loop) { + this.setTime(0); + } else { + this.stopPlayback(); + } + } + }, Math.floor(64 * (1 / this._speed))); + } + + stopPlayback() { + if (this._timer != null) { + this._timer = clearInterval(this._timer); + this._timer = null; + this.notify(); + } + } +} + +export const globalTime = new GlobalTime(); + +export function useCurrentTime() { + const [val, setVal] = useState(globalTime.currentTime); + + useEffect(() => { + const unsubscribe = globalTime.subscribeToTime(val => setVal(val)); + return () => { + unsubscribe(); + }; + }, [setVal]); + + return val; +} + +export function useTimeList() { + const [val, setVal] = useState(globalTime.timeList); + useEffect(() => { + const unsubscribe = globalTime.subscribe(() => { + setVal(globalTime.timeList); + }); + return () => { + unsubscribe(); + }; + }, [setVal]); + return val; +} + +export function usePlaybackStatus() { + const [val, setVal] = useState(globalTime.timeList); + useEffect(() => { + const unsubscribe = globalTime.subscribe(() => { + setVal(globalTime.isPlaybackRunning()); + }); + return () => { + unsubscribe(); + }; + }, [setVal]); + return val; +} + +export function usePlaybackSpeed() { + const [val, setVal] = useState(globalTime.speed); + useEffect(() => { + const unsubscribe = globalTime.subscribe(() => { + setVal(globalTime.speed); + }); + return () => { + unsubscribe(); + }; + }, [setVal]); + return val; +} diff --git a/template/src/timelapse/timelapseds.js b/template/src/timelapse/timelapseds.js new file mode 100644 index 0000000..1f5b54e --- /dev/null +++ b/template/src/timelapse/timelapseds.js @@ -0,0 +1,130 @@ +import DataSource from "@splunk/datasources/DataSource"; +import DataSet from "@splunk/datasource-utils/DataSet"; +import { globalTime } from "./timecontext"; +import { createDataSet } from "../datasource"; +import { registerScreenshotReadinessDep } from "../ready"; + +function capAt(fields, columns, timeColIdx, untilRow) { + return DataSet.fromJSONCols( + fields, + columns.map((c) => c.slice(0, untilRow)) + ); +} + +function nullAfter(fields, columns, timeColIdx, untilRow) { + return DataSet.fromJSONCols( + fields, + columns.map((c, i) => { + if (i === timeColIdx) { + return c; + } + return c.map((v, r) => (r >= untilRow ? null : v)); + }) + ); +} + +export default class TimelapseDataSource extends DataSource { + constructor(options = {}, context = {}) { + super(options, context); + this.uri = options.uri; + } + + request(options) { + options = options || {}; + return (observer) => { + const onabortCallbacks = []; + let readyDep = registerScreenshotReadinessDep("TLDS"); + + fetch(this.uri) + .then((r) => r.json()) + .then(({ fields, columns, timelapse }) => { + if (!timelapse) { + readyDep.ready(); + observer.next({ + data: createDataSet({ fields, columns }, options), + meta: {}, + }); + } else { + const timeFieldIdx = fields.indexOf(timelapse.field || "_time"); + const parsedTimes = columns[timeFieldIdx].map((v) => + new Date(v).getTime() + ); + + switch (timelapse.transform) { + case "null_after": + case "cap": + console.log("Found a cap"); + + { + const updateUntilTime = ([time]) => { + const t = time; + let untilRow = parsedTimes.findIndex((v) => v > t); + if (untilRow < 0) { + untilRow = Infinity; + } + const fn = + timelapse.transform === "cap" ? capAt : nullAfter; + observer.next({ + data: fn(fields, columns, timeFieldIdx, untilRow), + meta: { status: "done" }, + }); + readyDep.ready(); + }; + onabortCallbacks.push( + globalTime.subscribeToTimeSpan(updateUntilTime) + ); + } + break; + case "select": + console.log("Found a select"); + { + const selectNext = ([start, end]) => { + observer.next({ + data: DataSet.fromJSONCols( + fields.filter((_, i) => i !== timeFieldIdx), + columns + .filter((_, i) => i !== timeFieldIdx) + .map((c) => + c.filter((_, i) => { + const t = parsedTimes[i]; + return t >= start && t < end; + }) + ) + ), + meta: { status: "done" }, + }); + readyDep.ready(); + }; + onabortCallbacks.push( + globalTime.subscribeToTimeSpan(selectNext) + ); + } + break; + default: + throw new Error( + `Invalid timelapse transform ${timelapse.transform}` + ); + } + } + }) + .catch((e) => { + console.error(e); + observer.error({ + level: "error", + message: e.message || "Unexpected error", + }); + }); + + return () => { + for (const cb of onabortCallbacks) { + try { + cb(); + } catch (e) { + console.error("Abort callback failed", e); + } + } + readyDep.remove(); + }; + }; + } +} diff --git a/yarn.lock b/yarn.lock index 66699b6..c4ad1d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -195,10 +195,10 @@ chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" - integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== dependencies: ansi-styles "^4.1.0" supports-color "^7.1.0" @@ -287,6 +287,11 @@ cli-width@^2.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -631,21 +636,21 @@ inquirer@^5.1.0: strip-ansi "^4.0.0" through "^2.3.6" -inquirer@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" - integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== +inquirer@^7.3.3: + version "7.3.3" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" + integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA== dependencies: ansi-escapes "^4.2.1" - chalk "^3.0.0" + chalk "^4.1.0" cli-cursor "^3.1.0" - cli-width "^2.0.0" + cli-width "^3.0.0" external-editor "^3.0.3" figures "^3.0.0" - lodash "^4.17.15" + lodash "^4.17.19" mute-stream "0.0.8" run-async "^2.4.0" - rxjs "^6.5.3" + rxjs "^6.6.0" string-width "^4.1.0" strip-ansi "^6.0.0" through "^2.3.6" @@ -741,11 +746,16 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" -lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.5, lodash@^4.3.0: +lodash@^4.17.11, lodash@^4.17.5, lodash@^4.3.0: version "4.17.20" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.19: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -937,10 +947,10 @@ prebuild-install@^6.1.4: tar-fs "^2.0.0" tunnel-agent "^0.6.0" -prettier@^1.19.1: - version "1.19.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" - integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== +prettier@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.0.tgz#e7b19f691245a21d618c68bc54dc06122f6105ae" + integrity sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g== process-nextick-args@~2.0.0: version "2.0.1" @@ -1036,10 +1046,10 @@ rxjs@^5.5.2: dependencies: symbol-observable "1.0.1" -rxjs@^6.5.3: - version "6.5.4" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" - integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== +rxjs@^6.6.0: + version "6.6.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" + integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== dependencies: tslib "^1.9.0"