enterprise: fix license status progress bar (#11048)

* clamp width to 100% width

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add case for unlicensed and set to infinity when users of a type exists that dont have licenses

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rework license status into separate component...

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* enable coverage

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove annoying disable-search-engine-choice-screen

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* refactor percentage calculation

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix a bug found by tests, yay

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests for enterprise status card

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* upgrade vite-tsconfig-paths

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ...?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2024-08-24 14:23:32 +02:00
committed by GitHub
parent a6225ad7a7
commit 811823e648
9 changed files with 364 additions and 128 deletions

View File

@ -43,7 +43,7 @@ help: ## Show this help
sort
@echo ""
test-go:
go-test:
go test -timeout 0 -v -race -cover ./...
test-docker: ## Run all tests in a docker-compose
@ -205,11 +205,14 @@ gen: gen-build gen-client-ts
web-build: web-install ## Build the Authentik UI
cd web && npm run build
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
web: web-lint-fix web-lint web-check-compile web-test ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
web-install: ## Install the necessary libraries to build the Authentik UI
cd web && npm ci
web-test: ## Run tests for the Authentik UI
cd web && npm run test
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
rm -rf web/dist/
mkdir web/dist/

6
web/package-lock.json generated
View File

@ -120,7 +120,7 @@
"turnstile-types": "^1.2.2",
"typescript": "^5.5.4",
"typescript-eslint": "^8.2.0",
"vite-tsconfig-paths": "^4.3.2",
"vite-tsconfig-paths": "^5.0.1",
"wdio-wait-for": "^3.0.11",
"wireit": "^0.14.8"
},
@ -24860,7 +24860,9 @@
}
},
"node_modules/vite-tsconfig-paths": {
"version": "4.3.2",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz",
"integrity": "sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -108,7 +108,7 @@
"turnstile-types": "^1.2.2",
"typescript": "^5.5.4",
"typescript-eslint": "^8.2.0",
"vite-tsconfig-paths": "^4.3.2",
"vite-tsconfig-paths": "^5.0.1",
"wdio-wait-for": "^3.0.11",
"wireit": "^0.14.8"
},
@ -154,6 +154,7 @@
"tsc": "wireit",
"watch": "run-s build-locales esbuild:watch"
},
"type": "module",
"wireit": {
"build": {
"#comment": [
@ -323,13 +324,17 @@
"command": "node scripts/build-storybook-import-maps.mjs"
},
"test": {
"command": "wdio run ./wdio.conf.ts --logLevel=warn --autoCompileOpts.tsNodeOpts.project=tsconfig.test.json",
"command": "wdio run ./wdio.conf.ts --logLevel=warn",
"env": {
"CI": "true"
"CI": "true",
"TS_NODE_PROJECT": "tsconfig.test.json"
}
},
"test-view": {
"command": "wdio run ./wdio.conf.ts --autoCompileOpts.tsNodeOpts.project=tsconfig.test.json"
"command": "wdio run ./wdio.conf.ts",
"env": {
"TS_NODE_PROJECT": "tsconfig.test.json"
}
},
"tsc": {
"dependencies": [

View File

@ -1,4 +1,5 @@
import "@goauthentik/admin/enterprise/EnterpriseLicenseForm";
import "@goauthentik/admin/enterprise/EnterpriseStatusCard";
import "@goauthentik/admin/rbac/ObjectPermissionModal";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { getRelativeTime } from "@goauthentik/common/utils";
@ -14,17 +15,14 @@ import { TablePage } from "@goauthentik/elements/table/TablePage";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFProgress from "@patternfly/patternfly/components/Progress/progress.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFSplit from "@patternfly/patternfly/layouts/Split/split.css";
import {
EnterpriseApi,
@ -67,13 +65,10 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
static get styles(): CSSResult[] {
return super.styles.concat(
PFDescriptionList,
PFGrid,
PFBanner,
PFFormControl,
PFButton,
PFProgress,
PFSplit,
PFCard,
css`
.pf-m-no-padding-bottom {
@ -198,103 +193,14 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
</div>
</section>
<section class="pf-c-page__main-section pf-m-no-padding-bottom">
${this.renderCurrentSummary()}
<ak-enterprise-status-card
.summary=${this.summary}
.forecast=${this.forecast}
></ak-enterprise-status-card>
</section>
`;
}
renderSummaryBadge() {
switch (this.summary?.status) {
case LicenseSummaryStatusEnum.Expired:
return html`<ak-label color=${PFColor.Red}>${msg("Expired")}</ak-label>`;
case LicenseSummaryStatusEnum.ExpirySoon:
return html`<ak-label color=${PFColor.Orange}>${msg("Expiring soon")}</ak-label>`;
case LicenseSummaryStatusEnum.Unlicensed:
return html`<ak-label color=${PFColor.Grey}>${msg("Unlicensed")}</ak-label>`;
case LicenseSummaryStatusEnum.ReadOnly:
return html`<ak-label color=${PFColor.Red}>${msg("Read Only")}</ak-label>`;
case LicenseSummaryStatusEnum.Valid:
return html`<ak-label color=${PFColor.Green}>${msg("Valid")}</ak-label>`;
default:
return nothing;
}
}
renderCurrentSummary() {
if (!this.forecast || !this.summary) {
return html`${msg("Loading")}`;
}
const internalUserPercentage =
this.summary.internalUsers > 0
? Math.ceil(this.forecast.internalUsers / (this.summary.internalUsers / 100))
: 0;
const externalUserPercentage =
this.summary.externalUsers > 0
? Math.ceil(this.forecast.externalUsers / (this.summary.externalUsers / 100))
: 0;
return html`<div class="pf-c-card">
<div class="pf-c-card__title">${msg("Current license status")}</div>
<div class="pf-c-card__body pf-l-split pf-m-gutter">
<dl class="pf-l-split__item pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Overall license status")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.renderSummaryBadge()}
</div>
</dd>
</div>
</dl>
<div class="pf-l-split__item pf-m-fill">
<div class="pf-c-progress">
<div class="pf-c-progress__description">${msg("Internal user usage")}</div>
<div class="pf-c-progress__status" aria-hidden="true">
<span class="pf-c-progress__measure"
>${msg(str`${internalUserPercentage}%`)}</span
>
</div>
<div
class="pf-c-progress__bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${internalUserPercentage}"
>
<div
class="pf-c-progress__indicator"
style="width:${internalUserPercentage}%;"
></div>
</div>
</div>
<div class="pf-c-progress">
<div class="pf-c-progress__description">${msg("External user usage")}</div>
<div class="pf-c-progress__status" aria-hidden="true">
<span class="pf-c-progress__measure"
>${msg(str`${externalUserPercentage}%`)}</span
>
</div>
<div
class="pf-c-progress__bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${externalUserPercentage}"
>
<div
class="pf-c-progress__indicator"
style="width:${externalUserPercentage}%;"
></div>
</div>
</div>
</div>
</div>
</div>`;
}
row(item: License): TemplateResult[] {
let color = PFColor.Green;
if (item.expiry) {

View File

@ -0,0 +1,165 @@
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet.js";
import { $, expect } from "@wdio/globals";
import { msg } from "@lit/localize";
import { TemplateResult, html, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { LicenseForecast, LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api";
import "./EnterpriseStatusCard.js";
const render = (body: TemplateResult) => {
document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
ensureCSSStyleSheet(PFBase),
ensureCSSStyleSheet(AKGlobal),
];
return litRender(body, document.body);
};
describe("ak-enterprise-status-card", () => {
it("should not error when no data is loaded", async () => {
render(html`<ak-enterprise-status-card></ak-enterprise-status-card>`);
const status = await $("ak-enterprise-status-card");
await expect(status).toHaveText(msg("Loading"));
});
it("should render empty when unlicensed", async () => {
const forecast: LicenseForecast = {
externalUsers: 123,
internalUsers: 123,
forecastedExternalUsers: 123,
forecastedInternalUsers: 123,
};
const summary: LicenseSummary = {
status: LicenseSummaryStatusEnum.Unlicensed,
internalUsers: 0,
externalUsers: 0,
latestValid: new Date(0),
licenseFlags: [],
};
render(
html`<ak-enterprise-status-card .forecast=${forecast} .summary=${summary}>
</ak-enterprise-status-card>`,
);
const status = await $("ak-enterprise-status-card").$(
">>>.pf-c-description-list__description > .pf-c-description-list__text",
);
await expect(status).toExist();
await expect(status).toHaveText(msg("Unlicensed"));
const internalUserProgress = await $("ak-enterprise-status-card").$(
">>>#internalUsers > .pf-c-progress__bar",
);
await expect(internalUserProgress).toExist();
await expect(internalUserProgress).toHaveAttr("aria-valuenow", "0");
const externalUserProgress = await $("ak-enterprise-status-card").$(
">>>#externalUsers > .pf-c-progress__bar",
);
await expect(externalUserProgress).toExist();
await expect(externalUserProgress).toHaveAttr("aria-valuenow", "0");
});
it("should show warnings when full", async () => {
const forecast: LicenseForecast = {
externalUsers: 123,
internalUsers: 123,
forecastedExternalUsers: 123,
forecastedInternalUsers: 123,
};
const summary: LicenseSummary = {
status: LicenseSummaryStatusEnum.Valid,
internalUsers: 123,
externalUsers: 123,
latestValid: new Date(),
licenseFlags: [],
};
render(
html`<ak-enterprise-status-card .forecast=${forecast} .summary=${summary}>
</ak-enterprise-status-card>`,
);
const status = await $("ak-enterprise-status-card").$(
">>>.pf-c-description-list__description > .pf-c-description-list__text",
);
await expect(status).toExist();
await expect(status).toHaveText(msg("Valid"));
const internalUserProgress = await $("ak-enterprise-status-card").$(
">>>#internalUsers > .pf-c-progress__bar",
);
await expect(internalUserProgress).toExist();
await expect(internalUserProgress).toHaveAttr("aria-valuenow", "100");
await expect(
await $("ak-enterprise-status-card").$(">>>#internalUsers"),
).toHaveElementClass("pf-m-warning");
const externalUserProgress = await $("ak-enterprise-status-card").$(
">>>#externalUsers > .pf-c-progress__bar",
);
await expect(externalUserProgress).toExist();
await expect(externalUserProgress).toHaveAttr("aria-valuenow", "100");
await expect(
await $("ak-enterprise-status-card").$(">>>#internalUsers"),
).toHaveElementClass("pf-m-warning");
await expect(
await $("ak-enterprise-status-card").$(">>>#externalUsers"),
).toHaveElementClass("pf-m-warning");
});
it("should show infinity when not licensed for a user type", async () => {
const forecast: LicenseForecast = {
externalUsers: 123,
internalUsers: 123,
forecastedExternalUsers: 123,
forecastedInternalUsers: 123,
};
const summary: LicenseSummary = {
status: LicenseSummaryStatusEnum.Valid,
internalUsers: 123,
externalUsers: 0,
latestValid: new Date(),
licenseFlags: [],
};
render(
html`<ak-enterprise-status-card .forecast=${forecast} .summary=${summary}>
</ak-enterprise-status-card>`,
);
const status = await $("ak-enterprise-status-card").$(
">>>.pf-c-description-list__description > .pf-c-description-list__text",
);
await expect(status).toExist();
await expect(status).toHaveText(msg("Valid"));
const internalUserProgress = await $("ak-enterprise-status-card").$(
">>>#internalUsers > .pf-c-progress__bar",
);
await expect(internalUserProgress).toExist();
await expect(internalUserProgress).toHaveAttr("aria-valuenow", "100");
await expect(
await $("ak-enterprise-status-card").$(">>>#internalUsers"),
).toHaveElementClass("pf-m-warning");
const externalUserProgress = await $("ak-enterprise-status-card").$(
">>>#externalUsers > .pf-c-progress__bar",
);
await expect(externalUserProgress).toExist();
await expect(externalUserProgress).toHaveAttr("aria-valuenow", "∞");
await expect(
await $("ak-enterprise-status-card").$(">>>#internalUsers"),
).toHaveElementClass("pf-m-warning");
await expect(
await $("ak-enterprise-status-card").$(">>>#externalUsers"),
).toHaveElementClass("pf-m-danger");
});
});

View File

@ -0,0 +1,157 @@
import { AKElement } from "@goauthentik/elements/Base";
import { PFColor } from "@goauthentik/elements/Label";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFProgress from "@patternfly/patternfly/components/Progress/progress.css";
import PFSplit from "@patternfly/patternfly/layouts/Split/split.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { LicenseForecast, LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api";
@customElement("ak-enterprise-status-card")
export class EnterpriseStatusCard extends AKElement {
@state()
forecast?: LicenseForecast;
@state()
summary?: LicenseSummary;
static get styles(): CSSResult[] {
return [PFBase, PFDescriptionList, PFCard, PFSplit, PFProgress];
}
renderSummaryBadge() {
switch (this.summary?.status) {
case LicenseSummaryStatusEnum.Expired:
return html`<ak-label color=${PFColor.Red}>${msg("Expired")}</ak-label>`;
case LicenseSummaryStatusEnum.ExpirySoon:
return html`<ak-label color=${PFColor.Orange}>${msg("Expiring soon")}</ak-label>`;
case LicenseSummaryStatusEnum.Unlicensed:
return html`<ak-label color=${PFColor.Grey}>${msg("Unlicensed")}</ak-label>`;
case LicenseSummaryStatusEnum.ReadOnly:
return html`<ak-label color=${PFColor.Red}>${msg("Read Only")}</ak-label>`;
case LicenseSummaryStatusEnum.Valid:
return html`<ak-label color=${PFColor.Green}>${msg("Valid")}</ak-label>`;
default:
return nothing;
}
}
calcUserPercentage(licensed: number, current: number) {
const percentage = licensed > 0 ? Math.ceil(current / (licensed / 100)) : 0;
if (current > 0 && licensed === 0) return Infinity;
return percentage;
}
render() {
if (!this.forecast || !this.summary) {
return html`${msg("Loading")}`;
}
let internalUserPercentage = 0;
let externalUserPercentage = 0;
if (this.summary.status !== LicenseSummaryStatusEnum.Unlicensed) {
internalUserPercentage = this.calcUserPercentage(
this.summary.internalUsers,
this.forecast.internalUsers,
);
externalUserPercentage = this.calcUserPercentage(
this.summary.externalUsers,
this.forecast.externalUsers,
);
}
return html`<div class="pf-c-card">
<div class="pf-c-card__title">${msg("Current license status")}</div>
<div class="pf-c-card__body">
<div class="pf-l-split pf-m-gutter">
<dl class="pf-l-split__item pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Overall license status")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.renderSummaryBadge()}
</div>
</dd>
</div>
</dl>
<div class="pf-l-split__item pf-m-fill">
<div
class="pf-c-progress ${internalUserPercentage > 100
? "pf-m-danger"
: ""} ${internalUserPercentage >= 80 ? "pf-m-warning" : ""}"
id="internalUsers"
>
<div class="pf-c-progress__description">
${msg("Internal user usage")}
</div>
<div class="pf-c-progress__status" aria-hidden="true">
<span class="pf-c-progress__measure"
>${msg(
str`${internalUserPercentage < Infinity ? internalUserPercentage : "∞"}%`,
)}</span
>
</div>
<div
class="pf-c-progress__bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${internalUserPercentage}"
>
<div
class="pf-c-progress__indicator"
style="width:${Math.min(internalUserPercentage, 100)}%;"
></div>
</div>
</div>
<div
class="pf-c-progress ${externalUserPercentage > 100
? "pf-m-danger"
: ""} ${externalUserPercentage >= 80 ? "pf-m-warning" : ""}"
id="externalUsers"
>
<div class="pf-c-progress__description">
${msg("External user usage")}
</div>
<div class="pf-c-progress__status" aria-hidden="true">
<span class="pf-c-progress__measure"
>${msg(
str`${externalUserPercentage < Infinity ? externalUserPercentage : "∞"}%`,
)}</span
>
</div>
<div
class="pf-c-progress__bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="${externalUserPercentage < Infinity
? externalUserPercentage
: "∞"}"
>
<div
class="pf-c-progress__indicator"
style="width:${Math.min(externalUserPercentage, 100)}%;"
></div>
</div>
</div>
</div>
</div>
</div>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-enterprise-status-card": EnterpriseStatusCard;
}
}

View File

@ -17,11 +17,10 @@ describe("Search select: Test Input Field", () => {
let select: AkSearchSelectViewDriver;
beforeEach(async () => {
await render(
render(
html`<ak-search-select-view .options=${longGoodForYouPairs}> </ak-search-select-view>`,
document.body,
);
// @ts-ignore
select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view"));
});
@ -57,6 +56,7 @@ describe("Search select: Test Input Field", () => {
expect(await select.open).toBe(false);
expect(await select.menuIsVisible()).toBe(false);
await browser.keys("A");
select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view"));
expect(await select.open).toBe(true);
expect(await select.menuIsVisible()).toBe(true);
});
@ -64,19 +64,19 @@ describe("Search select: Test Input Field", () => {
it("should update the list as the user types", async () => {
await select.focusOnInput();
await browser.keys("Ap");
expect(await select.menuIsVisible()).toBe(true);
await expect(await select.menuIsVisible()).toBe(true);
const elements = Array.from(await select.listElements());
expect(elements.length).toBe(2);
await expect(elements.length).toBe(2);
});
it("set the value when a match is close", async () => {
await select.focusOnInput();
await browser.keys("Ap");
expect(await select.menuIsVisible()).toBe(true);
await expect(await select.menuIsVisible()).toBe(true);
const elements = Array.from(await select.listElements());
expect(elements.length).toBe(2);
await expect(elements.length).toBe(2);
await browser.keys(Key.Tab);
expect(await (await select.input()).getValue()).toBe("Apples");
await expect(await (await select.input()).getValue()).toBe("Apples");
});
it("should close the menu when the user clicks away", async () => {
@ -93,8 +93,8 @@ describe("Search select: Test Input Field", () => {
});
afterEach(async () => {
await document.body.querySelector("#a-separate-component")?.remove();
await document.body.querySelector("ak-search-select-view")?.remove();
document.body.querySelector("#a-separate-component")?.remove();
document.body.querySelector("ak-search-select-view")?.remove();
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
if (document.body["_$litPart$"]) {
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit

View File

@ -3,6 +3,7 @@
"baseUrl": ".",
"types": ["node", "webdriverio/async", "@wdio/cucumber-framework", "expect-webdriverio"],
"target": "esnext",
"module": "esnext",
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"lib": [

View File

@ -1,8 +1,7 @@
import replace from "@rollup/plugin-replace";
import type { Options } from "@wdio/types";
import { cwd } from "process";
// @ts-ignore
import * as postcssLit from "rollup-plugin-postcss-lit";
import postcssLit from "rollup-plugin-postcss-lit";
import type { UserConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
@ -31,7 +30,6 @@ export const config: Options.Testrunner = {
"preventAssignment": true,
}),
...(config?.plugins ?? []),
// @ts-ignore
postcssLit(),
tsconfigPaths(),
],
@ -92,14 +90,13 @@ export const config: Options.Testrunner = {
capabilities: [
{
// capabilities for local browser web tests
browserName: "chrome", // or "firefox", "microsoftedge", "safari"
...(runHeadless
? {
"goog:chromeOptions": {
args: ["headless", "disable-gpu"],
},
}
: {}),
"browserName": "chrome", // or "firefox", "microsoftedge", "safari"
"goog:chromeOptions": {
args: [
"disable-search-engine-choice-screen",
...(runHeadless ? ["headless", "disable-gpu"] : []),
],
},
},
],