refactor(web): move website into turbo app workspace
This commit is contained in:
parent
2664804e91
commit
b4e626a868
66 changed files with 318 additions and 298 deletions
|
|
@ -1,322 +0,0 @@
|
|||
import Ajv, { type ErrorObject } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import {
|
||||
DashboardDocumentSchema,
|
||||
type DashboardDocument,
|
||||
} from "./schema";
|
||||
|
||||
type DashboardValidationIssue = ErrorObject | SemanticValidationIssue;
|
||||
|
||||
interface SemanticValidationIssue {
|
||||
instancePath: string;
|
||||
schemaPath: string;
|
||||
keyword: "semantic";
|
||||
params: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DashboardValidationFailure {
|
||||
valid: false;
|
||||
errors: string[];
|
||||
details: DashboardValidationIssue[];
|
||||
}
|
||||
|
||||
export interface DashboardValidationSuccess {
|
||||
valid: true;
|
||||
data: DashboardDocument;
|
||||
}
|
||||
|
||||
export type DashboardValidationResult =
|
||||
| DashboardValidationFailure
|
||||
| DashboardValidationSuccess;
|
||||
|
||||
const ajv = addFormats(
|
||||
new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
strictNumbers: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
|
||||
|
||||
export function formatValidationErrors(
|
||||
errors: DashboardValidationIssue[] = [],
|
||||
): string[] {
|
||||
return errors.map((error) => {
|
||||
const path = error.instancePath || "/";
|
||||
const suffix = formatErrorParams(error);
|
||||
const message = error.message || "is invalid";
|
||||
return `${path} ${message}${suffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDashboardDocument(
|
||||
value: unknown,
|
||||
): DashboardValidationResult {
|
||||
const finiteNumberIssues: SemanticValidationIssue[] = [];
|
||||
const undefinedIssues: SemanticValidationIssue[] = [];
|
||||
collectFiniteNumberIssues(value, "", finiteNumberIssues);
|
||||
collectUndefinedIssues(value, "", undefinedIssues);
|
||||
|
||||
if (validateDashboard(value)) {
|
||||
const semanticIssues = [
|
||||
...finiteNumberIssues,
|
||||
...undefinedIssues,
|
||||
...validateSemanticRules(value),
|
||||
];
|
||||
if (semanticIssues.length === 0) {
|
||||
return { valid: true, data: value };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(semanticIssues),
|
||||
details: semanticIssues,
|
||||
};
|
||||
}
|
||||
|
||||
const details = [
|
||||
...(validateDashboard.errors || []),
|
||||
...finiteNumberIssues,
|
||||
...undefinedIssues,
|
||||
];
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(details),
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDashboardDocument(
|
||||
value: unknown,
|
||||
): asserts value is DashboardDocument {
|
||||
const result = validateDashboardDocument(value);
|
||||
if (!result.valid) {
|
||||
throw new Error(`Invalid dashboard document: ${result.errors.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isDashboardDocument(value: unknown): value is DashboardDocument {
|
||||
return validateDashboardDocument(value).valid;
|
||||
}
|
||||
|
||||
function formatErrorParams(error: DashboardValidationIssue): string {
|
||||
if (error.keyword === "additionalProperties") {
|
||||
const additionalProperty = error.params.additionalProperty;
|
||||
return typeof additionalProperty === "string"
|
||||
? `: ${additionalProperty}`
|
||||
: "";
|
||||
}
|
||||
|
||||
if (error.keyword === "semantic") {
|
||||
const id = error.params.id;
|
||||
const ref = error.params.ref;
|
||||
if (typeof id === "string") return `: ${id}`;
|
||||
if (typeof ref === "string") return `: ${ref}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateSemanticRules(document: DashboardDocument): SemanticValidationIssue[] {
|
||||
const issues: SemanticValidationIssue[] = [];
|
||||
|
||||
collectDuplicateIdIssues("telemetry", document.telemetry, issues);
|
||||
collectDuplicateIdIssues("serviceGroups", document.serviceGroups, issues);
|
||||
collectDuplicateIdIssues("statusStrips", document.statusStrips, issues);
|
||||
collectDuplicateIdIssues("modules", document.modules || [], issues);
|
||||
|
||||
document.serviceGroups.forEach((group, groupIndex) => {
|
||||
collectDuplicateIdIssues(
|
||||
`serviceGroups/${groupIndex}/services`,
|
||||
group.services,
|
||||
issues,
|
||||
);
|
||||
});
|
||||
|
||||
document.statusStrips.forEach((strip, stripIndex) => {
|
||||
collectDuplicateIdIssues(
|
||||
`statusStrips/${stripIndex}/items`,
|
||||
strip.items,
|
||||
issues,
|
||||
);
|
||||
});
|
||||
|
||||
collectMissingReferenceIssues(
|
||||
"layout/telemetry",
|
||||
document.layout.telemetry,
|
||||
new Set(document.telemetry.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/serviceGroups",
|
||||
document.layout.serviceGroups,
|
||||
new Set(document.serviceGroups.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/statusStrips",
|
||||
document.layout.statusStrips,
|
||||
new Set(document.statusStrips.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/modules",
|
||||
document.layout.modules || [],
|
||||
new Set((document.modules || []).map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
|
||||
document.telemetry.forEach((card, index) => {
|
||||
if (card.value.kind === "text" && card.thresholds !== undefined) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/telemetry/${index}/thresholds`,
|
||||
"must not be set for text metric values",
|
||||
{ id: card.id },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
card.value.kind === "percent" &&
|
||||
((card.thresholds?.warning !== undefined && card.thresholds.warning > 100) ||
|
||||
(card.thresholds?.danger !== undefined && card.thresholds.danger > 100))
|
||||
) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/telemetry/${index}/thresholds`,
|
||||
"percent thresholds must be between 0 and 100",
|
||||
{ id: card.id },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const warning = card.thresholds?.warning;
|
||||
const danger = card.thresholds?.danger;
|
||||
if (warning !== undefined && danger !== undefined && warning > danger) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/telemetry/${index}/thresholds`,
|
||||
"warning threshold must be less than or equal to danger threshold",
|
||||
{ id: card.id },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function collectUndefinedIssues(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
if (value === undefined) {
|
||||
issues.push(
|
||||
semanticIssue(path || "/", "must be omitted instead of undefined", {}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
collectUndefinedIssues(item, `${path}/${index}`, issues);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
Object.entries(value).forEach(([key, item]) => {
|
||||
collectUndefinedIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectFiniteNumberIssues(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
issues.push(
|
||||
semanticIssue(path || "/", "must be a finite JSON number", {}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
collectFiniteNumberIssues(item, `${path}/${index}`, issues);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
Object.entries(value).forEach(([key, item]) => {
|
||||
collectFiniteNumberIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectDuplicateIdIssues(
|
||||
collectionPath: string,
|
||||
items: Array<{ id: string }>,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
items.forEach((item, index) => {
|
||||
if (seen.has(item.id)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/${collectionPath}/${index}/id`,
|
||||
"must be unique within its collection",
|
||||
{ id: item.id },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
seen.add(item.id);
|
||||
});
|
||||
}
|
||||
|
||||
function collectMissingReferenceIssues(
|
||||
layoutPath: string,
|
||||
refs: string[],
|
||||
validIds: Set<string>,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
refs.forEach((ref, index) => {
|
||||
if (!validIds.has(ref)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/${layoutPath}/${index}`,
|
||||
"must reference an existing item",
|
||||
{ ref },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function semanticIssue(
|
||||
instancePath: string,
|
||||
message: string,
|
||||
params: Record<string, unknown>,
|
||||
): SemanticValidationIssue {
|
||||
return {
|
||||
instancePath,
|
||||
schemaPath: "",
|
||||
keyword: "semantic",
|
||||
params,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeJsonPointer(value: string): string {
|
||||
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue