Skip to content

Commit 576fb77

Browse files
Show usage by resource and member as allocated-vs-used donuts
1 parent dfeefea commit 576fb77

7 files changed

Lines changed: 401 additions & 198 deletions

File tree

connectors/Analytics/pkg/analytics/analytics_integration_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,36 @@ func TestUsageSummary_PISeesRankedMembers(t *testing.T) {
409409
}
410410
}
411411

412+
// A member whose profile has no first/last name still gets a readable label.
413+
func TestUsageSummary_MemberWithoutNameFallsBackToEmail(t *testing.T) {
414+
database, _, srv := setupTestStack(t)
415+
pi := seedUser(t, database, "pi7@example.edu")
416+
cluster := seedCluster(t, database)
417+
project := seedProject(t, database, pi)
418+
seedProjectRole(t, database, project, pi, models.ProjectRolePI)
419+
start := time.Now().UTC().AddDate(0, 0, -2)
420+
alloc := seedAllocation(t, database, project, cluster, 1000, start, time.Now().UTC().AddDate(0, 0, 28))
421+
res := seedResource(t, database, cluster, "gpu-01", "GPU_HOURS")
422+
if _, err := database.Exec("UPDATE users SET first_name = '', last_name = '' WHERE id = ?", pi); err != nil {
423+
t.Fatalf("blank name: %v", err)
424+
}
425+
seedUsage(t, database, alloc, res, pi, 400, 40, time.Now().UTC().AddDate(0, 0, -1))
426+
427+
rr := httptest.NewRecorder()
428+
req := withTestCaller(httptest.NewRequest(http.MethodGet, "/connectors/analytics/allocations/"+alloc+"/usage-summary", nil), pi)
429+
srv.ServeHTTP(rr, req)
430+
if rr.Code != http.StatusOK {
431+
t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String())
432+
}
433+
var got UsageSummary
434+
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
435+
t.Fatalf("decode: %v", err)
436+
}
437+
if len(got.ByMember) != 1 || got.ByMember[0].Name != "pi7@example.edu" {
438+
t.Fatalf("by_member name: got %+v, want email fallback", got.ByMember)
439+
}
440+
}
441+
412442
// Access is membership-only: a site-wide privilege does not open an allocation
413443
// the caller has no membership or role on.
414444
func TestUsageSummary_SitePrivilegeAloneDoesNotGrantAccess_404(t *testing.T) {

connectors/Analytics/pkg/analytics/store.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ func (s *mysqlStore) MemberUsage(ctx context.Context, allocationID string) ([]Me
269269
var rows []MemberRow
270270
err := s.db.SelectContext(ctx, &rows,
271271
`SELECT u.user_id,
272-
TRIM(CONCAT(COALESCE(usr.first_name, ''), ' ', COALESCE(usr.last_name, ''))) AS name,
272+
COALESCE(NULLIF(TRIM(CONCAT(COALESCE(usr.first_name, ''), ' ', COALESCE(usr.last_name, ''))), ''), usr.email) AS name,
273273
SUM(u.used_su_amount) AS used
274274
FROM compute_allocation_usages u
275275
JOIN users usr ON usr.id = u.user_id

web/src/features/core/analytics/components/AnalyticsPage.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,14 @@ function AnalyticsBody({
139139
<div className="space-y-6">
140140
<HeroTiles allocation={allocation} callerUsed={callerUsed} now={now} />
141141
<UsageOverTimeBars summary={summary} />
142-
<ResourceBreakdown summary={summary} />
143-
{isManager && summary.by_member ? <MemberBreakdown members={summary.by_member} /> : null}
142+
{isManager && summary.by_member ? (
143+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
144+
<ResourceBreakdown summary={summary} />
145+
<MemberBreakdown members={summary.by_member} total={summary.total} />
146+
</div>
147+
) : (
148+
<ResourceBreakdown summary={summary} />
149+
)}
144150
<JobsTable
145151
allocationId={allocation.id}
146152
canManage={isManager}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
"use client";
19+
20+
import * as React from "react";
21+
import { CHART_OTHER_COLOR, formatCredits, formatCreditsFull, formatPercent } from "../lib";
22+
23+
// folded items are listed individually in the legend but share one ring arc,
24+
// so fifty hair-thin sectors never happen.
25+
export type BudgetSlice = {
26+
key: string;
27+
label: string;
28+
value: number;
29+
color: string;
30+
folded?: boolean;
31+
};
32+
33+
// The track doubles as the unused remainder, so it stays a light neutral,
34+
// distinct from the darker chart-5 that marks the folded-tail arc.
35+
const REMAINDER_COLOR = "var(--brand-tint)";
36+
const OTHERS_KEY = "__others";
37+
38+
// A donut against the allocation's full credit budget: colored sectors are
39+
// consumed credits, the track that shows through is what remains. Hovering
40+
// (or tapping) a sector or legend row swaps the center to that slice's
41+
// numbers. When consumption exceeds the budget, sectors span the whole ring
42+
// and the center flips to how far over the budget the allocation is.
43+
export function BudgetDonut({
44+
items,
45+
total,
46+
ariaContext,
47+
}: {
48+
items: BudgetSlice[];
49+
total: number;
50+
ariaContext: string;
51+
}) {
52+
const [active, setActive] = React.useState<string | null>(null);
53+
54+
const used = items.reduce((a, s) => a + s.value, 0);
55+
const over = used > total;
56+
const denominator = Math.max(total, used);
57+
const remaining = Math.max(0, total - used);
58+
const pctOf = (v: number) => (denominator > 0 ? (v / denominator) * 100 : 0);
59+
60+
const shown = items.filter((s) => !s.folded);
61+
const folded = items.filter((s) => s.folded);
62+
const arcs: BudgetSlice[] = [...shown];
63+
if (folded.length > 0) {
64+
arcs.push({
65+
key: OTHERS_KEY,
66+
label: `+${folded.length} ${folded.length === 1 ? "other" : "others"}`,
67+
value: folded.reduce((a, s) => a + s.value, 0),
68+
color: CHART_OTHER_COLOR,
69+
});
70+
}
71+
72+
// A folded legend row highlights the shared tail arc.
73+
const arcKeyFor = (s: BudgetSlice) => (s.folded ? OTHERS_KEY : s.key);
74+
const activeItem =
75+
items.find((s) => s.key === active) ?? arcs.find((s) => s.key === active) ?? null;
76+
const toggle = (key: string) => setActive((cur) => (cur === key ? null : key));
77+
78+
return (
79+
<div className="flex flex-col items-center gap-6 md:flex-row md:items-center md:gap-8">
80+
<svg
81+
viewBox="0 0 42 42"
82+
className="h-[220px] w-[220px] shrink-0 md:h-[260px] md:w-[260px]"
83+
role="img"
84+
aria-label={`${ariaContext}: ${formatCreditsFull(used)} of ${formatCreditsFull(total)} credits used`}
85+
>
86+
<circle cx="21" cy="21" r="15.915" fill="none" stroke={REMAINDER_COLOR} strokeWidth="7" />
87+
{(() => {
88+
let cumulative = 0;
89+
return arcs.map((s) => {
90+
// r = 15.915 gives a circumference of ~100, so a percentage maps
91+
// straight to a dash length. Offset 25 starts the ring at 12 o'clock.
92+
const pct = pctOf(s.value);
93+
const isActive = active === s.key;
94+
const dimmed = active !== null && !isActive;
95+
const arc = (
96+
<circle
97+
key={s.key}
98+
cx="21"
99+
cy="21"
100+
r="15.915"
101+
fill="none"
102+
stroke={s.color}
103+
strokeWidth={isActive ? 8.2 : 7}
104+
strokeDasharray={`${pct} ${100 - pct}`}
105+
strokeDashoffset={25 - cumulative}
106+
opacity={dimmed ? 0.4 : 1}
107+
className="cursor-default motion-safe:transition-[opacity,stroke-width]"
108+
data-slice={s.key}
109+
onMouseEnter={() => setActive(s.key)}
110+
onMouseLeave={() => setActive(null)}
111+
>
112+
<title>{`${s.label}: ${formatCreditsFull(s.value)} credits, ${formatPercent(pct)}`}</title>
113+
</circle>
114+
);
115+
cumulative += pct;
116+
return arc;
117+
});
118+
})()}
119+
<CenterText
120+
activeItem={activeItem}
121+
over={over}
122+
used={used}
123+
total={total}
124+
remaining={remaining}
125+
pctOf={pctOf}
126+
/>
127+
</svg>
128+
<div aria-live="polite" className="sr-only">
129+
{activeItem
130+
? `${activeItem.label}: ${formatCreditsFull(activeItem.value)} credits, ${formatPercent(pctOf(activeItem.value))}`
131+
: null}
132+
</div>
133+
<div className="w-full min-w-0 max-w-[420px] flex-1">
134+
<ul className="max-h-none space-y-0.5 overflow-y-auto pr-2 md:max-h-[260px]">
135+
{items.map((s) => (
136+
<LegendRow
137+
key={s.key}
138+
label={s.label}
139+
value={s.value}
140+
pct={pctOf(s.value)}
141+
color={s.color}
142+
active={active === s.key || active === arcKeyFor(s)}
143+
dimmed={active !== null && active !== s.key && active !== arcKeyFor(s)}
144+
onEnter={() => setActive(s.key)}
145+
onLeave={() => setActive(null)}
146+
onToggle={() => toggle(s.key)}
147+
/>
148+
))}
149+
</ul>
150+
{!over && (
151+
<div className="mt-2 border-t border-border pt-2">
152+
<ul>
153+
<LegendRow
154+
label="Available"
155+
value={remaining}
156+
pct={pctOf(remaining)}
157+
color={REMAINDER_COLOR}
158+
emphasis
159+
/>
160+
</ul>
161+
</div>
162+
)}
163+
</div>
164+
</div>
165+
);
166+
}
167+
168+
function CenterText({
169+
activeItem,
170+
over,
171+
used,
172+
total,
173+
remaining,
174+
pctOf,
175+
}: {
176+
activeItem: BudgetSlice | null;
177+
over: boolean;
178+
used: number;
179+
total: number;
180+
remaining: number;
181+
pctOf: (v: number) => number;
182+
}) {
183+
if (activeItem) {
184+
return (
185+
<>
186+
<text x="21" y="17.5" textAnchor="middle" className="fill-muted-foreground text-[2.4px]">
187+
{activeItem.label.length > 24 ? `${activeItem.label.slice(0, 23)}…` : activeItem.label}
188+
</text>
189+
<text
190+
x="21"
191+
y="22.5"
192+
textAnchor="middle"
193+
className="fill-foreground text-[4.4px] font-semibold"
194+
>
195+
{formatCredits(activeItem.value)}
196+
</text>
197+
<text x="21" y="26.5" textAnchor="middle" className="fill-muted-foreground text-[2.2px]">
198+
{formatPercent(pctOf(activeItem.value))} · of {formatCredits(total)}
199+
</text>
200+
</>
201+
);
202+
}
203+
if (over) {
204+
return (
205+
<>
206+
<text
207+
x="21"
208+
y="20.5"
209+
textAnchor="middle"
210+
className="fill-[color:var(--tone-error-fg)] text-[5px] font-semibold"
211+
>
212+
-{formatCredits(used - total)}
213+
</text>
214+
<text x="21" y="25" textAnchor="middle" className="fill-muted-foreground text-[2.4px]">
215+
over the {formatCredits(total)} budget
216+
</text>
217+
</>
218+
);
219+
}
220+
return (
221+
<>
222+
<text x="21" y="20.5" textAnchor="middle" className="fill-foreground text-[5px] font-semibold">
223+
{formatCredits(remaining)}
224+
</text>
225+
<text x="21" y="25" textAnchor="middle" className="fill-muted-foreground text-[2.4px]">
226+
of {formatCredits(total)} available
227+
</text>
228+
</>
229+
);
230+
}
231+
232+
function LegendRow({
233+
label,
234+
value,
235+
pct,
236+
color,
237+
active,
238+
dimmed,
239+
emphasis,
240+
onEnter,
241+
onLeave,
242+
onToggle,
243+
}: {
244+
label: string;
245+
value: number;
246+
pct: number;
247+
color: string;
248+
active?: boolean;
249+
dimmed?: boolean;
250+
emphasis?: boolean;
251+
onEnter?: () => void;
252+
onLeave?: () => void;
253+
onToggle?: () => void;
254+
}) {
255+
return (
256+
<li>
257+
<button
258+
type="button"
259+
className={`flex w-full max-w-[380px] items-center gap-2 rounded px-1 py-1 text-left text-sm outline-none motion-safe:transition-opacity ${
260+
active ? "bg-[color:var(--brand-tint)]" : ""
261+
} ${dimmed ? "opacity-50" : ""} ${onToggle ? "" : "cursor-default"}`}
262+
onMouseEnter={onEnter}
263+
onMouseLeave={onLeave}
264+
onFocus={onEnter}
265+
onBlur={onLeave}
266+
onClick={onToggle}
267+
>
268+
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: color }} />
269+
<span
270+
className={`min-w-0 flex-1 truncate ${emphasis ? "font-medium" : ""}`}
271+
title={label}
272+
>
273+
{label}
274+
</span>
275+
<span className="shrink-0 tabular-nums text-muted-foreground">
276+
{formatPercent(pct)} · {formatCredits(value)}
277+
</span>
278+
</button>
279+
</li>
280+
);
281+
}

0 commit comments

Comments
 (0)