Skip to content

Commit 9787f3f

Browse files
committed
release: v0.0.28 — scheduler per-run timeouts + UI component refresh
Scheduled tasks now run under per-execution context timeouts (billing 10m / auto-recharge 5m / alert 3m) so a hung K8s/OpenCost call cannot block the loop. Includes new shared PageHeader/StatCard/AreaChart/DonutChart components integrated across pages.
1 parent 5313edb commit 9787f3f

19 files changed

Lines changed: 1060 additions & 363 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to the Bison project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.0.28] - 2026-06-19
9+
10+
### Changed — Scheduler run timeouts
11+
12+
- Each scheduled run is now bounded by a per-execution `context` timeout (billing 10m, auto-recharge 5m, alert 3m), so a hung Kubernetes/OpenCost call can no longer block a task loop indefinitely; the run is recorded as failed and retried on the next tick.
13+
14+
### Added — UI component refresh
15+
16+
- New shared `PageHeader`, `StatCard`, and `charts/` (`AreaChart`, `DonutChart`) components, integrated across the Dashboard, Cluster, Team, Project, User, Report, Audit, and Settings pages, with refreshed `theme.css` / layout styling.
17+
818
## [0.0.27] - 2026-06-19
919

1020
### Added — Supply-chain hygiene

api-server/internal/scheduler/scheduler.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ func (s *Scheduler) runBillingTask(ctx context.Context) {
149149
}
150150

151151
func (s *Scheduler) executeBillingTask(ctx context.Context) {
152+
// Bound each run so a hung K8s/OpenCost call cannot block the billing loop forever.
153+
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
154+
defer cancel()
155+
152156
exec := service.TaskExecution{
153157
TaskName: "billing",
154158
StartTime: time.Now(),
@@ -194,6 +198,9 @@ func (s *Scheduler) runAutoRechargeTask(ctx context.Context) {
194198
}
195199

196200
func (s *Scheduler) executeAutoRechargeTask(ctx context.Context) {
201+
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
202+
defer cancel()
203+
197204
exec := service.TaskExecution{
198205
TaskName: "auto_recharge",
199206
StartTime: time.Now(),
@@ -239,6 +246,9 @@ func (s *Scheduler) runAlertTask(ctx context.Context) {
239246
}
240247

241248
func (s *Scheduler) executeAlertTask(ctx context.Context) {
249+
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
250+
defer cancel()
251+
242252
exec := service.TaskExecution{
243253
TaskName: "alert_check",
244254
StartTime: time.Now(),

deploy/charts/bison/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: v2
22
name: bison
33
description: Bison - GPU 资源计费平台,基于 Capsule 多租户 + OpenCost 成本追踪
44
type: application
5-
version: 0.0.27
6-
appVersion: "0.0.27"
5+
version: 0.0.28
6+
appVersion: "0.0.28"
77
kubeVersion: ">=1.22.0-0"
88
keywords:
99
- gpu

web-ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bison-web-ui",
3-
"version": "0.0.27",
3+
"version": "0.0.28",
44
"private": true,
55
"scripts": {
66
"dev": "vite",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react';
2+
3+
export interface PageHeaderProps {
4+
/** Main title — usually a short noun phrase. */
5+
title: React.ReactNode;
6+
/** Optional one-line description shown beneath the title. */
7+
subtitle?: React.ReactNode;
8+
/** Optional leading icon rendered inside a gradient tile. */
9+
icon?: React.ReactNode;
10+
/** CSS gradient for the icon tile (defaults to the brand gradient). */
11+
gradient?: string;
12+
/** Right-aligned content such as action buttons or filters. */
13+
extra?: React.ReactNode;
14+
}
15+
16+
/**
17+
* Consistent page header used across list/detail pages. Provides a gradient
18+
* icon tile, a title + subtitle stack, and a slot for right-aligned actions.
19+
*/
20+
const PageHeader: React.FC<PageHeaderProps> = ({
21+
title,
22+
subtitle,
23+
icon,
24+
gradient = 'var(--gradient-brand)',
25+
extra,
26+
}) => (
27+
<div className="page-header">
28+
<div className="page-header-left">
29+
{icon && (
30+
<div className="page-header-icon" style={{ background: gradient }}>
31+
{icon}
32+
</div>
33+
)}
34+
<div className="page-header-text">
35+
<h1 className="page-header-title">{title}</h1>
36+
{subtitle && <div className="page-header-subtitle">{subtitle}</div>}
37+
</div>
38+
</div>
39+
{extra && <div className="page-header-extra">{extra}</div>}
40+
</div>
41+
);
42+
43+
export default PageHeader;

web-ui/src/components/StatCard.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from 'react';
2+
import { Spin } from 'antd';
3+
4+
export interface StatCardProps {
5+
/** Metric label, e.g. "集群节点". */
6+
title: string;
7+
/** Primary value — number or short string. */
8+
value: React.ReactNode;
9+
/** Icon rendered inside the gradient tile. */
10+
icon: React.ReactNode;
11+
/** CSS gradient for the icon tile (defaults to blue). */
12+
gradient?: string;
13+
/** Optional unit/suffix shown after the value. */
14+
suffix?: React.ReactNode;
15+
/** Optional small helper text under the value. */
16+
hint?: React.ReactNode;
17+
/** Custom color for the value text (e.g. status colors). */
18+
valueColor?: string;
19+
onClick?: () => void;
20+
loading?: boolean;
21+
}
22+
23+
/**
24+
* KPI card: a gradient icon tile beside a label + large value. Used on the
25+
* dashboard summary row. Renders as a plain element (not Ant Card) so the
26+
* gradient wash and hover lift are fully controllable.
27+
*/
28+
const StatCard: React.FC<StatCardProps> = ({
29+
title,
30+
value,
31+
icon,
32+
gradient = 'var(--gradient-blue)',
33+
suffix,
34+
hint,
35+
valueColor,
36+
onClick,
37+
loading,
38+
}) => (
39+
<div
40+
className={`stat-card${onClick ? ' stat-card-clickable' : ''}`}
41+
onClick={onClick}
42+
style={{ ['--stat-gradient' as string]: gradient } as React.CSSProperties}
43+
>
44+
<div className="stat-card-icon" style={{ background: gradient }}>
45+
{icon}
46+
</div>
47+
<div className="stat-card-body">
48+
<div className="stat-card-title">{title}</div>
49+
{loading ? (
50+
<Spin size="small" />
51+
) : (
52+
<div className="stat-card-value" style={valueColor ? { color: valueColor } : undefined}>
53+
{value}
54+
{suffix && <span className="stat-card-suffix">{suffix}</span>}
55+
</div>
56+
)}
57+
{hint && <div className="stat-card-hint">{hint}</div>}
58+
</div>
59+
</div>
60+
);
61+
62+
export default StatCard;
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import React, { useId, useState } from 'react';
2+
import { Empty } from 'antd';
3+
4+
export interface AreaPoint {
5+
date: string;
6+
value: number;
7+
}
8+
9+
export interface AreaChartProps {
10+
data: AreaPoint[];
11+
height?: number;
12+
/** Line/fill color (defaults to the theme accent). */
13+
color?: string;
14+
/** Prefix for the tooltip value, e.g. "$". */
15+
valuePrefix?: string;
16+
/** Decimal places for the tooltip value. */
17+
precision?: number;
18+
}
19+
20+
/** Build a smooth (Catmull-Rom → cubic bezier) path through the points. */
21+
function smoothPath(pts: { x: number; y: number }[]): string {
22+
if (pts.length === 0) return '';
23+
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
24+
let d = `M ${pts[0].x} ${pts[0].y}`;
25+
for (let i = 0; i < pts.length - 1; i++) {
26+
const p0 = pts[i - 1] || pts[i];
27+
const p1 = pts[i];
28+
const p2 = pts[i + 1];
29+
const p3 = pts[i + 2] || p2;
30+
const cp1x = p1.x + (p2.x - p0.x) / 6;
31+
const cp1y = p1.y + (p2.y - p0.y) / 6;
32+
const cp2x = p2.x - (p3.x - p1.x) / 6;
33+
const cp2y = p2.y - (p3.y - p1.y) / 6;
34+
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`;
35+
}
36+
return d;
37+
}
38+
39+
/**
40+
* Responsive SVG area/line chart with a gradient fill, grid lines and a
41+
* hover tooltip. Pure SVG/CSS — no charting dependency. The viewBox uses a
42+
* fixed coordinate space and `non-scaling-stroke` so the line stays crisp at
43+
* any width; the hover cursor/dot/tooltip are HTML overlays.
44+
*/
45+
const AreaChart: React.FC<AreaChartProps> = ({
46+
data,
47+
height = 200,
48+
color = 'var(--accent)',
49+
valuePrefix = '',
50+
precision = 2,
51+
}) => {
52+
const gradientId = useId().replace(/:/g, '');
53+
const [hover, setHover] = useState<number | null>(null);
54+
55+
if (!data || data.length === 0) {
56+
return <Empty description="暂无数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
57+
}
58+
59+
const W = 600;
60+
const padX = 10;
61+
const padTop = 14;
62+
const padBottom = 26;
63+
const innerH = height - padTop - padBottom;
64+
const n = data.length;
65+
const maxV = Math.max(...data.map(d => d.value), 1);
66+
67+
const xFor = (i: number) =>
68+
n === 1 ? W / 2 : padX + (i / (n - 1)) * (W - 2 * padX);
69+
const yFor = (v: number) => padTop + innerH - (v / maxV) * innerH;
70+
71+
const pts = data.map((d, i) => ({ x: xFor(i), y: yFor(d.value) }));
72+
const line = smoothPath(pts);
73+
const baseY = padTop + innerH;
74+
const area = `${line} L ${pts[n - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`;
75+
const gridLines = [0, 0.25, 0.5, 0.75, 1].map(f => padTop + innerH - f * innerH);
76+
77+
const handleMove = (e: React.MouseEvent<HTMLDivElement>) => {
78+
const rect = e.currentTarget.getBoundingClientRect();
79+
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
80+
setHover(Math.round(frac * (n - 1)));
81+
};
82+
83+
return (
84+
<div
85+
className="area-chart"
86+
style={{ height }}
87+
onMouseMove={handleMove}
88+
onMouseLeave={() => setHover(null)}
89+
>
90+
<svg
91+
className="area-svg"
92+
width="100%"
93+
height={height}
94+
viewBox={`0 0 ${W} ${height}`}
95+
preserveAspectRatio="none"
96+
>
97+
<defs>
98+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
99+
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
100+
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
101+
</linearGradient>
102+
</defs>
103+
{gridLines.map((y, i) => (
104+
<line
105+
key={i}
106+
x1={padX}
107+
y1={y}
108+
x2={W - padX}
109+
y2={y}
110+
stroke="var(--border-light)"
111+
strokeWidth="1"
112+
vectorEffect="non-scaling-stroke"
113+
/>
114+
))}
115+
<path d={area} fill={`url(#${gradientId})`} />
116+
<path
117+
d={line}
118+
fill="none"
119+
stroke={color}
120+
strokeWidth="2.5"
121+
strokeLinejoin="round"
122+
strokeLinecap="round"
123+
vectorEffect="non-scaling-stroke"
124+
/>
125+
</svg>
126+
127+
<div className="area-xlabels">
128+
<span>{data[0].date}</span>
129+
{n > 1 && <span>{data[n - 1].date}</span>}
130+
</div>
131+
132+
{hover !== null && (
133+
<>
134+
<div
135+
className="area-cursor"
136+
style={{ left: `${(pts[hover].x / W) * 100}%`, height: baseY }}
137+
/>
138+
<div
139+
className="area-dot"
140+
style={{
141+
left: `${(pts[hover].x / W) * 100}%`,
142+
top: pts[hover].y,
143+
background: color,
144+
}}
145+
/>
146+
<div
147+
className="area-tooltip"
148+
style={{ left: `${(pts[hover].x / W) * 100}%`, top: pts[hover].y }}
149+
>
150+
<div className="area-tt-date">{data[hover].date}</div>
151+
<div className="area-tt-val">
152+
{valuePrefix}
153+
{data[hover].value.toFixed(precision)}
154+
</div>
155+
</div>
156+
</>
157+
)}
158+
</div>
159+
);
160+
};
161+
162+
export default AreaChart;

0 commit comments

Comments
 (0)