From 18ba7d4440bcbb6077db0ef73ae22c935a99f2d8 Mon Sep 17 00:00:00 2001 From: Frikky Date: Mon, 20 Oct 2025 12:15:32 +0200 Subject: [PATCH] New dashboard relation added --- frontend/src/App.jsx | 16 + .../src/components/RunsOverTimeWidget.jsx | 326 ++++++ .../components/SuccessFailedRunsWidget.jsx | 1000 +++++++++++++++++ 3 files changed, 1342 insertions(+) create mode 100644 frontend/src/components/RunsOverTimeWidget.jsx create mode 100644 frontend/src/components/SuccessFailedRunsWidget.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e3c6e975..18083aea 100755 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,6 +25,7 @@ import AgentUI from "./views/AgentUI.jsx"; import Welcome from "./views/Welcome.jsx"; import Dashboard from "./views/Dashboard.jsx"; import DashboardView from "./views/DashboardViews.jsx"; +import NewDashboard from "./views/NewDashboard.jsx"; import AdminSetup from "./views/AdminSetup.jsx"; import Admin from "./views/Admin.jsx"; import Docs from "./views/Docs.jsx"; @@ -872,6 +873,21 @@ const App = (message, props) => { /> } /> + + + } + /> + 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}`; +} + +const RunsOverTimeWidget = (props) => { + const { globalUrl, onLoadingChange, monthOverride, dummyMode } = props; + const [mode, setMode] = useState('workflows'); // 'apps' | 'workflows' + 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 [loading, setLoading] = useState(false); + + // Helper: fetch time series for a specific statistics key + const fetchSeriesForKey = async (key) => { + try { + 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; + + // Optional org route fallback if present globally + const orgId = (window && window.selectedOrganization && window.selectedOrganization.id) || null; + if (orgId) { + const urlB = `${globalUrl}/api/v1/orgs/${encodeURIComponent(orgId)}/stats/${encodeURIComponent(key)}?days=${encodeURIComponent(days)}`; + const b = await doFetch(urlB); + if (b.length > 0) return b; + } + + // 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 load = async (curMode) => { + 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 = 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); + } + }; + + // 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(() => { + load(mode); + }, [mode, globalUrl, days, selectedMonth, dummyMode]); + + // 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 + ? String(dt.getDate()) // day of month for daily view + : 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 ?? ''} +
+ )} + /> + } + />; + + return ( +
+
+ Runs over time ({mode === "workflows" ? "Workflows" : "Apps"}) + + v && setMode(v)} + sx={{ + height: 37, + backgroundColor: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(255,255,255,0.22)', + borderRadius: '30px', + padding: '2px', + "& .MuiToggleButton-root": { + border: "none", + borderRadius: "30px", + color: "#fff", + padding: "6px 16px", + textTransform: "none", + fontSize: "14px", + "&.Mui-selected": { + backgroundColor: "#fff", + color: "#222", + fontWeight: "600", + "&:hover": { + backgroundColor: "#fff", + }, + }, + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.2)", + }, + }, + }} + > + + + Workflows + + + + + Apps + + + + + View Month + + + +
+ +
+
+ } />} + gridlines={} />} + yAxis={ + formatCompactNumber(d)} /> + } + /> + } + /> + } + animated={false} + /> +
+
+
+ ); +}; + +export default RunsOverTimeWidget; + + diff --git a/frontend/src/components/SuccessFailedRunsWidget.jsx b/frontend/src/components/SuccessFailedRunsWidget.jsx new file mode 100644 index 00000000..e9a3bd09 --- /dev/null +++ b/frontend/src/components/SuccessFailedRunsWidget.jsx @@ -0,0 +1,1000 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + ToggleButton, + ToggleButtonGroup, + Box, +} from "@mui/material"; +import { + AreaChart, + AreaSeries, + Area, + GridlineSeries, + Gridline, + ChartTooltip, + TooltipArea, + LinearXAxis, + LinearXAxisTickSeries, + LinearXAxisTickLabel, + LinearYAxis, + LinearYAxisTickSeries, + LinearYAxisTickLabel, +} from "reaviz"; +import theme from "../theme"; + +// KPI configuration constants +const RUN_MINUTES_SAVED_PER_WORKFLOW = 15; // minutes saved per workflow run +const RUN_DOLLARS_SAVED_PER_WORKFLOW = 25; // dollars saved per workflow run + +// Filters for status selection +const statusOptions = [ + { key: "ALL", label: "All" }, + { key: "FINISHED", label: "Success" }, + { key: "FAILED", label: "Failed" }, +]; + +// Response Object (Coming from backend) +// { +// "key": "app_executions", +// "value": 70, +// "date": "2025-10-15T19:36:03.928872+05:30" +// } + +// Date formatting helpers +// This is used to format the date in the format of YYYY-MM-DD +function formatDay(dateInput) { + try { + return new Date(dateInput).toISOString().slice(0, 10); + } catch { + return String(dateInput); + } +} + +// This is used to format the date in the format of YYYY-MM +function formatMonth(dateInput) { + const dt = new Date(dateInput); + const month = String(dt.getMonth() + 1).padStart(2, "0"); + return `${dt.getFullYear()}-${month}`; +} + +// Aggregate values by day or by month +// This is used for area chart toggle button (Daily / Monthly) +function bucketSeries(items, resolution) { + const map = new Map(); + for (const item of items) { + const key = + resolution === "monthly" ? formatMonth(item.key) : formatDay(item.key); + const value = Number(item.data || 0); + map.set(key, (map.get(key) || 0) + value); + } + return map; +} + +// Normalize API entries to a consistent structure +// for e.g, {date: "2025-10-15T19:36:03.928872+05:30", value: 70} +// will be normalized to {key: "2025-10-15", id: "2025-10-15", data: 70} +function normalizeEntries(arr) { + return (arr || []).map((d) => ({ + key: d?.date ? new Date(d.date) : new Date(), + id: d?.date || Math.random().toString(36).slice(2), + data: Number(d?.value ?? 0), + })); +} + +// Build continuous key sequence from start to end, aligned by resolution (Daily / Monthly) +function buildBackfilledKeys(allKeys, resolution) { + if (allKeys.length === 0) return allKeys; + + const start = new Date(allKeys[0]); + let end = new Date(allKeys[allKeys.length - 1]); + const today = new Date(); + + if (resolution === "monthly") { + const monthToday = new Date(today.getFullYear(), today.getMonth(), 1); + if (monthToday > end) end = monthToday; + } else { + // To ensure that the last day is included in the series + const dayToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + if (dayToday > end) end = dayToday; + } + + const addKey = (dt) => + resolution === "monthly" ? formatMonth(dt) : formatDay(dt); + const step = (dt) => { + if (resolution === "monthly") { + dt.setMonth(dt.getMonth() + 1); + dt.setDate(1); + } else { + dt.setDate(dt.getDate() + 1); + } + }; + + const sequence = []; + const cursor = new Date(start); + if (resolution === "monthly") cursor.setDate(1); + while (cursor <= end) { + sequence.push(addKey(cursor)); + step(cursor); + } + return sequence; +} + +// Ensure area series has at least two points +function ensureMinTwoPoints(arr) { + if (arr.length === 1) { + return [ + { key: 0, data: arr[0].data }, + { key: 1, data: arr[0].data }, + ]; + } + return arr; +} + +// Compact number formatter for axis ticks (e.g. 12,000 -> 12k, 12000000 -> 12M, 12000000000 -> 12B) +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}`; +} + +// Compute X axis ticks and label formatter +function computeTicks(allKeys, days, resolution) { + const maxTicks = 12; + const xInterval = Math.max(1, Math.floor(allKeys.length / maxTicks)); + let tickValues = Array.from( + { length: Math.ceil(allKeys.length / xInterval) }, + (_, i) => i * xInterval + ); + const lastIdx = allKeys.length - 1; + if (lastIdx >= 0 && tickValues[tickValues.length - 1] !== lastIdx) { + tickValues = [...tickValues, lastIdx]; + } + + // Format the label for the x axis + // if resolution is monthly, it will return the date in the format of YYYY-MM + // if resolution is daily, it will return the date in the format of YYYY-MM-DD + // if days is greater than 90, it will return the date in the format of YYYY-MM-DD + // else it will return the date in the format of MM-DD + const formatLabel = (idx) => { + const key = allKeys[idx]; + if (!key) return ""; + if (resolution === "monthly") return key; + if (days > 90) return key; + const parts = key.split("-"); + if (parts.length >= 3) return `${parts[1]}-${parts[2]}`; + return key; + }; + + return { tickValues, formatLabel }; +} + +// Compute upper bound for Y axis with padding +// Just to ensure that the area chart is not touching the top of the chart +function computePaddedMax(okArr, failArr) { + const rawMaxOk = okArr.reduce((m, p) => Math.max(m, Number(p?.data || 0)), 0); + const rawMaxFail = failArr.reduce( + (m, p) => Math.max(m, Number(p?.data || 0)), + 0 + ); + return Math.max(1, Math.ceil(Math.max(rawMaxOk, rawMaxFail) * 1.1 + 1)); +} + +// Build grouped series and matching color scheme based on filter selection +// This is used to build the grouped series and matching color scheme based on filter selection (Success / Failed / All) +function buildGroupedSeries(selectedStatus, okArr, failArr) { + let grouped = []; + let scheme = []; + + if (selectedStatus === "ALL") { + if (failArr.length > okArr.length) { + grouped = [ + { key: "Successful Runs", data: okArr }, + { key: "Failed Runs", data: failArr }, + ]; + scheme = ["#ef4444", "#22c55e"]; + } else { + grouped = [ + { key: "Failed Runs", data: failArr }, + { key: "Successful Runs", data: okArr }, + ]; + scheme = ["#22c55e", "#ef4444"]; + } + } else if (selectedStatus === "FAILED") { + grouped = [{ key: "Failed Runs", data: failArr }]; + scheme = ["#ef4444"]; + } else { + grouped = [{ key: "Successful Runs", data: okArr }]; + scheme = ["#22c55e"]; + } + + return { grouped, scheme }; +} + +const SuccessFailedRunsWidget = (props) => { + const { globalUrl, workflows, onControlsChange, onLoadingChange, onTotalsChange, overrideDays, dummyMode } = props; + + const [mode, setMode] = useState("workflows"); // 'workflows' | 'apps' + const [days, setDays] = useState(30); + const daysOptions = [5, 10, 15, 30, 60, 90, 180, 230, 365]; + + const [resolution, setResolution] = useState("daily"); // 'daily' | 'monthly' + const [selectedWorkflow, setSelectedWorkflow] = useState("ALL"); + const [selectedStatus, setSelectedStatus] = useState("ALL"); + const [seriesOk, setSeriesOk] = useState([]); + const [seriesFail, setSeriesFail] = useState([]); + const [loading, setLoading] = useState(false); + const [wfTotals, setWfTotals] = useState({ ok: 0, fail: 0, activeDays: 0 }); + + useEffect(() => { + try { + if (typeof onTotalsChange !== "function") return; + const totalOk = Math.max(0, Number(wfTotals.ok) || 0); + const totalFail = Math.max(0, Number(wfTotals.fail) || 0); + const totalRuns = totalOk + totalFail; + const activeDays = Math.max(0, Number(wfTotals.activeDays) || 0); + const timeSavedMinutes = totalRuns * RUN_MINUTES_SAVED_PER_WORKFLOW; + const moneySavedDollars = totalRuns * RUN_DOLLARS_SAVED_PER_WORKFLOW; + // Do not trigger parent updates when switching mode to avoid page blink + onTotalsChange({ days, totalRuns, successRuns: totalOk, failedRuns: totalFail, activeDays, timeSavedMinutes, moneySavedDollars }); + } catch { + onTotalsChange({ days, totalRuns: 0, successRuns: 0, failedRuns: 0, activeDays: 0, timeSavedMinutes: 0, moneySavedDollars: 0 }); + } + }, [wfTotals, days, onTotalsChange]); + + const workflowItems = useMemo(() => { + const base = [{ id: "ALL", name: "All Workflows" }]; + if (!Array.isArray(workflows)) return base; + return base.concat( + workflows + .filter((w) => w?.id && w?.name) + .map((w) => ({ id: w.id, name: w.name })) + ); + }, [workflows]); + + const fetchSeriesForKey = async (key) => { + try { + + const urlA = `${globalUrl}/api/v1/stats/${encodeURIComponent(key)}?days=${encodeURIComponent(days)}`; + const doFetch = async (u) => { + const r = await fetch(u, { method: "GET", credentials: "include" }); + 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; + + // Optional orgId fallback if exposed globally in app + // const orgId = (window && window.selectedOrganization && window.selectedOrganization.id) || null; + // if (orgId) { + // const urlB = `${globalUrl}/api/v1/orgs/${encodeURIComponent(orgId)}/stats/${encodeURIComponent(key)}?days=${encodeURIComponent(days)}`; + // const b = await doFetch(urlB); + // if (b.length > 0) return b; + // } + // return []; + } catch (e) { + return []; + } + }; + + const fetchSeries = async () => { + setLoading(true); + try { + if (dummyMode) { + // 10-day wave with a couple of bumps for a more dynamic preview + const today = new Date(); + const mk = (n, v) => ({ key: new Date(today.getFullYear(), today.getMonth(), today.getDate() - n), id: `${n}`, data: v }); + + // Success shows two bumps (days -8..-6 and -2..0) + const okVals = [7, 4, 9, 4, 6, 9, 7, 5, 8, 6]; // oldest -> newest + const failVals = [1, 0, 1, 2, 1, 1, 0, 1, 2, 1]; // small, non-zero noise + + const okSeries = okVals.map((v, idx) => mk(okVals.length - 1 - idx, v)); + const failSeries = failVals.map((v, idx) => mk(failVals.length - 1 - idx, v)); + + setSeriesOk(okSeries); + setSeriesFail(failSeries); + return; + } + const successKey = + mode === "workflows" + ? "workflow_executions_finished" + : "app_executions"; + const failedKey = + mode === "workflows" + ? "workflow_executions_failed" + : "app_executions_failed"; + const [succ, fail] = await Promise.all([ + fetchSeriesForKey(successKey), + fetchSeriesForKey(failedKey), + ]); + + let okSeries = normalizeEntries(succ); + let failSeries = normalizeEntries(fail); + + // Fallback: if empty, derive from /api/v1/stats daily_statistics + if (okSeries.length === 0 && failSeries.length === 0) { + const resp = await fetch(`${globalUrl}/api/v1/stats`, { + method: "GET", + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + if (resp.ok) { + const data = await resp.json(); + const fieldOk = + mode === "workflows" + ? "workflow_executions_finished" + : "app_executions"; + const fieldFail = + mode === "workflows" + ? "workflow_executions_failed" + : "app_executions_failed"; + const list = Array.isArray(data?.daily_statistics) + ? data.daily_statistics + : []; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + okSeries = list + .filter(Boolean) + .map((d) => ({ + key: new Date(d?.date || Date.now()), + id: d?.date || Math.random().toString(36).slice(2), + data: Number(d?.[fieldOk] || 0), + })) + .filter((p) => p.key >= cutoff); + failSeries = list + .filter(Boolean) + .map((d) => ({ + key: new Date(d?.date || Date.now()), + id: `f-${d?.date || Math.random().toString(36).slice(2)}`, + data: Number(d?.[fieldFail] || 0), + })) + .filter((p) => p.key >= cutoff); + } + } + + setSeriesOk(okSeries); + setSeriesFail(failSeries); + } catch (e) { + setSeriesOk([]); + setSeriesFail([]); + } finally { + setLoading(false); + } + }; + + // Only fetch on first render and when days window or mode changes + const firstLoadRef = React.useRef(true); + useEffect(() => { + if (firstLoadRef.current) { + firstLoadRef.current = false; + fetchSeries(); + return; + } + fetchSeries(); + }, [days, globalUrl, mode]); + + // Apply external days override (e.g. after onboarding completes) + useEffect(() => { + if (typeof overrideDays === 'number' && overrideDays > 0 && overrideDays !== days) { + setDays(overrideDays); + } + }, [overrideDays]); + +// For the KPIs : Time saved and Money saved + useEffect(() => { + let aborted = false; + const run = async () => { + // Skip fetching/storing real stats while onboarding preview is shown + // if (dummyMode) { + // if (!aborted) setWfTotals({ ok: 0, fail: 0, activeDays: 0 }); + // return; + // } + try { + const totalEntries = await fetchSeriesForKey("workflow_executions"); + const series = normalizeEntries(totalEntries); + const dayKey = (d) => { + try { return new Date(d?.date || d?.key).toISOString().slice(0,10); } catch { return null; } + }; + const dayTotals = new Map(); + for (const it of series) { + const k = dayKey(it); if (!k) continue; dayTotals.set(k, (dayTotals.get(k) || 0) + (Number(it?.data)||0)); + } + const activeDays = Array.from(dayTotals.values()).filter(v => v > 0).length; + const ok = series.reduce((s, p) => s + (Number(p?.data)||0), 0); + if (!aborted) setWfTotals({ ok, fail: 0, activeDays }); + } catch { + if (!aborted) setWfTotals({ ok: 0, fail: 0, activeDays: 0 }); + } + }; + run(); + return () => { aborted = true; }; + }, [globalUrl, days, dummyMode, overrideDays]); + + // Notify parent about loading state changes + useEffect(() => { + if (typeof onLoadingChange === "function") { + onLoadingChange(loading); + } + }, [loading, onLoadingChange]); + + // Build filters UI once here; optionally render externally via onControlsChange + const controlsNode = React.useMemo(() => ( +
+ {/* + Workflow + + */} + + Filter + + + + Last + + + v && setMode(v)} + sx={{ + height: 37, + backgroundColor: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(255,255,255,0.22)', + borderRadius: '30px', + padding: '2px', + "& .MuiToggleButton-root": { + border: "none", + borderRadius: "30px", + color: "#fff", + padding: "6px 16px", + textTransform: "none", + fontSize: "14px", + "&.Mui-selected": { + backgroundColor: "#fff", + color: "#222", + fontWeight: "600", + "&:hover": { + backgroundColor: "#fff", + }, + }, + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.2)", + }, + }, + }} + > + + + Workflows + + + + + + Apps + + + + { + if (!v) return; + setResolution(v); + if (v === 'monthly' && days !== 180) { + setDays(180); + } else if (v === 'daily' && days !== 30) { + setDays(30); + } + }} + sx={{ + height: 37, + backgroundColor: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(255,255,255,0.22)', + borderRadius: '30px', + padding: '2px', + "& .MuiToggleButton-root": { + border: "none", + borderRadius: "30px", + color: "#fff", + padding: "6px 16px", + textTransform: "none", + fontSize: "14px", + "&.Mui-selected": { + backgroundColor: "#fff", + color: "#222", + fontWeight: "600", + "&:hover": { + backgroundColor: "#fff", + }, + }, + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.2)", + }, + }, + }} + > + + + Daily + + + + + + Monthly + + + +
+ ), + [selectedStatus, mode, days, resolution, workflowItems] + ); + + useEffect(() => { + if (typeof onControlsChange === "function") { + onControlsChange(controlsNode); + return () => { + onControlsChange(null); + }; + } + }, [onControlsChange, controlsNode]); + + return ( +
+ {/* Top controls row: title left, all filters on the right */} +
+ {/* Title moved inside the area chart card */} + + {!onControlsChange && ( + <> + {controlsNode} + + )} +
+ + {/* Content row: area chart (left) + ring gauges (right) in separate sub-cards */} +
+
+ Successful vs Failed Runs ({mode === "workflows" ? "Workflows" : "Apps"}) + {(() => { + // Build unified timeline by day or by month + const useOk = Array.isArray(seriesOk) ? seriesOk : []; + const useFail = Array.isArray(seriesFail) ? seriesFail : []; + + const okMap = bucketSeries(useOk, resolution); + const failMap = bucketSeries(useFail, resolution); + let allKeys = Array.from( + new Set([...okMap.keys(), ...failMap.keys()]) + ); + allKeys.sort((a, b) => new Date(a) - new Date(b)); + allKeys = buildBackfilledKeys(allKeys, resolution); + + if (allKeys.length === 0) { + return ( +
+ + No data available + +
+ ); + } + + let okArr = allKeys.map((k, i) => ({ + key: i, + data: okMap.get(k) || 0, + })); + let failArr = allKeys.map((k, i) => ({ + key: i, + data: failMap.get(k) || 0, + })); + okArr = ensureMinTwoPoints(okArr); + failArr = ensureMinTwoPoints(failArr); + + const { grouped, scheme } = buildGroupedSeries( + selectedStatus, + okArr, + failArr + ); + const { tickValues, formatLabel } = computeTicks( + allKeys, + days, + resolution + ); + const paddedMax = computePaddedMax(okArr, failArr); + + return ( + formatCompactNumber(d)} />} + /> + } + /> + } + xAxis={ + formatLabel(Number(d))} + /> + } + tickValues={tickValues} + /> + } + /> + } + gridlines={} />} + series={ + + } + colorScheme={scheme} + tooltip={ + { + const idx = Math.max(0, Number(d?.x ?? 0)); + const rows = (grouped || []).map( + (seriesItem, i) => { + const point = Array.isArray(seriesItem?.data) + ? seriesItem.data[ + Math.min( + idx, + seriesItem.data.length - 1 + ) + ] + : null; + const value = Number(point?.data || 0); + return { + label: seriesItem?.key, + value, + color: scheme[scheme.length - 1 - i], + }; + } + ); + return ( +
+
+ {formatLabel(idx)} +
+
+ {rows.reverse().map((r) => ( +
+ + + {r.label} + + + {r.value} + +
+ ))} +
+
+ ); + }} + /> + } + /> + } + /> + } + /> + ); + })()} +
+
+
+ Successful Runs +
+
+ Failed Runs +
+
+
+ + X: {resolution === "monthly" ? "Date (month)" : "Date (MM-DD)"} + + |Y: Runs +
+
+
+ + {/* Ring gauges */} +
+
+ + {mode === "workflows" ? "Workflows" : "Apps"} Success Rates + +
+ {(() => { + const totalOk = (seriesOk || []).reduce( + (s, p) => s + (p?.data || 0), + 0 + ); + const totalFail = (seriesFail || []).reduce( + (s, p) => s + (p?.data || 0), + 0 + ); + const total = totalOk + totalFail; + const okPct = total > 0 ? Math.round((totalOk / total) * 100) : 0; + const failPct = + total > 0 ? Math.round((totalFail / total) * 100) : 0; + return ( + <> + + + + ); + })()} +
+
+
+
+
+ ); +}; + +export default SuccessFailedRunsWidget; + +// Lightweight SVG ring to avoid RadialGauge runtime issues +function Ring({ title, color, bg, percent }) { + const stroke = 9; + const r = 60; + const c = 2 * Math.PI * r; + const filled = (Math.max(0, Math.min(100, Number(percent) || 0)) / 100) * c; + + return ( +
+
+ + + + + {Math.round(Math.max(0, Math.min(100, Number(percent) || 0)))}% + + + {title} +
+
+ ); +} + +// (Old) custom area/line removed in favor of Reaviz AreaChart grouped + +function LegendDot({ color }) { + return ( + + ); +}