Author SHA1 Message Date
vkuchenik e50638ab55 add: volcano drop down filter w/ multiselect capabilities in widget settings. UI only! No functionality has been built yet 2026-11-05 15:39:27 -08:00
vkuchenik 189d6e2d1d add: new filtering options were added to the alert changes widget. options are lvl inc, lvl dec, elevated lvls. additionally, updated how the UI looks for the al changes widget settings menu 2026-11-05 10:43:05 -08:00
vkuchenik d4fb170e14 fix:
- alert level changes now display accurate color.
- comm coor report no longer has dark loading screen in lt mode. login input text no longer white in dark mode
2026-11-03 13:27:47 -08:00
vkuchenik 872550c2c9 wip: small random ui changes 2026-10-23 11:23:16 -07:00
17 changed files with 716 additions and 1030 deletions
-23
View File
@@ -1,23 +0,0 @@
# Stage 1: The Node Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy React source code and build it
COPY . .
RUN npm run build
# Stage 2: The Nginx Server
FROM nginx:alpine
# Copy the compiled files from Stage 1 (Change /dist to /build if using Create-React-App instead of Vite)
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy your custom Nginx routing file
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-23
View File
@@ -1,23 +0,0 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# SPA routing: if a file isn't found, serve index.html
try_files $uri $uri/ /index.html;
}
# Optional: Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
root /usr/share/nginx/html;
expires 1y;
add_header Cache-Control "public, no-transform";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
-2
View File
@@ -20,7 +20,6 @@ import Admin from "./pages/admin/Admin.jsx";
import MyDashboard from "./pages/home/tabs/MyDashboard.jsx";
import Settings from "./pages/auth/Settings.jsx";
import WebAdmin from "./pages/auth/WebAdmin.jsx";
import DataExplorer from "@/pages/data_explorer/DataExplorer.jsx";
export default function App() {
// 1. Pull the initialization function from Zustand
@@ -54,7 +53,6 @@ export default function App() {
<Route path="/regional-map" element={<RegionalMap/>}/>
<Route path="/volcano" element={<Volcano/>}/>
<Route path="/admin" element={<Admin/>}/>
<Route path="/data-explorer" element={<DataExplorer/>}/>
</Route>
</Route>
+364 -135
View File
@@ -1,28 +1,51 @@
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
import VdapApi from "../../hooks/useVdapApi.js";
import useBuildUrl from "../../hooks/useBuildUrl.js";
import { Footprints, Plane, Sailboat, TrendingUp, TrendingDown } from 'lucide-react';
import {Box, Collapsible, Flex, Spinner, Text, Timeline, Center} from "@chakra-ui/react";
import {
Footprints,
Plane,
Sailboat,
TrendingUp,
TrendingDown,
} from "lucide-react";
import {
Box,
Collapsible,
Flex,
Spinner,
Text,
Timeline,
Center,
} from "@chakra-ui/react";
import { useMemo, useCallback } from "react";
export default function AlertChanges({ settings, widgetID }) {
// 0) CREATE SELF AWARENESS, by finding this specific widgets object in the state-list of active/displayed widgets
const thisWidget = useDashboardStore(
useCallback(
(state) => state.widgets.find((widget) => widget.id === widgetID),
[widgetID],
),
);
export default function AlertChanges({ settings, widgetID}) {
// Find this specific widgets object in the state list of active & displayed widgets
const thisWidget = useDashboardStore(useCallback((state) =>
state.widgets.find((widget) => widget.id === widgetID), [widgetID]
));
// 1) BUILD URL, default is 7 days past
const url = useBuildUrl(
"/api/v2/alert_changes",
settings?.startDate,
settings?.endDate,
);
// build url, default is 7 days past
const url = useBuildUrl('/api/v2/alert_changes', settings?.startDate, settings?.endDate);
// 2) CALL API hook to retrieve data
const { data: apiData, isLoading, error } = VdapApi(url);
// call api hook to retrieve data
const {data: apiData, isLoading, error} = VdapApi(url);
const data = useMemo(() => {
// 3) SORT the data by grouping into volcanoes and sorting form newest to oldest
// doing this 1st bc the filters might change w/ user input but the sorts (currently) wont and thus will
// only run when the api retrieves new data
const sortedData = useMemo(() => {
if (apiData.length === 0) return [];
// .reduce() is a built-in array method used to transform an entire array into a single value (like an object)
// here it returns a single massive object w/ volcano names as keys
// here we sort the data by volcano, and return it as a single massive object w/ volcano names as keys
const grouped = apiData.reduce((accumulator, item) => {
if (!accumulator[item.volcano]) accumulator[item.volcano] = [];
accumulator[item.volcano].push(item);
@@ -33,33 +56,97 @@ export default function AlertChanges({ settings, widgetID}) {
const group_arr = Object.entries(grouped);
// sort data newest to oldest within each volcano
const sorted = group_arr.map((item) =>
[
item[0], // volcano name
[...(item[1])].sort((a, b) =>
new Date(b.alert_start_date_local) - new Date(a.alert_start_date_local))
]
const sorted = group_arr.map((item) => [
item[0], // volcano name
[...item[1]].sort( // item[1] is the array of al changes for a volcano
(a, b) =>
new Date(b.alert_start_date_local) -
new Date(a.alert_start_date_local),
),
]);
// the volcanoes with the most recent issued alert level change appear at the top of the list (newest -> oldest)
const sorted_entries = [...sorted].sort(
(a, b) =>
new Date(b[1][0].alert_start_date_local) -
new Date(a[1][0].alert_start_date_local),
);
// volcano with most recent alert level change appears at top of list
const sorted_entries = [...sorted].sort((a, b) => new Date(b[1][0].alert_start_date_local) - new Date(a[1][0].alert_start_date_local));
// additionally, seperates out data into domains within a volcano for sm widgets,
// to list all most recent al changes w/in a volcano w/in a domain
if (thisWidget?.size === "small") {
return (sorted_entries.map((volcano) => {
return sorted_entries.map((volcano) => {
const domain = volcano[1].reduce((acc, curr) => {
if (!acc[curr.alert_scheme_domain]) acc[curr.alert_scheme_domain] = [];
if (!acc[curr.alert_scheme_domain])
acc[curr.alert_scheme_domain] = [];
acc[curr.alert_scheme_domain].push(curr);
return acc;
}, {});
const domain_arr = Object.entries(domain);
console.log("sm data: ", [volcano[0], domain_arr]);
return [volcano[0], domain_arr];
}));
});
}
console.log("lg data: ", sorted_entries);
return sorted_entries;
}, [apiData, thisWidget]);
// 4) FILTER according to what is set in the widgets settings
const filteredData = useMemo(() => {
if (sortedData.length === 0) return [];
let filtered_arr = [];
// must show at least one inc or dec alert levels, otherwise no data is displayed
if (
!settings?.filters?.levelIncreases &&
!settings?.filters?.levelDecreases
)
return [];
// return all data
if (
settings?.filters?.levelIncreases &&
settings?.filters?.levelDecreases &&
!settings?.filters.elevatedLevels
)
return sortedData;
// only elevated alert levels
filtered_arr = settings?.filters.elevatedLevels
? sortedData.map((volcano) => [
volcano[0],
volcano[1].filter(
(item) =>
item.alert_scheme_highest - Number(item.alert_level_rank) <= 1,
),
])
.filter((volcano) => volcano[1].length > 0)
: sortedData;
// only decreases (rm inc)
filtered_arr = !settings?.filters.levelIncreases
? filtered_arr.map((volcano) => [
volcano[0],
volcano[1].filter((item) => !(item.change_type === "increase")),
])
.filter((volcano) => volcano[1].length > 0)
: filtered_arr;
// only increases (rm dec)
filtered_arr = !settings?.filters?.levelDecreases
? filtered_arr.map((volcano) => [
volcano[0],
volcano[1].filter((item) => !(item.change_type === "decrease")),
])
.filter((volcano) => volcano[1].length > 0)
: filtered_arr;
console.log("filtered_arr: ", filtered_arr);
return filtered_arr;
}, [sortedData, settings]);
// Alert colors are now light/dark mode responsive
const alertColors = {
Red: "alertRed",
@@ -68,153 +155,295 @@ export default function AlertChanges({ settings, widgetID}) {
Green: "alertGreen",
};
if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex>
);
const options = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short"
};
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Text color="red.400">{error}</Text>
</Flex>
);
if (isLoading)
return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px" />
</Flex>
);
if (data.length === 0) return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Text textAlign="center" color="widgetTextMuted">No flight level data available for the selected dates.</Text>
</Flex>
);
if (error)
return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Text color="red.400">{error}</Text>
</Flex>
);
if (filteredData.length === 0)
return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Text textAlign="center" color="widgetTextMuted">
No alert level data available for the selected dates.
</Text>
</Flex>
);
return (
// bg="transparent" to let the glass panel show through
<Box display="flex" flexDirection="column" gap={2} p={2} w="100%" h="100%" bg="transparent" overflowY="auto">
{data.map((volcano, index) => {
if (thisWidget?.size === "large") return (
<Collapsible.Root key={index} w="100%">
<Collapsible.Context>
{(collapsible) => (
<>
<Box
display="flex"
flexDirection="column"
gap={2}
p={2}
w="100%"
h="100%"
bg="transparent"
overflowY="auto"
>
{filteredData.map((volcano, index) => {
if (thisWidget?.size === "large")
return (
<Collapsible.Root key={index} w="100%">
<Collapsible.Context>
{(collapsible) => (
<Collapsible.Trigger w="100%">
<Flex direction="row"
w="100%"
justify="space-between" align="center"
p={1} pl={3} pr={3}
borderTopRadius="lg"
borderBottomRadius={collapsible.open ? "0" : "lg"}
bg="widgetItemBg"
<Flex
direction="row"
w="100%"
justify="space-between"
align="center"
p={1}
pl={3}
pr={3}
borderTopRadius="lg"
borderBottomRadius={collapsible.open ? "0" : "lg"}
bg="widgetItemBg"
>
<Flex direction="column" w="100%" minW={0} textAlign="left">
<Text truncate color="text" fontWeight="medium">{volcano[0]}</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">{volcano[1][0]?.alert_start_date_local}</Text>
<Flex
direction="column"
w="100%"
minW={0}
textAlign="left"
>
<Text truncate color="text" fontWeight="medium">
{volcano[0]}
</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">
{new Date(volcano[1][0]?.alert_start_date_local)
.toLocaleString('en-US', options)
}
</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">
<Flex w="100%" minW={0} justify="right" gap={2}>
<Text
truncate
pl={2}
pr={2}
borderRadius="lg"
bg="widgetBadgeBg"
color="text"
fontWeight="semibold"
>
{volcano[1][0]?.alert_level_name}
</Text>
<Flex w="fit">
{volcano[1][0]?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Flex>
</Flex>
</Flex>
</Collapsible.Trigger>
)}
</Collapsible.Context>
<Collapsible.Content w="100%" p={3} borderBottomRadius="lg" bg="widgetItemExpandedBg">
<Timeline.Root size="md" variant="subtle">
{volcano[1]?.map((object, index) => (
<Timeline.Item key={index} w="100%" h="100%">
<Timeline.Connector >
<Timeline.Indicator outline="none" bg="widgetBadgeBg" color="iconColor" size="lg">
{object?.alert_scheme_domain === "terrestrial" && <Footprints size={15} />}
{object?.alert_scheme_domain === "aviation" && <Plane size={15} />}
{object?.alert_scheme_domain === "maritime" && <Sailboat size={15} />}
</Timeline.Indicator>
<Timeline.Indicator h="50%" outline="none" bg="widgetBadgeBg" color="iconColor" size="lg">
{object?.change_type === "increase" ? <TrendingUp /> : <TrendingDown />}
</Timeline.Indicator>
</Timeline.Connector>
<Timeline.Content w="100%" h="100%">
<Timeline.Description w="100%" h="100%" gap={1}>
<Flex direction="column" gap={1}>
<Text w="100%" h="100%" color="widgetTextMuted">{object?.alert_start_date_local}</Text>
<Text w="100%" h="100%"
color={alertColors[object?.alert_level_name] || "text"}
fontSize="md"
fontWeight="semibold"
>
{object?.alert_level_name}
</Text>
<Flex w="100%" h="20px" direction="row" justify="left" align="center">
<Text color="widgetTextMuted">Alert Scale: </Text>
{(Array.from({ length: Number(object?.alert_scheme_highest) - Number(object?.alert_scheme_lowest) + 1}, (v, i) => Number(object?.alert_scheme_lowest) + i))
.map((num) => (
<Center
key={num}
w="20px" h="100%"
color="text"
bg={num === Number(object?.alert_level_rank) ? "widgetBadgeBg" : "transparent"}
borderRadius={num === Number(object?.alert_level_rank) ? "xl" : "none"}
textAlign="center"
fontSize="sm"
fontWeight="bold"
>
{num}
</Center>
))
<Collapsible.Content
w="100%"
p={3}
borderBottomRadius="lg"
bg="widgetItemExpandedBg"
>
<Timeline.Root size="md" variant="subtle">
{volcano[1]?.map((object, index) => (
<Timeline.Item key={index} w="100%" h="100%">
<Timeline.Connector>
<Timeline.Indicator
outline="none"
bg="widgetBadgeBg"
color="iconColor"
size="lg"
>
{object?.alert_scheme_domain ===
"terrestrial" && <Footprints size={15} />}
{object?.alert_scheme_domain === "aviation" && (
<Plane size={15} />
)}
{object?.alert_scheme_domain === "maritime" && (
<Sailboat size={15} />
)}
</Timeline.Indicator>
<Timeline.Indicator
h="50%"
outline="none"
bg="transparent"
color="iconColor"
size="lg"
>
{object?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Timeline.Indicator>
</Timeline.Connector>
<Timeline.Content w="100%" h="100%">
<Timeline.Description w="100%" h="100%" gap={1}>
<Flex direction="column" gap={1}>
<Text
w="100%"
h="100%"
color="widgetTextMuted"
>
{new Date(object?.alert_start_date_local)
.toLocaleString('en-US', {...options, timeZone: 'UTC'})
}
</Text>
<Text
w="100%"
h="100%"
color={
alertColors[object?.alert_level_name] ||
"text"
}
fontSize="md"
fontWeight="semibold"
>
{object?.alert_level_name}
</Text>
<Flex
w="100%"
h="20px"
direction="row"
justify="left"
align="center"
>
<Text color="widgetTextMuted">
Alert Scale:{" "}
</Text>
{Array.from(
{
length:
Number(object?.alert_scheme_highest) -
Number(object?.alert_scheme_lowest) +
1,
},
(v, i) =>
Number(object?.alert_scheme_lowest) + i,
).map((num) => (
<Center
key={num}
w="20px"
h="100%"
color="text"
bg={
num ===
Number(object?.alert_level_rank)
? "widgetBadgeBg"
: "transparent"
}
</Flex>
</Flex>
</Timeline.Description>
</Timeline.Content>
</Timeline.Item>
))}
</Timeline.Root>
</Collapsible.Content>
</>
)}
</Collapsible.Context>
</Collapsible.Root>
)
else if (thisWidget?.size === "small") return (
volcano[1]?.map((domain, index) => (
borderRadius={
num ===
Number(object?.alert_level_rank)
? "xl"
: "none"
}
textAlign="center"
fontSize="sm"
fontWeight="bold"
>
{num}
</Center>
))}
</Flex>
</Flex>
</Timeline.Description>
</Timeline.Content>
</Timeline.Item>
))}
</Timeline.Root>
</Collapsible.Content>
</Collapsible.Root>
);
else if (thisWidget?.size === "small")
return volcano[1]?.map((domain, index) => (
<Flex
key={index}
direction="row"
w="100%"
gap={2}
justify="space-between" align="center"
p={1} pl={3} pr={3}
justify="space-between"
align="center"
p={1}
pl={3}
pr={3}
borderRadius="lg"
bg="widgetItemBg"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Flex
direction="column"
w="100%"
minW={0}
textAlign="left"
color="text"
>
<Flex direction="row" w="100%" gap={2} align="center">
<Flex color="iconColor">
{domain[0] === "aviation" && <Plane size={15}/>}
{domain[0] === "terrestrial" && <Footprints size={15}/>}
{domain[0] === "maritime" && <Sailboat size={15}/>}
{domain[0] === "aviation" && <Plane size={15} />}
{domain[0] === "terrestrial" && <Footprints size={15} />}
{domain[0] === "maritime" && <Sailboat size={15} />}
</Flex>
<Text truncate fontWeight="medium">{volcano[0] || ""}</Text>
<Text truncate fontWeight="medium">
{volcano[0] || ""}
</Text>
</Flex>
<Text truncate w="100%" fontSize="xs" color="widgetTextMuted">{domain[1]?.[0]?.alert_start_date_local || ""}</Text>
<Text truncate w="100%" fontSize="xs" color="widgetTextMuted">
{domain[1]?.[0]?.alert_start_date_local || ""}
</Text>
</Flex>
<Flex direction="row" w="100%" minW={0} justify="right" gap={2} color="text">
<Flex
direction="row"
w="100%"
minW={0}
justify="right"
gap={2}
color="text"
>
<Flex w="25px" justify="right" color="iconColor">
{domain[1]?.[0]?.change_type === "increase" ? <TrendingUp/> : <TrendingDown/>}
{domain[1]?.[0]?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Flex>
<Text
truncate
pl={2} pr={2}
pl={2}
pr={2}
borderRadius="lg"
bg="widgetBadgeBg"
color={alertColors[domain[1]?.[0]?.alert_level_name] || "text"}
color={
alertColors[domain[1]?.[0]?.alert_level_name] || "text"
}
fontWeight="semibold"
>
{domain[1]?.[0]?.alert_level_name}
</Text>
</Flex>
</Flex>
))
);
));
})}
</Box>
)
}
);
}
@@ -115,19 +115,19 @@ export default function CommCoorReport({ settings, widgetID }) {
}, [settings, widgetID, updateSettings, apiData]);
if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="transparent">
<Text color="red.400">{error}</Text>
</Flex>
);
if (!apiData) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Text textAlign="center" color="gray.400">No data</Text>
</Flex>
);
@@ -111,18 +111,17 @@ export default function IvansAlerts({ settings, widgetID }) {
<Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
<Flex direction="column" gap={2} bg="widgetItemBg" borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Flex direction="column" gap={2} borderRadius="lg" p={2} align="start">
<Text w="fit" fontSize="sm" color="text" pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md">Preparer: {item?.preparer}</Text>
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
</Flex>
{item?.files?.map((file, index) => {
return (
<Box w="100%" h="100%" key={index} p={1}>
<br />
<Flex mb={2} justify="space-between">
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
<Box borderRadius="lg" bg="widgetBadgeBg" color="text" pl={1} pr={1} fontSize={13} asChild>
<a href={`${file?.url}#view=FitV&toolbar=1`} target="_blank" rel="noopener noreferrer">
<a href={`${file?.url}#view=FitV&toolbar=0`} target="_blank" rel="noopener noreferrer">
Open
</a>
</Box>
@@ -8,10 +8,6 @@ export default function LlmAlChangesQuadPlot(props) {
let tn_percent = 0;
let fp_percent = 0;
let fn_percent = 0;
let jf_percent = 0;
let error_count = 0;
let no_dvar_data_count = 0;
// Clean, single-line data fetching
const { data: apiData, isLoading, error } = VdapApi('/api/v2/llm_quadrant_plot');
@@ -36,34 +32,32 @@ export default function LlmAlChangesQuadPlot(props) {
if (apiData) {
console.log(apiData);
tp_percent = ((apiData[0]['tp_count']/apiData[0]['vr_count'])*100).toFixed(2);
tn_percent = ((apiData[0]['tn_count']/apiData[0]['vr_count'])*100).toFixed(2);
fp_percent = ((apiData[0]['fp_count']/apiData[0]['vr_count'])*100).toFixed(2);
fn_percent = ((apiData[0]['fn_count']/apiData[0]['vr_count'])*100).toFixed(2);
jf_percent = ((apiData[0]['jf_count']/apiData[0]['vr_count'])*100).toFixed(2);
error_count = apiData[0]['e_count'];
no_dvar_data_count = apiData[0]['ndd_count'];
tp_percent = ((apiData[0]['tp_count']/apiData[0]['tot_count'])*100).toFixed(2);
tn_percent = ((apiData[0]['tn_count']/apiData[0]['tot_count'])*100).toFixed(2);
fp_percent = ((apiData[0]['fp_count']/apiData[0]['tot_count'])*100).toFixed(2);
fn_percent = ((apiData[0]['fn_count']/apiData[0]['tot_count'])*100).toFixed(2);
}
return (
<Flex direction="column" w="100%" h="100%" bg="gray.800" borderRadius="lg" gap={1} p={1}>
<Flex direction="column" align="end" justify="center" color="white" gap={1}>
<Text w="fit" bg="cyan.200/5" borderRadius="sm" p={1}>All Time</Text>
<Flex direction="column" w="100%" h="100%" borderRadius="lg">
<Flex direction="row" w="100%" bg="gray.800" borderTopRadius="lg" justifyItems="center" alignItems="start" p={2} pb={0} pl={4} justifyContent="end">
{/*<Text fontWeight="bold" fontSize="lg" color="white">Alert Level Changes Quad Plot</Text>*/}
<Text bg="cyan.200/5" borderRadius="sm" padding="1" fontWeight="normal" fontSize="sm" color="white">All time</Text>
</Flex>
<Flex direction="column" w="100%" h="100%" gap="2" borderBottomRadius="lg">
<Flex direction="column" w="100%" h="100%" bg="gray.800" gap="2" padding="2" borderBottomRadius="lg">
<Flex direction="row" w="100%" h="100%" gap="2">
<Center bg={`blue.200/${tp_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>True Positive</Text>
<Text fontSize="lg"> {tp_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tp_count']}/{apiData[0]['vr_count']} </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tp_count']}/{apiData[0]['tot_count']} </Text>
</Flex>
</Center>
<Center bg={`blue.200/${tn_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>True Negative</Text>
<Text fontSize="lg"> {tn_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tn_count']}/{apiData[0]['vr_count']} </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['tn_count']}/{apiData[0]['tot_count']} </Text>
</Flex>
</Center>
</Flex>
@@ -72,30 +66,17 @@ export default function LlmAlChangesQuadPlot(props) {
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>False Positive</Text>
<Text fontSize="lg"> {fp_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fp_count']}/{apiData[0]['vr_count']} </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fp_count']}/{apiData[0]['tot_count']} </Text>
</Flex>
</Center>
<Center bg={`blue.200/${fn_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>False Negative</Text>
<Text fontSize="lg"> {fn_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fn_count']}/{apiData[0]['vr_count']} </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['fn_count']}/{apiData[0]['tot_count']} </Text>
</Flex>
</Center>
</Flex>
<Flex direction="row" w="100%" h="100%" gap="2">
<Center bg={`blue.200/${jf_percent % 50}`} w="100%" h="100%" borderRadius="lg" color="white" fontSize="sm" fontWeight="bold">
<Flex direction="column" w="100%" h="100%" justifyContent="center" alignItems="center">
<Text>Just False</Text>
<Text fontSize="lg"> {jf_percent}% </Text>
<Text color="gray.400" fontSize="xs" fontWeight="normal"> {apiData[0]['jf_count']}/{apiData[0]['vr_count']} </Text>
</Flex>
</Center>
</Flex>
</Flex>
<Flex direction="row" w="100%" justify="space-between" gap={1} color="white">
<Text w="fit" bg="blue.200/5" borderRadius="sm" p={1}>Errors: {error_count}</Text>
<Text w="fit" bg="blue.200/5" borderRadius="sm" p={1}>No DVAR Data: {no_dvar_data_count}</Text>
</Flex>
</Flex>
)
@@ -96,7 +96,7 @@ export default function RemoteSensing({ settings, widgetID }) {
<Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
<Flex direction="column" gap={2} bg="widgetItemBg" borderRadius="lg" p={2} align="end">
<Flex direction="column" gap={2} borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Text w="fit" fontSize="sm" color="text" pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md">Source: {item?.sender}</Text>
</Flex>
@@ -1,38 +1,289 @@
import {Flex, Slider, Switch} from "@chakra-ui/react";
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import {
Flex,
Slider,
Switch,
Text,
Field,
Fieldset,
Input,
CheckboxCard,
CheckboxGroup,
Combobox,
Portal,
useFilter,
useListCollection,
Wrap,
Badge
} from "@chakra-ui/react";
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
import { useState, useMemo } from "react";
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
export default function AlertChangeSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(
(state) => state.updateWidgetSettings,
);
const [startError, setStartError] = useState(null);
const [endError, setEndError] = useState(null);
console.log("currentSettings:", currentSettings);
const [selectedVolcanoes, setSelectedVolcanoes] = useState([]);
const { startsWith } = useFilter({ sensitivity: "base" });
const { collection, filter, reset } = useListCollection({
initialItems: volcanoes,
filter: startsWith,
limit: 10,
});
const handleValueChange = (details) => {
setSelectedVolcanoes(details.value)
}
return (
<Flex flexDirection="column" justifyContent="space-between" align="center" gap={2} pb={2}>
{/* SLIDER DATE RANGE SELECTION */}
<Slider.Root
w="90%" h="100%"
min={1} max={14}
defaultValue={[1]}
step={1}
onValueChangeEnd={(e) => {
console.log("change end", e);
const day = new Date(Date.now() - (e.value[0])*86400000).toLocaleDateString('en-CA');
updateSettings(widgetId, {...currentSettings, startDate: day});
}}
>
<Slider.Label>{currentSettings?.startDate} to {currentSettings?.endDate}</Slider.Label>
<Slider.Control>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumbs />
<Slider.MarkerGroup p={3}>
<Slider.Marker value={1} whiteSpace="nowrap">today</Slider.Marker>
<Slider.Marker value={7} whiteSpace="nowrap">1 week ago</Slider.Marker>
<Slider.Marker value={14} whiteSpace="nowrap">2 weeks ago</Slider.Marker>
</Slider.MarkerGroup>
</Slider.Control>
</Slider.Root>
<Flex direction="column" gap={4} pb={2} overflowY="auto">
<Fieldset.Root>
<Fieldset.Content>
{/* SELECTING DATE RANGE */}
<Flex direction="column" w="100%" h="100%" justify="center" gap="2">
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Date Range Selection
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* start date */}
<Field.Root invalid={!!startError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="gray.700">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.startDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {
...currentSettings,
startDate: value,
});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && dateSelected > today)
setStartError("Can't Choose a Future Date");
else setStartError("");
}}
/>
{startError && (
<Field.ErrorText>{startError}</Field.ErrorText>
)}
</Flex>
</Flex>
</Field.Root>
{/* end date */}
<Field.Root invalid={!!endError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="gray.700">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.endDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {
...currentSettings,
endDate: value,
});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && dateSelected > today)
setEndError("Can't Choose a Future Date");
else setEndError("");
}}
/>
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex>
{/* FILTERS */}
<CheckboxGroup w="100%">
<Flex direction="column" w="100%" gap={2}>
<Flex direction="column" w="100%">
<Text w="fit" fontWeight="bold">
Filters
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* level increases */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.levelIncreases}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
levelIncreases: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput /> {/* when removed can't check/uncheck box */}
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Level Increases
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* level decreases */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.levelDecreases}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
levelDecreases: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Level Decreases
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* elevated levels */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.elevatedLevels}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
elevatedLevels: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Only Elevated Levels
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
</Flex>
</CheckboxGroup>
{/* select volcano to filter on */}
<Combobox.Root
mt={1}
variant="subtle"
multiple
openOnClick
collection={collection}
onValueChange={handleValueChange}
onInputValueChange={(e) => filter(e.inputValue)}
>
<Wrap gap="2">
{selectedVolcanoes.map((volcano) => (
<Badge key={volcano}>{volcano}</Badge>
))}
</Wrap>
<Combobox.Label fontSize="xs" >Select Volcano (s)</Combobox.Label>
<Combobox.Control>
<Combobox.Input placeholder="Type to search" borderRadius="lg"/>
<Combobox.IndicatorGroup>
<Combobox.ClearTrigger />
<Combobox.Trigger onClick={reset} />
</Combobox.IndicatorGroup>
</Combobox.Control>
<Portal>
<Combobox.Positioner>
<Combobox.Content>
{volcanoes.map((item) => (
<Combobox.Item item={item} key={item.value}>
{item.label}
<Combobox.ItemIndicator />
</Combobox.Item>
))}
<Combobox.Empty>No items found</Combobox.Empty>
</Combobox.Content>
</Combobox.Positioner>
</Portal>
</Combobox.Root>
</Fieldset.Content>
</Fieldset.Root>
</Flex>
)
}
);
}
const volcanoes = [
{ label: "Mt. St. Helens", value: "helens" },
{ label: "Mt. Adams", value: "adams" },
{ label: "Rainier", value: "rainier" },
{ label: "The Three Sisters", value: "three sisters" },
{ label: "Fuego", value: "fuego" },
{ label: "Laki Laki", value: "lakilaki" },
{ label: "Lewotobi", value: "lewotobi" },
{ label: "Karangatang", value: "karangatang" },
{ label: "Cotopaxi", value: "cotopaxi" },
{ label: "Merapi", value: "merapi" },
{ label: "Marapi", value: "marapi" },
{ label: "Kick 'Em Jenny", value: "kick em jenny"}
]
+3 -4
View File
@@ -1,4 +1,4 @@
import {Mountain, House, Earth, MapPin, ShieldUser, PanelLeftClose, PanelLeftOpen, ChartScatter, Telescope, Map, UserLock} from 'lucide-react';
import {Mountain, House, Earth, MapPin, ShieldUser, PanelLeftClose, PanelLeftOpen} from 'lucide-react';
import {useSlotRecipe, Box, Button, IconButton, Text, Flex} from "@chakra-ui/react";
import {ColorModeButton} from "@/components/ui/color-mode.jsx";
import {NavLink} from "react-router-dom";
@@ -7,10 +7,9 @@ import {useState} from "react";
const SIDEBAR_DEFAULT_BUTTONS = [
{icon: House, name: "Home", url: "/home"},
{icon: Earth, name: "Global Map", url: "/global-map"},
{icon: Map, name: "Regional Map", url: "/regional-map"},
{icon: MapPin, name: "Regional Map", url: "/regional-map"},
{icon: Mountain, name: "Volcano", url: "/volcano"},
{icon: Telescope, name: "Data Explorer", url: "/data-explorer"},
{icon: UserLock, name: "Admin", url: "/admin"}, // TODO remove in the future as default should not have admin
{icon: ShieldUser, name: "Admin", url: "/admin"} // TODO remove in the future as default should not have admin
];
export default function Sidebar() {
+23 -17
View File
@@ -50,8 +50,8 @@ export const WIDGET_REGISTRY = {
name: "IVANS",
category: "Communications",
description: "Feed of recent IVANS reports and reviewer statuses.",
component: lazy(() => import('../components/widgets/IvansAlerts.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/IvansAlertsSettings.jsx')),
component: lazy(() => import('../components/Widgets/IvansAlerts.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')),
defaultSettings: {
startDate: getStartDate(),
endDate: getEndDate(),
@@ -72,8 +72,8 @@ export const WIDGET_REGISTRY = {
name: "Remote Sensing",
category: "Remote Sensing",
description: "Feed of recent remote sensing reports.",
component: lazy(() => import('../components/widgets/RemoteSensing.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/RemoteSensingSettings.jsx')),
component: lazy(() => import('../components/Widgets/RemoteSensing.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/Settings/RemoteSensingSettings.jsx')),
defaultSettings: {
startDate: getStartDate(),
endDate: getEndDate(),
@@ -94,11 +94,17 @@ export const WIDGET_REGISTRY = {
name: "Alert Level Changes",
category: "Daily Alerts",
description: "List of 7-day alert level changes with trend arrows.",
component: lazy(() => import('../components/widgets/AlertChanges.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/AlertChangeSettings.jsx')),
component: lazy(() => import('../components/Widgets/AlertChanges.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/AlertChangeSettings.jsx')),
defaultSettings: {
startDate: getStartDate(),
endDate: getEndDate(),
filters: {
levelIncreases: true,
levelDecreases: true,
elevatedLevels: false, // will filter to show only volcanoes in their top 2 highest alert levels
volcanoes: 'all', // filter on what volcanoes to display, defaults to 'all' instead of listing every volc
},
},
allowedSizes: {
small: { w: 3, h: 6 },
@@ -111,7 +117,7 @@ export const WIDGET_REGISTRY = {
category: "Daily Volcanic Activity",
description: "List of explosion events over a specified time period.",
component: lazy(() => import('../components/widgets/ExplosionEvents.jsx')),
component: lazy(() => import('../components/Widgets/ExplosionEvents.jsx')),
// The specific settings UI for this widget
settingsComponent: lazy(() => import('../components/widgets/settings/ExplosionEventsSettings.jsx')),
@@ -131,8 +137,8 @@ export const WIDGET_REGISTRY = {
name: "Plume Events",
category: "Daily Volcanic Activity",
description: "Plot of explosion heights over a weeks time.",
component: lazy(() => import('../components/widgets/PlumeEvents.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/PlumeEventsSettings.jsx')),
component: lazy(() => import('../components/Widgets/PlumeEvents.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/PlumeEventsSettings.jsx')),
defaultSettings: {
startDate: getStartDate(),
endDate: getEndDate(),
@@ -146,8 +152,8 @@ export const WIDGET_REGISTRY = {
name: "Seismic Events",
category: "Daily Volcanic Activity",
description: "Plot of number of seismic events over a weeks time.",
component: lazy(() => import('../components/widgets/SeismicEvents.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/SeismicEventsSettings.jsx')),
component: lazy(() => import('../components/Widgets/SeismicEvents.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/SeismicEventsSettings.jsx')),
defaultSettings: {
startDate: getStartDate(),
endDate: getEndDate(),
@@ -161,7 +167,7 @@ export const WIDGET_REGISTRY = {
name: "Alert Level Changes Quad Plot",
category: "Computer Science",
description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
component: lazy(() => import('../components/widgets/LlmAlChangesQuadPlot.jsx')),
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
allowedSizes: {
medium: { w: 3, h: 12 }
}
@@ -171,8 +177,8 @@ export const WIDGET_REGISTRY = {
name: "Communication Coordinator Report",
category: "Daily Volcanic Activity",
description: "Generated Report",
component: lazy(() => import('../components/widgets/CommCoorReport.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/CommCoorReportSettings.jsx')),
component: lazy(() => import('../components/Widgets/CommCoorReport.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/CommCoorReportSettings.jsx')),
defaultSettings: {
copyToClipboard: false,
downloadReport: false,
@@ -188,8 +194,8 @@ export const WIDGET_REGISTRY = {
name: "VAA Polygon Projections Map",
category: "Maps",
description: "By selecting a volcano, the map will take you to it and show a polygon of the ash in 8, 12, and 18 hours",
component: lazy(() => import('../components/widgets/VaaMap.jsx')),
settingsComponent: lazy(() => import('../components/widgets/settings/VaaMapSettings.jsx')),
component: lazy(() => import('../components/Widgets/VaaMap.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')),
defaultSettings: {
position: [45.61142, -122.49630], // CVO's coordinates
startDate: getToday(),
@@ -209,7 +215,7 @@ export const WIDGET_REGISTRY = {
name: "Travel Gantt Chart",
category: "Communications",
description: "View upcoming travels in a gantt chart",
component: lazy(() => import('../components/widgets/TravelGanttChart.jsx')),
component: lazy(() => import('../components/Widgets/TravelGanttChart.jsx')),
allowedSizes: {
medium: { w: 6, h: 12 }
}
+4 -2
View File
@@ -65,7 +65,7 @@ const Login = () => {
return (
<Flex p={8} bg="gray.50" _dark={{ bg: "gray.900" }} flex="1" minH="100vh" align="center" justify="center">
<Box w="full" maxW="450px" bg="white" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Box w="full" maxW="450px" bg="white" borderColor="gray.200" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px">
<Stack gap={6} align="stretch" as="form" onSubmit={handleSubmit}>
<Stack gap={2} textAlign="center">
@@ -88,6 +88,7 @@ const Login = () => {
onChange={(e) => setUsername(e.target.value)}
placeholder="jdoe"
{...fieldStyles}
color="gray.800"
/>
</Field.Root>
@@ -102,8 +103,9 @@ const Login = () => {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
// placeholder="••••••••" // not a necessary placeholder
{...fieldStyles}
color="gray.800"
/>
</Field.Root>
@@ -1,190 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import { Box, Stack, Separator, Center, Spinner, Alert, Flex } from "@chakra-ui/react";
import { toaster } from "../../components/ui/toaster.jsx"; // Adjust path to your v3 toaster if needed
// Import our newly consolidated engine and presentation components
import QueryBuilder from "./QueryBuilder.jsx";
import DataTable from "./DataTable.jsx";
// import { exportToCSV } from "../utils/csvExport.js";
export default function DataExplorer() {
// 1. Core State
const [results, setResults] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [allColumns, setAllColumns] = useState([]);
const isReadyRef = useRef(false);
// 2. The Master Query State
const [url, setUrl] = useState({
source: "",
page: 1,
columns: [],
bundles: [],
ordering: "id",
});
// 3. API & URL Logic
const buildUrlParams = () => {
const { source, page, columns, bundles, ordering } = url;
const params = new URLSearchParams();
if (bundles.length > 0) {
// Scrub out incomplete filters before sending to the backend
const cleanBundles = bundles.map(bundle => {
const validFilters = bundle.filters
.filter(f => f.column && f.type && f.value)
.map(({ id, ...rest }) => rest);
return { ...bundle, filters: validFilters };
}).filter(bundle => bundle.filters.length > 0); // Only send bundles that actually have filters
if (cleanBundles.length > 0) {
params.append("bundles", JSON.stringify(cleanBundles));
}
}
if (source) params.append("source", source);
if (columns?.length > 0) params.append("columns", columns.join(","));
if (ordering) params.append("ordering", ordering);
if (page) params.append("page", page);
return params.toString();
};
const getColumns = async (endpoint) => {
setError(null);
isReadyRef.current = false; // does this need to be set to true at the end?
try {
// 🚨 Relative pathing so Traefik proxy handles it
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
setAllColumns(data);
setUrl({ ...url, columns: Object.keys(data.types), source: data.view }); // changed it from (prev => {...prev, columns: __, source: __ })
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
const pullTable = async () => {
setIsLoading(true);
setError(null);
if (!url.source) {
setError("Please select a source (table) before fetching data.");
setIsLoading(false);
return;
}
try {
// 🚨 Relative pathing for your production API
const response = await fetch(`/api/v3/table_view?${buildUrlParams()}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
setResults(data);
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
// Auto-fetch when pagination, columns, or sorting changes (but skip first render)
useEffect(() => {
if (! isReadyRef.current) {
isReadyRef.current = false; // why are we resetting the readyRef back to false since it already is false?
return;
}
pullTable();
}, [url.page, url.columns, url.ordering]);
// 4. Export Utility
const handleDownloadCSV = () => {
if (results?.results?.length > 0) {
const timestamp = new Date().toISOString().slice(0, 10);
const filename = `${url.source}_${timestamp}.csv`;
// exportToCSV(results.results, filename);
toaster.create({
title: "Dummy Success Toaster (csv download fn has been disabled for now)",
description: `Downloaded ${results.results.length} records as CSV`,
type: "success",
});
} else {
toaster.create({
title: "No Data",
description: "No results to download",
type: "error",
});
}
};
// 5. Render Shell
return (
// 🚨 Stripped hardcoded #FFFBFA background.
// This Flex container will perfectly fill your new Sidebar Route canvas.
<Flex p={8} flex="1" direction="column" overflowY="auto" bg="transparent" h="100%">
<Stack direction="column" gap={6} align="stretch" w="100%">
{/* The Engine */}
<QueryBuilder
url={url}
setUrl={setUrl}
allColumns={allColumns}
getColumns={getColumns}
pullTable={pullTable}
isLoading={isLoading}
isReadyRef={isReadyRef}
/>
{results && <Separator borderColor="widgetBadgeBg" />}
{/* Global Loading & Error States */}
{isLoading && (
<Center p={10}>
<Spinner color="blue.500" size="xl" thickness="4px" />
</Center>
)}
{error && (
<Alert.Root status="error" mt={4} bg="red.900" color="white">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Error Fetching Data</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert.Root>
)}
{/* The Display (Table, Pagination, Download, Columns) */}
{results && !isLoading && !error && (
<Box w="100%">
{results.results.length > 0 ? (
<DataTable
data={results.results}
count={results.count}
url={url}
setUrl={setUrl}
allColumns={allColumns.types}
isLoading={isLoading}
onDownload={handleDownloadCSV}
/>
) : (
<Alert.Root status="warning" mt={4} bg="widgetItemExpandedBg" color="text">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>No Results Found</Alert.Title>
<Alert.Description>Try loosening your filter criteria.</Alert.Description>
</Alert.Content>
</Alert.Root>
)}
</Box>
)}
</Stack>
</Flex>
);
};
@@ -1,220 +0,0 @@
import React from "react";
import {
Button,
ButtonGroup,
Table,
HoverCard,
Text,
Flex,
Menu,
Portal,
useCheckboxGroup,
IconButton,
Pagination,
} from "@chakra-ui/react";
import { ChevronDown, ChevronLeft, ChevronRight, Download, Files, ClipboardPaste } from 'lucide-react';
// import { LuChevronDown, LuChevronLeft, LuChevronRight } from "react-icons/lu";
// import { FiDownload, FiCopy, FiClipboard } from "react-icons/fi";
// ============================================================================
// 1. COLUMN SELECTOR (Toggles visible columns)
// ============================================================================
const ColumnSelector = ({ allColumns, url, setUrl, isLoading }) => {
const group = useCheckboxGroup({ defaultValue: url.columns || Object.keys(allColumns) });
return (
<Menu.Root closeOnSelect={false}>
<Menu.Trigger asChild>
<Button variant="surface" bg="widgetBadgeBg" color="text" _hover={{ bg: "widgetItemExpandedBg" }}>
Show Columns <ChevronDown />
</Button>
</Menu.Trigger>
<Portal>
<Menu.Positioner>
<Menu.Content bg="widgetItemBg" borderColor="widgetBadgeBg">
<Menu.ItemGroup>
<Menu.ItemGroupLabel color="widgetTextMuted">Available Columns</Menu.ItemGroupLabel>
{Object.keys(allColumns).map((colName) => (
<Menu.CheckboxItem
key={colName}
value={colName}
checked={group.value.includes(colName)}
onCheckedChange={() => group.toggleValue(colName)}
color="text"
>
<Menu.ItemIndicator />
{colName}
</Menu.CheckboxItem>
))}
</Menu.ItemGroup>
<Menu.Item
onClick={() => setUrl((prev) => ({ ...prev, columns: [...group.value] }))}
p="2"
>
<Button size="sm" w="100%" loading={isLoading} bg="blue.600" color="white" _hover={{ bg: "blue.500" }}>
Apply Columns
</Button>
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
};
// ============================================================================
// 2. PAGE FOOTER (Pagination & Query Saving)
// ============================================================================
const PageFooter = ({ count, pageSize, url, setUrl }) => {
const page = url.page || 1;
const handleSaveQuery = () => {
navigator.clipboard.writeText(JSON.stringify(url)).then(() => {
alert("Query copied to clipboard!"); // Optional: replace with your Toast
});
};
const handlePasteQuery = async () => {
try {
const text = await navigator.clipboard.readText();
const clipBoardText = JSON.parse(text);
setUrl(clipBoardText);
} catch (err) {
console.error("Invalid clipboard content", err);
alert("Failed to paste. Make sure you copied a valid query.");
}
};
return (
<Flex w="100%" justify="space-between" align="center" mt="4">
{/* Query Save/Paste Tools */}
<ButtonGroup size="sm" attached>
<Button onClick={handleSaveQuery} variant="outline" borderColor="widgetBadgeBg" color="text">
<Files style={{ marginRight: "8px" }} /> Save Query
</Button>
<Button onClick={handlePasteQuery} variant="outline" borderColor="widgetBadgeBg" color="text">
<ClipboardPaste style={{ marginRight: "8px" }} /> Paste Query
</Button>
</ButtonGroup>
{/* Pagination Controls */}
<Pagination.Root count={count} pageSize={pageSize} page={page}>
<ButtonGroup variant="ghost" size="sm">
<Pagination.PrevTrigger asChild>
<IconButton onClick={() => setUrl((prev) => ({ ...prev, page: page - 1 }))} isDisabled={page <= 1} color="text">
<ChevronLeft />
</IconButton>
</Pagination.PrevTrigger>
<Pagination.Items
render={(pageItem) => (
<IconButton
key={pageItem.value}
variant={pageItem.value === page ? "surface" : "ghost"}
bg={pageItem.value === page ? "widgetBadgeBg" : "transparent"}
color="text"
onClick={() => setUrl((prev) => ({ ...prev, page: pageItem.value }))}
>
{pageItem.value}
</IconButton>
)}
/>
<Pagination.NextTrigger asChild>
<IconButton onClick={() => setUrl((prev) => ({ ...prev, page: page + 1 }))} isDisabled={page * pageSize >= count} color="text">
<ChevronRight />
</IconButton>
</Pagination.NextTrigger>
</ButtonGroup>
</Pagination.Root>
</Flex>
);
};
// ============================================================================
// 3. MAIN DATA TABLE (The Exported Presentation Wrapper)
// ============================================================================
const DataTable = React.memo(({ data, count, url, setUrl, allColumns, isLoading, onDownload }) => {
const columns = Object.keys(data[0] || {});
const handleSort = (col) => {
let order = col;
if (url.ordering === col) {
order = `-${col}`;
}
setUrl((prev) => ({ ...prev, ordering: order }));
};
return (
<Flex direction="column" w="100%" gap="4">
{/* Top Table Toolbar */}
<Flex w="100%" justify="space-between" align="center">
{allColumns && (
<ColumnSelector allColumns={allColumns} url={url} setUrl={setUrl} isLoading={isLoading} />
)}
<Button onClick={onDownload} variant="outline" borderColor="widgetBadgeBg" color="text" size="sm" _hover={{ bg: "widgetItemExpandedBg" }}>
<Download style={{ marginRight: "8px" }} /> Download CSV
</Button>
</Flex>
{/* Main Scrollable Table */}
<Table.ScrollArea borderWidth="1px" borderColor="widgetBadgeBg" rounded="md" maxH="65vh" bg="widgetItemBg">
<Table.Root stickyHeader showColumnBorder size="sm">
<Table.Header>
<Table.Row bg="widgetItemExpandedBg">
{columns.map((col) => (
<Table.ColumnHeader key={col} color="text" py="2">
<Button
variant="ghost"
size="sm"
color="text"
onClick={() => handleSort(col)}
w="100%"
justifyContent="flex-start"
_hover={{ bg: "widgetBadgeBg" }}
>
{col}{" "}
<ChevronDown
style={{
opacity: url.ordering === col || url.ordering === `-${col}` ? 1 : 0,
transform: url.ordering === col ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s",
marginLeft: "8px",
}}
/>
</Button>
</Table.ColumnHeader>
))}
</Table.Row>
</Table.Header>
<Table.Body>
{data.map((row, i) => (
<Table.Row key={i} _hover={{ bg: "widgetItemExpandedBg" }}>
{columns.map((col) => (
<Table.Cell key={col} maxW="150px" textAlign="start" truncate color="text" borderColor="widgetBadgeBg">
<HoverCard.Root>
<HoverCard.Trigger asChild>
<span style={{ cursor: "pointer" }}>{row[col]}</span>
</HoverCard.Trigger>
<HoverCard.Positioner>
<HoverCard.Content p="4" maxW="400px" whiteSpace="normal" wordBreak="break-word" rounded="md" shadow="lg" bg="widgetItemExpandedBg" color="text">
<Text>{row[col]}</Text>
</HoverCard.Content>
</HoverCard.Positioner>
</HoverCard.Root>
</Table.Cell>
))}
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
{/* Bottom Pagination Footer */}
<PageFooter count={count} pageSize={50} url={url} setUrl={setUrl} />
</Flex>
);
});
export default DataTable;
@@ -1,318 +0,0 @@
import React, { useRef } from "react";
import { Button, Stack, Flex, Field, NativeSelect, Input, HoverCard, Text, NumberInput, Box } from "@chakra-ui/react";
import { Plus, CircleMinus, Files, ClipboardPaste, Trash } from 'lucide-react';
// import { GrAdd, GrSubtractCircle } from "react-icons/gr";
// import { FaCopy, FaPaste, FaTrash } from "react-icons/fa";
// ============================================================================
// 1. FILTER ROW (The individual criteria inputs)
// ============================================================================
const FilterRow = React.memo(({ filter, columns, setUrl, bundleId, copiedFilter }) => {
const types = {
AutoField: ["=", "<", ">", "<=", ">="],
IntegerField: ["=", "<", ">", "<=", ">="],
CharField: ["=", "contains"],
DateTimeField: ["range"],
};
const columnType = columns[filter.column];
const operators = types[columnType] || ["="]; // default to equals if unknown
const handleRemoveFilter = () => {
setUrl(prev => ({
...prev,
bundles: prev.bundles.map((bundle) =>
bundle.id === bundleId ?
{ ...bundle, filters: bundle.filters.filter((fil) => fil.id !== filter.id) }
: bundle
),
}));
};
const updateFilterField = (field, value) => {
setUrl(prev => ({
...prev,
bundles: prev.bundles.map(bundle =>
bundle.id === bundleId
? {
...bundle,
filters: bundle.filters.map(fil =>
fil.id === filter.id ? { ...fil, [field]: value } : fil
),
}
: bundle
),
}));
};
const handleColumnChange = (newCol) => {
setUrl((prev) => ({
...prev,
bundles: prev.bundles.map((bundle) =>
bundle.id === bundleId
? {
...bundle,
filters: bundle.filters.map((fil) =>
fil.id === filter.id
? { ...fil, column: newCol, type: columns[newCol], operator: null, value: null }
: fil
),
}
: bundle
),
}));
};
const handleDateChange = (type, newDate) => {
const currentValue = filter.value || { start: null, end: null };
const updatedValue = { ...currentValue, [type]: newDate || null };
updateFilterField("value", updatedValue);
updateFilterField("operator", "range");
};
return (
<Flex gap="2" align="center">
{/* Remove button */}
<HoverCard.Root positioning={{ placement: "top" }}>
<HoverCard.Trigger asChild>
<Button onClick={handleRemoveFilter} variant="ghost" size="sm" color="red.400">
<CircleMinus />
</Button>
</HoverCard.Trigger>
<HoverCard.Positioner>
<HoverCard.Content p="2" bg="widgetItemExpandedBg"><Text fontSize="sm">Remove Filter</Text></HoverCard.Content>
</HoverCard.Positioner>
</HoverCard.Root>
{/* Copy / Paste Buttons */}
<HoverCard.Root positioning={{ placement: "top" }}>
<HoverCard.Trigger asChild>
<Button onClick={() => (copiedFilter.current = { ...filter })} variant="ghost" size="sm" color="text">
<Files />
</Button>
</HoverCard.Trigger>
<HoverCard.Positioner>
<HoverCard.Content p="2" bg="widgetItemExpandedBg"><Text fontSize="sm">Copy Filter</Text></HoverCard.Content>
</HoverCard.Positioner>
</HoverCard.Root>
<HoverCard.Root positioning={{ placement: "top" }}>
<HoverCard.Trigger asChild>
<Button
onClick={() => {
if (copiedFilter.current) {
setUrl((prev) => ({
...prev,
bundles: prev.bundles.map((bundle) =>
bundle.id === bundleId
? {
...bundle,
filters: bundle.filters.map((fil) =>
fil.id === filter.id ? { ...copiedFilter.current, id: filter.id } : fil
),
}
: bundle
),
}));
}
}}
variant="ghost"
size="sm"
color="text"
>
<ClipboardPaste />
</Button>
</HoverCard.Trigger>
<HoverCard.Positioner>
<HoverCard.Content p="2" bg="widgetItemExpandedBg"><Text fontSize="sm">Paste Filter</Text></HoverCard.Content>
</HoverCard.Positioner>
</HoverCard.Root>
{/* Column selector */}
<Field.Root w={columnType === "DateTimeField" ? "30%" : "30%"}>
<NativeSelect.Root>
<NativeSelect.Field
bg="widgetItemBg"
color="text"
value={filter.column ?? ""}
onChange={(e) => handleColumnChange(e.target.value)}
>
<option value="" disabled hidden>Select Column</option>
{Object.keys(columns).map((colName) => (
<option key={colName} value={colName}>{colName}</option>
))}
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
</Field.Root>
{/* Operator selector */}
<Field.Root w={columnType === "DateTimeField" ? "20%" : "20%"}>
{columnType === "DateTimeField" ? (
<Input type="date" bg="widgetItemBg" color="text" value={filter.value?.start || ""} onChange={(e) => handleDateChange("start", e.target.value)} />
) : (
<NativeSelect.Root>
<NativeSelect.Field bg="widgetItemBg" color="text" value={filter.operator ?? ""} onChange={(e) => updateFilterField("operator", e.target.value)}>
<option value="" disabled hidden>Operator</option>
{filter.type && operators.map((op) => <option key={op} value={op}>{op}</option>)}
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
)}
</Field.Root>
{/* Value input */}
<Field.Root w={columnType === "DateTimeField" ? "20%" : "50%"}>
{columnType === "DateTimeField" ? (
<Input type="date" bg="widgetItemBg" color="text" value={filter.value?.end || ""} onChange={(e) => handleDateChange("end", e.target.value)} />
) : columnType === "IntegerField" || columnType === "AutoField" ? (
<NumberInput.Root value={filter.value ?? ""} onValueChange={(e) => updateFilterField("value", e.value)}>
<NumberInput.Input placeholder="Value" bg="widgetItemBg" color="text" />
</NumberInput.Root>
) : (
<Input placeholder="Value" bg="widgetItemBg" color="text" value={filter.value ?? ""} onChange={(e) => updateFilterField("value", e.target.value)} />
)}
</Field.Root>
</Flex>
);
});
// ============================================================================
// 2. FILTER BUNDLES (The AND logic groups)
// ============================================================================
const FilterBundle = ({ bundle, columns, url, setUrl, copiedFilter }) => {
const handleRemoveBundle = () => {
setUrl(prev => ({
...prev,
bundles: prev.bundles.filter(b => b.id !== bundle.id)
}));
};
const handleAddFilter = () => {
setUrl(prev => ({
...prev,
bundles: prev.bundles.map(b =>
b.id === bundle.id ? ({
...b,
filters: [
...b.filters,
{
id: crypto.randomUUID(),
column: null,
type: null,
operator: null,
value: null
}
]
}) : (b)
),
}));
};
return (
<Stack bg="widgetItemBg" borderWidth="1px" borderColor="widgetBadgeBg" borderRadius="lg" p="3" spacing="3">
<Flex justify="space-between" align="center">
<Input placeholder="Group Name (Optional)" maxW="200px" size="sm" variant="flushed" color="text" />
<Button onClick={handleRemoveBundle} variant="ghost" size="sm" color="red.400">
<Trash />
</Button>
</Flex>
<Stack spacing="2">
{bundle.filters.map((filter) => (
<FilterRow key={filter.id} filter={filter} columns={columns} setUrl={setUrl} bundleId={bundle.id} copiedFilter={copiedFilter} />
))}
<Button onClick={handleAddFilter} variant="outline" size="sm" color="text" borderColor="widgetBadgeBg" _hover={{ bg: "widgetBadgeBg" }}>
Add Filter <Plus />
</Button>
</Stack>
</Stack>
);
};
// ============================================================================
// 3. MAIN QUERY BUILDER (The Engine Wrapper)
// ============================================================================
export default function QueryBuilder({ url, setUrl, allColumns, getColumns, pullTable, isLoading, isReadyRef }) {
const copiedFilter = useRef(null);
const handleAddBundle = () => {
setUrl(prev => ({
...prev,
bundles: [
...prev.bundles,
{
id: crypto.randomUUID(),
filters: [{
id: crypto.randomUUID(),
column: null,
type: null,
operator: null,
value: null
}],
},
],
}));
};
return (
<Flex direction="column" gap="4" w="100%" p="4" bg="transparent">
{/* Top Controls: Source & Execute */}
<Flex gap="4" align="flex-end">
<Field.Root maxW="300px">
<Field.Label color="text">Data Source</Field.Label>
<NativeSelect.Root>
<NativeSelect.Field
bg="widgetItemBg"
color="text"
value={url.source || ""}
onChange={(e) => {
const selectedView = e.target.value;
setUrl(prev => ({ ...prev, source: selectedView }));
// 🚨 FIXED: Now uses relative pathing for the production API
const endpoint = "/api/v3/view_dash_event_search_columns";
getColumns(endpoint);
}}
>
<option value="" disabled hidden>Select View</option>
<option value="main">Main View</option>
{/*<option value="test">Test View</option>*/}
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
</Field.Root>
<Button
bg="blue.600"
color="white"
_hover={{ bg: "blue.500" }}
loading={isLoading}
onClick={() => {
isReadyRef.current = true;
pullTable();
}}
>
Execute Search
</Button>
</Flex>
{/* Filter Builder Engine */}
{url.source && allColumns?.types && (
<Stack w="100%" gap="4">
<Text fontSize="lg" fontWeight="bold" color="text">Filters</Text>
<Box w="100%" maxH="400px" overflow="auto">
<Stack w="100%" gap="4" pr="4">
{url.bundles.map((bundle) => (
<FilterBundle key={bundle.id} bundle={bundle} columns={allColumns.types} url={url} setUrl={setUrl} copiedFilter={copiedFilter} />
))}
<Button onClick={handleAddBundle} variant="outline" borderColor="widgetBadgeBg" color="text" _hover={{ bg: "widgetBadgeBg" }}>
New Filter Group <Plus />
</Button>
</Stack>
</Box>
</Stack>
)}
</Flex>
);
}
-5
View File
@@ -46,11 +46,6 @@ const useAuthStore = create((set) => ({
// Hit the new lightweight endpoint
const res = await api.get('session/');
// 🔨 Security Fix a broken API call, Verify we actually got a user object back, not HTML fallback text
if (typeof res.data === 'string' || !res.data.username) {
throw new Error("Received invalid data format instead of user session");
}
// Rehydrate the store with the user's info!
set({
isAuthenticated: true,
+19 -19
View File
@@ -118,11 +118,11 @@ const sidebarRecipe = defineSlotRecipe({
_hover: {
bg: "rgba(255, 255, 255, 0.2)",
color: "{text}",
// "& svg": {
// strokeWidth: "3",
// },
"& svg": {
strokeWidth: "3",
},
"& span": {
fontWeight: "800",
fontWeight: "700",
},
},
@@ -353,7 +353,7 @@ const glassPopoverRecipe = defineSlotRecipe({
borderRadius: "xl",
p: 3,
boxShadow: "lg",
bg: {base: "rgba(255, 255, 255, 0.85)", _dark: "rgba(20, 22, 25, 0.85)"},
bg: {base: "rgba(255, 255, 255, 0.95)", _dark: "rgba(20, 22, 25, 0.95)"},
backdropFilter: "blur(15px)",
WebkitBackdropFilter: "blur(15px)",
},
@@ -530,12 +530,13 @@ export const rawWidgetColors = {
========================================================================== */
const config = defineConfig({
globalCss: {
// the shadow box behind widgets when editing layout
".react-grid-placeholder": {
background: "#c0d2e3ff !important",
borderColor: "#a9b6c2ff !important",
boxShadow: "0 0 10px #c0d2e3ff !important",
opacity: "1 !important",
borderRadius: "5px",
borderRadius: "3xl",
_dark: {
background: "#393c3fff !important",
borderColor: "#000000ff !important",
@@ -543,9 +544,6 @@ const config = defineConfig({
opacity: "1 !important",
},
},
".react-resizable-handle-se": {
zIndex: "1",
},
"*": {
scrollbarWidth: "thin",
scrollbarColor: "#a0aec0 transparent",
@@ -566,6 +564,7 @@ const config = defineConfig({
"::-webkit-scrollbar-track": {
background: "transparent",
},
// this is what changes the maps colors to 'dark mode'
".leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container": {
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
@@ -574,9 +573,10 @@ const config = defineConfig({
".leaflet-container": {
fontFamily: "system-ui, sans-serif !important",
},
".react-grid-item": {
transition: "none !important"
},
// removes the animation of moving widgets, seems very harsh and jumpy w/o animation
// ".react-grid-item": {
// transition: "none !important"
// },
},
theme: {
semanticTokens: {
@@ -584,8 +584,8 @@ const config = defineConfig({
text: { value: { base: "#000000", _dark: "#FFFFFF" } },
textInverted: { value: { base: "#000000", _dark: "#FFFFFF" } }, // Fixed: Properly inverted
MainBackground: { value: { base: "#F9F9F9", _dark: "#141414" } },
titleColor: { value: { base: "gray.800", _dark: "#FFFFFF" } },
iconColor: { value: { base: "gray.600", _dark: "gray.300" } }, // Fixed: Visible in dark mode
titleColor: { value: { base: "colors.gray.800", _dark: "#FFFFFF" } },
iconColor: { value: { base: "colors.gray.600", _dark: "colors.gray.300" } }, // Fixed: Visible in dark mode
glassSeperator: { value: { base: "#0000001A", _dark: "#FFFFFF3F" } },
gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "rgba(255, 255, 255, 0.1)"}},
@@ -593,13 +593,13 @@ const config = defineConfig({
widgetItemBg: { value: { base: "rgba(0, 0, 0, 0.04)", _dark: "rgba(255, 255, 255, 0.05)" } },
widgetItemExpandedBg: { value: { base: "rgba(0, 0, 0, 0.08)", _dark: "rgba(0, 0, 0, 0.3)" } },
widgetBadgeBg: { value: { base: "rgba(255, 255, 255, 0.6)", _dark: "rgba(0, 0, 0, 0.5)" } },
widgetTextMuted: { value: { base: "gray.600", _dark: "gray.400" } },
widgetTextMuted: { value: { base: "colors.gray.600", _dark: "colors.gray.400" } },
// ALERT COLORS
alertRed: { value: { base: "red.600", _dark: "red.400" } },
alertOrange: { value: { base: "orange.600", _dark: "orange.400" } },
alertYellow: { value: { base: "yellow.600", _dark: "yellow.400" } },
alertGreen: { value: { base: "green.600", _dark: "green.400" } },
alertRed: { value: { base: "colors.red.600", _dark: "colors.red.400" } },
alertOrange: { value: { base: "colors.orange.600", _dark: "colors.orange.400" } },
alertYellow: { value: { base: "colors.yellow.600", _dark: "colors.yellow.400" } },
alertGreen: { value: { base: "colors.green.600", _dark: "colors.green.400" } },
},
},
tokens: {