Files
authentik/web/src/elements/charts/Chart.ts
gcp-cherry-pick-bot[bot] 6021bb932d web: fix bug that was causing charts to be too tall (cherry-pick #14253) (#14254)
Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
fix bug that was causing charts to be too tall (#14253)
2025-04-28 13:51:49 +02:00

242 lines
7.2 KiB
TypeScript

import { EVENT_REFRESH, EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { formatElapsedTime } from "@goauthentik/common/temporal";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState";
import {
Chart,
ChartConfiguration,
ChartData,
ChartOptions,
Filler,
LineElement,
Plugin,
PointElement,
Tick,
} from "chart.js";
import { Legend, Tooltip } from "chart.js";
import { BarController, DoughnutController, LineController } from "chart.js";
import { ArcElement, BarElement } from "chart.js";
import { LinearScale, TimeScale } from "chart.js";
import "chartjs-adapter-date-fns";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { property, state } from "lit/decorators.js";
import { UiThemeEnum } from "@goauthentik/api";
Chart.register(Legend, Tooltip);
Chart.register(LineController, BarController, DoughnutController);
Chart.register(ArcElement, BarElement, PointElement, LineElement);
Chart.register(TimeScale, LinearScale, Filler);
export const FONT_COLOUR_DARK_MODE = "#fafafa";
export const FONT_COLOUR_LIGHT_MODE = "#151515";
export class RGBAColor {
constructor(
public r: number,
public g: number,
public b: number,
public a: number = 1,
) {}
toString(): string {
return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`;
}
}
export function getColorFromString(stringInput: string): RGBAColor {
let hash = 0;
for (let i = 0; i < stringInput.length; i++) {
hash = stringInput.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
const rgb = [0, 0, 0];
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 255;
rgb[i] = value;
}
return new RGBAColor(rgb[0], rgb[1], rgb[2]);
}
export abstract class AKChart<T> extends AKElement {
abstract apiRequest(): Promise<T>;
abstract getChartData(data: T): ChartData;
@state()
chart?: Chart;
@state()
error?: APIError;
@property()
centerText?: string;
fontColour = FONT_COLOUR_LIGHT_MODE;
static get styles(): CSSResult[] {
return [
css`
.container {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.container > span {
position: absolute;
font-size: 2.5rem;
}
canvas {
width: 100px;
height: 100px;
z-index: 1;
cursor: crosshair;
}
`,
];
}
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("resize", this.resizeHandler);
this.addEventListener(EVENT_REFRESH, this.refreshHandler);
this.addEventListener(EVENT_THEME_CHANGE, ((ev: CustomEvent<UiThemeEnum>) => {
if (ev.detail === UiThemeEnum.Light) {
this.fontColour = FONT_COLOUR_LIGHT_MODE;
} else {
this.fontColour = FONT_COLOUR_DARK_MODE;
}
this.chart?.update();
}) as EventListener);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("resize", this.resizeHandler);
this.removeEventListener(EVENT_REFRESH, this.refreshHandler);
}
refreshHandler(): void {
this.apiRequest().then((r: T) => {
if (!this.chart) return;
this.chart.data = this.getChartData(r);
this.chart.update();
});
}
resizeHandler(): void {
if (!this.chart) {
return;
}
this.chart.resize();
}
firstUpdated(): void {
this.apiRequest()
.then((r) => {
const canvas = this.shadowRoot?.querySelector<HTMLCanvasElement>("canvas");
if (!canvas) {
console.warn("Failed to get canvas element");
return;
}
const ctx = canvas.getContext("2d");
if (!ctx) {
console.warn("failed to get 2d context");
return;
}
this.chart = this.configureChart(r, ctx);
})
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.error = parsedError;
});
}
getChartType(): string {
return "bar";
}
getPlugins(): Plugin[] {
return [];
}
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string {
const valueStamp = ticks[index];
return formatElapsedTime(new Date(valueStamp.value));
}
getOptions(): ChartOptions {
return {
maintainAspectRatio: false,
responsive: true,
scales: {
x: {
type: "time",
display: true,
ticks: {
callback: (tickValue: string | number, index: number, ticks: Tick[]) => {
return this.timeTickCallback(tickValue, index, ticks);
},
autoSkip: true,
maxTicksLimit: 8,
},
stacked: true,
grid: {
color: "rgba(0, 0, 0, 0)",
},
offset: true,
},
y: {
type: "linear",
display: true,
stacked: true,
grid: {
color: "rgba(0, 0, 0, 0)",
},
},
},
} as ChartOptions;
}
configureChart(data: T, ctx: CanvasRenderingContext2D): Chart {
const config = {
type: this.getChartType(),
data: this.getChartData(data),
options: this.getOptions(),
plugins: this.getPlugins(),
};
return new Chart(ctx, config as ChartConfiguration);
}
render(): TemplateResult {
return html`
<div class="container">
${this.error
? html`
<ak-empty-state header="${msg("Failed to fetch data.")}" icon="fa-times">
<p slot="body">${pluckErrorDetail(this.error)}</p>
</ak-empty-state>
`
: html`${this.chart
? html``
: html`<ak-empty-state ?loading="${true}"></ak-empty-state>`}`}
${this.centerText ? html` <span>${this.centerText}</span> ` : html``}
<canvas style="${this.chart === undefined ? "display: none;" : ""}"></canvas>
</div>
`;
}
}