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
33 changes: 16 additions & 17 deletions dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
import type { ProjectRow, TrendResponse, GroupedTrendResponse } from './types';
import type { ProjectRow, GroupedTrendResponse, RangeKey } from './types';

export async function fetchProjects(fetchFn: typeof fetch = fetch): Promise<ProjectRow[]> {
const res = await fetchFn('/api/projects', { redirect: 'manual' });
if (!res.ok) throw new Error(`Failed to fetch projects: HTTP ${res.status}`);
return res.json() as Promise<ProjectRow[]>;
}

export async function fetchTrend(
owner: string,
repo: string,
metric: string,
branch: string,
limit: number,
fetchFn: typeof fetch = fetch,
): Promise<TrendResponse> {
const params = new URLSearchParams({ metric, branch, limit: String(limit) });
const res = await fetchFn(`/api/projects/${owner}/${repo}/metrics?${params}`, {
redirect: 'manual',
});
if (!res.ok) throw new Error(`Failed to fetch trend: HTTP ${res.status}`);
return res.json() as Promise<TrendResponse>;
export interface TrendOptions {
/** Row-count cap for the legacy (unwindowed) fetch — ignored when `range` is set. */
limit?: number;
/** Relative time window; when set, the backend returns an edge-anchored, windowed series. */
range?: RangeKey;
/** Align all categories to a shared right edge, carrying stale series forward. Requires `range`. */
align?: boolean;
}

export async function fetchTrendByCategory(
owner: string,
repo: string,
metric: string,
branch: string,
limit: number,
options: TrendOptions,
fetchFn: typeof fetch = fetch,
): Promise<GroupedTrendResponse> {
const params = new URLSearchParams({ metric, branch, limit: String(limit) });
const params = new URLSearchParams({ metric, branch });
if (options.range) {
params.set('range', options.range);
if (options.align) params.set('align', 'true');
} else {
params.set('limit', String(options.limit ?? 100));
}
const res = await fetchFn(`/api/projects/${owner}/${repo}/metrics/categories?${params}`, {
redirect: 'manual',
});
Expand Down
18 changes: 18 additions & 0 deletions dashboard/src/lib/chartFill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type uPlot from 'uplot';

export function hexAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}

/** Top-to-bottom fading fill, used by every uPlot line series in the dashboard. */
export function gradientFill(color: string, topAlpha = 0.28, bottomAlpha = 0.02) {
return (u: uPlot) => {
const grad = u.ctx.createLinearGradient(0, u.bbox.top, 0, u.bbox.top + u.bbox.height);
grad.addColorStop(0, hexAlpha(color, topAlpha));
grad.addColorStop(1, hexAlpha(color, bottomAlpha));
return grad;
};
}
43 changes: 40 additions & 3 deletions dashboard/src/lib/components/BadgeModal.svelte
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { fetchTrendByCategory } from '$lib/api';

let {
owner,
repo,
projectId,
badgeEnabled,
defaultBranch,
onclose,
}: {
owner: string;
repo: string;
projectId: number;
badgeEnabled: number;
defaultBranch: string;
onclose: () => void;
} = $props();

Expand All @@ -26,16 +29,42 @@
];

let selectedMetric = $state('coverage');
let categories = $state<string[]>(['default']);
let selectedCategory = $state('default');
let localBadgeEnabled = $state(untrack(() => badgeEnabled));
let toggling = $state(false);

$effect(() => {
const metric = selectedMetric;
(async () => {
let next = ['default'];
try {
const result = await fetchTrendByCategory(owner, repo, metric, defaultBranch, { limit: 1 });
if (result.categories.length > 0) {
next = result.categories.map((c) => c.category);
}
} catch {
// fall back to ['default']
}
categories = next;
if (!next.includes(selectedCategory)) {
selectedCategory = next[0] ?? 'default';
}
})();
});

const badgeEndpointUrl = $derived(
`${window.location.origin}/api/badge/${owner}/${repo}/${selectedMetric}.json`,
`${window.location.origin}/api/badge/${owner}/${repo}/${selectedMetric}.json${
selectedCategory !== 'default' ? `?category=${encodeURIComponent(selectedCategory)}` : ''
}`,
);
const shieldsUrl = $derived(
`https://img.shields.io/endpoint?url=${encodeURIComponent(badgeEndpointUrl)}`,
);
const markdownSnippet = $derived(`![${selectedMetric} badge](${shieldsUrl})`);
const badgeAltText = $derived(
`${selectedMetric}${selectedCategory !== 'default' ? ` (${selectedCategory})` : ''} badge`,
);
const markdownSnippet = $derived(`![${badgeAltText}](${shieldsUrl})`);
const rstSnippet = $derived(`.. image:: ${shieldsUrl}`);

let copiedField: string | null = $state(null);
Expand Down Expand Up @@ -155,11 +184,18 @@
<option value={m.value}>{m.label}</option>
{/each}
</select>

<label for="badge-category-select" class="metric-label">Category</label>
<select id="badge-category-select" class="metric-select" bind:value={selectedCategory}>
{#each categories as cat (cat)}
<option value={cat}>{cat}</option>
{/each}
</select>
</div>

<div class="badge-preview">
{#if localBadgeEnabled}
<img src={shieldsUrl} alt="{selectedMetric} status badge" />
<img src={shieldsUrl} alt="{badgeAltText} status badge" />
{:else}
<span class="badge-placeholder">badge preview unavailable</span>
{/if}
Expand Down Expand Up @@ -358,6 +394,7 @@
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}

.metric-label {
Expand Down
82 changes: 82 additions & 0 deletions dashboard/src/lib/components/MultiSparkLine.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script lang="ts">
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { gradientFill } from '../chartFill';

let {
series,
}: {
series: { category: string; timestamps: number[]; values: number[]; color: string }[];
} = $props();

let container: HTMLDivElement;
let chart: uPlot | null = null;

function buildChart() {
chart?.destroy();
chart = null;
if (!container || series.length === 0) return;

const tables = series.map((s) => [s.timestamps, s.values]) as uPlot.AlignedData[];
const joined = series.length > 1 ? uPlot.join(tables) : tables[0];
if ((joined[0] as number[]).length < 2) return;

chart = new uPlot(
{
width: container.clientWidth,
height: 44,
padding: [4, 0, 4, 0],
axes: [{ show: false, size: 0 }, { show: false, size: 0 }],
scales: { x: { time: true } },
legend: { show: false },
cursor: { show: false },
select: { show: false },
series: [
{},
...series.map((s) => ({
stroke: s.color,
fill: gradientFill(s.color, 0.16, 0.01),
width: 1.5,
points: { show: false },
})),
],
},
joined,
container,
);
}

$effect(() => {
void series;
buildChart();

if (!container) return;
const observer = new ResizeObserver(() => {
if (chart && container) {
chart.setSize({ width: container.clientWidth, height: 44 });
}
});
observer.observe(container);

return () => {
observer.disconnect();
chart?.destroy();
chart = null;
};
});
</script>

<div bind:this={container} class="multi-sparkline"></div>

<style>
.multi-sparkline {
width: 100%;
}
.multi-sparkline :global(.u-wrap) {
overflow: visible;
}
.multi-sparkline :global(.u-title),
.multi-sparkline :global(.u-legend) {
display: none;
}
</style>
10 changes: 2 additions & 8 deletions dashboard/src/lib/components/SparkLine.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { gradientFill } from '../chartFill';

let {
timestamps,
Expand All @@ -11,13 +12,6 @@
let container: HTMLDivElement;
let chart: uPlot | null = null;

function hexAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}

function buildChart(c: string) {
chart?.destroy();
if (timestamps.length < 2 || !container) { chart = null; return; }
Expand All @@ -36,7 +30,7 @@
{},
{
stroke: c,
fill: hexAlpha(c, 0.2),
fill: gradientFill(c),
width: 1.5,
points: { show: false },
},
Expand Down
17 changes: 1 addition & 16 deletions dashboard/src/lib/components/TrendChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import type { MetricPoint } from '../types';
import { hexAlpha, gradientFill } from '../chartFill';

let {
data,
Expand All @@ -24,22 +25,6 @@
let container: HTMLDivElement;
let chart: uPlot | null = null;

function hexAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}

function gradientFill(c: string) {
return (u: uPlot) => {
const grad = u.ctx.createLinearGradient(0, u.bbox.top, 0, u.bbox.top + u.bbox.height);
grad.addColorStop(0, hexAlpha(c, 0.28));
grad.addColorStop(1, hexAlpha(c, 0.02));
return grad;
};
}

// Draw a dot + vertical dashed guide at the last data point
function lastPointPlugin(c: string, bc: string) {
return {
Expand Down
24 changes: 17 additions & 7 deletions dashboard/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,8 @@ export interface MetricPoint {
value: number;
unit: string;
recorded_at: string;
}

export interface TrendResponse {
project: string;
branch: string;
metric: string;
data: MetricPoint[];
/** True for a point synthesized to anchor/carry a line to a window boundary — not a real run. */
synthetic?: boolean;
}

export interface CategoryTrend {
Expand All @@ -39,3 +34,18 @@ export interface GroupedTrendResponse {
export type MetricName = 'coverage' | 'complexity' | 'duplication';

export const METRICS: MetricName[] = ['coverage', 'complexity', 'duplication'];

export type RangeKey = '15m' | '1h' | '12h' | '1d' | '7d' | '30d';

export const RANGES: { key: RangeKey; label: string }[] = [
{ key: '15m', label: '15m' },
{ key: '1h', label: '1h' },
{ key: '12h', label: '12h' },
{ key: '1d', label: '1d' },
{ key: '7d', label: '7d' },
{ key: '30d', label: '30d' },
];

export function isRangeKey(value: string): value is RangeKey {
return RANGES.some((r) => r.key === value);
}
Loading
Loading