fix(costs): harden company auth check, fix frozen date memo, hide empty quota rows
- add company existence check on quota-windows route to guard against sentinel and forged company IDs (was a no-op assertCompanyAccess) - fix useDateRange minuteTick memo frozen at mount; realign interval to next calendar minute boundary via setTimeout + intervalRef pattern - fix midnight timer in Costs.tsx to use stable [] dep and self-scheduling todayTimerRef to avoid StrictMode double-invoke - return null for rolling window rows with no DB data instead of rendering $0.00 / 0 tok false zeros - fix secondsToWindowLabel to handle windows >168h with actual day count instead of silently falling back to 7d - fix byProvider.get(p) non-null assertion to use ?? [] fallback
This commit is contained in:
@@ -82,6 +82,13 @@ export function costRoutes(db: Db) {
|
|||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
|
// validate companyId resolves to a real company so the "__none__" sentinel
|
||||||
|
// and any forged ids are rejected before we touch provider credentials
|
||||||
|
const company = await companies.getById(companyId);
|
||||||
|
if (!company) {
|
||||||
|
res.status(404).json({ error: "Company not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const results = await fetchAllQuotaWindows();
|
const results = await fetchAllQuotaWindows();
|
||||||
res.json(results);
|
res.json(results);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ function secondsToWindowLabel(seconds: number | null | undefined, fallback: stri
|
|||||||
const hours = seconds / 3600;
|
const hours = seconds / 3600;
|
||||||
if (hours < 6) return "5h";
|
if (hours < 6) return "5h";
|
||||||
if (hours <= 24) return "24h";
|
if (hours <= 24) return "24h";
|
||||||
return "7d";
|
if (hours <= 168) return "7d";
|
||||||
|
// for windows larger than 7d, show the actual day count rather than silently mislabelling
|
||||||
|
return `${Math.round(hours / 24)}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCodexQuota(token: string, accountId: string | null): Promise<QuotaWindow[]> {
|
async function fetchCodexQuota(token: string, accountId: string | null): Promise<QuotaWindow[]> {
|
||||||
|
|||||||
@@ -137,8 +137,10 @@ export function ProviderQuotaCard({
|
|||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{ROLLING_WINDOWS.map((w) => {
|
{ROLLING_WINDOWS.map((w) => {
|
||||||
const row = windowMap.get(w);
|
const row = windowMap.get(w);
|
||||||
const cents = row?.costCents ?? 0;
|
// omit windows with no data rather than showing false $0.00 zeros
|
||||||
const tokens = (row?.inputTokens ?? 0) + (row?.outputTokens ?? 0);
|
if (!row) return null;
|
||||||
|
const cents = row.costCents;
|
||||||
|
const tokens = row.inputTokens + row.outputTokens;
|
||||||
const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0;
|
const barPct = maxWindowCents > 0 ? (cents / maxWindowCents) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div key={w} className="space-y-1">
|
<div key={w} className="space-y-1">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
|
||||||
|
|
||||||
@@ -13,17 +13,12 @@ export const PRESET_LABELS: Record<DatePreset, string> = {
|
|||||||
|
|
||||||
export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
|
||||||
|
|
||||||
// note: computeRange calls new Date() at evaluation time. for sliding presets (7d, 30d, etc.)
|
// note: computeRange is called inside a useMemo that re-evaluates once per minute
|
||||||
// the window is computed once at render time and can be up to ~1 minute stale between re-renders.
|
// (driven by minuteTick). this means sliding windows (7d, 30d) advance their upper
|
||||||
// this is acceptable for a cost dashboard but means the displayed range may lag wall clock time
|
// bound at most once per minute — acceptable for a cost dashboard.
|
||||||
// slightly between poll ticks.
|
|
||||||
function computeRange(preset: DatePreset): { from: string; to: string } {
|
function computeRange(preset: DatePreset): { from: string; to: string } {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// floor `to` to the nearest minute so the query key is stable across 30s refetch ticks
|
const to = now.toISOString();
|
||||||
// (prevents a new cache entry being created on every poll cycle)
|
|
||||||
const toFloored = new Date(now);
|
|
||||||
toFloored.setSeconds(0, 0);
|
|
||||||
const to = toFloored.toISOString();
|
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
case "mtd": {
|
case "mtd": {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
const d = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
@@ -47,6 +42,14 @@ function computeRange(preset: DatePreset): { from: string; to: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// floor a Date to the nearest minute so the query key is stable across
|
||||||
|
// 30s refetch ticks (prevents new cache entries on every poll cycle)
|
||||||
|
function floorToMinute(d: Date): string {
|
||||||
|
const floored = new Date(d);
|
||||||
|
floored.setSeconds(0, 0);
|
||||||
|
return floored.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseDateRangeResult {
|
export interface UseDateRangeResult {
|
||||||
preset: DatePreset;
|
preset: DatePreset;
|
||||||
setPreset: (p: DatePreset) => void;
|
setPreset: (p: DatePreset) => void;
|
||||||
@@ -66,6 +69,27 @@ export function useDateRange(): UseDateRangeResult {
|
|||||||
const [customFrom, setCustomFrom] = useState("");
|
const [customFrom, setCustomFrom] = useState("");
|
||||||
const [customTo, setCustomTo] = useState("");
|
const [customTo, setCustomTo] = useState("");
|
||||||
|
|
||||||
|
// tick at the next calendar minute boundary, then every 60s, so sliding presets
|
||||||
|
// (7d, 30d) advance their upper bound in sync with wall clock minutes rather than
|
||||||
|
// drifting by the mount offset.
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const [minuteTick, setMinuteTick] = useState(() => floorToMinute(new Date()));
|
||||||
|
useEffect(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setMinuteTick(floorToMinute(new Date()));
|
||||||
|
intervalRef.current = setInterval(
|
||||||
|
() => setMinuteTick(floorToMinute(new Date())),
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
}, msToNextMinute);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (intervalRef.current != null) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { from, to } = useMemo(() => {
|
const { from, to } = useMemo(() => {
|
||||||
if (preset !== "custom") return computeRange(preset);
|
if (preset !== "custom") return computeRange(preset);
|
||||||
// treat custom date strings as local-date boundaries so the full day is included
|
// treat custom date strings as local-date boundaries so the full day is included
|
||||||
@@ -76,7 +100,9 @@ export function useDateRange(): UseDateRangeResult {
|
|||||||
from: fromDate ? fromDate.toISOString() : "",
|
from: fromDate ? fromDate.toISOString() : "",
|
||||||
to: toDate ? toDate.toISOString() : "",
|
to: toDate ? toDate.toISOString() : "",
|
||||||
};
|
};
|
||||||
}, [preset, customFrom, customTo]);
|
// minuteTick drives re-evaluation of sliding presets once per minute.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [preset, customFrom, customTo, minuteTick]);
|
||||||
|
|
||||||
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
|
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
import type { CostByProviderModel, CostWindowSpendRow, QuotaWindow } from "@paperclipai/shared";
|
||||||
import { costsApi } from "../api/costs";
|
import { costsApi } from "../api/costs";
|
||||||
@@ -71,16 +71,23 @@ export function Costs() {
|
|||||||
setBreadcrumbs([{ label: "Costs" }]);
|
setBreadcrumbs([{ label: "Costs" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
// today as state so a scheduled effect can flip it at midnight, triggering a fresh weekRange
|
// today as state so the weekRange memo refreshes after midnight.
|
||||||
|
// stable [] dep + ref avoids the StrictMode double-invoke problem of the
|
||||||
|
// chained [today] dep pattern (which would schedule two concurrent timers).
|
||||||
const [today, setToday] = useState(() => new Date().toDateString());
|
const [today, setToday] = useState(() => new Date().toDateString());
|
||||||
|
const todayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const msUntilMidnight = () => {
|
const schedule = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime();
|
const ms = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime() - now.getTime();
|
||||||
|
todayTimerRef.current = setTimeout(() => {
|
||||||
|
setToday(new Date().toDateString());
|
||||||
|
schedule();
|
||||||
|
}, ms);
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => setToday(new Date().toDateString()), msUntilMidnight());
|
schedule();
|
||||||
return () => clearTimeout(timer);
|
return () => { if (todayTimerRef.current != null) clearTimeout(todayTimerRef.current); };
|
||||||
}, [today]);
|
}, []);
|
||||||
const weekRange = useMemo(() => currentWeekRange(), [today]);
|
const weekRange = useMemo(() => currentWeekRange(), [today]);
|
||||||
|
|
||||||
// ---------- spend tab queries (no polling — cost data doesn't change in real time) ----------
|
// ---------- spend tab queries (no polling — cost data doesn't change in real time) ----------
|
||||||
@@ -247,7 +254,7 @@ export function Costs() {
|
|||||||
},
|
},
|
||||||
...providers.map((p) => ({
|
...providers.map((p) => ({
|
||||||
value: p,
|
value: p,
|
||||||
label: <ProviderTabLabel provider={p} rows={byProvider.get(p)!} />,
|
label: <ProviderTabLabel provider={p} rows={byProvider.get(p) ?? []} />,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
}, [providers, byProvider]);
|
}, [providers, byProvider]);
|
||||||
|
|||||||
Reference in New Issue
Block a user