import React, { useEffect, useMemo, useState } from 'react'; import { makeStyles, } from "@mui/styles"; import { Typography, ToggleButton, ToggleButtonGroup, Select, MenuItem, FormControl, InputLabel, Box, Autocomplete, TextField, Tooltip, IconButton, } from '@mui/material'; import { BarChart, BarSeries, Bar, GridlineSeries, Gridline, TooltipArea, ChartTooltip, LinearYAxis, LinearYAxisTickSeries, LinearYAxisTickLabel } from 'reaviz'; import theme from '../theme.jsx'; import {green, yellow, red} from "../views/AngularWorkflow.jsx" import { OpenInNew as OpenInNewIcon, } from '@mui/icons-material'; // Compact number formatter for axis ticks (e.g. 12,000 -> 12k, 12,000,000 -> 12M) function formatCompactNumber(value) { const n = Number(value) || 0; const abs = Math.abs(n); if (abs >= 1e9) return `${Math.round((n / 1e9) * 10) / 10}B`; if (abs >= 1e6) return `${Math.round((n / 1e6) * 10) / 10}M`; if (abs >= 1e3) return `${Math.round((n / 1e3) * 10) / 10}k`; return `${n}`; } // Helpers to keep date and "today" logic concise and consistent function toYMD(date) { const d = new Date(date); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } function entriesContainDateYMD(entries, date) { const target = toYMD(date); return Array.isArray(entries) && entries.some((e) => toYMD(e.date) === target); } function computeTodayValueForAll(key, data) { if (key === 'app_executions') { return Number(data?.daily_app_executions ?? 0) + Number(data?.daily_child_app_executions ?? 0); } if (key === 'workflow_executions') { const parent = Number(data?.daily_workflow_executions ?? 0); const parentFinished = Number(data?.daily_workflow_executions_finished ?? 0); const parentFailed = Number(data?.daily_workflow_executions_failed ?? 0); const childFinished = Number(data?.daily_child_workflow_executions_finished ?? 0); const childFailed = Number(data?.daily_child_workflow_executions_failed ?? 0); const parentVal = parent > 0 ? parent : (parentFinished + parentFailed); return parentVal + childFinished + childFailed; } return 0; } function computeTodayValueForOrg(key, orgStats) { if (key === 'workflow_executions') { const total = orgStats?.daily_workflow_executions; const finished = Number(orgStats?.daily_workflow_executions_finished ?? 0); const failed = Number(orgStats?.daily_workflow_executions_failed ?? 0); return total !== undefined ? Number(total || 0) : (finished + failed); } if (key === 'app_executions') { return Number(orgStats?.daily_app_executions ?? 0); } return 0; } const RunsOverTimeWidget = (props) => { const { globalUrl, onLoadingChange, monthOverride, dummyMode, selectedOrganization, selectedOrgForStats, orgStats, orgForLimit, loadingSelectedOrgStats } = props; const [mode, setMode] = useState('workflows'); // 'apps' | 'workflows' const [selectedStatType, setSelectedStatType] = useState({ "value": 'total_workflow_executions', "label": 'Workflows', "amount": 0, }) const [series, setSeries] = useState([]); const [days, setDays] = useState(365); // aggregate to last 12 months by default const [selectedMonth, setSelectedMonth] = useState(null); // Date representing first day of target month, or null for yearly view const [statTypeOptions, setStatTypeOptions] = useState([]) const [loading, setLoading] = useState(false); const useStyles = makeStyles({ notchedOutline: { borderColor: "#FF8544 !important", }, root: { "& .MuiAutocomplete-listbox": { border: "2px solid #FF8544", color: theme.palette.text.primary, fontSize: 18, "& li:nth-child(even)": { backgroundColor: "#CCC", }, "& li:nth-child(odd)": { backgroundColor: "#FFF", }, }, }, inputRoot: { color: theme.palette.text.primary, "&:hover .MuiOutlinedInput-notchedOutline": { borderColor: "#f86a3e", }, }, }); const classes = useStyles(); // Helper: fetch time series for a specific statistics key const fetchSeriesForKey = async (key) => { if (statTypeOptions?.length < 3) { var foundkeys = {} for (var key in orgStats) { if (!key.startsWith("total_")) { continue } // Remove total foundkeys[key] = orgStats[key] } for (var foundKey in orgStats?.daily_statistics) { const dailyStats = orgStats?.daily_statistics[foundKey] for (var additionKey in dailyStats?.additions) { const addition = dailyStats?.additions[additionKey]; if (foundkeys[addition?.key] === undefined) { foundkeys[addition?.key] = addition?.value } else { foundkeys[addition?.key] += addition?.value } } } var newarray = [] for (var key in foundkeys) { const foundkey = key var parsedname = key if (parsedname?.startsWith("total_")) { parsedname = parsedname.replace("total_", "") } if (parsedname?.startsWith("categorylabel")) { parsedname = parsedname.replace("categorylabel", "") } parsedname = (parsedname.charAt(0).toUpperCase() + parsedname.substring(1)).replaceAll("_", " ") newarray.push({ "value": key, "label": parsedname, "amount": foundkeys[key], }) } if (newarray.length > 0) { setStatTypeOptions(newarray) } } try { // If specific org is selected and pre-fetched stats are available, use them directly if (selectedOrgForStats && selectedOrgForStats !== 'ALL' && orgStats && Array.isArray(orgStats?.daily_statistics)) { const dailyStats = orgStats.daily_statistics; const processedEntries = []; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); if (key.startsWith("total_")) { key = key.replace("total_", "") } for (const day of dailyStats) { if (!day?.date) continue; const dayDate = new Date(day.date); if (dayDate < cutoff) continue; let value = 0; if (day[key] === undefined) { for (var additionKey in day?.additions) { const addition = day?.additions[additionKey]; if (addition?.key === key) { value += Number(addition?.value || 0); break } } } else { value = Number(day[key] || 0); } processedEntries.push({ date: day.date, value }); } // Append today's values if not present try { if (!entriesContainDateYMD(processedEntries, new Date())) { const todayVal = computeTodayValueForOrg(key, orgStats); if (!Number.isNaN(todayVal)) { processedEntries.push({ date: new Date().toISOString(), value: todayVal }); } } } catch {} return processedEntries; } // If selectedOrgForStats is 'ALL', use parent org's full stats endpoint and combine app_executions + child_app_executions if (selectedOrgForStats === 'ALL' && selectedOrganization?.id) { // Use the full stats endpoint to get daily_statistics const url = `${globalUrl}/api/v1/orgs/${encodeURIComponent(selectedOrganization.id)}/stats`; const r = await fetch(url, { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' } }); if (r.ok) { const data = await r.json(); const dailyStats = Array.isArray(data?.daily_statistics) ? data.daily_statistics : []; // Process each day's data, combining parent + child org runs const processedEntries = []; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); for (const day of dailyStats) { if (day?.date) { const dayDate = new Date(day.date); if (dayDate < cutoff) continue; // Filter by days let value = day?.[key] || 0; // Add child org executions based on the key if (key === 'app_executions' && day?.child_app_executions !== undefined) { value += (day.child_app_executions || 0); } else if (key === 'workflow_executions') { // For workflow_executions, sum both child_workflow_executions_finished and child_workflow_executions_failed const childFinished = day?.child_workflow_executions_finished || 0; const childFailed = day?.child_workflow_executions_failed || 0; value += (childFinished + childFailed); } processedEntries.push({ date: day.date, value }); } } // Append today's datapoint for ALL by combining parent + child daily_* counters try { if (!entriesContainDateYMD(processedEntries, new Date())) { const todayVal = computeTodayValueForAll(key, data); if (!Number.isNaN(todayVal)) { processedEntries.push({ date: new Date().toISOString(), value: todayVal }); } } } catch {} return processedEntries; } } // // If a specific org is selected // if (selectedOrgForStats && selectedOrgForStats !== 'ALL') { // const url = `${globalUrl}/api/v1/orgs/${encodeURIComponent(selectedOrgForStats)}/stats/${encodeURIComponent(key)}?days=${encodeURIComponent(days)}`; // const r = await fetch(url, { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' } }); // if (r.ok) { // const j = await r.json(); // return Array.isArray(j?.entries) ? j.entries : []; // } // } // Fallback to global stats const urlA = `${globalUrl}/api/v1/stats/${encodeURIComponent(key)}?days=${encodeURIComponent(days)}`; const doFetch = async (u) => { const r = await fetch(u, { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' } }); if (!r.ok) return []; const j = await r.json(); return Array.isArray(j?.entries) ? j.entries : []; }; const a = await doFetch(urlA); if (a.length > 0) return a; // // Final fallback: old aggregate endpoint returning daily_statistics // const fallback = await fetch(`${globalUrl}/api/v1/stats`, { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json' } }); // if (fallback.ok) { // const data = await fallback.json(); // const daily = Array.isArray(data?.daily_statistics) ? data.daily_statistics : []; // const valField = key; // return daily.map((d) => ({ date: d?.date, value: Number(d?.[valField] || 0) })); // } // return []; } catch (e) { return []; } }; // Load and transform into monthly aggregation for last 12 months const loadStats = async (curMode, inputType) => { setLoading(true); try { // Clear current series immediately to avoid any visual overlap while switching views setSeries([]); if (dummyMode) { // Bring back the older dummy series with emphasis on earlier months const now = new Date(); const months = []; for (let i = 11; i >= 0; i--) { const dt = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push(new Date(dt.getFullYear(), dt.getMonth(), 1)); } const base = [20, 18, 22, 24, 23, 21, 15, 12, 9, 15, 24, 20]; const dummy = months.map((m, idx) => ({ key: m, id: `${m.getFullYear()}-${m.getMonth()}`, data: base[idx] })); setSeries(dummy); return; } const key = inputType !== undefined ? inputType : selectedStatType?.value?.length > 0 ? selectedStatType?.value : curMode === 'apps' ? 'app_executions' : 'workflow_executions'; const entries = await fetchSeriesForKey(key); // Normalize variants: {Date, Value} or {date, value} const normalized = (entries || []).map((d) => ({ date: d?.Date ? new Date(d.Date) : (d?.date ? new Date(d.date) : new Date()), value: Number(d?.Value ?? d?.value ?? 0), })); // If a month is selected, show DAILY bars for that month if (selectedMonth instanceof Date) { const year = selectedMonth.getFullYear(); const month = selectedMonth.getMonth(); // Build all days for selected month const firstDay = new Date(year, month, 1); const nextMonthFirst = new Date(year, month + 1, 1); const numDays = Math.round((nextMonthFirst - firstDay) / (1000 * 60 * 60 * 24)); // Sum values per day (normalize time to midnight) const byDayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; const sumPerDay = new Map(); normalized.forEach((p) => { if (!p?.date || Number.isNaN(p.value)) return; const d = p.date; if (d.getFullYear() !== year || d.getMonth() !== month) return; const dayKey = byDayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate())); sumPerDay.set(dayKey, (sumPerDay.get(dayKey) || 0) + p.value); }); const dailySeries = Array.from({ length: numDays }, (_, i) => { const d = new Date(year, month, i + 1); const k = byDayKey(d); const v = sumPerDay.get(k) || 0; return { key: d, id: k, data: v }; }); // Ensure consistent ordering setSeries(dailySeries.sort((a, b) => a.key - b.key)); return; } // Otherwise, show MONTHLY aggregation for last 12 months including current month const now = new Date(); const months = []; for (let i = 11; i >= 0; i--) { const dt = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push({ y: dt.getFullYear(), m: dt.getMonth(), key: new Date(dt.getFullYear(), dt.getMonth(), 1) }); } const byMonthKey = (d) => `${d.getFullYear()}-${d.getMonth()}`; const sumPerMonth = new Map(); normalized.forEach((p) => { if (!p?.date || Number.isNaN(p.value)) return; const k = byMonthKey(new Date(p.date.getFullYear(), p.date.getMonth(), 1)); sumPerMonth.set(k, (sumPerMonth.get(k) || 0) + p.value); }); const monthlySeries = months.map((mm) => { const k = `${mm.y}-${mm.m}`; const v = sumPerMonth.get(k) || 0; return { key: mm.key, id: k, data: v }; }); setSeries(monthlySeries); } finally { setLoading(false); } }; useEffect(() => { setTimeout(() => { const starterWidgetStatType = localStorage.getItem("runsOverTimeWidgetStatType") if (starterWidgetStatType) { setSelectedStatType({ "value": starterWidgetStatType, "label": (starterWidgetStatType.charAt(0).toUpperCase() + starterWidgetStatType.substring(1)).replaceAll("_", " "), "amount": 0, }) loadStats(mode, starterWidgetStatType) } }, 1500) }, []) // Apply month override (e.g. onboarding Explore Now) - consolidated with main load effect useEffect(() => { if (monthOverride instanceof Date) { // Clear series immediately to prevent visual overlap setSeries([]); setSelectedMonth(new Date(monthOverride.getFullYear(), monthOverride.getMonth(), 1)); setDays(370); } }, [monthOverride]); useEffect(() => { if (loadingSelectedOrgStats === true) { setSeries([]); return; } loadStats(mode); }, [selectedStatType, mode, globalUrl, days, selectedMonth, dummyMode, selectedOrgForStats, loadingSelectedOrgStats]); // Notify parent on loading changes useEffect(() => { if (typeof onLoadingChange === 'function') { onLoadingChange(loading); } }, [loading, onLoadingChange]); const barData = useMemo(() => ( (Array.isArray(series) ? series : []).map((d) => { const dt = new Date(d.key); const label = selectedMonth instanceof Date ? `${dt.toLocaleString('default', { month: 'short' })} ${dt.getDate()}` // e.g., 'Oct 1' : dt.toLocaleString('default', { month: 'short' }); return { key: label, data: Number(d?.data || 0) }; }) ), [series, mode, selectedMonth]); // Build month dropdown options for last 12 months const monthOptions = useMemo(() => { const now = new Date(); const opts = []; for (let i = 0; i < 12; i++) { const dt = new Date(now.getFullYear(), now.getMonth() - i, 1); opts.push(dt); } return opts; }, []); const tooltip = (
{data?.x ?? ''} {data?.y ?? ''}
)} /> } />; const barColorscheme = [ "#f85a3e", // anchor orange "#ff7a57", // brighter, more playful "#e14b2e", // slightly darker + redder "#ff9b6b", // soft peachy highlight "#c83f24", // deep burnt orange ] return (
{selectedStatType?.label} ({selectedStatType?.amount}) option.id === value.id} getOptionLabel={(option) => { if ( option === undefined || option === null || option.label === undefined || option.label === null ) { if (option.value !== undefined && option.value !== null) { return option.value } else { return option } } const newname = ( option.label.charAt(0).toUpperCase() + option.label.substring(1) ).replaceAll("_", " "); return newname; }} options={statTypeOptions} fullWidth style={{ backgroundColor: theme.palette?.inputColor, borderRadius: theme.palette?.borderRadius, }} onChange={(event, newValue) => { console.log("CHANGE: ", newValue) }} renderOption={(props, data, state) => { // Format to thousand or million const formattedamount = formatCompactNumber(data?.amount) const numbercolor = data?.amount >= 1000000 ? red : (data?.amount >= 100000 ? yellow : green) return ( { setSelectedStatType(data) // Set local storage for the key if (data?.value) { localStorage.setItem("runsOverTimeWidgetStatType", data?.value) } }} > {formattedamount} {data?.label} ) }} renderInput={(params) => { return (
) }} />
View Month
{loadingSelectedOrgStats === true ? (
Loading…
) : ( } />} gridlines={} />} yAxis={ formatCompactNumber(d)} /> } /> } /> } animated={false} /> )} {/* Custom reference line overlays for app execution limits (suborg and parent) */} {mode === 'apps' && barData.length > 0 && (() => { // Determine limits const parentAppExecutionLimit = selectedOrganization?.sync_features?.app_executions?.limit ? Number(selectedOrganization.sync_features.app_executions.limit) : null; const suborgAppExecutionLimit = orgForLimit?.sync_features?.app_executions?.limit ? Number(orgForLimit.sync_features.app_executions.limit) : null; const maxValue = Math.max(...barData.map(d => Number(d.data) || 0), 0); if (maxValue <= 0) return null; const chartTopPadding = 1; const chartBottomPadding = 8; const usableHeight = 100 - chartTopPadding - chartBottomPadding; // Helpers to compute Y position in percentage const posFor = (limit) => { const perc = Math.max(0, Math.min(1, (Number(limit) || 0) / maxValue)); return chartTopPadding + ((1 - perc) * usableHeight); }; let showParentLimit = false; let showSuborgLimit = false; // Suborg/current limit: only when viewing a specific org (not ALL) if (selectedOrgForStats !== 'ALL' && typeof suborgAppExecutionLimit === 'number' && suborgAppExecutionLimit > 0 && suborgAppExecutionLimit <= maxValue && selectedOrgForStats === orgForLimit?.id) { showSuborgLimit = true; } if (typeof parentAppExecutionLimit === 'number' && parentAppExecutionLimit > 0 && parentAppExecutionLimit <= maxValue) { if (selectedOrgForStats === 'ALL') { showParentLimit = true; } else if (parentAppExecutionLimit !== suborgAppExecutionLimit) { showParentLimit = true; } } if (!showParentLimit && !showSuborgLimit) return null; const suborgTop = showSuborgLimit ? posFor(suborgAppExecutionLimit) : null; const parentTop = showParentLimit ? posFor(parentAppExecutionLimit) : null; return ( <> {showSuborgLimit && ( <>
{formatCompactNumber(suborgAppExecutionLimit)} limit
)} {showParentLimit && ( <>
{formatCompactNumber(parentAppExecutionLimit)} limit
)} ); })()}
); }; export default RunsOverTimeWidget;