diff --git a/docs/public/data/summary/swt.datastatus b/docs/public/data/summary/swt.datastatus new file mode 100644 index 0000000..884a5d1 --- /dev/null +++ b/docs/public/data/summary/swt.datastatus @@ -0,0 +1,35 @@ +ATOK.Elev.Inst.30Minutes.0.Decodes-Raw +KEYS.Elev.Inst.30Minutes.0.Ccp-Rev +KEYS.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev +: OOLO.Elev.Inst.30Minutes.0.Ccp-Rev +: OOLO.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev +: DENI.Elev.Inst.1Hour.0.Ccp-Rev +: DENI.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev +: JOHN.Elev.Inst.1Hour.0.Ccp-Rev +: JOHN.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev +: INDX.Stage.Inst.1Hour.0.Ccp-Rev +: FLOR.Stage.Inst.1Hour.0.Ccp-Rev +: JOPL.Stage.Inst.1Hour.0.Ccp-Rev +: UNGE.Stage.Inst.1Hour.0.Ccp-Rev +: BART.Stage.Inst.1Hour.0.Ccp-Rev +: RALS.Stage.Inst.1Hour.0.Ccp-Rev +: DUND.Stage.Inst.1Hour.0.Ccp-Rev +: FRIT.Precip-Cuml.Inst.1Hour.0.Ccp-Rev +: AMES.Stage.Inst.1Hour.0.Ccp-Rev +: CLDY.Stage.Inst.1Hour.0.Ccp-Rev +ALTU.Precip-Inc.Total.15Minutes.15Minutes.Ccp-Rev +CLRK.Precip-Inc.Total.15Minutes.15Minutes.Ccp-Rev +: FSUP.Precip-Inc.Total.30Minutes.30Minutes.Ccp-Rev +: PENS.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev +: THRA.Precip-Inc.Total.30Minutes.30Minutes.Ccp-Rev +: WOO2.Precip-Inc.Total.30Minutes.30Minutes.Ccp-Rev +: ALTA.Flow.Inst.15Minutes.0.Ccp-Rev +: KANS.Flow.Inst.15Minutes.0.Ccp-Rev +: PECK.Flow.Inst.15Minutes.0.Ccp-Rev +: STIL.Flow.Inst.15Minutes.0.Ccp-Rev +: WETU.Flow.Inst.30Minutes.0.Ccp-Rev +: ARBU.Stor.Inst.30Minutes.0.Ccp-Rev +: DENI.Stor.Inst.30Minutes.0.Ccp-Rev +: MARI.Stor.Inst.1Hour.0.Ccp-Rev +: TENK.Stor.Inst.1Hour.0.Ccp-Rev +: WDMA.Stor.Inst.1Hour.0.Ccp-Rev diff --git a/docs/src/bundles/route-bundle.js b/docs/src/bundles/route-bundle.js index 1594028..33fa919 100644 --- a/docs/src/bundles/route-bundle.js +++ b/docs/src/bundles/route-bundle.js @@ -21,6 +21,7 @@ import DataHooks from "../pages/docs/hooks"; import HelpPage from "../pages/docs/help"; import CdaUrlProviderDocs from "../pages/docs/utilities/cda-url-provider"; import UtilitiesDocs from "../pages/docs/utilities"; +import DataStatus from "../pages/docs/summary/data-status"; export default createRouteBundle( { @@ -40,6 +41,7 @@ export default createRouteBundle( "/docs/plots/cwms-plot": CWMSPlotDocs, "/docs/maps": Maps, "/docs/tables": Tables, + "/docs/summary/data-status": DataStatus, "/docs/utilities": UtilitiesDocs, "/docs/utilities/cda-url-provider": CdaUrlProviderDocs, "/docs/react-query": ReactQuery, diff --git a/docs/src/pages/docs/summary/data-status.jsx b/docs/src/pages/docs/summary/data-status.jsx new file mode 100644 index 0000000..ff903b4 --- /dev/null +++ b/docs/src/pages/docs/summary/data-status.jsx @@ -0,0 +1,149 @@ +import { Badge, Text } from "@usace/groundwork"; +import ParamsTable from "../../components/params-table"; +import DocsPage from "../_docs-wrapper"; +import Divider from "../../components/divider"; +import { Code } from "../../components/code"; +// import { DataStatus } from "@usace-watermanagement/groundwork-water" +import DataStatus from "../../../../../lib/components/data/summary/DataStatus"; +import { useDataStatusFile } from "@usace-watermanagement/groundwork-water"; + +const returnParams = [ + { + name: "office", + type: "string", + required: true, + desc: "The office code for the data status", + }, + { + name: "tsids", + type: "array", + desc: "An array of TimeSeries Identifiers to fetch data status for from CDA", + }, + { + name: "pageSize", + type: "number", + desc: "The maximum number of entries to fetch in one date range", + }, + { + name: "cdaUrl", + type: "string", + desc: "The URL to the CDA service to fetch TimeSeries from. Defaults to: https://cwms-data.usace.army.mil/cwms-data", + }, + { + name: "linkPath", + type: "string", + desc: "A url path to a project or some other page. I.e. /{office}/projects/ would end up pointing to /{office}/projects/{name}", + }, + { + name: "lookBackHours", + type: "number", + desc: "Number of hours from current time to look back for data status", + }, + { + name: "dateFormat", + type: "string", + desc: ( + + A{" "} + + day.js + {" "} + format string for the date display in the table + + ), + }, + { + name: "showBadges", + type: "boolean", + desc: "A flag to determine if the badge color legend should be shown", + }, + { + name: "title", + type: "string", + desc: "The title of the Data Status component within UsaceBox", + }, +]; + +function DataStatusPage() { + const { data: fileTsids = [], error: fileError, isPending: filePending } = useDataStatusFile({ + fileUrl: "/data/summary/swt.datastatus", + }) + return ( + +
+ + The Data Status component uses quality flags and values from + timeseries to return a quick overview of data in the last number of + hours that you specify. It is not meant to be a replacement of the + DataStatusSummary CWMS application, but certainly can function as a + substitute! + +
+ {!filePending && fileError ?
Failed to load TSID data status file! {fileError?.message}
: } + +
+ + {`import dayjs from "dayjs"; +import { useState } from "react"; +import { useDataStatusFile } from "@usace-watermanagement/groundwork-water"; + +/* swt.datastatus Contents : +: This is a comment +KEYS.Elev.Inst.1Hour.0.Ccp-Rev +ALTU.Precip-Inc.Total.15Minutes.15Minutes.Ccp-Rev +CLRK.Precip-Inc.Total.15Minutes.15Minutes.Ccp-Rev +: KEYS.Precip-Inc.Total.1Hour.1Hour.Ccp-Rev +*/ + +default export function Example() { + /* This is optional if you want to load tsids from a file */ + const { data: fileTsids, error: fileError, isPending: filePending } = useDataStatusFile({ + fileUrl: "/data/summary/swt.datastatus", + }) + if (filePending) { + return + } + if (fileError) { + return
Failed to load TSID data status file! {fileError?.message}
+ } + /* You could use a timeseries group to load tsids as well! */ + // Or use a static array below : + const tsids = ["KEYS.Elev.Inst.1Hour.0.Ccp-Rev"] + return +} +`} +
+
+
+ Data Status Parameters + {``} +
+ + + + You must specify either dataStatusUrl or tsids or the table will have no + values! + +
+ ); +} + +export { DataStatusPage }; +export default DataStatusPage; diff --git a/lib/components/data/hooks/useDataStatusFile.ts b/lib/components/data/hooks/useDataStatusFile.ts new file mode 100644 index 0000000..7d0424e --- /dev/null +++ b/lib/components/data/hooks/useDataStatusFile.ts @@ -0,0 +1,31 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + + +interface useDataStatusFileParams { + fileUrl: string; + queryOptions?: Partial>; +} + +const useDataStatusFile = ({ + fileUrl, + queryOptions, +}: useDataStatusFileParams) => { + + return useQuery({ + queryKey: ["dataStatus", fileUrl], + queryFn: async () => { + return fetch(fileUrl) + .then((response) => response.text()) + .then((text) => { + // Split by new lines and filter out lines starting with ":" (commented) + return text.split("\n").filter((line) => !line.startsWith(":")); + }); + }, + refetchOnWindowFocus: false, + ...queryOptions, + }); + +}; + +export { useDataStatusFile }; +export default useDataStatusFile; diff --git a/lib/components/data/summary/DataStatus.jsx b/lib/components/data/summary/DataStatus.jsx new file mode 100644 index 0000000..9f8242d --- /dev/null +++ b/lib/components/data/summary/DataStatus.jsx @@ -0,0 +1,95 @@ + +import { + UsaceBox, + Badge, + Text, + Table, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@usace/groundwork"; + +import "../../../css/alert.css"; +import StatusRow from "./components/StatusRow"; + +function DataStatus({ + office, + pageSize, + cdaUrl, + linkPath, + tsids = [], + lookBackHours = 24, + dateFormat = "DD MMM HH:mm", + showBadges = true, + title = "Data Status", +}) { + // fetch the data status file from URL and parse it new line delimited + + + if (!tsids) { + console.error( + "Error: No data status URL or tsids provided to component " + ); + return ( + Error: No data status URL or tsids provided + ); + } + + return ( + + {showBadges && ( + <> + Data Quality Flags are shown as: + + Missing + + + Questionable + + + Unknown or Undefined + + + Passed Screening and/or Validated + + + )} + + {/* Build list of dates for column headers */} + + + Gage + Quality Info + + Latest Date-time + + + + + {tsids.map((name, idx) => { + if (name) { + return ( + + ); + } + return null; + })} + +
+
+ ); +} + +export default DataStatus; +export { DataStatus }; diff --git a/lib/components/data/summary/components/StatusRow.jsx b/lib/components/data/summary/components/StatusRow.jsx new file mode 100644 index 0000000..cdeafc0 --- /dev/null +++ b/lib/components/data/summary/components/StatusRow.jsx @@ -0,0 +1,168 @@ +import { + Badge, + TextLink, + TableRow, + TableCell, + Skeleton, +} from "@usace/groundwork"; +import dayjs from "dayjs"; +import { useCdaConfig } from "../../helpers/cda"; +import { TimeSeriesApi } from "cwmsjs"; +import { useQuery } from "@tanstack/react-query"; +import getQualityStr from "../../utilities/qualityDecoder"; +import { useLayoutEffect, useRef, useState } from "react"; + +export default function StatusRow({ + office, + linkPath, + name, + pageSize, + lookBackHours, + cdaUrl, + dateFormat, +}) { + // append fileTsids to tsids + const config = useCdaConfig("v2", cdaUrl); + const tsApi = new TimeSeriesApi(config); + const lookBack = dayjs().subtract(lookBackHours, "hour"); + const [statusDelta, setStatusDelta] = useState(null); + + const { + data: tsData, + error: tsError, + isPending: tsPending, + } = useQuery({ + queryKey: ["dataStatusTS", name, dayjs().format("YYYY-MM-DDTHH:mm:ss")], + queryFn: async () => { + let returnData = { name: null, values: [null, [null]] }; + return tsApi + .getTimeSeries({ + office, + name: name, + begin: dayjs(lookBack).format("YYYY-MM-DDTHH:mm:ssZZ"), + end: dayjs().format("YYYY-MM-DDTHH:mm:ssZZ"), + pageSize, + }) + .then((data) => { + returnData = { name: data?.name, values: [null, [null]] }; + if (data?.name && data?.values) { + if (data.values.length > 0) { + returnData.values = data?.values + } + } + returnData.values = data?.values; + return returnData; + }) + .catch((e) => { + return { ...returnData, response: e.response }; + }); + }, + refetchOnWindowFocus: false, + retry: false, + enabled: name != null && office != null, + }); + const cellRef = useRef(null); + + useLayoutEffect(() => { + if (tsData?.values) + setStatusDelta( + Math.round( + cellRef.current?.getBoundingClientRect()?.width / + tsData?.values.length + ) + ); + }, [tsPending]); + if (!office) { + return ( + + + Error + + No office provided + + ); + } + if (tsError || tsData?.response) { + const STATUS_CODE = tsData?.response?.status; + return ( + + + = 500 ? "red" : "yellow"}> + Error {STATUS_CODE} + + + +
+ {tsError?.message || tsData?.message || "CWMS Data API Unreachable"} +
+
+
+ ); + } + return ( + + {tsPending ? ( + + + + ) : ( + <> + + {linkPath ? ( + + {`${name.split(".")[0]} ${name.split(".")[1]}`} + + ) : ( + `${name.split(".")[0]} ${name.split(".")[1]}` + )} + + + + {tsData?.values + ? tsData?.values.map((val, idx) => { + return ( + { + // Setup a tooltip to show date - value when a user hovers with a visible line to show which value is highlighted + const tooltip = document.createElement("div"); + tooltip.className = "tooltip"; + tooltip.innerText = e.target.dataset.tooltip; + tooltip.style.position = "absolute"; + tooltip.style.top = `${e.clientY + 4}px`; + tooltip.style.left = `${e.clientX}px`; + document.body.appendChild(tooltip); + e.target.style.transform = "translateY(-4px)"; + }} + onMouseLeave={(e) => { + document.querySelector(".tooltip")?.remove(); + e.target.style.transform = "translateY(0)"; + }} + key={`line-${name}-${idx}`} + x1={Math.round(statusDelta * idx)} + y1="0" + x2={Math.round(statusDelta * idx)} + y2="10" + strokeWidth={statusDelta + "px"} + className={`alert-${getQualityStr(val)?.toLowerCase()}`} + data-tooltip={dayjs(val[0]).format(dateFormat) + " " + val[1]?.toFixed(2)} + /> + ); + }) + : null} + + + + {tsData?.values?.[0] + ? dayjs(tsData.values[tsData.values.length - 1][0]).format(dateFormat) + : "Missing"} + + + )} + + ); +} diff --git a/lib/components/data/utilities/qualityDecoder.tsx b/lib/components/data/utilities/qualityDecoder.tsx new file mode 100644 index 0000000..7adf743 --- /dev/null +++ b/lib/components/data/utilities/qualityDecoder.tsx @@ -0,0 +1,45 @@ +// Author: @stephenkissock + +const QUALITY_CODES: { [key: number]: string } = { + 0: "UNKNOWN", + 1: "UNKNOWN", + 3: "OKAY", + 5: "MISSING", + 9: "QUESTIONABLE", + 17: "REJECTED", + 33: "UNKNOWN", + 35: "OKAY", + 37: "MISSING", + 41: "QUESTIONABLE", + 49: "REJECTED", + 65: "UNKNOWN", + 67: "OKAY", + 69: "MISSING", + 73: "QUESTIONABLE", + 81: "REJECTED", + 97: "UNKNOWN", + 99: "OKAY", + 101: "MISSING", + 105: "QUESTIONABLE", + 113: "REJECTED", + 2433: "UNKNOWN", + 2435: "OKAY", + 2437: "MISSING", + 2441: "QUESTIONABLE", + 2449: "REJECTED", + 2465: "UNKNOWN", + 2467: "OKAY", + 2469: "MISSING", + 2473: "QUESTIONABLE", + 2481: "REJECTED", + 2497: "UNKNOWN", +} + +function getQualityStr(ts_value: (number | null)[]) { + if (ts_value?.[1] === null) return QUALITY_CODES[37] + return QUALITY_CODES?.[ts_value?.[2] ?? 0] +} + +export default getQualityStr +export { getQualityStr } +export type { QUALITY_CODES } \ No newline at end of file diff --git a/lib/css/alert.css b/lib/css/alert.css new file mode 100644 index 0000000..f0f9c0e --- /dev/null +++ b/lib/css/alert.css @@ -0,0 +1,29 @@ +/* ----------------- */ +/* DCP Status Colors */ +/* ----------------- */ + +.alert-okay { + stroke: green; + background-color: #7fbf7f; +} + +.alert-missing { + stroke: black; + background-color: #7f7f7f; + color: white; +} + +.alert-questionable { + stroke: yellow; + background-color: #ffff7f; +} + +.alert-unknown { + stroke: magenta; + background-color: #ff7fff; +} + +.alert-undefined { + stroke: magenta; + background-color: #ff7fff; +} diff --git a/lib/index.jsx b/lib/index.jsx index f843295..f7dbf39 100644 --- a/lib/index.jsx +++ b/lib/index.jsx @@ -18,21 +18,25 @@ import useCdaTimeSeries from "./components/data/hooks/useCdaTimeSeries"; import useCdaTimeSeriesGroup from "./components/data/hooks/useCdaTimeSeriesGroup"; import useNwpsGauge from "./components/data/hooks/useNwpsGauge"; import useNwpsGaugeData from "./components/data/hooks/useNwpsGaugeData"; +import DataStatus from "./components/data/summary/DataStatus"; +import useDataStatusFile from "./components/data/hooks/useDataStatusFile"; // import { helperFunction } from './utils/helpers'; export { - TSTable, - CWMSTable, - GageMap, - CWMSPlot, - CdaLatestValueCard, - CdaUrlProvider, - useCdaCatalog, - useCdaLatestValue, - useCdaLocation, - useCdaTimeSeries, - useCdaTimeSeriesGroup, - useNwpsGauge, - useNwpsGaugeData, + TSTable, + CWMSTable, + GageMap, + CWMSPlot, + CdaLatestValueCard, + CdaUrlProvider, + DataStatus, + useDataStatusFile, + useCdaCatalog, + useCdaLatestValue, + useCdaLocation, + useCdaTimeSeries, + useCdaTimeSeriesGroup, + useNwpsGauge, + useNwpsGaugeData, }; // export { helperFunction };