diff --git a/src/components/events/partials/ModalTabsAndPages/EventDetailsUsageDailyDetail.tsx b/src/components/events/partials/ModalTabsAndPages/EventDetailsUsageDailyDetail.tsx new file mode 100644 index 0000000000..42675f1e56 --- /dev/null +++ b/src/components/events/partials/ModalTabsAndPages/EventDetailsUsageDailyDetail.tsx @@ -0,0 +1,213 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import DatePicker from "react-datepicker"; +import type { ChartOptions } from "chart.js"; +import { addDays, eachDayOfInterval, format, subDays } from "date-fns"; +import BarChart from "../../../shared/BarChart"; +import { NotificationComponent } from "../../../shared/Notifications"; +import ButtonLikeAnchor from "../../../shared/ButtonLikeAnchor"; +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { + getUsageDailyStatistics, + hasUsageDailyStatisticsError, + isFetchingUsageDailyStatistics, +} from "../../../../selectors/eventDetailsSelectors"; +import { fetchEventUsageDailyStatistics } from "../../../../slices/eventDetailsSlice"; +import { formatHMS } from "../../../../utils/dateUtils"; +import { getCurrentLanguageInformation } from "../../../../utils/utils"; +import i18n from "../../../../i18n/i18n"; + +type Metric = "views" | "watchtime"; + +const DATE_FORMAT = "yyyy-MM-dd"; +const HOUR_LABELS = Array.from({ length: 24 }, (_, hour) => hour.toString().padStart(2, "0")); + +// The backend reports "day" and hour-of-day buckets in UTC. Reconstruct the +// actual UTC instant for a given bucket so it can be re-bucketed into the +// viewer's local day/hour for display. +const utcBucketToLocalDate = (day: string, hour: number): Date => { + const [year, month, dayOfMonth] = day.split("-").map(Number); + return new Date(Date.UTC(year, month - 1, dayOfMonth, hour)); +}; + +/** + * Shows per-day and hour-of-day breakdowns of an event's views/watchtime. + */ +const EventDetailsUsageDailyDetail = ({ eventId }: { eventId: string }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const [metric, setMetric] = useState("views"); + const [startDate, setStartDate] = useState(subDays(new Date(), 29)); + // null while the user has picked a new start but not yet a matching end + // (react-datepicker's own convention for an in-progress range selection). + const [endDate, setEndDate] = useState(new Date()); + + const dailyStatistics = useAppSelector(state => getUsageDailyStatistics(state)); + const isFetching = useAppSelector(state => isFetchingUsageDailyStatistics(state)); + const hasError = useAppSelector(state => hasUsageDailyStatisticsError(state)); + + const rangeEnd = endDate ?? startDate; + + useEffect(() => { + if (!endDate) { + return; + } + // Widen by a day on each side: a viewer ahead of UTC has local hours + // early on their first day that fall in the *previous* UTC day (and + // symmetrically for a viewer behind UTC on their last day), so the + // UTC-day-bounded backend range needs padding to cover them. + dispatch(fetchEventUsageDailyStatistics({ + eventId, + from: format(subDays(startDate, 1), DATE_FORMAT), + to: format(addDays(endDate, 1), DATE_FORMAT), + })); + }, [dispatch, eventId, startDate, endDate]); + + const dateLocale = getCurrentLanguageInformation(i18n.language)?.dateLocale; + + // Re-bucket the UTC day/hour data the backend returns into the viewer's + // local day/hour, then drop anything outside the viewer's selected local + // range (the padded fetch above can return local days beyond it). + const localizedBuckets = useMemo(() => { + const rangeStartKey = format(startDate, DATE_FORMAT); + const rangeEndKey = format(rangeEnd, DATE_FORMAT); + const buckets: { localDay: string, localHour: number, value: number }[] = []; + for (const entry of dailyStatistics) { + entry[metric].forEach((value, hour) => { + const localDate = utcBucketToLocalDate(entry.day, hour); + const localDay = format(localDate, DATE_FORMAT); + if (localDay < rangeStartKey || localDay > rangeEndKey) { + return; + } + buckets.push({ localDay, localHour: localDate.getHours(), value }); + }); + } + return buckets; + }, [dailyStatistics, metric, startDate, rangeEnd]); + + const dailyTotals = useMemo(() => { + const totalsByLocalDay = new Map(); + for (const bucket of localizedBuckets) { + totalsByLocalDay.set(bucket.localDay, (totalsByLocalDay.get(bucket.localDay) ?? 0) + bucket.value); + } + return eachDayOfInterval({ start: startDate, end: rangeEnd }).map(day => { + const key = format(day, DATE_FORMAT); + return { label: format(day, "P", { locale: dateLocale }), total: totalsByLocalDay.get(key) ?? 0 }; + }); + }, [localizedBuckets, startDate, rangeEnd, dateLocale]); + + const hourlyTotals = useMemo(() => { + const totals = new Array(24).fill(0); + for (const bucket of localizedBuckets) { + totals[bucket.localHour] += bucket.value; + } + return totals; + }, [localizedBuckets]); + + const chartOptions = useMemo(() => buildChartOptions(metric), [metric]); + + return ( +
+
{t("EVENTS.EVENTS.DETAILS.USAGE.DAILY_CAPTION")}
+
+
+ + { + const [start, end] = dates; + if (start) { + setStartDate(start); + } + // Clear end when a new range starts, so react-datepicker + // treats the next click as completing it rather than + // starting yet another new range. + setEndDate(end); + }} + showYearDropdown + showMonthDropdown + swapRange + allowSameDay + dateFormat="P" + popperPlacement="bottom" + popperClassName="datepicker-custom" + className="usage-date-range-input" + locale={dateLocale} + /> +
+ + {hasError && ( + + )} + + {!isFetching && !hasError && ( + <> +
{t("EVENTS.EVENTS.DETAILS.USAGE.PER_DAY")}
+ day.total)} + axisLabels={dailyTotals.map(day => day.label)} + options={chartOptions} + /> + +
{t("EVENTS.EVENTS.DETAILS.USAGE.BY_HOUR_OF_DAY")}
+ + + )} +
+
+ ); +}; + +const buildChartOptions = (metric: Metric): ChartOptions<"bar"> => ({ + responsive: true, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: context => { + const value = context.parsed.y ?? 0; + return metric === "watchtime" ? formatHMS(value) : String(value); + }, + }, + }, + }, + scales: { + y: { + beginAtZero: true, + ticks: { + precision: metric === "views" ? 0 : undefined, + callback: value => metric === "watchtime" ? formatHMS(Number(value)) : value, + }, + }, + }, +}); + +export default EventDetailsUsageDailyDetail; diff --git a/src/components/events/partials/ModalTabsAndPages/EventDetailsUsageTab.tsx b/src/components/events/partials/ModalTabsAndPages/EventDetailsUsageTab.tsx new file mode 100644 index 0000000000..711725db2e --- /dev/null +++ b/src/components/events/partials/ModalTabsAndPages/EventDetailsUsageTab.tsx @@ -0,0 +1,114 @@ +import { useTranslation } from "react-i18next"; +import { ParseKeys } from "i18next"; +import ModalContentTable from "../../../shared/modals/ModalContentTable"; +import { NotificationComponent } from "../../../shared/Notifications"; +import ButtonLikeAnchor from "../../../shared/ButtonLikeAnchor"; +import EventDetailsUsageDailyDetail from "./EventDetailsUsageDailyDetail"; +import { useAppDispatch, useAppSelector } from "../../../../store"; +import { + getModalUsageTabHierarchy, + getUsageStatistics, + hasUsageStatisticsError, + isFetchingUsageStatistics, +} from "../../../../selectors/eventDetailsSelectors"; +import { setModalUsageTabHierarchy } from "../../../../slices/eventDetailsSlice"; +import { formatHMS } from "../../../../utils/dateUtils"; + +export type UsageTabHierarchy = "overview" | "daily-detail"; + +/** + * This component manages the usage tab of the event details modal + */ +const EventDetailsUsageTab = ({ + eventId, + eventDate, + header, +}: { + eventId: string, + eventDate: string, + header: ParseKeys, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const tabHierarchy = useAppSelector(state => getModalUsageTabHierarchy(state)); + const usageStatistics = useAppSelector(state => getUsageStatistics(state)); + const hasError = useAppSelector(state => hasUsageStatisticsError(state)); + const isFetching = useAppSelector(state => isFetchingUsageStatistics(state)); + + const completenessThreshold = usageStatistics.completenessThreshold; + const isPossiblyIncomplete = !!completenessThreshold && !!eventDate + && new Date(eventDate).getTime() < new Date(completenessThreshold).getTime(); + + const openSubTab = (tabType: UsageTabHierarchy) => { + dispatch(setModalUsageTabHierarchy(tabType)); + }; + + return ( + + openSubTab("overview")} + > + {t("EVENTS.EVENTS.DETAILS.USAGE.TAB_OVERVIEW")} + + openSubTab("daily-detail")} + > + {t("EVENTS.EVENTS.DETAILS.USAGE.TAB_DETAILS")} + + + } + > + {tabHierarchy === "daily-detail" ? ( + + ) : ( + <> + {hasError && ( + + )} + {!isFetching && isPossiblyIncomplete && ( + + )} +
+
{t(header)}
+
+ {!isFetching && !hasError && ( + + + + + + + + + + + +
{t("EVENTS.EVENTS.DETAILS.USAGE.VIEWS")}{usageStatistics.views}
{t("EVENTS.EVENTS.DETAILS.USAGE.WATCHTIME")}{formatHMS(usageStatistics.watchtime)}
+ )} +
+
+ + )} +
+ ); +}; + +export default EventDetailsUsageTab; diff --git a/src/components/events/partials/modals/EventDetails.tsx b/src/components/events/partials/modals/EventDetails.tsx index eac0727b77..2f21d552dc 100644 --- a/src/components/events/partials/modals/EventDetails.tsx +++ b/src/components/events/partials/modals/EventDetails.tsx @@ -23,12 +23,16 @@ import { isFetchingStatistics, getModalWorkflowTabHierarchy, getModalPage, + getModalEvent, getEventDetailsTobiraDataError, getEventDetailsTobiraStatus, getWorkflows, + hasUsageStatistics as getHasUsageStatistics, + isFetchingUsageStatistics, } from "../../../../selectors/eventDetailsSelectors"; import { getUserInformation } from "../../../../selectors/userInfoSelectors"; import EventDetailsStatisticsTab from "../ModalTabsAndPages/EventDetailsStatisticsTab"; +import EventDetailsUsageTab from "../ModalTabsAndPages/EventDetailsUsageTab"; import { fetchAssetUploadOptions } from "../../../../thunks/assetsThunks"; import { hasAnyDeviceAccess } from "../../../../utils/resourceUtils"; import { getRecordings } from "../../../../selectors/recordingSelectors"; @@ -39,6 +43,7 @@ import { updateExtendedMetadata, fetchSchedulingInfo, fetchEventStatistics, + fetchEventUsageStatistics, openModalTab, fetchEventDetailsTobira, fetchHasActiveTransactions, @@ -65,6 +70,7 @@ export enum EventDetailsPage { Comments, Tobira, Statistics, + Usage, } export type WorkflowTabHierarchy = "workflows" | "workflow-details" | "workflow-operations" | "workflow-operation-details" | "errors-and-warnings" | "workflow-error-details" @@ -94,6 +100,7 @@ const EventDetails = ({ dispatch(fetchMetadata(eventId)); dispatch(fetchSchedulingInfo(eventId)); dispatch(fetchEventStatistics(eventId)); + dispatch(fetchEventUsageStatistics(eventId)); dispatch(fetchAssetUploadOptions()); dispatch(fetchHasActiveTransactions(eventId)).then(fetchTransactionResult => { @@ -125,6 +132,7 @@ const EventDetails = ({ }, []); const page = useAppSelector(state => getModalPage(state)); + const modalEvent = useAppSelector(state => getModalEvent(state)); const workflowTabHierarchy = useAppSelector(state => getModalWorkflowTabHierarchy(state)); const user = useAppSelector(state => getUserInformation(state)); const metadata = useAppSelector(state => getMetadata(state)); @@ -134,6 +142,8 @@ const EventDetails = ({ const isLoadingScheduling = useAppSelector(state => isFetchingScheduling(state)); const hasStatistics = useAppSelector(state => getHasStatistics(state)); const isLoadingStatistics = useAppSelector(state => isFetchingStatistics(state)); + const hasUsageStatistics = useAppSelector(state => getHasUsageStatistics(state)); + const isLoadingUsageStatistics = useAppSelector(state => isFetchingUsageStatistics(state)); const captureAgents = useAppSelector(state => getRecordings(state)); const tobiraStatus = useAppSelector(state => getEventDetailsTobiraStatus(state)); const tobiraError = useAppSelector(state => getEventDetailsTobiraDataError(state)); @@ -220,6 +230,14 @@ const EventDetails = ({ page: EventDetailsPage.Statistics, hidden: !hasStatistics, }, + { + tabNameTranslation: "EVENTS.EVENTS.DETAILS.TABS.USAGE", + bodyHeaderTranslation: "EVENTS.EVENTS.DETAILS.USAGE.CAPTION", + accessRole: "ROLE_UI_EVENTS_DETAILS_USAGE_VIEW", + name: "usage", + page: EventDetailsPage.Usage, + hidden: !hasUsageStatistics, + }, ]; const openTab = (tabNr: EventDetailsPage) => { @@ -373,6 +391,13 @@ const EventDetails = ({ header={tabs[page].bodyHeaderTranslation ?? "EVENTS.EVENTS.DETAILS.STATISTICS.CAPTION"} /> )} + {page === EventDetailsPage.Usage && !isLoadingUsageStatistics && ( + + )} ); diff --git a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json index 623d59c521..9d41c16384 100644 --- a/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json +++ b/src/i18n/org/opencastproject/adminui/languages/lang-en_US.json @@ -718,6 +718,7 @@ "ACCESS": "Access policy", "COMMENTS": "Comments", "STATISTICS": "Statistics", + "USAGE": "Usage", "TOBIRA": "Tobira" }, "PUBLICATIONS": { @@ -968,6 +969,20 @@ "STATISTICS": { "CAPTION": "Statistics" }, + "USAGE": { + "CAPTION": "Usage", + "VIEWS": "Views", + "WATCHTIME": "Watchtime", + "NOT_AVAILABLE": "Usage statistics cannot be displayed at the moment. Please try again later.", + "INCOMPLETE_WARNING": "Statistical data from before {{date}} might be incomplete or completely missing.", + "TAB_OVERVIEW": "Overview", + "TAB_DETAILS": "Details", + "DAILY_CAPTION": "Usage details", + "PER_DAY": "Per day", + "BY_HOUR_OF_DAY": "By hour of day", + "METRIC_VIEWS": "Views", + "METRIC_WATCHTIME": "Watchtime" + }, "METADATA": { "CAPTION": "Event details", "TITLE": "Title", diff --git a/src/selectors/eventDetailsSelectors.ts b/src/selectors/eventDetailsSelectors.ts index 5ca19b608d..604c078b7b 100644 --- a/src/selectors/eventDetailsSelectors.ts +++ b/src/selectors/eventDetailsSelectors.ts @@ -10,6 +10,8 @@ export const getModalWorkflowTabHierarchy = (state: RootState) => state.eventDetails.modal.workflowTabHierarchy; export const getModalAssetsTabHierarchy = (state: RootState) => state.eventDetails.modal.assetsTabHierarchy; +export const getModalUsageTabHierarchy = (state: RootState) => + state.eventDetails.modal.usageTabHierarchy; /* selectors for metadata */ export const getMetadata = (state: RootState) => state.eventDetails.metadata; @@ -193,3 +195,20 @@ export const hasStatisticsError = (state: RootState) => state.eventDetails.hasStatisticsError; export const isFetchingStatistics = (state: RootState) => state.eventDetails.statusStatistics === "loading"; + +/* selectors for usage statistics */ +export const hasUsageStatistics = (state: RootState) => + state.eventDetails.statusUsageStatistics === "succeeded"; +export const getUsageStatistics = (state: RootState) => + state.eventDetails.usageStatistics; +export const hasUsageStatisticsError = (state: RootState) => + state.eventDetails.statusUsageStatistics === "failed"; +export const isFetchingUsageStatistics = (state: RootState) => + state.eventDetails.statusUsageStatistics === "loading"; + +export const getUsageDailyStatistics = (state: RootState) => + state.eventDetails.usageDailyStatistics; +export const hasUsageDailyStatisticsError = (state: RootState) => + state.eventDetails.statusUsageDailyStatistics === "failed"; +export const isFetchingUsageDailyStatistics = (state: RootState) => + state.eventDetails.statusUsageDailyStatistics === "loading"; diff --git a/src/slices/eventDetailsSlice.ts b/src/slices/eventDetailsSlice.ts index 8ae42dffde..fed0dc5453 100644 --- a/src/slices/eventDetailsSlice.ts +++ b/src/slices/eventDetailsSlice.ts @@ -33,6 +33,7 @@ import { EventDetailsPage, WorkflowTabHierarchy, } from "../components/events/partials/modals/EventDetails"; +import { UsageTabHierarchy } from "../components/events/partials/ModalTabsAndPages/EventDetailsUsageTab"; import { AppDispatch, AppThunk } from "../store"; import { Ace } from "./aclSlice"; import { setTobiraTabHierarchy, TobiraData } from "./seriesDetailsSlice"; @@ -46,6 +47,7 @@ type EventDetailsModal = { event: Event | null, workflowTabHierarchy: WorkflowTabHierarchy, assetsTabHierarchy: AssetTabHierarchy, + usageTabHierarchy: UsageTabHierarchy, workflowId: string, } @@ -216,6 +218,10 @@ type EventDetailsState = { errorStatistics: SerializedError | null, statusStatisticsValue: "uninitialized" | "loading" | "succeeded" | "failed", errorStatisticsValue: SerializedError | null, + statusUsageStatistics: "uninitialized" | "loading" | "succeeded" | "failed", + errorUsageStatistics: SerializedError | null, + statusUsageDailyStatistics: "uninitialized" | "loading" | "succeeded" | "failed", + errorUsageDailyStatistics: SerializedError | null, statusTobiraData: "uninitialized" | "loading" | "succeeded" | "failed", errorTobiraData: SerializedError | null, eventId: string, @@ -376,6 +382,16 @@ type EventDetailsState = { publications: Publication[], statistics: Statistics[], hasStatisticsError: boolean, + usageStatistics: { + views: number, + watchtime: number, + completenessThreshold: string, + }, + usageDailyStatistics: { + day: string, + views: number[], + watchtime: number[], + }[], tobiraData: TobiraData, } @@ -439,6 +455,10 @@ const initialState: EventDetailsState = { errorStatistics: null, statusStatisticsValue: "uninitialized", errorStatisticsValue: null, + statusUsageStatistics: "uninitialized", + errorUsageStatistics: null, + statusUsageDailyStatistics: "uninitialized", + errorUsageDailyStatistics: null, statusTobiraData: "uninitialized", errorTobiraData: null, eventId: "", @@ -448,6 +468,7 @@ const initialState: EventDetailsState = { event: null, workflowTabHierarchy: "workflow-details", assetsTabHierarchy: "entry", + usageTabHierarchy: "overview", workflowId: "", }, metadata: { @@ -604,6 +625,12 @@ const initialState: EventDetailsState = { publications: [], statistics: [], hasStatisticsError: false, + usageStatistics: { + views: 0, + watchtime: 0, + completenessThreshold: "", + }, + usageDailyStatistics: [], tobiraData: { baseURL: "", id: "", @@ -1492,6 +1519,7 @@ export const openModalTab = ( dispatch(setTobiraTabHierarchy("main")); dispatch(setModalWorkflowTabHierarchy(workflowTab)); dispatch(setModalAssetsTabHierarchy(assetsTab)); + dispatch(setModalUsageTabHierarchy("overview")); }; export const fetchWorkflowOperationDetails = createAppAsyncThunk("eventDetails/fetchWorkflowOperationDetails", async (params: { @@ -1573,6 +1601,35 @@ export const fetchEventStatisticsValueUpdate = createAppAsyncThunk("eventDetails ); }); +export const fetchEventUsageStatistics = createAppAsyncThunk("eventDetails/fetchEventUsageStatistics", async (eventId: Event["id"]) => { + const [totalResponse, completenessThresholdResponse] = await Promise.all([ + axios.get<{ views: number, watchtime: number }>( + "/basicstatistics-aggregation/video/total", + { params: { itemId: eventId } }, + ), + axios.get("/basicstatistics/completenessThreshold"), + ]); + + return { + views: totalResponse.data.views, + watchtime: totalResponse.data.watchtime, + completenessThreshold: completenessThresholdResponse.data, + }; +}); + +export const fetchEventUsageDailyStatistics = createAppAsyncThunk("eventDetails/fetchEventUsageDailyStatistics", async (params: { + eventId: Event["id"], + from: string, + to: string, +}) => { + const { eventId, from, to } = params; + const data = await axios.get( + "/basicstatistics-aggregation/video/daily", + { params: { itemId: eventId, from, to } }, + ); + return data.data; +}); + export const updateMetadata = createAppAsyncThunk("eventDetails/updateMetadata", async (params: { id: Event["id"], values: { [key: string]: MetadataCatalog["fields"][0]["value"] } @@ -1894,6 +1951,11 @@ const eventDetailsSlice = createSlice({ >) { state.modal.assetsTabHierarchy = action.payload; }, + setModalUsageTabHierarchy(state, action: PayloadAction< + EventDetailsState["modal"]["usageTabHierarchy"] + >) { + state.modal.usageTabHierarchy = action.payload; + }, setEventMetadata(state, action: PayloadAction< EventDetailsState["metadata"] >) { @@ -2526,6 +2588,42 @@ const eventDetailsSlice = createSlice({ state.errorStatisticsValue = action.error; console.error(action.error); }) + // fetchEventUsageStatistics + .addCase(fetchEventUsageStatistics.pending, state => { + state.statusUsageStatistics = "loading"; + }) + .addCase(fetchEventUsageStatistics.fulfilled, (state, action: PayloadAction< + EventDetailsState["usageStatistics"] + >) => { + state.statusUsageStatistics = "succeeded"; + state.usageStatistics = action.payload; + }) + .addCase(fetchEventUsageStatistics.rejected, (state, action) => { + state.statusUsageStatistics = "failed"; + state.usageStatistics = { + views: 0, + watchtime: 0, + completenessThreshold: "", + }; + state.errorUsageStatistics = action.error; + console.error(action.error); + }) + // fetchEventUsageDailyStatistics + .addCase(fetchEventUsageDailyStatistics.pending, state => { + state.statusUsageDailyStatistics = "loading"; + }) + .addCase(fetchEventUsageDailyStatistics.fulfilled, (state, action: PayloadAction< + EventDetailsState["usageDailyStatistics"] + >) => { + state.statusUsageDailyStatistics = "succeeded"; + state.usageDailyStatistics = action.payload; + }) + .addCase(fetchEventUsageDailyStatistics.rejected, (state, action) => { + state.statusUsageDailyStatistics = "failed"; + state.usageDailyStatistics = []; + state.errorUsageDailyStatistics = action.error; + console.error(action.error); + }) .addCase(fetchHasActiveTransactions.rejected, (_state, action) => { console.error(action.error); }) @@ -2557,6 +2655,7 @@ export const { setModalWorkflowId, setModalWorkflowTabHierarchy, setModalAssetsTabHierarchy, + setModalUsageTabHierarchy, setEventMetadata, setExtendedEventMetadata, setEventWorkflow, diff --git a/src/styles/components/_components-config.scss b/src/styles/components/_components-config.scss index 7f9de404b0..adb35d1c8b 100644 --- a/src/styles/components/_components-config.scss +++ b/src/styles/components/_components-config.scss @@ -39,6 +39,7 @@ @use "table-filter"; @use "tables"; @use "tooltips"; +@use "usage"; @use "video-player"; // Integrated From Extensions diff --git a/src/styles/components/_usage.scss b/src/styles/components/_usage.scss new file mode 100644 index 0000000000..09d8e12311 --- /dev/null +++ b/src/styles/components/_usage.scss @@ -0,0 +1,19 @@ +@use "../base/variables"; + +.usage-daily-controls { + display: flex; + flex-direction: column; + gap: 8px; +} + +.usage-chart-title { + font-weight: 600; + margin: 16px 0 8px; +} + +// Make range field look like input +.usage-date-range-input { + width: 240px !important; + padding: 0 8px !important; + cursor: pointer; +} diff --git a/vite.config.ts b/vite.config.ts index 579ed45ecb..90f207d653 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ open: true, port: Number(process.env.PORT) || 3000, proxy: { - "^/(admin-ng|acl-manager|api|info|services|staticfiles|sysinfo|ui)/.*": { + "^/(admin-ng|acl-manager|api|info|services|staticfiles|sysinfo|ui|basicstatistics|basicstatistics-aggregation)/.*": { target: process.env.PROXY_TARGET || "https://develop.opencast.org", changeOrigin: true, secure: false,