Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const config = {
webpackMemoryOptimizations: true,
preloadEntriesOnStart: false,
turbopackFileSystemCacheForDev: false,
turbopackMemoryLimit: 4096,
},
images: {
unoptimized: true,
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"name": "cipp",
"version": "10.10.2",
"version": "10.10.3",
"author": "CIPP Contributors",
"homepage": "https://cipp.app/",
"bugs": {
"url": "https://github.com/CyberDrain/CIPP/issues"
},
"license": "AGPL-3.0",
"engines": {
"node": "^22.22.2"
"node": "^22.22.0"
},
"repository": {
"type": "git",
Expand Down Expand Up @@ -134,20 +134,20 @@
"@testing-library/user-event": "14.6.1",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.5",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"eslint": "^9.39.4",
"eslint-config-next": "^16.3.4",
"eslint-config-prettier": "^10.1.8",
"jsdom": "30.0.1",
"jsdom": "29.0.1",
"msw": "2.15.0",
"msw-storybook-addon": "3.0.0",
"playwright": "1.63.0",
"prettier": "^3.9.6",
"storybook": "10.5.10",
"typescript": "5.9.3",
"vite": "8.2.2",
"vitest": "4.1.10"
"vitest": "4.1.11"
},
"msw": {
"workerDirectory": [
Expand All @@ -160,4 +160,4 @@
"sharp": "^0.35.0",
"monaco-editor/dompurify": "^3.4.14"
}
}
}
2 changes: 1 addition & 1 deletion public/version.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"version": "10.10.2"
"version": "10.10.3"
}
3 changes: 3 additions & 0 deletions src/components/CippComponents/CippTranslations.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,7 @@ export const CippTranslations = {
RequestsPriorHour: 'Requests In Prior Hour',
BaselinePerHour: 'Baseline / Hr',
SharePct: 'Share %',
ExecutedRequests: 'Executed',
ServedRequests: 'Served (incl. cached)',
EgressToday: 'Egress Today',
}
1 change: 1 addition & 0 deletions src/components/CippComponents/CippUserActions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,7 @@ export const useCippUserActions = () => {
],
confirmText: 'Select a SharePoint site and where to create the OneDrive shortcut:',
multiPost: false,
allowResubmit: true,
condition: () => canWriteUser,
},
{
Expand Down
48 changes: 43 additions & 5 deletions src/components/CippIntegrations/CippApiClientManagement.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,35 @@ const CippApiClientManagement = () => {
queryKey: "CustomRoleList",
});

// Authoritative per-client egress (today) from Craft's accounting table. Self-hides (Enabled:false)
// when accounting is off / not hosted, in which case the column shows "-".
const egressUsage = ApiGetCall({
url: "/api/ListApiEgress",
queryKey: "ApiEgressUsage",
});

// Merge the client list with egress so the table can show a per-client "Egress (today)" column.
// The list is small, so this drives the table from `data` (client-side) rather than the server api.
const clientRows = useMemo(() => {
const clients = apiClients.data?.pages?.[0]?.Results || [];
const usage = egressUsage.data?.Results?.Enabled ? egressUsage.data.Results.Clients || [] : [];
const byAppId = new Map(usage.map((c) => [String(c.AppId).toLowerCase(), c]));
const fmtBytes = (b) =>
b == null
? "-"
: b >= 1073741824
? `${(b / 1073741824).toFixed(1)} GB`
: b >= 1048576
? `${(b / 1048576).toFixed(1)} MB`
: b >= 1024
? `${(b / 1024).toFixed(1)} KB`
: `${b} B`;
return clients.map((c) => {
const e = byAppId.get(String(c.ClientId).toLowerCase());
return { ...c, EgressToday: e ? fmtBytes(e.Bytes) : "-", EgressSheddedToday: e ? e.Shed : 0 };
});
}, [apiClients.data, egressUsage.data]);

// MCP-enabled clients whose role restricts sign-in to specific IPs. Those restrictions apply to
// MCP traffic (which runs as the signed-in user), so an AI client's cloud egress IPs get blocked.
const mcpRoleIpWarnings = useMemo(() => {
Expand Down Expand Up @@ -462,12 +491,21 @@ const CippApiClientManagement = () => {
<CippDataTable
actions={actions}
title="CIPP-API Clients"
api={{
url: "/api/ExecApiClient",
data: { Action: "List" },
dataKey: "Results",
data={clientRows}
isFetching={apiClients.isFetching || egressUsage.isFetching}
refreshFunction={() => {
apiClients.refetch?.();
egressUsage.refetch?.();
}}
simpleColumns={["Enabled", "MCPAllowed", "AppName", "ClientId", "Role", "IPRange"]}
simpleColumns={[
"Enabled",
"MCPAllowed",
"AppName",
"ClientId",
"Role",
"IPRange",
"EgressToday",
]}
queryKey={`ApiClients`}
/>
</Stack>
Expand Down
209 changes: 209 additions & 0 deletions src/components/CippIntegrations/CippApiEgressCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { useMemo, useState } from "react";
import {
Card,
CardContent,
CardHeader,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
useTheme,
} from "@mui/material";
import { Grid } from "@mui/system";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
PolarAngleAxis,
RadialBar,
RadialBarChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
} from "recharts";
import { ApiGetCall } from "../../api/ApiCall";

// Authoritative per-client API egress from Craft's CraftEgressAccounting table (via /api/ListApiEgress):
// a used-of-cap gauge for the instance total, and a stacked-by-client trend over the selected window.
// Self-hides when accounting is off / not hosted. Reused on the Diagnostics and Integrations pages.

const RANGE_OPTIONS = [
{ label: "24h", hours: 24 },
{ label: "3d", hours: 72 },
{ label: "7d", hours: 168 },
];

const formatBytes = (b) => {
if (b == null) return "-";
if (b >= 1073741824) return `${(b / 1073741824).toFixed(2)} GB`;
if (b >= 1048576) return `${(b / 1048576).toFixed(1)} MB`;
if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${b} B`;
};

const formatBucketTime = (iso, hours) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
if (hours > 48) return `${d.getUTCMonth() + 1}/${d.getUTCDate()} ${hh}:${mm}`;
return `${hh}:${mm}`;
};

export const CippApiEgressCard = () => {
const theme = useTheme();
const [hours, setHours] = useState(24);

const query = ApiGetCall({
url: "/api/ListApiEgress",
data: { Hours: String(hours) },
queryKey: `ApiEgressUsage-${hours}`,
});
const r = query.data?.Results;

const palette = useMemo(
() => [
theme.palette.primary.main,
theme.palette.info.main,
theme.palette.success.main,
theme.palette.warning.main,
theme.palette.secondary.main,
theme.palette.error.main,
],
[theme]
);

// Stacked chart rows: bytes -> MB per client per bucket.
const chartData = useMemo(() => {
const ids = r?.ClientIds ?? [];
return (r?.Trend ?? []).map((bucket) => {
const row = { time: formatBucketTime(bucket.BucketStartUtc, hours) };
ids.forEach((id) => {
row[id] = Math.round(((bucket[id] ?? 0) / 1048576) * 100) / 100;
});
return row;
});
}, [r, hours]);

// Query resolved but accounting is off / no data: render nothing.
if (query.isSuccess && !r?.Enabled) return null;

const clientIds = r?.ClientIds ?? [];
const capBytes = r?.CapBytes ?? 0;
const pct = r?.PctOfCap ?? (capBytes > 0 ? Math.round(((r?.BytesToday ?? 0) / capBytes) * 100) : 0);
const capReached = r?.CapReachedUtc != null;
const gaugeColor = capReached
? theme.palette.error.main
: pct >= 80
? theme.palette.warning.main
: theme.palette.success.main;
const gaugeData = [{ name: "used", value: Math.min(100, Math.max(0, pct)), fill: gaugeColor }];

return (
<Card>
<CardHeader
title="API Egress"
slotProps={{ title: { variant: "h6" } }}
action={
<ToggleButtonGroup
size="small"
exclusive
value={hours}
onChange={(e, v) => v && setHours(v)}
>
{RANGE_OPTIONS.map((o) => (
<ToggleButton key={o.hours} value={o.hours}>
{o.label}
</ToggleButton>
))}
</ToggleButtonGroup>
}
/>
<CardContent sx={{ pt: 0 }}>
<Grid container spacing={2}>
{/* ── Used-of-cap gauge / total ── */}
<Grid size={{ xs: 12, md: 4 }}>
<Stack alignItems="center" spacing={0.5}>
{capBytes > 0 ? (
<>
<ResponsiveContainer width="100%" height={160}>
<RadialBarChart
innerRadius="70%"
outerRadius="100%"
data={gaugeData}
startAngle={220}
endAngle={-40}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar dataKey="value" background cornerRadius={8} />
</RadialBarChart>
</ResponsiveContainer>
<Typography variant="h5" sx={{ mt: -6, mb: 3 }}>
{pct}%
</Typography>
<Typography variant="body2" color="text.secondary">
{formatBytes(r?.BytesToday)} of {formatBytes(capBytes)} today
</Typography>
</>
) : (
<Stack alignItems="center" spacing={0.5} sx={{ py: 4 }}>
<Typography variant="h5">{formatBytes(r?.BytesToday)}</Typography>
<Typography variant="body2" color="text.secondary">
used today (accounting only, no cap)
</Typography>
</Stack>
)}
{capReached && (
<Typography variant="caption" color="error">
Cap reached - requests shed with 429 ({r?.ShedRequests} today)
</Typography>
)}
{!capReached && r?.ShedRequests > 0 && (
<Typography variant="caption" color="text.secondary">
{r.ShedRequests} shed today
</Typography>
)}
</Stack>
</Grid>

{/* ── Stacked per-client trend ── */}
<Grid size={{ xs: 12, md: 8 }}>
{chartData.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
No egress recorded in this window yet.
</Typography>
) : (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData} margin={{ left: 0, right: 12, top: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={theme.palette.divider} />
<XAxis dataKey="time" tick={{ fontSize: 11 }} tickMargin={8} minTickGap={24} />
<YAxis tick={{ fontSize: 11 }} tickMargin={4} unit="MB" />
<RechartsTooltip
formatter={(value, name) => [`${value} MB`, r?.ClientNames?.[name] ?? name]}
/>
<Legend formatter={(name) => r?.ClientNames?.[name] ?? name} />
{clientIds.map((id, i) => (
<Area
key={id}
type="monotone"
dataKey={id}
name={id}
stackId="egress"
stroke={palette[i % palette.length]}
fill={palette[i % palette.length]}
fillOpacity={0.5}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</Grid>
</Grid>
</CardContent>
</Card>
);
};

export default CippApiEgressCard;
Loading
Loading